Merge branch 'main' into ionized-emmental

This commit is contained in:
Kirill Kalishev
2026-05-11 12:55:04 -04:00
committed by GitHub
195 changed files with 6151 additions and 2159 deletions
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Keep the Agent Manager new-tab button visible at the end of the tab row, separated by a divider, and show all tab tooltips above the tabs.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Move Agent Manager tab creation and search controls to the left side of the tab bar with clearer toolbar separators.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Auto-approve Task subagent tool permissions when running `kilo run --auto`.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/kilo-docs": patch
"@kilocode/cli": patch
---
Remove `--dangerously-skip-permissions` CLI flag which did nothing
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prevent subagents from spawning nested subagents.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Center description text and remove the fixed-width cap from the empty session panel so recent sessions can stretch naturally.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve JetBrains history navigation with Back support and a Show History action in recent sessions.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve the JetBrains history panel with native tabs, independent local and cloud search, and date-grouped cloud sessions.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Add a native session history panel to the JetBrains plugin.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show loading immediately when opening JetBrains sessions without showing duplicate progress for new sessions.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Reduce delays in long sessions by compacting older tool outputs when the model request becomes too large.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Clarify that semantic search returns matching code snippets with paths, line ranges, and relevance scores.
@@ -0,0 +1,27 @@
# kilocode_change - new file
name: Check Kilo generated artifacts
on:
pull_request:
paths:
- ".gitignore"
- ".github/workflows/check-kilo-generated-artifacts.yml"
- ".kilo/**"
- ".kilocode/**"
- "script/check-kilo-generated-artifacts.ts"
workflow_dispatch:
jobs:
check:
name: Check Kilo generated artifacts
if: github.repository == 'Kilo-Org/kilocode'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6 # kilocode_change
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- uses: oven-sh/setup-bun@v2
- name: Run check
run: bun run script/check-kilo-generated-artifacts.ts
+15 -1
View File
@@ -45,8 +45,22 @@ telemetry-id
tsconfig.tsbuildinfo
# Kilo
.kilo/.gitignore
.kilo/package.json
.kilo/package-lock.json
.kilo/pnpm-lock.yaml
.kilo/bun.lock
.kilo/yarn.lock
.kilo/node_modules
.kilo/plans/*upstream-merge-report-*.md
.kilocode/.gitignore
.kilocode/package.json
.kilocode/package-lock.json
.kilocode/pnpm-lock.yaml
.kilocode/bun.lock
.kilocode/yarn.lock
.kilocode/node_modules
# Test Artifacts
packages/app/.artifacts/
packages/opencode/.artifacts/
packages/opencode/.artifacts/
-376
View File
@@ -1,376 +0,0 @@
{
"name": ".kilo",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@kilocode/plugin": "7.2.31"
}
},
"node_modules/@kilocode/plugin": {
"version": "7.2.31",
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.31.tgz",
"integrity": "sha512-KmKTTIly7hRlJdXKhqZ/j/brvTPh0z0UTjWSjJWq5fqf4pATgYGn7G0g3ZjILnN7MUkkZXuljgqExTEeQJHGkQ==",
"license": "MIT",
"dependencies": {
"@kilocode/sdk": "7.2.31",
"effect": "4.0.0-beta.48",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.100",
"@opentui/solid": ">=0.1.100"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@kilocode/sdk": {
"version": "7.2.31",
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.31.tgz",
"integrity": "sha512-Sx05yv+3TIlc6M4Ze+YGgKCLoIg8B0WRE15JXDriVncT+wz7M6+e+4mjNWkwfsuywdeOTYPfeFViqX8iO7ckKA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.48",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.6.0",
"find-my-way-ts": "^0.1.6",
"ini": "^6.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^1.11.9",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.3"
}
},
"node_modules/fast-check": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/find-my-way-ts": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
"license": "MIT"
},
"node_modules/ini": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/kubernetes-types": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
"license": "Apache-2.0"
},
"node_modules/msgpackr": {
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
"license": "MIT"
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pure-rand": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
-376
View File
@@ -1,376 +0,0 @@
{
"name": ".kilocode",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@kilocode/plugin": "7.2.31"
}
},
"node_modules/@kilocode/plugin": {
"version": "7.2.31",
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.31.tgz",
"integrity": "sha512-KmKTTIly7hRlJdXKhqZ/j/brvTPh0z0UTjWSjJWq5fqf4pATgYGn7G0g3ZjILnN7MUkkZXuljgqExTEeQJHGkQ==",
"license": "MIT",
"dependencies": {
"@kilocode/sdk": "7.2.31",
"effect": "4.0.0-beta.48",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.100",
"@opentui/solid": ">=0.1.100"
},
"peerDependenciesMeta": {
"@opentui/core": {
"optional": true
},
"@opentui/solid": {
"optional": true
}
}
},
"node_modules/@kilocode/sdk": {
"version": "7.2.31",
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.31.tgz",
"integrity": "sha512-Sx05yv+3TIlc6M4Ze+YGgKCLoIg8B0WRE15JXDriVncT+wz7M6+e+4mjNWkwfsuywdeOTYPfeFViqX8iO7ckKA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.48",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.6.0",
"find-my-way-ts": "^0.1.6",
"ini": "^6.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^1.11.9",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.3"
}
},
"node_modules/fast-check": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"node_modules/find-my-way-ts": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
"license": "MIT"
},
"node_modules/ini": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"node_modules/kubernetes-types": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
"license": "Apache-2.0"
},
"node_modules/msgpackr": {
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/multipasta": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz",
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
"license": "MIT"
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pure-rand": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz",
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/uuid": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+1
View File
@@ -0,0 +1 @@
v1.14.33
+6 -1
View File
@@ -113,6 +113,11 @@ Our community is built on respect, inclusivity, and collaboration. Please review
This project is licensed under the MIT License.
You’re free to use, modify, and distribute this code, including for commercial purposes as long as you include proper attribution and license notices. See [License](/LICENSE).
### Where did Kilo CLI come from?
## FAQ
<details>
<summary>Where did Kilo CLI come from?</summary>
Kilo CLI is a fork of [OpenCode](https://github.com/anomalyco/opencode), enhanced to work within the Kilo agentic engineering platform.
</details>
+14 -16
View File
@@ -33,7 +33,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "7.2.49",
"version": "7.2.50",
"bin": {
"opencode": "./bin/opencode",
},
@@ -68,7 +68,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.2.49",
"version": "7.2.50",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -98,7 +98,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.2.49",
"version": "7.2.50",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.71",
@@ -134,7 +134,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.2.49",
"version": "7.2.50",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -144,7 +144,7 @@
},
"packages/kilo-indexing": {
"name": "@kilocode/kilo-indexing",
"version": "7.2.49",
"version": "7.2.50",
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "3.1005.0",
"@aws-sdk/credential-provider-ini": "3.972.31",
@@ -178,7 +178,7 @@
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.2.49",
"version": "7.2.50",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"posthog-node": "4.4.0",
@@ -192,7 +192,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.2.49",
"version": "7.2.50",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "0.13.11",
@@ -229,7 +229,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.2.49",
"version": "7.2.50",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-gateway": "workspace:*",
@@ -292,7 +292,7 @@
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.2.49",
"version": "7.2.50",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -450,7 +450,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.2.49",
"version": "7.2.50",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"effect": "catalog:",
@@ -475,7 +475,7 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.2.49",
"version": "7.2.50",
"dependencies": {
"semver": "^7.6.3",
},
@@ -486,7 +486,7 @@
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.2.49",
"version": "7.2.50",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -501,7 +501,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.2.49",
"version": "7.2.50",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -524,7 +524,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.2.49",
"version": "7.2.50",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -615,8 +615,6 @@
"@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",
+3 -7
View File
@@ -7,13 +7,11 @@
"packageManager": "bun@1.3.13",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"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-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
@@ -76,8 +74,6 @@
"@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"
@@ -149,6 +145,6 @@
"@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"
},
"version": "7.2.49",
"version": "7.2.50",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.2.49",
"version": "7.2.50",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.2.49"
version = "7.2.50"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilocode"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.49/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.50/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.49/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.50/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.49/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.50/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.49/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.50/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.49/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.50/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+2
View File
@@ -43,6 +43,8 @@ exclude = [
# Consistently times out in CI
'^https?://opncd\.ai',
'^https?://zod\.dev/v4/changelog',
# Example punycode domain used in homograph attack documentation — does not exist
'^https?://xn--pitest',
# OpenAI docs return 404 to plain GET link checks but resolve in browsers
'^https?://platform\.openai\.com/docs/api-reference/responses/create',
]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.2.49",
"version": "7.2.50",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -184,7 +184,6 @@ Options:
--variant model variant (provider-specific reasoning effort, e.g., high, max, minimal) [string]
--thinking show thinking blocks [boolean] [default: false]
--auto auto-approve all permissions (for autonomous/pipeline usage) [boolean] [default: false]
--dangerously-skip-permissions auto-approve permissions that are not explicitly denied (dangerous!) [boolean] [default: false]
```
## kilo debug
+7 -1
View File
@@ -1,7 +1,7 @@
# Source Code Links
<!-- Auto-generated by script/extract-source-links.ts — DO NOT EDIT -->
<!-- 81 unique URLs extracted from extension and CLI source -->
<!-- 84 unique URLs extracted from extension and CLI source -->
- <https://api.apertis.ai/v1>
<!-- packages/opencode/src/provider/model-cache.ts -->
@@ -177,3 +177,9 @@
<!-- packages/opencode/src/tool/warpgrep.ts -->
- <https://www.rfc-editor.org/rfc/rfc8628#section-3.5>
<!-- packages/opencode/src/plugin/github-copilot/copilot.ts -->
- <https://x.com>
<!-- packages/opencode/src/kilocode/util/url.ts -->
- <https://xn--pitest-2nf.com/status>
<!-- packages/opencode/src/kilocode/util/url.ts -->
- <https://аpitest.com/status>
<!-- packages/opencode/src/kilocode/util/url.ts -->
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.2.49",
"version": "7.2.50",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
+1 -1
View File
@@ -37,7 +37,7 @@ export const KILO_OPENROUTER_BASE = `${KILO_API_BASE}/api/openrouter`
export const POLL_INTERVAL_MS = 3000
/** Default model for authenticated users */
export const DEFAULT_MODEL = "kilo-auto/balanced"
export const DEFAULT_MODEL = "kilo-auto/free"
/** Default model for anonymous/free usage */
export const DEFAULT_FREE_MODEL = "kilo-auto/free"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.2.49",
"version": "7.2.50",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-indexing",
"version": "7.2.49",
"version": "7.2.50",
"type": "module",
"license": "MIT",
"description": "Standalone indexing engine and host helpers for Kilo Code",
+36
View File
@@ -118,6 +118,20 @@ The chat session feature uses a three-layer Model / Controller / View architectu
3. Handle the new `ChatEventDto` in `SessionController.handle()` by calling the model mutation method.
4. Add `-> Unit` stubs for the new event in any existing exhaustive `when` blocks in view code.
### Editor-Dependent Session Styling
Session UI components that render text using editor fonts or colors must not read global editor settings directly on every paint. Instead they must:
1. Implement `SessionEditorStyleTarget` (`session/ui/style/SessionEditorStyle.kt`).
2. Hold a snapshot field initialised with `SessionEditorStyle.current()`.
3. Override `applyStyle(style: SessionEditorStyle)` and update all fonts/colors in one place without rebuilding Swing nodes.
`SessionUi` propagates a refreshed `SessionEditorStyle` to every registered `SessionEditorStyleTarget` child when the global editor scheme changes. New session elements that depend on editor settings must be registered through this flow, not through ad hoc `EditorColorsManager` listeners.
**Anti-patterns to avoid:**
- Do not pass `SessionEditorStyle` fields through constructors or method parameters when the component can implement the interface and receive updates via `applyStyle`.
- Do not store individual style properties (e.g. a separate `font` field copied from the style) when holding the full `SessionEditorStyle` snapshot is cleaner.
### Testing
Controller tests extend `SessionControllerTestBase` (`test/…/session/SessionControllerTestBase.kt`),
@@ -714,6 +728,28 @@ Inspection `Plugin DevKit | Code | Undesirable class usage` highlights when you
- Always create via `JBUI.Borders.empty(top, left, bottom, right)` and `JBUI.insets()` — DPI-aware and auto-update on zoom.
- Use `JBUI.scale(int)` for any pixel dimension to ensure proper HiDPI scaling.
### UI Style Constants
Style tokens and layout constants are split across three files. Put new constants in the right place — do not scatter them into ad hoc fields or pass them through constructors.
**`UiStyle`** (`frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt`) — generic Swing style tokens that are not tied to any single session component:
- `UiStyle.Gap` — DPI-aware spacing primitives (`xs`, `sm`, `md`, `lg`, `pad`). Use these for borders, gaps, and insets anywhere in the plugin.
- `UiStyle.Colors` — theme-aware generic colors (`bg`, `fg`, `weak`, `editorBackground`, `errorLabelForeground`, `warningLabelForeground`).
- `UiStyle.Components` — small reusable Swing helpers (e.g. `transparent()`).
**`SessionUiStyle`** (`frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt`) — static tokens that are specific to the chat/session UI:
- `SessionUiStyle.SessionLayout` — transcript list geometry and scroll increments.
- `SessionUiStyle.View` — card sizing, card borders, surfaces, hover colors, and nested objects for `Prompt`, `Reasoning`, `Message`, and `Tool`.
- `SessionUiStyle.RecentSessions` — recent sessions list limits.
- `SessionUiStyle.Timeline` — activity-indicator colors for the session header timeline.
- `Dock` — border presets for question, permission, and connection dock panels.
**Rules for adding new constants:**
- Generic layout constants (gaps, generic colors, reusable helpers) → `UiStyle`.
- Session-specific constants (transcript layout, prompt chrome, card geometry, session colors) → `SessionUiStyle`.
- Do not create extra fields whose only purpose is to hold a constant value. Reference the constant object directly, e.g. `UiStyle.Gap.lg()` or `SessionUiStyle.View.Prompt.EDITOR_LINES`.
- Do not pass style constants through constructors or method parameters. Using the constant directly at the call site is correct and preferred. Only introduce parameters for values that are genuinely variable or test-controlled.
### Icons
- **Reuse platform icons**: browse at https://intellij-icons.jetbrains.design. Access via `AllIcons.*` constants.
@@ -6,6 +6,7 @@ import ai.kilocode.log.KiloLog
import ai.kilocode.jetbrains.api.client.DefaultApi
import ai.kilocode.jetbrains.api.model.GlobalSession
import ai.kilocode.jetbrains.api.model.SessionStatus
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionListDto
import ai.kilocode.rpc.dto.SessionStatusDto
@@ -155,6 +156,51 @@ class KiloBackendSessionManager(
directories.remove(id)
}
fun cloudSessions(dir: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto {
val h = http ?: throw IllegalStateException("Session manager not started")
val url = base ?: throw IllegalStateException("Session manager not started")
val params = listOfNotNull(
"directory=${encode(dir)}",
cursor?.let { "cursor=${encode(it)}" },
"limit=$limit",
gitUrl?.let { "gitUrl=${encode(it)}" },
).joinToString("&")
val path = "$url/kilo/cloud-sessions?$params"
val request = Request.Builder()
.url(path)
.get()
.build()
h.newCall(request).execute().use { response ->
val raw = response.body?.string().orEmpty()
if (!response.isSuccessful) {
log.warn("Cloud sessions failed: HTTP ${response.code}, body=$raw")
throw RuntimeException("Cloud sessions failed: HTTP ${response.code} — $raw")
}
return KiloCliDataParser.parseCloudSessions(raw)
}
}
fun importCloudSession(id: String, dir: String): SessionDto {
val h = http ?: throw IllegalStateException("Session manager not started")
val url = base ?: throw IllegalStateException("Session manager not started")
val json = """{"sessionId":"${escape(id)}"}"""
val request = Request.Builder()
.url("$url/kilo/cloud/session/import?directory=${encode(dir)}")
.post(json.toRequestBody("application/json".toMediaType()))
.build()
h.newCall(request).execute().use { response ->
val raw = response.body?.string().orEmpty()
if (!response.isSuccessful) {
log.warn("Cloud session import failed: HTTP ${response.code}, body=$raw")
throw RuntimeException("Cloud session import failed: HTTP ${response.code} — $raw")
}
return KiloCliDataParser.parseSession(raw)
}
}
fun seed(dir: String) {
try {
val raw = requireClient().sessionStatus(directory = dir)
@@ -186,8 +232,8 @@ class KiloBackendSessionManager(
title = s.title,
version = s.version,
time = SessionTimeDto(
created = s.time.created,
updated = s.time.updated,
created = s.time.created.toDouble(),
updated = s.time.updated.toDouble(),
archived = s.time.archived,
),
summary = s.summary?.let {
@@ -207,8 +253,8 @@ class KiloBackendSessionManager(
title = s.title,
version = s.version,
time = SessionTimeDto(
created = s.time.created,
updated = s.time.updated,
created = s.time.created.toDouble(),
updated = s.time.updated.toDouble(),
archived = s.time.archived,
),
summary = s.summary?.let {
@@ -227,4 +273,19 @@ class KiloBackendSessionManager(
next = s.next.toLong(),
requestID = s.requestID.ifBlank { null },
)
private fun encode(value: String) = java.net.URLEncoder.encode(value, Charsets.UTF_8)
private fun escape(value: String) = buildString {
for (c in value) {
when (c) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> if (c < '\u0020') append("\\u%04x".format(c.code)) else append(c)
}
}
}
}
@@ -1,6 +1,8 @@
package ai.kilocode.backend.cli
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.MessageDto
@@ -265,6 +267,25 @@ object KiloCliDataParser {
}
}
fun parseCloudSessions(raw: String): CloudSessionListDto {
val obj = tryParseObject(raw) ?: return CloudSessionListDto(emptyList())
val items = obj["cliSessions"]?.jsonArray ?: JsonArray(emptyList())
val sessions = items.mapNotNull { elem ->
val item = runCatching { elem.jsonObject }.getOrNull() ?: return@mapNotNull null
CloudSessionDto(
id = item.str("session_id") ?: return@mapNotNull null,
title = item.str("title"),
createdAt = item.str("created_at") ?: return@mapNotNull null,
updatedAt = item.str("updated_at") ?: return@mapNotNull null,
version = item.num("version") ?: return@mapNotNull null,
)
}
return CloudSessionListDto(
sessions = sessions,
nextCursor = obj.str("nextCursor"),
)
}
fun parseModelState(raw: String): ModelStateDto {
val obj = tryParseObject(raw) ?: return ModelStateDto()
return ModelStateDto(
@@ -9,6 +9,7 @@ import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
@@ -70,6 +71,12 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
workspaces.get(dir).deleteSession(id)
}
override suspend fun cloudSessions(directory: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto =
sessions.cloudSessions(directory, cursor, limit, gitUrl)
override suspend fun importCloudSession(id: String, directory: String): SessionDto =
sessions.importCloudSession(id, directory)
override suspend fun statuses(): Flow<Map<String, SessionStatusDto>> =
sessions.statuses
@@ -185,6 +185,104 @@ class KiloBackendSessionManagerTest {
}
}
@Test
fun `cloudSessions returns cloud sessions from server`() = runBlocking {
mock.cloudSessions = """{
"cliSessions": [
{"session_id":"cloud_1","title":"Cloud One","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z","version":2}
],
"nextCursor": "next_1"
}"""
val app = setup()
ready(app)
val result = app.sessions.cloudSessions("/repo", null, 50, null)
assertEquals(1, result.sessions.size)
assertEquals("cloud_1", result.sessions[0].id)
assertEquals("Cloud One", result.sessions[0].title)
assertEquals("next_1", result.nextCursor)
}
@Test
fun `cloudSessions passes filters and cursor`() = runBlocking {
val app = setup()
ready(app)
app.sessions.cloudSessions("/repo path", "cur 1", 25, "git@github.com:Kilo-Org/kilo.git")
val path = mock.lastCloudSessionsPath ?: error("missing cloud sessions request")
assertTrue(path.startsWith("/kilo/cloud-sessions?"))
val decoded = URLDecoder.decode(path, "UTF-8")
assertTrue(decoded.contains("directory=/repo path"), path)
assertTrue(decoded.contains("cursor=cur 1"), path)
assertTrue(decoded.contains("limit=25"), path)
assertTrue(decoded.contains("gitUrl=git@github.com:Kilo-Org/kilo.git"), path)
}
@Test
fun `cloudSessions throws when not started`() = runBlocking {
val app = setup()
assertFailsWith<IllegalStateException> {
app.sessions.cloudSessions("/test", null, 50, null)
}
}
@Test
fun `cloudSessions surfaces server failure`() = runBlocking {
mock.cloudSessionsStatus = 500
mock.cloudSessions = """{"error":"boom"}"""
val app = setup()
ready(app)
val err = assertFailsWith<RuntimeException> {
app.sessions.cloudSessions("/test", null, 50, null)
}
assertTrue(err.message.orEmpty().contains("HTTP 500"))
assertTrue(err.message.orEmpty().contains("boom"))
}
@Test
fun `importCloudSession posts cloud id and returns local session`() = runBlocking {
mock.cloudSessionImport = """{
"id":"ses_imported",
"slug":"imported",
"projectID":"prj_test",
"directory":"/repo",
"title":"Imported Cloud",
"version":"1.0.0",
"time":{"created":1000,"updated":2000}
}"""
val app = setup()
ready(app)
val session = app.sessions.importCloudSession("cloud_1", "/repo path")
assertEquals("ses_imported", session.id)
assertEquals("Imported Cloud", session.title)
val path = mock.lastCloudSessionImportPath ?: error("missing cloud import request")
assertTrue(path.startsWith("/kilo/cloud/session/import?"))
assertTrue(URLDecoder.decode(path, "UTF-8").contains("directory=/repo path"), path)
assertEquals("""{"sessionId":"cloud_1"}""", mock.lastCloudSessionImportBody)
}
@Test
fun `importCloudSession surfaces server failure`() = runBlocking {
mock.cloudSessionImportStatus = 500
mock.cloudSessionImport = """{"error":"boom"}"""
val app = setup()
ready(app)
val err = assertFailsWith<RuntimeException> {
app.sessions.importCloudSession("cloud_1", "/test")
}
assertTrue(err.message.orEmpty().contains("HTTP 500"))
assertTrue(err.message.orEmpty().contains("boom"))
}
// ------ Session create ------
@Test
@@ -398,6 +398,33 @@ class KiloCliDataParserTest {
assertNull(result.summary)
}
@Test
fun `parseCloudSessions maps cloud session list`() {
val raw = """{
"cliSessions": [
{"session_id":"cloud_1","title":"Cloud One","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z","version":2},
{"session_id":"cloud_2","title":null,"created_at":"2026-01-03T00:00:00Z","updated_at":"2026-01-04T00:00:00Z","version":3.5,"extra":true}
],
"nextCursor": "cursor_2"
}"""
val result = KiloCliDataParser.parseCloudSessions(raw)
assertEquals(2, result.sessions.size)
assertEquals("cloud_1", result.sessions[0].id)
assertEquals("Cloud One", result.sessions[0].title)
assertEquals("2026-01-02T00:00:00Z", result.sessions[0].updatedAt)
assertEquals(2.0, result.sessions[0].version)
assertNull(result.sessions[1].title)
assertEquals("cursor_2", result.nextCursor)
}
@Test
fun `parseCloudSessions tolerates malformed response`() {
assertEquals(emptyList(), KiloCliDataParser.parseCloudSessions("not json").sessions)
assertNull(KiloCliDataParser.parseCloudSessions("{}").nextCursor)
}
// ================================================================
// parseMessages
// ================================================================
@@ -62,6 +62,13 @@ class MockCliServer : AutoCloseable {
@Volatile var sessionGetStatus = 200
@Volatile var sessionDeleteStatus = 200
@Volatile var sessionStatusesStatus = 200
@Volatile var cloudSessions = """{"cliSessions":[],"nextCursor":null}"""
@Volatile var cloudSessionImport = """{"id":"ses_imported","slug":"imported","projectID":"prj_test","directory":"/test","title":"Imported Session","version":"1.0.0","time":{"created":1000,"updated":1000}}"""
@Volatile var cloudSessionsStatus = 200
@Volatile var cloudSessionImportStatus = 200
@Volatile var lastCloudSessionsPath: String? = null
@Volatile var lastCloudSessionImportPath: String? = null
@Volatile var lastCloudSessionImportBody: String? = null
@Volatile var summarizeStatus = 200
@Volatile var lastSummarizePath: String? = null
@Volatile var lastSummarizeBody: String? = null
@@ -220,6 +227,15 @@ class MockCliServer : AutoCloseable {
lastExperimentalSessionPath = path
respond(output, recentSessionsStatus, recentSessions)
}
bare == "/kilo/cloud-sessions" -> {
lastCloudSessionsPath = path
respond(output, cloudSessionsStatus, cloudSessions)
}
bare == "/kilo/cloud/session/import" && method == "POST" -> {
lastCloudSessionImportPath = path
lastCloudSessionImportBody = body
respond(output, cloudSessionImportStatus, cloudSessionImport)
}
bare == "/session/status" -> respond(output, sessionStatusesStatus, sessionStatuses)
bare == "/session" && method == "GET" -> respond(output, sessionsStatus, sessions)
bare == "/session" && method == "POST" -> respond(output, sessionCreateStatus, sessionCreate)
+6
View File
@@ -66,6 +66,12 @@ intellijPlatform {
}
}
tasks {
runIdeBackend {
splitModeServerPort.set(12345)
}
}
tasks.named<JavaExec>("runIde") {
dependsOn(":backend:processResources")
jvmArgumentProviders += CommandLineArgumentProvider {
@@ -1,5 +1,6 @@
package ai.kilocode.client
import ai.kilocode.client.actions.HistoryAction
import ai.kilocode.client.actions.NewSessionAction
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
@@ -65,7 +66,7 @@ class KiloToolWindowFactory : ToolWindowFactory, DumbAware {
manager.newSession()
ActionManager.getInstance().getAction("Kilo.Settings")?.let { settings ->
toolWindow.setTitleActions(listOf(NewSessionAction(), settings))
toolWindow.setTitleActions(listOf(NewSessionAction(), HistoryAction(), settings))
}
} catch (e: Exception) {
LOG.error("Failed to set up Kilo tool window content", e)
@@ -0,0 +1,22 @@
package ai.kilocode.client.actions
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionManager
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.project.DumbAware
class HistoryAction : AnAction(
KiloBundle.message("action.Kilo.History.text"),
KiloBundle.message("action.Kilo.History.description"),
AllIcons.Vcs.History,
), DumbAware {
override fun actionPerformed(e: AnActionEvent) {
e.getData(SessionManager.KEY)?.showHistory()
}
override fun update(e: AnActionEvent) {
e.presentation.isEnabled = e.getData(SessionManager.KEY) != null
}
}
@@ -5,6 +5,7 @@ package ai.kilocode.client.app
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
import ai.kilocode.rpc.dto.MessageWithPartsDto
import ai.kilocode.rpc.dto.ModelSelectionDto
@@ -15,6 +16,7 @@ import ai.kilocode.rpc.dto.PromptDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionListDto
import ai.kilocode.rpc.dto.SessionStatusDto
import com.intellij.openapi.components.Service
import ai.kilocode.log.KiloLog
@@ -34,7 +36,7 @@ import kotlinx.coroutines.launch
* Project-level frontend service for session management.
*
* Stateless with respect to "active session" — callers pass explicit
* session IDs. [ai.kilocode.client.session.update.SessionController] owns the
* session IDs. [ai.kilocode.client.session.controller.SessionController] owns the
* active session concept.
*/
@Service(Service.Level.PROJECT)
@@ -76,14 +78,19 @@ class KiloSessionService internal constructor(
fun refresh(dir: String) {
cs.launch {
try {
val result = call { list(dir) }
_sessions.value = result.sessions
list(dir)
} catch (e: Exception) {
LOG.warn("kind=session-list dir=${ChatLogSummary.dir(dir)} failed message=${e.message}", e)
}
}
}
suspend fun list(dir: String): SessionListDto {
val result = call { list(dir) }
_sessions.value = result.sessions
return result
}
/** Load recent sessions for the current worktree family. */
suspend fun recent(dir: String, limit: Int): List<SessionDto> =
call { recent(dir, limit) }.sessions
@@ -105,14 +112,24 @@ class KiloSessionService internal constructor(
fun delete(id: String, dir: String) {
cs.launch {
try {
call { delete(id, dir) }
refresh(dir)
deleteSession(id, dir)
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(id)} kind=session delete=true dir=${ChatLogSummary.dir(dir)} failed message=${e.message}", e)
}
}
}
suspend fun deleteSession(id: String, dir: String) {
call { delete(id, dir) }
list(dir)
}
suspend fun cloudSessions(dir: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto =
call { cloudSessions(dir, cursor, limit, gitUrl) }
suspend fun importCloudSession(id: String, dir: String): SessionDto =
call { importCloudSession(id, dir) }
/** Register a worktree directory override for a session. */
fun setDirectory(id: String, dir: String) {
cs.launch {
@@ -10,5 +10,11 @@ interface SessionManager {
fun newSession()
fun openSession(session: SessionDto)
fun showHistory()
fun openSession(ref: SessionRef)
fun openSession(session: SessionDto) {
openSession(SessionRef.Local(session))
}
}
@@ -0,0 +1,40 @@
package ai.kilocode.client.session
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.SessionDto
sealed interface SessionRef {
enum class Type { LOCAL, CLOUD }
val type: Type
val id: String
val key: String
data class Local(override val id: String, val session: SessionDto? = null) : SessionRef {
constructor(session: SessionDto) : this(session.id, session)
override val type: Type get() = Type.LOCAL
override val key: String get() = id
}
data class Cloud(override val id: String, val session: CloudSessionDto? = null) : SessionRef {
constructor(session: CloudSessionDto) : this(session.id, session)
override val type: Type get() = Type.CLOUD
override val key: String get() = "$CLOUD_PREFIX$id"
}
companion object {
const val CLOUD_PREFIX = "cloud:"
fun from(id: String?): SessionRef? {
val value = id?.takeIf { it.isNotBlank() } ?: return null
if (value.startsWith(CLOUD_PREFIX)) return Cloud(value.removePrefix(CLOUD_PREFIX))
return Local(value)
}
fun cloud(id: String?): Cloud? {
return from(id) as? Cloud
}
}
}
@@ -3,8 +3,8 @@ package ai.kilocode.client.session
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.session.ui.SessionStyleTarget
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.ui.UiStyle
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.util.IconLoader
@@ -50,7 +50,7 @@ internal class SessionScroll(
internal val jump: JBLabel
val view: JComponent? get() = component.viewport.view as? JComponent
private var style = SessionStyle.current()
private var style = SessionEditorStyle.current()
private var tail = true
private var auto = false
private var opening = false
@@ -71,7 +71,7 @@ internal class SessionScroll(
component.verticalScrollBar.addAdjustmentListener { onScroll() }
root.addOverlay(jump) { _, child ->
val size = child.preferredSize
val gap = JBUI.scale(UiStyle.Space.PAD)
val gap = UiStyle.Gap.pad()
Rectangle(
host.x + host.width - size.width - gap,
host.y + host.height - size.height - gap,
@@ -83,7 +83,7 @@ internal class SessionScroll(
fun show(panel: JPanel) {
if (component.viewport.view === panel) return
(panel as? SessionStyleTarget)?.applyStyle(style)
(panel as? SessionEditorStyleTarget)?.applyStyle(style)
component.viewport.setView(panel)
component.repaint()
updateJump()
@@ -128,12 +128,12 @@ internal class SessionScroll(
updateJump()
}
fun applyStyle(style: SessionStyle) {
fun applyStyle(style: SessionEditorStyle) {
this.style = style
jump.icon = patchedIcon(ICON)
messages.applyStyle(style)
val view = component.viewport.view
if (view !== messages) (view as? SessionStyleTarget)?.applyStyle(style)
if (view !== messages) (view as? SessionEditorStyleTarget)?.applyStyle(style)
refresh()
}
@@ -1,13 +1,19 @@
package ai.kilocode.client.session
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.client.session.history.HistoryController
import ai.kilocode.client.session.history.HistoryPanel
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.DataProvider
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.wm.IdeFocusManager
import kotlinx.coroutines.cancel
import java.awt.BorderLayout
import javax.swing.JComponent
import javax.swing.JPanel
@@ -15,10 +21,11 @@ import javax.swing.JPanel
class SessionSidePanelManager(
private val project: Project,
private val root: Workspace,
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 create: (Project, Workspace, SessionManager, SessionRef?) -> SessionUi = { project, workspace, manager, ref ->
service<SessionUiFactory>().create(project, workspace, manager, ref)
},
private val resolve: (String) -> Workspace = { dir -> service<KiloWorkspaceService>().workspace(dir) },
private val history: ((Disposable, (SessionRef) -> Unit, (String) -> Unit) -> JComponent)? = null,
) : SessionManager, Disposable {
val component: JPanel = object : JPanel(BorderLayout()), DataProvider {
override fun getData(dataId: String): Any? {
@@ -30,28 +37,108 @@ class SessionSidePanelManager(
private val opened = mutableMapOf<String, SessionUi>()
private val all = mutableSetOf<SessionUi>()
private var current: SessionUi? = null
private var latest: SessionUi? = null
private var panel: JComponent? = null
val defaultFocusedComponent: JComponent? get() = current?.defaultFocusedComponent
val defaultFocusedComponent: JComponent? get() = current?.defaultFocusedComponent ?: (panel as? HistoryPanel)?.defaultFocusedComponent
override fun newSession() {
val active = current
if (active?.blank == true) return
register(active)
show(create(project, root, this, null, active == null, null))
show(create(project, root, this, null))
}
override fun openSession(session: SessionDto) {
override fun openSession(ref: SessionRef) {
register(current)
val ui = opened.getOrPut(session.id) {
create(project, resolve(session.directory), this, session.id, false, session).also {
all.add(it)
}
val ui = opened[ref.key] ?: run {
val local = (ref as? SessionRef.Local)?.session?.id
val existing = local?.let { opened[it] }
if (existing != null) {
opened[ref.key] = existing
existing
} else create(ref)
}
show(ui)
}
private fun create(ref: SessionRef): SessionUi {
val workspace = when (ref) {
is SessionRef.Local -> ref.session?.directory?.let(resolve) ?: root
is SessionRef.Cloud -> root
}
return create(project, workspace, this, ref).also {
all.add(it)
opened[ref.key] = it
val local = (ref as? SessionRef.Local)?.session?.id
if (local != null) opened.putIfAbsent(local, it)
}
}
override fun showHistory() {
register(current)
release(current)
val cached = panel
val view = cached ?: createHistory().also { panel = it }
if (cached != null && view is HistoryPanel) view.refresh()
if (current == null && component.componentCount == 1 && component.getComponent(0) === view) {
focusHistory(view)
return
}
current = null
component.removeAll()
component.add(view, BorderLayout.CENTER)
component.revalidate()
component.repaint()
focusHistory(view)
}
private fun focusHistory(view: JComponent) {
val focus = (view as? HistoryPanel)?.defaultFocusedComponent ?: return
ApplicationManager.getApplication().invokeLater({
IdeFocusManager.getInstance(project).requestFocusInProject(focus, project)
}, ModalityState.defaultModalityState())
}
private fun createHistory(): JComponent {
val custom = history
if (custom != null) return custom(this, this::openSession, this::removeSession)
val factory = service<SessionUiFactory>()
val cs = factory.scope()
val controller = HistoryController(
sessions = project.service<KiloSessionService>(),
workspace = root,
cs = cs,
open = this::openSession,
deleted = this::removeSession,
)
Disposer.register(this) { cs.cancel() }
return HistoryPanel(this, controller, nav = this::back).component
}
private fun back() {
val ui = latest
if (ui != null && ui in all) {
show(ui)
return
}
latest = null
newSession()
}
private fun removeSession(id: String) {
val ui = opened.remove(id) ?: return
opened.entries.removeIf { it.value === ui }
all.remove(ui)
if (current === ui) current = null
if (latest === ui) latest = null
Disposer.dispose(ui)
}
private fun show(ui: SessionUi) {
all.add(ui)
register(ui)
latest = ui
if (current === ui) return
release(current)
component.removeAll()
@@ -62,13 +149,13 @@ class SessionSidePanelManager(
}
private fun register(ui: SessionUi?) {
val id = ui?.id ?: return
opened.putIfAbsent(id, ui)
val key = ui?.cacheKey ?: return
opened.putIfAbsent(key, ui)
}
private fun release(ui: SessionUi?) {
if (ui == null) return
if (ui.id != null) {
if (ui.cacheKey != null) {
register(ui)
return
}
@@ -81,6 +168,7 @@ class SessionSidePanelManager(
opened.clear()
all.clear()
current = null
latest = null
component.removeAll()
items.forEach { Disposer.dispose(it) }
}
@@ -3,11 +3,11 @@ 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.client.plugin.KiloBundle
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.ConnectionPanel
import ai.kilocode.client.session.ui.EmptySessionPanel
import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.ReasoningPicker
import ai.kilocode.client.session.ui.mode.ModePicker
import ai.kilocode.client.session.ui.model.ModelPicker
@@ -17,12 +17,11 @@ 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
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.session.update.SessionControllerEvent
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.controller.EVENT_FLUSH_MS
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.controller.SessionControllerEvent
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.log.KiloLog
import com.intellij.ide.ui.LafManagerListener
@@ -32,8 +31,6 @@ import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.Disposable
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.registry.Registry
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.Centerizer
import kotlinx.coroutines.CoroutineScope
import java.awt.BorderLayout
import javax.swing.BoxLayout
@@ -47,43 +44,16 @@ import javax.swing.JPanel
* It builds the session panels, wires controller/model listeners, and swaps the
* center body between the empty state and the message list.
*/
class SessionUi private constructor(
class SessionUi(
project: Project,
workspace: Workspace,
sessions: KiloSessionService,
app: KiloAppService,
cs: CoroutineScope,
id: String?,
displayMs: Long,
open: (SessionDto) -> Unit,
private val loading: Boolean,
session: SessionDto? = null,
) : JPanel(BorderLayout()), Disposable, SessionStyleTarget {
constructor(
project: Project,
workspace: Workspace,
sessions: KiloSessionService,
app: KiloAppService,
cs: CoroutineScope,
id: String? = null,
displayMs: Long = SessionController.DISPLAY_DELAY_MS,
open: (SessionDto) -> Unit = {},
session: SessionDto? = null,
) : this(project, workspace, sessions, app, cs, session?.id ?: id, displayMs, open, id == null, session)
internal constructor(
project: Project,
workspace: Workspace,
sessions: KiloSessionService,
app: KiloAppService,
cs: CoroutineScope,
id: String? = null,
displayMs: Long = SessionController.DISPLAY_DELAY_MS,
loading: Boolean,
open: (SessionDto) -> Unit = {},
session: SessionDto? = null,
) : this(project, workspace, sessions, app, cs, id, displayMs, open, loading, session)
ref: SessionRef? = null,
displayMs: Long = SessionController.DISPLAY_DELAY_MS,
private val manager: SessionManager? = null,
) : JPanel(BorderLayout()), Disposable, SessionEditorStyleTarget {
companion object {
private val LOG = KiloLog.create(SessionUi::class.java)
@@ -91,7 +61,7 @@ class SessionUi private constructor(
private val project = project
private val app = app
private var opening = id != null
private var opening = ref != null
private var pending = false
private var loaded: Boolean? = null
private val flushMs =
@@ -101,15 +71,14 @@ class SessionUi private constructor(
?: EVENT_FLUSH_MS
private val controller = SessionController(
this, id, sessions, workspace, app, cs, this,
this, ref, sessions, workspace, app, cs, comp = this,
flushMs = flushMs,
condense = Registry.`is`("kilo.session.condense", true),
displayMs = displayMs,
session = session,
open = open,
open = { item -> manager?.openSession(item) },
beforeUpdate = { if (opening) false else scroll.atBottom() },
afterUpdate = { if (!opening) scroll.followBottom(it) },
loaded = ::onHistoryLoaded,
loaded = ::onSessionLoaded,
)
@@ -132,16 +101,16 @@ class SessionUi private constructor(
private lateinit var connection: ConnectionPanel
private lateinit var prompt: PromptPanel
private lateinit var loadingLabel: JBLabel
private var style = SessionStyle.current()
private lateinit var load: LoadingPanel
private var style = SessionEditorStyle.current()
init {
buildUi()
scroll.show(body(controller.model.state))
bindUi()
bindStyle()
applyStyle(style)
onStateChanged(controller.model.state)
scroll.show(startBody())
loaded?.let(::finishOpen)
}
@@ -159,6 +128,8 @@ class SessionUi private constructor(
internal val id: String? get() = controller.id
internal val cacheKey: String? get() = controller.refKey
internal fun currentStyle() = style
val defaultFocusedComponent: JComponent get() = prompt.defaultFocusedComponent
@@ -172,14 +143,8 @@ class SessionUi private constructor(
isOpaque = false
}
progressBody = JPanel(BorderLayout()).apply {
isOpaque = false
loadingLabel = JBLabel(KiloBundle.message("session.empty.loading"))
add(Centerizer(
loadingLabel,
Centerizer.TYPE.BOTH,
), BorderLayout.CENTER)
}
load = LoadingPanel()
progressBody = load
messageBody = SessionMessageListPanel(controller.model, this)
header = SessionHeaderPanel(controller, this)
@@ -253,8 +218,8 @@ class SessionUi private constructor(
}
is SessionControllerEvent.ViewChanged.ShowRecents -> {
val panel = EmptySessionPanel(this, controller, event.recents)
scroll.show(panel)
val panel = EmptySessionPanel(this, controller, event.recents) { manager?.showHistory() }
scroll.show(panel.view)
}
is SessionControllerEvent.ViewChanged.ShowSession -> {
@@ -299,28 +264,28 @@ class SessionUi private constructor(
val bus = ApplicationManager.getApplication().messageBus.connect(this)
bus.subscribe(EditorColorsManager.TOPIC, EditorColorsListener {
ApplicationManager.getApplication().invokeLater {
applyStyle(SessionStyle.current())
applyStyle(SessionEditorStyle.current())
}
})
bus.subscribe(LafManagerListener.TOPIC, LafManagerListener {
ApplicationManager.getApplication().invokeLater {
applyStyle(SessionStyle.current())
applyStyle(SessionEditorStyle.current())
}
})
}
private fun startBody(): JPanel {
if (controller.model.showSession) return messageBody
if (loading) return progressBody
return blankBody
}
private fun onHistoryLoaded(show: Boolean) {
private fun onSessionLoaded(show: Boolean) {
loaded = show
if (!this::scroll.isInitialized) return
finishOpen(show)
}
private fun body(state: SessionState): JPanel {
if (controller.model.showSession) return messageBody
if (state is SessionState.Loading) return progressBody
return blankBody
}
private fun finishOpen(show: Boolean) {
loaded = show
if (!opening) return
@@ -380,9 +345,9 @@ class SessionUi private constructor(
root.repaint()
}
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
loadingLabel.font = style.uiFont
load.applyStyle(style)
header.applyStyle(style)
prompt.applyStyle(style)
scroll.applyStyle(style)
@@ -3,7 +3,6 @@ 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
@@ -19,22 +18,18 @@ class SessionUiFactory(
project: Project,
workspace: Workspace,
manager: SessionManager,
id: String? = null,
loading: Boolean = id == null,
session: SessionDto? = null,
ref: SessionRef? = null,
): SessionUi = SessionUi(
project = project,
workspace = workspace,
sessions = project.service<KiloSessionService>(),
app = service<KiloAppService>(),
cs = scope(),
id = session?.id ?: id,
loading = loading,
session = session,
open = manager::openSession,
ref = ref,
manager = manager,
)
private fun scope(): CoroutineScope {
fun scope(): CoroutineScope {
val parent = cs.coroutineContext[Job]
return CoroutineScope(cs.coroutineContext + SupervisorJob(parent))
}
@@ -1,4 +1,4 @@
package ai.kilocode.client.session.update
package ai.kilocode.client.session.controller
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
@@ -1,4 +1,4 @@
package ai.kilocode.client.session.update
package ai.kilocode.client.session.controller
import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
@@ -17,6 +17,7 @@ import ai.kilocode.client.session.model.Question
import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.QuestionOption
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.SessionRef
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigWarningDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
@@ -49,7 +50,7 @@ import java.awt.Component
/**
* Session lifecycle orchestrator for a single session.
*
* Accepts an optional [id] — if non-null, loads that session immediately.
* Accepts an optional [ref] — if non-null, loads that session immediately.
* If null, lazily creates a session on the first [prompt] call. This ensures
* event subscription happens before the prompt is sent, eliminating races.
*
@@ -60,7 +61,7 @@ import java.awt.Component
*/
class SessionController(
parent: Disposable,
id: String?,
ref: SessionRef? = null,
private val sessions: KiloSessionService,
private val workspace: Workspace,
private val app: KiloAppService,
@@ -69,8 +70,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 open: (SessionRef) -> Unit = {},
private val beforeUpdate: () -> Boolean = { false },
private val afterUpdate: (Boolean) -> Unit = {},
private val loaded: (Boolean) -> Unit = {},
@@ -89,8 +89,8 @@ class SessionController(
val model = SessionModel()
private val listeners = mutableListOf<SessionControllerListener>()
private var sessionId: String? = session?.id ?: id
private val initialSession = session
private var ref: SessionRef? = ref
private val sid: String? get() = (ref as? SessionRef.Local)?.id
private val directory: String get() = workspace.directory
private val updates = SessionUpdateQueue(
parent,
@@ -98,31 +98,41 @@ class SessionController(
flushMs,
::handle,
condense,
id != null
) { sessionId ?: "pending" }
ref != null
) { sid ?: ref?.key ?: "pending" }
private var disposed = false
private var partType: String? = null
private var tool: String? = null
private var eventJob: Job? = null
private var historyState: HistoryState = HistoryState.Idle
private var sessionLoadState: SessionLoadState = SessionLoadState.Idle
private var recentsState: RecentsState = RecentsState.Idle
private var viewState: SessionControllerEvent.ViewChanged? = null
private var connectionState: SessionControllerEvent.ConnectionChanged? = null
private var connectionTargetState: SessionControllerEvent.ConnectionChanged? = null
private val delayedState = DelayedState(displayMs)
private val connectionDelay = DelayedState(displayMs)
val ready: Boolean get() = model.isReady()
internal val blank: Boolean get() = sessionId == null && model.isEmpty() && !model.showSession
internal val id: String? get() = sessionId
internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession
internal val id: String? get() = sid
internal val refKey: String? get() = ref?.key
internal val refType: SessionRef.Type? get() = ref?.type
fun openSession(session: SessionDto) {
assertEdt()
open(session)
open(SessionRef.Local(session))
}
fun openSession(ref: SessionRef) {
assertEdt()
open(ref)
}
fun addListener(parent: Disposable, listener: SessionControllerListener) {
if (disposed) return
listeners.add(listener)
Disposer.register(parent) { listeners.remove(listener) }
replay(listener)
}
internal fun snapshotState(): ControllerStateSnapshot {
@@ -132,32 +142,38 @@ class SessionController(
viewState = viewState,
connectionState = connectionState,
connectionTargetState = connectionTargetState,
historyState = historyState.toString(),
refKey = ref?.key,
refType = ref?.type?.name,
sessionLoadState = sessionLoadState.toString(),
recentsState = recentsState.toString(),
)
}
internal fun flushEvents() {
assertEdt()
if (disposed) return
updates.requestFlush(true)
}
fun prompt(text: String) {
assertEdt()
val sid = sessionId ?: "pending"
val start = sid ?: ref?.key ?: "pending"
val dto = promptDto(text)
LOG.debug { "${ChatLogSummary.sid(sid)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" }
showMessages()
LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" }
showSession()
cs.launch {
try {
val id = sessionId ?: run {
val id = sid ?: run {
val session = sessions.create(directory)
runEdt {
sessionId = session.id
if (disposed) return@runEdt
ref = SessionRef.Local(session)
setRecentSessionsState(RecentsState.Idle)
updateModel {
model.setSession(session)
}
}
if (disposed) return@launch
val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session"
LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true")
subscribeEvents()
@@ -166,8 +182,9 @@ class SessionController(
sessions.prompt(id, directory, dto)
LOG.debug { "${ChatLogSummary.sid(id)} kind=prompt dispatched=true" }
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(sessionId ?: sid)} kind=prompt dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: start)} kind=prompt dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
edt {
if (disposed) return@edt
val msg = e.message ?: KiloBundle.message("session.error.prompt")
updateModel {
model.setState(SessionState.Error(msg))
@@ -179,8 +196,8 @@ class SessionController(
fun abort() {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=abort" }
val id = sessionId ?: return
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=abort" }
val id = sid ?: return
cs.launch {
try {
sessions.abort(id, directory)
@@ -193,7 +210,7 @@ class SessionController(
fun compact() {
assertEdt()
val id = sessionId ?: return
val id = sid ?: return
if (model.state.isBusy()) return
if (model.isEmpty()) return
val parsed = model.model?.let(::parseModel) ?: return
@@ -217,7 +234,7 @@ class SessionController(
fun retryConnection() {
assertEdt()
LOG.debug {
"${ChatLogSummary.sid(sessionId ?: "pending")} kind=connection-retry app=${model.app.status} workspace=${model.workspace.status}"
"${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=connection-retry app=${model.app.status} workspace=${model.workspace.status}"
}
setConnectionTargetState(SessionControllerEvent.ConnectionChanged.ShowConnecting)
setVisibleConnectionState(SessionControllerEvent.ConnectionChanged.ShowConnecting)
@@ -238,12 +255,12 @@ class SessionController(
fun selectAgent(name: String) {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config agent=$name" }
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config agent=$name" }
cs.launch {
try {
sessions.updateConfig(directory, ConfigUpdateDto(agent = name))
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=config agent=$name dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config agent=$name dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
}
}
fire(SessionControllerEvent.WorkspaceReady) {
@@ -254,7 +271,7 @@ class SessionController(
fun selectModel(provider: String, id: String) {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config model=$provider/$id" }
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config model=$provider/$id" }
val agent = model.agent ?: return
val key = "$provider/$id"
if (item(key) == null && model.workspace.providers != null) return
@@ -266,7 +283,7 @@ class SessionController(
fun clearModelOverride() {
assertEdt()
val agent = model.agent ?: return
LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config model-reset agent=$agent" }
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config model-reset agent=$agent" }
app.clearModel(agent)
val auto = configModel(agent) ?: providerModel(agent)
selectResolvedModel(auto)
@@ -277,7 +294,7 @@ class SessionController(
assertEdt()
val key = model.model ?: return
if (value !in model.variants) return
LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config variant=$key/$value" }
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=config variant=$key/$value" }
app.selectVariant(key, value)
model.variant = value
}
@@ -286,53 +303,63 @@ class SessionController(
fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=permission rid=$requestId reply=${reply.reply}" }
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId reply=${reply.reply}" }
cs.launch {
try {
if (rules != null) sessions.savePermissionRules(requestId, directory, rules)
sessions.replyPermission(requestId, directory, reply)
LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=permission rid=$requestId ok=true" }
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId ok=true" }
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=permission rid=$requestId reply=${reply.reply} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission rid=$requestId reply=${reply.reply} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
}
}
}
fun replyQuestion(requestId: String, answers: QuestionReplyDto) {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" }
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" }
cs.launch {
try {
sessions.replyQuestion(requestId, directory, answers)
LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId ok=true" }
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId ok=true" }
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
}
}
}
fun rejectQuestion(requestId: String) {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId rejected=true" }
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId rejected=true" }
cs.launch {
try {
sessions.rejectQuestion(requestId, directory)
LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId ok=true" }
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId ok=true" }
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId rejected=true dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=question rid=$requestId rejected=true dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
}
}
}
init {
initialSession?.let { model.setSession(it) }
if (sessionId != null) {
loadHistory()
subscribeEvents()
(ref as? SessionRef.Local)?.session?.let { model.setSession(it) }
when (val item = ref) {
is SessionRef.Cloud -> {
val token = SessionLoadState.Loading()
startSessionLoading(token)
importCloud(item.id, token)
}
is SessionRef.Local -> {
val token = SessionLoadState.Loading()
startSessionLoading(token)
loadSession(token)
subscribeEvents()
}
null -> Unit
}
model.addListener(this) { event ->
LOG.debug { "session=$sessionId model: $event" }
LOG.debug { "session=${sid ?: ref?.key ?: "pending"} model: $event" }
}
app.connect()
@@ -402,28 +429,24 @@ class SessionController(
if (state.status == KiloWorkspaceStatusDto.READY) {
fire(SessionControllerEvent.WorkspaceReady)
edt {
if (sessionId == null) refreshRecents()
if (canUseRecents()) refreshRecents()
}
}
}
}
}
private fun loadHistory() {
val id = sessionId ?: return
private fun loadSession(token: SessionLoadState.Loading) {
val target = ref as? SessionRef.Local ?: return
val id = target.id
cs.launch {
val state = HistoryState.Loading()
runEdt {
setHistoryState(state)
}
delayedState.run(state, { historyState }) {
if (!model.showSession) setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress)
}
try {
val session = initialSession ?: runCatching { sessions.get(id, directory) }.getOrNull()
val session = target.session ?: runCatching { sessions.get(id, directory) }.getOrNull()
val items = sessions.messages(id, directory)
LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(items)}" }
runEdt {
if (disposed) return@runEdt
if (sid != id) return@runEdt
updateModel {
this@SessionController.model.loadHistory(items)
if (session != null) this@SessionController.model.setSession(session)
@@ -431,21 +454,27 @@ class SessionController(
}
recoverPending(id)
runEdt {
val show = !model.isEmpty()
if (show) showMessages()
if (!show) refreshRecents(force = true)
loaded(show)
if (disposed) return@runEdt
if (sid != id) return@runEdt
showSession()
loaded(!model.isEmpty())
}
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(id)} kind=history dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
edt {
refreshRecents(force = true)
if (disposed) return@edt
if (sid != id) return@edt
updateModel {
model.setState(SessionState.Error(e.message ?: KiloBundle.message("history.error.local")))
}
showSession()
loaded(false)
}
} finally {
edt {
if (historyState != state) return@edt
setHistoryState(HistoryState.Idle)
if (disposed) return@edt
if (sessionLoadState != token) return@edt
setSessionLoadState(SessionLoadState.Idle)
}
updates.holdFlush(false)
updates.requestFlush(true)
@@ -453,8 +482,59 @@ class SessionController(
}
}
private fun importCloud(id: String, token: SessionLoadState.Loading) {
cs.launch {
try {
val session = sessions.importCloudSession(id, directory)
val items = sessions.messages(session.id, directory)
LOG.debug { "${ChatLogSummary.sid(session.id)} ${ChatLogSummary.history(items)}" }
runEdt {
if (disposed) return@runEdt
ref = SessionRef.Local(session)
setRecentSessionsState(RecentsState.Idle)
updateModel {
this@SessionController.model.loadHistory(items)
this@SessionController.model.setSession(session)
}
}
recoverPending(session.id)
runEdt {
if (disposed) return@runEdt
subscribeEvents()
showSession()
loaded(!model.isEmpty())
}
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(id)} kind=cloud-import dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
edt {
if (disposed) return@edt
updateModel {
model.setState(SessionState.Error(e.message ?: KiloBundle.message("history.error.cloud")))
}
showSession()
loaded(false)
}
} finally {
edt {
if (disposed) return@edt
if (sessionLoadState != token) return@edt
setSessionLoadState(SessionLoadState.Idle)
}
updates.holdFlush(false)
updates.requestFlush(true)
}
}
}
private fun startSessionLoading(token: SessionLoadState.Loading) {
assertEdt()
setSessionLoadState(token)
model.setState(SessionState.Loading)
if (!model.showSession) setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress)
}
private fun subscribeEvents() {
val id = sessionId ?: return
val id = sid ?: return
LOG.debug { "${ChatLogSummary.sid(id)} kind=subscription subscribe=true" }
eventJob?.cancel()
eventJob = cs.launch {
@@ -489,6 +569,8 @@ class SessionController(
"${ChatLogSummary.sid(id)} kind=recovery permissions=${permissions.size} questions=${questions.size} status=${status?.type ?: "none"} branch=$branch"
}
runEdt {
if (disposed) return@runEdt
if (sid != id) return@runEdt
updateModel {
if (permissions.isNotEmpty()) {
model.setState(SessionState.AwaitingPermission(toPermission(permissions.last())))
@@ -511,7 +593,7 @@ class SessionController(
* for "busy" because no more-specific state has arrived yet.
*/
private fun seedStatus(dto: SessionStatusDto) {
LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} evt=session.status ${ChatLogSummary.status(dto)}" }
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} evt=session.status ${ChatLogSummary.status(dto)}" }
val state = when (dto.type) {
"busy" -> SessionState.Busy(KiloBundle.message("session.status.considering"))
"retry" -> SessionState.Retry(
@@ -533,7 +615,7 @@ class SessionController(
when (event) {
is ChatEventDto.MessageUpdated -> {
val added = model.upsertMessage(event.info)
if (added) showMessages()
if (added) showSession()
}
is ChatEventDto.PartUpdated -> {
@@ -734,12 +816,13 @@ class SessionController(
private fun updateModel(block: () -> Unit) {
assertEdt()
if (disposed) return
val follow = beforeUpdate()
block()
afterUpdate(follow)
}
private fun showMessages() {
private fun showSession() {
assertEdt()
if (!model.showSession) {
setControllerViewState(SessionControllerEvent.ViewChanged.ShowSession)
@@ -764,50 +847,65 @@ class SessionController(
fun refreshRecents(force: Boolean = false) {
assertEdt()
if (model.showSession) return
if (!canUseRecents()) return
if (recentsState is RecentsState.Loading) return
if (recentsState is RecentsState.Loaded && !force) return
val state = RecentsState.Loading()
setRecentSessionsState(state)
delayedState.run(state, { recentsState }) {
setControllerViewState(SessionControllerEvent.ViewChanged.ShowProgress)
}
cs.launch {
try {
val items = sessions.recent(directory, RECENT_LIMIT)
edt {
if (!canUseRecents()) return@edt
if (recentsState != state) return@edt
setRecentSessionsState(RecentsState.Loaded)
if (model.showSession) return@edt
if (!canUseRecents()) return@edt
setControllerViewState(SessionControllerEvent.ViewChanged.ShowRecents(items))
}
} catch (e: Exception) {
LOG.warn("kind=session-recent dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
edt {
if (!canUseRecents()) return@edt
if (recentsState != state) return@edt
setRecentSessionsState(RecentsState.Loaded)
if (model.showSession) return@edt
if (!canUseRecents()) return@edt
setControllerViewState(SessionControllerEvent.ViewChanged.ShowRecents(emptyList()))
}
}
}
}
private fun canUseRecents(): Boolean {
assertEdt()
if (disposed) return false
if (ref != null) return false
if (sessionLoadState !is SessionLoadState.Idle) return false
return !model.showSession
}
private fun setControllerViewState(event: SessionControllerEvent.ViewChanged) {
assertEdt()
if (disposed) return
if (event is SessionControllerEvent.ViewChanged.ShowSession) openLocal()
if (viewState == event) return
fire(event) {
viewState = event
if (event is SessionControllerEvent.ViewChanged.ShowSession) {
model.showSession = true
setHistoryState(HistoryState.Idle)
setSessionLoadState(SessionLoadState.Idle)
setRecentSessionsState(RecentsState.Idle)
}
}
}
private fun openLocal() {
val session = model.session ?: return
open(SessionRef.Local(session))
}
private fun setConnectionTargetState(event: SessionControllerEvent.ConnectionChanged) {
assertEdt()
if (disposed) return
connectionTargetState = event
val state = event
if (event is SessionControllerEvent.ConnectionChanged.Hide || event is SessionControllerEvent.ConnectionChanged.ShowWarning) {
@@ -817,11 +915,14 @@ class SessionController(
if (connectionState == event) {
return
}
delayedState.run(event, { if (connectionTargetState == state) state else resolveConnectionState() }, ::setVisibleConnectionState)
connectionDelay.run(event, { if (connectionTargetState == state) state else resolveConnectionState() }) {
if (!disposed) setVisibleConnectionState(it)
}
}
private fun setVisibleConnectionState(event: SessionControllerEvent.ConnectionChanged) {
assertEdt()
if (disposed) return
if (connectionState == event) return
if (connectionState == null && event is SessionControllerEvent.ConnectionChanged.Hide) {
connectionState = event
@@ -837,9 +938,9 @@ class SessionController(
setConnectionTargetState(resolveConnectionState())
}
private fun setHistoryState(state: HistoryState) {
private fun setSessionLoadState(state: SessionLoadState) {
assertEdt()
historyState = state
sessionLoadState = state
}
private fun setRecentSessionsState(state: RecentsState) {
@@ -882,19 +983,41 @@ class SessionController(
}
private fun fire(event: SessionControllerEvent, before: (() -> Unit)? = null) {
LOG.debug { "session=$sessionId controller: $event" }
LOG.debug { "session=${sid ?: ref?.key ?: "pending"} controller: $event" }
val application = ApplicationManager.getApplication()
if (application.isDispatchThread) {
if (disposed) return
before?.invoke()
for (l in listeners) l.onEvent(event)
return
}
application.invokeLater {
if (disposed) return@invokeLater
before?.invoke()
for (l in listeners) l.onEvent(event)
}
}
private fun replay(listener: SessionControllerListener) {
if (disposed) return
val app = ApplicationManager.getApplication()
val block: () -> Unit = {
if (!disposed) {
viewState?.let(listener::onEvent)
connectionState?.let(listener::onEvent)
}
}
if (app.isDispatchThread) {
if (disposed) return
block()
return
}
app.invokeLater {
if (disposed) return@invokeLater
block()
}
}
private fun assertEdt() {
check(ApplicationManager.getApplication().isDispatchThread) { "SessionController state must be accessed on EDT" }
}
@@ -913,7 +1036,8 @@ class SessionController(
}
override fun dispose() {
delayedState.dispose()
disposed = true
connectionDelay.dispose()
eventJob?.cancel()
cs.cancel()
}
@@ -940,6 +1064,7 @@ class SessionController(
when (val state = model.state) {
is SessionState.Idle -> out.add("[idle]")
is SessionState.Loading -> out.add("[loading]")
is SessionState.Busy -> {
out.add("[busy]")
out.add("[${state.text.toDumpText()}]")
@@ -1046,9 +1171,9 @@ private sealed interface RecentsState {
data object Loaded : RecentsState
}
private sealed interface HistoryState {
data object Idle : HistoryState
data class Loading(val id: Any = Any()) : HistoryState
private sealed interface SessionLoadState {
data object Idle : SessionLoadState
data class Loading(val id: Any = Any()) : SessionLoadState
}
internal data class ControllerStateSnapshot(
@@ -1056,7 +1181,9 @@ internal data class ControllerStateSnapshot(
val viewState: SessionControllerEvent.ViewChanged?,
val connectionState: SessionControllerEvent.ConnectionChanged?,
val connectionTargetState: SessionControllerEvent.ConnectionChanged?,
val historyState: String,
val refKey: String?,
val refType: String?,
val sessionLoadState: String,
val recentsState: String,
)
@@ -1,4 +1,4 @@
package ai.kilocode.client.session.update
package ai.kilocode.client.session.controller
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
@@ -1,4 +1,4 @@
package ai.kilocode.client.session.update
package ai.kilocode.client.session.controller
import ai.kilocode.rpc.dto.ChatEventDto
@@ -1,4 +1,4 @@
package ai.kilocode.client.session.update
package ai.kilocode.client.session.controller
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.log.KiloLog
@@ -0,0 +1,122 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionRef
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.openapi.application.ApplicationManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class HistoryController(
private val sessions: KiloSessionService,
private val workspace: Workspace,
private val cs: CoroutineScope,
open: (SessionRef) -> Unit = {},
private val deleted: (String) -> Unit = {},
) {
companion object {
const val CLOUD_LIMIT = 150
}
val local = HistoryModel<LocalHistoryItem>()
val cloud = CloudHistoryModel()
private val deleting = mutableSetOf<String>()
private val opener = open
private var git: String? = null
fun reload(gitUrl: String? = null) {
reloadLocal()
reloadCloud(gitUrl)
}
fun reloadLocal() {
edt { local.start() }
cs.launch {
try {
val result = sessions.list(workspace.directory)
val items = result.sessions.map(::localItem)
edt { local.replace(items) }
} catch (e: Exception) {
edt { local.fail(e.message ?: KiloBundle.message("history.error.local")) }
}
}
}
fun reloadCloud(gitUrl: String? = null) {
git = gitUrl
loadCloud(reset = true)
}
fun loadMoreCloud() {
if (cloud.cursor == null || cloud.loading) return
loadCloud(reset = false)
}
fun delete(item: LocalHistoryItem) {
edt {
if (item.id in deleting) return@edt
deleting.add(item.id)
local.refresh()
cs.launch {
try {
sessions.deleteSession(item.id, item.directory ?: workspace.directory)
edt {
deleting.remove(item.id)
local.remove(item.id)
deleted(item.id)
}
} catch (e: Exception) {
edt {
deleting.remove(item.id)
local.fail(e.message ?: KiloBundle.message("history.error.local.delete"))
}
}
}
}
}
fun deleting(item: LocalHistoryItem): Boolean = item.id in deleting
fun open(item: LocalHistoryItem) {
edt { opener(SessionRef.Local(item.session)) }
}
fun open(item: CloudHistoryItem) {
edt { opener(SessionRef.Cloud(item.session)) }
}
private fun loadCloud(reset: Boolean) {
val cursor = cloud.cursor.takeUnless { reset }
val gitUrl = git
edt { cloud.start(reset) }
cs.launch {
try {
val result = sessions.cloudSessions(workspace.directory, cursor, CLOUD_LIMIT, gitUrl)
val items = result.sessions.map(::cloudItem)
edt {
if (reset) cloud.replace(items, result.nextCursor)
else cloud.append(items, result.nextCursor)
}
} catch (e: Exception) {
edt { cloud.fail(e.message ?: KiloBundle.message("history.error.cloud")) }
}
}
}
}
private fun edt(block: () -> Unit) {
val app = ApplicationManager.getApplication()
if (app.isDispatchThread) {
block()
return
}
app.invokeLater(block)
}
private fun localItem(session: SessionDto) = LocalHistoryItem(session)
private fun cloudItem(session: CloudSessionDto) = CloudHistoryItem(session)
@@ -0,0 +1,28 @@
package ai.kilocode.client.session.history
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.SessionDto
sealed interface HistoryItem {
val id: String
val title: String
val createdAt: String
val updatedAt: String
}
data class LocalHistoryItem(val session: SessionDto) : HistoryItem {
override val id: String get() = session.id
override val title: String get() = session.title
override val createdAt: String get() = session.time.created.toString()
override val updatedAt: String get() = session.time.updated.toString()
val directory: String? get() = session.directory
}
data class CloudHistoryItem(val session: CloudSessionDto) : HistoryItem {
override val id: String get() = session.id
override val title: String get() = session.title.orEmpty()
override val createdAt: String get() = session.createdAt
override val updatedAt: String get() = session.updatedAt
}
enum class HistorySource { LOCAL, CLOUD }
@@ -0,0 +1,128 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.ExperimentalUI
import com.intellij.ui.GroupHeaderSeparator
import com.intellij.ui.SimpleColoredComponent
import com.intellij.ui.SimpleTextAttributes
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.Point
import java.awt.Rectangle
import javax.swing.Icon
import javax.swing.JList
import javax.swing.JPanel
import javax.swing.ListCellRenderer
import javax.swing.SwingConstants
private const val DELETE_CLICK_AREA_WIDTH = 32
internal open class HistoryRenderer<T : HistoryItem>(
private val model: HistoryModel<T>,
private val deletable: Boolean,
) : JPanel(BorderLayout()), ListCellRenderer<T> {
companion object {
private val icon: Icon = AllIcons.Actions.GC
private val empty: Icon = EmptyIcon.create(icon)
fun isDeleteClick(list: JList<*>, bounds: Rectangle, point: Point): Boolean {
val width = JBUI.scale(DELETE_CLICK_AREA_WIDTH)
val inset = deleteInset(list)
if (list.componentOrientation.isLeftToRight) {
val right = bounds.x + bounds.width - inset
return point.x in (right - width)..right
}
val left = bounds.x + inset
return point.x in left..(left + width)
}
fun section(items: List<HistoryItem>, index: Int): String? {
val item = items.getOrNull(index) ?: return null
val current = HistoryTime.section(item)
val previous = items.getOrNull(index - 1)?.let(HistoryTime::section)
if (current == previous) return null
return HistoryTime.title(current)
}
private fun deleteInset(list: JList<*>): Int {
if (!ExperimentalUI.isNewUI()) return 0
val inner = JBUI.CurrentTheme.Popup.Selection.innerInsets()
val edge = JBUI.CurrentTheme.Popup.Selection.LEFT_RIGHT_INSET.get()
return edge + if (list.componentOrientation.isLeftToRight) inner.right else inner.left
}
}
private val sep = GroupHeaderSeparator(JBUI.CurrentTheme.Popup.separatorLabelInsets())
private val top = JPanel(BorderLayout()).apply {
border = JBUI.Borders.empty()
add(sep, BorderLayout.NORTH)
}
private val title = SimpleColoredComponent()
private val time = JBLabel()
private val del = JBLabel().apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
private val main = JPanel(BorderLayout()).apply {
add(title, BorderLayout.CENTER)
add(time, BorderLayout.EAST)
}
private val row = JPanel(BorderLayout()).apply {
add(main, BorderLayout.CENTER)
add(del, BorderLayout.EAST)
}
private val wrap = PickerRow()
init {
isOpaque = true
top.isOpaque = true
row.border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg())
UiStyle.Components.transparent(row, main, title, time, del)
wrap.setContent(row)
add(top, BorderLayout.NORTH)
add(wrap, BorderLayout.CENTER)
}
override fun getListCellRendererComponent(
list: JList<out T>,
value: T?,
index: Int,
selected: Boolean,
focus: Boolean,
): JPanel {
val focused = selected || list.hasFocus() || focus
val fg = UIUtil.getListForeground(selected, focused)
val weak = if (selected) fg else UIUtil.getContextHelpForeground()
background = list.background
top.background = list.background
wrap.update(list, selected, focused)
sep.caption = section(model.visibleItems, index)
sep.setHideLine(index == 0)
top.isVisible = sep.caption != null
title.clear()
title.append(
value?.let(::title).orEmpty(),
SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg),
)
time.text = value?.let(HistoryTime::relative).orEmpty()
time.foreground = weak
del.icon = if (deletable && selected) icon else empty
top.invalidate()
return this
}
fun deleteVisible(): Boolean = del.icon === icon
}
internal class LocalHistoryRenderer(model: HistoryModel<LocalHistoryItem>) : HistoryRenderer<LocalHistoryItem>(model, deletable = true)
internal class CloudHistoryRenderer(model: HistoryModel<CloudHistoryItem>) : HistoryRenderer<CloudHistoryItem>(model, deletable = false)
@@ -0,0 +1,15 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.plugin.KiloBundle
import com.intellij.ui.components.JBList
import java.awt.event.MouseEvent
internal fun title(item: HistoryItem): String = item.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("history.untitled")
internal fun <T> clicked(list: JBList<T>, e: MouseEvent): T? {
val row = list.locationToIndex(e.point)
val box = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return null
if (!box.contains(e.point)) return null
list.selectedIndex = row
return list.model.getElementAt(row)
}
@@ -0,0 +1,120 @@
package ai.kilocode.client.session.history
import javax.swing.AbstractListModel
open class HistoryModel<T : HistoryItem> : AbstractListModel<T>() {
private var all = emptyList<T>()
private var rows = emptyList<T>()
private var query = ""
var loading = false
private set
var error: String? = null
private set
var loaded = false
private set
val items: List<T> get() = all
val visibleItems: List<T> get() = rows
override fun getSize(): Int = rows.size
override fun getElementAt(index: Int): T = rows[index]
fun start() {
loading = true
error = null
refresh()
}
fun replace(items: List<T>) {
all = HistoryTime.sorted(items)
loading = false
loaded = true
error = null
filter()
}
fun append(items: List<T>) {
all = HistoryTime.sorted(all + items)
loading = false
loaded = true
error = null
filter()
}
fun remove(id: String) {
all = all.filterNot { it.id == id }
filter()
}
fun fail(message: String) {
loading = false
loaded = true
error = message
refresh()
}
fun setFilter(value: String) {
query = value.trim().lowercase()
filter()
}
fun hasFilter(): Boolean = query.isNotEmpty()
fun refresh() {
val end = size.coerceAtLeast(1) - 1
fireContentsChanged(this, 0, end)
}
protected fun clear() {
all = emptyList()
filter()
}
private fun filter() {
val old = rows.size
rows = all.filter(::matches)
val new = rows.size
when {
old > new -> {
if (new > 0) fireContentsChanged(this, 0, new - 1)
fireIntervalRemoved(this, new, old - 1)
}
new > old -> {
if (old > 0) fireContentsChanged(this, 0, old - 1)
fireIntervalAdded(this, old, new - 1)
}
new > 0 -> fireContentsChanged(this, 0, new - 1)
}
}
private fun matches(item: T): Boolean {
if (query.isEmpty()) return true
if (item.title.lowercase().contains(query)) return true
if (item.id.lowercase().contains(query)) return true
if (item is LocalHistoryItem && item.directory?.lowercase()?.contains(query) == true) return true
return false
}
}
class CloudHistoryModel : HistoryModel<CloudHistoryItem>() {
var cursor: String? = null
private set
fun start(reset: Boolean) {
cursor = cursor.takeUnless { reset }
start()
if (reset && !loaded) clear()
}
fun replace(items: List<CloudHistoryItem>, next: String?) {
cursor = next
replace(items)
}
fun append(items: List<CloudHistoryItem>, next: String?) {
cursor = next
append(items)
}
}
@@ -0,0 +1,440 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ide.ui.LafManagerListener
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.Disposable
import com.intellij.openapi.ui.Messages
import com.intellij.openapi.util.Disposer
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.ListUtil
import com.intellij.ui.SearchTextField
import com.intellij.ui.ScrollingUtil
import com.intellij.ui.components.JBList
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.tabs.JBTabs
import com.intellij.ui.tabs.JBTabsFactory
import com.intellij.ui.tabs.JBTabsPosition
import com.intellij.ui.tabs.TabInfo
import com.intellij.ui.tabs.TabsListener
import com.intellij.util.ui.Centerizer
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.Cursor
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.event.HierarchyEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JList
import javax.swing.KeyStroke
import javax.swing.ListSelectionModel
import javax.swing.SwingUtilities
import javax.swing.event.DocumentEvent
import javax.swing.event.ListDataEvent
import javax.swing.event.ListDataListener
class HistoryPanel(
parent: Disposable,
private val controller: HistoryController,
private val gitUrl: () -> String? = { null },
private val nav: () -> Unit = {},
) : BorderLayoutPanel(), Disposable {
private val localSearch = search(controller.local)
private val cloudSearch = search(controller.cloud)
private val localList = localList()
private val cloudList = cloudList()
private val more = LoadMoreButton()
private val localPanel = panel(localSearch, localList)
private val cloudPanel = panel(cloudSearch, cloudList, more)
private val cards = CardLayout()
private val body = BorderLayoutPanel().apply { layout = cards }
private val load = LoadingPanel()
private val localInfo = TabInfo(localPanel)
.setText(KiloBundle.message("history.tab.local"))
.setForeSideComponent(back())
private val cloudInfo = TabInfo(cloudPanel)
.setText(KiloBundle.message("history.tab.cloud"))
.setForeSideComponent(back())
private var stale = false
private val tabs: JBTabs = JBTabsFactory.createTabs(null, this).apply {
presentation.setSingleRow(true)
presentation.setTabsPosition(JBTabsPosition.top)
presentation.showBorder = false
addTab(localInfo).setPreferredFocusableComponent(localSearch.textEditor)
addTab(cloudInfo).setPreferredFocusableComponent(cloudSearch.textEditor)
addListener(object : TabsListener {
override fun selectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) {
sync()
}
}, this@HistoryPanel)
}
init {
Disposer.register(parent, this)
border = JBUI.Borders.empty(UiStyle.Gap.lg())
more.addActionListener { controller.loadMoreCloud() }
bind(localList, controller.local)
bind(cloudList, controller.cloud)
bindTheme()
addHierarchyListener { e ->
if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener
if (isShowing && stale) {
refresh()
return@addHierarchyListener
}
if (!isShowing) stale = true
}
body.add(load, CARD_LOAD)
body.add(tabs.component, CARD_TABS)
add(body, BorderLayout.CENTER)
sync()
refresh()
}
val component: JComponent get() = this
val defaultFocusedComponent: JComponent get() = activeSearch().textEditor
fun refresh() {
stale = false
updateTheme()
controller.reload(gitUrl())
}
private fun bindTheme() {
val bus = ApplicationManager.getApplication().messageBus.connect(this)
bus.subscribe(LafManagerListener.TOPIC, LafManagerListener {
ApplicationManager.getApplication().invokeLater {
updateTheme()
}
})
}
private fun updateTheme() {
SwingUtilities.updateComponentTreeUI(this)
SwingUtilities.updateComponentTreeUI(localPanel)
SwingUtilities.updateComponentTreeUI(cloudPanel)
load.applyStyle(SessionEditorStyle.current())
updateRenderer(localList)
updateRenderer(cloudList)
sync()
}
private fun updateRenderer(list: JBList<out HistoryItem>) {
val view = list.cellRenderer
if (view is JComponent) SwingUtilities.updateComponentTreeUI(view)
}
private fun search(model: HistoryModel<out HistoryItem>) = SearchTextField(false).apply {
textEditor.emptyText.text = KiloBundle.message("history.search.placeholder")
textEditor.document.addDocumentListener(object : DocumentAdapter() {
override fun textChanged(e: DocumentEvent) {
model.setFilter(text)
}
})
textEditor.registerKeyboardAction(
{ move(-1) },
KeyStroke.getKeyStroke(KeyEvent.VK_UP, 0),
JComponent.WHEN_FOCUSED,
)
textEditor.registerKeyboardAction(
{ move(1) },
KeyStroke.getKeyStroke(KeyEvent.VK_DOWN, 0),
JComponent.WHEN_FOCUSED,
)
textEditor.registerKeyboardAction(
{ activeList().selectedValue?.let(::activate) },
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
JComponent.WHEN_FOCUSED,
)
}
private fun back() = BorderLayoutPanel().apply {
add(JButton(KiloBundle.message("history.back"), AllIcons.Actions.Back).apply {
putClientProperty(DarculaButtonUI.DEFAULT_STYLE_KEY, true)
isFocusable = false
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
addActionListener { nav() }
}, BorderLayout.WEST)
border = JBUI.Borders.emptyRight(UiStyle.Gap.lg())
}
private fun panel(search: SearchTextField, list: JList<out HistoryItem>, footer: JComponent? = null): JComponent {
return BorderLayoutPanel().apply {
add(search, BorderLayout.NORTH)
add(JBScrollPane(list).apply {
border = JBUI.Borders.empty()
viewportBorder = JBUI.Borders.empty()
}, BorderLayout.CENTER)
footer?.let {
add(Centerizer(it, Centerizer.TYPE.HORIZONTAL).apply {
border = JBUI.Borders.emptyTop(UiStyle.Gap.lg())
}, BorderLayout.SOUTH)
}
}
}
private fun localList() = JBList(controller.local).apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
isFocusable = false
cellRenderer = LocalHistoryRenderer(controller.local)
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
emptyText.text = KiloBundle.message("history.empty")
addMouseListener(object : MouseAdapter() {
override fun mouseReleased(e: MouseEvent) {
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
val item = clicked(this@apply, e) ?: return
if (deleteClick(this@apply, e)) {
confirm(item)
e.consume()
return
}
activate(item)
}
})
registerKeyboardAction(
{ selectedValue?.let(::activate) },
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
JComponent.WHEN_FOCUSED,
)
ListUtil.installAutoSelectOnMouseMove(this)
ScrollingUtil.installActions(this)
}
private fun cloudList() = JBList(controller.cloud).apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
isFocusable = false
cellRenderer = CloudHistoryRenderer(controller.cloud)
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
emptyText.text = KiloBundle.message("history.empty")
addMouseListener(object : MouseAdapter() {
override fun mouseReleased(e: MouseEvent) {
if (!UIUtil.isActionClick(e, MouseEvent.MOUSE_RELEASED, true)) return
clicked(this@apply, e)?.let(::activate)
}
})
registerKeyboardAction(
{ selectedValue?.let(::activate) },
KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0),
JComponent.WHEN_FOCUSED,
)
ListUtil.installAutoSelectOnMouseMove(this)
ScrollingUtil.installActions(this)
}
private fun <T : HistoryItem> bind(list: JBList<T>, model: HistoryModel<T>) {
val listener = object : ListDataListener {
override fun intervalAdded(e: ListDataEvent) = sync()
override fun intervalRemoved(e: ListDataEvent) = sync()
override fun contentsChanged(e: ListDataEvent) = sync()
}
model.addListDataListener(listener)
Disposer.register(this) { model.removeListDataListener(listener) }
list.setPaintBusy(model.loading)
}
private fun sync() {
syncList(localList, controller.local)
syncList(cloudList, controller.cloud)
more.isEnabled = controller.cloud.cursor != null && !controller.cloud.loading
more.isVisible = controller.cloud.cursor != null || controller.cloud.loading
cards.show(body, if (loading()) CARD_LOAD else CARD_TABS)
revalidate()
repaint()
}
private fun loading(): Boolean {
if (controller.local.loaded || controller.cloud.loaded) return false
return controller.local.loading || controller.cloud.loading
}
private fun <T : HistoryItem> syncList(list: JBList<T>, model: HistoryModel<T>) {
list.setPaintBusy(model.loading)
list.emptyText.text = when {
model.loading -> KiloBundle.message("history.loading")
model.error != null -> model.error.orEmpty()
else -> KiloBundle.message("history.empty")
}
}
private fun deleteClick(list: JBList<LocalHistoryItem>, e: MouseEvent): Boolean {
val row = list.locationToIndex(e.point)
val box = row.takeIf { it >= 0 }?.let { list.getCellBounds(it, it) } ?: return false
if (!box.contains(e.point)) return false
return HistoryRenderer.isDeleteClick(list, box, e.point)
}
private fun activate(item: HistoryItem) {
when (item) {
is LocalHistoryItem -> controller.open(item)
is CloudHistoryItem -> controller.open(item)
}
}
private fun confirm(item: LocalHistoryItem) {
if (controller.deleting(item)) return
val result = Messages.showYesNoDialog(
this,
KiloBundle.message("history.delete.confirm.message", title(item)),
KiloBundle.message("history.delete.confirm.title"),
Messages.getWarningIcon(),
)
if (result != Messages.YES) return
controller.delete(item)
}
internal fun itemCount() = activeModel().size
internal fun selectedSource() = if (tabs.selectedInfo === cloudInfo) HistorySource.CLOUD else HistorySource.LOCAL
internal fun select(index: Int) {
activeList().selectedIndex = index
}
internal fun selectedIndex() = activeList().selectedIndex
internal fun listFocusable() = activeList().isFocusable
internal fun listCursor() = activeList().cursor.type
internal fun backText(): String? {
val view = activeInfo().foreSideComponent ?: return null
return UIUtil.uiTraverser(view).filter(JButton::class.java).firstOrNull()?.text
}
internal fun backCursor(): Int? {
val view = activeInfo().foreSideComponent ?: return null
return UIUtil.uiTraverser(view).filter(JButton::class.java).firstOrNull()?.cursor?.type
}
internal fun clickBack() {
val view = activeInfo().foreSideComponent ?: return
UIUtil.uiTraverser(view).filter(JButton::class.java).firstOrNull()?.doClick()
}
internal fun clickDelete() {
localList.selectedValue?.let(controller::delete)
}
internal fun clickCloud() {
tabs.select(cloudInfo, false)
sync()
}
internal fun clickLocal() {
tabs.select(localInfo, false)
sync()
}
internal fun clickMore() {
more.doClick()
}
internal fun setSearch(value: String) {
if (tabs.selectedInfo === cloudInfo) cloudSearch.text = value else localSearch.text = value
}
internal fun groupTitles(): List<String> {
val items = activeModel().visibleItems
return items.indices.mapNotNull { HistoryRenderer.section(items, it) }
}
internal fun deleteVisible(index: Int, selected: Boolean = true): Boolean {
val item = controller.local.getElementAt(index)
val view = localList.cellRenderer.getListCellRendererComponent(localList, item, index, selected, false)
return view is HistoryRenderer<*> && view.deleteVisible()
}
internal fun cloudDeleteVisible(index: Int, selected: Boolean = true): Boolean {
val item = controller.cloud.getElementAt(index)
val view = cloudList.cellRenderer.getListCellRendererComponent(cloudList, item, index, selected, false)
return view is HistoryRenderer<*> && view.deleteVisible()
}
private fun activeList(): JBList<out HistoryItem> = if (tabs.selectedInfo === cloudInfo) cloudList else localList
private fun activeModel(): HistoryModel<out HistoryItem> = if (tabs.selectedInfo === cloudInfo) controller.cloud else controller.local
private fun activeSearch(): SearchTextField = if (tabs.selectedInfo === cloudInfo) cloudSearch else localSearch
private fun activeInfo(): TabInfo = if (tabs.selectedInfo === cloudInfo) cloudInfo else localInfo
private fun move(step: Int) {
val list = activeList()
val size = list.model.size
if (size <= 0) return
val cur = list.selectedIndex.takeIf { it >= 0 } ?: if (step > 0) -1 else size
val idx = (cur + step).coerceIn(0, size - 1)
list.selectedIndex = idx
ScrollingUtil.ensureIndexIsVisible(list, idx, 0)
}
override fun dispose() {
// no-op
}
private class LoadMoreButton : JButton(KiloBundle.message("history.cloud.load.more")) {
private var over = false
init {
isFocusable = false
setRequestFocusEnabled(false)
isContentAreaFilled = false
isBorderPainted = false
isOpaque = false
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
sync(true)
}
override fun mouseExited(e: MouseEvent) {
sync(false)
}
})
}
override fun paintComponent(g: Graphics) {
if (isEnabled && over) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground()
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
}
super.paintComponent(g)
}
private fun sync(value: Boolean) {
if (over == value) return
over = value
repaint()
}
}
internal fun showingLoading() = !controller.local.loaded && !controller.cloud.loaded && (controller.local.loading || controller.cloud.loading)
private companion object {
const val CARD_LOAD = "load"
const val CARD_TABS = "tabs"
}
}
@@ -0,0 +1,82 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.plugin.KiloBundle
import java.time.Instant
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlin.math.abs
private const val SECOND_MS_LIMIT = 10_000_000_000L
private const val MINUTE = 60_000L
private const val HOUR = 60 * MINUTE
private const val DAY = 24 * HOUR
enum class HistorySection {
TODAY,
YESTERDAY,
WEEK,
MONTH,
OLDER,
}
internal object HistoryTime {
fun millis(item: HistoryItem): Long? {
val raw = item.updatedAt.toDoubleOrNull()?.toLong() ?: return parse(item.updatedAt)
if (abs(raw) < SECOND_MS_LIMIT) return raw * 1000
return raw
}
fun section(item: HistoryItem, now: Long = System.currentTimeMillis()): HistorySection {
val ms = millis(item) ?: return HistorySection.OLDER
val zone = ZoneId.systemDefault()
val date = Instant.ofEpochMilli(ms).atZone(zone).toLocalDate()
val today = Instant.ofEpochMilli(now).atZone(zone).toLocalDate()
if (date == today) return HistorySection.TODAY
if (date == today.minusDays(1)) return HistorySection.YESTERDAY
if (date.isAfter(today.minusDays(7)) && date.isBefore(today)) return HistorySection.WEEK
if (date.isAfter(today.minusDays(30)) && date.isBefore(today)) return HistorySection.MONTH
return HistorySection.OLDER
}
fun title(section: HistorySection): String = when (section) {
HistorySection.TODAY -> KiloBundle.message("history.group.today")
HistorySection.YESTERDAY -> KiloBundle.message("history.group.yesterday")
HistorySection.WEEK -> KiloBundle.message("history.group.week")
HistorySection.MONTH -> KiloBundle.message("history.group.month")
HistorySection.OLDER -> KiloBundle.message("history.group.older")
}
fun relative(item: HistoryItem, now: Long = System.currentTimeMillis()): String {
val ms = millis(item) ?: return item.updatedAt
val diff = (now - ms).coerceAtLeast(0)
if (diff < MINUTE) return KiloBundle.message("history.time.moments")
if (diff < HOUR) return KiloBundle.message("history.time.minutes", (diff / MINUTE).coerceAtLeast(1))
if (diff < DAY) return KiloBundle.message("history.time.hours", (diff / HOUR).coerceAtLeast(1))
if (diff < 7 * DAY) return KiloBundle.message("history.time.days", (diff / DAY).coerceAtLeast(1))
val date = LocalDate.ofInstant(Instant.ofEpochMilli(ms), ZoneId.systemDefault())
val today = LocalDate.ofInstant(Instant.ofEpochMilli(now), ZoneId.systemDefault())
val months = ChronoUnit.MONTHS.between(date.withDayOfMonth(1), today.withDayOfMonth(1))
if (months < 1) return KiloBundle.message("history.time.days", (diff / DAY).coerceAtLeast(1))
if (months < 12) return KiloBundle.message("history.time.months", months)
return KiloBundle.message("history.time.years", months / 12)
}
fun <T : HistoryItem> sorted(items: List<T>): List<T> = items.sortedWith(
compareByDescending<T> { millis(it) ?: Long.MIN_VALUE }
.thenBy { it.title.lowercase() }
.thenBy { it.id },
)
private fun parse(value: String): Long? {
val text = value.trim().replace(' ', 'T')
val zoned = when {
text.matches(Regex(".*[+-]\\d{2}$")) -> "$text:00"
text.matches(Regex(".*[+-]\\d{4}$")) -> text.dropLast(2) + ":" + text.takeLast(2)
else -> text
}
return runCatching { Instant.parse(zoned).toEpochMilli() }.getOrNull()
?: runCatching { OffsetDateTime.parse(zoned).toInstant().toEpochMilli() }.getOrNull()
}
}
@@ -18,7 +18,7 @@ import kotlin.math.roundToInt
/**
* Pure session model — single source of truth for session content and runtime state.
*
* **EDT-only access** — no synchronization. [ai.kilocode.client.session.update.SessionController] guarantees all
* **EDT-only access** — no synchronization. [ai.kilocode.client.session.controller.SessionController] guarantees all
* reads and writes happen on the EDT.
*
* In addition to the flat message list, the model maintains a derived
@@ -175,6 +175,7 @@ class SessionModel {
}
fun setState(state: SessionState) {
if (this.state == state) return
this.state = state
fire(SessionModelEvent.StateChanged(state))
updateHeader()
@@ -4,6 +4,8 @@ package ai.kilocode.client.session.model
sealed class SessionState {
data object Idle : SessionState()
data object Loading : SessionState()
data class Busy(val text: String) : SessionState()
data class AwaitingQuestion(val question: Question) : SessionState()
@@ -17,7 +19,7 @@ sealed class SessionState {
data class Error(val message: String, val kind: String? = null) : SessionState()
fun isBusy(): Boolean = when (this) {
is Idle, is Error -> false
is Idle, is Loading, is Error -> false
else -> true
}
}
@@ -1,9 +1,10 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.session.update.SessionControllerEvent
import ai.kilocode.client.session.update.SessionControllerListener
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.session.controller.SessionControllerEvent
import ai.kilocode.client.session.controller.SessionControllerListener
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
@@ -39,11 +40,11 @@ class ConnectionPanel(
}
private val header = BorderLayoutPanel().apply {
border = JBUI.Borders.empty(UiStyle.Space.SM, UiStyle.Space.LG, 0, UiStyle.Space.LG)
border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.lg(), 0, UiStyle.Gap.lg())
}
private val left = BorderLayoutPanel().apply {
layout = UiStyle.Gap.layout(UiStyle.Space.SM)
layout = BorderLayout(UiStyle.Gap.sm(), 0)
addMouseListener(click)
}
@@ -76,7 +77,7 @@ class ConnectionPanel(
}
private val scroll = JBScrollPane(details).apply {
border = JBUI.Borders.empty(0, UiStyle.Space.LG, UiStyle.Space.SM, 0)
border = JBUI.Borders.empty(0, UiStyle.Gap.lg(), UiStyle.Gap.sm(), 0)
// Match the banner background while retaining platform scroll behavior.
isOpaque = false
viewport.isOpaque = false
@@ -93,7 +94,7 @@ class ConnectionPanel(
// Keep the banner solid so expanded details cover transcript content beneath it.
isOpaque = true
background = UiStyle.Colors.bg()
border = JBUI.Borders.customLine(UiStyle.Colors.line(), 1, 0, 0, 0)
border = JBUI.Borders.customLine(SessionUiStyle.View.line(), 1, 0, 0, 0)
left.add(toggle, BorderLayout.WEST)
left.add(label, BorderLayout.CENTER)
header.add(left, BorderLayout.CENTER)
@@ -135,7 +136,7 @@ class ConnectionPanel(
}
private fun showError(text: String, detail: String?) {
label.foreground = UiStyle.Colors.error()
label.foreground = UiStyle.Colors.errorLabelForeground()
label.text = text
retry.isVisible = true
this.detail = detail?.takeIf { it.isNotBlank() }
@@ -145,7 +146,7 @@ class ConnectionPanel(
}
private fun showWarning(text: String, detail: String?) {
label.foreground = UiStyle.Colors.warning()
label.foreground = UiStyle.Colors.warningLabelForeground()
label.text = text
retry.isVisible = true
this.detail = detail?.takeIf { it.isNotBlank() }
@@ -1,73 +1,78 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.session.history.clicked
import ai.kilocode.client.session.history.title
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.ui.CenterShrinkPanel
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.md.MdView
import ai.kilocode.rpc.dto.SessionDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.IconLoader
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.util.ui.Centerizer
import com.intellij.util.ui.JBDimension
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import com.intellij.util.ui.components.BorderLayoutPanel
import com.intellij.xml.util.XmlStringUtil
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseMotionAdapter
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.DefaultListModel
import javax.swing.JButton
import javax.swing.JList
import javax.swing.ListCellRenderer
import javax.swing.ListSelectionModel
import kotlin.math.abs
/**
* Centered empty-session panel.
* Empty-session panel.
*
* The content is a BorderLayout panel, wrapped in a
* [CenterShrinkPanel] (exposed as [view]) so callers need not know about centering.
*/
class EmptySessionPanel(
parent: Disposable,
private val controller: SessionController,
recents: List<SessionDto>,
) : BorderLayoutPanel(), Disposable, SessionStyleTarget {
private val history: () -> Unit = {},
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
val view: CenterShrinkPanel = CenterShrinkPanel(this)
companion object {
internal val LIMIT = UiStyle.Size.LIMIT
internal val MAX_WIDTH = UiStyle.Size.WIDTH
private const val SECOND_MS_LIMIT = 10_000_000_000L
private const val MINUTE = 60_000L
private const val HOUR = 60 * MINUTE
private const val DAY = 24 * HOUR
}
private val model = DefaultListModel<SessionDto>()
private val model = DefaultListModel<LocalHistoryItem>()
private var hover = -1
private var style = SessionStyle.current()
private var style = SessionEditorStyle.current()
private val recentTitle = JBLabel(KiloBundle.message("session.empty.recent")).apply {
foreground = UIUtil.getContextHelpForeground()
border = JBUI.Borders.emptyLeft(UiStyle.Space.LG)
}
private val list = JBList(model).apply {
// Blend the recent-session list into the centered empty-state surface.
isOpaque = false
selectionMode = ListSelectionModel.SINGLE_SELECTION
visibleRowCount = LIMIT
visibleRowCount = SessionUiStyle.RecentSessions.LIMIT
cellRenderer = SessionRenderer()
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
emptyText.clear()
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
val index = row(e)
if (index < 0) return
selectedIndex = index
controller.openSession(model.getElementAt(index))
val item = clicked(this@apply, e) ?: return
controller.openSession(SessionRef.Local(item.session))
}
override fun mouseExited(e: MouseEvent) {
@@ -77,72 +82,83 @@ class EmptySessionPanel(
})
addMouseMotionListener(object : MouseMotionAdapter() {
override fun mouseMoved(e: MouseEvent) {
val index = row(e)
val index = index(e)
if (hover == index) return
hover = index
repaint()
}
})
}
private val md = MdView.html().apply {
// MdView uses an HTML component; transparency keeps the centered panel seamless.
opaque = false
private val historyButton = ShowHistoryButton().apply {
addActionListener { history() }
}
private val welcomeLabel = JBLabel(welcomeHtml()).apply {
foreground = UIUtil.getContextHelpForeground()
set(KiloBundle.message("session.empty.welcome"))
horizontalAlignment = JBLabel.CENTER
setAllowAutoWrapping(true)
}
private val description = object : BorderLayoutPanel() {
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
return Dimension(JBUI.scale(SessionUiStyle.RecentSessions.DESCRIPTION_WIDTH), size.height)
}
override fun getMaximumSize(): Dimension {
val size = super.getMaximumSize()
return Dimension(JBUI.scale(SessionUiStyle.RecentSessions.DESCRIPTION_WIDTH), size.height)
}
}.apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Gap.lg(), 0, UiStyle.Gap.lg(), 0)
add(welcomeLabel, BorderLayout.CENTER)
}
private val content = createContent()
init {
Disposer.register(parent, this)
// The empty state floats on the tool-window background.
isOpaque = false
border = UiStyle.Insets.empty()
applyStyle(SessionStyle.current())
applyStyle(SessionEditorStyle.current())
setSessions(recents)
add(Centerizer(content, Centerizer.TYPE.BOTH), BorderLayout.CENTER)
val gap = UiStyle.Gap.pad()
layout = BorderLayout(0, gap)
val logo = JBLabel(
IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java),
).apply {
horizontalAlignment = JBLabel.CENTER
}
val header = BorderLayoutPanel(0, gap).apply {
isOpaque = false
add(logo, BorderLayout.NORTH)
add(CenterShrinkPanel(description), BorderLayout.CENTER)
}
val recent = BorderLayoutPanel().apply {
isOpaque = false
add(recentTitle, BorderLayout.NORTH)
add(list, BorderLayout.CENTER)
}
val south = BorderLayoutPanel().apply {
isOpaque = false
add(Centerizer(historyButton, Centerizer.TYPE.HORIZONTAL), BorderLayout.CENTER)
}
add(header, BorderLayout.NORTH)
add(recent, BorderLayout.CENTER)
add(south, BorderLayout.SOUTH)
}
private fun setSessions(sessions: List<SessionDto>) {
model.clear()
sessions.take(LIMIT).forEach(model::addElement)
sessions.take(SessionUiStyle.RecentSessions.LIMIT).map(::LocalHistoryItem).forEach(model::addElement)
revalidate()
repaint()
}
private fun createContent(): BorderLayoutPanel {
val logo = JBLabel(
IconLoader.getIcon("/icons/kilo-content.svg", EmptySessionPanel::class.java),
).apply {
alignmentX = CENTER_ALIGNMENT
}
val intro = BorderLayoutPanel().apply {
alignmentX = CENTER_ALIGNMENT
add(md.component, BorderLayout.CENTER)
border = JBUI.Borders.empty(0, UiStyle.Space.PAD, 0, UiStyle.Space.PAD)
}
val recent = BorderLayoutPanel().apply {
alignmentX = CENTER_ALIGNMENT
add(recentTitle, BorderLayout.NORTH)
add(list, BorderLayout.CENTER)
}
val stack = BorderLayoutPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
add(logo)
add(Box.createVerticalStrut(JBUI.scale(UiStyle.Space.LOGO)))
add(intro)
add(Box.createVerticalStrut(JBUI.scale(UiStyle.Space.RECENT)))
add(recent)
}
return object : BorderLayoutPanel() {
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
return JBDimension(JBUI.scale(MAX_WIDTH), size.height)
}
}.apply {
add(stack, BorderLayout.NORTH)
}
}
internal fun recentCount() = model.size()
internal fun selectRecent(index: Int) {
@@ -153,14 +169,32 @@ class EmptySessionPanel(
internal fun clickRecent(index: Int) {
list.selectedIndex = index
controller.openSession(model.getElementAt(index))
controller.openSession(SessionRef.Local(model.getElementAt(index).session))
}
internal fun clickShowHistory() {
historyButton.doClick()
}
internal fun showHistoryText() = historyButton.text
internal fun showHistoryBorderPainted() = historyButton.isBorderPainted
internal fun showHistoryCursor() = historyButton.cursor.type
internal fun recentCursor() = list.cursor.type
internal fun recentVisible() = true
internal fun explanationMarkdown() = md.markdown()
internal fun explanationText() = KiloBundle.message("session.empty.welcome")
internal fun contentPreferredSize() = content.preferredSize
internal fun welcomeLabelAlignment() = welcomeLabel.horizontalAlignment
internal fun descriptionPreferredSize() = description.preferredSize
internal fun descriptionMaximumSize() = description.maximumSize
internal fun historyButtonPreferredWidth() = historyButton.preferredSize.width
internal fun initialized() = true
@@ -168,13 +202,8 @@ class EmptySessionPanel(
internal fun activeView() = getComponent(0)
internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) = time(session, now)
internal fun normalize(value: Double): Long {
val raw = value.toLong()
if (abs(raw) < SECOND_MS_LIMIT) return raw * 1000
return raw
}
internal fun text(session: SessionDto, now: Long = System.currentTimeMillis()) =
HistoryTime.relative(LocalHistoryItem(session), now)
internal fun rendererComponent(
session: SessionDto,
@@ -183,32 +212,33 @@ class EmptySessionPanel(
): Component {
val old = this.hover
this.hover = if (hover) 0 else -1
return list.cellRenderer.getListCellRendererComponent(list, session, 0, selected, false).also {
return list.cellRenderer.getListCellRendererComponent(list, LocalHistoryItem(session), 0, selected, false).also {
this.hover = old
}
}
private fun row(e: MouseEvent): Int {
val index = list.locationToIndex(e.point)
if (index < 0) return -1
val box = list.getCellBounds(index, index) ?: return -1
private fun index(e: MouseEvent): Int {
val idx = list.locationToIndex(e.point)
if (idx < 0) return -1
val box = list.getCellBounds(idx, idx) ?: return -1
if (!box.contains(e.point)) return -1
return index
return idx
}
private inner class SessionRenderer : BorderLayoutPanel(), ListCellRenderer<SessionDto> {
private inner class SessionRenderer : BorderLayoutPanel(), ListCellRenderer<LocalHistoryItem> {
private val title = JBLabel()
private val time = JBLabel()
init {
border = JBUI.Borders.empty(UiStyle.Space.LG, UiStyle.Space.LG, UiStyle.Space.LG, UiStyle.Space.LG)
layout = BorderLayout(UiStyle.Gap.pad(), 0)
border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg())
add(title, BorderLayout.CENTER)
add(time, BorderLayout.EAST)
}
override fun getListCellRendererComponent(
list: JList<out SessionDto>,
value: SessionDto?,
list: JList<out LocalHistoryItem>,
value: LocalHistoryItem?,
index: Int,
selected: Boolean,
focus: Boolean,
@@ -219,32 +249,67 @@ class EmptySessionPanel(
title.foreground = if (active) list.selectionForeground else UIUtil.getLabelForeground()
time.foreground = if (active) list.selectionForeground else UIUtil.getContextHelpForeground()
title.text = value?.let(::title) ?: ""
time.text = value?.let { time(it) } ?: ""
time.text = value?.let(HistoryTime::relative) ?: ""
return this
}
}
private fun title(session: SessionDto) =
session.title.takeIf { it.isNotBlank() } ?: KiloBundle.message("session.tab.untitled")
private inner class ShowHistoryButton : JButton(KiloBundle.message("session.showHistory"), AllIcons.Vcs.History) {
private var over = false
private fun time(session: SessionDto, now: Long = System.currentTimeMillis()): String {
val ms = normalize(session.time.updated)
val diff = (now - ms).coerceAtLeast(0)
if (diff < MINUTE) return KiloBundle.message("session.empty.time.moments")
if (diff < HOUR) return KiloBundle.message("session.empty.time.minutes", (diff / MINUTE).coerceAtLeast(1))
if (diff < DAY) return KiloBundle.message("session.empty.time.hours", (diff / HOUR).coerceAtLeast(1))
return KiloBundle.message("session.empty.time.days", (diff / DAY).coerceAtLeast(1))
init {
isFocusable = false
setRequestFocusEnabled(false)
isContentAreaFilled = false
isBorderPainted = false
isOpaque = false
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
sync(true)
}
override fun mouseExited(e: MouseEvent) {
sync(false)
}
})
}
override fun paintComponent(g: Graphics) {
if (isEnabled && over) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground()
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
}
super.paintComponent(g)
}
private fun sync(value: Boolean) {
if (over == value) return
over = value
repaint()
}
}
override fun dispose() {
// no-op
}
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
md.font = style.uiFont
welcomeLabel.font = style.uiFont
recentTitle.font = style.smallUiFont
revalidate()
repaint()
}
private fun welcomeHtml() = XmlStringUtil.wrapInHtml(
"<div style='text-align:center'>${XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome"))}</div>"
)
}
@@ -0,0 +1,25 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.Centerizer
import java.awt.BorderLayout
import javax.swing.JPanel
class LoadingPanel : JPanel(BorderLayout()), SessionEditorStyleTarget {
private val label = JBLabel(KiloBundle.message("session.empty.loading"))
init {
isOpaque = false
add(Centerizer(label, Centerizer.TYPE.BOTH), BorderLayout.CENTER)
applyStyle(SessionEditorStyle.current())
}
override fun applyStyle(style: SessionEditorStyle) {
label.font = style.uiFont
revalidate()
repaint()
}
}
@@ -1,9 +1,9 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.session.ui.style.Dock
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.rpc.dto.PermissionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.ui.dsl.builder.RightGap
@@ -36,7 +36,7 @@ class PermissionPanel(
private lateinit var requestId: String
init {
border = UiStyle.Dock.warning()
border = Dock.warning()
isVisible = false
}
@@ -3,6 +3,8 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.ui.UiStyle
import com.intellij.openapi.Disposable
import com.intellij.ui.AnimatedIcon
@@ -15,7 +17,7 @@ import java.awt.FlowLayout
*
* Reacts to [SessionModelEvent.StateChanged]:
* - [SessionState.Busy] → shows an animated spinner and [SessionState.Busy.text]
* - Any other state → hidden
* - Any other state -> hidden
*
* Owned by [SessionMessageListPanel], which always re-anchors it as the last child so it
* appears below all turn views inside the scroll pane.
@@ -23,7 +25,7 @@ import java.awt.FlowLayout
class ProgressPanel(
model: SessionModel,
parent: Disposable,
) : SessionLayoutPanel(), SessionStyleTarget {
) : SessionLayoutPanel(), SessionEditorStyleTarget {
private val label = JBLabel().apply {
foreground = UiStyle.Colors.weak()
@@ -32,8 +34,8 @@ class ProgressPanel(
init {
isOpaque = false
isVisible = false
layout = FlowLayout(FlowLayout.LEFT, UiStyle.Gap.inline(), 0)
applyStyle(SessionStyle.current())
layout = FlowLayout(FlowLayout.LEFT, UiStyle.Gap.md(), 0)
applyStyle(SessionEditorStyle.current())
add(JBLabel(AnimatedIcon.Default()))
add(label)
@@ -52,13 +54,14 @@ class ProgressPanel(
label.text = state.text
isVisible = true
}
is SessionState.Loading -> isVisible = false
else -> isVisible = false
}
revalidate()
repaint()
}
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
label.font = style.uiFont
revalidate()
repaint()
@@ -1,9 +1,9 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.session.model.Question
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.session.ui.style.Dock
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.rpc.dto.QuestionReplyDto
import com.intellij.icons.AllIcons
import com.intellij.ui.dsl.builder.RightGap
@@ -35,7 +35,7 @@ class QuestionPanel(
private var requestId: String? = null
init {
border = UiStyle.Dock.neutral()
border = Dock.neutral()
isVisible = false
}
@@ -1,7 +1,7 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.PickerButton
import com.intellij.icons.AllIcons
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.ListPopup
@@ -21,7 +21,7 @@ import javax.swing.Icon
* opens a list popup above the picker. Disabled (greyed out, not
* clickable) when no items are loaded.
*/
class ReasoningPicker : UiStyle.Pickers.Label() {
class ReasoningPicker : PickerButton() {
private companion object {
val checked: Icon = AllIcons.Actions.Checked
@@ -1,7 +1,8 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.util.ui.JBDimension
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.Component
import java.awt.Container
@@ -29,8 +30,8 @@ import java.awt.LayoutManager
* the panel width and the layout always has a valid width to work with.
*/
class SessionLayout(
private val gap: Int = UiStyle.Gap.part(),
private val pad: Insets = UiStyle.Insets.none(),
private val gap: Int = JBUI.scale(SessionUiStyle.SessionLayout.GAP),
private val pad: Insets = JBUI.emptyInsets(),
) : LayoutManager {
override fun addLayoutComponent(name: String, comp: Component) = Unit
@@ -76,9 +77,9 @@ class SessionLayout(
private fun bounds(ins: Insets, width: Int, comp: Component): Bounds {
val view = comp as? SessionView ?: return Bounds(ins.left, width)
if (view.sessionViewKind != SessionView.Kind.UserPrompt) return Bounds(ins.left, width)
val shift = UiStyle.Insets.userPrompt()
val shift = JBUI.scale(SessionUiStyle.SessionLayout.USER_PROMPT_INDENT)
val next = width - shift
if (next < UiStyle.Size.userPromptMin()) return Bounds(ins.left, width)
if (next < JBUI.scale(SessionUiStyle.SessionLayout.USER_PROMPT_INDENT)) return Bounds(ins.left, width)
return Bounds(ins.left + shift, next)
}
@@ -103,8 +104,8 @@ class SessionLayout(
* [SessionLayout] a valid width to measure against.
*/
open class SessionLayoutPanel(
gap: Int = UiStyle.Gap.part(),
pad: Insets = UiStyle.Insets.none(),
gap: Int = JBUI.scale(SessionUiStyle.SessionLayout.GAP),
pad: Insets = JBUI.emptyInsets(),
) : BorderLayoutPanel(), javax.swing.Scrollable {
init {
layout = SessionLayout(gap, pad)
@@ -117,7 +118,7 @@ open class SessionLayoutPanel(
visibleRect: java.awt.Rectangle,
@Suppress("UNUSED_PARAMETER") orientation: Int,
@Suppress("UNUSED_PARAMETER") direction: Int,
): Int = UiStyle.Gap.scroll()
): Int = JBUI.scale(SessionUiStyle.SessionLayout.SCROLL_INCREMENT)
override fun getScrollableBlockIncrement(
visibleRect: java.awt.Rectangle,
@Suppress("UNUSED_PARAMETER") orientation: Int,
@@ -2,10 +2,13 @@ package ai.kilocode.client.session.ui
import ai.kilocode.client.session.model.SessionModel
import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.MessageView
import ai.kilocode.client.session.views.TurnView
import ai.kilocode.client.ui.UiStyle
import com.intellij.openapi.Disposable
import com.intellij.util.ui.JBUI
/**
* Scrollable transcript panel that maps the model's turn grouping to
@@ -31,12 +34,20 @@ import com.intellij.openapi.Disposable
class SessionMessageListPanel(
private val model: SessionModel,
parent: Disposable,
) : SessionLayoutPanel(UiStyle.Card.groupGap(), UiStyle.Insets.transcript()), SessionStyleTarget {
) : SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
JBUI.insets(
SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING,
SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING,
SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING,
SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING,
),
), SessionEditorStyleTarget {
private val turnViews = LinkedHashMap<String, TurnView>()
private val msgToTurn = HashMap<String, TurnView>()
private val msgToView = HashMap<String, MessageView>()
private var style = SessionStyle.current()
private var style = SessionEditorStyle.current()
/** Progress footer — always the last child inside the scroll. */
val progress = ProgressPanel(model, parent)
@@ -240,7 +251,7 @@ class SessionMessageListPanel(
repaint()
}
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
for (view in turnViews.values) view.applyStyle(style)
progress.applyStyle(style)
@@ -2,7 +2,7 @@ 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.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.UiStyle
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
@@ -15,14 +15,14 @@ import java.awt.RenderingHints
import javax.swing.JComponent
import javax.swing.JPanel
internal class ContextBar : JPanel(BorderLayout(UiStyle.Gap.inline(), 0)) {
internal class ContextBar : JPanel(BorderLayout(UiStyle.Gap.md(), 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)
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
add(used, BorderLayout.WEST)
add(meter, BorderLayout.CENTER)
add(limit, BorderLayout.EAST)
@@ -40,7 +40,7 @@ internal class ContextBar : JPanel(BorderLayout(UiStyle.Gap.inline(), 0)) {
repaint()
}
fun applyStyle(style: SessionStyle) {
fun applyStyle(style: SessionEditorStyle) {
background = style.editorBackground
foreground = style.editorForeground
meter.background = style.editorBackground
@@ -143,7 +143,7 @@ private class Meter : JComponent() {
fun reservedColor(): Color = shade(0.28f)
private fun shade(alpha: Float): Color {
val base = background ?: UiStyle.Colors.panel()
val base = background ?: UiStyle.Colors.editorBackground()
val grey = if (UiStyle.Colors.bright(base)) Color.BLACK else Color.WHITE
return UiStyle.Colors.blend(base, grey, alpha)
}
@@ -3,9 +3,10 @@ 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.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.rpc.dto.TokensDto
import com.intellij.ide.util.PropertiesComponent
@@ -33,7 +34,7 @@ import javax.swing.SwingUtilities
class SessionHeaderPanel(
private val controller: SessionController,
parent: Disposable,
) : BorderLayoutPanel(), SessionStyleTarget {
) : BorderLayoutPanel(), SessionEditorStyleTarget {
companion object {
private val COMPRESS_ICON: Icon = IconLoader.getIcon("/icons/compress.svg", SessionHeaderPanel::class.java)
@@ -51,13 +52,13 @@ class SessionHeaderPanel(
private val cost = JBLabel()
private val context = JBLabel()
private val todos = JBLabel()
private val compact = UiStyle.Buttons.HoverIcon().apply {
private val compact = 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 {
private val expand = HoverIcon().apply {
icon = CHEVRON_ICON
toolTipText = KiloBundle.message("session.header.expand")
accessibleContext.accessibleName = KiloBundle.message("session.header.expand")
@@ -90,7 +91,7 @@ class SessionHeaderPanel(
iconTextGap = UiStyle.Gap.xs()
}
private val top = BorderLayoutPanel()
private val right = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.inline(), 0)).apply {
private val right = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.md(), 0)).apply {
isOpaque = false
add(cost)
add(context)
@@ -99,32 +100,32 @@ class SessionHeaderPanel(
}
private val tokens = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Space.SM, 0, 0, 0)
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
add(tokenTitle)
add(Box.createHorizontalStrut(UiStyle.Gap.inline()))
add(Box.createHorizontalStrut(UiStyle.Gap.md()))
add(input)
add(Box.createHorizontalStrut(UiStyle.Gap.small()))
add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
add(output)
add(Box.createHorizontalStrut(UiStyle.Gap.small()))
add(Box.createHorizontalStrut(UiStyle.Gap.sm()))
add(cacheRead)
add(Box.createHorizontalStrut(UiStyle.Gap.small()))
add(cacheWrite)
}
private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.inline(), 0)).apply {
private val todoRow = JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.md(), 0)).apply {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Space.SM, 0, 0, 0)
border = JBUI.Borders.empty(UiStyle.Gap.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)
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
add(viewport)
add(tokens)
add(bar)
add(todoRow)
}
private var style = SessionStyle.current()
private var style = SessionEditorStyle.current()
init {
isOpaque = true
@@ -184,7 +185,7 @@ class SessionHeaderPanel(
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),
JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.sm(), UiStyle.Gap.pad()),
)
}
@@ -218,7 +219,7 @@ class SessionHeaderPanel(
refresh()
}
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
background = style.editorBackground
foreground = style.editorForeground
@@ -9,7 +9,7 @@ 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 ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.util.ui.JBUI
import java.awt.Color
import java.awt.Dimension
@@ -138,14 +138,14 @@ internal class TimelinePanel : JPanel() {
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
if (part is Tool && part.state == ToolExecState.ERROR) return SessionUiStyle.Timeline.ERROR
if (part is Text) return SessionUiStyle.Timeline.TEXT
if (part is Reasoning) return SessionUiStyle.Timeline.TEXT
if (part is Compaction) return SessionUiStyle.Timeline.STEP
if (part is StepFinish) return SessionUiStyle.Timeline.SUCCESS
if (part !is Tool) return SessionUiStyle.Timeline.STEP
if (part.kind == ToolKind.READ) return SessionUiStyle.Timeline.READ
if (part.kind == ToolKind.WRITE) return SessionUiStyle.Timeline.WRITE
return SessionUiStyle.Timeline.TOOL
}
}
@@ -1,7 +1,7 @@
package ai.kilocode.client.session.ui.mode
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.PickerButton
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.PopupShowOptions
import java.awt.Cursor
@@ -9,7 +9,7 @@ import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.ListSelectionModel
class ModePicker : UiStyle.Pickers.Label() {
class ModePicker : PickerButton() {
data class Item(
val id: String,
@@ -32,37 +32,26 @@ internal class ModePickerRenderer(
private val icon = JBLabel().apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
UiStyle.Components.transparent(this)
}
private val title = SimpleColoredComponent().apply {
UiStyle.Components.transparent(this)
}
private val desc = SimpleColoredComponent().apply {
UiStyle.Components.transparent(this)
}
private val badge = JBLabel(KiloBundle.message("mode.picker.deprecated")).apply {
UiStyle.Components.transparent(this)
}
private val title = SimpleColoredComponent()
private val desc = SimpleColoredComponent()
private val badge = JBLabel(KiloBundle.message("mode.picker.deprecated"))
private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
UiStyle.Components.transparent(this)
add(title)
add(badge)
}
private val body = JPanel(BorderLayout()).apply {
UiStyle.Components.transparent(this)
}
private val body = JPanel(BorderLayout())
private val row = JPanel(BorderLayout())
private val wrap = PickerRow()
init {
UiStyle.Components.transparent(this)
UiStyle.Components.transparent(row)
(row.layout as BorderLayout).hgap = UiStyle.Gap.inline()
UiStyle.Components.transparent(this, icon, title, desc, badge, head, body, row)
(row.layout as BorderLayout).hgap = UiStyle.Gap.md()
row.border = JBUI.Borders.empty(
UiStyle.Space.MD,
UiStyle.Space.LG,
UiStyle.Space.MD,
UiStyle.Space.LG,
UiStyle.Gap.md(),
UiStyle.Gap.lg(),
UiStyle.Gap.md(),
UiStyle.Gap.lg(),
)
body.add(head, BorderLayout.NORTH)
body.add(desc, BorderLayout.CENTER)
@@ -82,7 +71,7 @@ internal class ModePickerRenderer(
val focus = selected || list.hasFocus() || focused
val fg = UIUtil.getListForeground(selected, focus)
val weak = if (selected) fg else UiStyle.Colors.weak()
val warn = if (selected) fg else UiStyle.Colors.warning()
val warn = if (selected) fg else UiStyle.Colors.warningLabelForeground()
background = list.background
wrap.update(list, selected, focus)
@@ -98,8 +87,8 @@ internal class ModePickerRenderer(
badge.border = JBUI.Borders.compound(
JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap()),
JBUI.Borders.compound(
RoundedLineBorder(warn, JBUI.scale(UiStyle.Space.SM)),
JBUI.Borders.empty(0, UiStyle.Space.MD),
RoundedLineBorder(warn, UiStyle.Gap.sm()),
JBUI.Borders.empty(0, UiStyle.Gap.md()),
),
)
icon.icon = icon(value)
@@ -1,7 +1,7 @@
package ai.kilocode.client.session.ui.model
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.PickerButton
import ai.kilocode.rpc.dto.ModelSelectionDto
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.popup.JBPopupFactory
@@ -45,7 +45,7 @@ private const val MODEL_PICKER_MAX_WIDTH = 760
private const val MODEL_PICKER_MAX_VISIBLE_ROWS = 10
private const val MODEL_PICKER_EMPTY_LIST_HEIGHT = 120
class ModelPicker : UiStyle.Pickers.Label() {
class ModelPicker : PickerButton() {
data class Item(
val id: String,
@@ -93,17 +93,12 @@ internal class ModelPickerRenderer(
init {
isOpaque = true
top.isOpaque = true
UiStyle.Components.transparent(row)
UiStyle.Components.transparent(check)
UiStyle.Components.transparent(title)
UiStyle.Components.transparent(head)
UiStyle.Components.transparent(provider)
UiStyle.Components.transparent(star)
UiStyle.Components.transparent(row, check, title, head, provider, star)
row.border = JBUI.Borders.empty(
UiStyle.Space.MD,
UiStyle.Space.LG,
UiStyle.Space.MD,
UiStyle.Space.LG + UiStyle.Space.SM,
UiStyle.Gap.md(),
UiStyle.Gap.lg(),
UiStyle.Gap.md(),
UiStyle.Gap.pad(),
)
wrap.setContent(row)
add(top, BorderLayout.NORTH)
@@ -4,11 +4,14 @@ import ai.kilocode.client.actions.SendPromptAction
import ai.kilocode.client.actions.StopSessionAction
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.ReasoningPicker
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.session.ui.SessionStyleTarget
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.ui.mode.ModePicker
import ai.kilocode.client.session.ui.model.ModelPicker
import ai.kilocode.client.ui.HoverIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.iconButton
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.log.KiloLog
import com.intellij.icons.AllIcons
@@ -58,21 +61,19 @@ class PromptPanel(
private val project: Project,
private val onSend: (String) -> Unit,
private val onAbort: () -> Unit,
) : BorderLayoutPanel(), SessionStyleTarget, SendPromptContext {
) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext {
companion object {
private val LOG = KiloLog.create(PromptPanel::class.java)
private val SEND_ICON: Icon = IconLoader.getIcon("/icons/send.svg", PromptPanel::class.java)
private val STOP_ICON: Icon = IconLoader.getIcon("/icons/stop.svg", PromptPanel::class.java)
private const val ARC = 6
private const val FOCUS = 2
}
val mode = ModePicker()
val model = ModelPicker()
val reasoning = ReasoningPicker()
var onReset: () -> Unit = {}
private var style = SessionStyle.current()
private var style = SessionEditorStyle.current()
private val shell = PromptShell()
private var bus: MessageBusConnection? = null
@@ -121,7 +122,7 @@ class PromptPanel(
}
}
private val reset = UiStyle.Buttons.HoverIcon().apply {
private val reset = HoverIcon().apply {
icon = AllIcons.Actions.Cancel
toolTipText = KiloBundle.message("model.picker.reset")
accessibleContext.accessibleName = KiloBundle.message("model.picker.reset")
@@ -142,7 +143,12 @@ class PromptPanel(
init {
border = JBUI.Borders.compound(
JBUI.Borders.customLineTop(JBUI.CurrentTheme.ToolWindow.borderColor()),
UiStyle.Insets.prompt(),
JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING),
),
)
applyStyle(style)
@@ -151,14 +157,14 @@ class PromptPanel(
val bar = BorderLayoutPanel().apply {
layout = BoxLayout(this, BoxLayout.X_AXIS)
isOpaque = false
border = JBUI.Borders.emptyTop(UiStyle.Space.SM)
border = JBUI.Borders.emptyTop(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP))
}
bar.add(mode)
bar.add(Box.createHorizontalStrut(UiStyle.Gap.small()))
bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP)))
bar.add(model)
bar.add(Box.createHorizontalStrut(UiStyle.Gap.small()))
bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP)))
bar.add(reasoning)
bar.add(Box.createHorizontalStrut(UiStyle.Gap.small()))
bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP)))
bar.add(reset)
bar.add(Box.createHorizontalGlue())
bar.add(button)
@@ -206,13 +212,13 @@ class PromptPanel(
internal val defaultFocusedComponent: JComponent get() = editor
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
editor.font = style.transcriptFont
editor.getEditor(false)?.let(style::applyToEditor)
editor.background = style.editorScheme.defaultBackground
val height = style.transcriptFont.size * UiStyle.Size.LINES + JBUI.scale(
UiStyle.Size.CHROME)
val height = style.transcriptFont.size * SessionUiStyle.View.Prompt.EDITOR_LINES + JBUI.scale(
SessionUiStyle.View.Prompt.EDITOR_CHROME)
editor.preferredSize = JBDimension(0, height)
editor.minimumSize = JBDimension(0, height)
revalidate()
@@ -303,7 +309,7 @@ class PromptPanel(
private var over = false
init {
UiStyle.Buttons.icon(this)
iconButton(this)
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
@@ -316,7 +322,10 @@ class PromptPanel(
})
}
override fun getPreferredSize() = JBUI.size(UiStyle.Size.BUTTON, UiStyle.Size.BUTTON)
override fun getPreferredSize() = JBUI.size(
SessionUiStyle.View.Prompt.SEND_BUTTON_SIZE,
SessionUiStyle.View.Prompt.SEND_BUTTON_SIZE,
)
override fun uiDataSnapshot(sink: DataSink) {
sink.set(PromptDataKeys.SEND, this@PromptPanel)
@@ -332,7 +341,7 @@ class PromptPanel(
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground()
val arc = JBUI.scale(JBUI.getInt("Button.arc", ARC))
val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
@@ -349,17 +358,23 @@ class PromptPanel(
}
private inner class PromptShell : BorderLayoutPanel() {
private val arc = JBValue.UIInteger("Button.arc", ARC)
private val focus = JBValue.UIInteger("Component.focusWidth", FOCUS)
private val arc = JBValue.UIInteger("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)
private val focus = JBValue.UIInteger("Component.focusWidth", SessionUiStyle.View.Prompt.FOCUS_WIDTH)
init {
isOpaque = false
border = JBUI.Borders.empty(UiStyle.Space.MD, UiStyle.Space.LG)
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
)
}
override fun updateUI() {
super.updateUI()
border = JBUI.Borders.empty(UiStyle.Space.MD, UiStyle.Space.LG)
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
)
}
override fun paintComponent(g: Graphics) {
@@ -376,7 +391,7 @@ class PromptPanel(
g2.color = if (active) {
JBUI.CurrentTheme.Focus.focusColor()
} else {
UiStyle.Colors.line()
SessionUiStyle.View.line()
}
val bw = if (active) focus.get() else JBUI.scale(1)
for (idx in 0 until bw) {
@@ -1,4 +1,4 @@
package ai.kilocode.client.session.ui
package ai.kilocode.client.session.ui.style
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.openapi.editor.colors.EditorColorsScheme
@@ -9,8 +9,13 @@ import java.awt.Color
import java.awt.Font
import kotlin.math.roundToInt
/** Snapshot of editor-derived transcript styling applied to live session components. */
data class SessionStyle(
/**
* Immutable snapshot of editor-derived fonts and colors for transcript components.
*
* Session UI uses this instead of reading editor globals in every component so font and color changes can be applied
* consistently through [SessionEditorStyleTarget].
*/
data class SessionEditorStyle(
val editorScheme: EditorColorsScheme,
val editorFamily: String,
val editorSize: Int,
@@ -30,7 +35,8 @@ data class SessionStyle(
}
companion object {
fun current(): SessionStyle {
/** Builds a style snapshot from the current global editor color scheme. */
fun current(): SessionEditorStyle {
val scheme = EditorColorsManager.getInstance().globalScheme
return create(scheme, scheme.editorFontName, scheme.editorFontSize)
}
@@ -39,11 +45,11 @@ data class SessionStyle(
scheme: EditorColorsScheme = EditorColorsManager.getInstance().globalScheme,
family: String = scheme.editorFontName,
size: Int = scheme.editorFontSize,
): SessionStyle {
): SessionEditorStyle {
val small = scaledSize(size, JBFont.small())
val ui = JBUI.Fonts.label().deriveFont(size.toFloat())
val smallUi = JBFont.small().deriveFont(small.toFloat())
return SessionStyle(
return SessionEditorStyle(
editorScheme = scheme,
editorFamily = family,
editorSize = size,
@@ -66,7 +72,7 @@ data class SessionStyle(
}
}
/** Implemented by session components that can update styling in place. */
interface SessionStyleTarget {
fun applyStyle(style: SessionStyle)
/** Session component contract for applying a refreshed [SessionEditorStyle] without rebuilding Swing nodes. */
interface SessionEditorStyleTarget {
fun applyStyle(style: SessionEditorStyle)
}
@@ -0,0 +1,124 @@
package ai.kilocode.client.session.ui.style
import ai.kilocode.client.ui.UiStyle
import com.intellij.ui.JBColor
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.JBUI.Borders.customLine
import com.intellij.util.ui.UIUtil
import java.awt.Color
import javax.swing.border.Border
/** Static style tokens owned by the chat session UI. */
object SessionUiStyle {
/** Geometry for the transcript list and its scroll behavior. */
object SessionLayout {
const val GAP = 4
const val TRANSCRIPT_PADDING = 12
const val USER_PROMPT_INDENT = 100
const val SCROLL_INCREMENT = 16
}
/** Shared tokens for individual transcript views and cards. */
object View {
const val CARD_LAYOUT_GAP = 6
const val CARD_VERTICAL_PADDING = 8
const val CARD_HORIZONTAL_PADDING = 12
const val CARD_BODY_EXTRA_HEIGHT = 16
internal const val BORDER_DELTA = 64
internal const val HOVER_ALPHA = 0.35f
/** Creates a visible separator against editor-derived transcript surfaces. */
fun line(): Color = JBColor.lazy { UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) }
fun surface(): Color = UiStyle.Colors.editorBackground()
fun header(): Color = UiStyle.Colors.editorBackground()
/** Local hover color for collapsible transcript card headers. */
fun headerHover(): Color = JBColor.lazy { UiStyle.Colors.blend(header(), line(), HOVER_ALPHA) }
fun card(): Border = cardBorder()
fun cardBorder(): Border = JBUI.Borders.customLine(line(), 1)
fun cardTop(): Border = JBUI.Borders.customLineTop(line())
/** Prompt input dimensions and chrome inside the session view. */
object Prompt {
const val EDITOR_LINES = 3
const val EDITOR_CHROME = 16
const val SEND_BUTTON_SIZE = 24
const val CORNER_ARC = 6
const val FOCUS_WIDTH = 2
const val PANEL_VERTICAL_PADDING = 8
const val PANEL_HORIZONTAL_PADDING = 12
const val CONTROL_GAP = 4
const val SHELL_VERTICAL_PADDING = 6
const val SHELL_HORIZONTAL_PADDING = 8
}
/** Reasoning block preview sizing. */
object Reasoning {
const val BODY_LINES = 5
}
/** Message container roles and user bubble geometry. */
object Message {
const val USER_ROLE = "user"
const val ASSISTANT_ROLE = "assistant"
const val USER_BORDER_ARC = 8
const val USER_BORDER_VERTICAL_PADDING = 8
const val USER_BORDER_HORIZONTAL_PADDING = 12
}
/** Tool card preview limits and state colors. */
object Tool {
const val BODY_LINES = 15
const val PREVIEW_LIMIT = 20_000
fun pending(): Color = UiStyle.Colors.weak()
fun running(): Color = UiStyle.Colors.fg()
fun completed(): Color = UiStyle.Colors.weak()
fun error(): Color = UiStyle.Colors.errorLabelForeground()
}
}
/** Limits for the empty-state recent sessions list. */
object RecentSessions {
const val LIMIT = 5
const val DESCRIPTION_WIDTH = 250
}
/** Colors for timeline/activity indicators in the session header. */
object Timeline {
val READ: Color = JBColor(Color(0x37, 0x94, 0xff), Color(0x37, 0x94, 0xff))
val WRITE: Color = JBColor(Color(0x00, 0x7f, 0xd4), Color(0x00, 0x7f, 0xd4))
val TOOL: Color = JBColor(Color(0x00, 0x7a, 0xcc), Color(0x00, 0x7a, 0xcc))
val SUCCESS: Color = JBColor.namedColor("Label.successForeground", UIUtil.getLabelSuccessForeground())
val ERROR: Color = JBColor(Color(0xf4, 0x87, 0x71), Color(0xf4, 0x87, 0x71))
val TEXT: Color = JBColor(Color(0x9d, 0x9d, 0x9d), Color(0x9d, 0x9d, 0x9d))
val STEP: Color = JBColor(Color(0x4d, 0x4d, 0x4d), Color(0x4d, 0x4d, 0x4d))
}
}
/** Border presets for question, permission, and connection dock panels. */
object Dock {
fun banner(): Border = JBUI.Borders.compound(
JBUI.Borders.customLineTop(SessionUiStyle.View.line()),
JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.lg(), 0, UiStyle.Gap.lg()),
)!!
fun neutral(): Border = JBUI.Borders.compound(
JBUI.Borders.customLine(SessionUiStyle.View.line(), 1),
JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()),
)!!
fun warning(): Border = JBUI.Borders.compound(
customLine(UiStyle.Colors.warningLabelForeground(), 1),
JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad()),
)!!
}
@@ -3,7 +3,8 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Compaction
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import com.intellij.ui.components.JBLabel
import java.awt.BorderLayout
@@ -29,16 +30,16 @@ class CompactionView(@Suppress("UNUSED_PARAMETER") compaction: Compaction) : Par
private val text = JBLabel(KiloBundle.message("session.part.compaction")).apply {
foreground = UiStyle.Colors.weak()
horizontalAlignment = SwingConstants.CENTER
border = JBUI.Borders.empty(0, UiStyle.Gap.regular())
border = JBUI.Borders.empty(0, UiStyle.Gap.lg())
}
init {
layout = BorderLayout()
isOpaque = false
applyStyle(SessionStyle.current())
applyStyle(SessionEditorStyle.current())
val line = { JPanel().apply {
background = UiStyle.Colors.line()
background = SessionUiStyle.View.line()
isOpaque = true
preferredSize = JBDimension(0, JBUI.scale(1))
} }
@@ -65,7 +66,7 @@ class CompactionView(@Suppress("UNUSED_PARAMETER") compaction: Compaction) : Par
override fun update(content: Content) {} // compaction has no mutable state
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
if (text.font == style.smallUiFont) return
text.font = style.smallUiFont
revalidate()
@@ -2,7 +2,7 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Generic
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.UiStyle
import com.intellij.ui.components.JBLabel
import java.awt.BorderLayout
@@ -20,13 +20,13 @@ class GenericView(content: Generic) : PartView() {
private val label = JBLabel("[${content.type}]").apply {
foreground = UiStyle.Colors.weak()
border = com.intellij.util.ui.JBUI.Borders.empty(UiStyle.Space.XS, 0)
border = com.intellij.util.ui.JBUI.Borders.empty(UiStyle.Gap.xs(), 0)
}
init {
layout = BorderLayout()
isOpaque = false
applyStyle(SessionStyle.current())
applyStyle(SessionEditorStyle.current())
add(label, BorderLayout.CENTER)
}
@@ -35,7 +35,7 @@ class GenericView(content: Generic) : PartView() {
/** Exposed for tests. */
fun labelText(): String = label.text
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
if (label.font == style.smallUiFont) return
label.font = style.smallUiFont
revalidate()
@@ -4,9 +4,11 @@ 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
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.ui.RoundedLineBorder
import com.intellij.util.ui.JBUI
/**
* A single message container inside a [TurnView].
@@ -21,24 +23,26 @@ import ai.kilocode.client.ui.UiStyle
*/
class MessageView(
val msg: Message,
private var style: SessionStyle = SessionStyle.current(),
) : ai.kilocode.client.session.ui.SessionLayoutPanel(UiStyle.Card.groupGap()), SessionStyleTarget, SessionView {
private var style: SessionEditorStyle = SessionEditorStyle.current(),
) : ai.kilocode.client.session.ui.SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
), SessionEditorStyleTarget, SessionView {
constructor(msg: Message) : this(msg, SessionStyle.current())
constructor(msg: Message) : this(msg, SessionEditorStyle.current())
val role: String get() = msg.info.role
override val sessionViewKind: SessionView.Kind
get() = if (role == "user") SessionView.Kind.UserPrompt else SessionView.Kind.Default
get() = if (role == SessionUiStyle.View.Message.USER_ROLE) SessionView.Kind.UserPrompt else SessionView.Kind.Default
private val parts = LinkedHashMap<String, PartView>()
init {
isOpaque = false
border = if (msg.info.role == "user") {
UiStyle.Borders.user()
border = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) {
userBorder()
} else {
UiStyle.Borders.assistant()
assistantBorder()
}
// Populate content that already exists (e.g. after loadHistory)
@@ -77,8 +81,8 @@ class MessageView(
}
private fun syncBorder() {
if (msg.info.role != "assistant") return
border = UiStyle.Borders.assistant()
if (msg.info.role != SessionUiStyle.View.Message.ASSISTANT_ROLE) return
border = assistantBorder()
}
/** Append a streaming delta to the renderer for [contentId]. */
@@ -97,7 +101,7 @@ class MessageView(
/** Compact dump for test assertions. */
fun dump(): String = parts.values.joinToString(", ") { it.dumpLabel() }
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
for (view in parts.values) view.applyStyle(style)
refresh()
@@ -107,4 +111,14 @@ class MessageView(
revalidate()
repaint()
}
private fun userBorder() = JBUI.Borders.compound(
RoundedLineBorder(SessionUiStyle.View.line(), JBUI.scale(SessionUiStyle.View.Message.USER_BORDER_ARC)),
JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Message.USER_BORDER_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Message.USER_BORDER_HORIZONTAL_PADDING),
),
)!!
private fun assistantBorder() = JBUI.Borders.empty()
}
@@ -1,8 +1,8 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.session.ui.SessionStyleTarget
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import javax.swing.JPanel
/**
@@ -14,7 +14,7 @@ import javax.swing.JPanel
*
* All methods must be called on the EDT.
*/
abstract class PartView : JPanel(), SessionStyleTarget {
abstract class PartView : JPanel(), SessionEditorStyleTarget {
/** Stable [Content.id] this renderer was created for. */
abstract val contentId: String
@@ -32,7 +32,7 @@ abstract class PartView : JPanel(), SessionStyleTarget {
*/
open fun appendDelta(delta: String) {}
override fun applyStyle(style: SessionStyle) {}
override fun applyStyle(style: SessionEditorStyle) {}
/** Readable name for test dumps. */
open fun dumpLabel(): String = javaClass.simpleName
@@ -5,12 +5,14 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Reasoning
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.md.MdView
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Cursor
import java.awt.Dimension
@@ -33,21 +35,27 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
private val arrow = JBLabel()
private val body = TrackPanel().apply {
isOpaque = true
background = UiStyle.Colors.surface()
border = UiStyle.Card.bodyInsets()
background = SessionUiStyle.View.surface()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
private val scroll = JBScrollPane(body).apply {
border = UiStyle.Card.divider()
border = SessionUiStyle.View.cardTop()
isOpaque = true
background = UiStyle.Colors.surface()
viewport.background = UiStyle.Colors.surface()
background = SessionUiStyle.View.surface()
viewport.background = SessionUiStyle.View.surface()
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
}
private val header = JPanel(UiStyle.Card.layout()).apply {
private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = true
background = UiStyle.Colors.header()
border = UiStyle.Card.headerInsets()
background = SessionUiStyle.View.header()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
private val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply {
foreground = UiStyle.Colors.weak()
@@ -56,7 +64,7 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
foreground = UiStyle.Colors.weak()
}
private var style = SessionStyle.current()
private var style = SessionEditorStyle.current()
private var source = reasoning.content.toString()
private val click = object : MouseAdapter() {
@@ -80,9 +88,9 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
init {
layout = BorderLayout()
isOpaque = false
border = UiStyle.Card.border()
border = SessionUiStyle.View.card()
val left = JPanel(UiStyle.Card.layout()).apply {
val left = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = false
add(icon, BorderLayout.WEST)
add(title, BorderLayout.CENTER)
@@ -95,7 +103,7 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
it.addMouseListener(mouse)
}
applyStyle(SessionStyle.current())
applyStyle(SessionEditorStyle.current())
md.opaque = false
md.set(source)
body.add(md.component, BorderLayout.CENTER)
@@ -142,11 +150,11 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
internal fun horizontalPolicy() = scroll.horizontalScrollBarPolicy
internal fun bodyMaxRows() = UiStyle.Card.REASONING_LINES
internal fun bodyMaxRows() = SessionUiStyle.View.Reasoning.BODY_LINES
internal fun bodyCreated() = true
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
var changed = false
if (title.font != style.smallEditorFont) {
@@ -172,7 +180,7 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
}
private fun setHover(value: Boolean) {
val color = if (value) UiStyle.Colors.headerHover() else UiStyle.Colors.header()
val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
if (header.background?.rgb == color.rgb) return
header.background = color
header.repaint()
@@ -254,7 +262,7 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
}
private fun bodyMaxHeight(): Int = md.component.getFontMetrics(md.font).height * bodyMaxRows() +
UiStyle.Card.scrollChrome()
JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT)
override fun dumpLabel(): String {
val state = if (bodyVisible()) "open" else "closed"
@@ -270,7 +278,7 @@ private class TrackPanel : JPanel(BorderLayout()), Scrollable {
visibleRect: Rectangle,
orientation: Int,
direction: Int,
) = UiStyle.Gap.scroll()
) = JBUI.scale(SessionUiStyle.SessionLayout.SCROLL_INCREMENT)
override fun getScrollableBlockIncrement(
visibleRect: Rectangle,
orientation: Int,
@@ -2,7 +2,7 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.md.MdView
import java.awt.BorderLayout
@@ -20,7 +20,7 @@ class TextView(text: Text) : PartView() {
init {
layout = BorderLayout()
isOpaque = false
applyStyle(SessionStyle.current())
applyStyle(SessionEditorStyle.current())
add(md.component, BorderLayout.CENTER)
if (text.content.isNotEmpty()) md.set(text.content.toString())
}
@@ -40,7 +40,7 @@ class TextView(text: Text) : PartView() {
/** Current markdown source — used by tests to assert rendered content. */
fun markdown(): String = md.markdown()
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
val changed = md.font != style.transcriptFont || md.codeFont != style.editorFamily
if (md.font != style.transcriptFont) md.font = style.transcriptFont
if (md.codeFont != style.editorFamily) md.codeFont = style.editorFamily
@@ -6,12 +6,14 @@ import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
import com.intellij.xml.util.XmlStringUtil
import java.awt.BorderLayout
import java.awt.Color
@@ -34,17 +36,20 @@ class ToolView(tool: Tool) : PartView() {
override val contentId: String = tool.id
private var item = tool
private var style = SessionStyle.current()
private var style = SessionEditorStyle.current()
private val root = JPanel(BorderLayout()).apply {
isOpaque = true
background = UiStyle.Colors.surface()
border = UiStyle.Card.border()
background = SessionUiStyle.View.surface()
border = SessionUiStyle.View.card()
}
private val header = JPanel(UiStyle.Card.layout()).apply {
private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = true
background = UiStyle.Colors.header()
border = UiStyle.Card.headerInsets()
background = SessionUiStyle.View.header()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
private val glyph = JBLabel()
private val title = JBLabel()
@@ -55,7 +60,7 @@ class ToolView(tool: Tool) : PartView() {
foreground = UiStyle.Colors.weak()
}
private val arrow = JBLabel()
private val center = JPanel(UiStyle.Card.layout()).apply {
private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = false
}
private val controls: JComponent = Box.createHorizontalBox().apply {
@@ -69,14 +74,17 @@ class ToolView(tool: Tool) : PartView() {
lineWrap = true
wrapStyleWord = true
foreground = bodyColor()
background = UiStyle.Colors.surface()
border = UiStyle.Card.bodyInsets()
background = SessionUiStyle.View.surface()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
private val scroll = JBScrollPane(text).apply {
border = UiStyle.Card.divider()
border = SessionUiStyle.View.cardTop()
isOpaque = true
background = UiStyle.Colors.surface()
viewport.background = UiStyle.Colors.surface()
background = SessionUiStyle.View.surface()
viewport.background = SessionUiStyle.View.surface()
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
}
@@ -114,7 +122,7 @@ class ToolView(tool: Tool) : PartView() {
it.addMouseListener(click)
}
text.text = preview(item)
applyStyle(SessionStyle.current())
applyStyle(SessionEditorStyle.current())
add(root, BorderLayout.CENTER)
sync()
}
@@ -171,11 +179,11 @@ class ToolView(tool: Tool) : PartView() {
internal fun bodyWrap() = text.lineWrap
internal fun bodyMaxRows() = UiStyle.Card.LINES
internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES
internal fun bodyCreated() = true
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
var changed = false
changed = setFont(title, style.boldEditorFont) || changed
@@ -193,7 +201,7 @@ class ToolView(tool: Tool) : PartView() {
}
private fun setHover(value: Boolean) {
val color = if (value) UiStyle.Colors.headerHover() else UiStyle.Colors.header()
val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
if (same(header.background, color)) return
header.background = color
header.repaint()
@@ -279,10 +287,11 @@ class ToolView(tool: Tool) : PartView() {
repaint()
}
private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.error() else UiStyle.Colors.fg()
private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg()
private fun bodyMaxHeight(): Int {
return text.getFontMetrics(text.font).height * bodyMaxRows() + UiStyle.Card.scrollChrome()
return text.getFontMetrics(text.font).height * bodyMaxRows() +
JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT)
}
override fun dumpLabel() = "ToolView#$contentId(${labelText()})"
@@ -345,14 +354,14 @@ private fun setFont(component: JComponent, font: Font): Boolean {
private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb
private fun color(tool: Tool) = when (tool.state) {
ToolExecState.PENDING -> UiStyle.Colors.weak()
ToolExecState.RUNNING -> UiStyle.Colors.running()
ToolExecState.COMPLETED -> UiStyle.Colors.weak()
ToolExecState.ERROR -> UiStyle.Colors.error()
ToolExecState.PENDING -> SessionUiStyle.View.Tool.pending()
ToolExecState.RUNNING -> SessionUiStyle.View.Tool.running()
ToolExecState.COMPLETED -> SessionUiStyle.View.Tool.completed()
ToolExecState.ERROR -> SessionUiStyle.View.Tool.error()
}
private fun titleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) {
UiStyle.Colors.error()
UiStyle.Colors.errorLabelForeground()
} else {
UiStyle.Colors.fg()
}
@@ -475,7 +484,7 @@ private class Preview {
fun append(value: String): Preview {
if (cut) return this
val rem = UiStyle.Size.toolBodyLimit() - text.length
val rem = SessionUiStyle.View.Tool.PREVIEW_LIMIT - text.length
if (value.length <= rem) {
text.append(value)
return this
@@ -2,9 +2,10 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Message
import ai.kilocode.client.session.ui.SessionLayoutPanel
import ai.kilocode.client.session.ui.SessionStyle
import ai.kilocode.client.session.ui.SessionStyleTarget
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.util.ui.JBUI
/**
* Top-level transcript item representing one conversational turn.
@@ -17,10 +18,10 @@ import ai.kilocode.client.ui.UiStyle
*/
class TurnView(
val id: String,
private var style: SessionStyle = SessionStyle.current(),
) : SessionLayoutPanel(UiStyle.Card.groupGap()), SessionStyleTarget {
private var style: SessionEditorStyle = SessionEditorStyle.current(),
) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), SessionEditorStyleTarget {
constructor(id: String) : this(id, SessionStyle.current())
constructor(id: String) : this(id, SessionEditorStyle.current())
private val messages = LinkedHashMap<String, MessageView>()
@@ -53,7 +54,7 @@ class TurnView(
/** Compact dump for test assertions. */
fun dump(): String = messages.entries.joinToString(", ") { (id, mv) -> "${mv.role}#$id" }
override fun applyStyle(style: SessionStyle) {
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
for (view in messages.values) view.applyStyle(style)
revalidate()
@@ -0,0 +1,38 @@
package ai.kilocode.client.ui
import java.awt.Component
import java.awt.Dimension
import javax.swing.JPanel
/**
* Centers its single child and shrinks it to available space when needed.
* If available space is larger than the child's maximum size, the child is not expanded.
*/
class CenterShrinkPanel(child: Component) : JPanel(null) {
init {
isOpaque = false
add(child)
}
override fun doLayout() {
if (componentCount == 0) return
val child = getComponent(0)
val insets = getInsets()
val availW = width - insets.left - insets.right
val availH = height - insets.top - insets.bottom
val pref = child.preferredSize
val max = child.maximumSize
val w = minOf(pref.width, max.width, availW)
val h = minOf(pref.height, max.height, availH)
val x = insets.left + (availW - w) / 2
val y = insets.top + (availH - h) / 2
child.setBounds(x, y, w, h)
}
override fun getPreferredSize(): Dimension {
if (componentCount == 0) return super.getPreferredSize()
val pref = getComponent(0).preferredSize
val insets = getInsets()
return Dimension(pref.width + insets.left + insets.right, pref.height + insets.top + insets.bottom)
}
}
@@ -0,0 +1,65 @@
package ai.kilocode.client.ui
import com.intellij.util.ui.JBUI
import java.awt.Dimension
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JButton
class HoverIcon : JButton() {
private var over = false
init {
iconButton(this)
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
sync(true)
}
override fun mouseExited(e: MouseEvent) {
sync(false)
}
})
}
override fun getPreferredSize(): Dimension = JBUI.size(24, 24)
override fun getMinimumSize(): Dimension = preferredSize
override fun getMaximumSize(): Dimension = preferredSize
override fun paintComponent(g: Graphics) {
if (isEnabled && over) paintHover(g)
super.paintComponent(g)
}
private fun paintHover(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground()
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
}
private fun sync(value: Boolean) {
if (over == value) return
over = value
repaint()
}
}
fun iconButton(button: JButton) {
button.isFocusable = false
button.setRequestFocusEnabled(false)
button.isContentAreaFilled = false
button.isBorderPainted = false
button.isOpaque = false
button.border = JBUI.Borders.empty()
}
@@ -0,0 +1,65 @@
package ai.kilocode.client.ui
import com.intellij.ui.JBColor
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.UIManager
open class PickerButton : JBLabel() {
private var over = false
init {
border = pickerBorder()
background = picker()
// The custom rounded fill needs parent background around the corners.
isOpaque = false
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
sync(true)
}
override fun mouseExited(e: MouseEvent) {
sync(false)
}
})
}
override fun updateUI() {
super.updateUI()
border = pickerBorder()
background = picker()
}
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = if (isEnabled && over) JBUI.CurrentTheme.ActionButton.hoverBackground() else picker()
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
super.paintComponent(g)
}
private fun sync(value: Boolean) {
if (over == value) return
over = value
repaint()
}
private fun picker() = JBColor.lazy {
UIManager.getColor("ComboBoxButton.background")
?: UIManager.getColor("ComboBox.nonEditableBackground")
?: UIUtil.getPanelBackground()
}
private fun pickerBorder() = JBUI.Borders.empty(UiStyle.Gap.xs(), UiStyle.Gap.lg())
}
@@ -2,104 +2,47 @@ package ai.kilocode.client.ui
import com.intellij.openapi.editor.colors.EditorColorsManager
import com.intellij.ui.JBColor
import com.intellij.ui.RoundedLineBorder
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
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 java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.UIManager
import javax.swing.border.Border
/** Static UI tokens and helpers for JetBrains session Swing surfaces. */
/** Shared Swing style tokens that are not tied to one session component. */
object UiStyle {
object Size {
const val WIDTH = 350
const val LIMIT = 5
const val LINES = 3
const val CHROME = 16
const val BUTTON_WIDTH = 28
const val BUTTON = 24
const val SCROLL = 16
const val USER_PROMPT = 100
const val TOOL_BODY = 20_000
fun userPromptMin(): Int = JBUI.scale(USER_PROMPT)
/** DPI-aware spacing primitives used across custom Swing layouts. */
object Gap {
fun xs() = JBUI.scale(2)
fun toolBodyLimit(): Int = TOOL_BODY
}
fun md() = JBUI.scale(6)
object Space {
const val XS = 2
const val SM = 4
const val MD = 6
const val LG = 8
const val PAD = 12
const val LOGO = 14
const val RECENT = 28
fun lg() = JBUI.scale(8)
fun sm() = JBUI.scale(4)
fun pad() = JBUI.scale(12)
}
/** Theme-aware colors and color math used by multiple UI surfaces. */
object Colors {
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()
fun weak(): Color = UIUtil.getContextHelpForeground()
/** Creates a visible separator against editor-derived transcript surfaces. */
fun line(): Color = JBColor.lazy { contrast(panel(), BORDER_DELTA) }
fun surface(): Color = panel()
/** Uses the editor background so chat cards feel native beside editor content. */
fun panel(): Color = JBColor.lazy { EditorColorsManager.getInstance().globalScheme.defaultBackground }
fun editorBackground(): Color = JBColor.lazy { EditorColorsManager.getInstance().globalScheme.defaultBackground }
fun panelHover(): Color = JBColor.lazy { blend(panel(), line(), HOVER_ALPHA) }
fun errorLabelForeground(): Color = JBColor.namedColor("Label.errorForeground", UIUtil.getErrorForeground())
fun header(): Color = panel()
fun headerBar(): Color = JBUI.CurrentTheme.ToolWindow.headerBackground(false)
/** Local hover color for collapsible transcript card headers. */
fun headerHover(): Color = panelHover()
fun error(): Color = JBColor.namedColor("Label.errorForeground", UIUtil.getErrorForeground())
fun warning(): Color = JBColor.lazy {
fun warningLabelForeground(): Color = JBColor.lazy {
UIManager.getColor("Component.warningFocusColor")
?: UIManager.getColor("Label.warningForeground")
?: UIUtil.getContextHelpForeground()
}
fun running(): Color = JBColor.namedColor("ProgressBar.foreground", UIUtil.getLabelForeground())
fun picker(): Color = JBColor.lazy {
UIManager.getColor("ComboBoxButton.background")
?: UIManager.getColor("ComboBox.nonEditableBackground")
?: UIUtil.getPanelBackground()
}
fun pickerHover(): Color = JBUI.CurrentTheme.ActionButton.hoverBackground()
internal fun contrast(base: Color, delta: Int): Color {
val step = if (bright(base)) -delta else delta
return Color(
@@ -124,208 +67,10 @@ object UiStyle {
(color.red * 0.299 + color.green * 0.587 + color.blue * 0.114) >= 128
}
object Insets {
fun none(): java.awt.Insets = JBUI.emptyInsets()
fun transcript(): java.awt.Insets = JBUI.insets(Space.PAD, Space.PAD, Space.PAD, Space.PAD)
fun userPrompt(): Int = Size.userPromptMin()
fun empty(): Border = JBUI.Borders.empty(Space.PAD)
fun prompt(): Border = JBUI.Borders.empty(Space.LG, Space.PAD, Space.LG, Space.PAD)
fun header(): Border = JBUI.Borders.empty(Space.LG, Space.LG)
fun body(): Border = JBUI.Borders.empty(Space.LG, Space.PAD)
}
object Borders {
fun card(): Border = cardBorder()
fun cardBorder(): Border = JBUI.Borders.customLine(Colors.line(), 1)
fun cardTop(): Border = JBUI.Borders.customLineTop(Colors.line())
fun warning(): Border = JBUI.Borders.customLine(Colors.warning(), 1)
fun picker(): Border = JBUI.Borders.empty(Space.XS, Space.LG)
fun user(): Border = JBUI.Borders.compound(
RoundedLineBorder(Colors.line(), JBUI.scale(Space.LG)),
JBUI.Borders.empty(Space.LG, Space.PAD),
)!!
fun assistant(): Border = JBUI.Borders.empty()
}
/** Shared geometry for collapsible transcript cards such as tools and reasoning. */
object Card {
const val LINES = 15
const val REASONING_LINES = 5
fun layout(): BorderLayout = Gap.layout(Space.MD)
fun groupGap(): Int = Gap.small()
fun headerInsets(): Border = JBUI.Borders.empty(Space.LG, Space.PAD)
fun bodyInsets(): Border = JBUI.Borders.empty(Space.LG, Space.PAD)
fun border(): Border = Borders.card()
fun divider(): Border = Borders.cardTop()
fun scrollChrome(): Int = JBUI.scale(Size.CHROME)
}
object Dock {
fun banner(): Border = JBUI.Borders.compound(
JBUI.Borders.customLineTop(Colors.line()),
JBUI.Borders.empty(Space.SM, Space.LG, 0, Space.LG),
)!!
fun neutral(): Border = JBUI.Borders.compound(
JBUI.Borders.customLine(Colors.line(), 1),
JBUI.Borders.empty(Space.LG, Space.PAD),
)!!
fun warning(): Border = JBUI.Borders.compound(
Borders.warning(),
JBUI.Borders.empty(Space.LG, Space.PAD),
)!!
}
object Gap {
fun xs() = JBUI.scale(Space.XS)
fun inline() = JBUI.scale(Space.MD)
fun regular() = JBUI.scale(Space.LG)
fun small() = JBUI.scale(Space.SM)
fun turn() = JBUI.scale(Space.PAD)
fun part() = JBUI.scale(Space.SM)
fun scroll() = JBUI.scale(Size.SCROLL)
fun layout(gap: Int = Space.LG) = BorderLayout(JBUI.scale(gap), 0)
}
object Buttons {
class HoverIcon : JButton() {
private var over = false
init {
isFocusable = false
setRequestFocusEnabled(false)
isContentAreaFilled = false
isBorderPainted = false
isOpaque = false
border = JBUI.Borders.empty()
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
sync(true)
}
override fun mouseExited(e: MouseEvent) {
sync(false)
}
})
}
override fun getPreferredSize(): Dimension = JBUI.size(Size.BUTTON, Size.BUTTON)
override fun getMinimumSize(): Dimension = preferredSize
override fun getMaximumSize(): Dimension = preferredSize
override fun paintComponent(g: Graphics) {
if (isEnabled && over) paintHover(g)
super.paintComponent(g)
}
private fun paintHover(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground()
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
}
private fun sync(value: Boolean) {
if (over == value) return
over = value
repaint()
}
}
fun icon(button: JButton) {
button.isFocusable = false
button.setRequestFocusEnabled(false)
button.isContentAreaFilled = false
button.isBorderPainted = false
button.isOpaque = false
button.border = JBUI.Borders.empty()
}
}
object Pickers {
open class Label : JBLabel() {
private var over = false
init {
border = Borders.picker()
background = Colors.picker()
// The custom rounded fill needs parent background around the corners.
isOpaque = false
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
sync(true)
}
override fun mouseExited(e: MouseEvent) {
sync(false)
}
})
}
override fun updateUI() {
super.updateUI()
border = Borders.picker()
background = Colors.picker()
}
override fun paintComponent(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = if (isEnabled && over) Colors.pickerHover() else Colors.picker()
val arc = JBUI.scale(JBUI.getInt("Button.arc", 6))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
super.paintComponent(g)
}
private fun sync(value: Boolean) {
if (over == value) return
over = value
repaint()
}
}
}
/** Small component helpers that keep repeated Swing setup in one place. */
object Components {
fun transparent(component: JComponent) {
component.isOpaque = false
fun transparent(vararg components: JComponent) {
components.forEach { it.isOpaque = false }
}
}
}
@@ -40,6 +40,9 @@
class="ai.kilocode.client.actions.KiloSettingsAction"
icon="AllIcons.General.GearPlain"/>
<action id="Kilo.History"
class="ai.kilocode.client.actions.HistoryAction"/>
<action id="Kilo.SendPrompt"
class="ai.kilocode.client.actions.SendPromptAction"
text="Send Prompt"
@@ -8,10 +8,7 @@ session.connection.warning.config=Configuration warnings
session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase.
session.empty.loading=Loading...
session.empty.recent=RECENT
session.empty.time.moments=Moments ago
session.empty.time.minutes={0} min ago
session.empty.time.hours={0}h ago
session.empty.time.days={0}d ago
session.showHistory=Show History
session.scroll.bottom=Scroll to bottom
session.tab.new=New Session
session.tab.untitled=Untitled Session
@@ -85,10 +82,40 @@ model.picker.free=Free
model.picker.reset=Reset model to default
reasoning.picker.tooltip=Select reasoning effort
history.tab.local=Local
history.tab.cloud=Cloud
history.back=Back
history.search.placeholder=Search sessions
history.empty=No sessions
history.loading=Loading...
history.untitled=Untitled
history.delete.text=Delete
history.delete.confirm.title=Delete session?
history.delete.confirm.message=Delete "{0}" from local history?
history.cloud.load.more=Load more
history.cloud.repo.only=Only this repository
history.group.today=Today
history.group.yesterday=Yesterday
history.group.week=This Week
history.group.month=This Month
history.group.older=Older
history.time.moments=Moments ago
history.time.minutes={0} min ago
history.time.hours={0}h ago
history.time.days={0}d ago
history.time.months={0}mo ago
history.time.years={0}y ago
history.error.local=Failed to load local history
history.error.cloud=Failed to load cloud history
history.error.local.delete=Failed to delete session
history.error.cloud.delete=Cloud sessions cannot be deleted yet
action.Kilo.Settings.text=Settings
action.Kilo.Settings.description=Kilo Code settings
action.Kilo.NewSession.text=New Session
action.Kilo.NewSession.description=Start a new Kilo session
action.Kilo.History.text=History
action.Kilo.History.description=Show session history
action.Kilo.SendPrompt.text=Send Prompt
action.Kilo.SendPrompt.description=Send the current Kilo prompt
action.Kilo.StopSession.text=Stop Session
@@ -1,7 +1,7 @@
package ai.kilocode.client.actions
import ai.kilocode.client.session.SessionManager
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.client.session.SessionRef
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.actionSystem.DataContext
import com.intellij.openapi.actionSystem.Presentation
@@ -49,7 +49,10 @@ class NewSessionActionTest : BasePlatformTestCase() {
created++
}
override fun openSession(session: SessionDto) {
override fun showHistory() {
}
override fun openSession(ref: SessionRef) {
}
}
}
@@ -0,0 +1,62 @@
package ai.kilocode.client.session
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionTimeDto
import junit.framework.TestCase
class SessionRefTest : TestCase() {
fun `test local ref exposes type id and key`() {
val ref = SessionRef.Local("ses_test")
assertEquals(SessionRef.Type.LOCAL, ref.type)
assertEquals("ses_test", ref.id)
assertEquals("ses_test", ref.key)
assertNull(ref.session)
}
fun `test metadata local ref keeps id identity`() {
val dto = session("ses_test")
val ref = SessionRef.Local(dto)
assertEquals(SessionRef.Type.LOCAL, ref.type)
assertEquals("ses_test", ref.id)
assertEquals("ses_test", ref.key)
assertSame(dto, ref.session)
}
fun `test cloud ref exposes type id and prefixed key`() {
val dto = cloud("cloud_1")
val ref = SessionRef.Cloud(dto)
assertEquals(SessionRef.Type.CLOUD, ref.type)
assertEquals("cloud_1", ref.id)
assertEquals("cloud:cloud_1", ref.key)
assertSame(dto, ref.session)
}
fun `test ref parser resolves cloud and local ids`() {
assertEquals(SessionRef.Cloud("cloud_1"), SessionRef.from("cloud:cloud_1"))
assertEquals(SessionRef.Local("ses_test"), SessionRef.from("ses_test"))
assertNull(SessionRef.from(null))
assertNull(SessionRef.from(""))
}
private fun session(id: String) = SessionDto(
id = id,
projectID = "prj",
directory = "/test",
title = "Session $id",
version = "1",
time = SessionTimeDto(created = 1.0, updated = 2.0),
)
private fun cloud(id: String) = CloudSessionDto(
id = id,
title = "Cloud $id",
createdAt = "2026-01-01T00:00:00Z",
updatedAt = "2026-01-02T00:00:00Z",
version = 1.0,
)
}
@@ -4,9 +4,11 @@ import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.history.HistoryPanel
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
@@ -19,6 +21,8 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import javax.swing.JLabel
import javax.swing.JComponent
import javax.swing.JPanel
@Suppress("UnstableApiUsage")
@@ -31,7 +35,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
private lateinit var app: KiloAppService
private val managers = mutableListOf<SessionSidePanelManager>()
private val created = mutableListOf<Pair<String, String?>>()
private val loading = mutableListOf<Boolean>()
private val refs = mutableListOf<SessionRef?>()
private val ui = mutableListOf<SessionUi>()
override fun setUp() {
@@ -78,7 +82,6 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
assertNotSame(first, second)
assertEquals(listOf("/test" to null, "/test" to null), created)
assertEquals(listOf(true, false), loading)
}
fun `test new session on blank session keeps active component`() {
@@ -91,7 +94,6 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
assertSame(first, second)
assertEquals(listOf("/test" to null), created)
assertEquals(listOf(true), loading)
}
fun `test default focused component tracks active session`() {
@@ -107,6 +109,16 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
assertNotSame(first.defaultFocusedComponent, manager.defaultFocusedComponent)
}
fun `test default focused component tracks history filter`() {
val manager = manager()
manager.showHistory()
settle()
val history = active(manager) as HistoryPanel
assertSame(history.defaultFocusedComponent, manager.defaultFocusedComponent)
}
fun `test opening same existing session reuses component`() {
val manager = manager()
val session = session("ses_1")
@@ -119,7 +131,6 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
assertSame(first, second)
assertEquals(listOf("/test" to "ses_1", "/test" to null), created)
assertEquals(listOf(false, false), loading)
}
fun `test prompted blank session is reused from recents`() {
@@ -157,7 +168,6 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
manager.openSession(session("ses_1", "/repo"))
assertEquals(listOf("/repo" to "ses_1"), created)
assertEquals(listOf(false), loading)
}
fun `test open session seeds session metadata into ui`() {
@@ -174,7 +184,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
manager.openSession(session("ses_1"))
val first = active(manager) as SessionUi
manager.openSession(session("ses_2"))
val style = ai.kilocode.client.session.ui.SessionStyle.create(family = "Courier New", size = 24)
val style = ai.kilocode.client.session.ui.style.SessionEditorStyle.create(family = "Courier New", size = 24)
first.applyStyle(style)
manager.openSession(session("ses_1"))
@@ -193,19 +203,192 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
assertEquals(0, manager.component.componentCount)
}
private fun manager(): SessionSidePanelManager {
fun `test show history swaps active component`() {
val history = JLabel("History")
val manager = manager(history = { _, _, _ -> history })
manager.newSession()
manager.showHistory()
assertSame(history, manager.component.getComponent(0))
assertNull(manager.defaultFocusedComponent)
}
fun `test history back restores latest open session`() {
val manager = manager()
manager.openSession(session("ses_1"))
val first = active(manager)
manager.showHistory()
back(manager)
assertSame(first, active(manager))
}
fun `test history back without latest session opens new session`() {
val manager = manager()
manager.showHistory()
back(manager)
assertTrue(active(manager) is SessionUi)
assertEquals(listOf("/test" to null), created)
}
fun `test history back ignores deleted latest session`() {
val manager = manager()
manager.openSession(session("ses_1"))
manager.showHistory()
remove(manager, "ses_1")
back(manager)
assertTrue(active(manager) is SessionUi)
assertEquals(listOf("/test" to "ses_1", "/test" to null), created)
}
fun `test opening local history item shows session ui`() {
lateinit var open: (SessionRef) -> Unit
val history = JLabel("History")
val manager = manager(history = { _, fn, _ ->
open = fn
history
})
manager.showHistory()
open(SessionRef.Local(session("ses_1")))
assertTrue(active(manager) is SessionUi)
assertEquals(listOf("/test" to "ses_1"), created)
}
fun `test opening cloud history item shows session ui before import`() {
lateinit var open: (SessionRef) -> Unit
rpc.historyGate = kotlinx.coroutines.CompletableDeferred()
rpc.importedCloudSession = session("ses_imported")
val manager = manager(history = { _, fn, _ ->
open = fn
JLabel("History")
})
manager.showHistory()
open(SessionRef.Cloud(cloud("cloud_1")))
settle()
assertTrue(active(manager) is SessionUi)
assertEquals(listOf("/test" to "cloud:cloud_1"), created)
assertEquals("cloud:cloud_1", refs.single()?.key)
assertEquals(listOf("cloud_1" to "/test"), rpc.imports)
rpc.historyGate?.complete(Unit)
settle()
assertSame(active(manager), ui.single())
assertEquals(listOf("/test" to "cloud:cloud_1"), created)
}
fun `test opening same cloud session while in-flight reuses existing ui`() {
rpc.historyGate = kotlinx.coroutines.CompletableDeferred()
rpc.importedCloudSession = session("ses_imported")
val manager = manager()
val ref = SessionRef.Cloud(cloud("cloud_1"))
manager.openSession(ref)
val first = active(manager)
manager.newSession()
manager.openSession(ref)
val second = active(manager)
assertSame(first, second)
assertEquals(listOf("/test" to "cloud:cloud_1", "/test" to null), created)
rpc.historyGate!!.complete(Unit)
settle()
}
fun `test imported cloud session is reused when opened as local`() {
rpc.importedCloudSession = session("ses_imported")
val manager = manager()
manager.openSession(SessionRef.Cloud(cloud("cloud_1")))
settle()
val first = active(manager)
manager.openSession(session("ses_imported"))
val second = active(manager)
assertSame(first, second)
assertEquals(listOf("/test" to "cloud:cloud_1"), created)
}
fun `test new session from history shows blank session`() {
val history = JLabel("History")
val manager = manager(history = { _, _, _ -> history })
manager.showHistory()
manager.newSession()
assertTrue(active(manager) is SessionUi)
assertEquals(listOf("/test" to null), created)
}
fun `test opening same local session while in-flight reuses existing ui`() {
val gate = kotlinx.coroutines.CompletableDeferred<Unit>()
rpc.historyGate = gate
val manager = manager()
val session = session("ses_1")
manager.openSession(session)
val first = active(manager)
// Open the same session again while history is still loading
manager.newSession()
manager.openSession(session)
val second = active(manager)
assertSame("in-flight session must be reused", first, second)
assertEquals(listOf("/test" to "ses_1", "/test" to null), created)
gate.complete(Unit)
settle()
}
fun `test deleted cached history session is not reused`() {
lateinit var deleted: (String) -> Unit
val manager = manager(history = { _, _, fn ->
deleted = fn
JLabel("History")
})
val session = session("ses_1")
manager.openSession(session)
val first = active(manager)
manager.showHistory()
deleted("ses_1")
manager.openSession(session)
assertNotSame(first, active(manager))
assertEquals(listOf("/test" to "ses_1", "/test" to "ses_1"), created)
}
private fun manager(
history: ((com.intellij.openapi.Disposable, (SessionRef) -> Unit, (String) -> Unit) -> JComponent)? = null,
): SessionSidePanelManager {
val manager = SessionSidePanelManager(
project = project,
root = workspace,
create = { project, workspace, owner, id, show, session ->
create = { project, workspace, owner, ref ->
val id = when (ref) {
is SessionRef.Local -> ref.id
is SessionRef.Cloud -> ref.key
null -> null
}
created.add(workspace.directory to id)
loading.add(show)
SessionUi(project, workspace, sessions, app, scope, id = id, loading = show, open = owner::openSession, session = session).also {
refs.add(ref)
SessionUi(project, workspace, sessions, app, scope, ref = ref, manager = owner).also {
ui.add(it)
Disposer.register(it) { ui.remove(it) }
}
},
resolve = { workspaces.workspace(it) },
history = history,
)
managers.add(manager)
return manager
@@ -213,10 +396,22 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
private fun active(manager: SessionSidePanelManager) = manager.component.getComponent(0) as JPanel
private fun JPanel.controller(): ai.kilocode.client.session.update.SessionController {
private fun JPanel.controller(): ai.kilocode.client.session.controller.SessionController {
val field = SessionUi::class.java.getDeclaredField("controller")
field.isAccessible = true
return field.get(this) as ai.kilocode.client.session.update.SessionController
return field.get(this) as ai.kilocode.client.session.controller.SessionController
}
private fun remove(manager: SessionSidePanelManager, id: String) {
val method = SessionSidePanelManager::class.java.getDeclaredMethod("removeSession", String::class.java)
method.isAccessible = true
method.invoke(manager, id)
}
private fun back(manager: SessionSidePanelManager) {
val method = SessionSidePanelManager::class.java.getDeclaredMethod("back")
method.isAccessible = true
method.invoke(manager)
}
private fun settle() = kotlinx.coroutines.runBlocking {
@@ -237,4 +432,12 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
time = SessionTimeDto(created = 1.0, updated = 2.0),
)
private fun cloud(id: String) = CloudSessionDto(
id = id,
title = "Cloud $id",
createdAt = "2026-01-01T00:00:00Z",
updatedAt = "2026-01-02T00:00:00Z",
version = 1.0,
)
}
@@ -48,7 +48,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
}
fun `test factory creates blank session ui`() {
val ui = direct().create(project, workspace, FakeManager(), null, true)
val ui = direct().create(project, workspace, FakeManager(), null)
assertNotNull(ui)
}
@@ -56,7 +56,7 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
fun `test factory wires open callback`() {
val manager = FakeManager()
val rpc = session("ses_1")
val ui = SessionUi(project, workspace, sessions, app, scope, open = manager::openSession)
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager)
val controller = controller(ui)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
@@ -66,22 +66,38 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
assertEquals(listOf("ses_1"), manager.opened)
}
fun `test empty panel opens through controller`() {
fun `test empty panel opens through SessionRef via controller`() {
val manager = FakeManager()
val rpc = session("ses_1")
val ui = SessionUi(project, workspace, sessions, app, scope, open = manager::openSession)
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager)
val controller = controller(ui)
val panel = ai.kilocode.client.session.ui.EmptySessionPanel(testRootDisposable, controller, listOf(rpc))
panel.clickRecent(0)
// Recent click routes through SessionRef.Local path
assertEquals(listOf("ses_1"), manager.opened)
}
private fun controller(ui: SessionUi): ai.kilocode.client.session.update.SessionController {
fun `test empty panel show history routes through manager`() {
val manager = FakeManager()
val ui = SessionUi(project, workspace, sessions, app, scope, manager = manager)
val controller = controller(ui)
val panel = ai.kilocode.client.session.ui.EmptySessionPanel(
testRootDisposable,
controller,
emptyList(),
) { manager.showHistory() }
panel.clickShowHistory()
assertEquals(1, manager.history)
}
private fun controller(ui: SessionUi): ai.kilocode.client.session.controller.SessionController {
val field = SessionUi::class.java.getDeclaredField("controller")
field.isAccessible = true
return field.get(ui) as ai.kilocode.client.session.update.SessionController
return field.get(ui) as ai.kilocode.client.session.controller.SessionController
}
fun `test application service is available`() {
@@ -101,11 +117,20 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
private class FakeManager : SessionManager {
val opened = mutableListOf<String>()
var history = 0
override fun newSession() {
}
override fun openSession(session: SessionDto) {
opened.add(session.id)
override fun showHistory() {
history++
}
override fun openSession(ref: SessionRef) {
val id = when (ref) {
is SessionRef.Local -> ref.id
is SessionRef.Cloud -> ref.key
}
opened.add(id)
}
}
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.session
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.Question
@@ -8,13 +9,14 @@ import ai.kilocode.client.session.model.QuestionOption
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.session.ui.ConnectionPanel
import ai.kilocode.client.session.ui.EmptySessionPanel
import ai.kilocode.client.session.ui.LoadingPanel
import ai.kilocode.client.session.ui.PermissionPanel
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.ui.QuestionPanel
import ai.kilocode.client.session.ui.SessionMessageListPanel
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
import ai.kilocode.client.session.update.SessionControllerEvent
import ai.kilocode.client.session.controller.SessionControllerEvent
import ai.kilocode.rpc.dto.MessageWithPartsDto
import com.intellij.ui.components.JBScrollPane
import javax.swing.JLayeredPane
@@ -119,7 +121,7 @@ class SessionUiLayoutTest : SessionUiTestBase() {
fun `test empty and message bodies share the same scroll pane`() {
settle()
val pane = scrollComponent()
val empty = find<EmptySessionPanel>(ui)
val empty = find<EmptySessionPanel>(ui).view
assertSame(empty, scrollView())
@@ -132,23 +134,35 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertSame(find<SessionMessageListPanel>(ui), scrollView())
}
fun `test new session starts with loading body`() {
fun `test new session starts neutral before controller view state`() {
ui = newUi(displayMs = 1_000)
assertFalse(scrollView() is EmptySessionPanel)
assertFalse(scrollView() is LoadingPanel)
}
fun `test action-created new session starts blank`() {
ui = newUi(displayMs = 1_000, loading = false)
ui = newUi(displayMs = 1_000)
assertFalse(scrollView() is EmptySessionPanel)
assertFalse(scrollView() is SessionMessageListPanel)
assertFalse(scrollView() is LoadingPanel)
}
fun `test clicking recent session calls opener`() {
fun `test existing session id shows loading body immediately`() {
rpc.historyGate = kotlinx.coroutines.CompletableDeferred()
ui = newUi(id = "ses_test", displayMs = 1_000)
assertSame(find<LoadingPanel>(ui), scrollView())
assertEquals(SessionState.Loading, controller().model.state)
rpc.historyGate?.complete(Unit)
}
fun `test clicking recent session calls opener via SessionRef`() {
val opened = mutableListOf<String>()
rpc.recent.add(session("ses_1"))
ui = newUi(open = { opened.add(it.id) })
ui = newUi(open = { ref -> if (ref is SessionRef.Local) opened.add(ref.id) })
settle()
layout()
@@ -166,10 +180,64 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertSame(find<SessionMessageListPanel>(ui), scrollView())
}
fun `test empty explicit session id shows message body`() {
rpc.recent.add(session("ses_recent"))
settle()
rpc.recentCalls.clear()
ui = newUi(id = "ses_test")
settle()
assertSame(find<SessionMessageListPanel>(ui), scrollView())
assertNull(find(ui, EmptySessionPanel::class.java))
assertTrue(rpc.recentCalls.isEmpty())
}
fun `test explicit session id loading does not show recents`() {
rpc.historyGate = kotlinx.coroutines.CompletableDeferred()
rpc.recent.add(session("ses_recent"))
settle()
rpc.recentCalls.clear()
ui = newUi(id = "ses_test", displayMs = 50)
settleShort(100)
assertSame(find<LoadingPanel>(ui), scrollView())
assertNull(find(ui, EmptySessionPanel::class.java))
assertTrue(rpc.recentCalls.isEmpty())
rpc.historyGate!!.complete(Unit)
settle()
assertSame(find<SessionMessageListPanel>(ui), scrollView())
assertTrue(rpc.recentCalls.isEmpty())
}
fun `test explicit cloud session loading does not show recents`() {
rpc.importedCloudSession = session("ses_imported")
rpc.historyGate = kotlinx.coroutines.CompletableDeferred()
rpc.recent.add(session("ses_recent"))
settle()
rpc.recentCalls.clear()
ui = newUi(id = "cloud:cloud_1", displayMs = 50)
settleShort(100)
assertSame(find<LoadingPanel>(ui), scrollView())
assertNull(find(ui, EmptySessionPanel::class.java))
assertTrue(rpc.recentCalls.isEmpty())
rpc.historyGate!!.complete(Unit)
settle()
assertSame(find<SessionMessageListPanel>(ui), scrollView())
assertTrue(rpc.recentCalls.isEmpty())
}
fun `test existing session history shows header above scroll pane`() {
rpc.history.add(MessageWithPartsDto(message("msg1"), emptyList()))
ui = SessionUi(project, workspace, sessions, app, scope, id = "ses_test", displayMs = 0).apply {
ui = SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.Local("ses_test"), displayMs = 0).apply {
setSize(800, 600)
}
settle()
@@ -181,31 +249,38 @@ class SessionUiLayoutTest : SessionUiTestBase() {
assertTrue(header.y + header.height <= scroll.y)
}
fun `test new session keeps loading body before recents delay`() {
fun `test new session shows blank body while recents are loading`() {
rpc.recentGate = kotlinx.coroutines.CompletableDeferred()
ui = newUi(displayMs = 1_000)
settleShort(100)
// A new session (no id) shows blank body while recents are pending, not loading body
assertFalse(scrollView() is EmptySessionPanel)
assertFalse(scrollView() is LoadingPanel)
rpc.recentGate!!.complete(Unit)
}
fun `test slow recents switch to loading body only after progress event`() {
fun `test slow recents never show loading body and show recents when complete`() {
rpc.recentGate = kotlinx.coroutines.CompletableDeferred()
rpc.recent.add(session("ses_1"))
ui = newUi(displayMs = 50)
settleShort(20)
// No loading body — recents do not trigger progress indicator
assertFalse(scrollView() is EmptySessionPanel)
assertFalse(scrollView() is LoadingPanel)
settleShort(80)
// Still no loading body even after the delay interval passes
assertFalse(scrollView() is EmptySessionPanel)
assertFalse(scrollView() is LoadingPanel)
rpc.recentGate!!.complete(Unit)
settle()
val panel = find<EmptySessionPanel>(ui)
assertSame(panel, scrollView())
assertSame(panel.view, scrollView())
assertEquals(1, panel.recentCount())
}
@@ -6,10 +6,11 @@ import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.session.ui.SessionRootPanel
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.session.update.SessionController
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.client.session.SessionRef
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
@@ -76,10 +77,16 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
protected fun newUi(
id: String? = null,
displayMs: Long = 0,
loading: Boolean = id == null,
open: (SessionDto) -> Unit = {},
open: ((SessionRef) -> Unit)? = null,
): SessionUi {
return SessionUi(project, workspace, sessions, app, scope, id = id, displayMs = displayMs, loading = loading, open = open).apply {
val manager = open?.let { fn ->
object : SessionManager {
override fun newSession() {}
override fun showHistory() {}
override fun openSession(ref: SessionRef) = fn(ref)
}
}
return SessionUi(project, workspace, sessions, app, scope, ref = SessionRef.from(id), displayMs = displayMs, manager = manager).apply {
setSize(800, 600)
}
}

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