diff --git a/.changeset/agent-manager-collapsible-sidebar.md b/.changeset/agent-manager-collapsible-sidebar.md new file mode 100644 index 00000000000..921f175b225 --- /dev/null +++ b/.changeset/agent-manager-collapsible-sidebar.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add a collapsible sidebar in the Agent Manager. The toggle button sits left of the tab title and the collapsed state persists across reloads and restarts. Starting a new session or worktree automatically reopens the sidebar so the new entry is visible. diff --git a/.changeset/agent-manager-state-exclude.md b/.changeset/agent-manager-state-exclude.md new file mode 100644 index 00000000000..43f9c067e14 --- /dev/null +++ b/.changeset/agent-manager-state-exclude.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep Agent Manager state hidden from Git before creating local sessions. diff --git a/.changeset/bright-checkpoints-graph.md b/.changeset/bright-checkpoints-graph.md new file mode 100644 index 00000000000..c9812a3ca69 --- /dev/null +++ b/.changeset/bright-checkpoints-graph.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep VS Code session timeline, token, context, and cost stats in sync after restoring a checkpoint. diff --git a/.changeset/codex-chatgpt-reauth.md b/.changeset/codex-chatgpt-reauth.md new file mode 100644 index 00000000000..50ba4d3fd1c --- /dev/null +++ b/.changeset/codex-chatgpt-reauth.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Show ChatGPT sign-in again when Codex authentication expires. diff --git a/.changeset/copy-tooltip-top.md b/.changeset/copy-tooltip-top.md new file mode 100644 index 00000000000..ded0f1a640d --- /dev/null +++ b/.changeset/copy-tooltip-top.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-ui": patch +--- + +Show the assistant message copy tooltip above the button so it no longer overlaps the thumbs up/down buttons. diff --git a/.changeset/fix-tui-diff-hunk-headers.md b/.changeset/fix-tui-diff-hunk-headers.md new file mode 100644 index 00000000000..2406a74b89c --- /dev/null +++ b/.changeset/fix-tui-diff-hunk-headers.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Fix TUI diff rendering when header-like content lines appear inside a unified diff hunk. diff --git a/.changeset/fresh-snapshot-cli.md b/.changeset/fresh-snapshot-cli.md new file mode 100644 index 00000000000..4bdd7e6cf27 --- /dev/null +++ b/.changeset/fresh-snapshot-cli.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Ensure snapshot installs package a fresh CLI backend so new extension features are available in local VSIX builds. diff --git a/.changeset/hide-subagent-sessions.md b/.changeset/hide-subagent-sessions.md new file mode 100644 index 00000000000..eb7a95948e5 --- /dev/null +++ b/.changeset/hide-subagent-sessions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Hide Task tool subagent sessions from Agent Manager session lists. 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/local-worktree-revert-diff.md b/.changeset/local-worktree-revert-diff.md new file mode 100644 index 00000000000..26c094336b3 --- /dev/null +++ b/.changeset/local-worktree-revert-diff.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Move worktree file revert diff status checks into the VS Code extension host so Kilo no longer asks the CLI server to run git diff commands through Bun during workspace and Agent Manager reverts. diff --git a/.changeset/manual-compact-limit.md b/.changeset/manual-compact-limit.md new file mode 100644 index 00000000000..c4d823ca617 --- /dev/null +++ b/.changeset/manual-compact-limit.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": minor +"@kilocode/sdk": minor +"kilo-code": minor +--- + +Support setting an auto-compaction threshold percentage so long sessions can compact before the context window is full. diff --git a/.changeset/model-picker-search-focus.md b/.changeset/model-picker-search-focus.md new file mode 100644 index 00000000000..33e7b6a635f --- /dev/null +++ b/.changeset/model-picker-search-focus.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep the New Worktree model picker open when interacting with its search field. diff --git a/.changeset/persist-chat-tool-cards.md b/.changeset/persist-chat-tool-cards.md new file mode 100644 index 00000000000..4e88b114edf --- /dev/null +++ b/.changeset/persist-chat-tool-cards.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/kilo-ui": patch +--- + +Keep chat tool cards open or closed when streaming updates remount them. diff --git a/.changeset/placeholder-vscode-token.md b/.changeset/placeholder-vscode-token.md new file mode 100644 index 00000000000..4c4ed7a085b --- /dev/null +++ b/.changeset/placeholder-vscode-token.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use the VS Code-native placeholder color for input fields so placeholder text is consistently distinct from values you have typed (Settings, Agent Manager, prompt input, model search, etc.). diff --git a/.changeset/reset-reverted-todos.md b/.changeset/reset-reverted-todos.md new file mode 100644 index 00000000000..03cfcb3a4b3 --- /dev/null +++ b/.changeset/reset-reverted-todos.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Reset the To-dos tab when reverting a session snapshot. diff --git a/.changeset/shell-syntax-highlighting.md b/.changeset/shell-syntax-highlighting.md new file mode 100644 index 00000000000..038f51dd685 --- /dev/null +++ b/.changeset/shell-syntax-highlighting.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add syntax highlighting to shell command output with Shiki, labeled "Command" and "Output" sections, per-section copy buttons, and an "Open in Editor" action that opens the full untruncated output in a VS Code editor tab. diff --git a/.changeset/silent-mermaid-labels.md b/.changeset/silent-mermaid-labels.md new file mode 100644 index 00000000000..0bb5b38ac2d --- /dev/null +++ b/.changeset/silent-mermaid-labels.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Fix Mermaid diagrams rendering with empty text inside every shape by restoring the `foreignObject` HTML integration point that DOMPurify dropped in 3.1.7. diff --git a/.changeset/speech-input-gateway.md b/.changeset/speech-input-gateway.md new file mode 100644 index 00000000000..cf48a6b19bb --- /dev/null +++ b/.changeset/speech-input-gateway.md @@ -0,0 +1,6 @@ +--- +"kilo-code": minor +"@kilocode/kilo-gateway": minor +--- + +Support experimental speech-to-text voice input in VS Code prompt fields through Kilo Gateway. diff --git a/.changeset/tame-responses-items.md b/.changeset/tame-responses-items.md new file mode 100644 index 00000000000..c08aa6f029a --- /dev/null +++ b/.changeset/tame-responses-items.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-gateway": patch +--- + +Prevent Kilo Gateway Responses requests from replaying transient provider item IDs when request storage is disabled. diff --git a/.changeset/terminals-split-diff-sidebar.md b/.changeset/terminals-split-diff-sidebar.md new file mode 100644 index 00000000000..8474c11fc35 --- /dev/null +++ b/.changeset/terminals-split-diff-sidebar.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Support viewing Agent Manager terminal tabs alongside the right diff sidebar, and send review comments to the active terminal. diff --git a/.changeset/vscode-mode-model-sync.md b/.changeset/vscode-mode-model-sync.md new file mode 100644 index 00000000000..142de1d15ab --- /dev/null +++ b/.changeset/vscode-mode-model-sync.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep configured mode models in sync when switching modes in VS Code. diff --git a/.github/workflows/auto-docs.yml b/.github/workflows/auto-docs.yml index 4364ee87480..5f84be47f38 100644 --- a/.github/workflows/auto-docs.yml +++ b/.github/workflows/auto-docs.yml @@ -1,35 +1,113 @@ -name: Trigger webhook for feat PRs +name: Auto-docs dry-run intake +# Trigger on merged feature PRs, or manually with a specific PR number. +# IMPORTANT: This workflow intentionally does NOT check out PR code and does NOT +# execute any code from the PR branch. It only reads PR metadata and forwards it +# to a webhook. This is required for safe use of pull_request_target on a public repo. on: - pull_request: + pull_request_target: types: [closed] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to test" + required: true + +# Minimal permissions: read repo contents and PR metadata only. +permissions: + contents: read + pull-requests: read + +# One run per PR at a time. Does not cancel in progress — let the current run +# finish so we don't drop events when a PR is quickly closed/reopened/closed. +concurrency: + group: auto-docs-${{ github.event.pull_request.number || inputs.pr_number }} + cancel-in-progress: false jobs: - call-webhook: + call-valtown: if: > - github.event.pull_request.merged == true && - (startsWith(github.event.pull_request.title, 'feat:') || - startsWith(github.event.pull_request.title, 'feat(')) + github.event_name == 'workflow_dispatch' || + ( + github.event.pull_request.merged == true && + ( + startsWith(github.event.pull_request.title, 'feat:') || + startsWith(github.event.pull_request.title, 'feat(') + ) + ) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: - - name: Send webhook safely + - name: Build webhook payload + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} + PR_NUMBER_FROM_EVENT: ${{ github.event.pull_request.number }} + PR_NUMBER_FROM_INPUT: ${{ inputs.pr_number }} run: | - payload=$(jq -n \ - --arg repo "${GITHUB_REPOSITORY}" \ - --arg pr_number "${{ github.event.pull_request.number }}" \ - --arg title "${{ github.event.pull_request.title }}" \ - --arg body "${{ github.event.pull_request.body }}" \ - --arg author "${{ github.event.pull_request.user.login }}" \ - --arg merged_at "${{ github.event.pull_request.merged_at }}" \ + set -euo pipefail + + PR_NUMBER="${PR_NUMBER_FROM_EVENT:-$PR_NUMBER_FROM_INPUT}" + + if [ -z "$PR_NUMBER" ]; then + echo "Missing PR number" + exit 1 + fi + + gh api "repos/$REPO/pulls/$PR_NUMBER" > pr.json + + jq -n \ + --arg event_name "$EVENT_NAME" \ + --arg repo "$REPO" \ + --arg pr_number "$PR_NUMBER" \ + --slurpfile pr pr.json \ '{ + event_name: $event_name, repo: $repo, pr_number: $pr_number, - title: $title, - description: $body, - author: $author, - merged_at: $merged_at - }' - ) - curl --fail-with-body -X POST "${{ secrets.DOC_WEBHOOK_URL }}" \ + title: $pr[0].title, + description: ($pr[0].body // ""), + author: $pr[0].user.login, + merged_at: ($pr[0].merged_at // ""), + pr_url: $pr[0].html_url, + base_branch: $pr[0].base.ref, + head_branch: $pr[0].head.ref + }' > payload.json + + echo "Payload created:" + jq '{ + event_name, + repo, + pr_number, + title, + author, + merged_at, + pr_url, + base_branch, + head_branch + }' payload.json + + - name: Send webhook to Val Town + env: + DOC_WEBHOOK_URL: ${{ secrets.DOC_WEBHOOK_URL }} + DOC_WEBHOOK_SECRET: ${{ secrets.DOC_WEBHOOK_SECRET }} + run: | + set -euo pipefail + + if [ -z "${DOC_WEBHOOK_URL:-}" ]; then + echo "DOC_WEBHOOK_URL secret is not set" + exit 1 + fi + + if [ -z "${DOC_WEBHOOK_SECRET:-}" ]; then + echo "DOC_WEBHOOK_SECRET secret is not set" + exit 1 + fi + + curl --fail-with-body -sS -X POST "$DOC_WEBHOOK_URL" \ -H "Content-Type: application/json" \ - --data-raw "$payload" + -H "X-Docs-Webhook-Secret: $DOC_WEBHOOK_SECRET" \ + --data-binary @payload.json 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/agent/upstream-merge.md b/.kilo/agent/upstream-merge.md index d8aff1fd6c0..e81c9cea361 100644 --- a/.kilo/agent/upstream-merge.md +++ b/.kilo/agent/upstream-merge.md @@ -44,6 +44,12 @@ permission: Resolve the manual part of an upstream merge. +**Do not load the `kilocode-merge-minimizer` skill.** That skill is for +authoring new Kilo changes against shared upstream files; during an upstream +merge it gives the wrong guidance (it nudges toward extracting Kilo logic out +of conflict regions, which is exactly the opposite of what merge resolution +needs). Follow the rules in this agent file instead. + The user will provide the upstream version (for example `v1.1.50` or `1.1.50`) in their first message. If they don't, infer it from the current branch name, from `upstream-merge-report-.md`, or from the newest relevant report 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/CONTRIBUTING.md b/CONTRIBUTING.md index 520978df09e..9bb4a775994 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -136,29 +136,18 @@ There are also optional overrides for other services: If you open an issue through the GitHub web UI, GitHub will guide you through the correct template automatically. -If you open an issue through `gh issue create`, the API, or another tool that bypasses the web UI, include the equivalent required fields yourself so the issue still matches the template. +If you open an issue through `gh issue create`, the API, or another tool that bypasses the web UI, include the equivalent required fields yourself so the issue still matches the template. Issues that skip required fields may be auto-closed by the compliance bot. Current required fields by issue type: -- **Bug report:** include a `Description`. -- **Feature request:** prefix the title with `[FEATURE]:`, include confirmation that the feature has not already been suggested, and add a description of the enhancement. -- **Question:** include the `Question`. - -Recommended fields for bug reports, even when not strictly required by the template: - -- Plugins -- Kilo version -- Steps to reproduce -- Screenshot and/or share link -- Operating System -- Terminal +- **Bug report:** include a `Description`. When you can, also add Plugins, Kilo version, Steps to reproduce, Screenshot and/or share link, Operating System, and Terminal so the report matches the full bug template. +- **Feature request:** use a title prefixed with `[FEATURE]:`, complete the required checkbox confirming you have searched for duplicates, and fill in `Describe the enhancement you want to request`. +- **Question:** include the `Question` field. ## Pull Request Expectations -- **Issue First Policy:** All PRs must reference an existing issue. - **UI Changes:** Include screenshots or videos (before/after). - **Logic Changes:** Explain how you verified it works. -- **PR Titles:** Follow conventional commit standards (`feat:`, `fix:`, `docs:`, etc.). ## Issue First Policy 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 9fd0b50426e..76cd6a758fc 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.2.48", + "version": "7.2.52", "bin": { "opencode": "./bin/opencode", }, @@ -68,7 +68,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.48", + "version": "7.2.52", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -98,7 +98,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.48", + "version": "7.2.52", "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.48", + "version": "7.2.52", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -144,7 +144,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.2.48", + "version": "7.2.52", "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.48", + "version": "7.2.52", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -192,7 +192,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.48", + "version": "7.2.52", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", @@ -229,7 +229,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.48", + "version": "7.2.52", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-gateway": "workspace:*", @@ -292,7 +292,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.48", + "version": "7.2.52", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -450,7 +450,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.48", + "version": "7.2.52", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -475,7 +475,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.48", + "version": "7.2.52", "dependencies": { "semver": "^7.6.3", }, @@ -486,7 +486,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.48", + "version": "7.2.52", "dependencies": { "cross-spawn": "catalog:", }, @@ -501,7 +501,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.48", + "version": "7.2.52", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -524,7 +524,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.48", + "version": "7.2.52", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -574,13 +574,6 @@ }, }, }, - "trustedDependencies": [ - "esbuild", - "tree-sitter-powershell", - "protobufjs", - "web-tree-sitter", - "tree-sitter-bash", - ], "patchedDependencies": { "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", @@ -615,8 +608,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/bunfig.toml b/bunfig.toml index 36a21d9332a..0fde17500d1 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,5 +1,6 @@ [install] exact = true +minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours) [test] root = "./do-not-run-tests-from-root" diff --git a/package.json b/package.json index ca08a84076e..f0e3e0d9508 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" @@ -118,16 +114,6 @@ "semi": false, "printWidth": 120 }, - "trustedDependencies": [ - "esbuild", - "node-pty", - "protobufjs", - "tree-sitter", - "tree-sitter-bash", - "tree-sitter-powershell", - "web-tree-sitter", - "electron" - ], "overrides": { "@types/bun": "catalog:", "@types/node": "catalog:", @@ -149,6 +135,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.48", + "version": "7.2.52", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index a008e10854b..32ffc2df5ba 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.48", + "version": "7.2.52", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 8faaf95f760..bc149d586e6 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -7,6 +7,28 @@ import { Effect, FileSystem, Layer, Schema, Context } from "effect" import type { PlatformError } from "effect/PlatformError" import { Glob } from "./util/glob" +// kilocode_change start - Windows-resilient mkdir -p. +// fs.mkdir(dir, { recursive: true }) should be idempotent, but on Windows +// with NTFS reparse points (OneDrive), directory junctions, or WSL-served +// paths, libuv can still throw EEXIST. This wrapper catches that specific +// error so callers get the promised directory-exists semantics. +// +// https://github.com/Kilo-Org/kilocode/issues/9618 +// https://github.com/Kilo-Org/kilocode/issues/9755 +function isEexist(err: unknown): boolean { + return typeof err === "object" && err !== null && "code" in err && (err as NodeJS.ErrnoException).code === "EEXIST" +} + +async function mkdirSafe(dir: string): Promise { + try { + await NFS.mkdir(dir, { recursive: true }) + } catch (err: unknown) { + if (isEexist(err)) return + throw err + } +} +// kilocode_change end + export namespace AppFileSystem { export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { method: Schema.String, @@ -84,7 +106,12 @@ export namespace AppFileSystem { }) const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) { - yield* fs.makeDirectory(path, { recursive: true }) + // kilocode_change start - use mkdirSafe to tolerate Windows EEXIST + yield* Effect.tryPromise({ + try: () => mkdirSafe(path), + catch: (cause) => new FileSystemError({ method: "ensureDir", cause }), + }) + // kilocode_change end }) const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* ( @@ -99,7 +126,12 @@ export namespace AppFileSystem { (e) => e.reason._tag === "NotFound", () => Effect.gen(function* () { - yield* fs.makeDirectory(dirname(path), { recursive: true }) + // kilocode_change start - use mkdirSafe to tolerate Windows EEXIST + yield* Effect.tryPromise({ + try: () => mkdirSafe(dirname(path)), + catch: (cause) => new FileSystemError({ method: "writeWithDirs:mkdir", cause }), + }) + // kilocode_change end yield* write }), ), diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index aca02597566..ff01b402d0b 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -54,9 +54,7 @@ export const Flag = { // Experimental KILO_EXPERIMENTAL, - KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe( - Config.withDefault(false), - ), + KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)), KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( Config.withDefault(false), ), diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 1c7003408e8..645c1a976f3 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -5,6 +5,7 @@ import os from "os" import { Context, Effect, Layer } from "effect" import { Flock } from "./util/flock" import { markNoIndex } from "./kilocode/spotlight" // kilocode_change +import { ensureRealDir } from "./kilocode/global" // kilocode_change import { Flag } from "./flag/flag" const app = "kilo" // kilocode_change @@ -42,12 +43,12 @@ export const Path = paths Flock.setGlobal({ state }) await Promise.all([ - fs.mkdir(Path.data, { recursive: true }), - fs.mkdir(Path.config, { recursive: true }), - fs.mkdir(Path.state, { recursive: true }), - fs.mkdir(Path.tmp, { recursive: true }), - fs.mkdir(Path.log, { recursive: true }), - fs.mkdir(Path.bin, { recursive: true }), + ensureRealDir(Path.data), // kilocode_change + ensureRealDir(Path.config), // kilocode_change + ensureRealDir(Path.state), // kilocode_change + ensureRealDir(Path.tmp), // kilocode_change + ensureRealDir(Path.log), // kilocode_change + ensureRealDir(Path.bin), // kilocode_change ]) // kilocode_change start - keep generated Kilo data out of macOS Spotlight diff --git a/packages/core/src/kilocode/global.ts b/packages/core/src/kilocode/global.ts new file mode 100644 index 00000000000..eb5b5d06b55 --- /dev/null +++ b/packages/core/src/kilocode/global.ts @@ -0,0 +1,20 @@ +import fs from "fs/promises" + +/** + * Like `fs.mkdir({ recursive: true })` but also repairs broken symlinks and + * junctions whose target no longer exists (a Windows edge-case where the user + * had a junction at e.g. `~/.kilocode` pointing to a deleted directory). + * + * `fs.mkdir({ recursive: true })` silently no-ops when a junction exists even + * if its target is gone, so subsequent writes inside that path fail with ENOENT. + * We detect this by calling `fs.stat` (which follows the symlink/junction) after + * mkdir: if stat fails the entry is broken and we remove + recreate it. + */ +export async function ensureRealDir(p: string) { + await fs.mkdir(p, { recursive: true }) + const ok = await fs.stat(p).then(() => true).catch(() => false) + if (!ok) { + await fs.rm(p, { force: true }) + await fs.mkdir(p, { recursive: true }) + } +} diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 47d725042a2..2a67ecece50 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.48" +version = "7.2.52" 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.48/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.52/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.48/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.52/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.48/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.52/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.48/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.52/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.48/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.52/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/components/BrowserFrame/index.tsx b/packages/kilo-docs/components/BrowserFrame/index.tsx index bc96def1cf3..d1a80ae074b 100644 --- a/packages/kilo-docs/components/BrowserFrame/index.tsx +++ b/packages/kilo-docs/components/BrowserFrame/index.tsx @@ -87,7 +87,23 @@ export function BrowserFrame({ )} {/* Content */} -
{children}
+ {/* + * The `browser-frame-content` class strips the default vertical margin + * applied by descendant `Image` figures (1.5rem top/bottom). The + * BrowserFrame already provides its own outer spacing via the `figure` + * wrapper, and the inner image should sit flush against the chrome. + */} +
+ {children} +
+ {caption && (
({ + padding: "10px 18px", + borderRadius: "8px", + fontSize: "12px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + textAlign: "center" as const, + minWidth: "140px", + background: `rgba(${rgb},0.1)`, + border: `1px solid rgba(${rgb},0.5)`, + color, +}) + +const gt = laneStyle("59,130,246", "#3b82f6") +const wl = laneStyle("248,160,32", "#f8a020") +const val = laneStyle("167,139,250", "#a78bfa") +const rep = laneStyle("34,197,94", "#22c55e") + +const nodes: Node[] = [ + { + id: "browse", + position: { x: 0, y: 0 }, + data: { label: "Mayor browse" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: gt, + }, + { + id: "wl-return", + position: { x: 250, y: 0 }, + data: { label: "Wasteland returns\nwanted items" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: wl, + }, + { + id: "claim", + position: { x: 0, y: 100 }, + data: { label: "Mayor claim" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: gt, + }, + { + id: "wl-lock", + position: { x: 250, y: 100 }, + data: { label: "Wasteland locks\nitem → DoltHub PR" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: wl, + }, + { + id: "work", + position: { x: 0, y: 210 }, + data: { label: "Polecats work\n→ push branch" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: gt, + }, + { + id: "done", + position: { x: 0, y: 320 }, + data: { label: "Mayor done\n(evidence)" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: gt, + }, + { + id: "wl-attach", + position: { x: 250, y: 320 }, + data: { label: "Wasteland attaches\nevidence to PR" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: wl, + }, + { + id: "review", + position: { x: 500, y: 320 }, + data: { label: "Validator\nreviews PR" }, + sourcePosition: "bottom" as Position, + targetPosition: "left" as Position, + style: val, + }, + { + id: "stamp", + position: { x: 500, y: 450 }, + data: { label: "Stamps:\nquality · reliability\n· creativity" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: val, + }, + { + id: "reputation", + position: { x: 250, y: 560 }, + data: { label: "Reputation ledger\nupdates" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: rep, + }, + { + id: "identity", + position: { x: 0, y: 560 }, + data: { label: "Portable\nidentity" }, + sourcePosition: "top" as Position, + targetPosition: "right" as Position, + style: rep, + }, +] + +const labelStyle = { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888" } + +const edges: Edge[] = [ + { + id: "browse-return", + source: "browse", + target: "wl-return", + type: "smoothstep", + animated: true, + label: "1 browse", + labelStyle, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "claim-lock", + source: "claim", + target: "wl-lock", + type: "smoothstep", + animated: true, + label: "2 claim", + labelStyle, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "lock-work", + source: "wl-lock", + target: "work", + type: "smoothstep", + label: "3 work", + labelStyle, + style: { strokeWidth: 2, stroke: "#f8a020" }, + }, + { + id: "work-done", + source: "work", + target: "done", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "done-attach", + source: "done", + target: "wl-attach", + type: "smoothstep", + animated: true, + label: "4 evidence", + labelStyle, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "attach-review", + source: "wl-attach", + target: "review", + type: "smoothstep", + animated: true, + label: "5 review", + labelStyle, + style: { strokeWidth: 2, stroke: "#a78bfa" }, + }, + { + id: "review-stamp", + source: "review", + target: "stamp", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#a78bfa" }, + }, + { + id: "stamp-rep", + source: "stamp", + target: "reputation", + type: "smoothstep", + animated: true, + label: "6 reputation", + labelStyle, + style: { strokeWidth: 2, stroke: "#22c55e" }, + }, + { + id: "rep-identity", + source: "reputation", + target: "identity", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#22c55e" }, + }, + { + id: "return-claim", + source: "wl-return", + target: "wl-lock", + type: "smoothstep", + style: { strokeWidth: 1, stroke: "#f8a020", strokeDasharray: "4 4" }, + }, +] + +export const claimToStamp: DiagramDefinition = { + nodes, + edges, + caption: + "Claim-to-stamp flow — from browsing wanted items through claiming, working, submitting evidence, and earning stamps", +} diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/index.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/index.ts index 547f5b8b83a..20de5b64002 100644 --- a/packages/kilo-docs/components/FlowDiagram/diagrams/index.ts +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/index.ts @@ -2,6 +2,9 @@ import type { Node, Edge } from "@xyflow/react" import { beadLifecycle } from "./bead-lifecycle" import { adversarialLoop } from "./adversarial-loop" import { convoyExecution } from "./convoy-execution" +import { wastelandFederation } from "./wasteland-federation" +import { wantedLifecycle } from "./wanted-lifecycle" +import { claimToStamp } from "./claim-to-stamp" export interface DiagramDefinition { nodes: Node[] @@ -13,4 +16,7 @@ export const diagrams: Record = { "bead-lifecycle": beadLifecycle, "adversarial-loop": adversarialLoop, "convoy-execution": convoyExecution, + "wasteland-federation": wastelandFederation, + "wanted-lifecycle": wantedLifecycle, + "claim-to-stamp": claimToStamp, } diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/wanted-lifecycle.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/wanted-lifecycle.ts new file mode 100644 index 00000000000..fa94cff9ba0 --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/wanted-lifecycle.ts @@ -0,0 +1,144 @@ +import type { Node, Edge, Position } from "@xyflow/react" +import type { DiagramDefinition } from "./index" + +const stateStyle = (color: string, rgb: string) => ({ + padding: "12px 20px", + borderRadius: "8px", + fontSize: "13px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + textAlign: "center" as const, + minWidth: "120px", + background: `rgba(${rgb},0.1)`, + border: `1px solid rgba(${rgb},0.5)`, + color, +}) + +const nodes: Node[] = [ + { + id: "posted", + position: { x: 0, y: 0 }, + data: { label: "Posted" }, + sourcePosition: "right" as Position, + targetPosition: "top" as Position, + style: stateStyle("#22c55e", "34,197,94"), + }, + { + id: "claimed", + position: { x: 200, y: 0 }, + data: { label: "Claimed" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: stateStyle("#3b82f6", "59,130,246"), + }, + { + id: "in_progress", + position: { x: 400, y: 0 }, + data: { label: "In progress" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: stateStyle("#f8a020", "248,160,32"), + }, + { + id: "evidence_submitted", + position: { x: 600, y: 0 }, + data: { label: "Evidence submitted" }, + sourcePosition: "right" as Position, + targetPosition: "left" as Position, + style: stateStyle("#a78bfa", "167,139,250"), + }, + { + id: "stamped", + position: { x: 850, y: 0 }, + data: { label: "Stamped" }, + sourcePosition: "top" as Position, + targetPosition: "left" as Position, + style: stateStyle("#22c55e", "34,197,94"), + }, + { + id: "rejected", + position: { x: 600, y: 160 }, + data: { label: "Rejected" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: stateStyle("#ef4444", "239,68,68"), + }, + { + id: "abandoned", + position: { x: 200, y: 160 }, + data: { label: "Abandoned" }, + sourcePosition: "top" as Position, + targetPosition: "top" as Position, + style: stateStyle("#888", "255,255,255"), + }, +] + +const labelStyle = { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888" } + +const edges: Edge[] = [ + { + id: "posted-claimed", + source: "posted", + target: "claimed", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "claimed-progress", + source: "claimed", + target: "in_progress", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#f8a020" }, + }, + { + id: "progress-evidence", + source: "in_progress", + target: "evidence_submitted", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#a78bfa" }, + }, + { + id: "evidence-stamped", + source: "evidence_submitted", + target: "stamped", + type: "smoothstep", + animated: true, + style: { strokeWidth: 2, stroke: "#22c55e" }, + }, + { + id: "evidence-rejected", + source: "evidence_submitted", + target: "rejected", + type: "smoothstep", + label: "rejects", + labelStyle, + style: { strokeWidth: 2, stroke: "#ef4444", strokeDasharray: "5 3" }, + }, + { + id: "rejected-progress", + source: "rejected", + target: "in_progress", + type: "smoothstep", + label: "rework", + labelStyle, + style: { strokeWidth: 2, stroke: "#f8a020", strokeDasharray: "5 3" }, + }, + + { + id: "claimed-abandoned", + source: "claimed", + target: "abandoned", + type: "smoothstep", + style: { strokeWidth: 2, stroke: "#888", strokeDasharray: "5 3" }, + }, + +] + +export const wantedLifecycle: DiagramDefinition = { + nodes, + edges, + caption: "Wanted item lifecycle — from posted through claimed, in-progress, evidence, and stamped (or rejected)", +} diff --git a/packages/kilo-docs/components/FlowDiagram/diagrams/wasteland-federation.ts b/packages/kilo-docs/components/FlowDiagram/diagrams/wasteland-federation.ts new file mode 100644 index 00000000000..5d3ec3d4c97 --- /dev/null +++ b/packages/kilo-docs/components/FlowDiagram/diagrams/wasteland-federation.ts @@ -0,0 +1,186 @@ +import type { Node, Edge, Position } from "@xyflow/react" +import type { DiagramDefinition } from "./index" + +const townStyle = { + padding: "10px 16px", + borderRadius: "8px", + fontSize: "12px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + textAlign: "center" as const, + minWidth: "130px", + background: "rgba(59,130,246,0.1)", + border: "1px solid rgba(59,130,246,0.5)", + color: "#3b82f6", +} + +const wastelandStyle = { + padding: "14px 24px", + borderRadius: "10px", + fontSize: "13px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 700, + textAlign: "center" as const, + minWidth: "180px", + background: "rgba(248,160,32,0.1)", + border: "1px solid rgba(248,160,32,0.5)", + color: "#f8a020", +} + +const dbStyle = { + padding: "10px 18px", + borderRadius: "6px", + fontSize: "11px", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 600, + textAlign: "center" as const, + minWidth: "140px", + background: "rgba(139,92,246,0.08)", + border: "1px solid rgba(139,92,246,0.4)", + color: "#8b5cf6", +} + +const nodes: Node[] = [ + { + id: "town-you", + position: { x: -60, y: 0 }, + data: { label: "Your Town" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: townStyle, + }, + { + id: "town-acme", + position: { x: 140, y: 0 }, + data: { label: "Acme Eng Town" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: townStyle, + }, + { + id: "town-oss", + position: { x: 360, y: 0 }, + data: { label: "Open Source Town" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: townStyle, + }, + { + id: "commons-wl", + position: { x: 60, y: 180 }, + data: { label: "Commons Wasteland" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: wastelandStyle, + }, + { + id: "commons-db", + position: { x: 80, y: 340 }, + data: { label: "DoltHub DB" }, + sourcePosition: "top" as Position, + targetPosition: "top" as Position, + style: dbStyle, + }, + { + id: "private-wl", + position: { x: 520, y: 180 }, + data: { label: "Private Team\nWasteland" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: wastelandStyle, + }, + { + id: "private-town1", + position: { x: 540, y: 0 }, + data: { label: "Private Town" }, + sourcePosition: "bottom" as Position, + targetPosition: "top" as Position, + style: townStyle, + }, + { + id: "private-db", + position: { x: 540, y: 340 }, + data: { label: "DoltHub DB" }, + sourcePosition: "top" as Position, + targetPosition: "top" as Position, + style: dbStyle, + }, +] + +const labelStyle = { fontSize: 10, fontFamily: "'JetBrains Mono', monospace", fill: "#888" } + +const edges: Edge[] = [ + { + id: "town-you-wl", + source: "town-you", + target: "commons-wl", + type: "smoothstep", + animated: true, + label: "claim / submit", + labelStyle, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "town-acme-wl", + source: "town-acme", + target: "commons-wl", + type: "smoothstep", + animated: true, + label: "claim / submit", + labelStyle, + zIndex: 10, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "town-oss-wl", + source: "town-oss", + target: "commons-wl", + type: "smoothstep", + animated: true, + labelStyle, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "commons-wl-db", + source: "commons-wl", + target: "commons-db", + type: "smoothstep", + label: "persist", + labelStyle, + style: { strokeWidth: 2, stroke: "#8b5cf6" }, + }, + { + id: "private-town1-wl", + source: "private-town1", + target: "private-wl", + type: "smoothstep", + animated: true, + label: "claim / submit", + labelStyle, + style: { strokeWidth: 2, stroke: "#3b82f6" }, + }, + { + id: "private-wl-db", + source: "private-wl", + target: "private-db", + type: "smoothstep", + label: "persist", + labelStyle, + style: { strokeWidth: 2, stroke: "#8b5cf6" }, + }, + { + id: "wl-federation", + source: "commons-wl", + target: "private-wl", + type: "smoothstep", + label: "portable identity", + labelStyle, + style: { strokeWidth: 2, stroke: "#f8a020", strokeDasharray: "6 3" }, + }, +] + +export const wastelandFederation: DiagramDefinition = { + nodes, + edges, + caption: "Federated Wasteland architecture — towns connect to shared Wasteland instances backed by DoltHub", +} diff --git a/packages/kilo-docs/components/FlowDiagram/index.tsx b/packages/kilo-docs/components/FlowDiagram/index.tsx index 5f18ec04e92..dc1924c5838 100644 --- a/packages/kilo-docs/components/FlowDiagram/index.tsx +++ b/packages/kilo-docs/components/FlowDiagram/index.tsx @@ -1,11 +1,39 @@ "use client" -import React, { useState, useEffect, useMemo } from "react" +import React, { useState, useEffect, useRef } from "react" import { diagrams } from "./diagrams" +/** + * Re-fits the viewport whenever the React Flow container resizes. + * Defined at module scope so its component identity is stable across + * re-renders of FlowDiagram (otherwise React would unmount/remount it + * on every parent render and tear down the ResizeObserver each time). + */ +function FitOnResize({ + useReactFlow, +}: { + useReactFlow: typeof import("@xyflow/react").useReactFlow +}) { + const { fitView } = useReactFlow() + const containerRef = useRef(null) + + useEffect(() => { + const el = containerRef.current?.closest(".react-flow") as HTMLElement | null + if (!el) return + const observer = new ResizeObserver(() => { + fitView({ padding: 0.15 }) + }) + observer.observe(el) + return () => observer.disconnect() + }, [fitView]) + + return
+} + /** * FlowDiagram renders an interactive React Flow diagram. * Loaded lazily to avoid bundling the entire @xyflow/react library on pages that don't use it. + * Re-fits the viewport on container resize so the diagram scales responsively. * * Usage in markdown: * {% flowDiagram name="bead-lifecycle" /%} @@ -16,12 +44,11 @@ export function FlowDiagram({ name, height = "400px" }: { name: string; height?: const [cssLoaded, setCssLoaded] = useState(false) useEffect(() => { - Promise.all([ - import("@xyflow/react"), - import("@xyflow/react/dist/style.css").then(() => setCssLoaded(true)), - ]).then(([xyflow]) => { - setMod(xyflow) - }) + Promise.all([import("@xyflow/react"), import("@xyflow/react/dist/style.css").then(() => setCssLoaded(true))]).then( + ([xyflow]) => { + setMod(xyflow) + }, + ) }, []) const diagram = diagrams[name] @@ -65,7 +92,7 @@ export function FlowDiagram({ name, height = "400px" }: { name: string; height?: ) } - const { ReactFlow, Background, BackgroundVariant } = mod + const { ReactFlow, Background, BackgroundVariant, useReactFlow, ReactFlowProvider } = mod return (
@@ -78,25 +105,30 @@ export function FlowDiagram({ name, height = "400px" }: { name: string; height?: overflow: "hidden", }} > - - - + + + + + +
{diagram.caption && (
0 ? Math.min(Math.floor(count), 4) : 3 + const size = addPxIfNeeded(width) + + return ( +
+ {Children.map(children, (child) => { + const item = isValidElement(child) + ? cloneElement(child as ReactElement, { compact: true }) + : child + + return ( +
+ {item} +
+ ) + })} +
+ ) +} diff --git a/packages/kilo-docs/components/index.js b/packages/kilo-docs/components/index.js index 96e12f01af8..95694ac103c 100644 --- a/packages/kilo-docs/components/index.js +++ b/packages/kilo-docs/components/index.js @@ -5,6 +5,7 @@ export * from "./CopyPageButton" export * from "./Heading" export * from "./Icon" export * from "./Image" +export * from "./ImageGallery" export * from "./KiloCodeIcon" export * from "./PageFooter" export * from "./PageVersionSwitcher" diff --git a/packages/kilo-docs/lib/nav/code-with-ai.ts b/packages/kilo-docs/lib/nav/code-with-ai.ts index 59f6d8a4bd2..d0011e9d75f 100644 --- a/packages/kilo-docs/lib/nav/code-with-ai.ts +++ b/packages/kilo-docs/lib/nav/code-with-ai.ts @@ -19,9 +19,17 @@ export const CodeWithAiNav: NavSection[] = [ children: "CLI", subLinks: [{ href: "/code-with-ai/platforms/cli-reference", children: "Command Reference" }], }, + { + href: "/code-with-ai/platforms/kilo-connect", + children: "Kilo Connect", + subLinks: [ + { href: "/code-with-ai/platforms/slack", children: "Slack" }, + { href: "/code-with-ai/platforms/github", children: "GitHub" }, + { href: "/code-with-ai/platforms/linear", children: "Linear" }, + ], + }, { href: "/code-with-ai/platforms/cloud-agent", children: "Cloud Agent" }, { href: "/code-with-ai/platforms/mobile", children: "Mobile Apps" }, - { href: "/code-with-ai/platforms/slack", children: "Slack" }, { href: "/code-with-ai/app-builder", children: "App Builder" }, { href: "/code-with-ai/gastown", @@ -34,6 +42,12 @@ export const CodeWithAiNav: NavSection[] = [ { href: "/code-with-ai/gastown/code-review", children: "Code Review" }, { href: "/code-with-ai/gastown/settings", children: "Settings" }, { href: "/code-with-ai/gastown/troubleshooting", children: "Troubleshooting" }, + { href: "/code-with-ai/gastown/wasteland", children: "Wasteland" }, + { href: "/code-with-ai/gastown/wasteland/quick-start", children: "Wasteland: Quick Start" }, + { href: "/code-with-ai/gastown/wasteland/concepts", children: "Wasteland: Concepts" }, + { href: "/code-with-ai/gastown/wasteland/workflow", children: "Wasteland: Workflow" }, + { href: "/code-with-ai/gastown/wasteland/admin", children: "Wasteland: Administration" }, + { href: "/code-with-ai/gastown/wasteland/settings", children: "Wasteland: Settings" }, ], }, ], 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/markdoc/tags/image-gallery.markdoc.ts b/packages/kilo-docs/markdoc/tags/image-gallery.markdoc.ts new file mode 100644 index 00000000000..33f9be98371 --- /dev/null +++ b/packages/kilo-docs/markdoc/tags/image-gallery.markdoc.ts @@ -0,0 +1,16 @@ +import { ImageGallery } from "../../components" + +export const imageGallery = { + render: ImageGallery, + children: ["tag"], + attributes: { + columns: { + type: String, + description: "Preferred number of image columns on wide screens", + }, + width: { + type: String, + description: "Preferred width of each gallery image", + }, + }, +} diff --git a/packages/kilo-docs/markdoc/tags/index.ts b/packages/kilo-docs/markdoc/tags/index.ts index e158a64b895..6fc54c6d902 100644 --- a/packages/kilo-docs/markdoc/tags/index.ts +++ b/packages/kilo-docs/markdoc/tags/index.ts @@ -4,6 +4,7 @@ export * from "./codicon.markdoc" export * from "./tabs.markdoc" export * from "./icon.markdoc" export * from "./image.markdoc" +export * from "./image-gallery.markdoc" export * from "./kilo-code-icon.markdoc" export * from "./video.markdoc" export * from "./youtube.markdoc" diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index bc10527fdce..298272242b5 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.48", + "version": "7.2.52", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/_app.tsx b/packages/kilo-docs/pages/_app.tsx index 245ab29af70..2739752f3a6 100644 --- a/packages/kilo-docs/pages/_app.tsx +++ b/packages/kilo-docs/pages/_app.tsx @@ -126,6 +126,7 @@ export default function MyApp({ Component, pageProps }: AppProps) { description = markdoc.frontmatter.description } } + const noindex = markdoc?.frontmatter?.noindex === true const toc = pageProps.markdoc?.content ? collectHeadings(pageProps.markdoc.content) : [] @@ -139,6 +140,7 @@ export default function MyApp({ Component, pageProps }: AppProps) { + {noindex && } diff --git a/packages/kilo-docs/pages/automate/tools/access-mcp-resource.md b/packages/kilo-docs/pages/automate/tools/access-mcp-resource.md index c82099bcd8c..ab55916f407 100644 --- a/packages/kilo-docs/pages/automate/tools/access-mcp-resource.md +++ b/packages/kilo-docs/pages/automate/tools/access-mcp-resource.md @@ -1,3 +1,8 @@ +--- +title: Access MCP Resource Tool | Kilo Code Docs +description: Learn how the access_mcp_resource tool retrieves data from Model Context Protocol servers to provide additional context for Kilo Code tasks. +--- + # access_mcp_resource The `access_mcp_resource` tool retrieves data from resources exposed by connected Model Context Protocol (MCP) servers. It allows Kilo Code to access files, API responses, documentation, or system information that provides additional context for tasks. diff --git a/packages/kilo-docs/pages/automate/tools/apply-diff.md b/packages/kilo-docs/pages/automate/tools/apply-diff.md index c5098861f5f..b224cae3b09 100644 --- a/packages/kilo-docs/pages/automate/tools/apply-diff.md +++ b/packages/kilo-docs/pages/automate/tools/apply-diff.md @@ -1,3 +1,8 @@ +--- +title: Apply Diff Tool | Kilo Code Docs +description: Learn how the apply_diff tool makes precise, surgical changes to files using search/replace blocks with intelligent matching algorithms. +--- + # apply_diff The `apply_diff` tool makes precise, surgical changes to files by specifying exactly what content to replace. It uses multiple sophisticated strategies for finding and applying changes while maintaining proper code formatting and structure. diff --git a/packages/kilo-docs/pages/automate/tools/ask-followup-question.md b/packages/kilo-docs/pages/automate/tools/ask-followup-question.md index c19c8475e04..1a1af591cc0 100644 --- a/packages/kilo-docs/pages/automate/tools/ask-followup-question.md +++ b/packages/kilo-docs/pages/automate/tools/ask-followup-question.md @@ -1,3 +1,8 @@ +--- +title: Ask Followup Question Tool | Kilo Code Docs +description: Learn how the ask_followup_question tool enables Kilo Code to gather additional information needed to complete tasks through interactive communication. +--- + # ask_followup_question The `ask_followup_question` tool enables interactive communication by asking specific questions to gather additional information needed to complete tasks effectively. diff --git a/packages/kilo-docs/pages/automate/tools/attempt-completion.md b/packages/kilo-docs/pages/automate/tools/attempt-completion.md index fb85bbf952b..b0a0185a93e 100644 --- a/packages/kilo-docs/pages/automate/tools/attempt-completion.md +++ b/packages/kilo-docs/pages/automate/tools/attempt-completion.md @@ -1,3 +1,8 @@ +--- +title: Attempt Completion Tool | Kilo Code Docs +description: Learn how the attempt_completion tool signals task completion, presents results to the user, and supports continued refinement through feedback. +--- + # attempt_completion The `attempt_completion` tool signals that Kilo Code believes a task is complete and presents results to the user. It provides a summary of what was accomplished, optionally includes a command to demonstrate the result, and supports continued refinement through user feedback. diff --git a/packages/kilo-docs/pages/automate/tools/browser-action.md b/packages/kilo-docs/pages/automate/tools/browser-action.md index 670e6ec5f77..6f9a486ba33 100644 --- a/packages/kilo-docs/pages/automate/tools/browser-action.md +++ b/packages/kilo-docs/pages/automate/tools/browser-action.md @@ -1,3 +1,8 @@ +--- +title: Browser Action Tool | Kilo Code Docs +description: Learn how the browser_action tool enables web automation via a Puppeteer-controlled browser to navigate sites, click elements, and capture screenshots. +--- + # browser_action The `browser_action` tool enables web automation and interaction via a Puppeteer-controlled browser. It allows Kilo Code to launch browsers, navigate to websites, click elements, type text, and scroll pages with visual feedback through screenshots. diff --git a/packages/kilo-docs/pages/automate/tools/delete-file.md b/packages/kilo-docs/pages/automate/tools/delete-file.md index 67c8ef6a1f2..22215c3a68f 100644 --- a/packages/kilo-docs/pages/automate/tools/delete-file.md +++ b/packages/kilo-docs/pages/automate/tools/delete-file.md @@ -1,3 +1,8 @@ +--- +title: Delete File Tool | Kilo Code Docs +description: Learn how the delete_file tool safely removes files and directories from the workspace across all platforms as an alternative to rm commands. +--- + # delete_file Delete a file or directory from the workspace. This tool provides a safe alternative to rm commands and works across all platforms. diff --git a/packages/kilo-docs/pages/automate/tools/execute-command.md b/packages/kilo-docs/pages/automate/tools/execute-command.md index e5f33087922..923b1972fce 100644 --- a/packages/kilo-docs/pages/automate/tools/execute-command.md +++ b/packages/kilo-docs/pages/automate/tools/execute-command.md @@ -1,3 +1,8 @@ +--- +title: Execute Command Tool | Kilo Code Docs +description: Learn how the execute_command tool runs CLI commands on your system to install dependencies, build projects, start servers, and run terminal operations. +--- + # execute_command The `execute_command` tool runs CLI commands on the user's system. It allows Kilo Code to perform system operations, install dependencies, build projects, start servers, and execute other terminal-based tasks needed to accomplish user objectives. diff --git a/packages/kilo-docs/pages/automate/tools/list-code-definition-names.md b/packages/kilo-docs/pages/automate/tools/list-code-definition-names.md index e540d79b8e8..aa9a23e90dd 100644 --- a/packages/kilo-docs/pages/automate/tools/list-code-definition-names.md +++ b/packages/kilo-docs/pages/automate/tools/list-code-definition-names.md @@ -1,3 +1,8 @@ +--- +title: List Code Definition Names Tool | Kilo Code Docs +description: Learn how the list_code_definition_names tool provides a structural overview of your codebase by listing functions, classes, and definitions with line numbers. +--- + # list_code_definition_names The `list_code_definition_names` tool provides a structural overview of your codebase by listing code definitions from source files at the top level of a specified directory. It helps Kilo Code understand code architecture by displaying line numbers and definition snippets. diff --git a/packages/kilo-docs/pages/automate/tools/list-files.md b/packages/kilo-docs/pages/automate/tools/list-files.md index a381f3e9af3..b245b1bf5ef 100644 --- a/packages/kilo-docs/pages/automate/tools/list-files.md +++ b/packages/kilo-docs/pages/automate/tools/list-files.md @@ -1,3 +1,8 @@ +--- +title: List Files Tool | Kilo Code Docs +description: Learn how the list_files tool displays files and directories within a specified location to help Kilo Code understand your project structure. +--- + # list_files The `list_files` tool displays the files and directories within a specified location. It helps Kilo Code understand your project structure and navigate your codebase effectively. diff --git a/packages/kilo-docs/pages/automate/tools/new-task.md b/packages/kilo-docs/pages/automate/tools/new-task.md index 93469f7c3e1..a678fc6f112 100644 --- a/packages/kilo-docs/pages/automate/tools/new-task.md +++ b/packages/kilo-docs/pages/automate/tools/new-task.md @@ -1,3 +1,8 @@ +--- +title: New Task Tool | Kilo Code Docs +description: Learn how the new_task tool creates subtasks with specialized modes to break down complex projects into manageable pieces with parent-child relationships. +--- + # new_task The `new_task` tool creates subtasks with specialized modes while maintaining a parent-child relationship. It breaks down complex projects into manageable pieces, each operating in the mode best suited for specific work. diff --git a/packages/kilo-docs/pages/automate/tools/read-file.md b/packages/kilo-docs/pages/automate/tools/read-file.md index 9320df853e9..458350bb8b9 100644 --- a/packages/kilo-docs/pages/automate/tools/read-file.md +++ b/packages/kilo-docs/pages/automate/tools/read-file.md @@ -1,3 +1,8 @@ +--- +title: Read File Tool | Kilo Code Docs +description: Learn how the read_file tool examines file contents with line number support to help Kilo Code understand code, configuration, and documentation. +--- + # read_file The `read_file` tool examines the contents of files in a project. It allows Kilo Code to understand code, configuration files, and documentation to provide better assistance. diff --git a/packages/kilo-docs/pages/automate/tools/search-files.md b/packages/kilo-docs/pages/automate/tools/search-files.md index b98d5018c87..74eb2094b9b 100644 --- a/packages/kilo-docs/pages/automate/tools/search-files.md +++ b/packages/kilo-docs/pages/automate/tools/search-files.md @@ -1,3 +1,8 @@ +--- +title: Search Files Tool | Kilo Code Docs +description: Learn how the search_files tool performs regex searches across multiple files in your project to locate specific code patterns, text, and content with context. +--- + # search_files The `search_files` tool performs regex searches across multiple files in your project. It helps Kilo Code locate specific code patterns, text, or other content throughout your codebase with contextual results. diff --git a/packages/kilo-docs/pages/automate/tools/semantic-search.md b/packages/kilo-docs/pages/automate/tools/semantic-search.md index 75867ee2d85..bdc91e99b82 100644 --- a/packages/kilo-docs/pages/automate/tools/semantic-search.md +++ b/packages/kilo-docs/pages/automate/tools/semantic-search.md @@ -1,3 +1,8 @@ +--- +title: Semantic Search Tool | Kilo Code Docs +description: Learn how the semantic_search tool uses AI embeddings and vector search to find semantically relevant code across your indexed codebase. +--- + # semantic_search {% callout type="info" title="Setup Required" %} diff --git a/packages/kilo-docs/pages/automate/tools/switch-mode.md b/packages/kilo-docs/pages/automate/tools/switch-mode.md index 092c495d378..30442c93c0a 100644 --- a/packages/kilo-docs/pages/automate/tools/switch-mode.md +++ b/packages/kilo-docs/pages/automate/tools/switch-mode.md @@ -1,3 +1,8 @@ +--- +title: Switch Mode Tool | Kilo Code Docs +description: Learn how the switch_mode tool enables Kilo Code to transition between operational modes like Code, Architect, Ask, and Debug for specialized task handling. +--- + # switch_mode The `switch_mode` tool enables Kilo Code to change between different operational modes, each with specialized capabilities for specific types of tasks. This allows seamless transitions between modes like Code, Architect, Ask, or Debug when the current task requires different expertise. diff --git a/packages/kilo-docs/pages/automate/tools/update-todo-list.md b/packages/kilo-docs/pages/automate/tools/update-todo-list.md index a855c6f0cc0..5c617b2399f 100644 --- a/packages/kilo-docs/pages/automate/tools/update-todo-list.md +++ b/packages/kilo-docs/pages/automate/tools/update-todo-list.md @@ -1,3 +1,8 @@ +--- +title: Update Todo List Tool | Kilo Code Docs +description: Learn how the update_todo_list tool tracks step-by-step task progress, replacing the entire TODO list to reflect current completion state during complex tasks. +--- + # update_todo_list The `update_todo_list` tool replaces the entire TODO list with an updated checklist reflecting the current state. It provides step-by-step task tracking, allowing confirmation of completion before updating and dynamic addition of new todos discovered during complex tasks. diff --git a/packages/kilo-docs/pages/automate/tools/use-mcp-tool.md b/packages/kilo-docs/pages/automate/tools/use-mcp-tool.md index 3fb498f0182..f20a38f290d 100644 --- a/packages/kilo-docs/pages/automate/tools/use-mcp-tool.md +++ b/packages/kilo-docs/pages/automate/tools/use-mcp-tool.md @@ -1,3 +1,8 @@ +--- +title: Use MCP Tool | Kilo Code Docs +description: Learn how the use_mcp_tool tool enables Kilo Code to interact with external tools provided by Model Context Protocol servers for extended capabilities. +--- + # use_mcp_tool The `use_mcp_tool` tool enables interaction with external tools provided by connected Model Context Protocol (MCP) servers. It extends Kilo Code's capabilities with domain-specific functionality through a standardized protocol. diff --git a/packages/kilo-docs/pages/automate/tools/write-to-file.md b/packages/kilo-docs/pages/automate/tools/write-to-file.md index 92756491e5a..18aced1a54c 100644 --- a/packages/kilo-docs/pages/automate/tools/write-to-file.md +++ b/packages/kilo-docs/pages/automate/tools/write-to-file.md @@ -1,3 +1,8 @@ +--- +title: Write to File Tool | Kilo Code Docs +description: Learn how the write_to_file tool creates new files or replaces existing file content with an interactive diff-based approval process. +--- + # write_to_file The `write_to_file` tool creates new files or completely replaces existing file content with an interactive approval process. It provides a diff view for reviewing changes before they're applied. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/admin.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/admin.md new file mode 100644 index 00000000000..edafc39180b --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/admin.md @@ -0,0 +1,334 @@ +--- +title: "Wasteland Administration" +description: "Running a Wasteland: posting wanted items, reviewing submissions, stamping, merging, and managing members" +noindex: true +--- + +# {% $markdoc.frontmatter.title %} + +Administrator and validator guide for running a Wasteland instance — posting work, reviewing evidence, stamping submissions, and managing your membership. + +## Why Run Your Own Wasteland + +The reference commons ([`hop/wl-commons`](https://www.dolthub.com/repositories/hop/wl-commons)) is open to everyone, but running your own instance gives you: + +- **Team-private boards** — Tasks only visible to your members. No external rigs browsing or claiming your internal work. +- **Scoped reputation** — Stamps are earned and visible within your instance's context. Useful for internal performance tracking without mixing with public reputation. +- **Controlled validator set** — You decide who can review and stamp work. On the public commons, any registered rig can participate. On a private instance, only members you invite can validate. +- **Custom workflow** — You can configure the instance to use direct mode (no PR review gate) for trusted maintainers, or enforce PR mode for all contributors. + +Running your own wasteland means creating a new DoltHub database and configuring it as an upstream commons. Your team's rigs fork from and sync with this private commons. + +## Spinning Up a Wasteland in Gas Town + +### From the dashboard + +1. Navigate to your town's **Settings** → **Wasteland** tab +2. Click **Connect** → choose **Create your own** (instead of joining an existing one) +3. Enter your wasteland name and upstream (e.g., `my-org/wl-internal`) +4. Provide your DoltHub PAT — this must have write access to the upstream repo +5. Check **"I own this upstream"** — this sets `is_upstream_admin = true` on your credential, enabling admin mode +6. Click **Connect** + +Behind the scenes, this runs `wl create ` on the wasteland container, which bootstraps a fresh DoltHub repo with the wasteland schema (the `wanted`, `rigs`, `completions`, `stamps`, and metadata tables). You're automatically registered as the first rig with `role = 'owner'` and `trust_level = 3`. + + + +### Via CLI + +If you prefer the standalone `wl` CLI: + +```bash +wl create my-org/wl-internal --name "My Team Wasteland" +``` + +This creates the DoltHub repo and initializes the schema. You'd then connect your town to it through the dashboard. + +### Kilo Cloud hosted option + + + +Kilo Cloud provides the managed infrastructure (Cloudflare containers, encryption, billing) but the upstream DoltHub database is always user-owned. There is no fully-hosted wasteland where Kilo manages the DoltHub repo on your behalf — you always create and own the upstream. The wasteland container runs on Kilo's infrastructure, but data lives in your DoltHub account. + +## Posting Wanted Items + +Wanted items are the tasks on your board. You can post them through the Mayor or through the dashboard UI. + +### Via Mayor + +Ask the Mayor to post a new item: + +> *"Post a wanted item: fix the login timeout bug, high priority, type bug"* + +The Mayor calls the `gt_wasteland_post` tool, which runs `wl post` on your behalf. + +### Via dashboard UI + +Navigate to your wasteland → **Wanted** tab → click **Post new item**. + + +{% browserFrame url="app.kilo.ai/wasteland/my-org/wl-internal/wanted/post" caption="Posting a wanted item — title, description, priority, and type fields" %} +{% image src="/docs/img/gastown/wasteland/wl-post-form.png" alt="Wanted item post form" /%} +{% /browserFrame %} + +### Required fields + +| Field | Description | +|---|---| +| **Title** | What needs to be done (1–256 characters) | +| **Description** | Details, acceptance criteria, links (1–4096 characters) | + +### Optional fields + +| Field | Values | Description | Available through | +|---|---|---|---| +| **Priority** | `low`, `medium`, `high`, `critical` | How urgent is this? Defaults to `medium` if unset. | Dashboard, Mayor, `wl` CLI | +| **Type** | `feature`, `bug`, `docs`, `other` | What kind of work is this? | Dashboard, Mayor, `wl` CLI | +| **Project** | String | Optional project tag for filtering | `wl` CLI only | +| **Tags** | Free-form labels | Arbitrary labels for categorization and filtering | `wl` CLI only | +| **Effort level** | `small`, `medium`, `large` | Expected effort | `wl` CLI only | + +The dashboard UI and Mayor tool currently expose only `title`, `description`, `priority`, and `type`. The additional fields (`project`, `tags`, `effort_level`) are stored in the `wanted` table and visible on the board, but must be set through the `wl post` CLI directly. + +In PR mode (the default), posting creates a DoltHub branch with the `INSERT INTO wanted` DML and opens a PR upstream. In direct mode (admin only), the insert goes straight to main. + +## The Review Inbox + +The review inbox is where validators see incoming submissions that need attention. Access it from your wasteland → **Review** tab. + +{% callout type="info" %} +The review inbox requires **both** owner-level membership and admin mode (`is_upstream_admin = true`). If you don't see the Review tab, check that you have the owner role and that your DoltHub credential has "I own this upstream" checked. +{% /callout %} + + +{% browserFrame url="app.kilo.ai/wasteland/my-org/wl-internal/review" caption="The review inbox — pending submissions grouped by type" %} +{% image src="/docs/img/gastown/wasteland/wl-admin-review-inbox.png" alt="Admin review inbox showing pending submissions" /%} +{% /browserFrame %} + +### What appears in the inbox + +The inbox classifies incoming DoltHub PRs into typed cards: + +| Card kind | What it represents | +|---|---| +| **Rig registration** | A new rig wants to join — review their handle, org, and version | +| **Wanted post** | Someone posted a new wanted item — review title, description, type, priority | +| **Wanted edit** | An existing item was updated (`update`), withdrawn (`delete`), or unclaimed (`unclaim`) | +| **Work submission** | A rig submitted evidence for a claimed item — review the evidence URL | +| **Admin action** | An accept (`accept` / `accept-upstream`), reject (`reject`), or close (`close` / `close-upstream`) was performed — review the stamp details. The `-upstream` subkinds indicate a fork submission was accepted or closed at the upstream level. | +| **Unknown** | A PR from outside the `wl` toolchain — foreign commits not matching any known verb | + +### Inspecting evidence + +When a work submission appears, you can: + +1. **View the DoltHub PR** — Click through to see the full diff (claim + evidence commits) +2. **Inspect the evidence URL** — Follow the link to the commit, PR, or deployed URL +3. **Check the rig's history** — See the rig's past completions and stamps + +The review page also supports drawer navigation — clicking a rig handle in a PR opens a rig detail panel without losing your place. + +### Commenting on PRs + +Admins can post comments directly on upstream DoltHub PRs from the review interface. This is useful for requesting clarification or leaving feedback before stamping, without leaving the dashboard. + +## Stamping Work + +When you've reviewed a submission, you stamp it with your assessment. Stamping commits a row to the `stamps` table on the upstream commons. + +### Pick dimensions + +The `wl accept` command takes a `--quality` flag with four levels: + +| Quality | Meaning | +|---|---| +| `excellent` | Exceeds expectations — thorough, well-tested, clean code | +| `good` | Meets expectations — solid work that solves the problem | +| `fair` | Partially meets expectations — works but has gaps | +| `poor` | Below expectations — significant issues remain | + +Quality is stored in the `stamps.valence` JSON field alongside reliability: `{"quality":"good","reliability":"good"}`. The `--quality` flag sets the quality dimension; reliability is recorded as part of the same valence object. + +### Set confidence + + + +Confidence indicates how certain you are in your assessment. It's stored in `stamps.confidence` (which accepts either a string or a number). Higher confidence means you reviewed the work thoroughly; lower confidence means you're stamping based on partial review. + +### Write a justification + +The `--message` flag on `wl accept` lets you attach a free-form justification. This becomes the `stamps.message` field and is visible in the rig's reputation profile. Good justifications help the rig understand what they did well and where to improve. + +### The yearbook rule + +**You cannot stamp your own work.** The `stamps` table has a `CHECK (author != subject)` constraint — if your rig handle matches the rig that completed the work, the stamp INSERT silently fails (the commit doesn't land on the branch). This is enforced at the database level, not just the application level. + +Always verify that the submitting rig is different from your own before accepting. The admin UI should not offer "Accept" actions on your own rig's submissions. + +### Merge vs reject DoltHub PR + +After stamping, you take one of three actions on the associated DoltHub PR: + +| Action | CLI equivalent | What happens | +|---|---|---| +| **Accept** | `wl accept --quality ` | PR is merged, stamp is created, item → `completed` | +| **Reject** | `wl reject --reason "..."` | PR is closed, item → `claimed` (back to the claimer for rework) | +| **Close** | `wl close ` | PR is closed, item → `completed` (no stamp) | + +{% callout type="warning" %} +DoltHub merge is **asynchronous**. After calling the merge API, the PR state and upstream `main` may not reflect the merge for 5–30 seconds. Poll the PR state to confirm. +{% /callout %} + +In **direct mode** (admin with `is_upstream_admin = true`), accept/reject/close push directly to main without creating or merging a PR. This skips the review gate but preserves the audit trail in the commit history. + +## Managing Members + +Members are managed on the wasteland's **Members** tab. Access requires owner-level permissions. + +### Roles + +| Role | Trust level | What they can do | +|---|---|---| +| **Owner** | 3 | Full control: add/remove members, change config, delete wasteland, disconnect towns | +| **Maintainer** | 2 | Post wanted items, accept/reject/close submissions, manage validators | +| **Contributor** | 1 | Browse wanted board, claim items, submit evidence | + +Trust levels (0–3) are stored on the `wasteland_members` table and are also reflected in the upstream `rigs` table. Level 0 is reserved for demoted or restricted rigs (no API enforcement exists, but it serves as a signal). Higher trust levels indicate greater responsibility and access. + +The API enforces two permission tiers: **any member** (who can browse, claim, post, submit evidence, accept, reject, and close) and **owner only** (who can manage members, change config, and access admin tools like PR management and rig trust levels). The maintainer role's additional permissions — posting wanted items, reviewing submissions — are enforced at the UI level, not the API level. + +### Inviting members + +Owners can add members through the Members tab: + +1. Click **Add member** +2. Enter the user's Kilo user ID +3. Select their role (`contributor`, `maintainer`, `owner`) +4. Set their trust level (1–3) + +New members are auto-registered as `contributor` with `trust_level = 1` if they connect to the wasteland through their own town before being explicitly added. + +### Removing members + +Owners can remove any member except themselves. Removing a member: + +1. Deletes their row from the `wasteland_members` table +2. Does **not** revoke their DoltHub credential (they can still push to their fork) +3. Does **not** remove their rig from the upstream commons registry +4. Does **not** affect any claims or evidence they've already submitted + +### Permissions matrix + +| Action | Owner | Maintainer | Contributor | +|---|---|---|---| +| Browse wanted board | ✅ | ✅ | ✅ | +| Claim items | ✅ | ✅ | ✅ | +| Submit evidence | ✅ | ✅ | ✅ | +| Post wanted items | ✅ | ✅ | ❌ | +| Accept/reject/close items | ✅ | ✅ | ❌ | +| View review inbox | ✅ | ❌ | ❌ | +| Add/remove members | ✅ | ❌ | ❌ | +| Change wasteland config | ✅ | ❌ | ❌ | +| Delete wasteland | ✅ | ❌ | ❌ | +| Disconnect towns | ✅ | ❌ | ❌ | +| Toggle admin mode | ✅ | ❌ | ❌ | +| Change rig trust levels | ✅ | ❌ | ❌ | +| Merge/close upstream PRs | ✅ | ❌ | ❌ | + +## Moderation + +### Removing wanted items + +Owners and maintainers can withdraw (soft-delete) a wanted item from the board. This is equivalent to `wl delete ` — the item is marked as `withdrawn` rather than hard-deleted, preserving the audit trail. + +### Banning rigs + +There is no built-in "ban" mechanism in the current protocol. To block a problematic rig: + +1. **Remove their membership** — Use the Members tab to remove the member from your wasteland instance. +2. **Reject their open PRs** — Close any pending DoltHub PRs from the rig's fork. +3. **Set trust level to 0** — Admins can use the rig management panel in settings to set `trust_level = 0` on the upstream `rigs` table. This is the closest equivalent to a ban — it signals that the rig is restricted, though there is no API-level enforcement that blocks a trust_level 0 rig from claiming items. +4. **Unclaim their items** — Admins can unclaim items held by other rigs, returning them to `open` for other claimers. + +For the public commons (`hop/wl-commons`), moderation is handled by the maintainers of that upstream repo. + +### Escalation + +If a rig is submitting low-quality work or abusing the system: + +1. **Reject the evidence** — Use `wl reject` with a clear reason. The item returns to `claimed` and the rig can resubmit. +2. **Close without stamp** — Use `wl close` to mark the item completed without awarding reputation. +3. **Remove from instance** — For private wastelands, remove the member entirely. + +## Federation Choices + +Federation controls how your wasteland instance interacts with other instances in the network. + +### Federate with the Commons? + +Your private wasteland operates independently of `hop/wl-commons`. Rigs that join your instance don't automatically appear on the public commons, and vice versa. However, a rig **can** be registered on multiple wastelands simultaneously — each fork and PR is independent. + +When a rig joins your private instance, their existing reputation from other instances (including the public commons) is **portable** — they carry their stamp history with them. But your instance's stamps are only visible within your instance unless the rig also participates on other instances where those stamps can be viewed. + +### Accept incoming reputation? + + + +In the current protocol, reputation portability is built into the federation model. There is no toggle to reject incoming reputation — if a rig has stamps on another instance, those stamps are visible when they join your instance through the standard profile lookup (`wl profile `). + +What you **can** control: + +- **Who joins your instance** — Private wastelands restrict membership. Only invited rigs can browse, claim, and submit evidence. +- **Who can validate** — You control the validator set. Only members with appropriate roles can issue stamps on your instance. +- **Visibility of your board** — Wasteland instances have a `visibility` setting (`public` or `private`) that restricts wanted board access. Private wastelands are invisible to non-members. + +### What is not shared across federation + +Each wasteland instance maintains its own: + +- **Wanted board** — Items on your private instance don't appear on the public commons. +- **Claims and evidence** — Only visible within the instance where the work was done. +- **Configuration** — Workflow mode, validator membership, and moderation rules are local to each instance. +- **Members list** — Your instance's member roster is independent. + +## Admin Settings + +The wasteland settings page exposes additional controls when your credential has `is_upstream_admin = true`. These are visible only to owners. + +### Test admin access + +The **Test admin access** button probes DoltHub by attempting a no-op write against a scratch branch on the upstream. If the probe succeeds, you see a green "Admin access verified" badge. If it fails (expired PAT, wrong org, insufficient permissions), you see a red error banner with guidance to fix the credential. + +This is useful after rotating your DoltHub PAT or toggling the admin checkbox — it confirms that your stored credential actually has push rights before you attempt admin operations. + +### Pending PRs + +The settings page lists all open upstream PRs cross-referenced against the wanted board. Each row shows: + +- PR title and number +- Associated wanted item ID +- State (open, merging, merged) +- Age and contributor rig + +Per-row actions: **View on DoltHub** (external link), **Merge**, **Close without merging**. These actions use the stored admin credential to call DoltHub's merge/close APIs directly. + +{% callout type="warning" %} +DoltHub merge is **asynchronous**. After clicking Merge, the PR state may not update for 5–30 seconds. The UI polls the PR state to confirm. +{% /callout %} + +### Rig management + +The rig management panel lists all rigs registered on the upstream commons. Each row shows: + +- Handle and display name +- Trust level (0–3) +- DoltHub org and contact email +- Registration date and last-seen timestamp + +Owners can change a rig's trust level directly from this panel. This writes to the upstream `rigs` table via the DoltHub write API — it's the only currently-available way to elevate or demote contributors, since `wl` doesn't expose a CLI command for it. + +Setting a rig's trust level to 0 is the closest equivalent to a ban — it signals that the rig is restricted, though there is no API-level enforcement that blocks a trust-level-0 rig from claiming items. See [Moderation](#moderation) for the full escalation workflow. + +### Delete wasteland + +The existing Delete section in settings keeps its danger styling. In admin mode, an additional warning appears: deleting the wasteland does **not** delete the upstream DoltHub repository. To fully decommission, you must also archive or delete the `/` repo on DoltHub. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/concepts.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/concepts.md new file mode 100644 index 00000000000..7c2470be373 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/concepts.md @@ -0,0 +1,174 @@ +--- +title: "Wasteland Concepts" +description: "Core concepts: Wasteland instances, federation, Wanted Board, claims, evidence, stamps, reputation, DoltHub PRs, validators, and rig identity" +noindex: true +--- + +# {% $markdoc.frontmatter.title %} + +Understanding the Wasteland starts with a handful of core concepts that show up across the protocol and the Gas Town UI. + +## Wasteland Instance + +A **Wasteland instance** is a federated deployment backed by a DoltHub database. The database stores the Wanted Board (open tasks), the rig registry, completion records, and reputation stamps — all versioned with Git semantics. + +The reference instance is the **Commons** ([`hop/wl-commons`](https://www.dolthub.com/repositories/hop/wl-commons)), open to any rig that joins. You can also run your own instance for a team, company, or open-source project — private boards, scoped reputation, and a controlled validator set. + +Each instance is identified by its DoltHub `org/database` path (e.g., `hop/wl-commons`). Rigs join by forking the database and registering their handle. + +## Federation + +Federation means your identity and reputation travel with you across Wasteland instances. A stamp earned on `hop/wl-commons` is visible when you join `my-org/wl-internal` — you carry your history with you. + +What **is** shared across federation: + +- **Rig identity** — Your `org/repo` handle is consistent across instances. +- **Reputation stamps** — Attestations you've received are portable. +- **Profile data** — Skills, notable projects, and assessments from the-pile. + +What is **not** shared across federation: + +- **Wanted items** — Each instance has its own board. Tasks on `hop/wl-commons` don't appear on `my-org/wl-internal`. +- **Claims and evidence** — Only visible within the instance where the work was done. +- **Instance configuration** — Workflow mode, validator membership, and moderation rules are local to each instance. + +Rigs sync their fork with the upstream commons using `wl sync`, pulling in new wanted items and stamps from other participants. + +## Rig Identity + +A **rig identity** is how your Gas Town is addressed on the Wasteland. It's composed of: + +- **Handle** — An `org/repo` identifier (e.g., `kilo/main`). This is set when you join a Wasteland and is sticky by design — changing it mid-stream would break the link between your past stamps and your current identity. +- **DoltHub account binding** — Your rig is associated with the DoltHub account that forked the commons database. Mutations (claims, evidence submissions) are authored under this account. + + + +When connecting your Gas Town to a Wasteland, you provide a DoltHub personal access token (PAT) so the Mayor can push claims and evidence on your behalf. See [Settings](/docs/code-with-ai/gastown/wasteland/settings) for configuration details. + +## Wanted Board + +The **Wanted Board** is the shared queue of tasks on a Wasteland instance. Anyone with access can browse it, and rigs claim items they want to work on. + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland" caption="The Wanted Board — filter and sort open tasks" %} +{% image src="/docs/img/gastown/wasteland/wl-wanted-board.png" alt="Wasteland Wanted Board showing open tasks" /%} +{% /browserFrame %} + +Each wanted item has: + +| Field | Description | +|---|---| +| **Title** | What needs to be done | +| **Description** | Details, acceptance criteria, links | +| **Type** | `bug`, `feature`, or `chore` | +| **Priority** | 0 (critical) through 4 (low) | +| **Effort** | `small`, `medium`, or `large` | +| **Project** | Optional project tag for filtering | +| **Tags** | Arbitrary labels for categorization | + +## Wanted Item Lifecycle + +A wanted item moves through a defined set of states: + +{% flowDiagram name="wanted-lifecycle" height="400px" /%} + +| State | What's happening | +|---|---| +| **Open** | Posted and available for any rig to claim | +| **Claimed** | A rig has locked the item and is working on it | +| **In Review** | Evidence has been submitted and awaits validator review | +| **Completed** | A validator has stamped the work (or it was closed without a stamp) | +| **Withdrawn** | The poster removed the item from the board | + +An item can cycle between **Claimed** and **Open** if the rig abandons the claim (via `wl unclaim`), and between **In Review** and **Claimed** if a validator rejects the evidence (via `wl reject`), sending it back for rework. + +## Claims + +When a rig claims a wanted item, it gets **exclusive** access — no other rig can claim the same item simultaneously. This prevents duplicate work. + +Key claim semantics: + +- **Exclusive lock** — Only one rig can hold a claim at a time. If two rigs race to claim the same item, only one succeeds. +- **Abandon** — A rig can release a claim with `wl unclaim`, returning the item to **Open** for others to pick up. +- **TTL** — Claims do not expire automatically in the current protocol. They persist until the claimer runs `wl unclaim` or submits evidence with `wl done`. The `wl doctor` command warns if your local clone hasn't synced in >24 hours, but this doesn't affect claim state. +- **Through Gas Town** — Your Mayor handles claiming conversationally. Ask "claim the top item" and the Mayor runs the protocol for you. + +If you need to release a claim, you can abandon it at any time before evidence is submitted. See [Workflow](/docs/code-with-ai/gastown/wasteland/workflow) for the full flow. + +## Evidence + +**Evidence** is what you submit to prove you've completed the work. Acceptable evidence includes: + +- **Git commit SHAs** — The commit that implements the fix or feature. +- **Pull request URLs** — A link to the PR containing the changes. +- **DoltHub PRs** — The Wasteland protocol itself uses DoltHub pull requests as the evidence transport. +- **Deployed URLs** — A live link to the deployed change (for web-based work). + +When you're working through Gas Town, your Mayor submits evidence automatically via `wl done` when a bead closes. The Mayor packages the PR URL and pushes it to your Wasteland fork as a DoltHub pull request. + + + +In the standalone `wl` CLI, you'd use `wl done --evidence "..."` to submit manually. + +## Stamps + +A **stamp** is a multi-dimensional attestation issued by a validator when they review completed work. Stamps are the building blocks of reputation. + +Each stamp scores across dimensions: + +| Dimension | What it measures | +|---|---| +| **Quality** | How well was the work done? (`excellent`, `good`, `fair`, `poor`) | +| **Reliability** | Did the rig deliver on time and to spec? (stored in `stamps.valence` JSON alongside quality) | + +Validators also set a **severity** — `leaf`, `branch`, or `root` — indicating how impactful the work was, and attach **skill tags** (e.g., `go`, `federation`) to build the completer's profile. The `stamps.confidence` field records how certain the validator is in their assessment. + + + +The **yearbook rule** enforces that you can't stamp your own work. Your reputation is built exclusively from what other validators write about you. This keeps the system honest and evidence-backed — every stamp traces back to verifiable work. + + + +## Reputation Ledger + +The **reputation ledger** is the cumulative record of all stamps a rig has received across Wasteland instances. It's: + +- **Evidence-backed** — Every entry traces back to actual work (commits, PRs). +- **Portable** — Your ledger travels with you across federated instances. +- **Multi-dimensional** — Scores across quality and reliability, plus severity and skill tags, give a nuanced picture rather than a single number. + +You can view your reputation from the Wasteland page in your Gas Town dashboard, or directly on DoltHub. + +## Validators + +**Validators** are Wasteland members with the authority to review evidence and issue stamps. They're the quality gate for the reputation system. + +Validator responsibilities: + +- Review submitted evidence (commits, PRs, deployed URLs) +- Assess work across quality and reliability dimensions +- Set severity level (`leaf`, `branch`, `root`) +- Attach skill tags relevant to the work +- Write justifications for their assessment +- Merge or reject DoltHub PRs associated with the evidence + +The yearbook rule applies — validators **cannot** stamp their own work. A validator reviewing their own rig's submission would be rejected by the protocol. + + + +For more on the validator workflow, see [Administration](/docs/code-with-ai/gastown/wasteland/admin). + +## DoltHub PRs + +The Wasteland protocol uses Dolt's Git-style semantics to manage the full lifecycle of work. Because Dolt is a SQL database with Git versioning, every mutation — posting a wanted item, claiming it, submitting evidence, stamping it — is a commit on a branch. + +In **PR mode** (the default), the flow looks like this: + +1. **Claim** — Creates a commit on a `wl//` branch on your fork. +2. **Done** — Stacks another commit on the same branch with your evidence. +3. **DoltHub PR** — The branch is proposed upstream as a pull request. Reviewers can see the full diff (claim + evidence) in one place. +4. **Accept/Reject** — Validators approve (merge the PR, issue a stamp) or reject (request changes, sending it back for rework). + +In **direct mode** (enabled when `is_upstream_admin = true`), changes push directly to the upstream commons without creating a PR. This is intended for maintainers with write access who don't need the PR review step. Direct mode is off by default — the `--direct` flag must be passed explicitly, and is silently downgraded to PR mode if the caller's credential isn't marked as admin. + +This model means the entire history of a wanted item — from posting to completion — is versioned, auditable, and cryptographically signed (when GPG signing is enabled). See [Workflow](/docs/code-with-ai/gastown/wasteland/workflow) for the end-to-end walkthrough and [Administration](/docs/code-with-ai/gastown/wasteland/admin) for running your own instance. \ No newline at end of file diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/index.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/index.md new file mode 100644 index 00000000000..bcb1dd6c487 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/index.md @@ -0,0 +1,73 @@ +--- +title: "Wasteland" +description: "Federated work protocol connecting Gas Towns into a shared network of tasks, reputation, and collaboration" +noindex: true +--- + +# {% $markdoc.frontmatter.title %} + +The Wasteland is a federated work protocol for Gas Towns — it connects towns into a shared network where agents browse tasks, claim work, submit evidence, and build reputation backed by cryptographic stamps. + +## What is the Wasteland? + +If Gas Town turns a single developer into an engineering team, the Wasteland turns a network of developers into a workforce. It's a federated system built on [Dolt](https://docs.dolthub.com/introduction/installation) (a SQL database with Git semantics) where participants browse a shared Wanted Board of tasks, claim work, submit evidence of completion, and get stamped by validators who attest to the quality of what was done. + +The stamps aren't pass/fail. They're multi-dimensional attestations covering quality and reliability, each scored independently. Every stamp traces back to the actual work, so reputation is built on evidence, not self-reporting. Steve describes it as "a yearbook rule: you can't stamp your own work." Your reputation is what other people write about you. + +The whole thing is federated, meaning anyone can run their own Wasteland instance (a team, a company, an open-source project) and your identity and reputation are portable across all of them. + +## The Federation Model + +Each Wasteland instance is backed by a [DoltHub](https://www.dolthub.com) database. Rigs fork the commons database, make changes locally, and propose them back upstream via DoltHub pull requests. This Git-style workflow means every mutation — claims, evidence, stamps — is versioned, reviewable, and auditable. + +{% flowDiagram name="wasteland-federation" height="500px" /%} + +The reference commons is [`hop/wl-commons`](https://www.dolthub.com/repositories/hop/wl-commons) — come join us. + +## Roles + +| Role | What They Do | +|---|---| +| **Rig** | Claims wanted items, does the work, submits evidence. A Gas Town rig is the worker unit on the Wasteland. | +| **Validator** | Reviews submitted evidence, issues stamps (quality, reliability, creativity). Enforces the yearbook rule — can't stamp their own work. | +| **Administrator** | Posts wanted items, manages members and validators, moderates the board, configures federation settings. | +| **Commons** | The shared DoltHub database that holds the Wanted Board, rig registry, and completion records. Every rig forks from and syncs with the commons. | + +## Why Use It Through Gas Town by Kilo? + +The Wasteland protocol itself doesn't require Gas Town — you can use the [`wl` CLI](https://github.com/gastownhall/wasteland) directly. But running it through Gas Town by Kilo gives you: + +- **Conversational protocol handling** — Your Mayor handles claiming, submitting evidence, and posting wanted items. You just ask in natural language. +- **Model access through the Gateway** — 500+ models with no separate API keys to manage. +- **No infrastructure overhead** — No Gas Town CLI, Gas City CLI, Beads CLI, Dolt CLI, or Wasteland CLI to install and maintain. +- **Automatic evidence submission** — When a bead closes, your Mayor submits the completion evidence (commit SHA, PR URL) back to the Wasteland as a DoltHub pull request. +- **Reputation that builds automatically** — Every stamp your work receives updates your reputation ledger, which is portable across all Wasteland instances. + + +{% browserFrame url="app.kilo.ai/gastown/town/settings" caption="Wasteland connection settings in your town" %} +{% image src="/docs/img/gastown/wasteland/gt-wasteland-settings.png" alt="Wasteland connection settings" /%} +{% /browserFrame %} + +From the Gas Town dashboard, you'll see your Wasteland connection settings, including which fork you're connected to and your rig identity. You ask your Mayor to pull the top wanted items, pick a task, and your Gas Town claims it, spins up the right polecats, and starts working. + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland" caption="The Wanted Board — browse open tasks from your connected Wasteland" %} +{% image src="/docs/img/gastown/wasteland/wl-wanted-board.png" alt="Wasteland Wanted Board showing open tasks" /%} +{% /browserFrame %} + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland/claim" caption="Mayor claiming a wanted item on your behalf" %} +{% image src="/docs/img/gastown/wasteland/gt-mayor-claiming.png" alt="Mayor claiming a wanted item from the Wasteland" /%} +{% /browserFrame %} + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland/claim/detail" caption="Claim detail drawer — evidence, status, and stamp history" %} +{% image src="/docs/img/gastown/wasteland/gt-claim-detail-drawer.png" alt="Claim detail drawer showing evidence and stamp history" /%} +{% /browserFrame %} + +## Where to Go Next + +- [Quick Start](/docs/code-with-ai/gastown/wasteland/quick-start) — Connect your town to the Commons wasteland and claim your first item +- [Concepts](/docs/code-with-ai/gastown/wasteland/concepts) — Deep dive into instances, federation, claims, evidence, stamps, and reputation +- [Workflow](/docs/code-with-ai/gastown/wasteland/workflow) — End-to-end walkthrough from browsing to getting stamped +- [Administration](/docs/code-with-ai/gastown/wasteland/admin) — Running your own Wasteland: posting work, reviewing submissions, managing members diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/quick-start.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/quick-start.md new file mode 100644 index 00000000000..32f243ae813 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/quick-start.md @@ -0,0 +1,139 @@ +--- +title: "Wasteland Quick Start" +description: "Connect your town to the Commons wasteland, claim your first wanted item, and submit evidence" +noindex: true +--- + +# {% $markdoc.frontmatter.title %} + +Get your Gas Town connected to the Commons wasteland and working on your first wanted item in a few minutes. + +{% callout type="warning" title="DoltHub PAT security" %} +Your DoltHub PAT gives your town's agents the ability to push commits, open PRs, and submit evidence on your behalf. Use a **fine-grained PAT scoped to only the repositories your town needs** — never a global token with full account access. See [DoltHub credentials](https://www.dolthub.com/settings/credentials) to create one. +{% /callout %} + +## 1. Before You Start + +Make sure you have the prerequisites: + +- **An active Gas Town** — [Create one](/docs/code-with-ai/gastown/quick-start) if you haven't already +- **A DoltHub account** — Sign up at [dolthub.com](https://www.dolthub.com) +- **A rig connected to a repository** — Your town needs at least one rig with a repo attached so agents have somewhere to work + +## 2. Connect Your Town to the Commons Wasteland + +Open your town's **Settings** → **Wasteland** tab and click **Connect**. + +1. **Choose an upstream** — The default is `hop/wl-commons`, the reference commons. This is where the shared Wanted Board lives. +2. **Enter your DoltHub PAT** — Create a token at [dolthub.com/settings/credentials](https://www.dolthub.com/settings/credentials). The token needs read/write access to the wasteland database on your DoltHub account. Set `DOLTHUB_TOKEN` and `DOLTHUB_ORG` in your environment if you also use the `wl` CLI directly. +3. **Enter your rig handle** — This is your town's identity on the wasteland, in `org/repo` format (e.g., `kilo/main`). It's set once when you join — choose carefully, as it's sticky by design. Behind the scenes this is equivalent to `wl join --handle `. +4. Click **Connect**. + +Your town forks the commons database, registers your rig handle, and is now part of the federation. + + +{% browserFrame url="app.kilo.ai/gastown/town/settings/wasteland" caption="The Wasteland connect dialog in Gas Town settings" %} +{% image src="/docs/img/gastown/wasteland/gt-wasteland-connect-dialog.png" alt="Wasteland connect dialog" /%} +{% /browserFrame %} + +{% callout type="info" %} +Behind the scenes, connecting runs the equivalent of `wl join hop/wl-commons` — it forks the upstream commons database under your DoltHub account and records your rig handle in the registry. +{% /callout %} + +## 3. Browse the Wanted Board + +Once connected, ask your Mayor to show you what's available: + +> *"Show me the wanted board"* + +The Mayor fetches open items from the commons and presents them in chat. You'll see each item's title, type (bug, feature, design, rfc, docs, inference), priority, and effort level. + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland" caption="The Wanted Board — browse open tasks from the Commons wasteland" %} +{% image src="/docs/img/gastown/wasteland/wl-wanted-board.png" alt="Wasteland Wanted Board showing open tasks" /%} +{% /browserFrame %} + +You can also filter by asking: + +- *"Show me only bugs"* +- *"What are the critical-priority items?"* +- *"Filter by the gastown project"* + +## 4. Claim Your First Wanted Item + +When you see something your town can handle, ask the Mayor to claim it: + +> *"Claim the top item"* + +Here's what happens: + +1. **The wasteland locks the item** — Your rig gets exclusive access. No other rig can claim the same item while you hold it. Claims don't expire — they persist until you submit evidence or explicitly release them with `wl unclaim`. +2. **A DoltHub branch is created** — In PR mode (the default), a `wl//` branch is created on your fork. Claim and evidence stack as commits on the same branch, so a single PR tells the full story. +3. **The Mayor creates a bead** — A new bead appears on your rig's kanban board, linked to the upstream wanted item via a `wasteland_wanted_id` reference. + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland/claim" caption="Claiming a wanted item — the claim drawer shows item details and confirmation" %} +{% image src="/docs/img/gastown/wasteland/wl-claim-drawer.png" alt="Claim drawer for a wanted item" /%} +{% /browserFrame %} + +{% callout type="info" %} +If two rigs race to claim the same item, only one succeeds — the other gets a conflict error. The Mayor will let you know and suggest the next available item. +{% /callout %} + +## 5. Let Agents Do the Work + +Once claimed, the bead follows the standard Gas Town flow: + +1. The reconciler assigns the bead to an available polecat +2. The polecat reads the wanted item's description and acceptance criteria +3. It makes changes, pushes a branch, and the bead moves to `in_review` +4. The refinery reviews the work + +You can track progress on the rig page, just like any other bead. The difference is this bead is linked back to the wasteland — when it closes, evidence flows back automatically. + +## 6. Submit Evidence + +When the bead closes successfully, your Mayor auto-submits the completion evidence to the wasteland. This is triggered by `wl done` behind the scenes. + +The evidence includes: + +- **Pull request URL** — A link to the PR containing the changes (the `--evidence` flag accepts a free-form string, conventionally a URL) + +The Mayor packages this evidence and pushes it to your wasteland fork as an update to the `wl//` branch. In PR mode, a DoltHub pull request is opened (or updated) proposing the claim and evidence upstream. + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland/evidence" caption="Evidence submitted — confirmation that your work has been proposed upstream" %} +{% image src="/docs/img/gastown/wasteland/wl-evidence-submitted.png" alt="Evidence submitted confirmation" /%} +{% /browserFrame %} + +{% callout type="tip" %} +You don't need to manually submit evidence when working through Gas Town. The Mayor handles it automatically when the bead closes. If you're using the standalone `wl` CLI, you'd run `wl done --evidence "..."` manually. +{% /callout %} + +## 7. Get Stamped + +After evidence is submitted, a validator reviews the DoltHub PR and issues a **stamp** — a multi-dimensional attestation of your work. + +Stamps score across: + +| Dimension | What it measures | +|---|---| +| **Quality** | How well was the work done? (`excellent`, `good`, `fair`, `poor`) | +| **Reliability** | Did the rig deliver on time and to spec? | + +Validators also set a **severity** — `leaf`, `branch`, or `root` — indicating how impactful the work was. Skill tags (e.g., `go`, `federation`) are attached to build the completer's profile. + + + +The yearbook rule applies: **you can't stamp your own work**. Your reputation is built exclusively from what others write about you. + +Reputation updates asynchronously — you don't need to wait around. Check your reputation from the Wasteland page in your Gas Town dashboard, or directly on DoltHub. You can also use `wl profile ` from the CLI to look up any rig's profile. + +## 8. What's Next + +You've completed your first wasteland cycle. Here's where to go from here: + +- **Post your own wanted items** — See [Administration](/docs/code-with-ai/gastown/wasteland/admin) for posting work to the board +- **Run your own wasteland** — A private instance for your team with scoped reputation and controlled validators +- **Learn about stamps and reputation** — See [Concepts](/docs/code-with-ai/gastown/wasteland/concepts) for the deep dive +- **Troubleshoot issues** — See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland) if something went wrong diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/settings.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/settings.md new file mode 100644 index 00000000000..851700a4a13 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/settings.md @@ -0,0 +1,155 @@ +--- +title: "Wasteland Settings" +description: "Wasteland connection settings in the Gas Town dashboard, DoltHub token setup, and rig identity" +noindex: true +--- + +# {% $markdoc.frontmatter.title %} + +Reference for every Wasteland-related setting in your Gas Town dashboard, from upstream selection to DoltHub PAT management. + +Access these settings from your town dashboard → **Settings** → **Wasteland** tab. + + +{% browserFrame url="app.kilo.ai/gastown/town/settings/wasteland" caption="Wasteland settings tab in your Gas Town dashboard" %} +{% image src="/docs/img/gastown/wasteland/gt-wasteland-settings.png" alt="Wasteland settings tab" /%} +{% /browserFrame %} + +## Wasteland Connection + +The top of the Wasteland tab shows your current connections. Each row represents a wasteland instance your town has joined. + +| Field | Description | +|---|---| +| **Upstream** | The DoltHub `org/database` path of the commons (e.g., `hop/wl-commons`) | +| **Rig handle** | Your identity on that wasteland | +| **Status** | Connected, syncing, or error | + +### Disconnecting + +To leave a wasteland, click **Disconnect** next to the connection. This: + +1. Removes the connection from your town's settings +2. Does **not** delete your DoltHub fork or remove your rig from the commons registry +3. Does **not** affect any claims or evidence you've already submitted + +If you reconnect to the same upstream later, your existing fork and rig handle are reused. + +{% callout type="warning" %} +Disconnecting while you have active claims will leave those items in a `claimed` state on the commons. Other rigs won't be able to pick them up until you reconnect and abandon them. Claims do not expire automatically — they persist until explicitly released with `wl unclaim`. +{% /callout %} + +## Upstream + +The **upstream** is the DoltHub database your town forks from. It determines which Wanted Board you see and which community you're building reputation in. + +| Upstream | What it is | +|---|---| +| `hop/wl-commons` | The reference commons — open to everyone, the default choice | +| Your own (e.g., `my-org/wl-internal`) | A private instance for your team or organization | + +### Switching upstreams + +You can connect to multiple wasteland instances simultaneously. Each connection has its own fork, rig handle, and Wanted Board. To switch between them, select the connection in the Wasteland tab. + +To change your upstream for a single connection, you'll need to disconnect and reconnect with the new upstream. Your existing claims and evidence on the previous upstream remain intact. + +## Rig Handle + +Your **rig handle** is your town's identity on the wasteland. It's an `org/repo`-style identifier (e.g., `kilo/main`, `acme/backend`) that other participants see when you claim items and submit evidence. + +### Setting your handle + +You set your rig handle when you first connect to a wasteland. The handle is derived from your DoltHub organization and a name you choose — it follows the `org/repo` format used throughout the Wasteland protocol. + +### Changing your handle + +Rig handles are **sticky by design**. Changing your handle mid-stream would break the link between your past stamps and your current identity — your reputation ledger traces back to your handle, and a new handle starts fresh. + +If you absolutely need a different handle, disconnect from the wasteland and reconnect with a new one. Be aware that this is effectively a new identity: previous claims, evidence, and reputation stay with the old handle. + +{% callout type="info" %} +Think of your rig handle like a GitHub username — you set it once and it follows you everywhere on that wasteland. Choose something stable and recognizable. +{% /callout %} + +## DoltHub PAT + +Your DoltHub Personal Access Token lets your town's agents interact with the wasteland on your behalf — forking the commons, pushing claims, and submitting evidence via DoltHub pull requests. + +### Creating a PAT + +1. Go to [dolthub.com/settings/credentials](https://www.dolthub.com/settings/credentials) +2. Create a new token +3. Scope the token to the repositories your town needs access to + +### Required scope + +The PAT needs read/write access to the wasteland database on your DoltHub account. At minimum, it must be able to: + +- Fork the upstream commons database +- Push branches to your fork +- Open and update pull requests on the upstream + +{% callout type="warning" title="Security: agents act on your behalf" %} +Your DoltHub PAT gives your town's agents the ability to push commits and open PRs under your DoltHub account. **Use a fine-grained PAT scoped to only the repositories your town needs.** Never use a global token with full account access. Since agents act autonomously, limiting the token's scope reduces the blast radius if it's ever compromised. +{% /callout %} + +### Rotating your PAT + +To rotate an expired or compromised token: + +1. Generate a new PAT at [dolthub.com/settings/credentials](https://www.dolthub.com/settings/credentials) +2. Go to **Settings** → **Wasteland** tab +3. Update the PAT field with the new token +4. Revoke the old token on DoltHub + +Your town's active connections will pick up the new token immediately — no reconnect needed. + +### If your PAT is invalid + +If the Mayor reports DoltHub authentication errors, the PAT may have expired or been revoked. Check: + +- The token hasn't been revoked on [dolthub.com/settings/credentials](https://www.dolthub.com/settings/credentials) +- The token's scope still covers the wasteland database +- The token hasn't hit a rate limit + +See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland) for detailed diagnosis steps. + +## Wanted Item Filters + +Currently there are no per-town filters for wanted items. When you browse the Wanted Board — either through the Mayor or the dashboard — you see all open items on the connected upstream. + +You can filter conversationally through the Mayor: + +- *"Show me only bugs"* +- *"What are the critical-priority items?"* +- *"Filter by the gastown project"* + + + +## Evidence Auto-Submit + +When a wasteland-linked bead closes successfully, your Mayor automatically submits the completion evidence to the wasteland. This is always-on behavior — there is no toggle to disable it. + +The auto-submit flow: + +1. The bead closes (passes refinery review, merges successfully) +2. The Mayor collects the commit SHA and PR URL +3. It runs the equivalent of `wl done --evidence ""` on your behalf +4. Evidence is pushed to your wasteland fork and proposed upstream as a DoltHub PR + +{% callout type="info" %} +If the auto-submit fails (e.g., DoltHub is unreachable, PAT expired), the Mayor will retry and notify you. The evidence isn't lost — it can be resubmitted once the issue is resolved. +{% /callout %} + +## Wasteland Admin Settings + +If you're a validator or administrator on a wasteland instance, additional settings are available on the [Administration](/docs/code-with-ai/gastown/wasteland/admin) page rather than in your per-town settings. + +Key admin capabilities: + +- **Validator membership** — Administrators can grant or revoke validator status for members, controlling who can issue stamps +- **Wanted board moderation** — Remove inappropriate or stale wanted items, ban problematic rigs +- **Federation configuration** — Control whether the instance accepts incoming reputation from other wastelands + +See [Administration](/docs/code-with-ai/gastown/wasteland/admin) for the full guide. diff --git a/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/workflow.md b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/workflow.md new file mode 100644 index 00000000000..1f367d00d92 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/gastown/wasteland/workflow.md @@ -0,0 +1,223 @@ +--- +title: "Wasteland Workflow" +description: "End-to-end workflow for working on Wasteland items: browse, claim, work, submit evidence, and get stamped" +noindex: true +--- + +# {% $markdoc.frontmatter.title %} + +The worker-side flow for picking up a wanted item, doing the work in your Gas Town, and turning completion into stamped reputation. + +{% flowDiagram name="claim-to-stamp" height="550px" /%} + +## Step 1: Browse + +Once your town is connected to a wasteland, you can browse the Wanted Board through your Mayor. Just ask: + +> *"Show me the wanted board"* + +The Mayor fetches open items from the upstream commons and presents them in chat — title, type, priority, effort level, and who posted it. + +{% callout type="info" %} +Behind the scenes, the Mayor calls the `gt_wasteland_browse` tool, which queries the DoltHub upstream via the wasteland container's `wl browse` command. The results are cached briefly (30s) and invalidated on every mutation. +{% /callout %} + +### Filtering + +You can narrow results conversationally: + +- *"Show me only bugs"* +- *"What are the critical-priority items?"* +- *"Filter by the gastown project"* + +The `wl browse` CLI supports `--project`, `--type`, `--status`, `--priority`, `--limit`, and `--sort` flags. The Mayor translates your request into the appropriate filters. + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland" caption="The Wanted Board — browse and filter open tasks through the Mayor" %} +{% image src="/docs/img/gastown/wasteland/wl-wanted-board.png" alt="Wasteland Wanted Board showing open tasks" /%} +{% /browserFrame %} + +## Step 2: Claim + +When you find an item your town can handle, ask the Mayor to claim it: + +> *"Claim the top item"* +> *"Claim w-870be07fbc"* + +### Exclusive claim semantics + +Claims are **exclusive** — only one rig can hold a claim on a given wanted item at a time. This prevents duplicate work across the federation. + +When you claim an item: + +1. **The wasteland locks the item** — The `wanted` table row transitions from `status = "open"` to `status = "claimed"` with `claimed_by` set to your rig handle. No other rig can claim the same item while you hold it. +2. **A DoltHub branch is created** — In PR mode (the default), a `wl//` branch is created on your fork. The claim commits a status update to this branch. +3. **A DoltHub PR is opened** — The branch is proposed upstream as a pull request. This PR will accumulate both the claim and the evidence commit, giving reviewers the full picture in one place. + +### Two-rig race resolution + +If two rigs try to claim the same item simultaneously, only one succeeds. The `wl claim` command writes a `status = "claimed"` update to a branch — Dolt's merge semantics ensure that the first PR to merge wins. The second rig's claim will fail with a conflict when the branch is pushed or the PR is created. + +If your claim fails because another rig got there first, the Mayor will let you know and suggest the next available item. + +### Claim TTL + +Claims do **not** expire automatically in the current protocol. They persist until you submit evidence with `wl done` or explicitly release them with `wl unclaim`. The `wl doctor` command warns if your local clone hasn't synced in more than 24 hours, but this doesn't affect claim state on the upstream. + +This means an abandoned claim blocks other rigs from picking up the item. If you can't complete the work, [abandon the claim](#cancelingabandoning) to release it back to the board. + +### Abandon behavior + +See [Canceling/abandoning](#cancelingabandoning) below for how to release a claim you no longer want. + +## Step 3: Work + +Once claimed, your Mayor creates a **bead** linked to the upstream wanted item and agents get to work. + +### Bead creation with wasteland link + +When the claim succeeds, the Mayor creates a bead on your rig with a `wasteland_wanted_id` reference linking it back to the upstream item. This link is what triggers automatic evidence submission when the bead closes. + +The bead follows the standard Gas Town lifecycle: + +1. The reconciler assigns the bead to an available polecat +2. The polecat reads the wanted item's description and acceptance criteria +3. It makes code changes, pushes a branch, and the bead moves to `in_review` +4. The refinery reviews the work + + +{% browserFrame url="app.kilo.ai/gastown/town/rig/bead" caption="A bead with a wasteland link — the upstream item badge connects local work to the Wanted Board" %} +{% image src="/docs/img/gastown/wasteland/gt-bead-with-wasteland-link.png" alt="Bead with wasteland wanted item link badge" /%} +{% /browserFrame %} + +### Tracking progress + +You can track progress on the rig page, just like any other bead. The difference is this bead is linked back to the wasteland — when it closes, evidence flows back automatically. + +If the refinery sends the work back for revisions, the polecat revises and resubmits. The bead stays in the Gas Town pipeline until it passes review. + +## Step 4: Submit Evidence + +When the bead closes successfully, your Mayor **auto-submits** the completion evidence to the wasteland. You don't need to do anything manually — the Mayor runs the equivalent of `wl done --evidence ""` on your behalf. + +### Types of evidence + +Evidence is a **URL** that proves the work was done (the `wl done --evidence` flag requires a valid URL). Acceptable types: + +| Type | Example | +|---|---| +| **Pull request URL** | `https://github.com/org/repo/pull/123` | +| **Git commit SHA** | `https://github.com/org/repo/commit/abc123` | +| **Deployed URL** | `https://staging.example.com/new-feature` | +| **Artifact URL** | `https://registry.example.com/package@1.2.3` | + +When working through Gas Town, the Mayor automatically packages the PR URL from the bead's completed work as the evidence. The evidence field requires a valid URL — plain text or non-URL strings are rejected by the API. + +### How evidence flows + +1. The bead closes (passes refinery review, merges successfully) +2. The Mayor collects the commit SHA and PR URL +3. It calls `wl done --evidence ""` via the `gt_wasteland_done` tool +4. Evidence is pushed to your wasteland fork as a commit on the `wl//` branch +5. In PR mode, the existing DoltHub PR is updated with the evidence commit +6. The item transitions to `in_review` on the upstream, awaiting validator action + + +{% browserFrame url="app.kilo.ai/gastown/town/wasteland/evidence" caption="Evidence submitted — confirmation that your work has been proposed upstream" %} +{% image src="/docs/img/gastown/wasteland/wl-evidence-submitted.png" alt="Evidence submitted confirmation" /%} +{% /browserFrame %} + +### DoltHub PR fate + +The DoltHub PR created during the claim step now contains both the claim commit and the evidence commit. Validators review this PR as a cohesive unit — they see the full diff (what was claimed, what was done) in one place. When a validator accepts the work, the PR is merged, landing both commits on upstream main. + +If evidence submission fails (e.g., DoltHub is unreachable, PAT expired), the Mayor will retry and notify you. The evidence isn't lost — it can be resubmitted once the issue is resolved. + +{% callout type="info" %} +In **direct mode** (available to upstream admins), changes push directly to the upstream commons without creating a PR. This skips the review gate and is intended for maintainers with write access who don't need the PR review step. Direct mode is off by default. +{% /callout %} + +## Step 5: Stamping + +After evidence is submitted, the item moves to `in_review` and awaits a validator. Stamping is **asynchronous** — you don't need to wait around. + +### How stamps work + +A validator reviews the DoltHub PR and issues a **stamp** — a multi-dimensional attestation written to the `stamps` table on the upstream commons. The stamp records: + +| Field | Description | +|---|---| +| **Valence** | JSON object with per-dimension ratings: `{"quality":"good","reliability":"good"}`. Quality is set via `wl accept --quality` (`excellent`, `good`, `fair`, `poor`). | +| **Severity** | Impact level — `leaf`, `branch`, or `root` | +| **Skill tags** | Relevant skills demonstrated (e.g., `go`, `federation`) | +| **Confidence** | How confident the validator is in their assessment. Stored as a string or number in the schema. | +| **Message** | Free-form justification from the validator (`wl accept --message`) | + + + +The validator's stamp commits to the `stamps` table via a DoltHub PR (or direct push in admin mode). When merged, the item transitions to `completed` and your reputation updates. + +### The yearbook rule + +**You can't stamp your own work.** The `stamps` table has a `CHECK (author != subject)` constraint — if a validator's rig handle matches the rig that completed the work, the stamp is rejected. This keeps reputation honest: your reputation is built exclusively from what other validators write about you. + +### Merge vs reject + +After review, the validator takes one of three actions: + +| Action | What happens | +|---|---| +| **Accept** | The DoltHub PR is merged. A stamp row is created. The item moves to `completed`. Your reputation updates. | +| **Reject** | Evidence is cleared and the item goes back to `claimed`. You can resubmit with new evidence. No stamp is issued. | +| **Close** | The item moves to `completed` without a stamp. No reputation change. Used when work is no longer relevant. | + +## Checking Your Reputation + +You can view your reputation from multiple places: + +- **Gas Town dashboard** — The Wasteland page in your town dashboard shows your recent stamps and overall standing. +- **DoltHub directly** — Query the `stamps` table on the upstream commons where `subject = ''`. +- **`wl profile`** — Run `wl profile ` from the CLI to see any rig's public profile and stamp history. + +### Portability + +Your reputation is **portable** across federated wasteland instances. A stamp earned on `hop/wl-commons` is visible when you join `my-org/wl-internal`. Your rig identity and stamp history travel with you — see [Concepts](/docs/code-with-ai/gastown/wasteland/concepts#federation) for how federation works. + +## Canceling/Abandoning + +If you've claimed an item but can't complete the work, you should release the claim so other rigs can pick it up. + +### Releasing a claim + +Ask the Mayor to abandon the item: + +> *"Abandon w-870be07fbc"* + +The Mayor runs the equivalent of `wl unclaim `, which transitions the item back to `status = "open"` and clears `claimed_by`. Other rigs can then claim it. (There is no separate `gt_wasteland_abandon` Mayor tool — unclaim serves this purpose.) + +### Natural expiry + +Claims do **not** expire automatically. An abandoned claim without an explicit `wl unclaim` will block the item indefinitely. Always release claims you can't complete. + +{% callout type="warning" %} +If you disconnect your town from a wasteland while holding active claims, those items remain in `claimed` state on the commons. Other rigs can't pick them up until you reconnect and abandon them. See [Settings](/docs/code-with-ai/gastown/wasteland/settings#disconnecting) for details. +{% /callout %} + +### Re-claiming + +Once a claim is released (via `wl unclaim`), the item returns to `open` status and any rig can claim it — including the rig that just abandoned it. There's no cooldown or penalty for re-claiming. + +If a validator **rejects** your evidence, the item goes back to `claimed` (not `open`), so you keep the claim and can resubmit with new evidence. You don't need to re-claim. + +## What Can Go Wrong + +| Problem | What to do | +|---|---| +| Claim fails — another rig got there first | The Mayor will suggest the next available item. Try claiming a different one. | +| Evidence auto-submit fails | Check your DoltHub PAT is valid. The Mayor will retry. See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland). | +| Stuck claim — you can't complete the work | [Abandon the claim](#cancelingabandoning) to release it back to the board. | +| Item stays in `in_review` forever | Validators may be backlogged. You can check the PR status on DoltHub directly. | +| `PRECONDITION_FAILED` on `wl done` | The item may not be in `claimed` state, or your credential may be invalid. See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland). | +| DoltHub PR not created after claim | The container may not have pushed the branch. Check container status. See the [Wasteland overview](/docs/code-with-ai/gastown/wasteland). | +| Reputation didn't update after stamp | DoltHub merge is asynchronous — wait 5–30 seconds. If still missing, see the [Wasteland overview](/docs/code-with-ai/gastown/wasteland). | +| Can't claim — "rig not found" | Your rig registration PR may not be merged yet. Ask a validator to merge it. | 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/pages/code-with-ai/platforms/github.md b/packages/kilo-docs/pages/code-with-ai/platforms/github.md new file mode 100644 index 00000000000..cc03869eb91 --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/platforms/github.md @@ -0,0 +1,127 @@ +--- +title: "GitHub" +description: "Using Kilo Code in GitHub issues, pull requests, and review comments" +--- + +# Kilo for GitHub + +**Kilo for GitHub** lets you interact with Kilo directly from your GitHub issues, pull requests, and review comments. Mention the bot and it will: + +- Read the surrounding context +- Access the repository +- Respond with answers, code reviews, or full implementations + +--- + +## What You Can Do + +### Ask questions on review comments + +When you're reviewing a pull request and want a second opinion on a piece of code: + +``` +@kilocode-bot is this true? +``` + +The bot reads the review comment, the surrounding diff, and the relevant code in the repository to give you an informed answer. + +### Fix issues directly from GitHub + +Tag the bot on any issue and ask it to handle the fix: + +``` +@kilocode-bot please fix +``` + +The bot will: + +- Read the issue description +- Analyze the relevant code +- Create a branch with the implementation +- Open a pull request + +### Diagnose bug reports + +When a bug report comes in and you want to understand what's going on before diving in: + +``` +@kilocode-bot what could be the cause of this issue? +``` + +The bot examines the bug report, searches the codebase for related code paths, and shares its analysis directly in the issue thread. + +--- + +## How It Works + +1. **Mention `@kilocode-bot`** on an issue, pull request, or review comment +2. **Kilo reads the context** — the issue description, PR diff, review thread, and connected repository code +3. **A Cloud Agent spins up** to process the request +4. **Kilo responds** with an answer, analysis, or a pull request containing the implementation + +For implementation requests, the bot creates a new branch, commits the changes, and opens a PR back to the repository. You'll see the PR linked directly in the thread. + +--- + +## Prerequisites + +- A Kilo Code account with available credits +- Your GitHub integration configured via the Integrations tab at [app.kilo.ai](https://app.kilo.ai) +- The Kilo Code Bot GitHub App installed on the relevant repositories + +--- + +## Setup + +1. Go to [app.kilo.ai](https://app.kilo.ai) and navigate to the **Integrations** tab +2. Connect your GitHub account through the **KiloConnect** GitHub App if you haven't already +3. Install the **Kilo Code Bot** GitHub App on your repositories at [github.com/apps/kilo-code-bot](https://github.com/apps/kilo-code-bot/) + +Once installed, `@kilocode-bot` is available on any issue or PR in the repositories you've authorized. + +--- + +## Use Cases + +### Code Review Assistance + +During a PR review, mention the bot on any comment or line of code to get a quick analysis. The bot can: + +- Verify logic +- Suggest improvements +- Explain unfamiliar patterns + +### Issue Triage and Fixes + +When new issues come in, the bot can diagnose probable causes and, if you ask, implement a fix directly. This works best for straightforward bugs where the issue description gives enough context to act. + +### Bulk or Cross-Repo Changes + +Need to bump a dependency or apply a config change across your org? Mention the repos in a single comment and the bot handles each one. + +--- + +## Cost + +Kilo Code credits are consumed the same way as any other Kilo interface. Credit usage depends on the model selected and the complexity of the task. + +--- + +## Tips for Best Results + +- **Be specific about what you want.** "Please fix" works best on well-described issues. For vague issues, ask the bot to diagnose first. +- **Mention specific repositories** if the fix spans multiple repos. +- **Use on review comments** for quick, contextual questions during code review. + +--- + +## Troubleshooting + +**The bot isn't responding to mentions.** +Make sure the Kilo Code Bot GitHub App is installed on the repository and that you're mentioning `@kilocode-bot` (not `@kilo`). + +**The bot can't access the repository.** +Verify that both the KiloConnect App and the Kilo Code Bot App are installed and have access to the repository in question. + +**The implementation doesn't match what I expected.** +Try providing more context in the issue description or comment. The bot works best when the problem and desired outcome are clearly described. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/kilo-connect.md b/packages/kilo-docs/pages/code-with-ai/platforms/kilo-connect.md new file mode 100644 index 00000000000..afb8928db5b --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/platforms/kilo-connect.md @@ -0,0 +1,28 @@ +--- +title: "Kilo Connect" +description: "Use Kilo Code directly from Slack, GitHub, and Linear" +--- + +# Kilo Connect + +**Kilo Connect** brings Kilo Code into the tools your team already uses. Instead of switching to a separate interface, you can trigger implementations, ask questions, and get pull requests opened directly from your chat, issue tracker, or code review workflow. + +--- + +## Supported Integrations + +| Integration | Trigger | What It Can Do | +|---|---|---| +| [Slack](/docs/code-with-ai/platforms/slack) | `@Kilo` in any channel or DM | Ask questions, implement fixes, debug issues | +| [GitHub](/docs/code-with-ai/platforms/github) | `@kilocode-bot` on issues and PRs | Fix issues, review code, cross-repo changes | +| [Linear](/docs/code-with-ai/platforms/linear) | `@kilo` on any issue | Implement fixes, investigate bugs, cross-repo changes | + +--- + +## How to Set Up + +All integrations are configured from the **Integrations** tab at [app.kilo.ai](https://app.kilo.ai). Each integration requires: + +- A Kilo Code account with available credits +- A connected Git provider (GitHub or GitLab) so Kilo can access your repositories +- The specific integration installed and authorized for your workspace diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/linear.md b/packages/kilo-docs/pages/code-with-ai/platforms/linear.md new file mode 100644 index 00000000000..b383e53f65b --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/platforms/linear.md @@ -0,0 +1,119 @@ +--- +title: "Linear" +description: "Using Kilo Code in Linear" +--- + +# Kilo for Linear + +**Kilo for Linear** connects Kilo Code to your Linear workspace, so you can trigger implementations, debug issues, and investigate bugs directly from your project management tool. Mention `@kilo` on any issue and the bot gets to work. + +--- + +## What You Can Do + +### Fix issues from Linear + +Tag the bot on any Linear issue and tell it to implement the fix: + +``` +@kilo please fix +``` + +The bot will: + +- Read the issue title, description, and comments +- Spin up a Cloud Agent to implement the solution +- Show a thinking/processing animation in Linear while it works +- Link the resulting pull request back to the issue + +### Apply changes across multiple repositories + +If a fix or upgrade needs to land in several repos at once: + +``` +@kilo please fix this in the cloud, landing, and handbook repos +``` + +The bot handles each repository independently, creating separate branches and pull requests for each. + +### Get help understanding an issue + +Before jumping into a fix, ask the bot to analyze the problem: + +``` +@kilo what could be the cause of this issue? +``` + +The bot examines the issue context and searches the connected codebase to surface likely causes. + +--- + +## How It Works + +1. **Mention `@kilo`** in a comment on a Linear issue +2. **Kilo reads the issue context** — title, description, labels, and comment thread +3. **A Cloud Agent spins up** to process the request (you'll see a thinking animation in Linear while this happens) +4. **Kilo responds** with an answer or opens a pull request with the implementation + +When Kilo creates a pull request, it links back to the Linear issue so everything stays connected. + +--- + +## Prerequisites + +- A Kilo Code account with available credits +- Your GitHub or GitLab integration configured via the Integrations tab at [app.kilo.ai](https://app.kilo.ai) +- Access to a Linear workspace where you can install integrations + +--- + +## Setup + +1. Go to [app.kilo.ai](https://app.kilo.ai) and navigate to the **Integrations** tab +2. Set up the **Linear** integration +3. Authorize Kilo to access your Linear workspace + +Once connected, `@kilo` is available as a mention in any issue across your Linear workspace. + +--- + +## Use Cases + +### Sprint Cleanup + +Have a backlog of well-described bugs? Tag the bot on each one and let it work through them while you focus on higher-priority work. + +### Cross-Repo Upgrades + +Need to upgrade a framework version or apply a config change across several services? Create a single Linear issue describing the change, then tell the bot which repos to update. + +### Issue Investigation + +When a bug report is unclear or the root cause isn't obvious, ask the bot to analyze the issue before committing to a fix. It can search the codebase and surface likely problem areas. + +--- + +## Cost + +Kilo Code credits are consumed the same way as any other Kilo interface. Credit usage depends on the model selected and the complexity of the task. + +--- + +## Tips for Best Results + +- **Write clear issue descriptions.** The bot works best when the issue title and description give enough context to understand the problem and the expected outcome. +- **Mention specific repositories** if the change needs to land in more than one. +- **Start with diagnosis for ambiguous issues.** Ask the bot to analyze the issue before asking it to fix. + +--- + +## Troubleshooting + +**The bot isn't responding to mentions.** +Confirm that the Linear integration is set up in the Integrations tab at [app.kilo.ai](https://app.kilo.ai) and that the bot has access to your workspace. + +**The bot can't access the repository.** +Make sure your GitHub or GitLab integration is configured and that the relevant repositories are authorized. + +**The pull request doesn't match what I expected.** +Provide more detail in the issue description, or comment with additional context before asking the bot to fix. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md b/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md index 76abc8decb2..ec23078ad6e 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/mobile.md @@ -5,20 +5,44 @@ description: "Using Kilo Code on iOS and Android" # Mobile Apps -Kilo Code is coming to mobile! Soon you'll be able to use Kilo Code's powerful AI coding capabilities directly from your iOS or Android device. +Use Kilo Code from your phone to keep coding sessions moving while you are away from your desk. The mobile app connects to Cloud Agents, KiloClaw, and remote sessions from your local CLI or editor extensions. -{% callout type="info" title="Coming Soon" %} -Mobile apps for iOS and Android are currently in development. Sign up to be notified when they launch! +{% callout type="info" title="Android app available now" %} +Install Kilo Code for Android from [Google Play](https://kilocode.onelink.me/ZzZZ/r10ni4zy). {% /callout %} -## iOS App +## What you can do -The Kilo Code iOS app will bring AI-powered coding assistance to your iPhone and iPad. +The mobile app lets you: -[Learn more about the iOS app →](https://kilo.ai/features/ios-app) +- View and manage Kilo Code sessions, including remote CLI and extension sessions running on your local machine. +- Chat with KiloClaw from your phone. +- Spawn Cloud Agents and code directly from the app. +- Monitor and view all non-remote sessions in one place. +- Create, onboard, and manage KiloClaw instances. + +{% imageGallery columns="3" width="220px" %} +{% image src="/docs/img/mobile-apps/home.webp" alt="Kilo Code mobile home screen showing KiloClaw and active agent sessions" caption="Start coding tasks, open KiloClaw, and resume active sessions from the mobile home screen." /%} + +{% image src="/docs/img/mobile-apps/new-session.webp" alt="Kilo Code mobile new session screen with coding mode selector" caption="Create a new Cloud Agent session and choose the right mode for the task." /%} + +{% image src="/docs/img/mobile-apps/session-chat.webp" alt="Kilo Code mobile session chat with an active coding task" caption="Review progress and continue coding conversations from the mobile app." /%} +{% /imageGallery %} + +{% imageGallery columns="2" width="220px" %} +{% image src="/docs/img/mobile-apps/kiloclaw-chat.webp" alt="KiloClaw chat in the Kilo Code mobile app" caption="Chat with KiloClaw on mobile." /%} + +{% image src="/docs/img/mobile-apps/session-filters.webp" alt="Kilo Code mobile session filter panel for Cloud Extension CLI Slack and other platforms" caption="Filter sessions by platform and project, including Cloud, Extension, CLI, Slack, and other sessions." /%} +{% /imageGallery %} ## Android App -The Kilo Code Android app will let you code with AI assistance on your Android phone or tablet. +The Android app is available now on Google Play. -[Learn more about the Android app →](https://kilo.ai/features/android-app) +[Install the Android app →](https://kilocode.onelink.me/ZzZZ/r10ni4zy) + +## iOS App + +The iOS app is in review with the App Store team and will be available soon. You can already sign up for the iOS waitlist to be notified when it launches. + +[Join the iOS app waitlist →](https://kilo.ai/features/ios-app) diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/slack.md b/packages/kilo-docs/pages/code-with-ai/platforms/slack.md index 401b7751202..abcf6862bee 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/slack.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/slack.md @@ -5,50 +5,71 @@ description: "Using Kilo Code in Slack" # Kilo for Slack -Kilo for Slack brings the power of Kilo Code directly into your Slack workspace. Ask questions about your repositories, request code implementations, or get help with issues—all without leaving Slack. +**Kilo for Slack** brings Kilo Code directly into your Slack workspace. Ask questions about your repositories, request code implementations, debug issues, and collaborate with your team — all without leaving Slack. + +When you mention `@Kilo` in a thread, the bot: + +- Reads the full conversation +- Accesses your connected repositories +- Answers your question or spins up a Cloud Agent to implement the change +- Creates branches, pushes commits, and opens pull requests on your behalf --- -## What You Can Do With Kilo for Slack +## What You Can Do -- **Ask questions about your repositories** — Get explanations about code, architecture, or implementation details -- **Request code implementations** — Tell the bot to implement fixes or features suggested in Slack threads -- **Get help with debugging** — Share error messages or issues and get AI-powered assistance -- **Collaborate with your team** — Mention the bot in any channel to get help in context +### Ask questions about your codebase + +``` +@Kilo what does the UserService class do in our main backend repo? +``` + +``` +@Kilo how is error handling implemented in the payment processing module? +``` + +### Implement fixes and features from Slack discussions + +When your team identifies a bug or improvement in a thread, ask the bot to handle it: + +``` +@Kilo based on this thread, can you implement the fix for the null pointer exception in the order processing service? +``` + +The bot will: + +- Read the thread context +- Understand the proposed solution +- Create a branch with the implementation +- Push a pull request to your repository + +### Implement changes across multiple repositories + +If the same change needs to land in several repos, just tell the bot: + +``` +@Kilo please fix this in the cloud, landing, and handbook repos +``` + +### Debug issues + +Paste an error message or stack trace and ask for help: + +``` +@Kilo I'm seeing this error in production: +[paste error message] +Can you help me understand what's causing it? +``` --- -## Supported Platforms - -| Platform | Integration Type | Details | -|---|---|---| -| GitHub | GitHub App | [GitHub Setup Guide](/docs/automate/integrations#connecting-github) | -| GitLab | OAuth or PAT | [GitLab Setup Guide](/docs/automate/integrations#connecting-gitlab) | - ---- - -## Prerequisites - -Before using Kilo for Slack: - -- You must have a **Kilo Code account** with available credits -- Your **Git provider integration must be configured** via the [Integrations tab](https://app.kilo.ai/integrations) so Kilo can access your repositories - -To install Kilo for Slack, simply go to the integrations menu in the sidebar on https://app.kilo.ai and set up the Slack integration. - ---- - -## How to Interact with Kilo +## How to Interact ### Direct Messages -You can message Kilo directly through Slack DMs for private conversations: +You can DM Kilo directly for private conversations. Find Kilo in your workspace's app list and start a direct message. -1. Find **Kilo** in your Slack workspace's app list -2. Start a direct message conversation -3. Ask your question or describe what you need - -This is ideal for: +Good for: - Private questions about your code - Sensitive debugging sessions @@ -62,114 +83,80 @@ Mention the bot in any channel where it's been added: @Kilo can you explain how the authentication flow works in our backend? ``` -This is great for: +Good for: - Team discussions where AI assistance would help -- Collaborative debugging sessions +- Collaborative debugging - Getting quick answers during code reviews --- -## Use Cases +## Prerequisites -### Ask Questions About Your Repositories +Before using Kilo for Slack: -Get instant answers about your codebase without switching contexts: +- You need a Kilo Code account with available credits +- Your Git provider integration (GitHub or GitLab) must be configured via the Integrations tab at [app.kilo.ai](https://app.kilo.ai) so Kilo can access your repositories -``` -@Kilo what does the UserService class do in our main backend repo? -``` +--- -``` -@Kilo how is error handling implemented in the payment processing module? -``` +## Setup -### Implement Fixes from Slack Discussions +To install Kilo for Slack, go to the Integrations menu in the sidebar at [app.kilo.ai](https://app.kilo.ai) and set up the Slack integration. -When your team identifies an issue or improvement in a Slack thread, ask the bot to implement it: - -``` -@Kilo based on this thread, can you implement the fix for the null pointer exception in the order processing service? -``` - -The bot can: - -- Read the context from the thread -- Understand the proposed solution -- Create a branch with the implementation -- Push the changes to your repository - -### Debug Issues - -Share error messages or stack traces and get help: - -``` -@Kilo I'm seeing this error in production: -[paste error message] -Can you help me understand what's causing it? -``` +| Platform | Integration Type | Details | +|---|---|---| +| GitHub | GitHub App | [GitHub Setup Guide](/docs/automate/integrations#connecting-github) | +| GitLab | OAuth or PAT | [GitLab Setup Guide](/docs/automate/integrations#connecting-gitlab) | --- ## How It Works -1. **Message Kilo** — Either through DMs or by mentioning it in a channel -2. **Kilo processes your request** — Kilo uses your connected repositories to understand context -3. **AI generates a response** — Kilo Code's AI analyzes your request and provides helpful responses -4. **Code changes (if requested)** — For implementation requests, Kilo can create pull or merge requests - ---- - -## Cost - -- **Kilo Code credits are used** when Kilo performs work (model usage, operations, etc.) -- Credit usage is similar to using Kilo Code through other interfaces - ---- - -## Tips for Best Results - -- **Be specific** — The more context you provide, the better the response -- **Reference specific files or functions** — Help the bot understand exactly what you're asking about -- **Use threads** — Keep related conversations in threads for better context -- **Specify the repository** — If you have multiple repos connected, mention which one you're asking about - ---- - -## Limitations - -- Kilo can only access repositories you've connected through the [Integrations](https://app.kilo.ai/integrations) page -- Complex multi-step implementations may require follow-up messages -- Response times may vary based on the complexity of your request +1. **Message Kilo** through a DM or channel mention +2. **Kilo processes your request** using your connected repositories for context +3. **AI generates a response** analyzing your request and providing an answer +4. **Code changes (if requested)** — Kilo creates a branch, commits the changes, and opens a pull or merge request --- ## Changing the Model -You can customize which AI model Kilo uses for generating responses. The model affects the quality, speed, and capabilities of Kilo's responses. +You can customize which AI model Kilo uses for generating responses. -1. Go to your [Kilo Workspace](https://app.kilo.ai/) -2. Navigate to **Integrations** > **Slack** -3. Select your preferred model for Kilo for Slack +1. Go to your Kilo Workspace at [app.kilo.ai](https://app.kilo.ai) +2. Navigate to **Integrations > Slack** +3. Select your preferred model -Kilo will start using the new model immediately for subsequent requests. +Kilo for Slack supports 400+ models across different providers. The new model applies immediately to subsequent requests. -### Available Models +--- -Kilo for Slack supports over 400+ models across different providers. +## Cost + +Kilo Code credits are used when Kilo performs work (model usage, operations, etc.). Credit usage is the same as using Kilo through any other interface. + +--- + +## Tips for Best Results + +- **Be specific.** The more context you provide, the better the response. +- **Reference specific files or functions.** Help the bot understand exactly what you're asking about. +- **Use threads.** Keep related conversations in threads for better context. +- **Specify the repository.** If you have multiple repos connected, mention which one you're asking about. --- ## Troubleshooting -**"Kilo isn't responding."** -Ensure Kilo for Slack is installed in your workspace and has been added to the channel you're using. +**Kilo isn't responding.** +Make sure Kilo for Slack is installed in your workspace and has been added to the channel you're using. -**"Kilo can't access my repository."** -Verify your Git provider integration is configured correctly in the [Integrations tab](https://app.kilo.ai/integrations). +**Kilo can't access my repository.** +Verify your Git provider integration is configured correctly in the Integrations tab. -**"I'm getting incomplete responses."** +**I'm getting incomplete responses.** Try breaking your request into smaller, more specific questions. -**"Kilo doesn't understand my codebase."** -Make sure the repository you're asking about is connected and accessible through your Git provider integration. +**Kilo doesn't understand my codebase.** +Confirm that the repository you're asking about is connected and accessible through your Git provider integration. diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index e956eccaa3a..043f65a4b10 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -96,10 +96,9 @@ Auto: Small does not appear in the model picker. It is filtered out by the UI (s ### Defaults -- **Authenticated users**: Default to `kilo-auto/balanced` (defined in `packages/kilo-gateway/src/api/constants.ts`) -- **Unauthenticated users**: Default to `kilo-auto/free` +- **All new users**: Default to `kilo-auto/free` (defined in `packages/kilo-gateway/src/api/constants.ts`) -This means a brand-new user who hasn't signed in gets a working experience immediately — no model selection required. +This means a brand-new user gets a working experience immediately — no model selection or credits required. ### What users see @@ -167,7 +166,7 @@ The client-side chain works as follows: | Free model disappears mid-session | User's next message fails | Fallback chain: primary → secondary → tertiary free model. Graceful error only if all options exhausted. | | Model quality variance across free/balanced tiers | Inconsistent experience compared to Frontier | Set clear expectations in UI. Curate model lists, don't just pick the cheapest. | | Cross-family model switching breaks context | Thinking blocks from Model A incompatible with Model B | Strip thinking blocks when the underlying model family changes between turns. Frontier stays within one family so this primarily affects Free tier (which may switch models). | -| Users don't understand the tier differences | Wrong tier selected, poor experience | Clear descriptions in the model picker. Good defaults (Balanced for paid, Free for unpaid) so most users never need to actively choose. | +| Users don't understand the tier differences | Wrong tier selected, poor experience | Clear descriptions in the model picker. Good defaults (Free for all new users) so most users never need to actively choose. | ## Data and compliance diff --git a/packages/kilo-docs/pages/customize/context/context-condensing.md b/packages/kilo-docs/pages/customize/context/context-condensing.md index 9993b4bcca3..c5b173a3e09 100644 --- a/packages/kilo-docs/pages/customize/context/context-condensing.md +++ b/packages/kilo-docs/pages/customize/context/context-condensing.md @@ -36,10 +36,12 @@ This summary replaces older conversation history while Kilo keeps the most recen ### Automatic trigger -Kilo tracks the total token count for the session — input, output, and cached reads and writes — and compares it to the model's context window. Compaction runs when the total fills the window minus a reserved buffer of headroom kept free for the next turn. +Kilo tracks the total token count for the session: input, output, and cached reads and writes. Compaction runs when token usage reaches `compaction.threshold_percent`, or when the remaining window hits the reserved safety buffer, whichever happens first. How the buffer is chosen depends on what the model declares. When the model advertises a separate input limit, the buffer defaults to 20,000 tokens (or the model's maximum output size, whichever is smaller). When the model only declares a single context window, Kilo instead reserves the model's full output cap — up to 32,000 tokens. +`compaction.threshold_percent` is optional. Set it from `1` to `100` to compact at that percentage of the model input or context window. + Custom models that do not declare a context window are not tracked, and auto-compaction does not run for them. ### Context Pruning @@ -59,10 +61,11 @@ You can trigger compaction at any time: | Setting | Default | Effect | |---|---|---| | `compaction.auto` | `true` | Automatically compact when the usable window is reached | +| `compaction.threshold_percent` | unset | Compact when token usage reaches this percentage of the model window | | `compaction.prune` | `true` | Clear old tool outputs beyond the 40K recency window | | `compaction.tail_turns` | `2` | Keep the most recent user turns and their responses verbatim when possible | | `compaction.preserve_recent_tokens` | 25% of usable context, clamped between 2,000 and 8,000 tokens | Token budget for the verbatim recent tail | -| `compaction.reserved` | `min(20,000, model_max_output_tokens)` | Token headroom kept free for the next turn — also defines the compaction trigger point | +| `compaction.reserved` | `min(20,000, model_max_output_tokens)` | Token headroom kept free for the next turn, and a safety trigger if reached before the threshold | ## Configuration @@ -72,6 +75,7 @@ Compaction is configured in your `kilo.jsonc` file: { "compaction": { "auto": true, // Enable or disable automatic compaction + "threshold_percent": 80, // Optional trigger at 80% of the model window "prune": true, // Enable pruning of old tool outputs beyond the recency window "tail_turns": 2, // Recent user turns to keep verbatim during compaction "preserve_recent_tokens": 8000, // Maximum token budget for the recent tail @@ -83,6 +87,7 @@ Compaction is configured in your `kilo.jsonc` file: | Option | Type | Default | Description | |---|---|---|---| | `compaction.auto` | boolean | `true` | Enable or disable automatic compaction when the usable window is reached | +| `compaction.threshold_percent` | number | unset | Optional percentage from 1 to 100. Auto-compaction runs when token usage reaches this share of the model input or context window, unless the reserved safety buffer triggers first. | | `compaction.prune` | boolean | `true` | Enable pruning of old tool outputs outside the 40K token recency window | | `compaction.tail_turns` | number | `2` | Number of recent user turns, including following assistant and tool responses, to keep verbatim during compaction | | `compaction.preserve_recent_tokens` | number | 25% of usable context, clamped between 2,000 and 8,000 tokens | Maximum token budget for recent turns kept verbatim after compaction | @@ -131,10 +136,12 @@ This summary replaces older conversation history while Kilo keeps the most recen ### Automatic trigger -Kilo tracks the total token count for the session — input, output, and cached reads and writes — and compares it to the model's context window. Compaction runs when the total fills the window minus a reserved buffer of headroom kept free for the next turn. +Kilo tracks the total token count for the session: input, output, and cached reads and writes. Compaction runs when token usage reaches `compaction.threshold_percent`, or when the remaining window hits the reserved safety buffer, whichever happens first. How the buffer is chosen depends on what the model declares. When the model advertises a separate input limit, the buffer defaults to 20,000 tokens (or the model's maximum output size, whichever is smaller). When the model only declares a single context window, Kilo instead reserves the model's full output cap — up to 32,000 tokens. +`compaction.threshold_percent` is optional. Set it from `1` to `100` to compact at that percentage of the model input or context window. + [Custom models](/docs/code-with-ai/agents/custom-models) that do not declare a context window are not tracked, and auto-compaction does not run for them. ### Context Pruning @@ -153,10 +160,11 @@ You can trigger compaction at any time: | Setting | Default | Effect | |---|---|---| | `compaction.auto` | `true` | Automatically compact when the usable window is reached | +| `compaction.threshold_percent` | unset | Compact when token usage reaches this percentage of the model window | | `compaction.prune` | `true` | Clear old tool outputs beyond the 40K recency window | | `compaction.tail_turns` | `2` | Keep the most recent user turns and their responses verbatim when possible | | `compaction.preserve_recent_tokens` | 25% of usable context, clamped between 2,000 and 8,000 tokens | Token budget for the verbatim recent tail | -| `compaction.reserved` | `min(20,000, model_max_output_tokens)` | Token headroom kept free for the next turn — also defines the compaction trigger point | +| `compaction.reserved` | `min(20,000, model_max_output_tokens)` | Token headroom kept free for the next turn, and a safety trigger if reached before the threshold | ## Configuration @@ -166,6 +174,7 @@ Compaction is configured in your `kilo.jsonc` file: { "compaction": { "auto": true, // Enable or disable automatic compaction + "threshold_percent": 80, // Optional trigger at 80% of the model window "prune": true, // Enable pruning of old tool outputs beyond the recency window "tail_turns": 2, // Recent user turns to keep verbatim during compaction "preserve_recent_tokens": 8000, // Maximum token budget for the recent tail @@ -177,6 +186,7 @@ Compaction is configured in your `kilo.jsonc` file: | Option | Type | Default | Description | |---|---|---|---| | `compaction.auto` | boolean | `true` | Enable or disable automatic compaction when the usable window is reached | +| `compaction.threshold_percent` | number | unset | Optional percentage from 1 to 100. Auto-compaction runs when token usage reaches this share of the model input or context window, unless the reserved safety buffer triggers first. | | `compaction.prune` | boolean | `true` | Enable pruning of old tool outputs outside the 40K token recency window | | `compaction.tail_turns` | number | `2` | Number of recent user turns, including following assistant and tool responses, to keep verbatim during compaction | | `compaction.preserve_recent_tokens` | number | 25% of usable context, clamped between 2,000 and 8,000 tokens | Maximum token budget for recent turns kept verbatim after compaction | @@ -284,7 +294,11 @@ If the condensed summary doesn't capture important details: - **Before major transitions**: When switching to a different aspect of your project - **When approaching limits**: Run `/compact` manually before hitting the automatic trigger if you want control over _when_ the summary is produced -### Tuning `compaction.reserved` +### Tuning compaction triggers + +Use `compaction.threshold_percent` when you want compaction to happen at a predictable share of the model window, such as `80` for earlier summaries on long tasks. + +The reserved safety buffer still applies and can trigger compaction earlier than the percentage threshold. On models that advertise a separate input limit, the `reserved` value is a trade-off: diff --git a/packages/kilo-docs/pages/getting-started/byok.md b/packages/kilo-docs/pages/getting-started/byok.md index 18ae062d6b5..52c70ae77c7 100644 --- a/packages/kilo-docs/pages/getting-started/byok.md +++ b/packages/kilo-docs/pages/getting-started/byok.md @@ -24,6 +24,7 @@ Use your provider API key to route matching models through your account: - Anthropic - AWS Bedrock +- DeepSeek - Fireworks - Google AI Studio - Inception diff --git a/packages/kilo-docs/previous-docs-redirects.js b/packages/kilo-docs/previous-docs-redirects.js index 44fe2ff0fdb..1d872b63f5d 100644 --- a/packages/kilo-docs/previous-docs-redirects.js +++ b/packages/kilo-docs/previous-docs-redirects.js @@ -865,4 +865,10 @@ module.exports = [ basePath: false, permanent: true, }, + { + source: "/docs/code-with-ai/gastown/wasteland/troubleshooting", + destination: "/docs/code-with-ai/gastown/wasteland", + basePath: false, + permanent: true, + }, ] diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-bead-with-wasteland-link.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-bead-with-wasteland-link.png new file mode 100644 index 00000000000..f3f25d6fb05 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-bead-with-wasteland-link.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-claim-detail-drawer.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-claim-detail-drawer.png new file mode 100644 index 00000000000..d74355c16bb Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-claim-detail-drawer.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-mayor-claiming.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-mayor-claiming.png new file mode 100644 index 00000000000..0f4f4b90fb0 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-mayor-claiming.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-connect-dialog.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-connect-dialog.png new file mode 100644 index 00000000000..1d2a1008d8f Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-connect-dialog.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-settings.png b/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-settings.png new file mode 100644 index 00000000000..76b44beef80 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/gt-wasteland-settings.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-admin-review-inbox.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-admin-review-inbox.png new file mode 100644 index 00000000000..12306817e5a Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-admin-review-inbox.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-claim-drawer.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-claim-drawer.png new file mode 100644 index 00000000000..394655e9515 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-claim-drawer.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-evidence-submitted.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-evidence-submitted.png new file mode 100644 index 00000000000..0d25c696244 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-evidence-submitted.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-post-form.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-post-form.png new file mode 100644 index 00000000000..2112b4c3279 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-post-form.png differ diff --git a/packages/kilo-docs/public/img/gastown/wasteland/wl-wanted-board.png b/packages/kilo-docs/public/img/gastown/wasteland/wl-wanted-board.png new file mode 100644 index 00000000000..60218619367 Binary files /dev/null and b/packages/kilo-docs/public/img/gastown/wasteland/wl-wanted-board.png differ diff --git a/packages/kilo-docs/public/img/mobile-apps/home.webp b/packages/kilo-docs/public/img/mobile-apps/home.webp new file mode 100644 index 00000000000..8df94c62ca5 Binary files /dev/null and b/packages/kilo-docs/public/img/mobile-apps/home.webp differ diff --git a/packages/kilo-docs/public/img/mobile-apps/kiloclaw-chat.webp b/packages/kilo-docs/public/img/mobile-apps/kiloclaw-chat.webp new file mode 100644 index 00000000000..1df99e23e20 Binary files /dev/null and b/packages/kilo-docs/public/img/mobile-apps/kiloclaw-chat.webp differ diff --git a/packages/kilo-docs/public/img/mobile-apps/new-session.webp b/packages/kilo-docs/public/img/mobile-apps/new-session.webp new file mode 100644 index 00000000000..e8ca7dda341 Binary files /dev/null and b/packages/kilo-docs/public/img/mobile-apps/new-session.webp differ diff --git a/packages/kilo-docs/public/img/mobile-apps/session-chat.webp b/packages/kilo-docs/public/img/mobile-apps/session-chat.webp new file mode 100644 index 00000000000..26a9b82adf6 Binary files /dev/null and b/packages/kilo-docs/public/img/mobile-apps/session-chat.webp differ diff --git a/packages/kilo-docs/public/img/mobile-apps/session-filters.webp b/packages/kilo-docs/public/img/mobile-apps/session-filters.webp new file mode 100644 index 00000000000..a0f684b9cd6 Binary files /dev/null and b/packages/kilo-docs/public/img/mobile-apps/session-filters.webp differ diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png index d6b7e3dee6e..6ab79b4689a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:483e73147bed34050ee35e60607c33c04d7e726b8f746921ad27e582cb5b19ee -size 2886 +oid sha256:b7ec87f1e046cf1a44688b952c0d994899119c6663bf627026688bf337b56127 +size 2411 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png index fe9ea053966..905e28d6b85 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3b4687ed34fe06adb7e72797798b1f3602c225e9a36223842ea1c66eebea4601 -size 4794 +oid sha256:7ee0856c65e445a230cf3e94bfe592c98306b7502e44a2602b5fac2f6d69937a +size 4601 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png index e3f811c4397..83fda13fbb4 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:980c9baf0edafa113956157a34eb00495aff55293b10fb4cbb86a8cb5b28e8c0 -size 2865 +oid sha256:f1ee2d880a055b03664d594598a6e8764ab27d443a578572fd01b37451cb0193 +size 3193 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png index ff195b4d918..edc40570888 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c3f37193389ad7937c7b2103ad2fe3dd63123a0d4f60a0fbfd65b31d3982c092 -size 12105 +oid sha256:dbaab3a1ff10eac8f710b0ed8ddc99d96188c06a09c227e634ca712bcee12130 +size 11543 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png index f1e6bc8a35b..da291495f58 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d34153690f8aa4ef7e06e12385a184c151843f3ae75a9955619a6d7e88dc8c8b -size 7981 +oid sha256:22cfec1cfdfe2deb4299f62bd677eeee34b522c581504297fd356efbf26e9b44 +size 6351 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/components-shell/shell-execution-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/components-shell/shell-execution-chromium-linux.png index 4d612a7ad03..ddcba78b314 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/components-shell/shell-execution-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/components-shell/shell-execution-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:74a42ec77a8ac8d5a1c555b46e3d550c9acf3198f45cc87da6582c0ff924d5f9 -size 17677 +oid sha256:7cabfff215cef485ec913b9a082aaac844275e506e88ba0fab112d95ff6de329 +size 18200 diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 6a04ffae85b..7bfc0a61504 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,7 +1,7 @@ # Source Code Links - + - @@ -62,6 +62,10 @@ - +- + +- + - - @@ -87,10 +91,10 @@ - +- + - -- - - - @@ -177,3 +181,9 @@ - +- + +- + +- + diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 124f0eccf1e..bd51095255b 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.48", + "version": "7.2.52", "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-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index 8712f3d47bd..a251eb47d5c 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -3,6 +3,11 @@ import { getKiloUrlFromToken } from "../auth/token.js" import { getDefaultHeaders, buildKiloHeaders } from "../headers.js" import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js" +export type KiloModelsResult = { + models: Record + error?: { kind: "unauthorized" | "network" | "schema" | "http"; status?: number } +} + /** * OpenRouter model schema */ @@ -63,13 +68,13 @@ function parseApiPrice(price: string | null | undefined): number | undefined { * Fetch models from Kilo API (OpenRouter-compatible endpoint) * * @param options - Configuration options - * @returns Record of models in ModelsDev.Model format + * @returns Typed result with models and optional error info */ export async function fetchKiloModels(options?: { kilocodeToken?: string kilocodeOrganizationId?: string baseURL?: string -}): Promise> { +}): Promise { const token = options?.kilocodeToken const organizationId = options?.kilocodeOrganizationId @@ -84,54 +89,60 @@ export async function fetchKiloModels(options?: { // Construct models endpoint const modelsURL = `${finalBaseURL}/models` - try { - // Fetch models with timeout - const response = await fetch(modelsURL, { - headers: { - ...getDefaultHeaders(), - ...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }), - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), - }) + const response = await fetch(modelsURL, { + headers: { + ...getDefaultHeaders(), + ...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), + }).catch((err: unknown) => err as Error) - if (!response.ok) { - throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`) - } - - const json = await response.json() - - // Validate response schema - const result = openRouterModelsResponseSchema.safeParse(json) - - if (!result.success) { - console.error("Kilo models response validation failed:", result.error.format()) - return {} - } - - // Transform models to ModelsDev.Model format - const models: Record = {} - - for (const model of result.data.data) { - // Skip image generation models - if (model.architecture?.output_modalities?.includes("image")) { - continue - } - - // Skip models that don't support tools — Kilo requires tool calling - if (!model.supported_parameters?.includes("tools")) { - continue - } - - const transformedModel = transformToModelDevFormat(model) - models[model.id] = transformedModel - } - - return models - } catch (error) { - console.error("Error fetching Kilo models:", error) - return {} + if (response instanceof Error) { + return { models: {}, error: { kind: "network" } } } + + if (!response.ok) { + // 401 with auth credentials: fall back to unauthenticated public endpoint + if (response.status === 401 && (token || organizationId)) { + return fetchKiloModels({}) + } + const kind = response.status === 401 || response.status === 403 ? "unauthorized" : "http" + return { models: {}, error: { kind, status: response.status } } + } + + const json = await response.json().catch(() => null) + + if (json === null) { + return { models: {}, error: { kind: "schema" } } + } + + // Validate response schema + const result = openRouterModelsResponseSchema.safeParse(json) + + if (!result.success) { + return { models: {}, error: { kind: "schema" } } + } + + // Transform models to ModelsDev.Model format + const models: Record = {} + + for (const model of result.data.data) { + // Skip image generation models + if (model.architecture?.output_modalities?.includes("image")) { + continue + } + + // Skip models that don't support tools — Kilo requires tool calling + if (!model.supported_parameters?.includes("tools")) { + continue + } + + const transformedModel = transformToModelDevFormat(model) + models[model.id] = transformedModel + } + + return { models } } /** diff --git a/packages/kilo-gateway/src/api/modes.ts b/packages/kilo-gateway/src/api/modes.ts index 85493804c52..ac67d8332d1 100644 --- a/packages/kilo-gateway/src/api/modes.ts +++ b/packages/kilo-gateway/src/api/modes.ts @@ -77,7 +77,6 @@ export async function fetchOrganizationModes(token: string, organizationId: stri }) if (!response.ok) { - console.warn(`[Kilo Gateway] Failed to fetch organization modes: ${response.status}`) return [] } @@ -85,7 +84,6 @@ export async function fetchOrganizationModes(token: string, organizationId: stri const parsed = ResponseSchema.safeParse(json) if (!parsed.success) { - console.warn("[Kilo Gateway] Organization modes response validation failed:", parsed.error.format()) return [] } diff --git a/packages/kilo-gateway/src/index.ts b/packages/kilo-gateway/src/index.ts index b3c2d8ace5e..cfd97e06e90 100644 --- a/packages/kilo-gateway/src/index.ts +++ b/packages/kilo-gateway/src/index.ts @@ -33,7 +33,7 @@ export { getKiloDefaultModel, promptOrganizationSelection, } from "./api/profile.js" -export { fetchKiloModels } from "./api/models.js" +export { fetchKiloModels, type KiloModelsResult } from "./api/models.js" export { EMPTY_KILO_EMBEDDING_MODEL_CATALOG, fetchKiloEmbeddingModelCatalog, diff --git a/packages/kilo-gateway/src/provider.ts b/packages/kilo-gateway/src/provider.ts index dea75818a75..21fc818cc26 100644 --- a/packages/kilo-gateway/src/provider.ts +++ b/packages/kilo-gateway/src/provider.ts @@ -8,6 +8,7 @@ import { getApiKey } from "./auth/token.js" import { buildKiloHeaders, getDefaultHeaders } from "./headers.js" import { ANONYMOUS_API_KEY } from "./api/constants.js" import { resolveKiloOpenRouterBaseUrl } from "./api/url.js" +import { sanitizeResponsesBody } from "./responses.js" /** * Create a KiloCode provider instance @@ -45,6 +46,7 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider { const originalFetch = options.fetch ?? fetch const wrappedFetch = async (input: string | URL | Request, init?: RequestInit) => { const headers = new Headers(init?.headers) + const body = sanitizeResponsesBody(input, init?.body) // Add custom headers Object.entries(customHeaders).forEach(([key, value]) => { @@ -59,6 +61,7 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider { return originalFetch(input, { ...init, headers, + body, }) } diff --git a/packages/kilo-gateway/src/responses.ts b/packages/kilo-gateway/src/responses.ts new file mode 100644 index 00000000000..3697c5cf775 --- /dev/null +++ b/packages/kilo-gateway/src/responses.ts @@ -0,0 +1,49 @@ +function record(input: unknown): input is Record { + return typeof input === "object" && input !== null && !Array.isArray(input) +} + +function endpoint(input: string | URL | Request) { + const raw = input instanceof Request ? input.url : input.toString() + const path = (() => { + try { + return new URL(raw).pathname + } catch { + return raw.split(/[?#]/, 1)[0] + } + })() + return path.endsWith("/responses") +} + +function strip(input: unknown[]) { + const kept = input.flatMap((item) => { + if (!record(item)) return [item] + if (item.type === "item_reference") return [] + if (!("id" in item)) return [item] + + const next = { ...item } + delete next.id + return [next] + }) + const changed = kept.length !== input.length || kept.some((item, index) => item !== input[index]) + return { kept, changed } +} + +export function sanitizeResponsesBody(input: string | URL | Request, body: BodyInit | null | undefined) { + if (!endpoint(input)) return body + if (typeof body !== "string") return body + + const data = (() => { + try { + return JSON.parse(body) as unknown + } catch { + return undefined + } + })() + if (!record(data)) return body + if (data.store === true) return body + if (!Array.isArray(data.input)) return body + + const result = strip(data.input) + if (!result.changed) return body + return JSON.stringify({ ...data, input: result.kept }) +} diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 269427b7f44..f2cd17498fa 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -132,6 +132,21 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { cost: z.number().optional(), }) + const TranscriptionResponse = z.object({ + text: z.string(), + usage: z.unknown().optional(), + }) + + const getProxyAuth = async () => { + const auth = await Auth.get("kilo") + const token = auth?.type === "api" ? auth.key : auth?.type === "oauth" ? auth.access : undefined + return { + auth, + token, + organizationId: auth?.type === "oauth" ? auth.accountId : undefined, + } + } + return new Hono() .get( "/profile", @@ -324,19 +339,16 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { }), ), async (c: any) => { - const auth = await Auth.get("kilo") + const proxy = await getProxyAuth() - if (!auth) { + if (!proxy.auth) { return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) } - const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined - if (!token) { + if (!proxy.token) { return c.json({ error: "No valid token found" }, 401) } - const organizationId = auth.type === "oauth" ? auth.accountId : undefined - const { prefix, suffix, model, maxTokens, temperature } = c.req.valid("json") const fimModel = model ?? "mistralai/codestral-2501" const fimMaxTokens = maxTokens ?? 256 @@ -347,8 +359,8 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { const headers = { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - ...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }), + Authorization: `Bearer ${proxy.token}`, + ...buildKiloHeaders(undefined, { kilocodeOrganizationId: proxy.organizationId }), [HEADER_FEATURE]: "autocomplete", } @@ -390,6 +402,66 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { }) }, ) + .post( + "/audio/transcriptions", + describeRoute({ + summary: "Speech to text transcription", + description: "Proxy an audio transcription request to the Kilo Gateway", + operationId: "kilo.audio.transcriptions", + responses: { + 200: { + description: "Transcription response", + content: { + "application/json": { + schema: resolver(TranscriptionResponse), + }, + }, + }, + ...errors(400, 401), + }, + }), + validator( + "json", + z.object({ + model: z.string(), + input_audio: z.object({ + data: z.string(), + format: z.string(), + }), + language: z.string().optional(), + temperature: z.number().optional(), + }), + ), + async (c: any) => { + const proxy = await getProxyAuth() + if (!proxy.auth) return c.json({ error: "Not authenticated with Kilo Gateway" }, 401) + + if (!proxy.token) return c.json({ error: "No valid token found" }, 401) + + const body = c.req.valid("json") + const headers = { + "Content-Type": "application/json", + Authorization: `Bearer ${proxy.token}`, + ...buildKiloHeaders(undefined, { kilocodeOrganizationId: proxy.organizationId }), + [HEADER_FEATURE]: "vscode-extension", + } + + const response = await fetch(`${KILO_API_BASE}/api/gateway/v1/audio/transcriptions`, { + method: "POST", + headers, + signal: c.req.raw.signal, + body: JSON.stringify(body), + }) + + const text = await response.text() + return new Response(text, { + status: response.status, + headers: { + "Content-Type": response.headers.get("Content-Type") ?? "application/json", + }, + }) + }, + ) .get( "/notifications", describeRoute({ diff --git a/packages/kilo-gateway/test/api/models.test.ts b/packages/kilo-gateway/test/api/models.test.ts new file mode 100644 index 00000000000..c9224687459 --- /dev/null +++ b/packages/kilo-gateway/test/api/models.test.ts @@ -0,0 +1,124 @@ +// Verifies fetchKiloModels typed result and 401 fallback behaviour. + +import { test, expect } from "bun:test" +import { fetchKiloModels } from "../../src/api/models.js" + +const VALID_RESPONSE = JSON.stringify({ + data: [ + { + id: "test/model-a", + name: "Test Model A", + context_length: 128000, + max_completion_tokens: 16384, + architecture: { + input_modalities: ["text"], + output_modalities: ["text"], + }, + supported_parameters: ["tools", "temperature"], + }, + ], +}) + +function stubFetch(fn: (input: string | URL | Request, init?: RequestInit) => Promise) { + ;(globalThis as any).fetch = fn +} + +test("returns empty models and error when both auth and public requests return 401", async () => { + const orig = globalThis.fetch + stubFetch(async () => new Response("Unauthorized", { status: 401, statusText: "Unauthorized" })) + + const result = await fetchKiloModels({ kilocodeToken: "bad-token" }) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual({}) + expect(result.error).toBeDefined() +}) + +test("falls back to public endpoint on 401 and returns models", async () => { + const orig = globalThis.fetch + let callCount = 0 + + stubFetch(async () => { + callCount++ + if (callCount === 1) { + return new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }) + } + return new Response(VALID_RESPONSE, { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) + + const result = await fetchKiloModels({ + kilocodeToken: "expired-token", + kilocodeOrganizationId: "org-123", + }) + + ;(globalThis as any).fetch = orig + + expect(callCount).toBe(2) + expect(result.error).toBeUndefined() + expect(Object.keys(result.models).length).toBeGreaterThan(0) +}) + +test("returns error with kind=network on fetch exception", async () => { + const orig = globalThis.fetch + stubFetch(async () => { + throw new Error("network error") + }) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual({}) + expect(result.error?.kind).toBe("network") +}) + +test("returns error with kind=http on non-auth HTTP error (e.g. 500)", async () => { + const orig = globalThis.fetch + stubFetch(async () => new Response("Server Error", { status: 500, statusText: "Internal Server Error" })) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual({}) + expect(result.error?.kind).toBe("http") + expect(result.error?.status).toBe(500) +}) + +test("returns models without error on success", async () => { + const orig = globalThis.fetch + stubFetch(async () => + new Response(VALID_RESPONSE, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.error).toBeUndefined() + expect(Object.keys(result.models).length).toBeGreaterThan(0) +}) + +test("returns error with kind=schema when response body is invalid JSON", async () => { + const orig = globalThis.fetch + stubFetch(async () => + new Response("not valid json{{{{", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + const result = await fetchKiloModels({}) + + ;(globalThis as any).fetch = orig + + expect(result.models).toEqual({}) + expect(result.error?.kind).toBe("schema") +}) diff --git a/packages/kilo-gateway/test/responses.test.ts b/packages/kilo-gateway/test/responses.test.ts new file mode 100644 index 00000000000..49520300f50 --- /dev/null +++ b/packages/kilo-gateway/test/responses.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test" +import { sanitizeResponsesBody } from "../src/responses" + +describe("Responses request sanitization", () => { + test("strips item ids when storage is disabled", () => { + const body = JSON.stringify({ + store: false, + input: [ + { + type: "reasoning", + id: "rs_tmp_123", + encrypted_content: "encrypted", + summary: [{ type: "summary_text", text: "thinking" }], + }, + { + type: "message", + role: "assistant", + id: "msg_tmp_123", + content: [{ type: "output_text", text: "Hello" }], + }, + { + type: "item_reference", + id: "rs_tmp_456", + }, + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Continue" }], + }, + ], + }) + + const result = sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/responses", body) + const data = JSON.parse(result as string) + + expect(data.input).toHaveLength(3) + expect(data.input[0].id).toBeUndefined() + expect(data.input[0].encrypted_content).toBe("encrypted") + expect(data.input[0].summary[0].text).toBe("thinking") + expect(data.input[1].id).toBeUndefined() + expect(data.input[1].content[0].text).toBe("Hello") + expect(data.input.some((item: { type?: string }) => item.type === "item_reference")).toBe(false) + expect(data.input[2].content[0].text).toBe("Continue") + }) + + test("keeps item ids when storage is enabled", () => { + const body = JSON.stringify({ + store: true, + input: [ + { + type: "reasoning", + id: "rs_123", + encrypted_content: "encrypted", + summary: [], + }, + { + type: "item_reference", + id: "rs_456", + }, + ], + }) + + expect(sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/responses", body)).toBe(body) + }) + + test("leaves non-responses requests unchanged", () => { + const body = "not json" + + expect(sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/chat/completions", body)).toBe(body) + }) + + test("leaves invalid responses JSON unchanged", () => { + const body = "not json" + + expect(sanitizeResponsesBody("https://api.kilo.ai/api/openrouter/responses", body)).toBe(body) + }) + + test("matches relative responses paths without a placeholder host", () => { + const body = JSON.stringify({ + input: [ + { + type: "message", + role: "assistant", + id: "msg_tmp_123", + content: [{ type: "output_text", text: "Hello" }], + }, + ], + }) + const result = sanitizeResponsesBody("/api/openrouter/responses?stream=true", body) + const data = JSON.parse(result as string) + + expect(data.input[0].id).toBeUndefined() + }) +}) diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index cc1ef749a14..9e86f83af9e 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.48", + "version": "7.2.52", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index 7dd1f9304fa..d7a778caa65 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -91,7 +91,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "أوقف لقطات Kilo لهذا المشروع. ستفقد ميزة التراجع/الإعادة لتعديلات Kilo، لكن git سيستمر في تتبع كل شيء.", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "فتح في عارض الفروقات", + "ui.messagePart.shell.command": "الأمر", + "ui.messagePart.shell.output": "المخرجات", + "ui.messagePart.openInEditor": "فتح في المحرر", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "كان هذا مفيدًا", diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index e79ef39bf30..bbf53cd2ff3 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -91,7 +91,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Desligue os snapshots do Kilo para este projeto. Você perde desfazer/refazer das mudanças feitas pelo Kilo, mas o git continua rastreando tudo.", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Abrir no Visualizador de Diferenças", + "ui.messagePart.shell.command": "Comando", + "ui.messagePart.shell.output": "Saída", + "ui.messagePart.openInEditor": "Abrir no Editor", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "Isso foi útil", diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index e23e964bbd5..2e4983260dc 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -96,7 +96,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Isključi Kilo snapshotove za ovaj projekat. Izgubićete poništi/vrati za izmjene koje napravi Kilo, ali git i dalje prati sve.", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Otvori u pregledniku razlika", + "ui.messagePart.shell.command": "Naredba", + "ui.messagePart.shell.output": "Izlaz", + "ui.messagePart.openInEditor": "Otvori u editoru", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "Ovo je bilo korisno", diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index 2a086970b40..e930733acbe 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -91,7 +91,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Slå Kilos snapshots fra for dette projekt. Du mister fortryd/gentag for Kilo-ændringer, men git sporer stadig alt.", - "ui.messagePart.openInDiffViewer": "Åbn i diff-viser", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Åbn i Diff-visning", + "ui.messagePart.shell.command": "Kommando", + "ui.messagePart.shell.output": "Output", + "ui.messagePart.openInEditor": "Åbn i editor", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "Dette var nyttigt", diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index 437f5fc649f..ce73601069b 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -91,7 +91,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Kilo-Snapshots für dieses Projekt ausschalten. Rückgängig/Wiederherstellen für Kilo-Änderungen ist nicht mehr möglich, aber git verfolgt weiterhin alles.", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Im Diff-Viewer öffnen", + "ui.messagePart.shell.command": "Befehl", + "ui.messagePart.shell.output": "Ausgabe", + "ui.messagePart.openInEditor": "Im Editor öffnen", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "Das war hilfreich", diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index bd80d92857d..7f908032995 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -99,6 +99,10 @@ export const dict = { // Edit-tool header: hover-revealed action opening the diff in a full tab. "ui.messagePart.openInDiffViewer": "Open in Diff Viewer", + // Shell-tool section labels and actions. + "ui.messagePart.shell.command": "Command", + "ui.messagePart.shell.output": "Output", + "ui.messagePart.openInEditor": "Open in Editor", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "This was helpful", diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index 8b05522cb9f..07af68a1a0f 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -91,7 +91,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Apaga las instantáneas de Kilo para este proyecto. Perderás deshacer/rehacer de los cambios de Kilo, pero git seguirá rastreando todo.", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Abrir en el visor de diferencias", + "ui.messagePart.shell.command": "Comando", + "ui.messagePart.shell.output": "Salida", + "ui.messagePart.openInEditor": "Abrir en el editor", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "Esto fue útil", diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index b64ace8f1f9..2faf61d345b 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -91,7 +91,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Désactivez les instantanés Kilo pour ce projet. Vous perdez l'annulation/restauration des modifications faites par Kilo, mais git continue de tout suivre.", - "ui.messagePart.openInDiffViewer": "Ouvrir dans la visionneuse de différences", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Ouvrir dans le visualiseur de différences", + "ui.messagePart.shell.command": "Commande", + "ui.messagePart.shell.output": "Sortie", + "ui.messagePart.openInEditor": "Ouvrir dans l'éditeur", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "C'était utile", diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index 9d5214c7a48..f30eae15557 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -89,7 +89,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "このプロジェクトでは Kilo のスナップショットを無効にします。Kilo による変更の取り消し/やり直しはできなくなりますが、git は引き続きすべてを追跡します。", - "ui.messagePart.openInDiffViewer": "差分ビューアで開く", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "差分ビューアーで開く", + "ui.messagePart.shell.command": "コマンド", + "ui.messagePart.shell.output": "出力", + "ui.messagePart.openInEditor": "エディタで開く", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "役に立ちました", diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index 6308cf9fc68..8a9692001fe 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -89,7 +89,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "이 프로젝트의 Kilo 스냅샷을 끕니다. Kilo 변경에 대한 실행 취소/다시 실행은 사용할 수 없지만 git은 여전히 모든 것을 추적합니다.", - "ui.messagePart.openInDiffViewer": "Diff 뷰어에서 열기", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "차이점 뷰어에서 열기", + "ui.messagePart.shell.command": "명령어", + "ui.messagePart.shell.output": "출력", + "ui.messagePart.openInEditor": "편집기에서 열기", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "도움이 됐어요", diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index 4fb55eddca5..142c0958286 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -93,7 +93,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Zet Kilo-snapshots uit voor dit project. Je verliest ongedaan maken/opnieuw doen van Kilo-wijzigingen, maar git blijft alles volgen.", - "ui.messagePart.openInDiffViewer": "Openen in diff-viewer", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Openen in Diff-weergave", + "ui.messagePart.shell.command": "Opdracht", + "ui.messagePart.shell.output": "Uitvoer", + "ui.messagePart.openInEditor": "Openen in editor", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "Dit was nuttig", diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index 915801390af..e7f223b923b 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -91,7 +91,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Slå av Kilos snapshots for dette prosjektet. Du mister angre/gjør om for Kilo-endringer, men git fortsetter å spore alt.", - "ui.messagePart.openInDiffViewer": "Åpne i diff-viser", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Åpne i diff-visning", + "ui.messagePart.shell.command": "Kommando", + "ui.messagePart.shell.output": "Utdata", + "ui.messagePart.openInEditor": "Åpne i editor", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "Dette var nyttig", diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 67b83e46c29..8cfd61e3c2b 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -91,7 +91,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Wyłącz snapshoty Kilo dla tego projektu. Stracisz cofanie/przywracanie zmian Kilo, ale git nadal śledzi wszystko.", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "Otwórz w podglądzie różnic", + "ui.messagePart.shell.command": "Polecenie", + "ui.messagePart.shell.output": "Wyjście", + "ui.messagePart.openInEditor": "Otwórz w edytorze", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "To było pomocne", diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index d9cff803db5..2a923436c22 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -91,7 +91,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Выключите снимки Kilo для этого проекта. Вы потеряете отмену/повтор изменений Kilo, но git по-прежнему отслеживает всё.", - "ui.messagePart.openInDiffViewer": "Открыть в просмотрщике различий", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Открыть в просмотре различий", + "ui.messagePart.shell.command": "Команда", + "ui.messagePart.shell.output": "Вывод", + "ui.messagePart.openInEditor": "Открыть в редакторе", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "Это было полезно", diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index be90eddc10a..ae0c5a4d88c 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -91,7 +91,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "ปิดสแน็ปช็อตของ Kilo สำหรับโปรเจกต์นี้ คุณจะสูญเสียการยกเลิก/ทำซ้ำสำหรับการเปลี่ยนแปลงของ Kilo แต่ git ยังคงติดตามทุกอย่าง", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "เปิดในตัวดูความแตกต่าง", + "ui.messagePart.shell.command": "คำสั่ง", + "ui.messagePart.shell.output": "ผลลัพธ์", + "ui.messagePart.openInEditor": "เปิดในตัวแก้ไข", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "สิ่งนี้มีประโยชน์", diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index 0ebf2a73f24..6297ed3295a 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -91,7 +91,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Bu proje için Kilo anlık görüntülerini kapat. Kilo değişiklikleri için geri alma/yeniden yapmayı kaybedersiniz, ancak git her şeyi izlemeye devam eder.", - "ui.messagePart.openInDiffViewer": "Fark görüntüleyicide aç", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Fark Görüntüleyicide Aç", + "ui.messagePart.shell.command": "Komut", + "ui.messagePart.shell.output": "Çıktı", + "ui.messagePart.openInEditor": "Düzenleyicide Aç", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "Bu yardımcı oldu", diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index 3e325a33f7d..afc11ae14e1 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -91,7 +91,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "Вимкніть знімки Kilo для цього проєкту. Ви втратите скасування/повторення для змін Kilo, але git продовжить відстежувати все.", - "ui.messagePart.openInDiffViewer": "Відкрити у переглядачі відмінностей", + // Edit-tool header and shell-tool section labels + "ui.messagePart.openInDiffViewer": "Відкрити в переглядачі відмінностей", + "ui.messagePart.shell.command": "Команда", + "ui.messagePart.shell.output": "Вивід", + "ui.messagePart.openInEditor": "Відкрити в редакторі", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "Це було корисно", diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index 29a57c5290d..37954ff59d2 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -86,7 +86,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "关闭本项目的 Kilo 快照。你将失去对 Kilo 更改的撤销/重做,但 git 仍会追踪所有内容。", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "在差异查看器中打开", + "ui.messagePart.shell.command": "命令", + "ui.messagePart.shell.output": "输出", + "ui.messagePart.openInEditor": "在编辑器中打开", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "这有帮助", diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index 08582812567..5561173b89d 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -86,7 +86,11 @@ export const dict = { "snapshot.slowRepo.answer.disable.description": "關閉本專案的 Kilo 快照。你將失去對 Kilo 變更的撤銷/重做,但 git 仍會追蹤所有內容。", + // Edit-tool header and shell-tool section labels "ui.messagePart.openInDiffViewer": "在差異檢視器中開啟", + "ui.messagePart.shell.command": "命令", + "ui.messagePart.shell.output": "輸出", + "ui.messagePart.openInEditor": "在編輯器中開啟", // Message feedback (thumbs up/down per assistant response) "ui.message.feedback.helpful": "這有幫助", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 01b6a92e9bd..0fe3b960876 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.48", + "version": "7.2.52", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-indexing/src/indexing/config-manager.ts b/packages/kilo-indexing/src/indexing/config-manager.ts index 00c9efd5278..1c5124de2dd 100644 --- a/packages/kilo-indexing/src/indexing/config-manager.ts +++ b/packages/kilo-indexing/src/indexing/config-manager.ts @@ -164,7 +164,8 @@ export class CodeIndexConfigManager { // LanceDB doesn't need a qdrant URL; qdrant does const hasStore = isLancedb || !!qdrant - if (provider === "kilo") return !!(this.kiloOptions?.apiKey && this.modelId && this.currentModelDimension && hasStore) + if (provider === "kilo") + return !!(this.kiloOptions?.apiKey && this.modelId && this.currentModelDimension && hasStore) if (provider === "openai") return !!(this.openAiOptions?.apiKey && hasStore) if (provider === "ollama") return !!(this.ollamaOptions?.baseUrl && hasStore) if (provider === "openai-compatible") 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/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index b4cae1beac5..5924d04a5f9 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -43,6 +43,7 @@ openApiGenerate { "anyOf<>" to "kotlin.Any", "number" to "kotlin.Double", "decimal" to "kotlin.Double", + "integer" to "kotlin.Long", )) openapiNormalizer.set(mapOf( "SIMPLIFY_ANYOF_STRING_AND_ENUM_STRING" to "true", 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 c3337285594..d1998f4d64b 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 @@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -155,6 +157,81 @@ class KiloBackendSessionManager( directories.remove(id) } + /** + * Rename a session by sending `PATCH /session/{id}?directory={dir}` with `{"title":"..."}`. + * + * Uses raw HTTP because the generated Kotlin client is build-time only and + * this repo already uses raw HTTP for session create and cloud operations. + */ + fun rename(id: String, dir: String, title: String): SessionDto { + val h = http ?: throw IllegalStateException("Session manager not started") + val url = base ?: throw IllegalStateException("Session manager not started") + val json = """{"title":"${escape(title)}"}""" + val patch = url.toHttpUrl().newBuilder() + .addPathSegment("session") + .addPathSegment(id) + .addQueryParameter("directory", dir) + .build() + val request = Request.Builder() + .url(patch) + .method("PATCH", json.toRequestBody("application/json".toMediaType())) + .build() + + h.newCall(request).execute().use { response -> + val raw = response.body?.string() + if (!response.isSuccessful) { + log.warn("Session rename failed: HTTP ${response.code}, body=$raw") + throw RuntimeException("Session rename failed: HTTP ${response.code} — $raw") + } + return KiloCliDataParser.parseSession(raw!!) + } + } + + 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) @@ -192,9 +269,9 @@ class KiloBackendSessionManager( ), summary = s.summary?.let { SessionSummaryDto( - additions = it.additions.toInt(), - deletions = it.deletions.toInt(), - files = it.files.toInt(), + additions = it.additions.safeInt(), + deletions = it.deletions.safeInt(), + files = it.files.safeInt(), ) }, ) @@ -213,9 +290,9 @@ class KiloBackendSessionManager( ), summary = s.summary?.let { SessionSummaryDto( - additions = it.additions.toInt(), - deletions = it.deletions.toInt(), - files = it.files.toInt(), + additions = it.additions.safeInt(), + deletions = it.deletions.safeInt(), + files = it.files.safeInt(), ) }, ) @@ -223,8 +300,25 @@ class KiloBackendSessionManager( private fun statusDto(s: SessionStatus) = SessionStatusDto( type = s.type.value, message = s.message.ifBlank { null }, - attempt = s.attempt.toInt(), - next = s.next.toLong(), + attempt = s.attempt.safeInt(), + next = s.next, 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) + } + } + } + + private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() } 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..9e097feae5c 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 @@ -198,8 +200,8 @@ object KiloCliDataParser { val file = d.str("file") ?: return@mapNotNull null DiffFileDto( file = file, - additions = d.long("additions")?.toInt() ?: 0, - deletions = d.long("deletions")?.toInt() ?: 0, + additions = d.long("additions")?.safeInt() ?: 0, + deletions = d.long("deletions")?.safeInt() ?: 0, patch = d.str("patch"), ) } ?: emptyList() @@ -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( @@ -516,9 +537,9 @@ object KiloCliDataParser { ), summary = summary?.let { SessionSummaryDto( - additions = it.long("additions")?.toInt() ?: 0, - deletions = it.long("deletions")?.toInt() ?: 0, - files = it.long("files")?.toInt() ?: 0, + additions = it.long("additions")?.safeInt() ?: 0, + deletions = it.long("deletions")?.safeInt() ?: 0, + files = it.long("files")?.safeInt() ?: 0, ) }, ) @@ -543,7 +564,7 @@ object KiloCliDataParser { return SessionStatusDto( type = type, message = st.str("message"), - attempt = st.long("attempt")?.toInt(), + attempt = st.long("attempt")?.safeInt(), next = st.long("next"), requestID = st.str("requestID"), ) @@ -658,6 +679,8 @@ private fun JsonObject.num(key: String): Double? = private fun JsonObject.long(key: String): Long? = this[key]?.jsonPrimitive?.longOrNull +private fun Long.safeInt() = coerceIn(Int.MIN_VALUE.toLong(), Int.MAX_VALUE.toLong()).toInt() + private fun JsonObject?.map(key: String): Map { val obj = this?.get(key)?.jsonObject ?: return emptyMap() return obj.entries.mapNotNull { (name, value) -> 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..0192304ef4a 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,17 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { workspaces.get(dir).deleteSession(id) } + override suspend fun rename(id: String, directory: String, title: String): ai.kilocode.rpc.dto.SessionDto { + val dir = sessions.getDirectory(id, directory) + return sessions.rename(id, dir, title) + } + + 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..8419d47340d 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 @@ -20,6 +20,7 @@ import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -185,6 +186,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 @@ -374,6 +473,134 @@ class KiloBackendSessionManagerTest { assertNotNull(result) } + // ------ Session rename ------ + + @Test + fun `rename patches session title and returns updated session`() = runBlocking { + mock.sessionRenameResponse = """{ + "id": "ses_1", + "slug": "s1", + "projectID": "prj_test", + "directory": "/test", + "title": "New Name", + "version": "1.0.0", + "time": {"created": 1000, "updated": 2000} + }""" + val app = setup() + ready(app) + + val session = app.sessions.rename("ses_1", "/test", "New Name") + + assertEquals("ses_1", session.id) + assertEquals("New Name", session.title) + val path = mock.lastSessionRenamePath ?: error("missing rename request") + assertTrue(path.startsWith("/session/ses_1?"), "Expected /session/ses_1?... got $path") + assertTrue(path.contains("directory=%2Ftest"), "Expected directory=/test in $path") + assertEquals("PATCH", mock.lastSessionRenameMethod) + assertEquals("""{"title":"New Name"}""", mock.lastSessionRenameBody) + } + + @Test + fun `rename response preserves directory parentId summary and timestamps`() = runBlocking { + mock.sessionRenameResponse = """{ + "id": "ses_1", + "slug": "s1", + "projectID": "prj_test", + "directory": "/worktree/path", + "title": "Renamed", + "version": "2.0.0", + "time": {"created": 1000, "updated": 9999}, + "parentID": "ses_parent", + "summary": {"additions": 5, "deletions": 3, "files": 2} + }""" + val app = setup() + ready(app) + + val session = app.sessions.rename("ses_1", "/test", "Renamed") + + assertEquals("/worktree/path", session.directory) + assertEquals("ses_parent", session.parentID) + assertEquals(1000.0, session.time.created) + assertEquals(9999.0, session.time.updated) + assertNotNull(session.summary) + assertEquals(5, session.summary!!.additions) + assertEquals(3, session.summary!!.deletions) + assertEquals(2, session.summary!!.files) + } + + @Test + fun `rename url-encodes session id and directory for special characters`() = runBlocking { + // Session IDs/directories may contain spaces, slashes, plus signs, and ampersands + val app = setup() + ready(app) + + app.sessions.rename("ses_a/b c", "/my dir/project", "New Name") + + val path = mock.lastSessionRenamePath ?: error("missing rename request") + assertTrue(path.startsWith("/session/ses_a%2Fb%20c?"), "Expected encoded session path in $path") + assertTrue(path.contains("directory=%2Fmy%20dir%2Fproject"), "Expected encoded directory in $path") + } + + @Test + fun `rename url-encodes ampersand plus and query separators`() = runBlocking { + val app = setup() + ready(app) + + app.sessions.rename("ses_a+b&c?d", "/path?a=1&b=2", "Title") + + val path: String = mock.lastSessionRenamePath ?: error("missing rename request") + val bare = path.substringBefore("?") + val query = path.substringAfter("?", "") + assertTrue(path.contains("/session/ses_a+b&c%3Fd?"), "Unexpected encoded session id: $path") + assertFalse(query.contains("/path?a=1&b=2"), "Directory must be encoded as one query value: $query") + assertTrue(query.contains("directory=%2Fpath%3Fa%3D1%26b%3D2"), "Unexpected encoded directory: $query") + } + + @Test + fun `rename encodes title in json body`() = runBlocking { + val app = setup() + ready(app) + + app.sessions.rename("ses_1", "/test", "Has \"quotes\" and \\ backslash") + + assertEquals("""{"title":"Has \"quotes\" and \\ backslash"}""", mock.lastSessionRenameBody) + } + + @Test + fun `rename encodes title control characters in json body`() = runBlocking { + val app = setup() + ready(app) + + app.sessions.rename("ses_1", "/test", "Line\nTab\tReturn\rBell\u0007") + + assertEquals("""{"title":"Line\nTab\tReturn\rBell\u0007"}""", mock.lastSessionRenameBody) + } + + @Test + fun `rename surfaces server failure`() = runBlocking { + mock.sessionRenameStatus = 500 + mock.sessionRenameResponse = """{"error":"boom"}""" + val app = setup() + ready(app) + + val err = assertFailsWith { + app.sessions.rename("ses_1", "/test", "New Name") + } + + assertTrue(err.message.orEmpty().contains("HTTP 500")) + assertTrue(err.message.orEmpty().contains("boom")) + } + + @Test + fun `rename throws before start`() = runBlocking { + val app = setup() + // Don't connect — manager is not started + + assertFailsWith { + app.sessions.rename("ses_1", "/test", "Title") + } + } + // ------ Session with summary ------ @Test @@ -398,4 +625,32 @@ class KiloBackendSessionManagerTest { assertEquals(7, session.summary!!.deletions) assertEquals(3, session.summary!!.files) } + + @Test + fun `status and summary long values clamp to shared dto int range`() = runBlocking { + mock.sessions = """[{ + "id": "ses_big", + "slug": "big", + "projectID": "prj", + "directory": "/d", + "title": "Big", + "version": "1", + "time": {"created": 1, "updated": 1}, + "summary": {"additions": 2147483648, "deletions": 9223372036854775807, "files": 3} + }]""" + mock.sessionStatuses = """{ + "ses_big": {"type":"retry","attempt":2147483648,"message":"retrying","next":9223372036854775807,"requestID":"req"} + }""" + val app = setup() + ready(app) + + val result = app.sessions.list("/d") + val session = result.sessions[0] + val status = result.statuses["ses_big"] ?: error("missing status") + + assertEquals(Int.MAX_VALUE, session.summary?.additions) + assertEquals(Int.MAX_VALUE, session.summary?.deletions) + assertEquals(Int.MAX_VALUE, status.attempt) + assertEquals(Long.MAX_VALUE, status.next) + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt index 7ddd445175f..b19ed6072dd 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/GeneratedApiModelSerializationTest.kt @@ -39,7 +39,7 @@ class GeneratedApiModelSerializationTest { @Test fun `empty provider list`() { - val src = """{"all":[],"default":{},"connected":[]}""" + val src = """{"all":[],"default":{},"connected":[],"failed":[]}""" val obj = json.decodeFromString(src) assertTrue(obj.all.isEmpty()) assertTrue(obj.default.isEmpty()) @@ -81,7 +81,8 @@ class GeneratedApiModelSerializationTest { } }], "default": {"code": "anthropic/claude-4"}, - "connected": ["anthropic"] + "connected": ["anthropic"], + "failed": [] }""" val obj = json.decodeFromString(src) assertEquals(1, obj.all.size) @@ -132,7 +133,8 @@ class GeneratedApiModelSerializationTest { } }], "default": {}, - "connected": [] + "connected": [], + "failed": [] }""" val obj = json.decodeFromString(src) val model = obj.all[0].models["free-model"]!! @@ -149,12 +151,22 @@ class GeneratedApiModelSerializationTest { "all": [], "default": {}, "connected": [], + "failed": [], "future_field": "value" }""" val obj = json.decodeFromString(src) assertTrue(obj.all.isEmpty()) } + @Test + fun `provider list defaults omitted collection fields`() { + val obj = json.decodeFromString("{}") + assertTrue(obj.all.isEmpty()) + assertTrue(obj.default.isEmpty()) + assertTrue(obj.connected.isEmpty()) + assertTrue(obj.failed.isEmpty()) + } + // ------ Agent ------ @Test @@ -287,4 +299,70 @@ class GeneratedApiModelSerializationTest { val list = json.decodeFromString>("[]") assertTrue(list.isEmpty()) } + + // ------ ProviderList200Response.failed non-empty ------ + + @Test + fun `provider list failed field handles non-empty list`() { + val src = """{ + "all": [], + "default": {}, + "connected": [], + "failed": ["openai", "anthropic"] + }""" + val obj = json.decodeFromString(src) + assertEquals(listOf("openai", "anthropic"), obj.failed) + } + + @Test + fun `provider list failed field handles single failed provider`() { + val src = """{"all":[],"default":{},"connected":[],"failed":["openai"]}""" + val obj = json.decodeFromString(src) + assertEquals(listOf("openai"), obj.failed) + } + + // ------ Model limits with realistic large values ------ + + @Test + fun `model limit context and output deserialize with large values`() { + val src = """{ + "all": [{ + "id": "p", + "name": "Provider", + "source": "api", + "env": [], + "options": {}, + "models": { + "big-model": { + "id": "big-model", + "providerID": "p", + "name": "Big Model", + "api": {"id": "p", "url": "", "npm": ""}, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": {"text": true, "audio": false, "image": false, "video": false, "pdf": false}, + "output": {"text": true, "audio": false, "image": false, "video": false, "pdf": false}, + "interleaved": {"field":"reasoning_content"} + }, + "cost": {"input": 0, "output": 0, "cache": {"read": 0, "write": 0}}, + "limit": {"context": 1000000, "output": 32000}, + "status": "active", + "options": {}, + "headers": {}, + "release_date": "2025-01-01" + } + } + }], + "default": {}, + "connected": [], + "failed": [] + }""" + val obj = json.decodeFromString(src) + val model = obj.all[0].models["big-model"]!! + assertEquals(1000000.0, model.limit.context) + assertEquals(32000.0, model.limit.output) + } } 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..6b1f3c980fe 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 // ================================================================ @@ -809,6 +836,21 @@ class KiloCliDataParserTest { assertEquals(5000L, result.status.next) } + @Test + fun `parseChatEvent - session status clamps large attempt`() { + val data = globalEvent(""" + "type": "session.status", + "properties": { + "sessionID": "ses_1", + "status": {"type": "retry", "message": "Retrying...", "attempt": 2147483648, "next": 9223372036854775807} + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.status", data) as ChatEventDto.SessionStatusChanged + assertEquals(Int.MAX_VALUE, result.status.attempt) + assertEquals(Long.MAX_VALUE, result.status.next) + } + @Test fun `parseChatEvent - session status offline with requestID`() { val data = globalEvent(""" @@ -943,6 +985,21 @@ class KiloCliDataParserTest { assertEquals(3, result.diff[0].additions) } + @Test + fun `parseChatEvent - session diff clamps large counts`() { + val data = globalEvent(""" + "type": "session.diff", + "properties": { + "sessionID": "ses_1", + "diff": [{"file": "src/A.kt", "additions": 2147483648, "deletions": 9223372036854775807, "patch": "@@ ..."}] + } + """) + + val result = KiloCliDataParser.parseChatEvent("session.diff", data) as ChatEventDto.SessionDiffChanged + assertEquals(Int.MAX_VALUE, result.diff[0].additions) + assertEquals(Int.MAX_VALUE, result.diff[0].deletions) + } + // ================================================================ // parseChatEvent — part with callID // ================================================================ diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt index 093b62e9c6a..35ba6f52e34 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ProjectModelSerializationTest.kt @@ -23,7 +23,7 @@ class ProjectModelSerializationTest { @Test fun `empty provider list`() { - val src = """{"all":[],"default":{},"connected":[]}""" + val src = """{"all":[],"default":{},"connected":[],"failed":[]}""" val obj = json.decodeFromString(src) assertTrue(obj.all.isEmpty()) assertTrue(obj.default.isEmpty()) @@ -64,7 +64,8 @@ class ProjectModelSerializationTest { } }], "default": {"code": "anthropic/claude-4"}, - "connected": ["anthropic"] + "connected": ["anthropic"], + "failed": [] }""" val obj = json.decodeFromString(src) assertEquals(1, obj.all.size) @@ -115,7 +116,8 @@ class ProjectModelSerializationTest { } }], "default": {}, - "connected": [] + "connected": [], + "failed": [] }""" val obj = json.decodeFromString(src) val model = obj.all[0].models["free-model"]!! @@ -132,12 +134,20 @@ class ProjectModelSerializationTest { "all": [], "default": {}, "connected": [], + "failed": [], "future_field": "value" }""" val obj = json.decodeFromString(src) assertTrue(obj.all.isEmpty()) } + @Test + fun `provider list defaults omitted failed field`() { + val src = """{"all":[],"default":{},"connected":[]}""" + val obj = json.decodeFromString(src) + assertTrue(obj.failed.isEmpty()) + } + // ------ Agent ------ @Test @@ -270,4 +280,18 @@ class ProjectModelSerializationTest { val list = json.decodeFromString>("[]") assertTrue(list.isEmpty()) } + + // ------ ProviderList200Response.failed non-empty ------ + + @Test + fun `provider list failed field handles non-empty list`() { + val src = """{ + "all": [], + "default": {}, + "connected": [], + "failed": ["openai", "gemini"] + }""" + val obj = json.decodeFromString(src) + assertEquals(listOf("openai", "gemini"), obj.failed) + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt index 398a1e4c4aa..e837d2693b9 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/SessionModelSerializationTest.kt @@ -26,7 +26,7 @@ class SessionModelSerializationTest { "directory": "/test/project", "title": "My Session", "version": "1.0.0", - "time": {"created": 1000.0, "updated": 2000.0} + "time": {"created": 1000, "updated": 2000} }""" val obj = json.decodeFromString(src) assertEquals("ses_abc", obj.id) @@ -34,8 +34,8 @@ class SessionModelSerializationTest { assertEquals("prj_123", obj.projectID) assertEquals("/test/project", obj.directory) assertEquals("My Session", obj.title) - assertEquals(1000.0, obj.time.created) - assertEquals(2000.0, obj.time.updated) + assertEquals(1000L, obj.time.created) + assertEquals(2000L, obj.time.updated) assertNull(obj.parentID) assertNull(obj.summary) } @@ -49,14 +49,14 @@ class SessionModelSerializationTest { "directory": "/d", "title": "T", "version": "1.0.0", - "time": {"created": 1.0, "updated": 2.0}, + "time": {"created": 1, "updated": 2}, "summary": {"additions": 10, "deletions": 5, "files": 3} }""" val obj = json.decodeFromString(src) assertNotNull(obj.summary) - assertEquals(10.0, obj.summary!!.additions) - assertEquals(5.0, obj.summary!!.deletions) - assertEquals(3.0, obj.summary!!.files) + assertEquals(10, obj.summary!!.additions) + assertEquals(5, obj.summary!!.deletions) + assertEquals(3, obj.summary!!.files) } @Test @@ -68,7 +68,7 @@ class SessionModelSerializationTest { "directory": "/d", "title": "Fork", "version": "1.0.0", - "time": {"created": 1.0, "updated": 2.0, "archived": 3000.0}, + "time": {"created": 1, "updated": 2, "archived": 3000.0}, "parentID": "ses_parent" }""" val obj = json.decodeFromString(src) @@ -132,9 +132,9 @@ class SessionModelSerializationTest { val src = """{"type":"retry","attempt":2,"message":"Rate limited","next":1500,"requestID":"req_1"}""" val obj = json.decodeFromString(src) assertEquals(SessionStatus.Type.RETRY, obj.type) - assertEquals(2.0, obj.attempt) + assertEquals(2L, obj.attempt) assertEquals("Rate limited", obj.message) - assertEquals(1500.0, obj.next) + assertEquals(1500L, obj.next) } @Test @@ -162,4 +162,69 @@ class SessionModelSerializationTest { val map = json.decodeFromString>("{}") assertTrue(map.isEmpty()) } + + // ------ Large integer (Long) handling ------ + + @Test + fun `Session time created and updated deserialize as Long for values larger than Int MAX_VALUE`() { + val big = Int.MAX_VALUE.toLong() + 12345L + val src = """{ + "id": "ses_big", + "slug": "big", + "projectID": "prj", + "directory": "/d", + "title": "Big Times", + "version": "1", + "time": {"created": $big, "updated": ${big + 1L}} + }""" + val obj = json.decodeFromString(src) + assertEquals(big, obj.time.created) + assertEquals(big + 1L, obj.time.updated) + } + + @Test + fun `Session time millisecond epoch timestamps deserialize correctly`() { + // Realistic millisecond timestamp: 2026-01-01T00:00:00Z = 1767139200000 + val ms = 1767139200000L + val src = """{ + "id": "ses_ms", + "slug": "ms", + "projectID": "prj", + "directory": "/d", + "title": "MS Times", + "version": "1", + "time": {"created": $ms, "updated": $ms} + }""" + val obj = json.decodeFromString(src) + assertEquals(ms, obj.time.created) + assertEquals(ms, obj.time.updated) + assertTrue(obj.time.created > Int.MAX_VALUE) + } + + @Test + fun `Session time defaults to 0L when fields are omitted`() { + // The generated model has default = 0L for created and updated + val obj = ai.kilocode.jetbrains.api.model.SessionTime() + assertEquals(0L, obj.created) + assertEquals(0L, obj.updated) + } + + @Test + fun `SessionStatus attempt and next deserialize as Long for large values`() { + val bigAttempt = Int.MAX_VALUE.toLong() + 5L + val bigNext = Int.MAX_VALUE.toLong() + 99999L + val src = """{"type":"retry","attempt":$bigAttempt,"message":"Retrying","next":$bigNext,"requestID":"req_1"}""" + val obj = json.decodeFromString(src) + assertEquals(SessionStatus.Type.RETRY, obj.type) + assertEquals(bigAttempt, obj.attempt) + assertEquals(bigNext, obj.next) + } + + @Test + fun `SessionStatus defaults attempt and next to 0L when omitted`() { + val src = """{"type":"idle","message":"","requestID":""}""" + val obj = json.decodeFromString(src) + assertEquals(0L, obj.attempt) + assertEquals(0L, obj.next) + } } 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..e98c52c0429 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 @@ -41,7 +41,7 @@ class MockCliServer : AutoCloseable { @Volatile var notificationsStatus = 200 // Project-scoped REST responses - @Volatile var providers = """{"all":[],"default":{},"connected":[]}""" + @Volatile var providers = """{"all":[],"default":{},"connected":[],"failed":[]}""" @Volatile var agents = "[]" @Volatile var commands = "[]" @Volatile var skills = "[]" @@ -62,9 +62,21 @@ 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 + @Volatile var sessionRenameStatus = 200 + @Volatile var sessionRenameResponse = """{"id":"ses_test","slug":"test","projectID":"prj_test","directory":"/test","title":"Renamed","version":"1.0.0","time":{"created":1000,"updated":2000}}""" + @Volatile var lastSessionRenamePath: String? = null + @Volatile var lastSessionRenameBody: String? = null + @Volatile var lastSessionRenameMethod: String? = null /** Configurable delay for all endpoint responses (ms). 0 = no delay. */ @Volatile var responseDelay: Long = 0 @@ -220,13 +232,28 @@ 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) - bare.matches(Regex("/session/ses_[^/]+")) && method == "GET" -> + bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "GET" -> respond(output, sessionGetStatus, sessionCreate) - bare.matches(Regex("/session/ses_[^/]+")) && method == "DELETE" -> + bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "DELETE" -> respond(output, sessionDeleteStatus, "true") + bare.matches(Regex("/session/ses_.+")) && !bare.contains("/summarize") && method == "PATCH" -> { + lastSessionRenamePath = path + lastSessionRenameBody = body + lastSessionRenameMethod = method + respond(output, sessionRenameStatus, sessionRenameResponse) + } bare.matches(Regex("/session/ses_[^/]+/summarize")) && method == "POST" -> { lastSummarizePath = path lastSummarizeBody = body diff --git a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt index 7a39f7c2161..099d0212123 100644 --- a/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt +++ b/packages/kilo-jetbrains/build-tasks/src/main/kotlin/FixGeneratedApiTask.kt @@ -218,7 +218,7 @@ abstract class FixGeneratedApiTask : DefaultTask() { } } - // Fix 7: Default values for non-nullable primitives in model data classes. + // Fix 7: Default values for non-nullable primitives and collections in model data classes. // The CLI API may omit fields that the OpenAPI spec marks as required // (e.g. `attachment`, `reasoning` on dynamically added models). // Add Kotlin defaults so kotlinx.serialization doesn't throw @@ -230,6 +230,9 @@ abstract class FixGeneratedApiTask : DefaultTask() { Regex("""(val \w+:\s*kotlin\.Boolean)(,|\n)""") to { m: MatchResult -> "${m.groupValues[1]} = false${m.groupValues[2]}" }, + Regex("""(val \w+:\s*kotlin\.Long)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = 0L${m.groupValues[2]}" + }, Regex("""(val \w+:\s*kotlin\.Int)(,|\n)""") to { m: MatchResult -> "${m.groupValues[1]} = 0${m.groupValues[2]}" }, @@ -239,6 +242,12 @@ abstract class FixGeneratedApiTask : DefaultTask() { Regex("""(val \w+:\s*kotlin\.String)(,|\n)""") to { m: MatchResult -> "${m.groupValues[1]} = \"\"${m.groupValues[2]}" }, + Regex("""(val \w+:\s*kotlin\.collections\.List<[^>]+>)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = emptyList()${m.groupValues[2]}" + }, + Regex("""(val \w+:\s*kotlin\.collections\.Map<[^>]+>)(,|\n)""") to { m: MatchResult -> + "${m.groupValues[1]} = emptyMap()${m.groupValues[2]}" + }, ) for ((pattern, transform) in primitiveDefaults) { if (pattern.containsMatchIn(text)) { diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index ace89616df1..3ce7ec96cd4 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -121,6 +121,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/DeleteSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt new file mode 100644 index 00000000000..3db258f8cce --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/DeleteSessionAction.kt @@ -0,0 +1,47 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.history.HistoryDataKeys +import ai.kilocode.client.session.SessionManager +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages + +class DeleteSessionAction : AnAction() { + /** Overridable in tests to avoid showing a real modal dialog. */ + internal var confirm: (project: Project?, msg: String) -> Boolean = { project, msg -> + Messages.showYesNoDialog( + project, + msg, + KiloBundle.message("history.delete.confirm.title"), + Messages.getWarningIcon(), + ) == Messages.YES + } + + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selection = e.getData(HistoryDataKeys.SELECTION) + val manager = e.getData(SessionManager.KEY) + e.presentation.isEnabledAndVisible = manager != null && + selection != null && + selection.selectedLocal.isNotEmpty() + } + + override fun actionPerformed(e: AnActionEvent) { + val selection = e.getData(HistoryDataKeys.SELECTION) ?: return + val controller = e.getData(HistoryDataKeys.CONTROLLER) ?: return + val items = selection.selectedLocal.filter { !controller.deleting(it) } + if (items.isEmpty()) return + + val msg = if (items.size == 1) + KiloBundle.message("history.delete.confirm.message", ai.kilocode.client.session.history.title(items[0])) + else + KiloBundle.message("history.delete.confirm.message.multiple", items.size) + + if (!confirm(e.project, msg)) return + items.forEach { controller.delete(it) } + } +} 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/actions/OpenSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSessionAction.kt new file mode 100644 index 00000000000..c6ef6f3dc10 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/OpenSessionAction.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.session.history.HistoryDataKeys +import ai.kilocode.client.session.SessionManager +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent + +class OpenSessionAction : AnAction() { + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selection = e.getData(HistoryDataKeys.SELECTION) + val manager = e.getData(SessionManager.KEY) + e.presentation.isEnabledAndVisible = manager != null && + selection != null && + (selection.selectedLocal.size == 1 || selection.cloudItems.size == 1) + } + + override fun actionPerformed(e: AnActionEvent) { + val selection = e.getData(HistoryDataKeys.SELECTION) ?: return + val controller = e.getData(HistoryDataKeys.CONTROLLER) ?: return + selection.selectedLocal.singleOrNull()?.let { controller.open(it); return } + selection.cloudItems.singleOrNull()?.let { controller.open(it) } + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt new file mode 100644 index 00000000000..aa40c15fb41 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/actions/RenameSessionAction.kt @@ -0,0 +1,47 @@ +package ai.kilocode.client.actions + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.history.HistoryDataKeys +import ai.kilocode.client.session.history.title +import ai.kilocode.client.session.SessionManager +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages + +class RenameSessionAction : AnAction() { + /** Overridable in tests to avoid showing a real modal dialog. */ + internal var input: (project: Project?, current: String) -> String? = { project, current -> + Messages.showInputDialog( + project, + KiloBundle.message("history.rename.prompt"), + KiloBundle.message("history.rename.title"), + null, + current, + null, + ) + } + + override fun getActionUpdateThread() = ActionUpdateThread.EDT + + override fun update(e: AnActionEvent) { + val selection = e.getData(HistoryDataKeys.SELECTION) + val manager = e.getData(SessionManager.KEY) + e.presentation.isEnabledAndVisible = manager != null && + selection != null && + selection.selectedLocal.size == 1 + } + + override fun actionPerformed(e: AnActionEvent) { + val selection = e.getData(HistoryDataKeys.SELECTION) ?: return + val controller = e.getData(HistoryDataKeys.CONTROLLER) ?: return + val item = selection.selectedLocal.singleOrNull() ?: return + + val current = title(item) + val newTitle = input(e.project, current)?.trim() ?: return + + if (newTitle.isBlank() || newTitle == current) return + controller.rename(item, newTitle) + } +} 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..4f2fdcf05f9 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,30 @@ 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 renameSession(id: String, dir: String, newTitle: String): ai.kilocode.rpc.dto.SessionDto { + val session = call { rename(id, dir, newTitle) } + _sessions.value = _sessions.value.map { if (it.id == id) session else it } + return session + } + + 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..118c2e87d24 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, manager = this).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/GitRemoteUrl.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/GitRemoteUrl.kt new file mode 100644 index 00000000000..7093a5008b3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/GitRemoteUrl.kt @@ -0,0 +1,34 @@ +package ai.kilocode.client.session.history + +import java.io.File +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +/** + * Resolves the origin remote URL for a given directory by running + * `git remote get-url origin` as a subprocess. + * + * Returns null when the directory is not a git repo, has no origin remote, + * or the command fails for any reason. + * + * Overridable in tests via [resolve] parameter. + */ +internal fun resolveGitRemoteUrl(dir: String): String? = runCatching { + val proc = ProcessBuilder("git", "remote", "get-url", "origin") + .directory(File(dir)) + .redirectErrorStream(true) + .start() + + val text = CompletableFuture.supplyAsync { + proc.inputStream.bufferedReader().use { it.readText() } + } + val done = proc.waitFor(5, TimeUnit.SECONDS) + if (!done) { + proc.destroyForcibly() + return@runCatching null + } + + val out = text.get(1, TimeUnit.SECONDS).trim() + val code = proc.exitValue() + if (code == 0 && out.isNotEmpty()) out else null +}.getOrNull() 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..d4b6165e4d5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryController.kt @@ -0,0 +1,188 @@ +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.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +class HistoryController( + private val sessions: KiloSessionService, + private val workspace: Workspace, + private val cs: CoroutineScope, + open: (SessionRef) -> Unit = {}, + private val deleted: (String) -> Unit = {}, + private val gitUrlProvider: () -> String? = { resolveGitRemoteUrl(workspace.directory) }, +) { + companion object { + const val CLOUD_LIMIT = 50 + } + + val local = HistoryModel() + val cloud = CloudHistoryModel() + + /** Resolved once on first cloud load; null means no remote found. Written from IO, read on EDT. */ + @Volatile + var gitUrl: String? = null + private set + + @Volatile + private var resolved = false + private val lock = Mutex() + + /** Whether to filter cloud history by the current repository. */ + var repoOnly: Boolean = false + private set + + /** Notified on EDT when [repoOnly] changes (e.g. to update checkbox state). */ + var onRepoOnlyChanged: ((Boolean) -> Unit)? = null + + private fun updateRepoOnly(value: Boolean) { + if (repoOnly == value) return + repoOnly = value + edt { onRepoOnlyChanged?.invoke(value) } + } + + private val deleting = mutableSetOf() + private val opener = open + + fun reload() { + reloadLocal() + reloadCloud() + } + + 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() { + loadCloud(reset = true) + } + + fun loadMoreCloud() { + if (cloud.cursor == null || cloud.loading) return + loadCloud(reset = false) + } + + fun applyRepoOnly(value: Boolean) { + updateRepoOnly(value) + edt { reloadCloud() } + } + + fun delete(item: LocalHistoryItem) { + edt { + if (item.id in deleting) return@edt + deleting.add(item.id) + local.refresh() + val dir = item.directory ?: workspace.directory + cs.launch { + try { + sessions.deleteSession(item.id, dir) + 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 rename(item: LocalHistoryItem, title: String) { + val dir = item.directory ?: workspace.directory + cs.launch { + try { + val updated = sessions.renameSession(item.id, dir, title) + edt { local.update(LocalHistoryItem(updated)) } + } catch (e: Exception) { + edt { local.fail(e.message ?: KiloBundle.message("history.error.local.rename")) } + } + } + } + + 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 } + edt { cloud.start(reset) } + cs.launch { + val url = resolveUrlIfNeeded() + val filter = if (repoOnly) url else null + try { + val result = sessions.cloudSessions(workspace.directory, cursor, CLOUD_LIMIT, filter) + 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")) } + } + } + } + + /** + * Resolves [gitUrl] on first cloud load. Subsequent calls return the cached value. + * Also enables [repoOnly] by default when a URL is found the first time. + * + * Must be called from a coroutine. Writes [gitUrl] directly (volatile) and then + * propagates state updates to EDT via [edt]. + */ + private suspend fun resolveUrlIfNeeded(): String? { + if (resolved) return gitUrl + return lock.withLock { + if (resolved) return@withLock gitUrl + val url = withContext(Dispatchers.IO) { + gitUrlProvider() + } + // Write gitUrl directly (volatile) so it is visible before EDT callbacks fire. + gitUrl = url + resolved = true + if (url != null) updateRepoOnly(true) + url + } + } +} + +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/HistoryDataKeys.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryDataKeys.kt new file mode 100644 index 00000000000..3c560038abc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryDataKeys.kt @@ -0,0 +1,16 @@ +package ai.kilocode.client.session.history + +import com.intellij.openapi.actionSystem.DataKey + +data class HistorySelection( + val source: HistorySource, + val localItems: List, + val cloudItems: List, +) { + val selectedLocal: List get() = if (source == HistorySource.LOCAL) localItems else emptyList() +} + +object HistoryDataKeys { + val SELECTION: DataKey = DataKey.create("ai.kilocode.client.session.history.HistorySelection") + val CONTROLLER: DataKey = DataKey.create("ai.kilocode.client.session.history.HistoryController") +} 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..4aae232943a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListRenderer.kt @@ -0,0 +1,118 @@ +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.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_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_AREA_WIDTH) + return if (list.componentOrientation.isLeftToRight) { + val right = bounds.x + bounds.width + point.x in (right - width)..right + } else { + val left = bounds.x + 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 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) + if (deletable) 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 + if (deletable) del.icon = if (selected) icon else empty + + top.invalidate() + return this + } +} + +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..df01ec2b823 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryListUi.kt @@ -0,0 +1,14 @@ +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 itemAt(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 + 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..67b6c105e93 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryModel.kt @@ -0,0 +1,124 @@ +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 update(item: T) { + if (all.none { it.id == item.id }) return + all = HistoryTime.sorted(all.map { if (it.id == item.id) item else it }) + filter() + } + + fun fail(message: String) { + loading = false + loaded = true + error = message + refresh() + } + + fun setFilter(value: String) { + query = value.trim().lowercase() + filter() + } + + 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..b3ad33485d8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/history/HistoryPanel.kt @@ -0,0 +1,501 @@ +package ai.kilocode.client.session.history + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.SessionManager +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.openapi.application.ApplicationManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionGroup +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DataProvider +import com.intellij.openapi.actionSystem.Presentation +import com.intellij.openapi.actionSystem.impl.ActionButton +import com.intellij.openapi.util.Disposer +import com.intellij.ui.DocumentAdapter +import com.intellij.ui.PopupHandler +import com.intellij.ui.SearchTextField +import com.intellij.ui.ScrollingUtil +import com.intellij.ui.components.JBCheckBox +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 nav: () -> Unit = {}, + private val manager: SessionManager? = null, +) : BorderLayoutPanel(), Disposable, DataProvider { + 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 repoOnly = JBCheckBox(KiloBundle.message("history.cloud.repo.only"), true).apply { + isVisible = false + border = JBUI.Borders.emptyLeft(UiStyle.Gap.lg()) + addActionListener { controller.applyRepoOnly(isSelected) } + } + 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() + controller.onRepoOnlyChanged = { value -> + repoOnly.isSelected = value + } + 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() + } + + 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 { + val label = KiloBundle.message("history.back") + val action = object : AnAction(label, null, AllIcons.Actions.Back) { + override fun actionPerformed(e: AnActionEvent) = nav() + } + val presentation = Presentation(label).apply { icon = AllIcons.Actions.Back } + val btn = ActionButton(action, presentation, ActionPlaces.TOOLBAR, JBUI.size(16)).apply { + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + } + return BorderLayoutPanel().apply { + add(btn, BorderLayout.WEST) + border = JBUI.Borders.emptyRight(UiStyle.Gap.lg()) + } + } + + private fun panel(search: SearchTextField, list: JList, footer: JComponent? = null): JComponent { + return BorderLayoutPanel().apply { + val north = BorderLayoutPanel().apply { + add(search, BorderLayout.CENTER) + if (list === cloudList) { + add(repoOnly, BorderLayout.SOUTH) + } + } + add(north, 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.MULTIPLE_INTERVAL_SELECTION + isFocusable = true + cellRenderer = LocalHistoryRenderer(controller.local) + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + emptyText.text = KiloBundle.message("history.empty") + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + val row = locationToIndex(e.point) + val box = row.takeIf { it >= 0 }?.let { getCellBounds(it, it) } ?: return + if (!box.contains(e.point)) return + if (e.clickCount == 1 && HistoryRenderer.isDeleteClick(this@apply, box, e.point)) { + val item = model.getElementAt(row) + confirm(item) + } else if (e.clickCount == 2) { + selectedValue?.let(::activate) + } + } + }) + registerKeyboardAction( + { selectedValue?.let(::activate) }, + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), + JComponent.WHEN_FOCUSED, + ) + installContextMenu(this) + ScrollingUtil.installActions(this) + } + + private fun cloudList() = JBList(controller.cloud).apply { + selectionMode = ListSelectionModel.SINGLE_SELECTION + isFocusable = true + cellRenderer = CloudHistoryRenderer(controller.cloud) + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + emptyText.text = KiloBundle.message("history.empty") + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + if (e.clickCount == 2) selectedValue?.let(::activate) + } + }) + registerKeyboardAction( + { selectedValue?.let(::activate) }, + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), + JComponent.WHEN_FOCUSED, + ) + installContextMenu(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 + repoOnly.isVisible = controller.gitUrl != null + 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 activate(item: HistoryItem) { + when (item) { + is LocalHistoryItem -> controller.open(item) + is CloudHistoryItem -> controller.open(item) + } + } + + override fun getData(dataId: String): Any? { + if (SessionManager.KEY.`is`(dataId)) return manager + if (HistoryDataKeys.CONTROLLER.`is`(dataId)) return controller + if (HistoryDataKeys.SELECTION.`is`(dataId)) { + val source = selectedSource() + val local = if (source == HistorySource.LOCAL) localList.selectedValuesList.filterIsInstance() else emptyList() + val cloud = if (source == HistorySource.CLOUD) cloudList.selectedValuesList.filterIsInstance() else emptyList() + return HistorySelection(source, local, cloud) + } + return null + } + + private fun installContextMenu(list: JBList) { + val group = ActionManager.getInstance().getAction("Kilo.History.ContextMenu") + if (group is ActionGroup) { + PopupHandler.installPopupMenu(list, group, ActionPlaces.POPUP) + } + } + + private fun confirm(item: LocalHistoryItem) { + if (controller.deleting(item)) return + val result = com.intellij.openapi.ui.Messages.showYesNoDialog( + this, + KiloBundle.message("history.delete.confirm.message", title(item)), + KiloBundle.message("history.delete.confirm.title"), + com.intellij.openapi.ui.Messages.getWarningIcon(), + ) + if (result != com.intellij.openapi.ui.Messages.YES) return + controller.delete(item) + } + + internal fun confirmDelete(items: List) { + val active = items.filter { !controller.deleting(it) } + if (active.isEmpty()) return + val msg = if (active.size == 1) + KiloBundle.message("history.delete.confirm.message", title(active[0])) + else + KiloBundle.message("history.delete.confirm.message.multiple", active.size) + val result = com.intellij.openapi.ui.Messages.showYesNoDialog( + this, + msg, + KiloBundle.message("history.delete.confirm.title"), + com.intellij.openapi.ui.Messages.getWarningIcon(), + ) + if (result != com.intellij.openapi.ui.Messages.YES) return + active.forEach { controller.delete(it) } + } + + 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 selectIndices(vararg indices: Int) { + activeList().selectedIndices = indices + } + + internal fun selectedIndex() = activeList().selectedIndex + + internal fun listFocusable() = activeList().isFocusable + + internal fun listSelectionMode() = activeList().selectionMode + + internal fun loadMoreFocusable() = more.isFocusable + + internal fun listCursor() = activeList().cursor.type + + internal fun backText(): String? { + val view = activeInfo().foreSideComponent ?: return null + return UIUtil.uiTraverser(view).filter(ActionButton::class.java).firstOrNull()?.presentation?.text + } + + internal fun backCursor(): Int? { + val view = activeInfo().foreSideComponent ?: return null + return UIUtil.uiTraverser(view).filter(ActionButton::class.java).firstOrNull()?.cursor?.type + } + + internal fun clickBack() { + val view = activeInfo().foreSideComponent ?: return + UIUtil.uiTraverser(view).filter(ActionButton::class.java).firstOrNull()?.click() + } + + internal fun clickDelete() { + val items = localList.selectedValuesList.filterIsInstance() + items.forEach { controller.delete(it) } + } + + 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 repoOnlyVisible() = repoOnly.isVisible + + internal fun repoOnlySelected() = repoOnly.isSelected + + internal fun clickRepoOnly() { + repoOnly.doClick() + } + + 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() { + controller.onRepoOnlyChanged = null + } + + private class LoadMoreButton : JButton(KiloBundle.message("history.cloud.load.more")) { + private var over = false + + init { + isFocusable = true + 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..a498e09365d --- /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.isBefore(today.minusDays(7)) && date.isBefore(today)) return HistorySection.WEEK + if (!date.isBefore(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..3d079596616 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.itemAt +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 = itemAt(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..615f52df59e 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(Box.createHorizontalStrut(UiStyle.Gap.sm())) 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..dedf3ee3045 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"/> + + + + + + + + + + + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index d9376c4e2ee..4277f72e785 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -8,30 +8,27 @@ session.connection.warning.config=Configuration warnings session.empty.welcome=Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase. session.empty.loading=Loading... session.empty.recent=RECENT -session.empty.time.moments=Moments ago -session.empty.time.minutes={0} min ago -session.empty.time.hours={0}h ago -session.empty.time.days={0}d ago +session.showHistory=Show History session.scroll.bottom=Scroll to bottom session.tab.new=New Session session.tab.untitled=Untitled Session session.permission.title=Permission request -session.permission.meta=Tool: {0} \u2022 Patterns: {1} +session.permission.meta=Tool: {0} • Patterns: {1} session.permission.allow=Allow session.permission.deny=Deny session.question.dismiss=Dismiss -session.status.considering=Considering next steps\u2026 -session.status.thinking=Thinking\u2026 -session.status.writing=Writing response\u2026 -session.status.delegating=Delegating work\u2026 -session.status.planning=Planning\u2026 -session.status.gathering=Gathering context\u2026 -session.status.searching.codebase=Searching codebase\u2026 -session.status.searching.web=Searching web\u2026 -session.status.editing=Making edits\u2026 -session.status.commands=Running commands\u2026 +session.status.considering=Considering next steps… +session.status.thinking=Thinking… +session.status.writing=Writing response… +session.status.delegating=Delegating work… +session.status.planning=Planning… +session.status.gathering=Gathering context… +session.status.searching.codebase=Searching codebase… +session.status.searching.web=Searching web… +session.status.editing=Making edits… +session.status.commands=Running commands… session.part.reasoning=Reasoning session.part.compaction=context compacted @@ -85,10 +82,44 @@ model.picker.free=Free model.picker.reset=Reset model to default reasoning.picker.tooltip=Select reasoning effort +history.tab.local=Local +history.tab.cloud=Cloud +history.back=Back +history.search.placeholder=Search sessions +history.empty=No sessions +history.loading=Loading... +history.untitled=Untitled +history.delete.text=Delete +history.delete.confirm.title=Delete session? +history.delete.confirm.message=Delete "{0}" from local history? +history.delete.confirm.message.multiple=Delete {0} sessions from local history? +history.rename.title=Rename Session +history.rename.prompt=New session name: +history.cloud.load.more=Load more +history.cloud.repo.only=Only this repository +history.group.today=Today +history.group.yesterday=Yesterday +history.group.week=This Week +history.group.month=This Month +history.group.older=Older +history.time.moments=Moments ago +history.time.minutes={0} min ago +history.time.hours={0}h ago +history.time.days={0}d ago +history.time.months={0}mo ago +history.time.years={0}y ago +history.error.local=Failed to load local history +history.error.cloud=Failed to load cloud history +history.error.local.delete=Failed to delete session +history.error.local.rename=Failed to rename session +history.error.cloud.delete=Cloud sessions cannot be deleted yet + action.Kilo.Settings.text=Settings action.Kilo.Settings.description=Kilo Code settings action.Kilo.NewSession.text=New Session action.Kilo.NewSession.description=Start a new Kilo session +action.Kilo.History.text=History +action.Kilo.History.description=Show session history action.Kilo.SendPrompt.text=Send Prompt action.Kilo.SendPrompt.description=Send the current Kilo prompt action.Kilo.StopSession.text=Stop Session @@ -99,3 +130,10 @@ action.Kilo.Restart.text=Restart Kilo action.Kilo.Restart.description=Kill and restart the CLI process action.Kilo.Reinstall.text=Reinstall Kilo action.Kilo.Reinstall.description=Re-extract the CLI binary and restart +action.Kilo.Session.Open.text=Open +action.Kilo.Session.Open.description=Open the selected session +action.Kilo.Session.Rename.text=Rename +action.Kilo.Session.Rename.description=Rename the selected session +action.Kilo.Session.Delete.text=Delete +action.Kilo.Session.Delete.description=Delete the selected session(s) +action.Kilo.History.ContextMenu.text=History Actions diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties new file mode 100644 index 00000000000..9cb9ee73187 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -0,0 +1,139 @@ +session.connection.connecting=جاري التحميل… +session.connection.error.app=فشل الاتصال +session.connection.error.workspace=فشل تحميل مساحة العمل +session.connection.error.unknown=خطأ غير معروف +session.connection.retry=إعادة المحاولة +session.connection.warning.config=تحذيرات التكوين + +session.empty.welcome=Kilo Code هو مساعد برمجة بالذكاء الاصطناعي. اطلب منه بناء ميزات أو إصلاح أخطاء أو شرح قاعدة الكود. +session.empty.loading=جاري التحميل… +session.empty.recent=الحديثة +session.showHistory=عرض السجل +session.scroll.bottom=التمرير إلى الأسفل +session.tab.new=جلسة جديدة +session.tab.untitled=جلسة بدون عنوان + +session.permission.title=طلب إذن +session.permission.meta=الأداة: {0} • الأنماط: {1} +session.permission.allow=سماح +session.permission.deny=رفض +session.question.dismiss=رفض + +session.status.considering=جار التفكير في الخطوات التالية… +session.status.thinking=جار التفكير… +session.status.writing=جاري كتابة الرد… +session.status.delegating=جار تفويض العمل… +session.status.planning=جار التخطيط… +session.status.gathering=جار جمع السياق… +session.status.searching.codebase=جار البحث في قاعدة الكود… +session.status.searching.web=جار البحث على الويب… +session.status.editing=جاري التحرير… +session.status.commands=جاري تنفيذ الأوامر… + +session.part.reasoning=الاستدلال +session.part.compaction=السياق مضغوط +session.part.tool.copy=نسخ +session.part.tool.error=خطأ +session.part.tool.pending=معلق +session.part.tool.read=قراءة +session.part.tool.running=قيد التشغيل +session.part.tool.shell=Shell +session.part.tool.truncated=المخرجات مختصرة في المعاينة المسبقة. المخرجات الكاملة لا تزال في بيانات الجلسة. + +session.error.prompt=فشل الطلب +session.error.compact=فشل ضغط الجلسة +session.error.unknown=خطأ غير معروف + +session.header.tokens=الرموز +session.header.tokens.description=الرموز المستخدمة في آخر رد للمساعد: الإدخال، الإخراج، كتابات المخزن المؤقت وقراءاته. +session.header.input=إدخال {0} +session.header.output=إخراج {0} +session.header.cache.write=كتابة مخزن مؤقت {0} +session.header.cache.read=قراءة مخزن مؤقت {0} +session.header.compact=ضغط الجلسة +session.header.compact.description=تلخيص الجلسة لتقليل حجم السياق +session.header.expand=عرض مقاييس الجلسة +session.header.collapse=إخفاء مقاييس الجلسة +session.header.todos.progress={0}/{1} مهام مكتملة +session.header.todos.done=جميع {0} مهام مكتملة +session.header.context.tooltip.percent={0} رمز ({1}٪ من السياق) +session.header.context.tooltip.tokens={0} رمز +session.header.context.used={0} / {1} رمز مستخدم +session.header.context.reserved={0} محجوز للإخراج +session.header.context.available={0} متاح + +prompt.placeholder=اكتب رسالة… +prompt.placeholder.with.shortcuts=اكتب رسالة… ({0} للإرسال، {1} لسطر جديد) +prompt.placeholder.with.send=اكتب رسالة… ({0} للإرسال) +prompt.placeholder.with.newline=اكتب رسالة… ({0} لسطر جديد) +prompt.button.send=إرسال +prompt.button.stop=إيقاف +prompt.button.send.tooltip.stop=للإيقاف، اضغط {0} +mode.picker.tooltip=اختيار الوضع +mode.picker.deprecated=مهجور +model.picker.tooltip=اختيار النموذج +model.picker.recommended=موصى به +model.picker.favorites=المفضلة +model.picker.search=بحث عن نماذج +model.picker.no.matches=لا توجد نماذج مطابقة +model.picker.favorite.add=إضافة إلى المفضلة +model.picker.favorite.remove=إزالة من المفضلة +model.picker.free=مجاني +model.picker.reset=إعادة ضبط النموذج للافتراضي +reasoning.picker.tooltip=اختيار مستوى الاستدلال + +history.tab.local=محلي +history.tab.cloud=سحابي +history.back=رجوع +history.search.placeholder=بحث في الجلسات +history.empty=لا توجد جلسات +history.loading=جاري التحميل… +history.untitled=بدون عنوان +history.delete.text=حذف +history.delete.confirm.title=حذف الجلسة؟ +history.delete.confirm.message=حذف "{0}" من السجل المحلي؟ +history.delete.confirm.message.multiple=حذف {0} جلسات من السجل المحلي؟ +history.rename.title=إعادة تسمية الجلسة +history.rename.prompt=اسم الجلسة الجديد: +history.cloud.load.more=تحميل المزيد +history.cloud.repo.only=هذا المستودع فقط +history.group.today=اليوم +history.group.yesterday=أمس +history.group.week=هذا الأسبوع +history.group.month=هذا الشهر +history.group.older=أقدم +history.time.moments=للتو +history.time.minutes=منذ {0} دقيقة +history.time.hours=منذ {0} ساعة +history.time.days=منذ {0} يوم +history.time.months=منذ {0} شهر +history.time.years=منذ {0} سنة +history.error.local=فشل تحميل السجل المحلي +history.error.cloud=فشل تحميل سجل السحابة +history.error.local.delete=فشل حذف الجلسة +history.error.local.rename=فشل إعادة تسمية الجلسة +history.error.cloud.delete=لا يمكن حذف جلسات السحابة بعد + +action.Kilo.Settings.text=الإعدادات +action.Kilo.Settings.description=إعدادات Kilo Code +action.Kilo.NewSession.text=جلسة جديدة +action.Kilo.NewSession.description=بدء جلسة Kilo جديدة +action.Kilo.History.text=السجل +action.Kilo.History.description=عرض سجل الجلسات +action.Kilo.SendPrompt.text=إرسال الطلب +action.Kilo.SendPrompt.description=إرسال طلب Kilo الحالي +action.Kilo.StopSession.text=إيقاف الجلسة +action.Kilo.StopSession.description=إيقاف جلسة Kilo الحالية +action.Kilo.SettingsGroup.text=الإعدادات +action.Kilo.SettingsGroup.description=إعدادات Kilo Code +action.Kilo.Restart.text=إعادة تشغيل Kilo +action.Kilo.Restart.description=إنهاء عملية CLI وإعادة تشغيلها +action.Kilo.Reinstall.text=إعادة تثبيت Kilo +action.Kilo.Reinstall.description=استخراج CLI مجدداً وإعادة التشغيل +action.Kilo.Session.Open.text=فتح +action.Kilo.Session.Open.description=فتح الجلسة المحددة +action.Kilo.Session.Rename.text=إعادة تسمية +action.Kilo.Session.Rename.description=إعادة تسمية الجلسة المحددة +action.Kilo.Session.Delete.text=حذف +action.Kilo.Session.Delete.description=حذف الجلسة (الجلسات) المحددة +action.Kilo.History.ContextMenu.text=إجراءات السجل diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties new file mode 100644 index 00000000000..a310da70e8e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -0,0 +1,139 @@ +session.connection.connecting=Učitavanje… +session.connection.error.app=Greška pri spajanju +session.connection.error.workspace=Greška pri učitavanju radnog prostora +session.connection.error.unknown=Nepoznata greška +session.connection.retry=Pokušaj ponovo +session.connection.warning.config=Upozorenja konfiguracije + +session.empty.welcome=Kilo Code je AI asistent za kodiranje. Zatražite od njega da gradi funkcije, ispravlja greške ili objašnjava vašu bazu koda. +session.empty.loading=Učitavanje… +session.empty.recent=NEDAVNO +session.showHistory=Prikaži historiju +session.scroll.bottom=Skrolaj na dno +session.tab.new=Nova sesija +session.tab.untitled=Sesija bez naslova + +session.permission.title=Zahtjev za dozvolu +session.permission.meta=Alat: {0} • Uzorci: {1} +session.permission.allow=Dozvoli +session.permission.deny=Odbij +session.question.dismiss=Zatvori + +session.status.considering=Razmatranje sljedećih koraka… +session.status.thinking=Razmišljanje… +session.status.writing=Pisanje odgovora… +session.status.delegating=Delegiranje posla… +session.status.planning=Planiranje… +session.status.gathering=Prikupljanje konteksta… +session.status.searching.codebase=Pretraživanje baze koda… +session.status.searching.web=Pretraživanje weba… +session.status.editing=Uređivanje… +session.status.commands=Pokretanje komandi… + +session.part.reasoning=Rezonovanje +session.part.compaction=kontekst komprimovan +session.part.tool.copy=Kopiraj +session.part.tool.error=Greška +session.part.tool.pending=Na čekanju +session.part.tool.read=Čita +session.part.tool.running=Pokrenuto +session.part.tool.shell=Shell +session.part.tool.truncated=Izlaz skraćen u pregledu. Potpuni izlaz ostaje u podacima sesije. + +session.error.prompt=Greška upita +session.error.compact=Komprimiranje sesije neuspješno +session.error.unknown=Nepoznata greška + +session.header.tokens=Tokeni +session.header.tokens.description=Tokeni korišteni u posljednjem odgovoru asistenta: ulaz, izlaz, pisanja u keš i čitanja iz keša. +session.header.input=ulaz {0} +session.header.output=izlaz {0} +session.header.cache.write=pisanje u keš {0} +session.header.cache.read=čitanje iz keša {0} +session.header.compact=Komprimiraj sesiju +session.header.compact.description=Sažmite sesiju kako biste smanjili veličinu konteksta +session.header.expand=Prikaži metrike sesije +session.header.collapse=Sakrij metrike sesije +session.header.todos.progress={0}/{1} zadataka završeno +session.header.todos.done=Svih {0} zadataka završeno +session.header.context.tooltip.percent={0} tokena ({1}% konteksta) +session.header.context.tooltip.tokens={0} tokena +session.header.context.used={0} / {1} tokena korišteno +session.header.context.reserved={0} rezervisano za izlaz +session.header.context.available={0} dostupno + +prompt.placeholder=Upišite poruku… +prompt.placeholder.with.shortcuts=Upišite poruku… ({0} za slanje, {1} za novi red) +prompt.placeholder.with.send=Upišite poruku… ({0} za slanje) +prompt.placeholder.with.newline=Upišite poruku… ({0} za novi red) +prompt.button.send=Pošalji +prompt.button.stop=Zaustavi +prompt.button.send.tooltip.stop=Da zaustavite, pritisnite {0} +mode.picker.tooltip=Odaberi način rada +mode.picker.deprecated=zastarjelo +model.picker.tooltip=Odaberi model +model.picker.recommended=Preporučeno +model.picker.favorites=Omiljeni +model.picker.search=Pretraži modele +model.picker.no.matches=Nema odgovarajućih modela +model.picker.favorite.add=Dodaj u omiljene +model.picker.favorite.remove=Ukloni iz omiljenih +model.picker.free=Besplatno +model.picker.reset=Vrati model na zadano +reasoning.picker.tooltip=Odaberi razinu rezoniranja + +history.tab.local=Lokalno +history.tab.cloud=Oblak +history.back=Nazad +history.search.placeholder=Pretraži sesije +history.empty=Nema sesija +history.loading=Učitavanje… +history.untitled=Bez naslova +history.delete.text=Obriši +history.delete.confirm.title=Obrisati sesiju? +history.delete.confirm.message=Obrisati "{0}" iz lokalne historije? +history.delete.confirm.message.multiple=Obrisati {0} sesija iz lokalne historije? +history.rename.title=Preimenuj sesiju +history.rename.prompt=Novi naziv sesije: +history.cloud.load.more=Učitaj više +history.cloud.repo.only=Samo ovaj repozitorij +history.group.today=Danas +history.group.yesterday=Jučer +history.group.week=Ove sedmice +history.group.month=Ovog mjeseca +history.group.older=Starije +history.time.moments=Upravo sada +history.time.minutes=Prije {0} min +history.time.hours=Prije {0} h +history.time.days=Prije {0} d +history.time.months=Prije {0} mj +history.time.years=Prije {0} g +history.error.local=Učitavanje lokalne historije neuspješno +history.error.cloud=Učitavanje historije iz oblaka neuspješno +history.error.local.delete=Brisanje sesije neuspješno +history.error.local.rename=Preimenovanje sesije neuspješno +history.error.cloud.delete=Sesije iz oblaka još uvijek nije moguće brisati + +action.Kilo.Settings.text=Postavke +action.Kilo.Settings.description=Postavke Kilo Code +action.Kilo.NewSession.text=Nova sesija +action.Kilo.NewSession.description=Pokrenite novu Kilo sesiju +action.Kilo.History.text=Historija +action.Kilo.History.description=Prikaži historiju sesija +action.Kilo.SendPrompt.text=Pošalji upit +action.Kilo.SendPrompt.description=Pošalji trenutni Kilo upit +action.Kilo.StopSession.text=Zaustavi sesiju +action.Kilo.StopSession.description=Zaustavi trenutnu Kilo sesiju +action.Kilo.SettingsGroup.text=Postavke +action.Kilo.SettingsGroup.description=Postavke Kilo Code +action.Kilo.Restart.text=Ponovo pokrenite Kilo +action.Kilo.Restart.description=Ugasite i ponovo pokrenite CLI proces +action.Kilo.Reinstall.text=Ponovo instalirajte Kilo +action.Kilo.Reinstall.description=Ponovo raspakirajte CLI binarnu datoteku i ponovo pokrenite +action.Kilo.Session.Open.text=Otvori +action.Kilo.Session.Open.description=Otvorite odabranu sesiju +action.Kilo.Session.Rename.text=Preimenuj +action.Kilo.Session.Rename.description=Preimenujte odabranu sesiju +action.Kilo.Session.Delete.text=Obriši +action.Kilo.Session.Delete.description=Obrišite odabrane sesije +action.Kilo.History.ContextMenu.text=Akcije historije diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties new file mode 100644 index 00000000000..706e2faf0b6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -0,0 +1,139 @@ +session.connection.connecting=Indlæser… +session.connection.error.app=Forbindelsesfejl +session.connection.error.workspace=Fejl ved indlæsning af arbejdsområde +session.connection.error.unknown=Ukendt fejl +session.connection.retry=Prøv igen +session.connection.warning.config=Konfigurationsadvarsler + +session.empty.welcome=Kilo Code er en AI-kodningsassistent. Bed den om at bygge funktioner, rette fejl eller forklare din kodebase. +session.empty.loading=Indlæser… +session.empty.recent=SENESTE +session.showHistory=Vis historik +session.scroll.bottom=Rul til bunden +session.tab.new=Ny session +session.tab.untitled=Unavngivet session + +session.permission.title=Tilladelsesanmodning +session.permission.meta=Værktøj: {0} • Mønstre: {1} +session.permission.allow=Tillad +session.permission.deny=Afvis +session.question.dismiss=Luk + +session.status.considering=Overvejer næste skridt… +session.status.thinking=Tænker… +session.status.writing=Skriver svar… +session.status.delegating=Delegerer arbejde… +session.status.planning=Planlagger… +session.status.gathering=Indsamler kontekst… +session.status.searching.codebase=Søger i kodebasen… +session.status.searching.web=Søger på nettet… +session.status.editing=Foretager redigeringer… +session.status.commands=Kører kommandoer… + +session.part.reasoning=Ræsonnering +session.part.compaction=kontekst komprimeret +session.part.tool.copy=Kopiér +session.part.tool.error=Fejl +session.part.tool.pending=Afventer +session.part.tool.read=Læs +session.part.tool.running=Kører +session.part.tool.shell=Shell +session.part.tool.truncated=Output afkortet i forhåndsvisning. Fuldt output forbliver i sessionsdata. + +session.error.prompt=Prompt mislykkedes +session.error.compact=Sessionskomprimering mislykkedes +session.error.unknown=Ukendt fejl + +session.header.tokens=Tokens +session.header.tokens.description=Tokens brugt af det seneste assistentsvar: input, output, cache-skrivninger og cache-læsninger. +session.header.input=ind {0} +session.header.output=ud {0} +session.header.cache.write=cache-skrivning {0} +session.header.cache.read=cache-læsning {0} +session.header.compact=Komprimer session +session.header.compact.description=Opsummer sessionen for at reducere kontekstsstørrelsen +session.header.expand=Vis sessionsmålinger +session.header.collapse=Skjul sessionsmålinger +session.header.todos.progress={0}/{1} opgaver færdige +session.header.todos.done=Alle {0} opgaver færdige +session.header.context.tooltip.percent={0} tokens ({1}% af konteksten) +session.header.context.tooltip.tokens={0} tokens +session.header.context.used={0} / {1} tokens brugt +session.header.context.reserved={0} reserveret til output +session.header.context.available={0} tilgængelige + +prompt.placeholder=Skriv en besked… +prompt.placeholder.with.shortcuts=Skriv en besked… ({0} for at sende, {1} for ny linje) +prompt.placeholder.with.send=Skriv en besked… ({0} for at sende) +prompt.placeholder.with.newline=Skriv en besked… ({0} for ny linje) +prompt.button.send=Send +prompt.button.stop=Stop +prompt.button.send.tooltip.stop=Tryk på {0} for at stoppe +mode.picker.tooltip=Vælg tilstand +mode.picker.deprecated=forældet +model.picker.tooltip=Vælg model +model.picker.recommended=Anbefalet +model.picker.favorites=Favoritter +model.picker.search=Søg efter modeller +model.picker.no.matches=Ingen matchende modeller +model.picker.favorite.add=Føj til favoritter +model.picker.favorite.remove=Fjern fra favoritter +model.picker.free=Gratis +model.picker.reset=Nulstil model til standard +reasoning.picker.tooltip=Vælg ræsonneringsindsats + +history.tab.local=Lokal +history.tab.cloud=Sky +history.back=Tilbage +history.search.placeholder=Søg i sessioner +history.empty=Ingen sessioner +history.loading=Indlæser… +history.untitled=Unavngivet +history.delete.text=Slet +history.delete.confirm.title=Slet session? +history.delete.confirm.message=Slet "{0}" fra lokal historik? +history.delete.confirm.message.multiple=Slet {0} sessioner fra lokal historik? +history.rename.title=Omdøb session +history.rename.prompt=Nyt sessionsnavn: +history.cloud.load.more=Indlæs mere +history.cloud.repo.only=Kun dette arkiv +history.group.today=I dag +history.group.yesterday=I går +history.group.week=Denne uge +history.group.month=Denne måned +history.group.older=Ældre +history.time.moments=Lige nu +history.time.minutes={0} min siden +history.time.hours={0} t siden +history.time.days={0} d siden +history.time.months={0} mdr siden +history.time.years={0} år siden +history.error.local=Kunne ikke indlæse lokal historik +history.error.cloud=Kunne ikke indlæse skyhistorik +history.error.local.delete=Kunne ikke slette session +history.error.local.rename=Kunne ikke omdøbe session +history.error.cloud.delete=Skysessioner kan endnu ikke slettes + +action.Kilo.Settings.text=Indstillinger +action.Kilo.Settings.description=Kilo Code-indstillinger +action.Kilo.NewSession.text=Ny session +action.Kilo.NewSession.description=Start en ny Kilo-session +action.Kilo.History.text=Historik +action.Kilo.History.description=Vis sessionshistorik +action.Kilo.SendPrompt.text=Send prompt +action.Kilo.SendPrompt.description=Send den aktuelle Kilo-prompt +action.Kilo.StopSession.text=Stop session +action.Kilo.StopSession.description=Stop den aktuelle Kilo-session +action.Kilo.SettingsGroup.text=Indstillinger +action.Kilo.SettingsGroup.description=Kilo Code-indstillinger +action.Kilo.Restart.text=Genstart Kilo +action.Kilo.Restart.description=Afslut og genstart CLI-processen +action.Kilo.Reinstall.text=Geninstaller Kilo +action.Kilo.Reinstall.description=Udpak CLI-binærfilen igen og genstart +action.Kilo.Session.Open.text=Åbn +action.Kilo.Session.Open.description=Åbn den valgte session +action.Kilo.Session.Rename.text=Omdøb +action.Kilo.Session.Rename.description=Omdøb den valgte session +action.Kilo.Session.Delete.text=Slet +action.Kilo.Session.Delete.description=Slet den/de valgte session(er) +action.Kilo.History.ContextMenu.text=Historikhandlinger diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties new file mode 100644 index 00000000000..f604c18c850 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -0,0 +1,139 @@ +session.connection.connecting=Wird geladen… +session.connection.error.app=Verbindung fehlgeschlagen +session.connection.error.workspace=Workspace-Laden fehlgeschlagen +session.connection.error.unknown=Unbekannter Fehler +session.connection.retry=Erneut versuchen +session.connection.warning.config=Konfigurationswarnungen + +session.empty.welcome=Kilo Code ist ein KI-Coding-Assistent. Bitten Sie ihn, Funktionen zu erstellen, Fehler zu beheben oder Ihre Codebasis zu erklären. +session.empty.loading=Wird geladen… +session.empty.recent=ZULETZT +session.showHistory=Verlauf anzeigen +session.scroll.bottom=Zum Ende scrollen +session.tab.new=Neue Sitzung +session.tab.untitled=Unbenannte Sitzung + +session.permission.title=Berechtigungsanfrage +session.permission.meta=Werkzeug: {0} • Muster: {1} +session.permission.allow=Erlauben +session.permission.deny=Ablehnen +session.question.dismiss=Schließen + +session.status.considering=Nächste Schritte abwägen… +session.status.thinking=Nachdenken… +session.status.writing=Antwort schreiben… +session.status.delegating=Arbeit delegieren… +session.status.planning=Planen… +session.status.gathering=Kontext sammeln… +session.status.searching.codebase=Codebasis durchsuchen… +session.status.searching.web=Web durchsuchen… +session.status.editing=Änderungen vornehmen… +session.status.commands=Befehle ausführen… + +session.part.reasoning=Denken +session.part.compaction=Kontext komprimiert +session.part.tool.copy=Kopieren +session.part.tool.error=Fehler +session.part.tool.pending=Ausstehend +session.part.tool.read=Lesen +session.part.tool.running=Läuft +session.part.tool.shell=Shell +session.part.tool.truncated=Ausgabe in der Vorschau gekürzt. Vollständige Ausgabe verbleibt in den Sitzungsdaten. + +session.error.prompt=Anfrage fehlgeschlagen +session.error.compact=Sitzungskomprimierung fehlgeschlagen +session.error.unknown=Unbekannter Fehler + +session.header.tokens=Token +session.header.tokens.description=Von der letzten Assistentenantwort verwendete Token: Eingabe, Ausgabe, Cache-Schreibvorgänge und Cache-Lesevorgänge. +session.header.input=ein {0} +session.header.output=aus {0} +session.header.cache.write=Cache-Schreiben {0} +session.header.cache.read=Cache-Lesen {0} +session.header.compact=Sitzung komprimieren +session.header.compact.description=Sitzung zusammenfassen, um die Kontextgröße zu reduzieren +session.header.expand=Sitzungsmetriken anzeigen +session.header.collapse=Sitzungsmetriken ausblenden +session.header.todos.progress={0}/{1} Aufgaben abgeschlossen +session.header.todos.done=Alle {0} Aufgaben abgeschlossen +session.header.context.tooltip.percent={0} Token ({1}% des Kontexts) +session.header.context.tooltip.tokens={0} Token +session.header.context.used={0} / {1} Token verwendet +session.header.context.reserved={0} für Ausgabe reserviert +session.header.context.available={0} verfügbar + +prompt.placeholder=Nachricht eingeben… +prompt.placeholder.with.shortcuts=Nachricht eingeben… ({0} zum Senden, {1} für neue Zeile) +prompt.placeholder.with.send=Nachricht eingeben… ({0} zum Senden) +prompt.placeholder.with.newline=Nachricht eingeben… ({0} für neue Zeile) +prompt.button.send=Senden +prompt.button.stop=Stopp +prompt.button.send.tooltip.stop=Zum Stoppen drücken Sie {0} +mode.picker.tooltip=Modus auswählen +mode.picker.deprecated=veraltet +model.picker.tooltip=Modell auswählen +model.picker.recommended=Empfohlen +model.picker.favorites=Favoriten +model.picker.search=Modelle suchen +model.picker.no.matches=Keine passenden Modelle +model.picker.favorite.add=Zu Favoriten hinzufügen +model.picker.favorite.remove=Aus Favoriten entfernen +model.picker.free=Kostenlos +model.picker.reset=Modell auf Standard zurücksetzen +reasoning.picker.tooltip=Denkanstrengung auswählen + +history.tab.local=Lokal +history.tab.cloud=Cloud +history.back=Zurück +history.search.placeholder=Sitzungen suchen +history.empty=Keine Sitzungen +history.loading=Wird geladen… +history.untitled=Unbenannt +history.delete.text=Löschen +history.delete.confirm.title=Sitzung löschen? +history.delete.confirm.message="{0}" aus dem lokalen Verlauf löschen? +history.delete.confirm.message.multiple={0} Sitzungen aus dem lokalen Verlauf löschen? +history.rename.title=Sitzung umbenennen +history.rename.prompt=Neuer Sitzungsname: +history.cloud.load.more=Mehr laden +history.cloud.repo.only=Nur dieses Repository +history.group.today=Heute +history.group.yesterday=Gestern +history.group.week=Diese Woche +history.group.month=Dieser Monat +history.group.older=Älter +history.time.moments=Gerade eben +history.time.minutes={0} Min. zuvor +history.time.hours={0} Std. zuvor +history.time.days={0} Tage zuvor +history.time.months={0} Mon. zuvor +history.time.years={0} J. zuvor +history.error.local=Lokaler Verlauf konnte nicht geladen werden +history.error.cloud=Cloud-Verlauf konnte nicht geladen werden +history.error.local.delete=Sitzung konnte nicht gelöscht werden +history.error.local.rename=Sitzung konnte nicht umbenannt werden +history.error.cloud.delete=Cloud-Sitzungen können noch nicht gelöscht werden + +action.Kilo.Settings.text=Einstellungen +action.Kilo.Settings.description=Kilo Code Einstellungen +action.Kilo.NewSession.text=Neue Sitzung +action.Kilo.NewSession.description=Neue Kilo-Sitzung starten +action.Kilo.History.text=Verlauf +action.Kilo.History.description=Sitzungsverlauf anzeigen +action.Kilo.SendPrompt.text=Anfrage senden +action.Kilo.SendPrompt.description=Aktuelle Kilo-Anfrage senden +action.Kilo.StopSession.text=Sitzung stoppen +action.Kilo.StopSession.description=Aktuelle Kilo-Sitzung stoppen +action.Kilo.SettingsGroup.text=Einstellungen +action.Kilo.SettingsGroup.description=Kilo Code Einstellungen +action.Kilo.Restart.text=Kilo neu starten +action.Kilo.Restart.description=CLI-Prozess beenden und neu starten +action.Kilo.Reinstall.text=Kilo neu installieren +action.Kilo.Reinstall.description=CLI-Binärdatei neu extrahieren und neu starten +action.Kilo.Session.Open.text=Öffnen +action.Kilo.Session.Open.description=Ausgewählte Sitzung öffnen +action.Kilo.Session.Rename.text=Umbenennen +action.Kilo.Session.Rename.description=Ausgewählte Sitzung umbenennen +action.Kilo.Session.Delete.text=Löschen +action.Kilo.Session.Delete.description=Ausgewählte Sitzung(en) löschen +action.Kilo.History.ContextMenu.text=Verlaufsaktionen diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties new file mode 100644 index 00000000000..92fbad3508f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -0,0 +1,139 @@ +session.connection.connecting=Cargando… +session.connection.error.app=Error de conexión +session.connection.error.workspace=Error al cargar el espacio de trabajo +session.connection.error.unknown=Error desconocido +session.connection.retry=Reintentar +session.connection.warning.config=Advertencias de configuración + +session.empty.welcome=Kilo Code es un asistente de codificación con IA. Pida que construya funciones, corrija errores o explique su base de código. +session.empty.loading=Cargando… +session.empty.recent=RECIENTE +session.showHistory=Mostrar historial +session.scroll.bottom=Desplazarse al final +session.tab.new=Nueva sesión +session.tab.untitled=Sesión sin título + +session.permission.title=Solicitud de permiso +session.permission.meta=Herramienta: {0} • Patrones: {1} +session.permission.allow=Permitir +session.permission.deny=Denegar +session.question.dismiss=Descartar + +session.status.considering=Considerando los próximos pasos… +session.status.thinking=Pensando… +session.status.writing=Escribiendo respuesta… +session.status.delegating=Delegando trabajo… +session.status.planning=Planificando… +session.status.gathering=Recopilando contexto… +session.status.searching.codebase=Buscando en la base de código… +session.status.searching.web=Buscando en la web… +session.status.editing=Realizando ediciones… +session.status.commands=Ejecutando comandos… + +session.part.reasoning=Razonamiento +session.part.compaction=contexto compactado +session.part.tool.copy=Copiar +session.part.tool.error=Error +session.part.tool.pending=Pendiente +session.part.tool.read=Leer +session.part.tool.running=Ejecutando +session.part.tool.shell=Shell +session.part.tool.truncated=Salida truncada en la vista previa. La salida completa permanece en los datos de la sesión. + +session.error.prompt=Error en el mensaje +session.error.compact=Error al compactar la sesión +session.error.unknown=Error desconocido + +session.header.tokens=Tokens +session.header.tokens.description=Tokens utilizados por la última respuesta del asistente: entrada, salida, escrituras en caché y lecturas en caché. +session.header.input=entrada {0} +session.header.output=salida {0} +session.header.cache.write=escritura caché {0} +session.header.cache.read=lectura caché {0} +session.header.compact=Compactar sesión +session.header.compact.description=Resumir la sesión para reducir el tamaño del contexto +session.header.expand=Mostrar métricas de sesión +session.header.collapse=Ocultar métricas de sesión +session.header.todos.progress={0}/{1} tareas completadas +session.header.todos.done=Todas las {0} tareas completadas +session.header.context.tooltip.percent={0} tokens ({1}% del contexto) +session.header.context.tooltip.tokens={0} tokens +session.header.context.used={0} / {1} tokens utilizados +session.header.context.reserved={0} reservados para la salida +session.header.context.available={0} disponibles + +prompt.placeholder=Escribe un mensaje… +prompt.placeholder.with.shortcuts=Escribe un mensaje… ({0} para enviar, {1} para nueva línea) +prompt.placeholder.with.send=Escribe un mensaje… ({0} para enviar) +prompt.placeholder.with.newline=Escribe un mensaje… ({0} para nueva línea) +prompt.button.send=Enviar +prompt.button.stop=Detener +prompt.button.send.tooltip.stop=Para detener, presiona {0} +mode.picker.tooltip=Seleccionar modo +mode.picker.deprecated=obsoleto +model.picker.tooltip=Seleccionar modelo +model.picker.recommended=Recomendado +model.picker.favorites=Favoritos +model.picker.search=Buscar modelos +model.picker.no.matches=No hay modelos coincidentes +model.picker.favorite.add=Añadir a favoritos +model.picker.favorite.remove=Eliminar de favoritos +model.picker.free=Gratis +model.picker.reset=Restablecer modelo a predeterminado +reasoning.picker.tooltip=Seleccionar esfuerzo de razonamiento + +history.tab.local=Local +history.tab.cloud=Nube +history.back=Atrás +history.search.placeholder=Buscar sesiones +history.empty=Sin sesiones +history.loading=Cargando… +history.untitled=Sin título +history.delete.text=Eliminar +history.delete.confirm.title=¿Eliminar sesión? +history.delete.confirm.message=¿Eliminar "{0}" del historial local? +history.delete.confirm.message.multiple=¿Eliminar {0} sesiones del historial local? +history.rename.title=Renombrar sesión +history.rename.prompt=Nuevo nombre de sesión: +history.cloud.load.more=Cargar más +history.cloud.repo.only=Solo este repositorio +history.group.today=Hoy +history.group.yesterday=Ayer +history.group.week=Esta semana +history.group.month=Este mes +history.group.older=Más antiguo +history.time.moments=Hace un momento +history.time.minutes=Hace {0} min +history.time.hours=Hace {0} h +history.time.days=Hace {0} d +history.time.months=Hace {0} mes(es) +history.time.years=Hace {0} año(s) +history.error.local=Error al cargar el historial local +history.error.cloud=Error al cargar el historial en la nube +history.error.local.delete=Error al eliminar la sesión +history.error.local.rename=Error al renombrar la sesión +history.error.cloud.delete=Las sesiones en la nube no se pueden eliminar aún + +action.Kilo.Settings.text=Configuración +action.Kilo.Settings.description=Configuración de Kilo Code +action.Kilo.NewSession.text=Nueva sesión +action.Kilo.NewSession.description=Iniciar una nueva sesión de Kilo +action.Kilo.History.text=Historial +action.Kilo.History.description=Mostrar el historial de sesiones +action.Kilo.SendPrompt.text=Enviar mensaje +action.Kilo.SendPrompt.description=Enviar el mensaje actual de Kilo +action.Kilo.StopSession.text=Detener sesión +action.Kilo.StopSession.description=Detener la sesión actual de Kilo +action.Kilo.SettingsGroup.text=Configuración +action.Kilo.SettingsGroup.description=Configuración de Kilo Code +action.Kilo.Restart.text=Reiniciar Kilo +action.Kilo.Restart.description=Terminar y reiniciar el proceso CLI +action.Kilo.Reinstall.text=Reinstalar Kilo +action.Kilo.Reinstall.description=Reextraer el binario CLI y reiniciar +action.Kilo.Session.Open.text=Abrir +action.Kilo.Session.Open.description=Abrir la sesión seleccionada +action.Kilo.Session.Rename.text=Renombrar +action.Kilo.Session.Rename.description=Renombrar la sesión seleccionada +action.Kilo.Session.Delete.text=Eliminar +action.Kilo.Session.Delete.description=Eliminar la(s) sesión(es) seleccionada(s) +action.Kilo.History.ContextMenu.text=Acciones del historial diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties new file mode 100644 index 00000000000..bf51dd4e426 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -0,0 +1,139 @@ +session.connection.connecting=Chargement… +session.connection.error.app=Échec de la connexion +session.connection.error.workspace=Échec du chargement de l'espace de travail +session.connection.error.unknown=Erreur inconnue +session.connection.retry=Réessayer +session.connection.warning.config=Avertissements de configuration + +session.empty.welcome=Kilo Code est un assistant de codage IA. Demandez-lui de créer des fonctionnalités, corriger des bugs ou expliquer votre base de code. +session.empty.loading=Chargement… +session.empty.recent=RÉCENT +session.showHistory=Afficher l'historique +session.scroll.bottom=Faire défiler vers le bas +session.tab.new=Nouvelle session +session.tab.untitled=Session sans titre + +session.permission.title=Demande de permission +session.permission.meta=Outil : {0} • Modèles : {1} +session.permission.allow=Autoriser +session.permission.deny=Refuser +session.question.dismiss=Ignorer + +session.status.considering=Considération des prochaines étapes… +session.status.thinking=Réflexion… +session.status.writing=Rédaction de la réponse… +session.status.delegating=Délégation du travail… +session.status.planning=Planification… +session.status.gathering=Collecte du contexte… +session.status.searching.codebase=Recherche dans la base de code… +session.status.searching.web=Recherche sur le web… +session.status.editing=Modifications en cours… +session.status.commands=Exécution des commandes… + +session.part.reasoning=Raisonnement +session.part.compaction=contexte compacté +session.part.tool.copy=Copier +session.part.tool.error=Erreur +session.part.tool.pending=En attente +session.part.tool.read=Lire +session.part.tool.running=En cours +session.part.tool.shell=Shell +session.part.tool.truncated=Sortie tronquée dans l'aperçu. La sortie complète reste dans les données de session. + +session.error.prompt=Échec de l'invite +session.error.compact=Échec de la compaction de session +session.error.unknown=Erreur inconnue + +session.header.tokens=Tokens +session.header.tokens.description=Tokens utilisés par la dernière réponse de l'assistant : entrée, sortie, écritures en cache et lectures en cache. +session.header.input=entrée {0} +session.header.output=sortie {0} +session.header.cache.write=écriture cache {0} +session.header.cache.read=lecture cache {0} +session.header.compact=Compacter la session +session.header.compact.description=Résumer la session pour réduire la taille du contexte +session.header.expand=Afficher les métriques de session +session.header.collapse=Masquer les métriques de session +session.header.todos.progress={0}/{1} tâches terminées +session.header.todos.done=Toutes les {0} tâches terminées +session.header.context.tooltip.percent={0} tokens ({1} % du contexte) +session.header.context.tooltip.tokens={0} tokens +session.header.context.used={0} / {1} tokens utilisés +session.header.context.reserved={0} réservés pour la sortie +session.header.context.available={0} disponibles + +prompt.placeholder=Saisir un message… +prompt.placeholder.with.shortcuts=Saisir un message… ({0} pour envoyer, {1} pour nouvelle ligne) +prompt.placeholder.with.send=Saisir un message… ({0} pour envoyer) +prompt.placeholder.with.newline=Saisir un message… ({0} pour nouvelle ligne) +prompt.button.send=Envoyer +prompt.button.stop=Arrêter +prompt.button.send.tooltip.stop=Pour arrêter, appuyez sur {0} +mode.picker.tooltip=Sélectionner le mode +mode.picker.deprecated=obsolète +model.picker.tooltip=Sélectionner le modèle +model.picker.recommended=Recommandé +model.picker.favorites=Favoris +model.picker.search=Rechercher des modèles +model.picker.no.matches=Aucun modèle correspondant +model.picker.favorite.add=Ajouter aux favoris +model.picker.favorite.remove=Retirer des favoris +model.picker.free=Gratuit +model.picker.reset=Réinitialiser le modèle par défaut +reasoning.picker.tooltip=Sélectionner l'effort de raisonnement + +history.tab.local=Local +history.tab.cloud=Cloud +history.back=Retour +history.search.placeholder=Rechercher des sessions +history.empty=Aucune session +history.loading=Chargement… +history.untitled=Sans titre +history.delete.text=Supprimer +history.delete.confirm.title=Supprimer la session ? +history.delete.confirm.message=Supprimer « {0} » de l'historique local ? +history.delete.confirm.message.multiple=Supprimer {0} sessions de l'historique local ? +history.rename.title=Renommer la session +history.rename.prompt=Nouveau nom de session : +history.cloud.load.more=Charger plus +history.cloud.repo.only=Ce dépôt uniquement +history.group.today=Aujourd'hui +history.group.yesterday=Hier +history.group.week=Cette semaine +history.group.month=Ce mois +history.group.older=Plus ancien +history.time.moments=À l'instant +history.time.minutes=Il y a {0} min +history.time.hours=Il y a {0} h +history.time.days=Il y a {0} j +history.time.months=Il y a {0} mois +history.time.years=Il y a {0} an(s) +history.error.local=Échec du chargement de l'historique local +history.error.cloud=Échec du chargement de l'historique cloud +history.error.local.delete=Échec de la suppression de la session +history.error.local.rename=Échec du renommage de la session +history.error.cloud.delete=Les sessions cloud ne peuvent pas encore être supprimées + +action.Kilo.Settings.text=Paramètres +action.Kilo.Settings.description=Paramètres de Kilo Code +action.Kilo.NewSession.text=Nouvelle session +action.Kilo.NewSession.description=Démarrer une nouvelle session Kilo +action.Kilo.History.text=Historique +action.Kilo.History.description=Afficher l'historique des sessions +action.Kilo.SendPrompt.text=Envoyer l'invite +action.Kilo.SendPrompt.description=Envoyer l'invite Kilo actuelle +action.Kilo.StopSession.text=Arrêter la session +action.Kilo.StopSession.description=Arrêter la session Kilo actuelle +action.Kilo.SettingsGroup.text=Paramètres +action.Kilo.SettingsGroup.description=Paramètres de Kilo Code +action.Kilo.Restart.text=Redémarrer Kilo +action.Kilo.Restart.description=Tuer et redémarrer le processus CLI +action.Kilo.Reinstall.text=Réinstaller Kilo +action.Kilo.Reinstall.description=Réextraire le binaire CLI et redémarrer +action.Kilo.Session.Open.text=Ouvrir +action.Kilo.Session.Open.description=Ouvrir la session sélectionnée +action.Kilo.Session.Rename.text=Renommer +action.Kilo.Session.Rename.description=Renommer la session sélectionnée +action.Kilo.Session.Delete.text=Supprimer +action.Kilo.Session.Delete.description=Supprimer la ou les sessions sélectionnées +action.Kilo.History.ContextMenu.text=Actions d'historique diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties new file mode 100644 index 00000000000..c32cc633e9f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -0,0 +1,139 @@ +session.connection.connecting=読み込み中… +session.connection.error.app=接続に失敗しました +session.connection.error.workspace=ワークスペースの読み込みに失敗しました +session.connection.error.unknown=不明なエラー +session.connection.retry=再試行 +session.connection.warning.config=設定の警告 + +session.empty.welcome=Kilo CodeはAIコーディングアシスタントです。機能の作成、バグの修正、またはコードベースの説明を依頼できます。 +session.empty.loading=読み込み中… +session.empty.recent=最近 +session.showHistory=履歴を表示 +session.scroll.bottom=一番下にスクロール +session.tab.new=新しいセッション +session.tab.untitled=名前なしのセッション + +session.permission.title=権限リクエスト +session.permission.meta=ツール:{0} • パターン:{1} +session.permission.allow=許可 +session.permission.deny=拒否 +session.question.dismiss=閉じる + +session.status.considering=次のステップを検討中… +session.status.thinking=考え中… +session.status.writing=回答を作成中… +session.status.delegating=作業を委任中… +session.status.planning=計画中… +session.status.gathering=コンテキストを収集中… +session.status.searching.codebase=コードベースを検索中… +session.status.searching.web=ウェブを検索中… +session.status.editing=編集中… +session.status.commands=コマンドを実行中… + +session.part.reasoning=推論 +session.part.compaction=コンテキスト圧縮済み +session.part.tool.copy=コピー +session.part.tool.error=エラー +session.part.tool.pending=保留中 +session.part.tool.read=読み取り +session.part.tool.running=実行中 +session.part.tool.shell=シェル +session.part.tool.truncated=プレビューでは出力が切り詰められています。完全な出力はセッションデータに残っています。 + +session.error.prompt=プロンプトが失敗しました +session.error.compact=セッション圧縮が失敗しました +session.error.unknown=不明なエラー + +session.header.tokens=トークン +session.header.tokens.description=最新のアシスタントの回答で使用されたトークン:入力、出力、キャッシュ書き込み、キャッシュ読み取り。 +session.header.input=入力 {0} +session.header.output=出力 {0} +session.header.cache.write=キャッシュ書き込み {0} +session.header.cache.read=キャッシュ読み取り {0} +session.header.compact=セッションを圧縮 +session.header.compact.description=セッションを要約してコンテキストサイズを削減 +session.header.expand=セッションメトリクスを表示 +session.header.collapse=セッションメトリクスを非表示 +session.header.todos.progress={0}/{1}個のタスク完了 +session.header.todos.done=全{0}個のタスク完了 +session.header.context.tooltip.percent={0}トークン(コンテキストの{1}%) +session.header.context.tooltip.tokens={0}トークン +session.header.context.used={0} / {1}トークン使用中 +session.header.context.reserved={0}は出力用に予約済み +session.header.context.available={0}利用可能 + +prompt.placeholder=メッセージを入力… +prompt.placeholder.with.shortcuts=メッセージを入力…({0}で送信、{1}で改行) +prompt.placeholder.with.send=メッセージを入力…({0}で送信) +prompt.placeholder.with.newline=メッセージを入力…({0}で改行) +prompt.button.send=送信 +prompt.button.stop=停止 +prompt.button.send.tooltip.stop=停止するには{0}を押してください +mode.picker.tooltip=モードを選択 +mode.picker.deprecated=非推奨 +model.picker.tooltip=モデルを選択 +model.picker.recommended=おすすめ +model.picker.favorites=お気に入り +model.picker.search=モデルを検索 +model.picker.no.matches=一致するモデルなし +model.picker.favorite.add=お気に入りに追加 +model.picker.favorite.remove=お気に入りから削除 +model.picker.free=無料 +model.picker.reset=モデルをデフォルトにリセット +reasoning.picker.tooltip=推論努力を選択 + +history.tab.local=ローカル +history.tab.cloud=クラウド +history.back=戻る +history.search.placeholder=セッションを検索 +history.empty=セッションなし +history.loading=読み込み中… +history.untitled=名前なし +history.delete.text=削除 +history.delete.confirm.title=セッションを削除しますか? +history.delete.confirm.message=「{0}」をローカル履歴から削除しますか? +history.delete.confirm.message.multiple={0}個のセッションをローカル履歴から削除しますか? +history.rename.title=セッションを変名 +history.rename.prompt=新しいセッション名: +history.cloud.load.more=さらに読み込む +history.cloud.repo.only=このリポジトリのみ +history.group.today=今日 +history.group.yesterday=昨日 +history.group.week=今週 +history.group.month=今月 +history.group.older=さらに古い +history.time.moments=たった今 +history.time.minutes={0}分前 +history.time.hours={0}時間前 +history.time.days={0}日前 +history.time.months={0}か月前 +history.time.years={0}年前 +history.error.local=ローカル履歴の読み込みに失敗しました +history.error.cloud=クラウド履歴の読み込みに失敗しました +history.error.local.delete=セッションの削除に失敗しました +history.error.local.rename=セッションの変名に失敗しました +history.error.cloud.delete=クラウドセッションはまだ削除できません + +action.Kilo.Settings.text=設定 +action.Kilo.Settings.description=Kilo Codeの設定 +action.Kilo.NewSession.text=新しいセッション +action.Kilo.NewSession.description=新しいKiloセッションを開始 +action.Kilo.History.text=履歴 +action.Kilo.History.description=セッション履歴を表示 +action.Kilo.SendPrompt.text=プロンプトを送信 +action.Kilo.SendPrompt.description=現在のKiloプロンプトを送信 +action.Kilo.StopSession.text=セッションを停止 +action.Kilo.StopSession.description=現在のKiloセッションを停止 +action.Kilo.SettingsGroup.text=設定 +action.Kilo.SettingsGroup.description=Kilo Codeの設定 +action.Kilo.Restart.text=Kiloを再起動 +action.Kilo.Restart.description=CLIプロセスを終了して再起動 +action.Kilo.Reinstall.text=Kiloを再インストール +action.Kilo.Reinstall.description=CLIバイナリを再展開して再起動 +action.Kilo.Session.Open.text=開く +action.Kilo.Session.Open.description=選択したセッションを開く +action.Kilo.Session.Rename.text=変名 +action.Kilo.Session.Rename.description=選択したセッションを変名 +action.Kilo.Session.Delete.text=削除 +action.Kilo.Session.Delete.description=選択したセッションを削除 +action.Kilo.History.ContextMenu.text=履歴アクション diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties new file mode 100644 index 00000000000..893abc95b96 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -0,0 +1,139 @@ +session.connection.connecting=로딩 중… +session.connection.error.app=연결 실패 +session.connection.error.workspace=워크스페이스 로딩 실패 +session.connection.error.unknown=알 수 없는 오류 +session.connection.retry=다시 시도 +session.connection.warning.config=구성 경고 + +session.empty.welcome=Kilo Code는 AI 코딩 어시스턴트입니다. 기능 구축, 버그 수정, 코드베이스 설명을 요청하세요. +session.empty.loading=로딩 중… +session.empty.recent=최근 +session.showHistory=기록 보기 +session.scroll.bottom=맨 아래로 스크롤 +session.tab.new=새 세션 +session.tab.untitled=제목 없는 세션 + +session.permission.title=권한 요청 +session.permission.meta=도구: {0} • 패턴: {1} +session.permission.allow=허용 +session.permission.deny=거부 +session.question.dismiss=닫기 + +session.status.considering=다음 단계 고려 중… +session.status.thinking=생각 중… +session.status.writing=응답 작성 중… +session.status.delegating=작업 위임 중… +session.status.planning=계획 중… +session.status.gathering=컨텍스트 수집 중… +session.status.searching.codebase=코드베이스 검색 중… +session.status.searching.web=웹 검색 중… +session.status.editing=편집 중… +session.status.commands=명령 실행 중… + +session.part.reasoning=추론 +session.part.compaction=컨텍스트 압축됨 +session.part.tool.copy=복사 +session.part.tool.error=오류 +session.part.tool.pending=대기 중 +session.part.tool.read=읽기 +session.part.tool.running=실행 중 +session.part.tool.shell=셸 +session.part.tool.truncated=미리보기에서 출력이 잘렸습니다. 전체 출력은 세션 데이터에 남아 있습니다. + +session.error.prompt=프롬프트 실패 +session.error.compact=세션 압축 실패 +session.error.unknown=알 수 없는 오류 + +session.header.tokens=토큰 +session.header.tokens.description=마지막 어시스턴트 응답에서 사용된 토큰: 입력, 출력, 캐시 쓰기, 캐시 읽기. +session.header.input=입력 {0} +session.header.output=출력 {0} +session.header.cache.write=캐시 쓰기 {0} +session.header.cache.read=캐시 읽기 {0} +session.header.compact=세션 압축 +session.header.compact.description=컨텍스트 크기를 줄이기 위해 세션 요약 +session.header.expand=세션 메트릭 표시 +session.header.collapse=세션 메트릭 숨기기 +session.header.todos.progress={0}/{1} 할 일 완료 +session.header.todos.done=모든 {0}개 할 일 완료 +session.header.context.tooltip.percent={0} 토큰 (컨텍스트의 {1}%) +session.header.context.tooltip.tokens={0} 토큰 +session.header.context.used={0} / {1} 토큰 사용 중 +session.header.context.reserved={0} 출력용 예약됨 +session.header.context.available={0} 사용 가능 + +prompt.placeholder=메시지를 입력하세요… +prompt.placeholder.with.shortcuts=메시지를 입력하세요… ({0} 전송, {1} 새 줄) +prompt.placeholder.with.send=메시지를 입력하세요… ({0} 전송) +prompt.placeholder.with.newline=메시지를 입력하세요… ({0} 새 줄) +prompt.button.send=전송 +prompt.button.stop=정지 +prompt.button.send.tooltip.stop=정지하려면 {0}을 누르세요 +mode.picker.tooltip=모드 선택 +mode.picker.deprecated=사용 중단 +model.picker.tooltip=모델 선택 +model.picker.recommended=추천 +model.picker.favorites=즐겨찾기 +model.picker.search=모델 검색 +model.picker.no.matches=일치하는 모델 없음 +model.picker.favorite.add=즐겨찾기에 추가 +model.picker.favorite.remove=즐겨찾기에서 제거 +model.picker.free=무료 +model.picker.reset=모델을 기본값으로 재설정 +reasoning.picker.tooltip=추론 노력 선택 + +history.tab.local=로컈 +history.tab.cloud=클라우드 +history.back=뒤로 +history.search.placeholder=세션 검색 +history.empty=세션 없음 +history.loading=로딩 중… +history.untitled=제목 없음 +history.delete.text=삭제 +history.delete.confirm.title=세션을 삭제하시겠습니까? +history.delete.confirm.message=로컈 기록에서 "{0}"을 삭제하시겠습니까? +history.delete.confirm.message.multiple=로컈 기록에서 {0}개의 세션을 삭제하시겠습니까? +history.rename.title=세션 이름 바꾸기 +history.rename.prompt=새 세션 이름: +history.cloud.load.more=더 불러오기 +history.cloud.repo.only=이 리포지터리만 +history.group.today=오늘 +history.group.yesterday=어제 +history.group.week=이번 주 +history.group.month=이번 달 +history.group.older=더 오래된 +history.time.moments=방금 +history.time.minutes={0}분 전 +history.time.hours={0}시간 전 +history.time.days={0}일 전 +history.time.months={0}개월 전 +history.time.years={0}년 전 +history.error.local=로컈 기록을 로드하지 못했습니다 +history.error.cloud=클라우드 기록을 로드하지 못했습니다 +history.error.local.delete=세션을 삭제하지 못했습니다 +history.error.local.rename=세션의 이름을 바꾸지 못했습니다 +history.error.cloud.delete=클라우드 세션은 아직 삭제할 수 없습니다 + +action.Kilo.Settings.text=설정 +action.Kilo.Settings.description=Kilo Code 설정 +action.Kilo.NewSession.text=새 세션 +action.Kilo.NewSession.description=새 Kilo 세션 시작 +action.Kilo.History.text=기록 +action.Kilo.History.description=세션 기록 표시 +action.Kilo.SendPrompt.text=프롬프트 전송 +action.Kilo.SendPrompt.description=현재 Kilo 프롬프트 전송 +action.Kilo.StopSession.text=세션 정지 +action.Kilo.StopSession.description=현재 Kilo 세션 정지 +action.Kilo.SettingsGroup.text=설정 +action.Kilo.SettingsGroup.description=Kilo Code 설정 +action.Kilo.Restart.text=Kilo 재시작 +action.Kilo.Restart.description=CLI 프로세스 종료 후 재시작 +action.Kilo.Reinstall.text=Kilo 재설치 +action.Kilo.Reinstall.description=CLI 바이너리 재추출 후 재시작 +action.Kilo.Session.Open.text=열기 +action.Kilo.Session.Open.description=선택한 세션 열기 +action.Kilo.Session.Rename.text=이름 바꾸기 +action.Kilo.Session.Rename.description=선택한 세션 이름 바꾸기 +action.Kilo.Session.Delete.text=삭제 +action.Kilo.Session.Delete.description=선택한 세션 삭제 +action.Kilo.History.ContextMenu.text=기록 작업 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties new file mode 100644 index 00000000000..e88d7960997 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -0,0 +1,139 @@ +session.connection.connecting=Laden… +session.connection.error.app=Verbinding mislukt +session.connection.error.workspace=Werkruimte laden mislukt +session.connection.error.unknown=Onbekende fout +session.connection.retry=Opnieuw proberen +session.connection.warning.config=Configuratiewaarschuwingen + +session.empty.welcome=Kilo Code is een AI-codeerassistent. Vraag het om functies te bouwen, bugs te repareren of uw codebase uit te leggen. +session.empty.loading=Laden… +session.empty.recent=RECENT +session.showHistory=Geschiedenis weergeven +session.scroll.bottom=Naar beneden scrollen +session.tab.new=Nieuwe sessie +session.tab.untitled=Naamloze sessie + +session.permission.title=Toestemmingsverzoek +session.permission.meta=Hulpmiddel: {0} • Patronen: {1} +session.permission.allow=Toestaan +session.permission.deny=Weigeren +session.question.dismiss=Sluiten + +session.status.considering=Volgende stappen overwegen… +session.status.thinking=Nadenken… +session.status.writing=Antwoord schrijven… +session.status.delegating=Werk delegeren… +session.status.planning=Plannen… +session.status.gathering=Context verzamelen… +session.status.searching.codebase=Codebase doorzoeken… +session.status.searching.web=Web doorzoeken… +session.status.editing=Bewerkingen uitvoeren… +session.status.commands=Opdrachten uitvoeren… + +session.part.reasoning=Redenering +session.part.compaction=context gecomprimeerd +session.part.tool.copy=Kopiëren +session.part.tool.error=Fout +session.part.tool.pending=In afwachting +session.part.tool.read=Lezen +session.part.tool.running=Actief +session.part.tool.shell=Shell +session.part.tool.truncated=Uitvoer ingekort in voorvertoning. Volledige uitvoer blijft beschikbaar in sessiegegevens. + +session.error.prompt=Prompt mislukt +session.error.compact=Sessiecomprimering mislukt +session.error.unknown=Onbekende fout + +session.header.tokens=Tokens +session.header.tokens.description=Tokens gebruikt door de laatste assistent-reactie: invoer, uitvoer, cache-schrijfacties en cache-leesacties. +session.header.input=in {0} +session.header.output=uit {0} +session.header.cache.write=cache schrijven {0} +session.header.cache.read=cache lezen {0} +session.header.compact=Sessie comprimeren +session.header.compact.description=Sessie samenvatten om de contextgrootte te verkleinen +session.header.expand=Sessiestatistieken weergeven +session.header.collapse=Sessiestatistieken verbergen +session.header.todos.progress={0}/{1} taken voltooid +session.header.todos.done=Alle {0} taken voltooid +session.header.context.tooltip.percent={0} tokens ({1}% van de context) +session.header.context.tooltip.tokens={0} tokens +session.header.context.used={0} / {1} tokens gebruikt +session.header.context.reserved={0} gereserveerd voor uitvoer +session.header.context.available={0} beschikbaar + +prompt.placeholder=Typ een bericht… +prompt.placeholder.with.shortcuts=Typ een bericht… ({0} om te verzenden, {1} voor nieuwe regel) +prompt.placeholder.with.send=Typ een bericht… ({0} om te verzenden) +prompt.placeholder.with.newline=Typ een bericht… ({0} voor nieuwe regel) +prompt.button.send=Verzenden +prompt.button.stop=Stoppen +prompt.button.send.tooltip.stop=Druk op {0} om te stoppen +mode.picker.tooltip=Modus selecteren +mode.picker.deprecated=verouderd +model.picker.tooltip=Model selecteren +model.picker.recommended=Aanbevolen +model.picker.favorites=Favorieten +model.picker.search=Modellen zoeken +model.picker.no.matches=Geen overeenkomende modellen +model.picker.favorite.add=Toevoegen aan favorieten +model.picker.favorite.remove=Verwijderen uit favorieten +model.picker.free=Gratis +model.picker.reset=Model terugzetten naar standaard +reasoning.picker.tooltip=Redeerniveau selecteren + +history.tab.local=Lokaal +history.tab.cloud=Cloud +history.back=Terug +history.search.placeholder=Sessies zoeken +history.empty=Geen sessies +history.loading=Laden… +history.untitled=Naamloos +history.delete.text=Verwijderen +history.delete.confirm.title=Sessie verwijderen? +history.delete.confirm.message="{0}" verwijderen uit lokale geschiedenis? +history.delete.confirm.message.multiple={0} sessies verwijderen uit lokale geschiedenis? +history.rename.title=Sessie hernoemen +history.rename.prompt=Nieuwe sessienaam: +history.cloud.load.more=Meer laden +history.cloud.repo.only=Alleen deze repository +history.group.today=Vandaag +history.group.yesterday=Gisteren +history.group.week=Deze week +history.group.month=Deze maand +history.group.older=Ouder +history.time.moments=Zojuist +history.time.minutes={0} min geleden +history.time.hours={0} u geleden +history.time.days={0} d geleden +history.time.months={0} mnd geleden +history.time.years={0} jr geleden +history.error.local=Laden van lokale geschiedenis mislukt +history.error.cloud=Laden van cloudgeschiedenis mislukt +history.error.local.delete=Verwijderen van sessie mislukt +history.error.local.rename=Hernoemen van sessie mislukt +history.error.cloud.delete=Cloudsessies kunnen nog niet worden verwijderd + +action.Kilo.Settings.text=Instellingen +action.Kilo.Settings.description=Kilo Code-instellingen +action.Kilo.NewSession.text=Nieuwe sessie +action.Kilo.NewSession.description=Een nieuwe Kilo-sessie starten +action.Kilo.History.text=Geschiedenis +action.Kilo.History.description=Sessiegeschiedenis weergeven +action.Kilo.SendPrompt.text=Prompt verzenden +action.Kilo.SendPrompt.description=Huidige Kilo-prompt verzenden +action.Kilo.StopSession.text=Sessie stoppen +action.Kilo.StopSession.description=Huidige Kilo-sessie stoppen +action.Kilo.SettingsGroup.text=Instellingen +action.Kilo.SettingsGroup.description=Kilo Code-instellingen +action.Kilo.Restart.text=Kilo herstarten +action.Kilo.Restart.description=CLI-proces beëindigen en herstarten +action.Kilo.Reinstall.text=Kilo herinstalleren +action.Kilo.Reinstall.description=CLI-binair opnieuw uitpakken en herstarten +action.Kilo.Session.Open.text=Openen +action.Kilo.Session.Open.description=Geselecteerde sessie openen +action.Kilo.Session.Rename.text=Hernoemen +action.Kilo.Session.Rename.description=Geselecteerde sessie hernoemen +action.Kilo.Session.Delete.text=Verwijderen +action.Kilo.Session.Delete.description=Geselecteerde sessie(s) verwijderen +action.Kilo.History.ContextMenu.text=Geschiedenisacties diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties new file mode 100644 index 00000000000..e7631a21d2c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -0,0 +1,139 @@ +session.connection.connecting=Laster… +session.connection.error.app=Tilkoblingsfeil +session.connection.error.workspace=Feil ved lasting av arbeidsområde +session.connection.error.unknown=Ukjent feil +session.connection.retry=Prøv igjen +session.connection.warning.config=Konfigurasjonsadvarsler + +session.empty.welcome=Kilo Code er en AI-kodingsassistent. Be den om å bygge funksjoner, fikse feil eller forklare kodebasen din. +session.empty.loading=Laster… +session.empty.recent=NYLIGE +session.showHistory=Vis historikk +session.scroll.bottom=Rull til bunnen +session.tab.new=Ny økt +session.tab.untitled=Uten tittel + +session.permission.title=Tillatelsesforespørsel +session.permission.meta=Verktøy: {0} • Mønstre: {1} +session.permission.allow=Tillat +session.permission.deny=Avslå +session.question.dismiss=Lukk + +session.status.considering=Vurderer neste steg… +session.status.thinking=Tenker… +session.status.writing=Skriver svar… +session.status.delegating=Delegerer arbeid… +session.status.planning=Planlegger… +session.status.gathering=Samler kontekst… +session.status.searching.codebase=Søker i kodebasen… +session.status.searching.web=Søker på nettet… +session.status.editing=Gjør redigeringer… +session.status.commands=Kjører kommandoer… + +session.part.reasoning=Resonnering +session.part.compaction=kontekst komprimert +session.part.tool.copy=Kopier +session.part.tool.error=Feil +session.part.tool.pending=Venter +session.part.tool.read=Les +session.part.tool.running=Kjører +session.part.tool.shell=Shell +session.part.tool.truncated=Utdata avkortet i forhåndsvisning. Fullstendig utdata finnes fortsatt i øktdata. + +session.error.prompt=Forespørsel mislyktes +session.error.compact=Komprimer økt mislyktes +session.error.unknown=Ukjent feil + +session.header.tokens=Tokens +session.header.tokens.description=Tokens brukt av siste assistentsvar: inndata, utdata, cache-skrivninger og cache-lesninger. +session.header.input=inn {0} +session.header.output=ut {0} +session.header.cache.write=cache-skriving {0} +session.header.cache.read=cache-lesing {0} +session.header.compact=Komprimer økt +session.header.compact.description=Oppsummer økten for å redusere kontekststørrelsen +session.header.expand=Vis øktberegninger +session.header.collapse=Skjul øktberegninger +session.header.todos.progress={0}/{1} oppgaver fullført +session.header.todos.done=Alle {0} oppgaver fullført +session.header.context.tooltip.percent={0} tokens ({1}% av konteksten) +session.header.context.tooltip.tokens={0} tokens +session.header.context.used={0} / {1} tokens brukt +session.header.context.reserved={0} reservert for utdata +session.header.context.available={0} tilgjengelig + +prompt.placeholder=Skriv en melding… +prompt.placeholder.with.shortcuts=Skriv en melding… ({0} for å sende, {1} for ny linje) +prompt.placeholder.with.send=Skriv en melding… ({0} for å sende) +prompt.placeholder.with.newline=Skriv en melding… ({0} for ny linje) +prompt.button.send=Send +prompt.button.stop=Stopp +prompt.button.send.tooltip.stop=Trykk {0} for å stoppe +mode.picker.tooltip=Velg modus +mode.picker.deprecated=utdatert +model.picker.tooltip=Velg modell +model.picker.recommended=Anbefalt +model.picker.favorites=Favoritter +model.picker.search=Søk etter modeller +model.picker.no.matches=Ingen samsvarende modeller +model.picker.favorite.add=Legg til favoritter +model.picker.favorite.remove=Fjern fra favoritter +model.picker.free=Gratis +model.picker.reset=Tilbakestill modell til standard +reasoning.picker.tooltip=Velg resonnementsnivå + +history.tab.local=Lokal +history.tab.cloud=Sky +history.back=Tilbake +history.search.placeholder=Søk i økter +history.empty=Ingen økter +history.loading=Laster… +history.untitled=Uten tittel +history.delete.text=Slett +history.delete.confirm.title=Slette økt? +history.delete.confirm.message=Slette "{0}" fra lokal historikk? +history.delete.confirm.message.multiple=Slette {0} økter fra lokal historikk? +history.rename.title=Gi økten nytt navn +history.rename.prompt=Nytt øktnavn: +history.cloud.load.more=Last inn mer +history.cloud.repo.only=Bare dette depotet +history.group.today=I dag +history.group.yesterday=I går +history.group.week=Denne uken +history.group.month=Denne måneden +history.group.older=Eldre +history.time.moments=Akkurat nå +history.time.minutes={0} min siden +history.time.hours={0} t siden +history.time.days={0} d siden +history.time.months={0} mnd siden +history.time.years={0} år siden +history.error.local=Kunne ikke laste lokal historikk +history.error.cloud=Kunne ikke laste sky-historikk +history.error.local.delete=Kunne ikke slette økt +history.error.local.rename=Kunne ikke gi økten nytt navn +history.error.cloud.delete=Sky-økter kan ikke slettes ennå + +action.Kilo.Settings.text=Innstillinger +action.Kilo.Settings.description=Kilo Code-innstillinger +action.Kilo.NewSession.text=Ny økt +action.Kilo.NewSession.description=Start en ny Kilo-økt +action.Kilo.History.text=Historikk +action.Kilo.History.description=Vis økthistorikk +action.Kilo.SendPrompt.text=Send forespørsel +action.Kilo.SendPrompt.description=Send gjeldende Kilo-forespørsel +action.Kilo.StopSession.text=Stopp økt +action.Kilo.StopSession.description=Stopp gjeldende Kilo-økt +action.Kilo.SettingsGroup.text=Innstillinger +action.Kilo.SettingsGroup.description=Kilo Code-innstillinger +action.Kilo.Restart.text=Start Kilo på nytt +action.Kilo.Restart.description=Avslutt og start CLI-prosessen på nytt +action.Kilo.Reinstall.text=Installer Kilo på nytt +action.Kilo.Reinstall.description=Pakk ut CLI-binærfilen på nytt og start på nytt +action.Kilo.Session.Open.text=Åpne +action.Kilo.Session.Open.description=Åpne valgt økt +action.Kilo.Session.Rename.text=Gi nytt navn +action.Kilo.Session.Rename.description=Gi valgt økt nytt navn +action.Kilo.Session.Delete.text=Slett +action.Kilo.Session.Delete.description=Slett valgte økt(er) +action.Kilo.History.ContextMenu.text=Historikkhandlinger diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties new file mode 100644 index 00000000000..53ddeb68b15 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -0,0 +1,139 @@ +session.connection.connecting=Ładowanie… +session.connection.error.app=Błąd połączenia +session.connection.error.workspace=Błąd ładowania obszaru roboczego +session.connection.error.unknown=Nieznany błąd +session.connection.retry=Spróbuj ponownie +session.connection.warning.config=Ostrzeżenia konfiguracji + +session.empty.welcome=Kilo Code to asystent kodowania AI. Poproś go o tworzenie funkcji, naprawianie błędów lub wyjaśnianie bazy kodu. +session.empty.loading=Ładowanie… +session.empty.recent=OSTATNIE +session.showHistory=Pokaż historię +session.scroll.bottom=Przewiń na dół +session.tab.new=Nowa sesja +session.tab.untitled=Sesja bez tytułu + +session.permission.title=Prośba o uprawnienia +session.permission.meta=Narzędzie: {0} • Wzorce: {1} +session.permission.allow=Zezwól +session.permission.deny=Odmów +session.question.dismiss=Odrzuć + +session.status.considering=Rozważanie następnych kroków… +session.status.thinking=Myślenie… +session.status.writing=Pisanie odpowiedzi… +session.status.delegating=Delegowanie pracy… +session.status.planning=Planowanie… +session.status.gathering=Zbieranie kontekstu… +session.status.searching.codebase=Przeszukiwanie bazy kodu… +session.status.searching.web=Przeszukiwanie sieci… +session.status.editing=Dokonywanie edycji… +session.status.commands=Uruchamianie poleceń… + +session.part.reasoning=Rozumowanie +session.part.compaction=kontekst skompresowany +session.part.tool.copy=Kopiuj +session.part.tool.error=Błąd +session.part.tool.pending=Oczekuje +session.part.tool.read=Odczyt +session.part.tool.running=Uruchomione +session.part.tool.shell=Powłoka +session.part.tool.truncated=Wyjście skrócone w podglądzie. Pełne wyjście pozostaje w danych sesji. + +session.error.prompt=Błąd monitu +session.error.compact=Błąd kompresji sesji +session.error.unknown=Nieznany błąd + +session.header.tokens=Tokeny +session.header.tokens.description=Tokeny użyte przez ostatnią odpowiedź asystenta: wejściowe, wyjściowe, zapisy do bufora i odczyty z bufora. +session.header.input=we {0} +session.header.output=wy {0} +session.header.cache.write=zapis bufora {0} +session.header.cache.read=odczyt bufora {0} +session.header.compact=Kompresuj sesję +session.header.compact.description=Podsumuj sesję, aby zmniejszyć rozmiar kontekstu +session.header.expand=Pokaż metryki sesji +session.header.collapse=Ukryj metryki sesji +session.header.todos.progress={0}/{1} zadań ukończono +session.header.todos.done=Wszystkie {0} zadań ukończono +session.header.context.tooltip.percent={0} tokenów ({1}% kontekstu) +session.header.context.tooltip.tokens={0} tokenów +session.header.context.used={0} / {1} tokenów użito +session.header.context.reserved={0} zarezerwowane dla wyjścia +session.header.context.available={0} dostępne + +prompt.placeholder=Wpisz wiadomość… +prompt.placeholder.with.shortcuts=Wpisz wiadomość… ({0} aby wyslać, {1} nowa linia) +prompt.placeholder.with.send=Wpisz wiadomość… ({0} aby wyslać) +prompt.placeholder.with.newline=Wpisz wiadomość… ({0} nowa linia) +prompt.button.send=Wyślij +prompt.button.stop=Zatrzymaj +prompt.button.send.tooltip.stop=Aby zatrzymać, naciśnij {0} +mode.picker.tooltip=Wybierz tryb +mode.picker.deprecated=przestarzały +model.picker.tooltip=Wybierz model +model.picker.recommended=Zalecane +model.picker.favorites=Ulubione +model.picker.search=Szukaj modeli +model.picker.no.matches=Brak pasujących modeli +model.picker.favorite.add=Dodaj do ulubionych +model.picker.favorite.remove=Usuń z ulubionych +model.picker.free=Bezpłatny +model.picker.reset=Zresetuj model do domyślnego +reasoning.picker.tooltip=Wybierz poziom rozumowania + +history.tab.local=Lokalne +history.tab.cloud=Chmura +history.back=Wstecz +history.search.placeholder=Szukaj sesji +history.empty=Brak sesji +history.loading=Ładowanie… +history.untitled=Bez tytułu +history.delete.text=Usuń +history.delete.confirm.title=Usunąć sesję? +history.delete.confirm.message=Usunąć "{0}" z lokalnej historii? +history.delete.confirm.message.multiple=Usunąć {0} sesji z lokalnej historii? +history.rename.title=Zmień nazwę sesji +history.rename.prompt=Nowa nazwa sesji: +history.cloud.load.more=Załaduj więcej +history.cloud.repo.only=Tylko to repozytorium +history.group.today=Dzisiaj +history.group.yesterday=Wczoraj +history.group.week=Ten tydzień +history.group.month=Ten miesiąc +history.group.older=Starsze +history.time.moments=Przed chwilą +history.time.minutes={0} min temu +history.time.hours={0} godz temu +history.time.days={0} dni temu +history.time.months={0} mies temu +history.time.years={0} lat temu +history.error.local=Nie udało się załadować lokalnej historii +history.error.cloud=Nie udało się załadować historii w chmurze +history.error.local.delete=Nie udało się usunąć sesji +history.error.local.rename=Nie udało się zmienić nazwy sesji +history.error.cloud.delete=Sesji w chmurze nie można jeszcze usuwać + +action.Kilo.Settings.text=Ustawienia +action.Kilo.Settings.description=Ustawienia Kilo Code +action.Kilo.NewSession.text=Nowa sesja +action.Kilo.NewSession.description=Rozpocznij nową sesję Kilo +action.Kilo.History.text=Historia +action.Kilo.History.description=Pokaż historię sesji +action.Kilo.SendPrompt.text=Wyślij monit +action.Kilo.SendPrompt.description=Wyślij bieżący monit Kilo +action.Kilo.StopSession.text=Zatrzymaj sesję +action.Kilo.StopSession.description=Zatrzymaj bieżącą sesję Kilo +action.Kilo.SettingsGroup.text=Ustawienia +action.Kilo.SettingsGroup.description=Ustawienia Kilo Code +action.Kilo.Restart.text=Uruchom ponownie Kilo +action.Kilo.Restart.description=Zakończ i uruchom ponownie proces CLI +action.Kilo.Reinstall.text=Zainstaluj ponownie Kilo +action.Kilo.Reinstall.description=Ponownie wyodrębnij plik binarny CLI i uruchom ponownie +action.Kilo.Session.Open.text=Otwórz +action.Kilo.Session.Open.description=Otwórz wybraną sesję +action.Kilo.Session.Rename.text=Zmień nazwę +action.Kilo.Session.Rename.description=Zmień nazwę wybranej sesji +action.Kilo.Session.Delete.text=Usuń +action.Kilo.Session.Delete.description=Usuń wybraną sesję (wybrane sesje) +action.Kilo.History.ContextMenu.text=Akcje historii diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties new file mode 100644 index 00000000000..27c7eb48436 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -0,0 +1,139 @@ +session.connection.connecting=Carregando… +session.connection.error.app=Falha na conexão +session.connection.error.workspace=Falha ao carregar o espaço de trabalho +session.connection.error.unknown=Erro desconhecido +session.connection.retry=Tentar novamente +session.connection.warning.config=Avisos de configuração + +session.empty.welcome=Kilo Code é um assistente de codificação com IA. Peça que construa funcionalidades, corrija bugs ou explique sua base de código. +session.empty.loading=Carregando… +session.empty.recent=RECENTE +session.showHistory=Mostrar histórico +session.scroll.bottom=Rolar para o fim +session.tab.new=Nova sessão +session.tab.untitled=Sessão sem título + +session.permission.title=Solicitação de permissão +session.permission.meta=Ferramenta: {0} • Padrões: {1} +session.permission.allow=Permitir +session.permission.deny=Negar +session.question.dismiss=Dispensar + +session.status.considering=Considerando os próximos passos… +session.status.thinking=Pensando… +session.status.writing=Escrevendo resposta… +session.status.delegating=Delegando trabalho… +session.status.planning=Planejando… +session.status.gathering=Coletando contexto… +session.status.searching.codebase=Pesquisando na base de código… +session.status.searching.web=Pesquisando na web… +session.status.editing=Realizando edições… +session.status.commands=Executando comandos… + +session.part.reasoning=Raciocínio +session.part.compaction=contexto compactado +session.part.tool.copy=Copiar +session.part.tool.error=Erro +session.part.tool.pending=Pendente +session.part.tool.read=Ler +session.part.tool.running=Executando +session.part.tool.shell=Shell +session.part.tool.truncated=Saída truncada na pré-visualização. A saída completa permanece nos dados da sessão. + +session.error.prompt=Falha no prompt +session.error.compact=Falha ao compactar a sessão +session.error.unknown=Erro desconhecido + +session.header.tokens=Tokens +session.header.tokens.description=Tokens usados pela última resposta do assistente: entrada, saída, escritas em cache e leituras em cache. +session.header.input=entrada {0} +session.header.output=saída {0} +session.header.cache.write=escrita em cache {0} +session.header.cache.read=leitura em cache {0} +session.header.compact=Compactar sessão +session.header.compact.description=Resumir a sessão para reduzir o tamanho do contexto +session.header.expand=Exibir métricas da sessão +session.header.collapse=Ocultar métricas da sessão +session.header.todos.progress={0}/{1} tarefas concluídas +session.header.todos.done=Todas as {0} tarefas concluídas +session.header.context.tooltip.percent={0} tokens ({1}% do contexto) +session.header.context.tooltip.tokens={0} tokens +session.header.context.used={0} / {1} tokens utilizados +session.header.context.reserved={0} reservados para saída +session.header.context.available={0} disponíveis + +prompt.placeholder=Digite uma mensagem… +prompt.placeholder.with.shortcuts=Digite uma mensagem… ({0} para enviar, {1} para nova linha) +prompt.placeholder.with.send=Digite uma mensagem… ({0} para enviar) +prompt.placeholder.with.newline=Digite uma mensagem… ({0} para nova linha) +prompt.button.send=Enviar +prompt.button.stop=Parar +prompt.button.send.tooltip.stop=Para parar, pressione {0} +mode.picker.tooltip=Selecionar modo +mode.picker.deprecated=obsoleto +model.picker.tooltip=Selecionar modelo +model.picker.recommended=Recomendado +model.picker.favorites=Favoritos +model.picker.search=Pesquisar modelos +model.picker.no.matches=Nenhum modelo correspondente +model.picker.favorite.add=Adicionar aos favoritos +model.picker.favorite.remove=Remover dos favoritos +model.picker.free=Gratuito +model.picker.reset=Redefinir modelo para o padrão +reasoning.picker.tooltip=Selecionar esforço de raciocínio + +history.tab.local=Local +history.tab.cloud=Nuvem +history.back=Voltar +history.search.placeholder=Pesquisar sessões +history.empty=Nenhuma sessão +history.loading=Carregando… +history.untitled=Sem título +history.delete.text=Excluir +history.delete.confirm.title=Excluir sessão? +history.delete.confirm.message=Excluir "{0}" do histórico local? +history.delete.confirm.message.multiple=Excluir {0} sessões do histórico local? +history.rename.title=Renomear sessão +history.rename.prompt=Novo nome da sessão: +history.cloud.load.more=Carregar mais +history.cloud.repo.only=Somente este repositório +history.group.today=Hoje +history.group.yesterday=Ontem +history.group.week=Esta semana +history.group.month=Este mês +history.group.older=Mais antigo +history.time.moments=Agora mesmo +history.time.minutes=Há {0} min +history.time.hours=Há {0} h +history.time.days=Há {0} d +history.time.months=Há {0} mês(es) +history.time.years=Há {0} ano(s) +history.error.local=Falha ao carregar o histórico local +history.error.cloud=Falha ao carregar o histórico na nuvem +history.error.local.delete=Falha ao excluir a sessão +history.error.local.rename=Falha ao renomear a sessão +history.error.cloud.delete=As sessões na nuvem ainda não podem ser excluídas + +action.Kilo.Settings.text=Configurações +action.Kilo.Settings.description=Configurações do Kilo Code +action.Kilo.NewSession.text=Nova sessão +action.Kilo.NewSession.description=Iniciar uma nova sessão do Kilo +action.Kilo.History.text=Histórico +action.Kilo.History.description=Exibir histórico de sessões +action.Kilo.SendPrompt.text=Enviar prompt +action.Kilo.SendPrompt.description=Enviar o prompt atual do Kilo +action.Kilo.StopSession.text=Parar sessão +action.Kilo.StopSession.description=Parar a sessão atual do Kilo +action.Kilo.SettingsGroup.text=Configurações +action.Kilo.SettingsGroup.description=Configurações do Kilo Code +action.Kilo.Restart.text=Reiniciar Kilo +action.Kilo.Restart.description=Encerrar e reiniciar o processo CLI +action.Kilo.Reinstall.text=Reinstalar Kilo +action.Kilo.Reinstall.description=Reextrair o binário CLI e reiniciar +action.Kilo.Session.Open.text=Abrir +action.Kilo.Session.Open.description=Abrir a sessão selecionada +action.Kilo.Session.Rename.text=Renomear +action.Kilo.Session.Rename.description=Renomear a sessão selecionada +action.Kilo.Session.Delete.text=Excluir +action.Kilo.Session.Delete.description=Excluir a(s) sessão(es) selecionada(s) +action.Kilo.History.ContextMenu.text=Ações do histórico diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties new file mode 100644 index 00000000000..e6bdb4d1c62 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -0,0 +1,139 @@ +session.connection.connecting=Загрузка… +session.connection.error.app=Ошибка подключения +session.connection.error.workspace=Ошибка загрузки рабочей области +session.connection.error.unknown=Неизвестная ошибка +session.connection.retry=Повторить +session.connection.warning.config=Предупреждения конфигурации + +session.empty.welcome=Kilo Code — это AI-ассистент по программированию. Попросите его разработать функции, исправить ошибки или объяснить кодовую базу. +session.empty.loading=Загрузка… +session.empty.recent=НЕДАВНИЕ +session.showHistory=Показать историю +session.scroll.bottom=Прокрутить вниз +session.tab.new=Новая сессия +session.tab.untitled=Незаголовок сессия + +session.permission.title=Запрос разрешения +session.permission.meta=Инструмент: {0} • Шаблоны: {1} +session.permission.allow=Разрешить +session.permission.deny=Отказать +session.question.dismiss=Отклонить + +session.status.considering=Обдумываю следующие шаги… +session.status.thinking=Размышляю… +session.status.writing=Пишу ответ… +session.status.delegating=Передаю задание… +session.status.planning=Планирую… +session.status.gathering=Сбор контекста… +session.status.searching.codebase=Поиск в кодовой базе… +session.status.searching.web=Поиск в интернете… +session.status.editing=Вношу изменения… +session.status.commands=Выполняю команды… + +session.part.reasoning=Рассуждение +session.part.compaction=контекст сжат +session.part.tool.copy=Копировать +session.part.tool.error=Ошибка +session.part.tool.pending=Ожидание +session.part.tool.read=Чтение +session.part.tool.running=Выполняется +session.part.tool.shell=Shell +session.part.tool.truncated=Вывод усечён в предпросмотре. Полный вывод сохраняется в данных сессии. + +session.error.prompt=Ошибка запроса +session.error.compact=Ошибка сжатия сессии +session.error.unknown=Неизвестная ошибка + +session.header.tokens=Токены +session.header.tokens.description=Токены, использованные последним ответом ассистента: входные, исходные, запись в кэш и чтение из кэша. +session.header.input=вход {0} +session.header.output=выход {0} +session.header.cache.write=запись в кэш {0} +session.header.cache.read=чтение из кэша {0} +session.header.compact=Сжать сессию +session.header.compact.description=Суммировать сессию для уменьшения размера контекста +session.header.expand=Показать метрики сессии +session.header.collapse=Скрыть метрики сессии +session.header.todos.progress={0}/{1} задач выполнено +session.header.todos.done=Все {0} задач выполнены +session.header.context.tooltip.percent={0} токенов ({1}% контекста) +session.header.context.tooltip.tokens={0} токенов +session.header.context.used={0} / {1} токенов использовано +session.header.context.reserved={0} зарезервировано для вывода +session.header.context.available={0} доступно + +prompt.placeholder=Введите сообщение… +prompt.placeholder.with.shortcuts=Введите сообщение… ({0} — отправить, {1} — новая строка) +prompt.placeholder.with.send=Введите сообщение… ({0} — отправить) +prompt.placeholder.with.newline=Введите сообщение… ({0} — новая строка) +prompt.button.send=Отправить +prompt.button.stop=Остановить +prompt.button.send.tooltip.stop=Для остановки нажмите {0} +mode.picker.tooltip=Выбрать режим +mode.picker.deprecated=устаревший +model.picker.tooltip=Выбрать модель +model.picker.recommended=Рекомендуемые +model.picker.favorites=Избранное +model.picker.search=Поиск моделей +model.picker.no.matches=Нет подходящих моделей +model.picker.favorite.add=Добавить в избранное +model.picker.favorite.remove=Удалить из избранного +model.picker.free=Бесплатно +model.picker.reset=Сбросить модель на по умолчанию +reasoning.picker.tooltip=Выбрать уровень рассуждения + +history.tab.local=Локальная +history.tab.cloud=Облако +history.back=Назад +history.search.placeholder=Поиск сессий +history.empty=Нет сессий +history.loading=Загрузка… +history.untitled=Без названия +history.delete.text=Удалить +history.delete.confirm.title=Удалить сессию? +history.delete.confirm.message=Удалить «{0}» из локальной истории? +history.delete.confirm.message.multiple=Удалить {0} сессий из локальной истории? +history.rename.title=Переименовать сессию +history.rename.prompt=Новое имя сессии: +history.cloud.load.more=Загрузить ещё +history.cloud.repo.only=Только этот репозиторий +history.group.today=Сегодня +history.group.yesterday=Вчера +history.group.week=На этой неделе +history.group.month=В этом месяце +history.group.older=Старше +history.time.moments=Только что +history.time.minutes={0} мин назад +history.time.hours={0} ч назад +history.time.days={0} д назад +history.time.months={0} мес назад +history.time.years={0} л назад +history.error.local=Ошибка загрузки локальной истории +history.error.cloud=Ошибка загрузки облачной истории +history.error.local.delete=Ошибка удаления сессии +history.error.local.rename=Ошибка переименования сессии +history.error.cloud.delete=Облачные сессии пока нельзя удалить + +action.Kilo.Settings.text=Настройки +action.Kilo.Settings.description=Настройки Kilo Code +action.Kilo.NewSession.text=Новая сессия +action.Kilo.NewSession.description=Запустить новую сессию Kilo +action.Kilo.History.text=История +action.Kilo.History.description=Показать историю сессий +action.Kilo.SendPrompt.text=Отправить запрос +action.Kilo.SendPrompt.description=Отправить текущий запрос Kilo +action.Kilo.StopSession.text=Остановить сессию +action.Kilo.StopSession.description=Остановить текущую сессию Kilo +action.Kilo.SettingsGroup.text=Настройки +action.Kilo.SettingsGroup.description=Настройки Kilo Code +action.Kilo.Restart.text=Перезапустить Kilo +action.Kilo.Restart.description=Завершить и перезапустить CLI-процесс +action.Kilo.Reinstall.text=Переустановить Kilo +action.Kilo.Reinstall.description=Переизвлечь CLI-бинарник и перезапустить +action.Kilo.Session.Open.text=Открыть +action.Kilo.Session.Open.description=Открыть выбранную сессию +action.Kilo.Session.Rename.text=Переименовать +action.Kilo.Session.Rename.description=Переименовать выбранную сессию +action.Kilo.Session.Delete.text=Удалить +action.Kilo.Session.Delete.description=Удалить выбранную(-ые) сессию(-ии) +action.Kilo.History.ContextMenu.text=Действия с историей diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties new file mode 100644 index 00000000000..049b1e1d1ab --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -0,0 +1,139 @@ +session.connection.connecting=กำลังโหลด… +session.connection.error.app=การเชื่อมต่อล้มเหลว +session.connection.error.workspace=โหลดพื้นที่ทำงานล้มเหลว +session.connection.error.unknown=ข้อผิดพลาดที่ไม่ทราบ +session.connection.retry=ลองอีกครั้ง +session.connection.warning.config=คำเตือนการกำหนดค่า + +session.empty.welcome=Kilo Code คือผู้ช่วยเขียนโค้ด AI ขอให้สร้างฟีเจอร์แก้สันข้อผิดพลาด หรืออธิบายโค้ดเบสของคุณ +session.empty.loading=กำลังโหลด… +session.empty.recent=ล่าสุด +session.showHistory=แสดงประวัติ +session.scroll.bottom=เลื่อนไปด้านล่าง +session.tab.new=เซสชันใหม่ +session.tab.untitled=เซสชันไม่มีชื่อ + +session.permission.title=ขอสิทธิ์ +session.permission.meta=เครื่องมือ: {0} • รูปแบบ: {1} +session.permission.allow=อนุญาต +session.permission.deny=ปฏิเสธ +session.question.dismiss=ปิด + +session.status.considering=กำลังพิจารณาขั้นตอนถัดไป… +session.status.thinking=กำลังคิด… +session.status.writing=กำลังเขียนคำตอบ… +session.status.delegating=กำลังมอบหมายงาน… +session.status.planning=กำลังวางแผน… +session.status.gathering=กำลังรวบรวมบริบท… +session.status.searching.codebase=กำลังค้นหาในโค้ดเบส… +session.status.searching.web=กำลังค้นหาบนเว็บ… +session.status.editing=กำลังแก้ไข… +session.status.commands=กำลังเรียกใช้คำสั่ง… + +session.part.reasoning=การใช้เหตุผล +session.part.compaction=บริบทถูกบีบอัดแล้ว +session.part.tool.copy=คัดลอก +session.part.tool.error=ข้อผิดพลาด +session.part.tool.pending=รอดำเนินการ +session.part.tool.read=อ่าน +session.part.tool.running=กำลังทำงาน +session.part.tool.shell=Shell +session.part.tool.truncated=ผลลัพธ์ถูกตัดทอนในส่วนตัวอย่าง ผลลัพธ์ทั้งหมดยังคงอยู่ในข้อมูลเซสชัน + +session.error.prompt=คำขอล้มเหลว +session.error.compact=การบีบอัดเซสชันล้มเหลว +session.error.unknown=ข้อผิดพลาดที่ไม่ทราบ + +session.header.tokens=โทเคน +session.header.tokens.description=โทเคนที่ใช้โดยคำตอบล่าสุดของผู้ช่วย: อินพุต เอาต์พุต การเขียนแคช และการอ่านแคช +session.header.input=อินพุต {0} +session.header.output=เอาต์พุต {0} +session.header.cache.write=เขียนแคช {0} +session.header.cache.read=อ่านแคช {0} +session.header.compact=บีบอัดเซสชัน +session.header.compact.description=สรุปเซสชันเพื่อลดขนาดบริบท +session.header.expand=แสดงเมตริกส์เซสชัน +session.header.collapse=ซ่อนเมตริกส์เซสชัน +session.header.todos.progress={0}/{1} งานเสร็จสิ้นแล้ว +session.header.todos.done=ครบทุก {0} งานเสร็จสิ้นแล้ว +session.header.context.tooltip.percent={0} โทเคน ({1}% ของบริบท) +session.header.context.tooltip.tokens={0} โทเคน +session.header.context.used=ใช้แล้ว {0} / {1} โทเคน +session.header.context.reserved={0} สำรองไว้สำหรับเอาต์พุต +session.header.context.available={0} พร้อมใช้งาน + +prompt.placeholder=พิมพ์ข้อความ… +prompt.placeholder.with.shortcuts=พิมพ์ข้อความ… ({0} เพื่อส่ง {1} สำหรับบรรทัดใหม่) +prompt.placeholder.with.send=พิมพ์ข้อความ… ({0} เพื่อส่ง) +prompt.placeholder.with.newline=พิมพ์ข้อความ… ({0} สำหรับบรรทัดใหม่) +prompt.button.send=ส่ง +prompt.button.stop=หยุด +prompt.button.send.tooltip.stop=กด {0} เพื่อหยุด +mode.picker.tooltip=เลือกโหมด +mode.picker.deprecated=ล้าสมัย +model.picker.tooltip=เลือกโมเดล +model.picker.recommended=แนะนำ +model.picker.favorites=รายการโปรด +model.picker.search=ค้นหาโมเดล +model.picker.no.matches=ไม่พบโมเดลที่ตรงกัน +model.picker.favorite.add=เพิ่มในรายการโปรด +model.picker.favorite.remove=นำออกจากรายการโปรด +model.picker.free=ฟรี +model.picker.reset=รีเซ็ตโมเดลเป็นค่าเริ่มต้น +reasoning.picker.tooltip=เลือกระดับการใช้เหตุผล + +history.tab.local=เครื่องนี้ +history.tab.cloud=คลาวด์ +history.back=ย้อนกลับ +history.search.placeholder=ค้นหาเซสชัน +history.empty=ไม่มีเซสชัน +history.loading=กำลังโหลด… +history.untitled=ไม่มีชื่อ +history.delete.text=ลบ +history.delete.confirm.title=ลบเซสชัน? +history.delete.confirm.message=ลบ "{0}" ออกจากประวัติในเครื่อง? +history.delete.confirm.message.multiple=ลบ {0} เซสชันออกจากประวัติในเครื่อง? +history.rename.title=เปลี่ยนชื่อเซสชัน +history.rename.prompt=ชื่อเซสชันใหม่: +history.cloud.load.more=โหลดเพิ่มเติม +history.cloud.repo.only=เฉพาะรีโปซิทอรีนี้ +history.group.today=วันนี้ +history.group.yesterday=เมื่อวาน +history.group.week=สัปดาห์นี้ +history.group.month=เดือนนี้ +history.group.older=เก่ากว่า +history.time.moments=เมื่อกี้นี้ +history.time.minutes={0} นาทีที่แล้ว +history.time.hours={0} ชั่วโมงที่แล้ว +history.time.days={0} วันที่แล้ว +history.time.months={0} เดือนที่แล้ว +history.time.years={0} ปีที่แล้ว +history.error.local=ไม่สามารถโหลดประวัติในเครื่อง +history.error.cloud=ไม่สามารถโหลดประวัติคลาวด์ +history.error.local.delete=ไม่สามารถลบเซสชัน +history.error.local.rename=ไม่สามารถเปลี่ยนชื่อเซสชัน +history.error.cloud.delete=ยังไม่สามารถลบเซสชันคลาวด์ได้ + +action.Kilo.Settings.text=การตั้งค่า +action.Kilo.Settings.description=การตั้งค่า Kilo Code +action.Kilo.NewSession.text=เซสชันใหม่ +action.Kilo.NewSession.description=เริ่มเซสชัน Kilo ใหม่ +action.Kilo.History.text=ประวัติ +action.Kilo.History.description=แสดงประวัติเซสชัน +action.Kilo.SendPrompt.text=ส่งคำขอ +action.Kilo.SendPrompt.description=ส่งคำขอ Kilo ปัจจุบัน +action.Kilo.StopSession.text=หยุดเซสชัน +action.Kilo.StopSession.description=หยุดเซสชัน Kilo ปัจจุบัน +action.Kilo.SettingsGroup.text=การตั้งค่า +action.Kilo.SettingsGroup.description=การตั้งค่า Kilo Code +action.Kilo.Restart.text=รีสตาร์ต Kilo +action.Kilo.Restart.description=สิ้นสุดและรีสตาร์ตกระบวนการ CLI +action.Kilo.Reinstall.text=ติดตั้ง Kilo ใหม่ +action.Kilo.Reinstall.description=แตกไฟล์ไบนารี CLI อีกครั้งและรีสตาร์ต +action.Kilo.Session.Open.text=เปิด +action.Kilo.Session.Open.description=เปิดเซสชันที่เลือก +action.Kilo.Session.Rename.text=เปลี่ยนชื่อ +action.Kilo.Session.Rename.description=เปลี่ยนชื่อเซสชันที่เลือก +action.Kilo.Session.Delete.text=ลบ +action.Kilo.Session.Delete.description=ลบเซสชันที่เลือก +action.Kilo.History.ContextMenu.text=การดำเนินการประวัติ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties new file mode 100644 index 00000000000..096fcb1ee94 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -0,0 +1,139 @@ +session.connection.connecting=Yükleniyor… +session.connection.error.app=Bağlantı hatası +session.connection.error.workspace=Çalışma alanı yüklenemedi +session.connection.error.unknown=Bilinmeyen hata +session.connection.retry=Tekrar dene +session.connection.warning.config=Yapılandırma uyarıları + +session.empty.welcome=Kilo Code, bir yapay zeka kodlama asistanıdır. Özellik oluşturmasını, hata düzeltirlmesi veya kod tabanınızı açıklamasını isteyin. +session.empty.loading=Yükleniyor… +session.empty.recent=SON +session.showHistory=Geçmişi göster +session.scroll.bottom=En alta kaýr +session.tab.new=Yeni oturum +session.tab.untitled=Başlıksız oturum + +session.permission.title=İzin isteği +session.permission.meta=Araç: {0} • Desenler: {1} +session.permission.allow=İzin ver +session.permission.deny=Reddet +session.question.dismiss=Kapat + +session.status.considering=Sonraki adımlar düşünülüyor… +session.status.thinking=Düşünülüyor… +session.status.writing=Yanıt yazılıyor… +session.status.delegating=İş devrediliyor… +session.status.planning=Plan hazırlanıyor… +session.status.gathering=Bağlam toplanıyor… +session.status.searching.codebase=Kod tabanı aranıyor… +session.status.searching.web=Web aranıyor… +session.status.editing=Düzenleme yapılıyor… +session.status.commands=Komutlar çalıştırılıyor… + +session.part.reasoning=Muhakeme +session.part.compaction=bağlam sıkıştırıldı +session.part.tool.copy=Kopyala +session.part.tool.error=Hata +session.part.tool.pending=Bekliyor +session.part.tool.read=Oku +session.part.tool.running=Çalışıyor +session.part.tool.shell=Kabuk +session.part.tool.truncated=Önizlemede çıktı kısaltıldı. Tam çıktı oturum verilerinde kalıyor. + +session.error.prompt=İstem hatası +session.error.compact=Oturum sıkıştırma hatası +session.error.unknown=Bilinmeyen hata + +session.header.tokens=Jeton +session.header.tokens.description=Son asistan yanıtında kullanılan jetonlar: giriş, çıkış, önbelleğe yazma ve önbellekten okuma. +session.header.input=giriş {0} +session.header.output=çıkış {0} +session.header.cache.write=önbelleğe yazma {0} +session.header.cache.read=önbellekten okuma {0} +session.header.compact=Oturumu sıkıştır +session.header.compact.description=Bağlam boyutunu azaltmak için oturumu özetle +session.header.expand=Oturum metriklerini göster +session.header.collapse=Oturum metriklerini gizle +session.header.todos.progress={0}/{1} görev tamamlandı +session.header.todos.done=Tüm {0} görev tamamlandı +session.header.context.tooltip.percent={0} jeton (bağlamın %{1}’i) +session.header.context.tooltip.tokens={0} jeton +session.header.context.used={0} / {1} jeton kullanıldı +session.header.context.reserved={0} çıkış için ayrıldı +session.header.context.available={0} kullanılabilir + +prompt.placeholder=Bir mesaj yazın… +prompt.placeholder.with.shortcuts=Bir mesaj yazın… ({0} göndermek için, {1} yeni satır için) +prompt.placeholder.with.send=Bir mesaj yazın… ({0} göndermek için) +prompt.placeholder.with.newline=Bir mesaj yazın… ({0} yeni satır için) +prompt.button.send=Gönder +prompt.button.stop=Durdur +prompt.button.send.tooltip.stop=Durdurmak için {0} basın +mode.picker.tooltip=Mod seç +mode.picker.deprecated=geçersiz +model.picker.tooltip=Model seç +model.picker.recommended=Önerilen +model.picker.favorites=Favoriler +model.picker.search=Model ara +model.picker.no.matches=Eşleşen model yok +model.picker.favorite.add=Favorilere ekle +model.picker.favorite.remove=Favorilerden çıkar +model.picker.free=Ücretsiz +model.picker.reset=Modeli varsayılana sıfırla +reasoning.picker.tooltip=Muhakeme çabası seç + +history.tab.local=Yerel +history.tab.cloud=Bulut +history.back=Geri +history.search.placeholder=Oturum ara +history.empty=Oturum yok +history.loading=Yükleniyor… +history.untitled=Başlıksız +history.delete.text=Sil +history.delete.confirm.title=Oturum silinsin mi? +history.delete.confirm.message="{0}" yerel geçmişten silinsin mi? +history.delete.confirm.message.multiple={0} oturum yerel geçmişten silinsin mi? +history.rename.title=Oturumu yeniden adlandır +history.rename.prompt=Yeni oturum adı: +history.cloud.load.more=Daha fazla yükle +history.cloud.repo.only=Yalnızca bu depo +history.group.today=Bugün +history.group.yesterday=Dün +history.group.week=Bu hafta +history.group.month=Bu ay +history.group.older=Daha eski +history.time.moments=Az önce +history.time.minutes={0} dk önce +history.time.hours={0} sa önce +history.time.days={0} g önce +history.time.months={0} ay önce +history.time.years={0} yıl önce +history.error.local=Yerel geçmiş yüklenemedi +history.error.cloud=Bulut geçmişi yüklenemedi +history.error.local.delete=Oturum silinemedi +history.error.local.rename=Oturum yeniden adlandırılamadı +history.error.cloud.delete=Bulut oturumları henüz silinemiyor + +action.Kilo.Settings.text=Ayarlar +action.Kilo.Settings.description=Kilo Code ayarları +action.Kilo.NewSession.text=Yeni oturum +action.Kilo.NewSession.description=Yeni bir Kilo oturumu başlat +action.Kilo.History.text=Geçmiş +action.Kilo.History.description=Oturum geçmişini göster +action.Kilo.SendPrompt.text=İstem gönder +action.Kilo.SendPrompt.description=Geçerli Kilo istemini gönder +action.Kilo.StopSession.text=Oturumu durdur +action.Kilo.StopSession.description=Geçerli Kilo oturumunu durdur +action.Kilo.SettingsGroup.text=Ayarlar +action.Kilo.SettingsGroup.description=Kilo Code ayarları +action.Kilo.Restart.text=Kilo’yu yeniden başlat +action.Kilo.Restart.description=CLI sürecini sonlandır ve yeniden başlat +action.Kilo.Reinstall.text=Kilo’yu yeniden yükle +action.Kilo.Reinstall.description=CLI ikili dosyasını yeniden çıkar ve yeniden başlat +action.Kilo.Session.Open.text=Aç +action.Kilo.Session.Open.description=Seçilen oturumu aç +action.Kilo.Session.Rename.text=Yeniden adlandır +action.Kilo.Session.Rename.description=Seçilen oturumu yeniden adlandır +action.Kilo.Session.Delete.text=Sil +action.Kilo.Session.Delete.description=Seçilen oturumları sil +action.Kilo.History.ContextMenu.text=Geçmiş eylemleri diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties new file mode 100644 index 00000000000..73d770b3c05 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -0,0 +1,139 @@ +session.connection.connecting=Завантаження… +session.connection.error.app=Помилка з'єднання +session.connection.error.workspace=Помилка завантаження робочого простору +session.connection.error.unknown=Невідома помилка +session.connection.retry=Спробувати знову +session.connection.warning.config=Попередження конфігурації + +session.empty.welcome=Kilo Code — це AI-асистент для програмування. Попросіть його створити функції, виправити помилки або пояснити ваш код. +session.empty.loading=Завантаження… +session.empty.recent=НЕДАВНІ +session.showHistory=Показати історію +session.scroll.bottom=Прокрутити донизу +session.tab.new=Нова сесія +session.tab.untitled=Сесія без назви + +session.permission.title=Запит дозволу +session.permission.meta=Інструмент: {0} • Шаблони: {1} +session.permission.allow=Дозволити +session.permission.deny=Заборонити +session.question.dismiss=Закрити + +session.status.considering=Обмірковую наступні кроки… +session.status.thinking=Міркую… +session.status.writing=Пишу відповідь… +session.status.delegating=Делегую роботу… +session.status.planning=Планую… +session.status.gathering=Збираю контекст… +session.status.searching.codebase=Шукаю в кодовій базі… +session.status.searching.web=Шукаю в інтернеті… +session.status.editing=Вношу зміни… +session.status.commands=Виконую команди… + +session.part.reasoning=Міркування +session.part.compaction=контекст стиснуто +session.part.tool.copy=Копіювати +session.part.tool.error=Помилка +session.part.tool.pending=Очікується +session.part.tool.read=Читання +session.part.tool.running=Виконується +session.part.tool.shell=Shell +session.part.tool.truncated=Вивід у попередньому перегляді усічено. Повний вивід зберігається в даних сесії. + +session.error.prompt=Помилка запиту +session.error.compact=Помилка стиснення сесії +session.error.unknown=Невідома помилка + +session.header.tokens=Токени +session.header.tokens.description=Токени, використані останній відповід䍎ю асистента: вхід, вихід, запис у кеш і читання з кешу. +session.header.input=вхід {0} +session.header.output=вихід {0} +session.header.cache.write=запис у кеш {0} +session.header.cache.read=читання з кешу {0} +session.header.compact=Стиснути сесію +session.header.compact.description=Стиснути сесію, щоб зменшити розмір контексту +session.header.expand=Показати метрики сесії +session.header.collapse=Приховати метрики сесії +session.header.todos.progress={0}/{1} завдань виконано +session.header.todos.done=Усі {0} завдань виконано +session.header.context.tooltip.percent={0} токенів ({1}% контексту) +session.header.context.tooltip.tokens={0} токенів +session.header.context.used={0} / {1} токенів використано +session.header.context.reserved={0} зарезервовано для виходу +session.header.context.available={0} доступно + +prompt.placeholder=Введіть повідомлення… +prompt.placeholder.with.shortcuts=Введіть повідомлення… ({0} — надіслати, {1} — новий рядок) +prompt.placeholder.with.send=Введіть повідомлення… ({0} — надіслати) +prompt.placeholder.with.newline=Введіть повідомлення… ({0} — новий рядок) +prompt.button.send=Надіслати +prompt.button.stop=Зупинити +prompt.button.send.tooltip.stop=Щоб зупинити, натисніть {0} +mode.picker.tooltip=Обрати режим +mode.picker.deprecated=застарілий +model.picker.tooltip=Обрати модель +model.picker.recommended=Рекомендовані +model.picker.favorites=Улюблені +model.picker.search=Пошук моделей +model.picker.no.matches=Немає відповідних моделей +model.picker.favorite.add=Додати до улюблених +model.picker.favorite.remove=Видалити з улюблених +model.picker.free=Безкоштовно +model.picker.reset=Скинути модель до стандартної +reasoning.picker.tooltip=Обрати рівень міркування + +history.tab.local=Локальна +history.tab.cloud=Хмара +history.back=Назад +history.search.placeholder=Пошук сесій +history.empty=Немає сесій +history.loading=Завантаження… +history.untitled=Без назви +history.delete.text=Видалити +history.delete.confirm.title=Видалити сесію? +history.delete.confirm.message=Видалити «{0}» з локальної історії? +history.delete.confirm.message.multiple=Видалити {0} сесій з локальної історії? +history.rename.title=Перейменувати сесію +history.rename.prompt=Нова назва сесії: +history.cloud.load.more=Завантажити більше +history.cloud.repo.only=Тільки цей репозиторій +history.group.today=Сьогодні +history.group.yesterday=Вчора +history.group.week=Цього тижня +history.group.month=Цього місяця +history.group.older=Старіше +history.time.moments=Щойно +history.time.minutes={0} хв тому +history.time.hours={0} год тому +history.time.days={0} дн тому +history.time.months={0} міс тому +history.time.years={0} р тому +history.error.local=Помилка завантаження локальної історії +history.error.cloud=Помилка завантаження хмарної історії +history.error.local.delete=Помилка видалення сесії +history.error.local.rename=Помилка перейменування сесії +history.error.cloud.delete=Хмарні сесії поки не можна видалити + +action.Kilo.Settings.text=Налаштування +action.Kilo.Settings.description=Налаштування Kilo Code +action.Kilo.NewSession.text=Нова сесія +action.Kilo.NewSession.description=Розпочати нову сесію Kilo +action.Kilo.History.text=Історія +action.Kilo.History.description=Показати історію сесій +action.Kilo.SendPrompt.text=Надіслати запит +action.Kilo.SendPrompt.description=Надіслати поточний запит Kilo +action.Kilo.StopSession.text=Зупинити сесію +action.Kilo.StopSession.description=Зупинити поточну сесію Kilo +action.Kilo.SettingsGroup.text=Налаштування +action.Kilo.SettingsGroup.description=Налаштування Kilo Code +action.Kilo.Restart.text=Перезапустити Kilo +action.Kilo.Restart.description=Завершити і перезапустити CLI-процес +action.Kilo.Reinstall.text=Перевстановити Kilo +action.Kilo.Reinstall.description=Перерозпакувати CLI-бінарний файл і перезапустити +action.Kilo.Session.Open.text=Відкрити +action.Kilo.Session.Open.description=Відкрити вибрану сесію +action.Kilo.Session.Rename.text=Перейменувати +action.Kilo.Session.Rename.description=Перейменувати вибрану сесію +action.Kilo.Session.Delete.text=Видалити +action.Kilo.Session.Delete.description=Видалити вибрану(-і) сесію(-ї) +action.Kilo.History.ContextMenu.text=Дії з історією diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties new file mode 100644 index 00000000000..2288060553f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -0,0 +1,139 @@ +session.connection.connecting=加载中… +session.connection.error.app=连接失败 +session.connection.error.workspace=工作区加载失败 +session.connection.error.unknown=未知错误 +session.connection.retry=重试 +session.connection.warning.config=配置警告 + +session.empty.welcome=Kilo Code 是一个 AI 编程助手。可请它构建功能、修复错误或解释您的代码库。 +session.empty.loading=加载中… +session.empty.recent=最近 +session.showHistory=显示历史 +session.scroll.bottom=滚动到底部 +session.tab.new=新建会话 +session.tab.untitled=无标题会话 + +session.permission.title=权限请求 +session.permission.meta=工具:{0} • 模式:{1} +session.permission.allow=允许 +session.permission.deny=拒绝 +session.question.dismiss=关闭 + +session.status.considering=正在考虑下一步… +session.status.thinking=思考中… +session.status.writing=正在编写回复… +session.status.delegating=正在委派工作… +session.status.planning=规划中… +session.status.gathering=收集上下文… +session.status.searching.codebase=搜索代码库… +session.status.searching.web=搜索网页… +session.status.editing=正在编辑… +session.status.commands=运行命令… + +session.part.reasoning=推理 +session.part.compaction=上下文已压缩 +session.part.tool.copy=复制 +session.part.tool.error=错误 +session.part.tool.pending=待处理 +session.part.tool.read=读取 +session.part.tool.running=运行中 +session.part.tool.shell=Shell +session.part.tool.truncated=预览中的输出已截断。完整输出仍保留在会话数据中。 + +session.error.prompt=提示失败 +session.error.compact=会话压缩失败 +session.error.unknown=未知错误 + +session.header.tokens=令牌 +session.header.tokens.description=最近一次助手回复中使用的令牌:输入、输出、缓存写入和缓存读取。 +session.header.input=输入 {0} +session.header.output=输出 {0} +session.header.cache.write=缓存写入 {0} +session.header.cache.read=缓存读取 {0} +session.header.compact=压缩会话 +session.header.compact.description=汇总会话以减少上下文大小 +session.header.expand=显示会话指标 +session.header.collapse=隐藏会话指标 +session.header.todos.progress={0}/{1} 个任务已完成 +session.header.todos.done=全部 {0} 个任务已完成 +session.header.context.tooltip.percent={0} 令牌(上下文的 {1}%) +session.header.context.tooltip.tokens={0} 令牌 +session.header.context.used=已使用 {0} / {1} 令牌 +session.header.context.reserved={0} 为输出预留 +session.header.context.available={0} 可用 + +prompt.placeholder=输入消息… +prompt.placeholder.with.shortcuts=输入消息…({0} 发送,{1} 换行) +prompt.placeholder.with.send=输入消息…({0} 发送) +prompt.placeholder.with.newline=输入消息…({0} 换行) +prompt.button.send=发送 +prompt.button.stop=停止 +prompt.button.send.tooltip.stop=按 {0} 停止 +mode.picker.tooltip=选择模式 +mode.picker.deprecated=已弃用 +model.picker.tooltip=选择模型 +model.picker.recommended=推荐 +model.picker.favorites=收藏夹 +model.picker.search=搜索模型 +model.picker.no.matches=无匹配模型 +model.picker.favorite.add=添加到收藏夹 +model.picker.favorite.remove=从收藏夹移除 +model.picker.free=免费 +model.picker.reset=重置模型为默认 +reasoning.picker.tooltip=选择推理力度 + +history.tab.local=本地 +history.tab.cloud=云端 +history.back=返回 +history.search.placeholder=搜索会话 +history.empty=暂无会话 +history.loading=加载中… +history.untitled=无标题 +history.delete.text=删除 +history.delete.confirm.title=删除会话? +history.delete.confirm.message=从本地历史中删除“{0}”? +history.delete.confirm.message.multiple=从本地历史中删除 {0} 个会话? +history.rename.title=重命名会话 +history.rename.prompt=新会话名称: +history.cloud.load.more=加载更多 +history.cloud.repo.only=仅此仓库 +history.group.today=今天 +history.group.yesterday=昨天 +history.group.week=本周 +history.group.month=本月 +history.group.older=更早以前 +history.time.moments=刚刚 +history.time.minutes={0} 分钟前 +history.time.hours={0} 小时前 +history.time.days={0} 天前 +history.time.months={0} 个月前 +history.time.years={0} 年前 +history.error.local=无法加载本地历史 +history.error.cloud=无法加载云端历史 +history.error.local.delete=删除会话失败 +history.error.local.rename=重命名会话失败 +history.error.cloud.delete=云端会话暂无法删除 + +action.Kilo.Settings.text=设置 +action.Kilo.Settings.description=Kilo Code 设置 +action.Kilo.NewSession.text=新建会话 +action.Kilo.NewSession.description=开始新的 Kilo 会话 +action.Kilo.History.text=历史 +action.Kilo.History.description=显示会话历史 +action.Kilo.SendPrompt.text=发送提示 +action.Kilo.SendPrompt.description=发送当前 Kilo 提示 +action.Kilo.StopSession.text=停止会话 +action.Kilo.StopSession.description=停止当前 Kilo 会话 +action.Kilo.SettingsGroup.text=设置 +action.Kilo.SettingsGroup.description=Kilo Code 设置 +action.Kilo.Restart.text=重启 Kilo +action.Kilo.Restart.description=终止并重启 CLI 进程 +action.Kilo.Reinstall.text=重新安装 Kilo +action.Kilo.Reinstall.description=重新解压 CLI 二进制文件并重启 +action.Kilo.Session.Open.text=打开 +action.Kilo.Session.Open.description=打开所选会话 +action.Kilo.Session.Rename.text=重命名 +action.Kilo.Session.Rename.description=重命名所选会话 +action.Kilo.Session.Delete.text=删除 +action.Kilo.Session.Delete.description=删除所选会话 +action.Kilo.History.ContextMenu.text=历史操作 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties new file mode 100644 index 00000000000..40fb78179b3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -0,0 +1,139 @@ +session.connection.connecting=載入中… +session.connection.error.app=連線失敗 +session.connection.error.workspace=工作區載入失敗 +session.connection.error.unknown=未知預設 +session.connection.retry=重試 +session.connection.warning.config=設定警告 + +session.empty.welcome=Kilo Code 是 AI 程式輔助。可請它建置功能、修復錯誤或解釋您的程式程式庫。 +session.empty.loading=載入中… +session.empty.recent=最近 +session.showHistory=顯示歷史 +session.scroll.bottom=滾動到底部 +session.tab.new=新建工作階段 +session.tab.untitled=未命名的工作階段 + +session.permission.title=權限請求 +session.permission.meta=工具:{0} • 樣式:{1} +session.permission.allow=允許 +session.permission.deny=拒絕 +session.question.dismiss=關閉 + +session.status.considering=考慮下一步… +session.status.thinking=思考中… +session.status.writing=正在撰寫回覆… +session.status.delegating=正在委派工作… +session.status.planning=計劃中… +session.status.gathering=收集上下文… +session.status.searching.codebase=搜尋程式程式庫… +session.status.searching.web=搜尋網頁… +session.status.editing=進行編輯… +session.status.commands=執行指令… + +session.part.reasoning=推理 +session.part.compaction=上下文已壓縮 +session.part.tool.copy=複製 +session.part.tool.error=錯誤 +session.part.tool.pending=待處理 +session.part.tool.read=讀取 +session.part.tool.running=執行中 +session.part.tool.shell=Shell +session.part.tool.truncated=預覽中的輸出已截斷。完整輸出仍保留在工作階段資料中。 + +session.error.prompt=提示失敗 +session.error.compact=工作階段壓縮失敗 +session.error.unknown=未知錯誤 + +session.header.tokens=記號 +session.header.tokens.description=最新助手回覆使用的記號:輸入、輸出、快取寫入和快取讀取。 +session.header.input=輸入 {0} +session.header.output=輸出 {0} +session.header.cache.write=快取寫入 {0} +session.header.cache.read=快取讀取 {0} +session.header.compact=壓縮工作階段 +session.header.compact.description=摘要工作階段以減少上下文大小 +session.header.expand=顯示工作階段度量 +session.header.collapse=隱藏工作階段度量 +session.header.todos.progress={0}/{1} 個待辦事項已完成 +session.header.todos.done=全部 {0} 個待辦事項已完成 +session.header.context.tooltip.percent={0} 記號(上下文的 {1}%) +session.header.context.tooltip.tokens={0} 記號 +session.header.context.used=已使用 {0} / {1} 記號 +session.header.context.reserved={0} 為輸出預留 +session.header.context.available={0} 可用 + +prompt.placeholder=輸入訊息… +prompt.placeholder.with.shortcuts=輸入訊息…({0} 發送,{1} 換行) +prompt.placeholder.with.send=輸入訊息…({0} 發送) +prompt.placeholder.with.newline=輸入訊息…({0} 換行) +prompt.button.send=發送 +prompt.button.stop=停止 +prompt.button.send.tooltip.stop=按 {0} 停止 +mode.picker.tooltip=選擇模式 +mode.picker.deprecated=已棄用 +model.picker.tooltip=選擇模型 +model.picker.recommended=推薦 +model.picker.favorites=收藏夾 +model.picker.search=搜尋模型 +model.picker.no.matches=無匹配模型 +model.picker.favorite.add=新增至收藏夾 +model.picker.favorite.remove=從收藏夾移除 +model.picker.free=免費 +model.picker.reset=重設模型為預設 +reasoning.picker.tooltip=選擇推理力度 + +history.tab.local=本機 +history.tab.cloud=雲端 +history.back=返回 +history.search.placeholder=搜尋工作階段 +history.empty=無工作階段 +history.loading=載入中… +history.untitled=未命名 +history.delete.text=刪除 +history.delete.confirm.title=刪除工作階段? +history.delete.confirm.message=從本機歷史中刪除「{0}」? +history.delete.confirm.message.multiple=從本機歷史中刪除 {0} 個工作階段? +history.rename.title=重命名工作階段 +history.rename.prompt=新工作階段名稱: +history.cloud.load.more=載入更多 +history.cloud.repo.only=僅此儲存庫 +history.group.today=今天 +history.group.yesterday=昨天 +history.group.week=本週 +history.group.month=本月 +history.group.older=更早以前 +history.time.moments=剛剛 +history.time.minutes={0} 分鐘前 +history.time.hours={0} 小時前 +history.time.days={0} 天前 +history.time.months={0} 個月前 +history.time.years={0} 年前 +history.error.local=無法載入本機歷史 +history.error.cloud=無法載入雲端歷史 +history.error.local.delete=刪除工作階段失敗 +history.error.local.rename=重命名工作階段失敗 +history.error.cloud.delete=雲端工作階段暫時無法刪除 + +action.Kilo.Settings.text=設定 +action.Kilo.Settings.description=Kilo Code 設定 +action.Kilo.NewSession.text=新建工作階段 +action.Kilo.NewSession.description=開始新的 Kilo 工作階段 +action.Kilo.History.text=歷史 +action.Kilo.History.description=顯示工作階段歷史 +action.Kilo.SendPrompt.text=發送提示 +action.Kilo.SendPrompt.description=發送目前的 Kilo 提示 +action.Kilo.StopSession.text=停止工作階段 +action.Kilo.StopSession.description=停止目前的 Kilo 工作階段 +action.Kilo.SettingsGroup.text=設定 +action.Kilo.SettingsGroup.description=Kilo Code 設定 +action.Kilo.Restart.text=重啟 Kilo +action.Kilo.Restart.description=終止並重啟 CLI 程序 +action.Kilo.Reinstall.text=重新安裝 Kilo +action.Kilo.Reinstall.description=重新解壓縮 CLI 二進位檔案並重啟 +action.Kilo.Session.Open.text=開啟 +action.Kilo.Session.Open.description=開啟所選工作階段 +action.Kilo.Session.Rename.text=重命名 +action.Kilo.Session.Rename.description=重命名所選工作階段 +action.Kilo.Session.Delete.text=刪除 +action.Kilo.Session.Delete.description=刪除所選工作階段 +action.Kilo.History.ContextMenu.text=歷史操作 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt new file mode 100644 index 00000000000..9165e8d50ef --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/HistorySessionActionsTest.kt @@ -0,0 +1,488 @@ +package ai.kilocode.client.actions + +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.SessionManager +import ai.kilocode.client.session.SessionRef +import ai.kilocode.client.session.history.CloudHistoryItem +import ai.kilocode.client.session.history.HistoryController +import ai.kilocode.client.session.history.HistoryDataKeys +import ai.kilocode.client.session.history.HistorySelection +import ai.kilocode.client.session.history.HistorySource +import ai.kilocode.client.session.history.LocalHistoryItem +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.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.CommonDataKeys +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.actionSystem.Presentation +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 kotlinx.coroutines.withTimeout + +@Suppress("UnstableApiUsage") +class HistorySessionActionsTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeSessionRpcApi + private lateinit var sessions: KiloSessionService + private lateinit var workspace: Workspace + private lateinit var controller: HistoryController + private lateinit var manager: FakeManager + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeSessionRpcApi() + sessions = KiloSessionService(project, scope, rpc) + val workspaces = KiloWorkspaceService(scope, FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY) + }) + workspace = workspaces.workspace("/test") + controller = HistoryController(sessions, workspace, scope) + manager = FakeManager() + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + // ------ OpenSessionAction.update ------ + + fun `test open action enabled for single local selection`() { + val action = OpenSessionAction() + val local = localItem("ses_1") + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller) + + action.update(event) + + assertTrue(event.presentation.isEnabledAndVisible) + } + + fun `test open action enabled for single cloud selection`() { + val action = OpenSessionAction() + val item = cloudItem("cloud_1") + val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller) + + action.update(event) + + assertTrue(event.presentation.isEnabledAndVisible) + } + + fun `test open action disabled without manager`() { + val action = OpenSessionAction() + val local = localItem("ses_1") + val event = event(action, null, selection(HistorySource.LOCAL, listOf(local)), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test open action disabled with no selection`() { + val action = OpenSessionAction() + val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test open action disabled with multiple local items`() { + val action = OpenSessionAction() + val items = listOf(localItem("ses_1"), localItem("ses_2")) + val event = event(action, manager, selection(HistorySource.LOCAL, items), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + // ------ OpenSessionAction.actionPerformed ------ + + fun `test open action performs opens local item`() { + val opened = mutableListOf() + val ctrl = HistoryController(sessions, workspace, scope, open = { ref -> + when (ref) { + is SessionRef.Local -> opened.add(ref.id) + is SessionRef.Cloud -> opened.add("cloud:${ref.id}") + } + }) + val local = localItem("ses_1") + val action = OpenSessionAction() + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), ctrl) + + action.actionPerformed(event) + flush() + + assertEquals(listOf("ses_1"), opened) + } + + fun `test open action performs opens cloud item`() { + val opened = mutableListOf() + val ctrl = HistoryController(sessions, workspace, scope, open = { ref -> + when (ref) { + is SessionRef.Local -> opened.add(ref.id) + is SessionRef.Cloud -> opened.add("cloud:${ref.id}") + } + }) + val item = cloudItem("cloud_1") + val action = OpenSessionAction() + val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), ctrl) + + action.actionPerformed(event) + flush() + + assertEquals(listOf("cloud:cloud_1"), opened) + } + + // ------ DeleteSessionAction.update ------ + + fun `test delete action enabled for non-empty local selection`() { + val action = DeleteSessionAction() + val local = localItem("ses_1") + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller) + + action.update(event) + + assertTrue(event.presentation.isEnabledAndVisible) + } + + fun `test delete action disabled for cloud-only selection`() { + val action = DeleteSessionAction() + val item = cloudItem("cloud_1") + val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test delete action disabled without manager`() { + val action = DeleteSessionAction() + val local = localItem("ses_1") + val event = event(action, null, selection(HistorySource.LOCAL, listOf(local)), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test delete action disabled with empty selection`() { + val action = DeleteSessionAction() + val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + // ------ DeleteSessionAction.actionPerformed ------ + + fun `test delete action deletes selected local items after confirmation`() { + rpc.listed += sessionDto("ses_1", "One") + rpc.listed += sessionDto("ses_2", "Two") + controller.reloadLocal() + flush() + + assertEquals(2, controller.local.items.size) + + val items = controller.local.items.toList() + val action = DeleteSessionAction().apply { confirm = { _, _ -> true } } + val event = event(action, manager, selection(HistorySource.LOCAL, items), controller) + + action.actionPerformed(event) + flush() + + assertEquals(listOf("ses_1", "ses_2"), rpc.deletes.map { it.first }) + assertTrue(controller.local.items.isEmpty()) + } + + fun `test delete action skips items already being deleted`() { + rpc.listed += sessionDto("ses_1", "One") + controller.reloadLocal() + flush() + + rpc.deleteGate = kotlinx.coroutines.CompletableDeferred() + val item = controller.local.items[0] + controller.delete(item) + waitFor { controller.deleting(item) } + + val action = DeleteSessionAction().apply { confirm = { _, _ -> true } } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertTrue(rpc.deletes.isEmpty()) + + rpc.deleteGate?.complete(Unit) + waitFor { rpc.deletes.size == 1 } + assertEquals(listOf("ses_1"), rpc.deletes.map { it.first }) + } + + fun `test delete action cancelled when user says no`() { + rpc.listed += sessionDto("ses_1", "One") + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + val action = DeleteSessionAction().apply { confirm = { _, _ -> false } } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertTrue(rpc.deletes.isEmpty()) + } + + // ------ RenameSessionAction.update ------ + + fun `test rename action enabled for exactly one local item`() { + val action = RenameSessionAction() + val local = localItem("ses_1") + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(local)), controller) + + action.update(event) + + assertTrue(event.presentation.isEnabledAndVisible) + } + + fun `test rename action disabled with no selection`() { + val action = RenameSessionAction() + val event = event(action, manager, selection(HistorySource.LOCAL, emptyList()), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test rename action disabled with multiple local items`() { + val action = RenameSessionAction() + val items = listOf(localItem("ses_1"), localItem("ses_2")) + val event = event(action, manager, selection(HistorySource.LOCAL, items), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + fun `test rename action disabled for cloud selection`() { + val action = RenameSessionAction() + val item = cloudItem("cloud_1") + val event = event(action, manager, selection(HistorySource.CLOUD, emptyList(), listOf(item)), controller) + + action.update(event) + + assertFalse(event.presentation.isEnabledAndVisible) + } + + // ------ RenameSessionAction.actionPerformed ------ + + fun `test rename action calls controller with trimmed changed title`() { + rpc.listed += sessionDto("ses_1", "Original") + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + val action = RenameSessionAction().apply { input = { _, _ -> " Renamed " } } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertEquals(listOf(Triple("ses_1", "/test", "Renamed")), rpc.renames) + } + + fun `test rename action passes displayed current title to input`() { + rpc.listed += sessionDto("ses_1", "Original") + controller.reloadLocal() + flush() + + val prompts = mutableListOf() + val item = controller.local.items[0] + val action = RenameSessionAction().apply { + input = { _, current -> + prompts.add(current) + null + } + } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertEquals(listOf("Original"), prompts) + assertTrue(rpc.renames.isEmpty()) + } + + fun `test rename action passes untitled fallback to input`() { + rpc.listed += sessionDto("ses_1", "") + controller.reloadLocal() + flush() + + val prompts = mutableListOf() + val item = controller.local.items[0] + val action = RenameSessionAction().apply { + input = { _, current -> + prompts.add(current) + null + } + } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertEquals(listOf(KiloBundle.message("history.untitled")), prompts) + assertTrue(rpc.renames.isEmpty()) + } + + fun `test rename action ignores blank input`() { + rpc.listed += sessionDto("ses_1", "Original") + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + val action = RenameSessionAction().apply { input = { _, _ -> " " } } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertTrue(rpc.renames.isEmpty()) + } + + fun `test rename action ignores unchanged input`() { + rpc.listed += sessionDto("ses_1", "Original") + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + val action = RenameSessionAction().apply { input = { _, _ -> "Original" } } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertTrue(rpc.renames.isEmpty()) + } + + fun `test rename action ignores null input`() { + rpc.listed += sessionDto("ses_1", "Original") + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + val action = RenameSessionAction().apply { input = { _, _ -> null } } + val event = event(action, manager, selection(HistorySource.LOCAL, listOf(item)), controller) + + action.actionPerformed(event) + flush() + + assertTrue(rpc.renames.isEmpty()) + } + + fun `test frontend descriptor registers history actions`() { + val xml = javaClass.classLoader.getResourceAsStream("kilo.jetbrains.frontend.xml") + ?.bufferedReader() + ?.use { it.readText() } + ?: error("missing frontend descriptor") + + assertTrue(xml.contains("id=\"Kilo.Session.Open\"")) + assertTrue(xml.contains("id=\"Kilo.Session.Rename\"")) + assertTrue(xml.contains("id=\"Kilo.Session.Delete\"")) + assertTrue(xml.contains("id=\"Kilo.History.ContextMenu\"")) + assertTrue(xml.contains("ref=\"Kilo.Session.Open\"")) + assertTrue(xml.contains("ref=\"Kilo.Session.Rename\"")) + assertTrue(xml.contains("ref=\"Kilo.Session.Delete\"")) + } + + // ------ Helpers ------ + + private fun event( + action: com.intellij.openapi.actionSystem.AnAction, + manager: SessionManager?, + selection: HistorySelection, + ctrl: HistoryController, + ): AnActionEvent { + val presentation = Presentation().apply { copyFrom(action.templatePresentation) } + val context = DataContext { id -> + when { + CommonDataKeys.PROJECT.`is`(id) -> project + SessionManager.KEY.`is`(id) -> manager + HistoryDataKeys.SELECTION.`is`(id) -> selection + HistoryDataKeys.CONTROLLER.`is`(id) -> ctrl + else -> null + } + } + return AnActionEvent.createFromDataContext("", presentation, context) + } + + private fun selection( + source: HistorySource, + local: List, + cloud: List = emptyList(), + ) = HistorySelection(source, local, cloud) + + private fun localItem(id: String, title: String = id) = LocalHistoryItem(sessionDto(id, title)) + + private fun sessionDto(id: String, title: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = title, + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + + private fun cloudItem(id: String, title: String = id) = CloudHistoryItem( + CloudSessionDto( + id = id, + title = title, + createdAt = "2026-01-01T00:00:00Z", + updatedAt = "2026-01-02T00:00:00Z", + version = 1.0, + ) + ) + + private fun flush() = runBlocking { + repeat(10) { + delay(100) + ApplicationManager.getApplication().invokeAndWait { UIUtil.dispatchAllInvocationEvents() } + } + } + + private fun waitFor(done: () -> Boolean) = runBlocking { + withTimeout(5_000) { + while (!done()) { + delay(25) + ApplicationManager.getApplication().invokeAndWait { UIUtil.dispatchAllInvocationEvents() } + } + } + ApplicationManager.getApplication().invokeAndWait { UIUtil.dispatchAllInvocationEvents() } + } + + private class FakeManager : SessionManager { + override fun newSession() {} + override fun showHistory() {} + override fun openSession(ref: SessionRef) {} + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt index d9da97616d8..3de571b0d8f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/actions/NewSessionActionTest.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.actions import ai.kilocode.client.session.SessionManager -import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.client.session.SessionRef import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.Presentation @@ -49,7 +49,10 @@ class NewSessionActionTest : BasePlatformTestCase() { created++ } - override fun openSession(session: SessionDto) { + override fun showHistory() { + } + + override fun openSession(ref: SessionRef) { } } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt new file mode 100644 index 00000000000..3a6ae7a3d99 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/app/KiloSessionServiceTest.kt @@ -0,0 +1,100 @@ +package ai.kilocode.client.app + +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +@Suppress("UnstableApiUsage") +class KiloSessionServiceTest : BasePlatformTestCase() { + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeSessionRpcApi + private lateinit var service: KiloSessionService + + override fun setUp() { + super.setUp() + scope = CoroutineScope(SupervisorJob()) + rpc = FakeSessionRpcApi() + service = KiloSessionService(project, scope, rpc) + } + + override fun tearDown() { + try { + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test rename replaces cached session in sessions value`() = runBlocking(Dispatchers.Default) { + rpc.listed += session("ses_1", "Original") + rpc.listed += session("ses_2", "Other") + service.list("/test") + + assertEquals(2, service.sessions.value.size) + assertEquals("Original", service.sessions.value.find { it.id == "ses_1" }!!.title) + + service.renameSession("ses_1", "/test", "Renamed") + + assertEquals(2, service.sessions.value.size) + assertEquals("Renamed", service.sessions.value.find { it.id == "ses_1" }!!.title) + assertEquals("Other", service.sessions.value.find { it.id == "ses_2" }!!.title) + } + + fun `test rename of unknown id does not insert new item`() = runBlocking(Dispatchers.Default) { + rpc.listed += session("ses_1", "Original") + service.list("/test") + + assertEquals(1, service.sessions.value.size) + + service.renameSession("ses_unknown", "/test", "Should Not Insert") + + // Size remains 1 — no unexpected insert + assertEquals(1, service.sessions.value.size) + assertEquals("ses_1", service.sessions.value[0].id) + } + + fun `test rename failure propagates exception without mutating cache`() = runBlocking(Dispatchers.Default) { + rpc.listed += session("ses_1", "Original") + service.list("/test") + + val before = service.sessions.value.toList() + rpc.renameThrows = RuntimeException("server error") + + var threw = false + try { + service.renameSession("ses_1", "/test", "Renamed") + } catch (_: RuntimeException) { + threw = true + } + + assertTrue(threw) + assertEquals(before.map { it.id to it.title }, service.sessions.value.map { it.id to it.title }) + } + + fun `test list populates sessions value`() = runBlocking(Dispatchers.Default) { + rpc.listed += session("ses_1", "One") + rpc.listed += session("ses_2", "Two") + + service.list("/test") + + assertEquals(2, service.sessions.value.size) + assertTrue(service.sessions.value.any { it.id == "ses_1" }) + assertTrue(service.sessions.value.any { it.id == "ses_2" }) + } + + private fun session(id: String, title: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = title, + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRefTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRefTest.kt new file mode 100644 index 00000000000..6b6d1a7079d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionRefTest.kt @@ -0,0 +1,62 @@ +package ai.kilocode.client.session + +import ai.kilocode.rpc.dto.CloudSessionDto +import ai.kilocode.rpc.dto.SessionDto +import ai.kilocode.rpc.dto.SessionTimeDto +import junit.framework.TestCase + +class SessionRefTest : TestCase() { + + fun `test local ref exposes type id and key`() { + val ref = SessionRef.Local("ses_test") + + assertEquals(SessionRef.Type.LOCAL, ref.type) + assertEquals("ses_test", ref.id) + assertEquals("ses_test", ref.key) + assertNull(ref.session) + } + + fun `test metadata local ref keeps id identity`() { + val dto = session("ses_test") + val ref = SessionRef.Local(dto) + + assertEquals(SessionRef.Type.LOCAL, ref.type) + assertEquals("ses_test", ref.id) + assertEquals("ses_test", ref.key) + assertSame(dto, ref.session) + } + + fun `test cloud ref exposes type id and prefixed key`() { + val dto = cloud("cloud_1") + val ref = SessionRef.Cloud(dto) + + assertEquals(SessionRef.Type.CLOUD, ref.type) + assertEquals("cloud_1", ref.id) + assertEquals("cloud:cloud_1", ref.key) + assertSame(dto, ref.session) + } + + fun `test ref parser resolves cloud and local ids`() { + assertEquals(SessionRef.Cloud("cloud_1"), SessionRef.from("cloud:cloud_1")) + assertEquals(SessionRef.Local("ses_test"), SessionRef.from("ses_test")) + assertNull(SessionRef.from(null)) + assertNull(SessionRef.from("")) + } + + private fun session(id: String) = SessionDto( + id = id, + projectID = "prj", + directory = "/test", + title = "Session $id", + version = "1", + time = SessionTimeDto(created = 1.0, updated = 2.0), + ) + + private fun cloud(id: String) = CloudSessionDto( + id = id, + title = "Cloud $id", + createdAt = "2026-01-01T00:00:00Z", + updatedAt = "2026-01-02T00:00:00Z", + version = 1.0, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt index e0d31c4c763..74272ac98ca 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionSidePanelManagerTest.kt @@ -4,9 +4,11 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.history.HistoryPanel import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.CloudSessionDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import ai.kilocode.rpc.dto.KiloWorkspaceStateDto @@ -19,6 +21,8 @@ import com.intellij.testFramework.fixtures.BasePlatformTestCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import javax.swing.JLabel +import javax.swing.JComponent import javax.swing.JPanel @Suppress("UnstableApiUsage") @@ -31,7 +35,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() { private lateinit var app: KiloAppService private val managers = mutableListOf() 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..7cddaef3a4d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/history/HistoryControllerTest.kt @@ -0,0 +1,741 @@ +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.SessionManager +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 java.util.concurrent.atomic.AtomicInteger +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() + 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, 50, null), 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", 50, null), 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 + assertTrue(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) + assertTrue(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 history section boundaries are inclusive at 7 and 30 days`() { + val now = Instant.now() + val exactly7 = LocalHistoryItem(session("s7", "7d", now.minus(7, ChronoUnit.DAYS).toEpochMilli().toDouble())) + val exactly30 = LocalHistoryItem(session("s30", "30d", now.minus(30, ChronoUnit.DAYS).toEpochMilli().toDouble())) + val within7 = LocalHistoryItem(session("s6", "6d", now.minus(6, ChronoUnit.DAYS).toEpochMilli().toDouble())) + val within30 = LocalHistoryItem(session("s29", "29d", now.minus(29, ChronoUnit.DAYS).toEpochMilli().toDouble())) + val beyond30 = LocalHistoryItem(session("s31", "31d", now.minus(31, ChronoUnit.DAYS).toEpochMilli().toDouble())) + + assertEquals(HistorySection.WEEK, HistoryTime.section(exactly7, now.toEpochMilli())) + assertEquals(HistorySection.WEEK, HistoryTime.section(within7, now.toEpochMilli())) + assertEquals(HistorySection.MONTH, HistoryTime.section(exactly30, now.toEpochMilli())) + assertEquals(HistorySection.MONTH, HistoryTime.section(within30, now.toEpochMilli())) + assertEquals(HistorySection.OLDER, HistoryTime.section(beyond30, now.toEpochMilli())) + } + + fun `test list is focusable and uses multiple interval selection`() { + val panel = HistoryPanel(parent, controller()) + flush() + + assertTrue(panel.listFocusable()) + assertEquals(javax.swing.ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, panel.listSelectionMode()) + } + + fun `test load more button is focusable`() { + rpc.cloud += cloud("cloud_1", "Cloud") + rpc.cloudCursor = "next" + val controller = controller() + val panel = HistoryPanel(parent, controller) + flush() + + assertTrue(panel.loadMoreFocusable()) + } + + fun `test rename updates local item title`() { + rpc.listed += session("ses_1", "Original") + val controller = controller() + flush() + + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + controller.rename(item, "Renamed") + flush() + + assertEquals("Renamed", controller.local.items[0].title) + assertEquals(listOf(Triple("ses_1", "/test", "Renamed")), rpc.renames) + } + + fun `test data context exposes selection and controller`() { + rpc.listed += session("ses_1", "Alpha") + val controller = controller() + val panel = HistoryPanel(parent, controller) + flush() + + panel.select(0) + + val sel = panel.getData(HistoryDataKeys.SELECTION.name) as? HistorySelection + assertNotNull(sel) + assertEquals(1, sel!!.selectedLocal.size) + assertEquals("ses_1", sel.selectedLocal[0].id) + + val ctrl = panel.getData(HistoryDataKeys.CONTROLLER.name) + assertSame(controller, ctrl) + } + + // ------ Multi-selection and cloud selection data context ------ + + fun `test data context exposes local multi-selection`() { + rpc.listed += session("ses_1", "Alpha") + rpc.listed += session("ses_2", "Beta") + val controller = controller() + val panel = HistoryPanel(parent, controller) + flush() + + panel.selectIndices(0, 1) + + val sel = panel.getData(HistoryDataKeys.SELECTION.name) as? HistorySelection + assertNotNull(sel) + assertEquals(HistorySource.LOCAL, sel!!.source) + assertEquals(2, sel.selectedLocal.size) + assertTrue(sel.selectedLocal.map { it.id }.containsAll(listOf("ses_1", "ses_2"))) + } + + fun `test data context exposes cloud selection`() { + rpc.cloud += cloud("cloud_1", "Cloud One") + val controller = controller() + val panel = HistoryPanel(parent, controller) + flush() + + panel.clickCloud() + flush() + panel.select(0) + + val sel = panel.getData(HistoryDataKeys.SELECTION.name) as? HistorySelection + assertNotNull(sel) + assertEquals(HistorySource.CLOUD, sel!!.source) + assertTrue(sel.selectedLocal.isEmpty()) + assertEquals(1, sel.cloudItems.size) + assertEquals("cloud_1", sel.cloudItems[0].id) + } + + fun `test data context exposes session manager`() { + val manager = FakeManager() + val controller = controller() + val panel = HistoryPanel(parent, controller, manager = manager) + flush() + + assertSame(manager, panel.getData(SessionManager.KEY.name)) + } + + fun `test data context returns null for absent session manager`() { + val controller = controller() + val panel = HistoryPanel(parent, controller) + flush() + + assertNull(panel.getData(SessionManager.KEY.name)) + } + + fun `test local list uses multiple interval selection mode`() { + val panel = HistoryPanel(parent, controller()) + flush() + + assertEquals(javax.swing.ListSelectionModel.MULTIPLE_INTERVAL_SELECTION, panel.listSelectionMode()) + } + + fun `test cloud list uses single selection mode`() { + val panel = HistoryPanel(parent, controller()) + flush() + + panel.clickCloud() + flush() + + assertEquals(javax.swing.ListSelectionModel.SINGLE_SELECTION, panel.listSelectionMode()) + } + + // ------ Rename failure and directory selection ------ + + fun `test rename failure keeps original title and sets error`() { + rpc.listed += session("ses_1", "Original") + val controller = controller() + controller.reloadLocal() + flush() + + rpc.renameThrows = IllegalStateException("rename failed") + val item = controller.local.items[0] + controller.rename(item, "Renamed") + flush() + + assertEquals("Original", controller.local.items[0].title) + assertNotNull(controller.local.error) + } + + fun `test rename uses item directory when present`() { + val dto = session("ses_1", "Original").copy(directory = "/worktree/path") + rpc.listed += dto + val controller = controller() + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + assertEquals("/worktree/path", item.directory) + + controller.rename(item, "Renamed") + flush() + + assertEquals(listOf(Triple("ses_1", "/worktree/path", "Renamed")), rpc.renames) + } + + fun `test rename falls back to workspace directory when item directory is workspace`() { + // When session.directory matches workspace directory (not a worktree override) + rpc.listed += session("ses_1", "Original") + val controller = controller() + controller.reloadLocal() + flush() + + val item = controller.local.items[0] + controller.rename(item, "Renamed") + flush() + + assertEquals(listOf(Triple("ses_1", "/test", "Renamed")), rpc.renames) + } + + // ------ HistoryModel update/sorting ------ + + fun `test model update re-sorts items by updated time`() { + val now = java.time.Instant.now() + rpc.listed += session("ses_1", "Alpha", now.toEpochMilli().toDouble()) + rpc.listed += session("ses_2", "Beta", now.minusSeconds(100).toEpochMilli().toDouble()) + val controller = controller() + controller.reloadLocal() + flush() + + // ses_1 is newer so comes first + assertEquals("ses_1", controller.local.items[0].id) + + // Update ses_2 to be newer + val updated = session("ses_2", "Beta Updated", now.plusSeconds(100).toEpochMilli().toDouble()) + controller.local.update(LocalHistoryItem(updated)) + + // Now ses_2 should come first + assertEquals("ses_2", controller.local.items[0].id) + assertEquals("Beta Updated", controller.local.items[0].title) + } + + fun `test model update with unknown id leaves model unchanged`() { + rpc.listed += session("ses_1", "Alpha") + val controller = controller() + controller.reloadLocal() + flush() + + val before = controller.local.items.toList() + val unknown = session("unknown_id", "Unknown") + controller.local.update(LocalHistoryItem(unknown)) + + assertEquals(before.map { it.id }, controller.local.items.map { it.id }) + } + + fun `test model update removes renamed item from active filter`() { + rpc.listed += session("ses_1", "Alpha") + rpc.listed += session("ses_2", "Beta") + val controller = controller() + controller.reloadLocal() + flush() + + controller.local.setFilter("alpha") + assertEquals(listOf("ses_1"), controller.local.visibleItems.map { it.id }) + + controller.local.update(LocalHistoryItem(session("ses_1", "Gamma"))) + + assertTrue(controller.local.visibleItems.isEmpty()) + } + + fun `test model update adds renamed item to active filter`() { + rpc.listed += session("ses_1", "Alpha") + rpc.listed += session("ses_2", "Beta") + val controller = controller() + controller.reloadLocal() + flush() + + controller.local.setFilter("gamma") + assertTrue(controller.local.visibleItems.isEmpty()) + + controller.local.update(LocalHistoryItem(session("ses_2", "Gamma"))) + + assertEquals(listOf("ses_2"), controller.local.visibleItems.map { it.id }) + } + + fun `test cloud load passes git url when repo only enabled`() { + rpc.cloud += cloud("cloud_1", "Cloud One") + val url = "git@github.com:test/repo.git" + val controller = controllerWithGit(url) + + controller.reloadCloud() + flush() + + assertEquals(1, rpc.cloudCalls.size) + assertEquals(url, rpc.cloudCalls[0].gitUrl) + assertEquals(true, controller.repoOnly) + assertEquals(url, controller.gitUrl) + } + + fun `test cloud load passes null when no git url`() { + rpc.cloud += cloud("cloud_1", "Cloud One") + val controller = controllerWithGit(null) + + controller.reloadCloud() + flush() + + assertEquals(1, rpc.cloudCalls.size) + assertNull(rpc.cloudCalls[0].gitUrl) + assertEquals(false, controller.repoOnly) + assertNull(controller.gitUrl) + } + + fun `test cloud git url resolves once across overlapping reloads`() { + rpc.cloud += cloud("cloud_1", "Cloud One") + val calls = AtomicInteger() + val controller = HistoryController(sessions, workspace, scope, gitUrlProvider = { + calls.incrementAndGet() + Thread.sleep(100) + "git@github.com:test/repo.git" + }) + + controller.reloadCloud() + controller.reloadCloud() + flush() + + assertEquals(1, calls.get()) + assertEquals(2, rpc.cloudCalls.size) + } + + fun `test cloud load passes null when repo only disabled`() { + rpc.cloud += cloud("cloud_1", "Cloud One") + val url = "git@github.com:test/repo.git" + val controller = controllerWithGit(url) + + controller.reloadCloud() + flush() + assertEquals(url, rpc.cloudCalls[0].gitUrl) + + controller.applyRepoOnly(false) + flush() + + assertEquals(2, rpc.cloudCalls.size) + assertNull(rpc.cloudCalls[1].gitUrl) + } + + fun `test load more passes git url when repo only enabled`() { + rpc.cloud += cloud("cloud_1", "Cloud One") + rpc.cloudCursor = "next_1" + val url = "git@github.com:test/repo.git" + val controller = controllerWithGit(url) + + controller.reloadCloud() + flush() + + rpc.cloud.clear() + rpc.cloud += cloud("cloud_2", "Cloud Two") + rpc.cloudCursor = null + controller.loadMoreCloud() + flush() + + assertEquals(2, rpc.cloudCalls.size) + assertEquals(url, rpc.cloudCalls[1].gitUrl) + } + + fun `test repo only checkbox visible only when git url exists`() { + val url = "git@github.com:test/repo.git" + val panel = HistoryPanel(parent, controllerWithGit(url)) + flush() + + panel.clickCloud() + flush() + + assertTrue(panel.repoOnlyVisible()) + assertTrue(panel.repoOnlySelected()) + } + + fun `test repo only checkbox hidden when no git url`() { + val panel = HistoryPanel(parent, controllerWithGit(null)) + flush() + + panel.clickCloud() + flush() + + assertFalse(panel.repoOnlyVisible()) + } + + fun `test repo only checkbox toggle reloads cloud history`() { + rpc.cloud += cloud("cloud_1", "Cloud One") + val url = "git@github.com:test/repo.git" + val panel = HistoryPanel(parent, controllerWithGit(url)) + flush() + + panel.clickCloud() + flush() + + val before = rpc.cloudCalls.size + + panel.clickRepoOnly() + flush() + + assertTrue(rpc.cloudCalls.size > before) + assertFalse(panel.repoOnlySelected()) + } + + private fun controller() = HistoryController(sessions, workspace, scope) + + private fun controllerWithGit(url: String?) = HistoryController( + sessions, + workspace, + scope, + gitUrlProvider = { url }, + ) + + 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 class FakeManager : SessionManager { + override fun newSession() {} + override fun showHistory() {} + override fun openSession(ref: SessionRef) {} + } + + 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..ca5d29db1cc 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,19 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val permissionRulesSaved = mutableListOf>() val questionReplies = mutableListOf>() val questionRejects = mutableListOf>() + val deletes = mutableListOf>() + var deleteGate: CompletableDeferred? = null + val renames = mutableListOf>() + var renameThrows: Exception? = null + 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 +108,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 +130,33 @@ class FakeSessionRpcApi : KiloSessionRpcApi { override suspend fun delete(id: String, directory: String) { assertNotEdt("delete") + deleteGate?.await() + deletes.add(id to directory) + listed.removeAll { it.id == id } + } + + override suspend fun rename(id: String, directory: String, title: String): SessionDto { + assertNotEdt("rename") + renameThrows?.let { throw it } + renames.add(Triple(id, directory, title)) + val updated = listed.indexOfFirst { it.id == id } + if (updated >= 0) { + listed[updated] = listed[updated].copy(title = title) + return listed[updated] + } + return session.copy(id = id, title = title) + } + + 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..25c8a63c3ab 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,15 @@ interface KiloSessionRpcApi : RemoteApi { /** Delete a session. */ suspend fun delete(id: String, directory: String) + /** Rename a session. */ + suspend fun rename(id: String, directory: String, title: String): SessionDto + + /** 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 b6cd54c3a29..08eeeca07eb 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.48", + "version": "7.2.52", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 16dbcc92201..4f8837a2579 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -156,7 +156,7 @@ export namespace Telemetry { taskId?: string mode?: "review" feature?: "code_reviews" - command?: "local-review" | "local-review-uncommitted" + command?: "review" | "local-review" | "local-review-uncommitted" apiProvider: string modelId: string inputTokens?: number diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index a3733c7a8dd..27a64548719 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.48", + "version": "7.2.52", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/basic-tool.tsx b/packages/kilo-ui/src/components/basic-tool.tsx index 66d15277b35..1cf5a25bf1f 100644 --- a/packages/kilo-ui/src/components/basic-tool.tsx +++ b/packages/kilo-ui/src/components/basic-tool.tsx @@ -1 +1,27 @@ -export * from "@opencode-ai/ui/basic-tool" +import { BasicTool as Base, GenericTool } from "@opencode-ai/ui/basic-tool" +import type { BasicToolProps as BaseProps, TriggerTitle } from "@opencode-ai/ui/basic-tool" +import { toolOpenKey, readToolOpen, writeToolOpen } from "./tool-open-state" + +export { GenericTool } +export type { TriggerTitle } + +export interface BasicToolProps extends BaseProps { + tool?: string + callID?: string + partID?: string +} + +export function BasicTool(props: BasicToolProps) { + const key = () => toolOpenKey(props) + const initial = () => (props.forceOpen ? true : readToolOpen(key(), props.defaultOpen)) + return ( + { + writeToolOpen(key(), open) + props.onOpenChange?.(open) + }} + /> + ) +} 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-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index bb6f99c515a..5494f7c0c71 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -195,11 +195,69 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { color: var(--vscode-terminal-foreground, var(--vscode-editor-foreground)); } - [data-slot="bash-copy"] [data-component="icon-button"][data-variant="secondary"] { - background: var(--vscode-terminal-background, var(--vscode-panel-background)); + [data-slot="bash-divider"] { + background: var(--vscode-panel-border, var(--border-weak-base)); } } +/* Bash tool: section layout (command + output) */ +[data-component="bash-output"] [data-slot="bash-section"] { + position: relative; +} + +[data-component="bash-output"] [data-slot="bash-section-code"] { + overflow-y: auto; + overflow-x: hidden; + max-height: 240px; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } +} + +[data-component="bash-output"] [data-slot="bash-section-actions"] { + position: absolute; + top: 4px; + right: 4px; + display: flex; + align-items: center; + gap: 2px; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} + +[data-component="bash-output"] [data-slot="bash-section"]:hover [data-slot="bash-section-actions"], +[data-component="bash-output"] [data-slot="bash-section"]:focus-within [data-slot="bash-section-actions"] { + opacity: 1; + pointer-events: auto; +} + +/* Edge-to-edge divider (matches MCP tool divider) */ +[data-component="bash-output"] [data-slot="bash-divider"] { + height: 1px; + background: var(--border-weak-base); +} + +/* Shiki syntax-highlighted shell output */ +[data-component="bash-output"] .shiki, +[data-component="shell-expanded-output"] .shiki { + margin: 0; + padding: 12px; + background: transparent !important; +} + +[data-component="bash-output"] .shiki code, +[data-component="shell-expanded-output"] .shiki code { + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: var(--font-size-small); + line-height: var(--line-height-large); + white-space: pre-wrap; + overflow-wrap: anywhere; + background: transparent; +} + /* Tool error inline card (upstream removed these styles in v1.2.25) */ [data-component="tool-error"] { display: flex; diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 5df2fd231a1..aa9bbbe086a 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -50,10 +50,13 @@ import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { GrowBox } from "./grow-box" import { COLLAPSIBLE_SPRING } from "./motion" import { busy, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils" +import { readToolOpen, toolOpenKey } from "./tool-open-state" import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results" import { ShellRollingResults } from "./shell-rolling-results" import { extractFilePathFromHref } from "../file-path" import { normalize } from "./session-diff" +import { deferredHighlight } from "../context/marked" +import { escapeHtml } from "../util/escape-html" // Windows CLI tools (e.g. winget) use \r to overwrite progress bars in-place. // Without this, every progress frame renders as a separate visual line. @@ -1117,6 +1120,9 @@ function McpTool(props: ToolProps) { string; label: string }) { + const i18n = useI18n() + const [copied, setCopied] = createSignal(false) + const handler = async () => { + const text = props.value() + if (!text) return + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( + + + + ) +} + +function BashHighlightedOutput(props: { cmd: string; output: string; outputPath?: string; active?: boolean }) { + const data = useData() + const i18n = useI18n() + const cmdState = { signal: { aborted: false } } + const outState = { signal: { aborted: false } } + let cmdRef: HTMLDivElement | undefined + let outRef: HTMLDivElement | undefined + + createEffect(() => { + cmdState.signal.aborted = true + if (!props.active) return + const cmd = props.cmd + if (!cmdRef || !cmd) return + const signal = { aborted: false } + cmdState.signal = signal + cmdRef.innerHTML = `
${escapeHtml(cmd)}
` + void deferredHighlight(cmdRef, undefined, signal) + }) + + createEffect(() => { + outState.signal.aborted = true + if (!props.active) return + const out = props.output + if (!outRef || !out) return + const signal = { aborted: false } + outState.signal = signal + outRef.innerHTML = `
${escapeHtml(out)}
` + void deferredHighlight(outRef, undefined, signal) + }) + + onCleanup(() => { + cmdState.signal.aborted = true + outState.signal.aborted = true + }) + + const openInEditor = () => { + // When output was truncated, open the full output file on disk + if (props.outputPath && data.openFile) { + data.openFile(props.outputPath) + return + } + if (!data.openContent) return + data.openContent(props.output, "log") + } + + return ( +
+ +
{i18n.t("ui.messagePart.shell.command")}
+
+
+
+ props.cmd} label={i18n.t("ui.message.copy")} /> +
+
+ + +
+ + +
{i18n.t("ui.messagePart.shell.output")}
+
+
+
+ + + + + + props.output} label={i18n.t("ui.message.copy")} /> +
+
+ +
+ ) +} + ToolRegistry.register({ name: "bash", render(props) { @@ -2035,6 +2147,8 @@ ToolRegistry.register({ const pending = () => busy(props.status) const reveal = useToolReveal(pending, () => props.reveal !== false) const subtitle = () => props.input.description ?? props.metadata.description + const key = () => toolOpenKey(props) + const [open, setOpen] = createSignal(readToolOpen(key(), props.defaultOpen ?? true) ?? true) // also apply processCarriageReturns for Windows CLI tools const cmd = createMemo(() => { @@ -2048,19 +2162,6 @@ ToolRegistry.register({ return "" }) const out = createMemo(() => processCarriageReturns(stripAnsi(rawOutput()))) - const text = createMemo(() => `$ ${cmd()}${out() ? "\n\n" + out() : ""}`) - - const hasOutput = createMemo(() => out().length > 0) - const [copied, setCopied] = createSignal(false) - - const handleCopy = async () => { - const command = cmd() - if (!command) return - const content = out() ? `${command}\n\n${out()}` : command - await navigator.clipboard.writeText(content) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } return ( @@ -2080,29 +2182,7 @@ ToolRegistry.register({
} > -
-
- - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} - /> - -
-
-
-              {text()}
-            
-
-
+ ) }, diff --git a/packages/kilo-ui/src/components/shell-rolling-results.tsx b/packages/kilo-ui/src/components/shell-rolling-results.tsx index e5310b6ce08..f5a639cb695 100644 --- a/packages/kilo-ui/src/components/shell-rolling-results.tsx +++ b/packages/kilo-ui/src/components/shell-rolling-results.tsx @@ -3,6 +3,8 @@ import stripAnsi from "strip-ansi" import type { ToolPart } from "@kilocode/sdk/v2" import { useReducedMotion } from "../hooks/use-reduced-motion" import { useI18n } from "../context/i18n" +import { deferredHighlight } from "../context/marked" +import { escapeHtml } from "../util/escape-html" import { RollingResults } from "./rolling-results" import { Icon } from "./icon" import { IconButton } from "./icon-button" @@ -11,6 +13,7 @@ import { Tooltip } from "./tooltip" import { GROW_SPRING } from "./motion" import { useSpring } from "./motion-spring" import { busy, createThrottledValue, updateScrollMask, useCollapsible, useRowWipe, useToolFade } from "./tool-utils" +import { readToolOpen, toolOpenKey, writeToolOpen } from "./tool-open-state" function ShellRollingSubtitle(props: { text: string; animate?: boolean }) { let ref: HTMLSpanElement | undefined @@ -57,6 +60,28 @@ function ShellRollingCommand(props: { text: string; animate?: boolean }) { ) } +function ShellOutputHighlight(props: { code: string; active?: boolean }) { + const state = { signal: { aborted: false } } + let ref: HTMLDivElement | undefined + + createEffect(() => { + state.signal.aborted = true + if (!props.active) return + const code = props.code + if (!ref || !code) return + const signal = { aborted: false } + state.signal = signal + ref.innerHTML = `
${escapeHtml(code)}
` + void deferredHighlight(ref, undefined, signal) + }) + + onCleanup(() => { + state.signal.aborted = true + }) + + return
+} + function ShellExpanded(props: { cmd: string; out: string; open: boolean }) { const i18n = useI18n() const rows = 10 @@ -156,9 +181,7 @@ function ShellExpanded(props: { cmd: string; out: string; open: boolean }) { onScroll={updateMask} style={{ "max-height": `${cap()}px` }} > -
-                  {props.out}
-                
+
@@ -173,7 +196,8 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean; const reduce = useReducedMotion() const wiped = new Set() const [mounted, setMounted] = createSignal(false) - const [open, setOpen] = createSignal(props.defaultOpen ?? true) + const key = () => toolOpenKey({ tool: props.part.tool, callID: props.part.callID, partID: props.part.id }) + const [open, setOpen] = createSignal(readToolOpen(key(), props.defaultOpen ?? true) ?? true) onMount(() => setMounted(true)) const state = createMemo(() => props.part.state as Record) const pending = createMemo(() => busy(props.part.state.status)) @@ -205,7 +229,9 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean; const el = headerClipRef const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null const beforeY = el?.getBoundingClientRect().top ?? 0 - setOpen((prev) => !prev) + const next = !open() + setOpen(next) + writeToolOpen(key(), next) if (viewport && el) { requestAnimationFrame(() => { const afterY = el.getBoundingClientRect().top diff --git a/packages/kilo-ui/src/components/text-field.css b/packages/kilo-ui/src/components/text-field.css index a8579590cfc..9c675564518 100644 --- a/packages/kilo-ui/src/components/text-field.css +++ b/packages/kilo-ui/src/components/text-field.css @@ -44,7 +44,7 @@ color: var(--text-base); &::placeholder { - color: var(--icon-weak-base); + color: var(--vscode-input-placeholderForeground, var(--text-weaker)); } } diff --git a/packages/kilo-ui/src/components/tool-open-state.ts b/packages/kilo-ui/src/components/tool-open-state.ts new file mode 100644 index 00000000000..3e95617ffe4 --- /dev/null +++ b/packages/kilo-ui/src/components/tool-open-state.ts @@ -0,0 +1,33 @@ +type Props = { + tool?: string + callID?: string + partID?: string +} + +const MAX = 2000 +const state = new Map() + +export function toolOpenKey(props: Props) { + const id = props.callID || props.partID + if (!id) return + if (!props.tool) return id + return `${props.tool}:${id}` +} + +export function readToolOpen(key: string | undefined, fallback: boolean | undefined) { + if (key && state.has(key)) return state.get(key) + return fallback +} + +export function writeToolOpen(key: string | undefined, value: boolean) { + if (!key) return + if (!state.has(key) && state.size >= MAX) { + const first = state.keys().next().value + if (first) state.delete(first) + } + state.set(key, value) +} + +export function resetToolOpenState() { + state.clear() +} diff --git a/packages/kilo-ui/src/util/escape-html.ts b/packages/kilo-ui/src/util/escape-html.ts new file mode 100644 index 00000000000..243d85be4c6 --- /dev/null +++ b/packages/kilo-ui/src/util/escape-html.ts @@ -0,0 +1,3 @@ +export function escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">") +} diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index c825105ff1f..461e4d8fe1c 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,34 @@ # kilo-code +## 7.2.51 + +### Patch Changes + +- [#10143](https://github.com/Kilo-Org/kilocode/pull/10143) [`ea023b0`](https://github.com/Kilo-Org/kilocode/commit/ea023b06dd269445eaf0c433fb42f4c9de2605b9) - Reduce delays in long sessions by compacting older tool outputs when the model request becomes too large. + +## 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 ### Minor Changes diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index ccc67e64954..d073c0dde9d 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.48", + "version": "7.2.52", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -776,6 +776,15 @@ "default": false, "description": "Enable chat textarea autocomplete" }, + "kilo-code.new.speechToText.enabled": { + "type": "boolean", + "default": false, + "description": "Enable experimental speech-to-text voice input in prompt fields. Uses your Kilo account through the Kilo Gateway." + }, + "kilo-code.new.speechToText.model": { + "type": "string", + "description": "Model to use for experimental speech-to-text voice input. Requires Kilo Gateway." + }, "kilo-code.new.claudeCodeCompat": { "type": "boolean", "default": false, @@ -886,7 +895,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "knip": "knip", - "check-kilocode-change": "! grep -rn 'kilocode_change' . ../kilo-ui/ --exclude='package.json' --exclude='*.md' --exclude-dir='node_modules' --exclude-dir='dist' | grep -v '`kilocode_change`'", + "check-kilocode-change": "! grep -rIn 'kilocode_change' . ../kilo-ui/ --exclude='package.json' --exclude='*.md' --exclude-dir='node_modules' --exclude-dir='dist' | grep -v '`kilocode_change`'", "lint": "eslint src webview-ui", "test": "vscode-test", "test:unit": "bun test tests/unit/", diff --git a/packages/kilo-vscode/script/build.ts b/packages/kilo-vscode/script/build.ts index cb285b327b4..b611a06e32a 100644 --- a/packages/kilo-vscode/script/build.ts +++ b/packages/kilo-vscode/script/build.ts @@ -2,6 +2,7 @@ import { $ } from "bun" import { join } from "node:path" import { existsSync, mkdirSync, rmSync, chmodSync } from "node:fs" +import { ensureFfmpegForTarget } from "./ffmpeg-helper" const packageJsonPath = join(import.meta.dir, "..", "package.json") const packageJson = await Bun.file(packageJsonPath).json() @@ -81,6 +82,9 @@ for (const config of targets) { console.log(` ✅ Binary ready at ${targetBinary}`) + console.log("Adding bundled FFmpeg helper...") + await ensureFfmpegForTarget(config.target, binDir) + console.log(` 📦 Packaging .vsix for ${config.target}${prerelease ? " (pre-release)" : ""}...`) const vsixPath = join(outDir, `kilo-vscode-${config.target}.vsix`) const args = ["--no-dependencies", "--skip-license", "--target", config.target, "-o", vsixPath] diff --git a/packages/kilo-vscode/script/dev-snapshot.ts b/packages/kilo-vscode/script/dev-snapshot.ts index 7637579ea38..edbe590573e 100755 --- a/packages/kilo-vscode/script/dev-snapshot.ts +++ b/packages/kilo-vscode/script/dev-snapshot.ts @@ -41,7 +41,7 @@ console.log("\n📦 Rebuilding SDK...") await $`bun run --cwd ../sdk/js build`.cwd(root) console.log("\n🔧 Preparing CLI binary...") -await $`bun script/local-bin.ts`.cwd(root) +await $`bun script/local-bin.ts --force`.cwd(root) console.log("\n✅ Type-checking...") await $`bun run typecheck`.cwd(root) diff --git a/packages/kilo-vscode/script/ffmpeg-helper.ts b/packages/kilo-vscode/script/ffmpeg-helper.ts new file mode 100644 index 00000000000..c0b5e440ef4 --- /dev/null +++ b/packages/kilo-vscode/script/ffmpeg-helper.ts @@ -0,0 +1,49 @@ +import { $ } from "bun" +import { chmodSync, copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs" +import { join } from "node:path" + +const packages: Record = { + "darwin-x64": "@ffmpeg-installer/darwin-x64@4.1.0", + "darwin-arm64": "@ffmpeg-installer/darwin-arm64@4.1.5", + "linux-x64": "@ffmpeg-installer/linux-x64@4.1.0", + "linux-arm64": "@ffmpeg-installer/linux-arm64@4.1.4", + "alpine-x64": "@ffmpeg-installer/linux-x64@4.1.0", + "alpine-arm64": "@ffmpeg-installer/linux-arm64@4.1.4", + "win32-x64": "@ffmpeg-installer/win32-x64@4.1.0", +} + +export async function ensureFfmpegForTarget(target: string, bin: string): Promise { + if (target === "win32-arm64") { + console.warn("No Windows ARM64 FFmpeg helper package is available; speech input will use system FFmpeg.") + return + } + + const spec = packages[target] + if (!spec) throw new Error(`No FFmpeg helper package configured for target ${target}`) + + const exe = target.startsWith("win32") ? "ffmpeg.exe" : "ffmpeg" + const dest = join(bin, exe) + if (existsSync(dest)) return + + const tmp = join(bin, ".ffmpeg-tmp") + rmSync(tmp, { recursive: true, force: true }) + mkdirSync(tmp, { recursive: true }) + + try { + const packed = await $`npm pack ${spec} --pack-destination ${tmp}`.quiet() + const name = packed.text().trim().split(/\s+/).pop() + if (!name) throw new Error(`npm pack did not return a tarball for ${spec}`) + + await $`tar -xzf ${join(tmp, name)} -C ${tmp}`.quiet() + copyFileSync(join(tmp, "package", exe), dest) + if (!target.startsWith("win32")) chmodSync(dest, 0o755) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } +} + +export function currentFfmpegTarget(): string { + const os = process.platform === "win32" ? "win32" : process.platform + const arch = process.arch === "x64" ? "x64" : process.arch === "arm64" ? "arm64" : process.arch + return `${os}-${arch}` +} diff --git a/packages/kilo-vscode/script/local-bin.ts b/packages/kilo-vscode/script/local-bin.ts index aeb57e81643..a8d2f669bde 100644 --- a/packages/kilo-vscode/script/local-bin.ts +++ b/packages/kilo-vscode/script/local-bin.ts @@ -2,6 +2,7 @@ import { $ } from "bun" import { join, relative, dirname, basename } from "node:path" import { chmodSync, statSync, rmSync, readdirSync, existsSync } from "node:fs" +import { currentFfmpegTarget, ensureFfmpegForTarget } from "./ffmpeg-helper" const forceRebuild = process.argv.includes("--force") @@ -158,6 +159,7 @@ async function main() { log( `CLI binary already present at ${relative(kiloVscodeDir, targetBinPath)} (${Math.round(st.size / 1024 / 1024)}MB). Use --force to rebuild.`, ) + await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) return } @@ -181,6 +183,7 @@ async function main() { await $`mkdir -p ${targetBinDir}` await $`cp ${sourceBinPath} ${targetBinPath}` chmodSync(targetBinPath, 0o755) + await ensureFfmpegForTarget(currentFfmpegTarget(), targetBinDir) // Record the CLI source version so future runs detect when a rebuild is needed const hash = await cliSourceHash() diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index cf3c33479e2..a3ed7bd2215 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -68,10 +68,12 @@ import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" import { abortSession } from "./kilo-provider/abort" import { buildAutocompleteSettingsMessage, - routeAutocompleteMessage, validAutocompleteSetting, watchAutocompleteConfig, } from "./services/autocomplete/settings" +import { validSpeechToTextSetting } from "./speech-to-text/settings" +import { sendSpeechToTextSettings, watchSpeechToTextConfig } from "./speech-to-text/config" +import { routeEarlyMessage } from "./kilo-provider/early-message" import * as ModelState from "./kilo-provider/model-state" import { handleForkSession } from "./kilo-provider/fork-session" import { openConfig } from "./kilo-provider/open-config" @@ -112,7 +114,7 @@ import { handleQuestionReject, fetchAndSendPendingQuestions, } from "./kilo-provider/handlers/question" -import { fetchAndSendPendingSuggestions, routeSuggestionWebviewMessage } from "./kilo-provider/handlers/suggestion" +import { fetchAndSendPendingSuggestions } from "./kilo-provider/handlers/suggestion" import { nativeTitle } from "./kilo-provider/native-tab-title" import { @@ -229,6 +231,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private webviewMessageDisposable: vscode.Disposable | null = null private autocompleteConfigDisposable: vscode.Disposable | null = null private telemetryStateDisposable: vscode.Disposable | null = null + private speechToTextDisposable: vscode.Disposable | null = null private viewStateDisposable: vscode.Disposable | null = null private visibilityDisposable: vscode.Disposable | null = null private autoApproveBridge: ReturnType | null = null @@ -592,6 +595,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.webviewMessageDisposable?.dispose() this.autocompleteConfigDisposable?.dispose() this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg)) + this.speechToTextDisposable?.dispose() + this.speechToTextDisposable = watchSpeechToTextConfig(this) this.telemetryStateDisposable?.dispose() this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg)) this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => { @@ -604,9 +609,18 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (intercepted === null) return message = intercepted - await routeSuggestionWebviewMessage(this.questionCtx, message) - if (await ModelState.handleMessage(message.type, message, this.client, (msg) => this.postMessage(msg))) return - if (await routeAutocompleteMessage(message, (msg) => this.postMessage(msg))) return + if ( + await routeEarlyMessage(message, { + question: this.questionCtx, + client: this.client, + connection: this.connectionService, + dir: this.getWorkspaceDirectory(this.currentSession?.id), + post: (msg) => this.postMessage(msg), + }) + ) { + return + } + if (this.handleEditorOpenMessage(message)) return if ( await handleSidebarWorktreeMessage(message, { post: (msg) => this.postMessage(msg), @@ -670,7 +684,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.handleAbort(message.sessionID) break case "revertSession": - this.handleRevertSession(message.sessionID, message.messageID).catch((e) => + this.handleRevertSession(message.sessionID, message.messageID, message.partID).catch((e) => console.error("[Kilo New] handleRevertSession failed:", e), ) break @@ -771,11 +785,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper break case "saveImage": return saveImage(this.getWorkspaceDirectory(this.currentSession?.id), message) - case "openFile": - if (message.filePath) { - this.handleOpenFile(message.filePath, message.line, message.column) - } - break case "requestProviders": this.fetchAndSendProviders().catch((e) => console.error("[Kilo New] fetchAndSendProviders failed:", e)) break @@ -1301,6 +1310,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo }) this.sendNotificationSettings() this.sendTimelineSetting() + this.sendSpeechToTextSettings() this.postMessage({ type: "extensionDataReady" }) if (this.cachedGitRepo) this.startStatsPolling() @@ -2341,6 +2351,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) } + private sendSpeechToTextSettings(): void { + sendSpeechToTextSettings(this) + } + /** Returns the number of sessions currently in "busy" state. */ private getBusySessionCount(): number { return getBusySessionCount(this.sessionStatusMap) @@ -2356,6 +2370,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper partial.provider !== undefined || partial.disabled_providers !== undefined || partial.enabled_providers !== undefined + const refreshAgents = + partial.default_agent !== undefined || + partial.agent !== undefined || + project.default_agent !== undefined || + project.agent !== undefined const hasGlobal = Object.keys(partial).length > 0 const hasProject = Object.keys(project).length > 0 @@ -2388,7 +2407,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper globalConfig: global, features: configFeatures(merged), }) - if (refreshProviders) await this.fetchAndSendProviders() + await Promise.all([ + refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(), + refreshAgents ? this.fetchAndSendAgents() : Promise.resolve(), + ]) } catch (error) { console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error) const patch = @@ -2696,10 +2718,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - private async handleRevertSession(sessionID: string, messageID: string): Promise { + private async handleRevertSession(sessionID: string, messageID: string, partID?: string): Promise { if (!this.client) return const dir = this.getWorkspaceDirectory(sessionID) - const { data, error } = await this.client.session.revert({ sessionID, messageID, directory: dir }) + const { data, error } = await this.client.session.revert({ sessionID, messageID, partID, directory: dir }) if (error) { console.error("[Kilo New] KiloProvider: Failed to revert session:", error) this.postMessage({ type: "error", message: "Failed to revert session", sessionID }) @@ -2889,6 +2911,35 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper .then(open, (err) => console.error("[Kilo New] KiloProvider: Failed to preview image:", err)) } + private handleEditorOpenMessage(message: { + type?: string + filePath?: string + line?: number + column?: number + content?: string + language?: string + }): boolean { + if (message.type === "openFile") { + if (message.filePath) this.handleOpenFile(message.filePath, message.line, message.column) + return true + } + if (message.type === "openContent") { + if (message.content) this.handleOpenContent(message.content, message.language) + return true + } + return false + } + + /** + * Handle openContent request - open arbitrary text in an untitled VS Code editor tab. + */ + private handleOpenContent(content: string, language?: string): void { + vscode.workspace.openTextDocument({ content, language: language || "log" }).then( + (doc) => vscode.window.showTextDocument(doc, { preview: true }), + (err) => console.error("[Kilo New] KiloProvider: Failed to open content:", err), + ) + } + /** * Handle openFile request from the webview — open a file in the VS Code editor. * Resolves relative paths against the current session's directory (which may be @@ -2920,6 +2971,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async handleUpdateSetting(key: string, value: unknown): Promise { const { section, leaf } = buildSettingPath(key) if (section === "autocomplete" && !validAutocompleteSetting(leaf, value)) return + if (section === "speechToText" && !validSpeechToTextSetting(leaf, value)) return const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`) await config.update(leaf, value, vscode.ConfigurationTarget.Global) } @@ -2960,6 +3012,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Re-send all settings to the webview so the UI reflects the reset this.postMessage(buildAutocompleteSettingsMessage()) + this.sendSpeechToTextSettings() this.sendBrowserSettings() this.sendNotificationSettings() this.sendTimelineSetting() @@ -3091,9 +3144,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } // Config was updated without a full dispose (e.g. permission-only save). - // Fetch and push the updated config so the Settings panel reflects the change. + // Fetch and push the updated config + refresh agents and providers so the + // Settings panel and mode/model pickers reflect the change. if (event.type === "global.config.updated") { - void this.fetchAndSendConfigUpdated() + void Promise.all([this.fetchAndSendConfigUpdated(), this.fetchAndSendAgents(), this.fetchAndSendProviders()]) return } @@ -3460,6 +3514,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.visibilityDisposable?.dispose() this.webviewMessageDisposable?.dispose() this.autocompleteConfigDisposable?.dispose() + this.speechToTextDisposable?.dispose() this.telemetryStateDisposable?.dispose() this.autoApproveBridge?.dispose() this.streams.dispose() diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 4890788eaeb..f88e32e770c 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -120,7 +120,6 @@ export class AgentManagerProvider implements Disposable { getState: () => this.getStateManager(), getRoot: () => this.getRoot(), getStateReady: () => this.stateReady, - getClient: () => this.connectionService.getClient(), git: this.gitOps, localDiff: (dir, base) => localDiffSummary(this.gitOps, dir, base, (...args) => this.log(...args)), localDiffFile: (dir, base, file) => localDiffFile(this.gitOps, dir, base, file, (...args) => this.log(...args)), @@ -251,6 +250,7 @@ export class AgentManagerProvider implements Disposable { return } + await this.ensureGitExclude(manager) const loaded = await state.load() manager.cleanupOrphanedTempDirs() @@ -291,6 +291,12 @@ export class AgentManagerProvider implements Disposable { this.panel?.sessions.recoverPendingPrompts() } + private async ensureGitExclude(manager: WorktreeManager): Promise { + await manager.ensureGitExclude().catch((err) => { + this.log("Failed to update git exclude:", err) + }) + } + private async recoverWorktrees(manager: WorktreeManager, state: WorktreeStateManager): Promise { const infos = await manager.discoverWorktrees().catch((err) => { this.log("Failed to discover worktrees during state recovery:", err) @@ -522,6 +528,10 @@ export class AgentManagerProvider implements Disposable { this.state?.setSessionsCollapsed(m.collapsed) return null } + if (m.type === "agentManager.setSidebarCollapsed") { + this.state?.setSidebarCollapsed(m.collapsed) + return null + } if (this.handleSection(m)) return null if (m.type === "agentManager.setReviewDiffStyle") { this.state?.setReviewDiffStyle(m.style) @@ -848,6 +858,7 @@ export class AgentManagerProvider implements Disposable { case "agentManager.setTabOrder": case "agentManager.setWorktreeOrder": case "agentManager.setSessionsCollapsed": + case "agentManager.setSidebarCollapsed": case "agentManager.setReviewDiffStyle": case "agentManager.setDefaultBaseBranch": case "agentManager.createSection": @@ -1490,6 +1501,7 @@ export class AgentManagerProvider implements Disposable { tabOrder: state.getTabOrder(), worktreeOrder: state.getWorktreeOrder(), sessionsCollapsed: state.getSessionsCollapsed(), + sidebarCollapsed: state.getSidebarCollapsed(), reviewDiffStyle: state.getReviewDiffStyle(), reviewMarkdownRender: getDiffMarkdownRender(), isGitRepo: true, diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 74fea89b555..352932fde03 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -71,6 +71,7 @@ interface StateFile { tabOrder?: Record worktreeOrder?: string[] sessionsCollapsed?: boolean + sidebarCollapsed?: boolean reviewDiffStyle?: "unified" | "split" defaultBaseBranch?: string } @@ -99,6 +100,7 @@ export class WorktreeStateManager { private tabOrder: Record = {} private worktreeOrder: string[] = [] private collapsed = false + private sidebar = false private reviewDiffStyle: "unified" | "split" = "unified" private defaultBase: string | undefined private readonly log: (msg: string) => void @@ -522,6 +524,19 @@ export class WorktreeStateManager { void this.save() } + // --------------------------------------------------------------------------- + // Sidebar collapsed + // --------------------------------------------------------------------------- + + getSidebarCollapsed(): boolean { + return this.sidebar + } + + setSidebarCollapsed(value: boolean): void { + this.sidebar = value + void this.save() + } + // --------------------------------------------------------------------------- // Review diff style // --------------------------------------------------------------------------- @@ -636,6 +651,7 @@ export class WorktreeStateManager { } const repaired = this.setNormalizedWorktreeOrder(this.worktreeOrder) this.collapsed = data.sessionsCollapsed ?? false + this.sidebar = data.sidebarCollapsed ?? false if (data.reviewDiffStyle === "split") { this.reviewDiffStyle = "split" } @@ -741,6 +757,9 @@ export class WorktreeStateManager { if (this.collapsed) { data.sessionsCollapsed = true } + if (this.sidebar) { + data.sidebarCollapsed = true + } if (this.reviewDiffStyle === "split") { data.reviewDiffStyle = "split" } diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index ef52f68ad15..4a74212b7a4 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -126,6 +126,7 @@ interface StateMessage { tabOrder?: Record worktreeOrder?: string[] sessionsCollapsed?: boolean + sidebarCollapsed?: boolean reviewDiffStyle?: "unified" | "split" reviewMarkdownRender?: boolean isGitRepo?: boolean @@ -456,6 +457,11 @@ interface SetSessionsCollapsedIn { collapsed: boolean } +interface SetSidebarCollapsedIn { + type: "agentManager.setSidebarCollapsed" + collapsed: boolean +} + interface SetReviewDiffStyleIn { type: "agentManager.setReviewDiffStyle" style: "unified" | "split" @@ -736,6 +742,7 @@ export type AgentManagerInMessage = | SetTabOrderIn | SetWorktreeOrderIn | SetSessionsCollapsedIn + | SetSidebarCollapsedIn | SetReviewDiffStyleIn | SetReviewMarkdownRenderIn | SetDefaultBaseBranchIn diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index 94625e0544e..79694070a7e 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,7 +1,6 @@ -import type { KiloClient } from "@kilocode/sdk/v2/client" import { SourceController } from "../diff/SourceController" import { resolveLocalDiffTarget } from "../diff/shared/target" -import { WorktreeDiffClient } from "../diff/shared/client" +import { WorktreeDiffReverter, type StatusResolver } from "../diff/shared/reverter" import type { DiffFile } from "../diff/types" import type { DiffSource, DiffSourceDescriptor, DiffSourceFetch } from "../diff/sources/types" import type { ApplyConflict, GitOps } from "./GitOps" @@ -20,12 +19,9 @@ export interface WorktreeDiffControllerContext { getRoot: () => string | undefined getStateReady: () => Promise | undefined /** - * SDK client — used by `revert()` via `WorktreeDiffClient` for the one-shot - * file-status lookup. Hot polling paths (`request`, `requestFile`, polling) - * deliberately bypass the client and go through `localDiff`/`localDiffFile` - * to keep git spawns out of the Bun `kilo serve` process (see oven-sh/bun#18265). + * In-process diff paths deliberately bypass the SDK client to keep git spawns + * out of the Bun `kilo serve` process (see oven-sh/bun#18265). */ - getClient: () => KiloClient git: GitOps /** In-process diff summary (replaces client.worktree.diffSummary). */ localDiff: (dir: string, base: string) => Promise @@ -272,7 +268,11 @@ export class WorktreeDiffController { if (!target) return { ok: false, message: "Could not resolve diff target" } try { - const diff = new WorktreeDiffClient(this.ctx.getClient(), this.ctx.git, (...args) => this.ctx.log(...args)) + const status: StatusResolver = async (current, item) => { + const diff = await this.ctx.localDiffFile(current.directory, current.baseBranch, item) + return diff?.status + } + const diff = new WorktreeDiffReverter(this.ctx.git, status, (...args) => this.ctx.log(...args)) return await diff.revertFile(target, file) } catch (error) { const msg = error instanceof Error ? error.message : String(error) diff --git a/packages/kilo-vscode/src/diff/shared/client.ts b/packages/kilo-vscode/src/diff/shared/reverter.ts similarity index 51% rename from packages/kilo-vscode/src/diff/shared/client.ts rename to packages/kilo-vscode/src/diff/shared/reverter.ts index 18922a77a20..84900a4ccf7 100644 --- a/packages/kilo-vscode/src/diff/shared/client.ts +++ b/packages/kilo-vscode/src/diff/shared/reverter.ts @@ -1,39 +1,34 @@ -import type { KiloClient } from "@kilocode/sdk/v2/client" import type { GitOps } from "../../agent-manager/GitOps" /** - * A worktree diff target: the working directory and the base branch we diff + * A worktree target: the working directory and the base branch we diff * against (usually the tracking branch). */ export type DiffTarget = { directory: string; baseBranch: string } -type Status = "added" | "deleted" | "modified" +export type DiffStatus = "added" | "deleted" | "modified" +export type StatusResolver = (target: DiffTarget, file: string) => Promise /** - * Thin coordinator that wraps (KiloClient, GitOps, DiffTarget) and exposes the - * small set of operations used by the Changes panel's WorktreeDiffSource and - * the agent manager's WorktreeDiffController. + * Thin coordinator that wraps local diff status lookup with GitOps revert + * behavior used by the Changes panel and Agent Manager. */ -export class WorktreeDiffClient { +export class WorktreeDiffReverter { constructor( - private readonly client: KiloClient, private readonly git: GitOps, + private readonly status: StatusResolver, private readonly log: (...args: unknown[]) => void, ) {} /** * Look up the diff status for a single file. Used by revert flows to pick - * the right git strategy (added → delete, modified/deleted → checkout). + * the right git strategy (added means delete, modified/deleted means checkout). * Returns `undefined` on error so callers can still attempt a best-effort - * revert — `GitOps.revertFile` defaults to a modified-file strategy. + * revert, `GitOps.revertFile` defaults to a modified-file strategy. */ - async fileStatus(target: DiffTarget, file: string): Promise { + async fileStatus(target: DiffTarget, file: string): Promise { try { - const { data } = await this.client.worktree.diffFile( - { directory: target.directory, base: target.baseBranch, file }, - { throwOnError: true }, - ) - return data?.status + return await this.status(target, file) } catch (err) { this.log("Failed to look up file status for revert:", err) return undefined @@ -41,7 +36,7 @@ export class WorktreeDiffClient { } /** - * Revert a single file in the worktree. Composes `fileStatus` + `GitOps.revertFile`. + * Revert a single file in the worktree. Composes `fileStatus` and `GitOps.revertFile`. * Returns a normalized result; callers handle UI/messaging. */ async revertFile(target: DiffTarget, file: string): Promise<{ ok: boolean; message: string }> { diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index 2c29b3536f0..fa36aaecf2d 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -91,7 +91,7 @@ export class DiffSourceCatalog implements vscode.Disposable { build(id: string, ctx: PanelContext): DiffSource { if (id === WORKSPACE_SOURCE_ID) { - return createWorktreeDiffSource(this.connection, { baseBranchOverride: ctx.baseBranchOverride }) + return createWorktreeDiffSource({ baseBranchOverride: ctx.baseBranchOverride }) } if (id === STAGED_SOURCE_ID) return createStagedDiffSource() diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index fdb2ebe9328..fdeea76a44a 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -1,9 +1,8 @@ import * as vscode from "vscode" -import type { KiloConnectionService } from "../../services/cli-backend" import { GitOps } from "../../agent-manager/GitOps" import { diffSummary, diffFile } from "../../agent-manager/local-diff" import type { WorktreeDiffEntry } from "../../agent-manager/types" -import { WorktreeDiffClient, type DiffTarget } from "../shared/client" +import { WorktreeDiffReverter, type DiffTarget, type StatusResolver } from "../shared/reverter" import { resolveLocalDiffTarget } from "../shared/target" import { appendOutput, getWorkspaceRoot } from "../../review-utils" import type { DiffFile } from "../types" @@ -32,10 +31,7 @@ export interface WorktreeDiffSourceOptions { * `before`/`after` per file on demand via `fetchFile`. Runs entirely in the * extension host — no `kilo serve` round-trip. */ -export function createWorktreeDiffSource( - connection: KiloConnectionService, - opts: WorktreeDiffSourceOptions = {}, -): DiffSource { +export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): DiffSource { const output = vscode.window.createOutputChannel("Kilo Diff: Workspace") const log = (...args: unknown[]) => appendOutput(output, "WorktreeDiffSource", ...args) const git = new GitOps({ log }) @@ -65,6 +61,11 @@ export function createWorktreeDiffSource( return target } + const status: StatusResolver = async (current, file) => { + const entry = await diffFile(git, current.directory, current.baseBranch, file, log) + return entry?.status + } + return { descriptor: WORKSPACE_DESCRIPTOR, @@ -98,8 +99,7 @@ export function createWorktreeDiffSource( if (!current) return { ok: false, message: "Could not resolve diff target" } try { - const client = connection.getClient() - const diff = new WorktreeDiffClient(client, git, log) + const diff = new WorktreeDiffReverter(git, status, log) return await diff.revertFile(current, file) } catch (err) { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts new file mode 100644 index 00000000000..74b73a14955 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -0,0 +1,20 @@ +import { routeSuggestionWebviewMessage } from "./handlers/suggestion" +import * as ModelState from "./model-state" +import { routeInputToolMessage } from "../services/input-tools" +import type { KiloConnectionService } from "../services/cli-backend/connection-service" +import type { SuggestionContext } from "./handlers/suggestion" +import type { KiloClient } from "@kilocode/sdk/v2/client" + +type Ctx = { + question: SuggestionContext + client: KiloClient | null + connection: KiloConnectionService + dir: string + post: (msg: unknown) => void +} + +export async function routeEarlyMessage(message: { type: string }, ctx: Ctx): Promise { + await routeSuggestionWebviewMessage(ctx.question, message) + if (await ModelState.handleMessage(message.type, message, ctx.client, ctx.post)) return true + return await routeInputToolMessage(message, { connection: ctx.connection, dir: ctx.dir, post: ctx.post }) +} diff --git a/packages/kilo-vscode/src/services/input-tools.ts b/packages/kilo-vscode/src/services/input-tools.ts new file mode 100644 index 00000000000..86180d9b50c --- /dev/null +++ b/packages/kilo-vscode/src/services/input-tools.ts @@ -0,0 +1,49 @@ +import type { KiloConnectionService } from "./cli-backend/connection-service" +import { routeAutocompleteMessage } from "./autocomplete/settings" +import { buildSpeechToTextSettingsMessage } from "../speech-to-text/settings" +import { handleSpeechToTextCancel, handleSpeechToTextStart, handleSpeechToTextStop } from "../speech-to-text/handler" + +type Msg = { + type: string + requestId?: string + model?: string + language?: string +} + +type Ctx = { + connection: KiloConnectionService + dir: string + post: (msg: unknown) => void +} + +export async function routeInputToolMessage(message: Msg, ctx: Ctx): Promise { + if (await routeAutocompleteMessage(message, ctx.post)) return true + + if (message.type === "requestSpeechToTextSettings") { + ctx.post(buildSpeechToTextSettingsMessage()) + return true + } + + if (message.type === "speechToTextStart") { + if (!message.requestId) return true + handleSpeechToTextStart( + { requestId: message.requestId, model: message.model, language: message.language }, + ctx.post, + ) + return true + } + + if (message.type === "speechToTextStop") { + if (!message.requestId) return true + handleSpeechToTextStop(ctx.connection, { requestId: message.requestId }, ctx.dir, ctx.post) + return true + } + + if (message.type === "speechToTextCancel") { + if (!message.requestId) return true + handleSpeechToTextCancel({ requestId: message.requestId }, ctx.post) + return true + } + + return false +} diff --git a/packages/kilo-vscode/src/speech-to-text/capture.ts b/packages/kilo-vscode/src/speech-to-text/capture.ts new file mode 100644 index 00000000000..3ae3005601b --- /dev/null +++ b/packages/kilo-vscode/src/speech-to-text/capture.ts @@ -0,0 +1,278 @@ +import { existsSync } from "fs" +import { readFile, stat, unlink } from "fs/promises" +import * as os from "os" +import * as path from "path" +import type { ChildProcess } from "child_process" +import { exec, spawn } from "../util/process" + +type Input = { + requestId: string + model: string + language?: string +} + +type Recording = Input & { + file: string + proc: ChildProcess + stderr: string[] + stopped: boolean + exit?: { + code: number | null + signal: string | null + } +} + +type Audio = { + data: string + format: "wav" + model: string + language?: string +} + +let active: Recording | undefined +let starting: string | undefined + +export async function startSpeechCapture(input: Input): Promise { + if (active || starting) throw new Error("Speech recording is already in progress") + + starting = input.requestId + try { + const bin = await findFFmpeg() + const file = path.join(os.tmpdir(), `kilo-stt-${process.pid}-${Date.now()}.wav`) + const state = await startWithArgs(bin, file, input, await inputArgSets(bin)) + return !state.stopped + } finally { + if (starting === input.requestId) starting = undefined + } +} + +export async function stopSpeechCapture(requestId: string): Promise