diff --git a/.changeset/agent-manager-tab-add-button.md b/.changeset/agent-manager-tab-add-button.md deleted file mode 100644 index 93098cacf85..00000000000 --- a/.changeset/agent-manager-tab-add-button.md +++ /dev/null @@ -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. diff --git a/.changeset/agent-manager-tab-controls.md b/.changeset/agent-manager-tab-controls.md deleted file mode 100644 index 8d9b184dd1e..00000000000 --- a/.changeset/agent-manager-tab-controls.md +++ /dev/null @@ -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. diff --git a/.changeset/auto-subagent-permissions.md b/.changeset/auto-subagent-permissions.md new file mode 100644 index 00000000000..73c865fdfbb --- /dev/null +++ b/.changeset/auto-subagent-permissions.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Auto-approve Task subagent tool permissions when running `kilo run --auto`. diff --git a/.changeset/breezy-feet-allow.md b/.changeset/breezy-feet-allow.md new file mode 100644 index 00000000000..9bf546fb38c --- /dev/null +++ b/.changeset/breezy-feet-allow.md @@ -0,0 +1,6 @@ +--- +"@kilocode/kilo-docs": patch +"@kilocode/cli": patch +--- + +Remove `--dangerously-skip-permissions` CLI flag which did nothing diff --git a/.changeset/deny-nested-subagents.md b/.changeset/deny-nested-subagents.md new file mode 100644 index 00000000000..38466b96f7e --- /dev/null +++ b/.changeset/deny-nested-subagents.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent subagents from spawning nested subagents. diff --git a/.changeset/jetbrains-empty-panel-layout.md b/.changeset/jetbrains-empty-panel-layout.md new file mode 100644 index 00000000000..3f13739eeb8 --- /dev/null +++ b/.changeset/jetbrains-empty-panel-layout.md @@ -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. diff --git a/.changeset/jetbrains-history-navigation.md b/.changeset/jetbrains-history-navigation.md new file mode 100644 index 00000000000..92f361fc775 --- /dev/null +++ b/.changeset/jetbrains-history-navigation.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve JetBrains history navigation with Back support and a Show History action in recent sessions. diff --git a/.changeset/jetbrains-history-tabs.md b/.changeset/jetbrains-history-tabs.md new file mode 100644 index 00000000000..859a598e3e8 --- /dev/null +++ b/.changeset/jetbrains-history-tabs.md @@ -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. diff --git a/.changeset/jetbrains-session-history.md b/.changeset/jetbrains-session-history.md new file mode 100644 index 00000000000..8aa827208cc --- /dev/null +++ b/.changeset/jetbrains-session-history.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Add a native session history panel to the JetBrains plugin. diff --git a/.changeset/jetbrains-session-loading-state.md b/.changeset/jetbrains-session-loading-state.md new file mode 100644 index 00000000000..bc930ebcbca --- /dev/null +++ b/.changeset/jetbrains-session-loading-state.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Show loading immediately when opening JetBrains sessions without showing duplicate progress for new sessions. diff --git a/.changeset/large-session-tool-pruning.md b/.changeset/large-session-tool-pruning.md new file mode 100644 index 00000000000..187f985c2ea --- /dev/null +++ b/.changeset/large-session-tool-pruning.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Reduce delays in long sessions by compacting older tool outputs when the model request becomes too large. diff --git a/.changeset/semantic-search-snippets.md b/.changeset/semantic-search-snippets.md new file mode 100644 index 00000000000..8b45d0e7f44 --- /dev/null +++ b/.changeset/semantic-search-snippets.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Clarify that semantic search returns matching code snippets with paths, line ranges, and relevance scores. diff --git a/.github/workflows/check-kilo-generated-artifacts.yml b/.github/workflows/check-kilo-generated-artifacts.yml new file mode 100644 index 00000000000..1fa1c083f28 --- /dev/null +++ b/.github/workflows/check-kilo-generated-artifacts.yml @@ -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 diff --git a/.gitignore b/.gitignore index 95a44149d17..91bf7230c02 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file +packages/opencode/.artifacts/ diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json deleted file mode 100644 index ce556edce27..00000000000 --- a/.kilo/package-lock.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/.kilocode/package-lock.json b/.kilocode/package-lock.json deleted file mode 100644 index 32676c56243..00000000000 --- a/.kilocode/package-lock.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/.opencode-version b/.opencode-version new file mode 100644 index 00000000000..9866d345648 --- /dev/null +++ b/.opencode-version @@ -0,0 +1 @@ +v1.14.33 diff --git a/README.md b/README.md index 3a9c92b457d..afbb34ef673 100644 --- a/README.md +++ b/README.md @@ -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 + +
+Where did Kilo CLI come from? Kilo CLI is a fork of [OpenCode](https://github.com/anomalyco/opencode), enhanced to work within the Kilo agentic engineering platform. + +
diff --git a/bun.lock b/bun.lock index 33fca023363..69b439a427c 100644 --- a/bun.lock +++ b/bun.lock @@ -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", diff --git a/package.json b/package.json index d5cbbaf4375..ff0a116dc3d 100644 --- a/package.json +++ b/package.json @@ -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": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 3fc50a9ce77..c8800d778af 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index f43d4ec9fa8..bd0807ac0a5 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -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"] diff --git a/packages/kilo-docs/lychee.toml b/packages/kilo-docs/lychee.toml index ef52759b953..5d15d940df1 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -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', ] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 18e176f2b08..1c72cc0238c 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -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", diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index c959cf3f9e1..09c5ad62c8c 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md @@ -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 diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 6a04ffae85b..d4c3079937a 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,7 +1,7 @@ # Source Code Links - + - @@ -177,3 +177,9 @@ - +- + +- + +- + diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index ae71a5b8d4b..c56d0a419cb 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -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", diff --git a/packages/kilo-gateway/src/api/constants.ts b/packages/kilo-gateway/src/api/constants.ts index 0cc4931710d..44acecfa75d 100644 --- a/packages/kilo-gateway/src/api/constants.ts +++ b/packages/kilo-gateway/src/api/constants.ts @@ -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" diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 500af94d754..5bee4575135 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -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", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 4d369efd670..88ddae1e9f5 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -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", diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index e9d9d3f71f1..7d7b12d3bf0 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -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. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index 80ce9ab7946..b5e014623e8 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -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) + } + } + } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index 8995a1a383e..3371a389004 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -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( diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 83aecba6219..302e3bb9f3b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -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> = sessions.statuses diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt index 867ad3ecbf7..0f653f89f11 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendSessionManagerTest.kt @@ -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 { + 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 { + 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 { + app.sessions.importCloudSession("cloud_1", "/test") + } + + assertTrue(err.message.orEmpty().contains("HTTP 500")) + assertTrue(err.message.orEmpty().contains("boom")) + } + // ------ Session create ------ @Test diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index d47528ce901..6e6550256d6 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -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 // ================================================================ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index aa1aee34759..5b234053a70 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -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) diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 3ecc8be04b6..00b4bd5ea2b 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -66,6 +66,12 @@ intellijPlatform { } } +tasks { + runIdeBackend { + splitModeServerPort.set(12345) + } +} + tasks.named("runIde") { dependsOn(":backend:processResources") jvmArgumentProviders += CommandLineArgumentProvider { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index b5e8cb2b5e8..52262d1ba8e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt new file mode 100644 index 00000000000..92d662e7ae5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/HistoryAction.kt @@ -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 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 9053849503d..a011ad885e6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -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 = 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 { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt index a65cde6df18..62d8ec94074 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionManager.kt @@ -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)) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionRef.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionRef.kt new file mode 100644 index 00000000000..73bb72c2943 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionRef.kt @@ -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 + } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionScroll.kt index 3a4b2ce7cab..eef78776d83 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionScroll.kt @@ -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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt index d81e9d4437d..2fa5d531c56 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionSidePanelManager.kt @@ -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().create(project, workspace, manager, id, loading, session) + private val create: (Project, Workspace, SessionManager, SessionRef?) -> SessionUi = { project, workspace, manager, ref -> + service().create(project, workspace, manager, ref) }, private val resolve: (String) -> Workspace = { dir -> service().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() private val all = mutableSetOf() 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() + val cs = factory.scope() + val controller = HistoryController( + sessions = project.service(), + 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) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index f555b0e6377..cb316ab2d93 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt index d4e3b012186..52735be09d5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUiFactory.kt @@ -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(), app = service(), 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)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/DelayedState.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/DelayedState.kt index 2dba541f6b1..623524e2c0a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/DelayedState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/DelayedState.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt similarity index 79% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 447ab18a775..3c0da06ba82 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -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() - 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, ) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt index 121af83b5d8..ba6217af592 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionControllerEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionControllerEvent.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionQueueCondenser.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionQueueCondenser.kt index dcb06f4e6ca..4c8ca98a74f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionQueueCondenser.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionQueueCondenser.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt index 770c9ee388c..7c35f9dde2e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/update/SessionUpdateQueue.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueue.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt new file mode 100644 index 00000000000..81993348e78 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt @@ -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() + val cloud = CloudHistoryModel() + + private val deleting = mutableSetOf() + 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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryItem.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryItem.kt new file mode 100644 index 00000000000..35c9f851452 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryItem.kt @@ -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 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt new file mode 100644 index 00000000000..a7fb4e2c961 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt @@ -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( + private val model: HistoryModel, + private val deletable: Boolean, +) : JPanel(BorderLayout()), ListCellRenderer { + 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, 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, + 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) : HistoryRenderer(model, deletable = true) + +internal class CloudHistoryRenderer(model: HistoryModel) : HistoryRenderer(model, deletable = false) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListUi.kt new file mode 100644 index 00000000000..c5c9b2a6be4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListUi.kt @@ -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 clicked(list: JBList, 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) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt new file mode 100644 index 00000000000..a9d0af9e774 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt @@ -0,0 +1,120 @@ +package ai.kilocode.client.session.history + +import javax.swing.AbstractListModel + +open class HistoryModel : AbstractListModel() { + private var all = emptyList() + private var rows = emptyList() + private var query = "" + + var loading = false + private set + var error: String? = null + private set + var loaded = false + private set + + val items: List get() = all + val visibleItems: List 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) { + all = HistoryTime.sorted(items) + loading = false + loaded = true + error = null + filter() + } + + fun append(items: List) { + 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() { + var cursor: String? = null + private set + + fun start(reset: Boolean) { + cursor = cursor.takeUnless { reset } + start() + if (reset && !loaded) clear() + } + + fun replace(items: List, next: String?) { + cursor = next + replace(items) + } + + fun append(items: List, next: String?) { + cursor = next + append(items) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt new file mode 100644 index 00000000000..4c4460acd28 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt @@ -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) { + val view = list.cellRenderer + if (view is JComponent) SwingUtilities.updateComponentTreeUI(view) + } + + private fun search(model: HistoryModel) = 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, 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 bind(list: JBList, model: HistoryModel) { + 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 syncList(list: JBList, model: HistoryModel) { + 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, 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 { + 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 = if (tabs.selectedInfo === cloudInfo) cloudList else localList + + private fun activeModel(): HistoryModel = 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" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryTime.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryTime.kt new file mode 100644 index 00000000000..1b3f9516ecf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryTime.kt @@ -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 sorted(items: List): List = items.sortedWith( + compareByDescending { 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() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 89d044dd752..4a380ac6ae6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt index 7d0f5f1b67b..156411aaf88 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt @@ -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 } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt index 422b6daa9d9..9b6beb55e8c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ConnectionPanel.kt @@ -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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt index 1caf26b538d..d2811072a28 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/EmptySessionPanel.kt @@ -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, -) : 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() + private val model = DefaultListModel() 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) { 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 { + private inner class SessionRenderer : BorderLayoutPanel(), ListCellRenderer { 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, - value: SessionDto?, + list: JList, + 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( + "
${XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome"))}
" + ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt new file mode 100644 index 00000000000..04acd9f6b44 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/LoadingPanel.kt @@ -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() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt index 7d87fdd90ea..49e8b14261c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt @@ -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 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt index 2712a4e2952..376816612de 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt index 9d7db8c0d78..144a703cbbe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt @@ -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 } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt index ea19f7e5e49..60b925da60a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ReasoningPicker.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt index 25c22ee7689..296a87ef9e7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt @@ -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, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index 44afdeda402..bc348f09079 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -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() private val msgToTurn = HashMap() private val msgToView = HashMap() - 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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt index f053a2d28d8..e15a525fdd1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/ContextBar.kt @@ -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) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index 0368fa9b50f..e559d1b5d23 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TimelinePanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TimelinePanel.kt index 3906138b5de..b0e1cf49fc3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TimelinePanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/TimelinePanel.kt @@ -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 } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePicker.kt index d42e76aa2e0..6c9a03b6565 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePicker.kt @@ -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, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePickerRenderer.kt index fbb3ba6c667..fae243c8e12 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePickerRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/mode/ModePickerRenderer.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt index 9a86d0a85d7..4423679e9ef 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPicker.kt @@ -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, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt index fea99069103..0a51cbaee15 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/model/ModelPickerRenderer.kt @@ -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) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index 0c6436f9759..ac4bc25f6e2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -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) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt similarity index 75% rename from packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionStyle.kt rename to packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt index 550b3741746..d510a0fdebe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionEditorStyle.kt @@ -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) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt new file mode 100644 index 00000000000..9ace86801c3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/style/SessionUiStyle.kt @@ -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()), + )!! +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt index cfe1b5e39b9..df406de4762 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt index fc87d42d0b5..31db00f4ecd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index 17322436fb2..dbba074cdb2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -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() 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() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt index 330f099ddb5..1d32c764b7d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt index 4e2f143e1ae..bec8c6b6fad 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -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, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt index 1954aaa8a48..e08880b9017 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt index 70d1e13b9ce..39048dd38af 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt @@ -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 diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index 2f132f7f084..57538a8f19d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -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() @@ -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() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt new file mode 100644 index 00000000000..57e7d95e5f4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/CenterShrinkPanel.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt new file mode 100644 index 00000000000..70f8b27af8e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/HoverIcon.kt @@ -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() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt new file mode 100644 index 00000000000..6a08c5bb9ce --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/PickerButton.kt @@ -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()) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt index b4ffe3717a4..850ab9f84dd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/UiStyle.kt @@ -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 } } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 619584623bd..5eabe4727d6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -40,6 +40,9 @@ class="ai.kilocode.client.actions.KiloSettingsAction" icon="AllIcons.General.GearPlain"/> + + () private val created = mutableListOf>() - private val loading = mutableListOf() + private val refs = mutableListOf() private val ui = mutableListOf() 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() + 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, + ) + } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt index cad1b00915c..75b3c17d393 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiFactoryTest.kt @@ -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() + 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) } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt index 547f7c3a881..ce71341b4b7 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt @@ -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(ui) + val empty = find(ui).view assertSame(empty, scrollView()) @@ -132,23 +134,35 @@ class SessionUiLayoutTest : SessionUiTestBase() { assertSame(find(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(ui), scrollView()) + assertEquals(SessionState.Loading, controller().model.state) + rpc.historyGate?.complete(Unit) + } + + fun `test clicking recent session calls opener via SessionRef`() { val opened = mutableListOf() 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(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(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(ui), scrollView()) + assertNull(find(ui, EmptySessionPanel::class.java)) + assertTrue(rpc.recentCalls.isEmpty()) + + rpc.historyGate!!.complete(Unit) + settle() + + assertSame(find(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(ui), scrollView()) + assertNull(find(ui, EmptySessionPanel::class.java)) + assertTrue(rpc.recentCalls.isEmpty()) + + rpc.historyGate!!.complete(Unit) + settle() + + assertSame(find(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(ui) - assertSame(panel, scrollView()) + assertSame(panel.view, scrollView()) assertEquals(1, panel.recentCount()) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt index 96ba3937824..a1d28529c71 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiTestBase.kt @@ -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) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/AppWatchingTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/AppWatchingTest.kt index efda159ca97..0309baec13d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/AppWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/AppWatchingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ChatLoggingFlowTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ChatLoggingFlowTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ChatLoggingFlowTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ChatLoggingFlowTest.kt index a2852df403f..312913c29c1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ChatLoggingFlowTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ChatLoggingFlowTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConfigSelectionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConfigSelectionTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt index 03b6681cf06..1a986bc6c9a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConfigSelectionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConfigSelectionTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentConfigDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt index d5269a7ca86..4c4c7172c71 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ConnectionDelayTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.KiloAppStateDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/DelayedStateTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/DelayedStateTest.kt index 8f6e5cfc3e0..349171f6549 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/DelayedStateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/DelayedStateTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/DisposedStateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/DisposedStateTest.kt new file mode 100644 index 00000000000..e4920f663f1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/DisposedStateTest.kt @@ -0,0 +1,128 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.MessageWithPartsDto +import com.intellij.openapi.util.Disposer +import kotlinx.coroutines.CompletableDeferred + +/** + * Tests that disposed [SessionController] ignores all late async completions. + */ +class DisposedStateTest : SessionControllerTestBase() { + + fun `test dispose during local session loading emits no late model events`() { + val gate = CompletableDeferred() + rpc.historyGate = gate + val m = controller("ses_test") + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + Disposer.dispose(m) + gate.complete(Unit) + flush() + + assertTrue("expected no model events after dispose, got: $modelEvents", modelEvents.isEmpty()) + } + + fun `test dispose during local session loading emits no controller events`() { + val gate = CompletableDeferred() + rpc.historyGate = gate + val m = controller("ses_test") + val events = collect(m) + flush() + events.clear() + + Disposer.dispose(m) + gate.complete(Unit) + flush() + + assertTrue("expected no controller events after dispose, got: $events", events.isEmpty()) + } + + fun `test dispose during cloud import emits no model events`() { + val gate = CompletableDeferred() + rpc.historyGate = gate + rpc.importedCloudSession = session("ses_imported") + val m = controller("cloud:cloud_1") + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + Disposer.dispose(m) + gate.complete(Unit) + flush() + + assertTrue("expected no model events after cloud dispose, got: $modelEvents", modelEvents.isEmpty()) + } + + fun `test dispose during recents loading emits no recents event`() { + projectRpc.state.value = workspaceReady() + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller() + val events = collect(m) + flush() + events.clear() + + Disposer.dispose(m) + gate.complete(Unit) + flush() + + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + } + + fun `test disposed controller addListener is no-op`() { + val m = controller("ses_test") + flush() + Disposer.dispose(m) + + val extra = mutableListOf() + val disposable = Disposer.newDisposable("extra") + Disposer.register(parent, disposable) + m.addListener(disposable) { extra.add(it) } + + assertTrue(extra.isEmpty()) + } + + fun `test dispose marks controller disposed and blocks model updates`() { + val m = controller("ses_test") + flush() + val modelEvents = collectModelEvents(m) + + Disposer.dispose(m) + edt { + // Simulate a late model mutation attempt on EDT after dispose + // updateModel is no-op when disposed + m.flushEvents() + } + + assertTrue(modelEvents.isEmpty()) + } + + fun `test session load state is idle after normal load completes`() { + rpc.history.add(MessageWithPartsDto(msg("msg1", "ses_test", "user"), emptyList())) + val m = controller("ses_test") + flush() + + var snap: ControllerStateSnapshot? = null + edt { snap = m.snapshotState() } + assertEquals("Idle", snap!!.sessionLoadState) + } + + fun `test session load state is loading before history arrives`() { + val gate = CompletableDeferred() + rpc.historyGate = gate + val m = controller("ses_test") + + edt { + assertEquals(SessionState.Loading, m.model.state) + val snap = m.snapshotState() + assertTrue("expected Loading in: ${snap.sessionLoadState}", snap.sessionLoadState.startsWith("Loading")) + } + + gate.complete(Unit) + flush() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt similarity index 58% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt index 6f0cf34d24f..5d8d5350a81 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/HistoryLoadingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/HistoryLoadingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.MessageWithPartsDto @@ -32,9 +32,12 @@ class HistoryLoadingTest : SessionControllerTestBase() { val events = collect(c) flush() + // ViewChanged progress fires immediately on controller construction (step 3 of plan). + // ViewChanged session fires after non-empty history is loaded. assertControllerEvents(""" AppChanged WorkspaceChanged + ViewChanged progress ViewChanged session """, events) @@ -47,4 +50,28 @@ class HistoryLoadingTest : SessionControllerTestBase() { c, ) } + + fun `test empty explicit session history shows messages view`() { + rpc.recent.add(session("ses_recent")) + + val c = controller("ses_test") + val events = collect(c) + val modelEvents = collectModelEvents(c) + flush() + + assertTrue(rpc.recentCalls.isEmpty()) + assertModelEvents("HistoryLoaded", modelEvents) + assertControllerEvents(""" + AppChanged + WorkspaceChanged + ViewChanged progress + ViewChanged session + """, events) + assertSession( + """ + [app: DISCONNECTED] [workspace: PENDING] + """, + c, + ) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ListenerLifecycleTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ListenerLifecycleTest.kt index e5d61e373fc..fa0e118ba2c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ListenerLifecycleTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/MessageListTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/MessageListTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/MessageListTest.kt index b09da2064e1..381995d06fd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/MessageListTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ProgressTrackingTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ProgressTrackingTest.kt index e6885853521..dbe7c98d4ea 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ProgressTrackingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ProgressTrackingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/PromptLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 8e3cea3ba9f..727f527b25f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionArtifactsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionArtifactsTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionArtifactsTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionArtifactsTest.kt index d2afd1387e5..33a2620a502 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionArtifactsTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionArtifactsTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt similarity index 94% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt index 13b30a8355b..214df032c3b 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionControllerTestBase.kt @@ -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 @@ -10,6 +10,7 @@ import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.SessionRef import ai.kilocode.rpc.dto.AgentDto import ai.kilocode.rpc.dto.AgentsDto import ai.kilocode.rpc.dto.ChatEventDto @@ -39,7 +40,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking /** - * Base class for [ai.kilocode.client.session.update.SessionController] tests. + * Base class for [ai.kilocode.client.session.controller.SessionController] tests. * * Provides real IntelliJ Application/EDT/Disposer via [BasePlatformTestCase], * real frontend services wired to fake RPC backends, and shared helpers. @@ -131,6 +132,14 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { return controller(id, flushMs, true, displayMs = displayMs) } + protected fun controller( + ref: SessionRef, + flushMs: Long = Long.MAX_VALUE, + displayMs: Long = Long.MAX_VALUE, + ): SessionController { + return controller(ref = ref, flushMs = flushMs, condense = true, displayMs = displayMs) + } + protected fun controller( id: String? = null, flushMs: Long, @@ -139,20 +148,20 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { session: SessionDto? = null, beforeUpdate: () -> Boolean = { false }, afterUpdate: (Boolean) -> Unit = {}, + ref: SessionRef? = if (session != null) SessionRef.Local(session) else SessionRef.from(id), ): SessionController { val root = Root() val m = SessionController( - parent, - id, - sessions, - workspace, - app, - scope, - root, - flushMs, - condense, - displayMs, - session = session, + parent, + ref, + sessions, + workspace, + app, + scope, + root, + flushMs, + condense, + displayMs, beforeUpdate = beforeUpdate, afterUpdate = afterUpdate, ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCreationTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCreationTest.kt index 936c8851466..698dec3f5e0 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionCreationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionCreationTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.rpc.dto.ConfigDto import ai.kilocode.rpc.dto.KiloAppStateDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionHeaderControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionHeaderControllerTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionHeaderControllerTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionHeaderControllerTest.kt index 8ebea6e5676..b9b3c4fe21e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionHeaderControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionHeaderControllerTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionQueueCondenserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionQueueCondenserTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionQueueCondenserTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionQueueCondenserTest.kt index c7871d4332e..7db99809802 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionQueueCondenserTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionQueueCondenserTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.DiffFileDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt index e407fb61b49..80229177999 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionRecoveryTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRecoveryTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.PermissionRequestDto @@ -10,7 +10,7 @@ import ai.kilocode.rpc.dto.SessionStatusDto * Tests for pending permission/question recovery after history load. * * VS Code rehydrates pending prompts by calling list endpoints after - * reconnect. JetBrains now does the same in [ai.kilocode.client.session.update.SessionController.recoverPending]. + * reconnect. JetBrains now does the same in [ai.kilocode.client.session.controller.SessionController.recoverPending]. */ class SessionRecoveryTest : SessionControllerTestBase() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt similarity index 99% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionUpdateQueueTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt index 871db12db3f..1a10efeabc6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/SessionUpdateQueueTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionUpdateQueueTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/StatusComputationTest.kt similarity index 96% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/StatusComputationTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/StatusComputationTest.kt index 27adf704416..a629dc76876 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/StatusComputationTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt similarity index 98% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/TurnLifecycleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt index 80aa8367511..bff862e380d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.client.session.model.SessionState import ai.kilocode.rpc.dto.ChatEventDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt new file mode 100644 index 00000000000..ae2a2f2ea72 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ViewSwitchingTest.kt @@ -0,0 +1,350 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.session.model.SessionState +import kotlinx.coroutines.CompletableDeferred + +class ViewSwitchingTest : SessionControllerTestBase() { + + fun `test first prompt shows messages view`() { + val m = controller() + val events = collect(m) + flush() + events.clear() + + edt { m.prompt("hello") } + flush() + + assertControllerEvents("ViewChanged session", events) + assertSession( + """ + [app: DISCONNECTED] [workspace: PENDING] + """, + m, + ) + } + + fun `test session event sees updated view state on EDT`() { + val m = controller() + val states = collectStates(m) + flush() + states.clear() + + edt { m.prompt("hello") } + flush() + + val state = states.single { it.first is SessionControllerEvent.ViewChanged.ShowSession }.second + assertTrue(state.showSession) + assertEquals(SessionControllerEvent.ViewChanged.ShowSession, state.viewState) + } + + fun `test ViewChanged not fired twice`() { + val m = controller() + val events = collect(m) + flush() + events.clear() + + edt { m.prompt("first") } + flush() + edt { m.prompt("second") } + flush() + + assertControllerEvents("ViewChanged session", events) + } + + fun `test recent sessions show after workspace ready`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller() + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertControllerEvents(""" + AppChanged + WorkspaceChanged + WorkspaceReady + ViewChanged recents=1 + """, events) + } + + fun `test recent load failure shows empty recents`() { + projectRpc.state.value = workspaceReady() + rpc.recentFailures = 1 + val m = controller() + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertControllerEvents(""" + AppChanged + WorkspaceChanged + WorkspaceReady + ViewChanged recents=0 + """, events) + } + + fun `test empty explicit session history shows session view`() { + rpc.recent.add(session("ses_1")) + val m = controller("ses_test", displayMs = 1_000) + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.isEmpty()) + assertControllerEvents(""" + AppChanged + WorkspaceChanged + ViewChanged progress + ViewChanged session + """, events) + assertTrue(m.model.showSession) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + } + + fun `test workspace ready does not load recents during explicit local load`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.historyGate = gate + val m = controller("ses_test", displayMs = 50) + val events = collect(m) + + pause(80) + + assertTrue(rpc.recentCalls.isEmpty()) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + + gate.complete(Unit) + flush() + + assertTrue(rpc.recentCalls.isEmpty()) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowSession }) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + } + + fun `test workspace ready does not load recents during cloud import`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + rpc.importedCloudSession = session("ses_imported") + val gate = CompletableDeferred() + rpc.historyGate = gate + val m = controller("cloud:cloud_1", displayMs = 50) + val events = collect(m) + + pause(80) + + assertTrue(rpc.recentCalls.isEmpty()) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + + gate.complete(Unit) + flush() + + assertTrue(rpc.recentCalls.isEmpty()) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowSession }) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + } + + fun `test local history session event sees loaded state on EDT`() { + projectRpc.state.value = workspaceReady() + val gate = CompletableDeferred() + rpc.historyGate = gate + val m = controller("ses_test", displayMs = 50) + val states = collectStates(m) + + gate.complete(Unit) + flush() + + val state = states.single { it.first is SessionControllerEvent.ViewChanged.ShowSession }.second + assertTrue(state.showSession) + assertEquals(SessionControllerEvent.ViewChanged.ShowSession, state.viewState) + assertEquals("Idle", state.sessionLoadState) + assertEquals("Idle", state.recentsState) + assertEquals("ses_test", state.refKey) + assertEquals("LOCAL", state.refType) + } + + fun `test cloud history session event sees imported local state on EDT`() { + projectRpc.state.value = workspaceReady() + rpc.importedCloudSession = session("ses_imported") + val gate = CompletableDeferred() + rpc.historyGate = gate + val m = controller("cloud:cloud_1", displayMs = 50) + val states = collectStates(m) + + gate.complete(Unit) + flush() + + val state = states.single { it.first is SessionControllerEvent.ViewChanged.ShowSession }.second + assertTrue(state.showSession) + assertEquals(SessionControllerEvent.ViewChanged.ShowSession, state.viewState) + assertEquals("Idle", state.sessionLoadState) + assertEquals("Idle", state.recentsState) + assertEquals("ses_imported", state.refKey) + assertEquals("LOCAL", state.refType) + } + + fun `test existing session load shows progress immediately`() { + val gate = CompletableDeferred() + rpc.historyGate = gate + val m = controller("ses_test", displayMs = 1_000) + val events = collect(m) + + assertEquals(SessionState.Loading, m.model.state) + pause(20) + + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + gate.complete(Unit) + flush() + } + + fun `test slow recents do not show progress then emit recents when complete`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller(displayMs = 50) + val events = collect(m) + + pause(20) + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + + pause(80) + // Slow recents must NOT show progress even after the delay interval + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + + gate.complete(Unit) + flush() + + // After completing, recents must fire directly (no prior progress event) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertEquals(1, events.count { it is SessionControllerEvent.ViewChanged.ShowRecents }) + val recentsView = events.filterIsInstance() + assertEquals("ViewChanged recents=1", recentsView.last().toString()) + } + + fun `test recents loaded state visible on EDT when recents event fires`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller(displayMs = 50) + val states = collectStates(m) + + gate.complete(Unit) + flush() + + assertFalse(states.any { it.first is SessionControllerEvent.ViewChanged.ShowProgress }) + val state = states.single { it.first is SessionControllerEvent.ViewChanged.ShowRecents }.second + assertEquals("Loaded", state.recentsState) + } + + fun `test fast recents suppress progress`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller(displayMs = 1_000) + val events = collect(m) + + flush() + + assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + } + + fun `test recents event sees loaded state on EDT`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val m = controller(displayMs = 1_000) + val states = collectStates(m) + + flush() + + val event = states.single { it.first is SessionControllerEvent.ViewChanged.ShowRecents } + assertEquals(event.first, event.second.viewState) + assertEquals("Loaded", event.second.recentsState) + } + + fun `test failed fast recents suppress progress and show empty recents`() { + projectRpc.state.value = workspaceReady() + rpc.recentFailures = 1 + val m = controller(displayMs = 1_000) + val events = collect(m) + + flush() + + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertEquals(1, events.count { it is SessionControllerEvent.ViewChanged.ShowRecents }) + assertTrue(events.filterIsInstance().single().recents.isEmpty()) + } + + fun `test recents progress is canceled when messages view appears`() { + projectRpc.state.value = workspaceReady() + rpc.recent.add(session("ses_1")) + val gate = CompletableDeferred() + rpc.recentGate = gate + val m = controller(displayMs = 50) + val events = collect(m) + + pause(20) + edt { m.prompt("hello") } + pause(80) + gate.complete(Unit) + flush() + + assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowSession }) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) + assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) + } + + fun `test id-only local ref starts with local identity`() { + val m = controller(SessionRef.Local("ses_test")) + + flush() + + val snap = m.snapshotState() + assertEquals("ses_test", snap.refKey) + assertEquals("LOCAL", snap.refType) + } + + fun `test cloud ref becomes local after import`() { + rpc.importedCloudSession = session("ses_imported") + val m = controller(SessionRef.Cloud("cloud_1")) + + val start = m.snapshotState() + assertEquals("cloud:cloud_1", start.refKey) + assertEquals("CLOUD", start.refType) + + flush() + + val end = m.snapshotState() + assertEquals("ses_imported", end.refKey) + assertEquals("LOCAL", end.refType) + } + + fun `test prompt updates blank controller to local ref`() { + val m = controller() + + flush() + edt { m.prompt("hello") } + flush() + + val snap = m.snapshotState() + assertEquals("ses_test", snap.refKey) + assertEquals("LOCAL", snap.refType) + } + + private fun session(id: String) = ai.kilocode.rpc.dto.SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = "Title $id", + version = "1", + time = ai.kilocode.rpc.dto.SessionTimeDto(created = 1.0, updated = 2.0), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt similarity index 97% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt index 595bbebb300..96f63afe9b5 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/WorkspaceWatchingTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/WorkspaceWatchingTest.kt @@ -1,4 +1,4 @@ -package ai.kilocode.client.session.update +package ai.kilocode.client.session.controller import ai.kilocode.rpc.dto.AgentDto diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt new file mode 100644 index 00000000000..250877766f5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt @@ -0,0 +1,372 @@ +package ai.kilocode.client.session.history + +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.CloudSessionDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.awt.Cursor +import java.awt.event.KeyEvent +import java.time.Instant +import java.time.temporal.ChronoUnit +import javax.swing.JComponent +import javax.swing.KeyStroke +import javax.swing.event.ListDataEvent +import javax.swing.event.ListDataListener + +@Suppress("UnstableApiUsage") +class HistoryControllerTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var parent: Disposable + private lateinit var rpc: FakeSessionRpcApi + private lateinit var sessions: KiloSessionService + private lateinit var workspace: Workspace + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + parent = Disposer.newDisposable("history") + rpc = FakeSessionRpcApi() + sessions = KiloSessionService(project, scope, rpc) + val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY) + }) + workspace = workspaces.workspace("/test") + } + + override fun tearDown() { + try { + Disposer.dispose(parent) + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test local load maps sessions and events`() { + rpc.listed += session("ses_1", "Local One") + val controller = controller() + val events = collect(controller) + + controller.reloadLocal() + flush() + + assertEquals(listOf("/test"), rpc.lists) + assertEquals(1, controller.local.items.size) + assertEquals("ses_1", controller.local.items[0].id) + assertEquals("Local One", controller.local.items[0].title) + assertEquals("loading=true size=0 error=null\nloading=false size=1 error=null", events.joinToString("\n")) + } + + fun `test cloud load maps sessions and supports load more`() { + rpc.cloud += cloud("cloud_1", "Cloud One") + rpc.cloudCursor = "next_1" + val controller = controller() + + controller.reloadCloud(gitUrl = "git@example.com:repo.git") + flush() + + assertEquals(1, controller.cloud.items.size) + assertEquals("cloud_1", controller.cloud.items[0].id) + assertEquals("next_1", controller.cloud.cursor) + assertEquals(FakeSessionRpcApi.CloudCall("/test", null, 150, "git@example.com:repo.git"), rpc.cloudCalls[0]) + + rpc.cloud.clear() + rpc.cloud += cloud("cloud_2", "Cloud Two") + rpc.cloudCursor = null + controller.loadMoreCloud() + flush() + + assertEquals(listOf("cloud_1", "cloud_2"), controller.cloud.items.map { it.id }) + assertEquals(FakeSessionRpcApi.CloudCall("/test", "next_1", 150, "git@example.com:repo.git"), rpc.cloudCalls[1]) + } + + fun `test local delete calls rpc and removes item`() { + rpc.listed += session("ses_1", "Local One") + val controller = controller() + controller.reloadLocal() + flush() + + controller.delete(controller.local.items[0]) + flush() + + assertEquals(listOf("ses_1" to "/test"), rpc.deletes) + assertTrue(controller.local.items.isEmpty()) + } + + fun `test panel filters and switches source`() { + rpc.listed += session("ses_1", "Alpha") + rpc.listed += session("ses_2", "Beta") + rpc.cloud += cloud("cloud_1", "Cloud") + val controller = controller() + val panel = HistoryPanel(parent, controller) + flush() + + assertEquals(2, panel.itemCount()) + panel.setSearch("alp") + assertEquals(1, panel.itemCount()) + + panel.clickCloud() + flush() + + assertEquals(HistorySource.CLOUD, panel.selectedSource()) + panel.setSearch("") + assertEquals(1, panel.itemCount()) + } + + fun `test panel shows back button with tabs`() { + val panel = HistoryPanel(parent, controller()) + flush() + + assertEquals(KiloBundle.message("history.back"), panel.backText()) + + panel.clickCloud() + flush() + + assertEquals(KiloBundle.message("history.back"), panel.backText()) + } + + fun `test panel back button invokes callback`() { + var calls = 0 + val panel = HistoryPanel(parent, controller(), nav = { calls++ }) + flush() + + panel.clickBack() + + assertEquals(1, calls) + } + + fun `test history actions use hand cursor`() { + val panel = HistoryPanel(parent, controller()) + flush() + + assertEquals(Cursor.HAND_CURSOR, panel.backCursor()) + assertEquals(Cursor.HAND_CURSOR, panel.listCursor()) + + panel.clickCloud() + flush() + + assertEquals(Cursor.HAND_CURSOR, panel.backCursor()) + assertEquals(Cursor.HAND_CURSOR, panel.listCursor()) + } + + fun `test panel preserves independent search per source`() { + rpc.listed += session("ses_1", "Alpha") + rpc.listed += session("ses_2", "Beta") + rpc.cloud += cloud("cloud_1", "Cloud Alpha") + rpc.cloud += cloud("cloud_2", "Cloud Beta") + val panel = HistoryPanel(parent, controller()) + flush() + + panel.setSearch("alp") + assertEquals(1, panel.itemCount()) + + panel.clickCloud() + flush() + assertEquals(2, panel.itemCount()) + panel.setSearch("beta") + assertEquals(1, panel.itemCount()) + + panel.clickLocal() + assertEquals(1, panel.itemCount()) + } + + fun `test panel focuses active search and moves list selection from search`() { + rpc.listed += session("ses_1", "Alpha") + rpc.listed += session("ses_2", "Beta") + rpc.cloud += cloud("cloud_1", "Cloud One") + rpc.cloud += cloud("cloud_2", "Cloud Two") + val panel = HistoryPanel(parent, controller()) + flush() + + val local = panel.defaultFocusedComponent + assertFalse(panel.listFocusable()) + assertEquals(-1, panel.selectedIndex()) + key(local, KeyEvent.VK_DOWN) + assertEquals(0, panel.selectedIndex()) + key(local, KeyEvent.VK_DOWN) + assertEquals(1, panel.selectedIndex()) + key(local, KeyEvent.VK_UP) + assertEquals(0, panel.selectedIndex()) + + panel.clickCloud() + flush() + + val cloud = panel.defaultFocusedComponent + assertNotSame(local, cloud) + assertFalse(panel.listFocusable()) + assertEquals(-1, panel.selectedIndex()) + key(cloud, KeyEvent.VK_DOWN) + assertEquals(0, panel.selectedIndex()) + } + + fun `test panel opens selected local session on enter from search`() { + rpc.listed += session("ses_1", "Alpha") + rpc.listed += session("ses_2", "Beta") + val opened = mutableListOf() + val panel = HistoryPanel(parent, controller(opened)) + flush() + + val search = panel.defaultFocusedComponent + key(search, KeyEvent.VK_DOWN) + key(search, KeyEvent.VK_DOWN) + key(search, KeyEvent.VK_ENTER) + + assertEquals(listOf("ses_2"), opened) + } + + fun `test panel opens selected cloud session immediately on enter from search`() { + rpc.cloud += cloud("cloud_1", "Cloud") + rpc.importedCloudSession = session("ses_imported", "Imported") + val opened = mutableListOf() + val panel = HistoryPanel(parent, controller(opened)) + flush() + + panel.clickCloud() + flush() + val search = panel.defaultFocusedComponent + key(search, KeyEvent.VK_DOWN) + key(search, KeyEvent.VK_ENTER) + flush() + + assertTrue(rpc.imports.isEmpty()) + assertEquals(listOf("cloud:cloud_1"), opened) + } + + fun `test panel refresh reloads local history`() { + rpc.listed += session("ses_1", "One") + val controller = controller() + val panel = HistoryPanel(parent, controller) + flush() + + assertEquals(listOf("ses_1"), controller.local.items.map { it.id }) + + rpc.listed.clear() + rpc.listed += session("ses_2", "Two") + panel.refresh() + flush() + + assertEquals(listOf("ses_2"), controller.local.items.map { it.id }) + } + + fun `test panel groups sessions by date`() { + val now = Instant.now() + rpc.listed += session("ses_today", "Today", now.toEpochMilli().toDouble()) + rpc.listed += session("ses_yesterday", "Yesterday", now.minus(1, ChronoUnit.DAYS).toEpochMilli().toDouble()) + rpc.listed += session("ses_week", "Week", now.minus(3, ChronoUnit.DAYS).toEpochMilli().toDouble()) + rpc.listed += session("ses_month", "Month", now.minus(10, ChronoUnit.DAYS).toEpochMilli().toDouble()) + rpc.listed += session("ses_older", "Older", now.minus(60, ChronoUnit.DAYS).toEpochMilli().toDouble()) + val panel = HistoryPanel(parent, controller()) + flush() + + assertTrue(panel.groupTitles().containsAll(listOf("Today", "Yesterday", "This Week", "Older"))) + } + + fun `test cloud history uses relative time and sections`() { + val now = Instant.now() + val today = CloudHistoryItem(cloud("cloud_today", "Today", now.minus(5, ChronoUnit.HOURS))) + val yesterday = CloudHistoryItem(cloud("cloud_yesterday", "Yesterday", now.minus(1, ChronoUnit.DAYS))) + val offset = CloudHistoryItem( + cloud( + "cloud_offset", + "Offset", + now.minus(10, ChronoUnit.HOURS).toString().replace("Z", "+00"), + ), + ) + + assertEquals(KiloBundle.message("history.time.hours", 5), HistoryTime.relative(today, now.toEpochMilli())) + assertEquals(HistorySection.TODAY, HistoryTime.section(today, now.toEpochMilli())) + assertEquals(HistorySection.YESTERDAY, HistoryTime.section(yesterday, now.toEpochMilli())) + assertEquals(KiloBundle.message("history.time.hours", 10), HistoryTime.relative(offset, now.toEpochMilli())) + } + + fun `test local renderer exposes delete and cloud renderer hides it`() { + rpc.listed += session("ses_1", "Local") + rpc.cloud += cloud("cloud_1", "Cloud") + val panel = HistoryPanel(parent, controller()) + flush() + + assertTrue(panel.deleteVisible(0)) + + panel.clickCloud() + flush() + assertFalse(panel.cloudDeleteVisible(0)) + } + + private fun controller() = HistoryController(sessions, workspace, scope) + + private fun controller(opened: MutableList) = HistoryController(sessions, workspace, scope, open = { open -> + val id = when (open) { + is SessionRef.Local -> open.id + is SessionRef.Cloud -> "cloud:${open.id}" + } + opened.add(id) + }) + + private fun collect(controller: HistoryController): MutableList { + val events = mutableListOf() + val listener = object : ListDataListener { + override fun intervalAdded(e: ListDataEvent) = record() + + override fun intervalRemoved(e: ListDataEvent) = record() + + override fun contentsChanged(e: ListDataEvent) = record() + + private fun record() { + assertTrue(ApplicationManager.getApplication().isDispatchThread) + events.add("loading=${controller.local.loading} size=${controller.local.items.size} error=${controller.local.error}") + } + } + controller.local.addListDataListener(listener) + Disposer.register(parent) { controller.local.removeListDataListener(listener) } + return events + } + + private fun flush() = runBlocking { + repeat(5) { + delay(100) + ApplicationManager.getApplication().invokeAndWait { UIUtil.dispatchAllInvocationEvents() } + } + } + + private fun key(component: JComponent, code: Int) { + val action = component.getActionForKeyStroke(KeyStroke.getKeyStroke(code, 0)) + requireNotNull(action).actionPerformed(null) + } + + private fun session(id: String, title: String, updated: Double = 2.0) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = title, + version = "1", + time = SessionTimeDto(created = 1.0, updated = updated), + ) + + private fun cloud(id: String, title: String, updated: Instant = Instant.parse("2026-01-02T00:00:00Z")) = cloud(id, title, updated.toString()) + + private fun cloud(id: String, title: String, updated: String) = CloudSessionDto( + id = id, + title = title, + createdAt = "2026-01-01T00:00:00Z", + updatedAt = updated, + version = 1.0, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index fa3d3856afa..fd8eea08536 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -332,6 +332,17 @@ class SessionModelTest : UsefulTestCase() { assertEquals(state, (events.single() as SessionModelEvent.StateChanged).state) } + fun `test setState duplicate is no-op`() { + val state = SessionState.Busy("thinking") + model.setState(state) + events.clear() + + model.setState(state) + + assertTrue(events.isEmpty()) + assertEquals(state, model.state) + } + fun `test setState to Error stores error data`() { model.setState(SessionState.Error("something broke", "timeout")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt index a2c12370def..16536b70276 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ConnectionPanelTest.kt @@ -1,8 +1,8 @@ package ai.kilocode.client.session.ui -import ai.kilocode.client.session.update.SessionController -import ai.kilocode.client.session.update.SessionControllerEvent -import ai.kilocode.client.session.update.SessionControllerTestBase +import ai.kilocode.client.session.controller.SessionController +import ai.kilocode.client.session.controller.SessionControllerEvent +import ai.kilocode.client.session.controller.SessionControllerTestBase import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.ConfigWarningDto import ai.kilocode.rpc.dto.KiloAppStateDto @@ -45,7 +45,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertTrue(panel.isVisible) assertEquals("CLI startup failed", panel.summaryText()) - assertEquals(UiStyle.Colors.error(), panel.summaryColor()) + assertEquals(UiStyle.Colors.errorLabelForeground(), panel.summaryColor()) assertTrue(panel.toggleVisible()) assertFalse(panel.toggleExpanded()) assertFalse(panel.detailsVisible()) @@ -103,7 +103,7 @@ class ConnectionPanelTest : SessionControllerTestBase() { assertTrue(panel.isVisible) assertEquals("Configuration warnings", panel.summaryText()) - assertEquals(UiStyle.Colors.warning(), panel.summaryColor()) + assertEquals(UiStyle.Colors.warningLabelForeground(), panel.summaryColor()) assertTrue(panel.toggleVisible()) assertFalse(panel.toggleExpanded()) assertFalse(panel.detailsVisible()) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt index 8cd526a4010..ca717ded514 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/EmptySessionPanelTest.kt @@ -4,7 +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.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.ui.style.SessionUiStyle +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 @@ -15,11 +19,14 @@ import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.UIUtil import com.intellij.util.ui.components.BorderLayoutPanel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import java.awt.BorderLayout +import java.awt.Cursor @Suppress("UnstableApiUsage") class EmptySessionPanelTest : BasePlatformTestCase() { @@ -41,7 +48,7 @@ class EmptySessionPanelTest : BasePlatformTestCase() { workspace = workspaces.workspace("/test") controller = SessionController( parent = testRootDisposable, - id = null, + ref = null, sessions = KiloSessionService(project, scope, FakeSessionRpcApi()), workspace = workspace, app = app, @@ -78,11 +85,30 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertTrue(panel.preferredSize.height > 0) } - fun `test content has fixed preferred width`() { + fun `test description width is capped at DESCRIPTION_WIDTH`() { val panel = panel() - assertEquals(com.intellij.util.ui.JBUI.scale(EmptySessionPanel.MAX_WIDTH), panel.contentPreferredSize().width) - assertTrue(panel.contentPreferredSize().height > 0) + assertEquals( + com.intellij.util.ui.JBUI.scale(SessionUiStyle.RecentSessions.DESCRIPTION_WIDTH), + panel.descriptionPreferredSize().width, + ) + assertEquals( + com.intellij.util.ui.JBUI.scale(SessionUiStyle.RecentSessions.DESCRIPTION_WIDTH), + panel.descriptionMaximumSize().width, + ) + } + + fun `test description label is centered`() { + val panel = panel() + + assertEquals(javax.swing.SwingConstants.CENTER, panel.welcomeLabelAlignment()) + } + + fun `test show history button has its own preferred width`() { + val panel = panel() + val btn = panel.historyButtonPreferredWidth() + + assertTrue(btn > 0) } fun `test recent sessions are capped at five`() { @@ -92,12 +118,12 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals(5, panel.recentCount()) } - fun `test explanation uses markdown view`() { + fun `test explanation uses welcome message`() { val panel = panel() assertEquals( "Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase.", - panel.explanationMarkdown(), + panel.explanationText(), ) } @@ -118,6 +144,29 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals(listOf("ses_2"), opened) } + fun `test show history button uses localized text`() { + val panel = panel() + + assertEquals(ai.kilocode.client.plugin.KiloBundle.message("session.showHistory"), panel.showHistoryText()) + } + + fun `test action controls use hand cursor and no show history outline`() { + val panel = panel() + + assertFalse(panel.showHistoryBorderPainted()) + assertEquals(Cursor.HAND_CURSOR, panel.showHistoryCursor()) + assertEquals(Cursor.HAND_CURSOR, panel.recentCursor()) + } + + fun `test clicking show history delegates callback`() { + var calls = 0 + val panel = panel(history = { calls++ }) + + panel.clickShowHistory() + + assertEquals(1, calls) + } + fun `test renderer aligns title center and time east`() { val cell = panel().rendererComponent(session("ses_1")) as BorderLayoutPanel val layout = cell.layout as BorderLayout @@ -137,11 +186,16 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals(selected.background, hovered.background) } - fun `test timestamp normalization handles seconds and milliseconds`() { - val panel = panel() + fun `test renderer reuses history title fallback`() { + val cell = panel().rendererComponent(session("ses_1", title = "")) as BorderLayoutPanel + val label = UIUtil.uiTraverser(cell).filter(JBLabel::class.java).firstOrNull() - assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000.0)) - assertEquals(1_700_000_000_000L, panel.normalize(1_700_000_000_000.0)) + assertEquals("Untitled", label?.text) + } + + fun `test timestamp normalization handles seconds and milliseconds`() { + assertEquals(1_700_000_000_000L, HistoryTime.millis(LocalHistoryItem(session("ses_1", 1_700_000_000)))) + assertEquals(1_700_000_000_000L, HistoryTime.millis(LocalHistoryItem(session("ses_1", 1_700_000_000_000)))) } fun `test timestamp renders coarse relative text`() { @@ -154,14 +208,14 @@ class EmptySessionPanelTest : BasePlatformTestCase() { assertEquals("4d ago", panel.text(session("ses_1", now - 345_600_000), now)) } - private fun panel(recents: List = emptyList()) = - EmptySessionPanel(testRootDisposable, controller, recents) + private fun panel(recents: List = emptyList(), history: () -> Unit = {}) = + EmptySessionPanel(testRootDisposable, controller, recents, history) - private fun session(id: String, updated: Long = 2_000L) = SessionDto( + private fun session(id: String, updated: Long = 2_000L, title: String = "Title $id") = SessionDto( id = id, projectID = "prj", directory = "/repo/$id", - title = "Title $id", + title = title, version = "1", time = SessionTimeDto(created = 1.0, updated = updated.toDouble()), ) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PermissionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PermissionPanelTest.kt index b44519ce852..4f39bb9810a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PermissionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PermissionPanelTest.kt @@ -6,7 +6,8 @@ import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta -import ai.kilocode.client.session.update.SessionController +import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi @@ -53,7 +54,7 @@ class PermissionPanelTest : BasePlatformTestCase() { app = KiloAppService(scope, api) workspaces = KiloWorkspaceService(scope, work) workspace = workspaces.workspace("/test") - controller = SessionController(parent, "ses_test", sessions, workspace, app, scope, BorderLayoutPanel()) + controller = SessionController(parent, SessionRef.Local("ses_test"), sessions, workspace, app, scope, BorderLayoutPanel()) panel = PermissionPanel(controller) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 253e6758e8a..1b210456b57 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.prompt.PromptDataKeys import ai.kilocode.client.session.ui.prompt.PromptPanel import com.intellij.icons.AllIcons @@ -14,7 +15,7 @@ import javax.swing.SwingUtilities class PromptPanelTest : BasePlatformTestCase() { fun `test prompt input uses editor font settings`() { - val style = SessionStyle.current() + val style = SessionEditorStyle.current() val panel = PromptPanel(project, {}, {}) val font = panel.inputFont() @@ -23,7 +24,7 @@ class PromptPanelTest : BasePlatformTestCase() { } fun `test prompt input uses editor background`() { - val style = SessionStyle.current() + val style = SessionEditorStyle.current() val panel = PromptPanel(project, {}, {}) assertEquals(style.editorScheme.defaultBackground, panel.defaultFocusedComponent.background) @@ -31,7 +32,7 @@ class PromptPanelTest : BasePlatformTestCase() { fun `test applyStyle updates prompt input and height`() { val panel = PromptPanel(project, {}, {}) - val style = SessionStyle.create(family = "Courier New", size = 26) + val style = SessionEditorStyle.create(family = "Courier New", size = 26) panel.applyStyle(style) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt index e861ecca773..50d44ea348d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt @@ -4,7 +4,8 @@ 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.update.SessionController +import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.session.controller.SessionController import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption @@ -55,7 +56,7 @@ class QuestionPanelTest : BasePlatformTestCase() { workspaces = KiloWorkspaceService(scope, workspaceRpc) workspace = workspaces.workspace("/test") val root = BorderLayoutPanel() - controller = SessionController(parent, "ses_test", sessions, workspace, app, scope, root) + controller = SessionController(parent, SessionRef.Local("ses_test"), sessions, workspace, app, scope, root) panel = QuestionPanel(controller) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt similarity index 83% rename from packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionStyleTest.kt rename to packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt index 35ba549f76e..b11df3d3320 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionEditorStyleTest.kt @@ -1,15 +1,16 @@ package ai.kilocode.client.session.ui +import ai.kilocode.client.session.ui.style.SessionEditorStyle import com.intellij.openapi.editor.colors.EditorColorsManager import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Font @Suppress("UnstableApiUsage") -class SessionStyleTest : BasePlatformTestCase() { +class SessionEditorStyleTest : BasePlatformTestCase() { fun `test transcript font uses editor settings`() { val scheme = EditorColorsManager.getInstance().globalScheme - val style = SessionStyle.current() + val style = SessionEditorStyle.current() val font = style.transcriptFont assertEquals(scheme.editorFontName, font.name) @@ -20,7 +21,7 @@ class SessionStyleTest : BasePlatformTestCase() { } fun `test bold editor font uses editor family and size`() { - val style = SessionStyle.current() + val style = SessionEditorStyle.current() val font = style.boldEditorFont assertEquals(style.editorFamily, font.name) @@ -29,7 +30,7 @@ class SessionStyleTest : BasePlatformTestCase() { } fun `test small editor font uses editor family with smaller editor-derived size`() { - val style = SessionStyle.current() + val style = SessionEditorStyle.current() val font = style.smallEditorFont assertEquals(style.editorFamily, font.name) @@ -37,7 +38,7 @@ class SessionStyleTest : BasePlatformTestCase() { } fun `test custom style derives fonts from supplied editor baseline`() { - val style = SessionStyle.create(family = "Courier New", size = 22) + val style = SessionEditorStyle.create(family = "Courier New", size = 22) assertEquals("Courier New", style.editorFamily) assertEquals(22, style.editorSize) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 40085e5b4d9..96fbc965366 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -1,6 +1,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.views.TextView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -232,7 +233,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { val message = panel.findMessage("a1")!! val text = message.part("p1") as TextView val comp = text.md.component - val style = SessionStyle.create(family = "Courier New", size = 24) + val style = SessionEditorStyle.create(family = "Courier New", size = 24) panel.applyStyle(style) @@ -246,7 +247,7 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { fun `test new content after applyStyle uses queued style`() { model.upsertMessage(msg("a1", "assistant")) - val style = SessionStyle.create(family = "Courier New", size = 25) + val style = SessionEditorStyle.create(family = "Courier New", size = 25) panel.applyStyle(style) model.updateContent("a1", part("p1", "a1", "text", text = "hello")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt index b00c0596e73..217c8ca94d2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanelTest.kt @@ -5,8 +5,8 @@ import ai.kilocode.client.session.model.StepFinish import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.model.ToolKind -import ai.kilocode.client.session.ui.SessionStyle -import ai.kilocode.client.session.update.SessionControllerTestBase +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.controller.SessionControllerTestBase import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -51,7 +51,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { fun `test shows populated session header`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) - val style = SessionStyle.current() + val style = SessionEditorStyle.current() assertTrue(panel.isVisible) assertTrue(panel.isExpanded()) @@ -109,7 +109,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { fun `test apply style updates header colors`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) - val style = SessionStyle.current().copy( + val style = SessionEditorStyle.current().copy( editorForeground = Color(1, 2, 3), editorBackground = Color(4, 5, 6), ) @@ -130,7 +130,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { fun `test expanded body shows timeline context and token metrics`() { val c = promptedHeader() val panel = SessionHeaderPanel(c, parent) - val style = SessionStyle.current() + val style = SessionEditorStyle.current() val body = panel.bodyPanel() val timeline = panel.timelinePanel() val bar = panel.contextBar() @@ -465,7 +465,7 @@ class SessionHeaderPanelTest : SessionControllerTestBase() { assertEquals(0, panel.timelineViewport().viewPosition.y) } - private fun promptedHeader(): ai.kilocode.client.session.update.SessionController { + private fun promptedHeader(): ai.kilocode.client.session.controller.SessionController { appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto( ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"), diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt deleted file mode 100644 index 1f5de63b397..00000000000 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/update/ViewSwitchingTest.kt +++ /dev/null @@ -1,212 +0,0 @@ -package ai.kilocode.client.session.update - -import kotlinx.coroutines.CompletableDeferred - -class ViewSwitchingTest : SessionControllerTestBase() { - - fun `test first prompt shows messages view`() { - val m = controller() - val events = collect(m) - flush() - events.clear() - - edt { m.prompt("hello") } - flush() - - assertControllerEvents("ViewChanged session", events) - assertSession( - """ - [app: DISCONNECTED] [workspace: PENDING] - """, - m, - ) - } - - fun `test session event sees updated view state on EDT`() { - val m = controller() - val states = collectStates(m) - flush() - states.clear() - - edt { m.prompt("hello") } - flush() - - val state = states.single { it.first is SessionControllerEvent.ViewChanged.ShowSession }.second - assertTrue(state.showSession) - assertEquals(SessionControllerEvent.ViewChanged.ShowSession, state.viewState) - } - - fun `test ViewChanged not fired twice`() { - val m = controller() - val events = collect(m) - flush() - events.clear() - - edt { m.prompt("first") } - flush() - edt { m.prompt("second") } - flush() - - assertControllerEvents("ViewChanged session", events) - } - - fun `test recent sessions show after workspace ready`() { - projectRpc.state.value = workspaceReady() - rpc.recent.add(session("ses_1")) - val m = controller() - val events = collect(m) - - flush() - - assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) - assertControllerEvents(""" - AppChanged - WorkspaceChanged - WorkspaceReady - ViewChanged recents=1 - """, events) - } - - fun `test recent load failure shows empty recents`() { - projectRpc.state.value = workspaceReady() - rpc.recentFailures = 1 - val m = controller() - val events = collect(m) - - flush() - - assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) - assertControllerEvents(""" - AppChanged - WorkspaceChanged - WorkspaceReady - ViewChanged recents=0 - """, events) - } - - fun `test empty history transitions to recents`() { - rpc.recent.add(session("ses_1")) - val m = controller("ses_test", displayMs = 1_000) - val events = collect(m) - - flush() - - assertControllerEvents(""" - AppChanged - WorkspaceChanged - ViewChanged recents=1 - """, events) - } - - fun `test slow recents show progress after delay then recents`() { - projectRpc.state.value = workspaceReady() - rpc.recent.add(session("ses_1")) - val gate = CompletableDeferred() - rpc.recentGate = gate - val m = controller(displayMs = 50) - val events = collect(m) - - pause(20) - assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) - assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) - - pause(80) - assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) - - gate.complete(Unit) - flush() - - assertEquals( - """ - ViewChanged progress - ViewChanged recents=1 - """.trimIndent().trim(), - events.filterIsInstance().joinToString("\n"), - ) - } - - fun `test recents progress event sees loading state on EDT`() { - projectRpc.state.value = workspaceReady() - rpc.recent.add(session("ses_1")) - val gate = CompletableDeferred() - rpc.recentGate = gate - val m = controller(displayMs = 50) - val states = collectStates(m) - - pause(80) - - val state = states.single { it.first is SessionControllerEvent.ViewChanged.ShowProgress }.second - assertEquals(SessionControllerEvent.ViewChanged.ShowProgress, state.viewState) - assertTrue(state.recentsState.startsWith("Loading")) - - gate.complete(Unit) - flush() - } - - fun `test fast recents suppress progress`() { - projectRpc.state.value = workspaceReady() - rpc.recent.add(session("ses_1")) - val m = controller(displayMs = 1_000) - val events = collect(m) - - flush() - - assertTrue(rpc.recentCalls.contains("/test" to SessionController.RECENT_LIMIT)) - assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) - assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) - } - - fun `test recents event sees loaded state on EDT`() { - projectRpc.state.value = workspaceReady() - rpc.recent.add(session("ses_1")) - val m = controller(displayMs = 1_000) - val states = collectStates(m) - - flush() - - val event = states.single { it.first is SessionControllerEvent.ViewChanged.ShowRecents } - assertEquals(event.first, event.second.viewState) - assertEquals("Loaded", event.second.recentsState) - } - - fun `test failed fast recents suppress progress and show empty recents`() { - projectRpc.state.value = workspaceReady() - rpc.recentFailures = 1 - val m = controller(displayMs = 1_000) - val events = collect(m) - - flush() - - assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) - assertEquals(1, events.count { it is SessionControllerEvent.ViewChanged.ShowRecents }) - assertTrue(events.filterIsInstance().single().recents.isEmpty()) - } - - fun `test recents progress is canceled when messages view appears`() { - projectRpc.state.value = workspaceReady() - rpc.recent.add(session("ses_1")) - val gate = CompletableDeferred() - rpc.recentGate = gate - val m = controller(displayMs = 50) - val events = collect(m) - - pause(20) - edt { m.prompt("hello") } - pause(80) - gate.complete(Unit) - flush() - - assertTrue(events.any { it is SessionControllerEvent.ViewChanged.ShowSession }) - assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowProgress }) - assertFalse(events.any { it is SessionControllerEvent.ViewChanged.ShowRecents }) - } - - private fun session(id: String) = ai.kilocode.rpc.dto.SessionDto( - id = id, - projectID = "prj", - directory = "/test", - title = "Title $id", - version = "1", - time = ai.kilocode.rpc.dto.SessionTimeDto(created = 1.0, updated = 2.0), - ) -} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt index c3d62fd07c8..6cae7834def 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewTest.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Reasoning -import ai.kilocode.client.session.ui.SessionStyle +import ai.kilocode.client.session.ui.style.SessionEditorStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase import javax.swing.ScrollPaneConstants @@ -127,7 +127,7 @@ class ReasoningViewTest : BasePlatformTestCase() { } fun `test reasoning markdown uses editor font settings`() { - val style = SessionStyle.current() + val style = SessionEditorStyle.current() val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) assertSmallItalicSheet(view.md.overrideSheet(), style) @@ -135,7 +135,7 @@ class ReasoningViewTest : BasePlatformTestCase() { } fun `test reasoning header uses smaller editor-derived font`() { - val style = SessionStyle.current() + val style = SessionEditorStyle.current() val view = ReasoningView(reasoning("p1", done = true, text = "one")) val font = view.headerFont() @@ -146,7 +146,7 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test applyStyle updates reasoning in place`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) val component = view.md.component - val style = SessionStyle.create(family = "Courier New", size = 24) + val style = SessionEditorStyle.create(family = "Courier New", size = 24) view.applyStyle(style) @@ -163,12 +163,12 @@ class ReasoningViewTest : BasePlatformTestCase() { assertTrue(view.preferredSize.height > 0) } - private fun assertEditorSheet(sheet: String, style: SessionStyle) { + private fun assertEditorSheet(sheet: String, style: SessionEditorStyle) { assertTrue(sheet.contains(style.editorFamily)) assertTrue(sheet.contains("${style.editorSize}pt")) } - private fun assertSmallItalicSheet(sheet: String, style: SessionStyle) { + private fun assertSmallItalicSheet(sheet: String, style: SessionEditorStyle) { assertTrue(sheet.contains(style.editorFamily)) assertFalse(sheet.contains("${style.editorSize}pt")) assertTrue(sheet.contains("font-style: italic")) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt index 3bd72a98a03..a457b45d9e2 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Text -import ai.kilocode.client.session.ui.SessionStyle +import ai.kilocode.client.session.ui.style.SessionEditorStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase /** @@ -78,7 +78,7 @@ class TextViewTest : BasePlatformTestCase() { } fun `test markdown uses editor font settings`() { - val style = SessionStyle.current() + val style = SessionEditorStyle.current() val view = TextView(Text("p1")) val sheet = view.md.overrideSheet() @@ -89,7 +89,7 @@ class TextViewTest : BasePlatformTestCase() { fun `test applyStyle updates markdown in place`() { val view = TextView(Text("p1")) val component = view.md.component - val style = SessionStyle.create(family = "Courier New", size = 23) + val style = SessionEditorStyle.create(family = "Courier New", size = 23) view.applyStyle(style) val sheet = view.md.overrideSheet() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt index e5579fda720..1486718c479 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt @@ -4,8 +4,8 @@ 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.model.toolKind -import ai.kilocode.client.session.ui.SessionStyle -import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase import javax.swing.ScrollPaneConstants @@ -208,7 +208,7 @@ class ToolViewTest : BasePlatformTestCase() { } fun `test bash output uses editor font settings`() { - val style = SessionStyle.current() + val style = SessionEditorStyle.current() val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) assertEditorFont(view.bodyFont(), style) @@ -219,7 +219,7 @@ class ToolViewTest : BasePlatformTestCase() { } fun `test tool header uses editor-derived fonts`() { - val style = SessionStyle.current() + val style = SessionEditorStyle.current() val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) assertEditorFont(view.titleFont(), style) @@ -230,7 +230,7 @@ class ToolViewTest : BasePlatformTestCase() { fun `test applyStyle updates tool fonts in place`() { val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) - val style = SessionStyle.create(family = "Courier New", size = 25) + val style = SessionEditorStyle.create(family = "Courier New", size = 25) view.applyStyle(style) @@ -266,7 +266,7 @@ class ToolViewTest : BasePlatformTestCase() { } fun `test large tool output is truncated in preview`() { - val out = "x".repeat(UiStyle.Size.toolBodyLimit() + 1_000) + val out = "x".repeat(SessionUiStyle.View.Tool.PREVIEW_LIMIT + 1_000) val t = tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "log") it.output = out @@ -281,7 +281,7 @@ class ToolViewTest : BasePlatformTestCase() { } fun `test large generic tool output is truncated in preview`() { - val out = "x".repeat(UiStyle.Size.toolBodyLimit() + 1_000) + val out = "x".repeat(SessionUiStyle.View.Tool.PREVIEW_LIMIT + 1_000) val t = tool("p1", "glob", ToolExecState.COMPLETED).also { it.output = out } @@ -329,12 +329,12 @@ class ToolViewTest : BasePlatformTestCase() { private fun tool(id: String, name: String, state: ToolExecState, title: String? = null): Tool = Tool(id, name, toolKind(name)).also { it.state = state; it.title = title } - private fun assertEditorFont(font: java.awt.Font, style: SessionStyle) { + private fun assertEditorFont(font: java.awt.Font, style: SessionEditorStyle) { assertEquals(style.editorFamily, font.name) assertEquals(style.editorSize, font.size) } - private fun assertSmallEditorFont(font: java.awt.Font, style: SessionStyle) { + private fun assertSmallEditorFont(font: java.awt.Font, style: SessionEditorStyle) { assertEquals(style.editorFamily, font.name) assertTrue(font.size < style.editorSize) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt index ca961ee6650..8427cce067e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -6,10 +6,11 @@ import ai.kilocode.client.session.model.Text 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 ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI /** * Tests for [TurnView] and [MessageView]. @@ -170,7 +171,10 @@ class TurnViewTest : BasePlatformTestCase() { mv.setSize(400, 200) mv.doLayout() - assertEquals(UiStyle.Card.groupGap(), mv.part("t1")!!.y - mv.part("r1")!!.bounds.maxY.toInt()) + assertEquals( + JBUI.scale(SessionUiStyle.SessionLayout.GAP), + mv.part("t1")!!.y - mv.part("r1")!!.bounds.maxY.toInt(), + ) } fun `test consecutive messages use shared compact gap`() { @@ -187,7 +191,7 @@ class TurnViewTest : BasePlatformTestCase() { val first = tv.messageView("u1")!! val second = tv.messageView("a2")!! - assertEquals(UiStyle.Card.groupGap(), second.y - first.bounds.maxY.toInt()) + assertEquals(JBUI.scale(SessionUiStyle.SessionLayout.GAP), second.y - first.bounds.maxY.toInt()) } // ------ helpers ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 49127d853ae..dd7c8537930 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -2,6 +2,8 @@ package ai.kilocode.client.testing import ai.kilocode.rpc.KiloSessionRpcApi 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.MessageWithPartsDto import ai.kilocode.rpc.dto.ModelSelectionDto @@ -50,6 +52,14 @@ class FakeSessionRpcApi : KiloSessionRpcApi { var recentFailures = 0 var recentGate: CompletableDeferred? = null + /** Local sessions returned by [list]. */ + val listed = mutableListOf() + + /** Cloud sessions returned by [cloudSessions]. */ + val cloud = mutableListOf() + var cloudCursor: String? = null + var importedCloudSession = session + /** Push chat events here; tests collect from [events]. */ val events = MutableSharedFlow(extraBufferCapacity = 64, replay = 64) @@ -75,10 +85,16 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val permissionRulesSaved = mutableListOf>() val questionReplies = mutableListOf>() val questionRejects = mutableListOf>() + val deletes = mutableListOf>() + val lists = mutableListOf() val recentCalls = mutableListOf>() + val cloudCalls = mutableListOf() + val imports = mutableListOf>() var creates = 0 private set + data class CloudCall(val directory: String, val cursor: String?, val limit: Int, val gitUrl: String?) + // --- Implementation --- override suspend fun create(directory: String): SessionDto { @@ -89,7 +105,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi { override suspend fun list(directory: String): SessionListDto { assertNotEdt("list") - return SessionListDto(emptyList(), emptyMap()) + lists.add(directory) + return SessionListDto(listed.toList(), emptyMap()) } override suspend fun recent(directory: String, limit: Int): SessionListDto { @@ -110,6 +127,20 @@ class FakeSessionRpcApi : KiloSessionRpcApi { override suspend fun delete(id: String, directory: String) { assertNotEdt("delete") + deletes.add(id to directory) + listed.removeAll { it.id == id } + } + + override suspend fun cloudSessions(directory: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto { + assertNotEdt("cloudSessions") + cloudCalls.add(CloudCall(directory, cursor, limit, gitUrl)) + return CloudSessionListDto(cloud.take(limit), cloudCursor) + } + + override suspend fun importCloudSession(id: String, directory: String): SessionDto { + assertNotEdt("importCloudSession") + imports.add(id to directory) + return importedCloudSession } override suspend fun statuses(): Flow> { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt index df7b02889e4..73269d10499 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/UiStyleTest.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.ui +import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI import java.awt.Color @Suppress("UnstableApiUsage") @@ -8,7 +10,7 @@ class UiStyleTest : BasePlatformTestCase() { fun `test border is lighter than dark panel`() { val panel = Color(0, 0, 0) - val border = UiStyle.Colors.contrast(panel, UiStyle.Colors.BORDER_DELTA) + val border = UiStyle.Colors.contrast(panel, SessionUiStyle.View.BORDER_DELTA) assertTrue(border.red > panel.red) assertTrue(border.green > panel.green) @@ -17,7 +19,7 @@ class UiStyleTest : BasePlatformTestCase() { fun `test border is darker than light panel`() { val panel = Color(255, 255, 255) - val border = UiStyle.Colors.contrast(panel, UiStyle.Colors.BORDER_DELTA) + val border = UiStyle.Colors.contrast(panel, SessionUiStyle.View.BORDER_DELTA) assertTrue(border.red < panel.red) assertTrue(border.green < panel.green) @@ -26,8 +28,8 @@ class UiStyleTest : BasePlatformTestCase() { fun `test hover blends from panel toward border`() { val panel = Color(0, 0, 0) - val border = UiStyle.Colors.contrast(panel, UiStyle.Colors.BORDER_DELTA) - val hover = UiStyle.Colors.blend(panel, border, UiStyle.Colors.HOVER_ALPHA) + val border = UiStyle.Colors.contrast(panel, SessionUiStyle.View.BORDER_DELTA) + val hover = UiStyle.Colors.blend(panel, border, SessionUiStyle.View.HOVER_ALPHA) assertTrue(hover.red > panel.red) assertTrue(hover.red < border.red) @@ -35,12 +37,12 @@ class UiStyleTest : BasePlatformTestCase() { assertEquals(hover.green, hover.blue) } - fun `test card helpers provide shared geometry`() { - assertNotNull(UiStyle.Card.layout()) - assertNotNull(UiStyle.Card.border()) - assertNotNull(UiStyle.Card.divider()) - assertNotNull(UiStyle.Card.headerInsets()) - assertNotNull(UiStyle.Card.bodyInsets()) - assertTrue(UiStyle.Card.groupGap() > 0) + fun `test session layout constants provide shared geometry`() { + assertTrue(JBUI.scale(SessionUiStyle.SessionLayout.GAP) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING) > 0) + assertTrue(SessionUiStyle.View.Tool.BODY_LINES > 0) + assertTrue(SessionUiStyle.View.Reasoning.BODY_LINES > 0) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt index 025fb5787c7..8a40caf7337 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewTest.kt @@ -414,4 +414,5 @@ class MdViewTest : BasePlatformTestCase() { view.resetStyles() assertTrue(view.html().contains("")) } + } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index d12efefb742..c467af110d1 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -1,6 +1,7 @@ package ai.kilocode.rpc 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 @@ -50,6 +51,12 @@ interface KiloSessionRpcApi : RemoteApi { /** Delete a session. */ suspend fun delete(id: String, directory: String) + /** List cloud-backed sessions. */ + suspend fun cloudSessions(directory: String, cursor: String?, limit: Int, gitUrl: String?): CloudSessionListDto + + /** Import a cloud-backed session into local storage. */ + suspend fun importCloudSession(id: String, directory: String): SessionDto + /** Observe live session status changes. */ suspend fun statuses(): Flow> diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/HistoryDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/HistoryDto.kt new file mode 100644 index 00000000000..ce8115e8914 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/HistoryDto.kt @@ -0,0 +1,18 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CloudSessionDto( + val id: String, + val title: String? = null, + val createdAt: String, + val updatedAt: String, + val version: Double, +) + +@Serializable +data class CloudSessionListDto( + val sessions: List, + val nextCursor: String? = null, +) diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index c284e8fbfb5..c057306a42f 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.49", + "version": "7.2.50", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 7ad72ec064b..a6f51b2f047 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.49", + "version": "7.2.50", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/diff.tsx b/packages/kilo-ui/src/components/diff.tsx index 43abaaf2459..febb70ca813 100644 --- a/packages/kilo-ui/src/components/diff.tsx +++ b/packages/kilo-ui/src/components/diff.tsx @@ -256,10 +256,7 @@ export function Diff(props: DiffProps) { host.removeAttribute("data-color-scheme") } - // Patch a bug in @pierre/diffs where `grid-template-columns: 100% auto` is set - // for `line-info-basic` separators under `@media (pointer: fine)`, causing the - // expand button to consume 100% of the gutter width and overlap the separator - // content text. We inject into `@layer unsafe` which overrides `@layer base`. + // Patch Pierre shadow styles for Kilo-specific layout and contrast tweaks. let separatorPatchSheet: CSSStyleSheet | null = null const patchSeparatorLayout = () => { const root = getRoot() @@ -267,7 +264,7 @@ export function Diff(props: DiffProps) { if (!separatorPatchSheet) { separatorPatchSheet = new CSSStyleSheet() separatorPatchSheet.replaceSync( - `@layer unsafe { @media (pointer: fine) { [data-separator='line-info-basic'][data-expand-index] [data-separator-wrapper] { grid-template-columns: 34px auto; } } }`, + `@layer unsafe { @media (pointer: fine) { [data-separator='line-info-basic'][data-expand-index] [data-separator-wrapper] { grid-template-columns: 34px auto; } } [data-utility-button] { background-color: var(--vscode-button-background, var(--button-primary-base, var(--icon-interactive-base))); color: var(--vscode-button-foreground, var(--icon-invert-base, #fff)); } }`, ) } if (!root.adoptedStyleSheets.includes(separatorPatchSheet)) diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index aeb0fd6cadb..b93f0c6e273 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,26 @@ # kilo-code +## 7.2.50 + +### Patch Changes + +- [#10082](https://github.com/Kilo-Org/kilocode/pull/10082) [`0aa908c`](https://github.com/Kilo-Org/kilocode/commit/0aa908c7afe9f5e43c77ba157936511a7b959c9f) - 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. + +- [#10082](https://github.com/Kilo-Org/kilocode/pull/10082) [`a5f2fbc`](https://github.com/Kilo-Org/kilocode/commit/a5f2fbc9fdba4c51adcdbf6298088a323631f769) - Move Agent Manager tab creation and search controls to the left side of the tab bar with clearer toolbar separators. + +- [#10134](https://github.com/Kilo-Org/kilocode/pull/10134) [`0e8d2fe`](https://github.com/Kilo-Org/kilocode/commit/0e8d2fe0911719a2e364a43fdc70b1a18ce4d935) - Fix session cost breakdowns after reloading conversations with subagents. + +- [#10125](https://github.com/Kilo-Org/kilocode/pull/10125) [`fc6ce73`](https://github.com/Kilo-Org/kilocode/commit/fc6ce735c9dde64a544e0952d789397fa9fb7815) - Hide the task header graph area until timeline data is available. + +- [#10129](https://github.com/Kilo-Org/kilocode/pull/10129) [`d858252`](https://github.com/Kilo-Org/kilocode/commit/d8582521c3d0b1daef7ca6da64cc2a8340786ca9) - Keep older revert checkpoints available after reverting a message in the VS Code extension. + +- [#10130](https://github.com/Kilo-Org/kilocode/pull/10130) [`5482cc0`](https://github.com/Kilo-Org/kilocode/commit/5482cc0d09f7c22e15063c75dac223f13b1fa4f9) - Improve review comment plus icon contrast in diff viewers. + +- [#10127](https://github.com/Kilo-Org/kilocode/pull/10127) [`3259008`](https://github.com/Kilo-Org/kilocode/commit/3259008074cafe232a30d380e9ecc73c63ca0a35) - Keep shell command blocks stable while output streams so pending animations and expanded state do not reset. + +- Updated dependencies [[`5482cc0`](https://github.com/Kilo-Org/kilocode/commit/5482cc0d09f7c22e15063c75dac223f13b1fa4f9)]: + - @kilocode/kilo-ui@7.2.50 + ## 7.2.49 ## 7.2.48 diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index a66d000db5b..33d50e240e2 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.2.49", + "version": "7.2.50", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts b/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts new file mode 100644 index 00000000000..83f03b0fa76 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/revert-checkpoints.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "../..") +const TURN_FILE = path.join(ROOT, "webview-ui/src/components/chat/VscodeSessionTurn.tsx") + +const src = fs.readFileSync(TURN_FILE, "utf-8") + +describe("message revert checkpoints", () => { + it("keeps revert actions available after a session is already reverted", () => { + expect(src).toMatch(/onRevert=\{\s*assistantMessages\(\)\.length > 0\s*\? \(\) =>/) + expect(src).not.toMatch(/onRevert=\{[\s\S]*?&& !session\.revert\(\)[\s\S]*?\? \(\) =>/) + }) + + it("only marks revert disabled while the agent is busy", () => { + expect(src).toMatch(/data-revert-disabled=\{\s*assistantMessages\(\)\.length > 0 && session\.status\(\) !== "idle"/) + expect(src).not.toMatch(/data-revert-disabled=\{[\s\S]*?!session\.revert\(\)/) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-utils.test.ts b/packages/kilo-vscode/tests/unit/session-utils.test.ts index 64454c9e51e..e308a576e3d 100644 --- a/packages/kilo-vscode/tests/unit/session-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/session-utils.test.ts @@ -4,6 +4,7 @@ import { calcTotalCost, calcContextUsage, buildFamilyCosts, + buildFamilyParents, buildFamilyLabels, buildCostBreakdown, collapseCostBreakdown, @@ -287,6 +288,83 @@ describe("buildFamilyCosts", () => { expect(costs.size).toBe(1) expect(costs.get("s1")).toBeCloseTo(0.01) }) + + it("subtracts child totals using task parent links when session metadata is missing", () => { + const family = new Set(["root", "child"]) + const messages = { + root: [msg("m1", "assistant", 0.15)], + child: [msg("m2", "assistant", 0.1)], + } + const parts = { m1: [toolPart("task", "child", { subagent_type: "explore" })] } + const parents = buildFamilyParents(family, messages, parts) + const costs = buildFamilyCosts(family, messages, { root: {} }, parents) + expect(costs.get("root")).toBeCloseTo(0.05) + expect(costs.get("child")).toBeCloseTo(0.1) + }) + + it("returns own costs for a nested propagated subagent chain", () => { + const ids = ["root", "a", "b", "c"] + const own = new Map([ + ["root", 1], + ["a", 2], + ["b", 3], + ["c", 4], + ]) + const total = ids.reduce((sum, sid) => sum + own.get(sid)!, 0) + const subtree = (index: number) => ids.slice(index).reduce((sum, sid) => sum + own.get(sid)!, 0) + const messages = Object.fromEntries(ids.map((sid, index) => [sid, [msg(`m-${sid}`, "assistant", subtree(index))]])) + const sessions = Object.fromEntries(ids.map((sid, index) => [sid, index === 0 ? {} : { parentID: ids[index - 1] }])) + + const costs = buildFamilyCosts(new Set(ids), messages, sessions) + const sum = [...costs.values()].reduce((acc, cost) => acc + cost, 0) + const bad = ids.reduce((acc, _sid, index) => acc + subtree(index), 0) + + expect(bad).toBe(30) + expect(costs.get("root")).toBe(1) + expect(costs.get("a")).toBe(2) + expect(costs.get("b")).toBe(3) + expect(costs.get("c")).toBe(4) + expect(sum).toBe(total) + }) + + it("returns own costs for a nested chain using task-derived parent links", () => { + const ids = ["root", "a", "b", "c"] + const own = { root: 1, a: 2, b: 3, c: 4 } + const subtree = (index: number) => ids.slice(index).reduce((sum, sid) => sum + own[sid as keyof typeof own], 0) + const messages = Object.fromEntries(ids.map((sid, index) => [sid, [msg(`m-${sid}`, "assistant", subtree(index))]])) + const parts = Object.fromEntries( + ids + .slice(0, -1) + .map((sid, index) => [`m-${sid}`, [toolPart("task", ids[index + 1], { subagent_type: "explore" })]]), + ) + const parents = buildFamilyParents(new Set(ids), messages, parts) + const costs = buildFamilyCosts(new Set(ids), messages, { root: {} }, parents) + const sum = [...costs.values()].reduce((acc, cost) => acc + cost, 0) + + expect(parents.size).toBe(3) + expect(costs.get("root")).toBe(1) + expect(costs.get("a")).toBe(2) + expect(costs.get("b")).toBe(3) + expect(costs.get("c")).toBe(4) + expect(sum).toBe(10) + }) +}) + +describe("buildFamilyParents", () => { + it("derives child-to-parent links from task tool parts", () => { + const family = new Set(["root", "child"]) + const messages = { root: [msg("m1", "assistant")], child: [msg("m2", "assistant")] } + const parts = { m1: [toolPart("task", "child", { subagent_type: "general" })] } + const parents = buildFamilyParents(family, messages, parts) + expect(parents.get("child")).toBe("root") + }) + + it("ignores task parts that point outside the family", () => { + const family = new Set(["root"]) + const messages = { root: [msg("m1", "assistant")] } + const parts = { m1: [toolPart("task", "orphan", { subagent_type: "general" })] } + expect(buildFamilyParents(family, messages, parts).size).toBe(0) + }) }) describe("buildFamilyLabels", () => { @@ -494,4 +572,17 @@ describe("collapseCostBreakdown", () => { expect(aggregated.label).toBe("12 older sessions") expect(aggregated.cost).toBeCloseTo(0.05 * 12) }) + + it("collapses older nested rows after own-cost correction, not subtree totals", () => { + const items = [ + { label: "This session", cost: 1 }, + ...Array.from({ length: 10 }, (_, i) => ({ label: "explore", cost: i + 2 })), + ] + const result = collapseCostBreakdown(items, summary) + const hidden = result.at(-1) + const shown = result.reduce((sum, item) => sum + item.cost, 0) + + expect(hidden).toEqual({ label: "2 older sessions", cost: 2 + 3 }) + expect(shown).toBe(1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11) + }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 909688ed72d..5181f537af1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -1991,8 +1991,8 @@ body.am-wt-dragging-active * { .am-markdown-comment-button:hover, .am-markdown-comment-button:focus-visible { - color: var(--text-base); - background: var(--surface-interactive-base); + color: var(--vscode-button-foreground, var(--icon-invert-base, #fff)); + background: var(--vscode-button-background, var(--button-primary-base, var(--icon-interactive-base))); } .am-markdown-line-number { diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index 998d610d579..9acf713193d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -67,19 +67,26 @@ interface AssistantMessageProps { feedback?: MessageFeedbackControls } +type ToolStateProps = { + input?: Record + metadata?: Record + output?: string + status?: string +} + function TodoToolCard(props: { part: ToolPart }) { const render = ToolRegistry.render(props.part.tool) - const state = props.part.state as any + const state = () => props.part.state as ToolStateProps return ( {(renderFn) => ( @@ -90,23 +97,23 @@ function TodoToolCard(props: { part: ToolPart }) { function BashToolCard(props: { part: ToolPart; defaultOpen: boolean }) { const render = ToolRegistry.render(props.part.tool) - const state = props.part.state as any + const state = () => props.part.state as ToolStateProps return ( {(card) => ( >} - input={state?.input ?? {}} - metadata={state?.metadata ?? {}} + input={state()?.input ?? {}} + metadata={state()?.metadata ?? {}} partMetadata={props.part.metadata ?? {}} tool={props.part.tool} partID={props.part.id} callID={props.part.callID} - output={state?.output} - status={state?.status} + output={state()?.output} + status={state()?.status} defaultOpen={props.defaultOpen} animate - reveal={state?.status === "pending" || state?.status === "running"} + reveal={state()?.status === "pending" || state()?.status === "running"} /> )} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx index 9e9da21929e..66f05f92503 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PermissionDock.tsx @@ -21,6 +21,7 @@ import { describePatterns, resolveLabel, savedRuleStates, type RuleDecision } fr import { PermissionCommand } from "./PermissionCommand" import { PermissionDiff } from "./PermissionDiff" import { permissionDiffs } from "./permission-diff-utils" +import { normalizeUrls } from "../../../../../opencode/src/kilocode/util/url" import type { PermissionRequest } from "../../types/messages" let rulesExpandedPreference = false @@ -42,7 +43,9 @@ export const PermissionDock: Component<{ const label = (rule: string) => (rule === "*" ? "" : rule.replace(/ \*$/, "")) const command = () => { const cmd = props.request.args?.command - return typeof cmd === "string" ? cmd : undefined + if (typeof cmd !== "string") return undefined + // Normalize IDN/Unicode hostnames to punycode ASCII to prevent homograph attacks. + return normalizeUrls(cmd) } const description = createMemo(() => command() ? null : describePatterns(props.request.toolName, props.request.patterns, language.t), diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 30bb1a56af8..3ff6d7650bb 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -934,6 +934,12 @@ export const PromptInput: Component = (props) => { {ghost.text()} + {/* A
with white-space: pre-wrap collapses a trailing newline, + but a