diff --git a/.changeset/add-ods-support.md b/.changeset/add-ods-support.md deleted file mode 100644 index 89c27911c1..0000000000 --- a/.changeset/add-ods-support.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": minor ---- - -Support reading .ods (OpenDocument Spreadsheet) files in the read tool \ No newline at end of file diff --git a/.changeset/agent-manager-model-sync.md b/.changeset/agent-manager-model-sync.md deleted file mode 100644 index 2971b1b325..0000000000 --- a/.changeset/agent-manager-model-sync.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix agent-manager model sync on config change diff --git a/.changeset/bright-pointer-controls.md b/.changeset/bright-pointer-controls.md deleted file mode 100644 index b81deb4c6f..0000000000 --- a/.changeset/bright-pointer-controls.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"kilo-code": patch -"@kilocode/kilo-ui": patch ---- - -Show a pointer cursor for clickable controls and links in Kilo webviews. diff --git a/.changeset/brisk-terminal-bench-details.md b/.changeset/brisk-terminal-bench-details.md deleted file mode 100644 index 123669d310..0000000000 --- a/.changeset/brisk-terminal-bench-details.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@kilocode/kilo-gateway": minor -"@kilocode/cli": minor -"kilo-code": minor ---- - -Show Terminal Bench completion scores and per-attempt costs in supported model details. diff --git a/.changeset/calm-image-events.md b/.changeset/calm-image-events.md deleted file mode 100644 index 105f722b6c..0000000000 --- a/.changeset/calm-image-events.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Preserve image attachments when Photon is unavailable, enforce attachment limits for user images, and correlate shell lifecycle events correctly. diff --git a/.changeset/calm-timeline-follow.md b/.changeset/calm-timeline-follow.md new file mode 100644 index 0000000000..77c4d6ca81 --- /dev/null +++ b/.changeset/calm-timeline-follow.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep task timelines in place while reviewing earlier activity across Kilo chat surfaces, and resume following updates from the latest bar. diff --git a/.changeset/calm-titles-return.md b/.changeset/calm-titles-return.md deleted file mode 100644 index dc0386fd4a..0000000000 --- a/.changeset/calm-titles-return.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Restore automatic session titles for models that require reasoning without assuming a supported effort level. diff --git a/.changeset/cloud-session-diff-restore.md b/.changeset/cloud-session-diff-restore.md deleted file mode 100644 index 70c636c224..0000000000 --- a/.changeset/cloud-session-diff-restore.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": minor -"kilo-code": minor ---- - -Restore cloud session filesystem changes from synced session diffs when importing sessions, including inherited changes across imported session forks. diff --git a/.changeset/fix-jetbrains-reasoning.md b/.changeset/fix-jetbrains-reasoning.md new file mode 100644 index 0000000000..22e7c73ec3 --- /dev/null +++ b/.changeset/fix-jetbrains-reasoning.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Improve JetBrains reasoning blocks so active reasoning opens while streaming, completed reasoning collapses automatically, empty blocks stay hidden, and adjacent reasoning renders as one block. diff --git a/.changeset/fix-jetbrains-session-scroll.md b/.changeset/fix-jetbrains-session-scroll.md new file mode 100644 index 0000000000..f707263102 --- /dev/null +++ b/.changeset/fix-jetbrains-session-scroll.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Fix JetBrains session scrolling so mouse wheel and keyboard scrolling no longer snap back or bounce near the transcript bottom. diff --git a/.changeset/fix-worktree-project-skills.md b/.changeset/fix-worktree-project-skills.md deleted file mode 100644 index 1cdec650ce..0000000000 --- a/.changeset/fix-worktree-project-skills.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Discover project-installed skills in Agent Manager worktree sessions. diff --git a/.changeset/gentle-canyon.md b/.changeset/gentle-canyon.md new file mode 100644 index 0000000000..863dfb8c22 --- /dev/null +++ b/.changeset/gentle-canyon.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Prevent the JetBrains session scrollbar from covering transcript content. diff --git a/.changeset/glob-jetbrains-view.md b/.changeset/glob-jetbrains-view.md new file mode 100644 index 0000000000..be879d87bb --- /dev/null +++ b/.changeset/glob-jetbrains-view.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render glob search results in the JetBrains chat as collapsible tool output with separate directory and pattern rows. diff --git a/.changeset/isolate-forked-subagents.md b/.changeset/isolate-forked-subagents.md deleted file mode 100644 index 42e59605a9..0000000000 --- a/.changeset/isolate-forked-subagents.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Keep subagent sessions isolated when forking sessions through editor clients. diff --git a/.changeset/jetbrains-session-icons.md b/.changeset/jetbrains-session-icons.md new file mode 100644 index 0000000000..b8e4c77dcb --- /dev/null +++ b/.changeset/jetbrains-session-icons.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Use matching VS Code-style icons for JetBrains session views. diff --git a/.changeset/plan-followup-clean-session.md b/.changeset/plan-followup-clean-session.md deleted file mode 100644 index 4188e0a61a..0000000000 --- a/.changeset/plan-followup-clean-session.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Avoid copying visible planning chat into new sessions started from the plan follow-up prompt. diff --git a/.changeset/quiet-concurrent-streams.md b/.changeset/quiet-concurrent-streams.md deleted file mode 100644 index 839c227894..0000000000 --- a/.changeset/quiet-concurrent-streams.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Keep Agent Manager streaming responsive when multiple sessions run concurrently. diff --git a/.changeset/quiet-jetbrains-separators.md b/.changeset/quiet-jetbrains-separators.md new file mode 100644 index 0000000000..c5a6009890 --- /dev/null +++ b/.changeset/quiet-jetbrains-separators.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Hide thematic separator lines in JetBrains chat markdown while preserving surrounding prose and code blocks. diff --git a/.changeset/refine-jetbrains-card-borders.md b/.changeset/refine-jetbrains-card-borders.md new file mode 100644 index 0000000000..d2a244ee74 --- /dev/null +++ b/.changeset/refine-jetbrains-card-borders.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Refine JetBrains session card borders so prompt and question surfaces use brighter outlines while reasoning and tool cards use softer default borders. diff --git a/.changeset/relative-jetbrains-search-paths.md b/.changeset/relative-jetbrains-search-paths.md new file mode 100644 index 0000000000..a5e48794d1 --- /dev/null +++ b/.changeset/relative-jetbrains-search-paths.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Display JetBrains search tool paths relative to the current repository when possible. diff --git a/.changeset/rename-mercury-autocomplete-labels.md b/.changeset/rename-mercury-autocomplete-labels.md deleted file mode 100644 index 3805014e30..0000000000 --- a/.changeset/rename-mercury-autocomplete-labels.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Rename Mercury autocomplete model labels to distinguish FIM and Next Edit modes. diff --git a/.changeset/reset-fork-costs.md b/.changeset/reset-fork-costs.md deleted file mode 100644 index 957422296f..0000000000 --- a/.changeset/reset-fork-costs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Start forked sessions at zero cost instead of carrying over the source session's spend. diff --git a/.changeset/reset-jetbrains-session-hover.md b/.changeset/reset-jetbrains-session-hover.md new file mode 100644 index 0000000000..dc588b95f1 --- /dev/null +++ b/.changeset/reset-jetbrains-session-hover.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Reset stale hover styling when moving between JetBrains session cards and draw card outlines only while expanded. diff --git a/.changeset/restore-session-export.md b/.changeset/restore-session-export.md new file mode 100644 index 0000000000..eea6ce17a8 --- /dev/null +++ b/.changeset/restore-session-export.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Re-enable free-model session and Git workspace data export. diff --git a/.changeset/route-worktree-permissions.md b/.changeset/route-worktree-permissions.md deleted file mode 100644 index b97fb570bc..0000000000 --- a/.changeset/route-worktree-permissions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Route Agent Manager permission approvals to the worktree that created the request. diff --git a/.changeset/search-jetbrains-view.md b/.changeset/search-jetbrains-view.md new file mode 100644 index 0000000000..f1ca380083 --- /dev/null +++ b/.changeset/search-jetbrains-view.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Render grep searches in the JetBrains chat with a dedicated search header that shows stacked, clipped targets. diff --git a/.changeset/session-prompt-queue-memory-leak.md b/.changeset/session-prompt-queue-memory-leak.md deleted file mode 100644 index 7a035f2f78..0000000000 --- a/.changeset/session-prompt-queue-memory-leak.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Prevent memory leak in KiloSessionPromptQueue.cancel for sessions without active tails diff --git a/.changeset/stellar-wolf.md b/.changeset/stellar-wolf.md new file mode 100644 index 0000000000..5248ade0cf --- /dev/null +++ b/.changeset/stellar-wolf.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Improve mouse wheel scrolling speed in the JetBrains session view. diff --git a/.changeset/sync-api-key-sessions.md b/.changeset/sync-api-key-sessions.md new file mode 100644 index 0000000000..059681c1f5 --- /dev/null +++ b/.changeset/sync-api-key-sessions.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Sync CLI sessions to Kilo session history when authenticated with `KILO_API_KEY` when no stored Kilo auth is present. diff --git a/.changeset/tidy-context-preflight.md b/.changeset/tidy-context-preflight.md deleted file mode 100644 index ce309a6e35..0000000000 --- a/.changeset/tidy-context-preflight.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Compact sessions at the configured context percentage before sending an oversized provider request. diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml index 33c145c142..c071fd5bc8 100644 --- a/.github/workflows/close-issues.yml +++ b/.github/workflows/close-issues.yml @@ -7,6 +7,7 @@ on: jobs: close: + if: github.repository == 'anomalyco/opencode' # kilocode_change - Kilo uses kilo-auto-close.yml runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index 430d0183ff..3e7b1db14b 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -17,7 +17,7 @@ permissions: jobs: close-stale-prs: - if: github.repository == 'Kilo-Org/kilocode' + if: github.repository == 'anomalyco/opencode' # kilocode_change - Kilo uses kilo-auto-close.yml runs-on: blacksmith-2vcpu-ubuntu-2404 # kilocode_change timeout-minutes: 15 steps: diff --git a/.github/workflows/kilo-auto-close.yml b/.github/workflows/kilo-auto-close.yml new file mode 100644 index 0000000000..65c87b9c4a --- /dev/null +++ b/.github/workflows/kilo-auto-close.yml @@ -0,0 +1,345 @@ +# kilocode_change - new file +name: kilo-auto-close + +on: + workflow_dispatch: + inputs: + dryRun: + description: "Log actions without closing items" + type: boolean + default: true + schedule: + - cron: "0 6 * * *" + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + close: + if: github.repository == 'Kilo-Org/kilocode' + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 30 + steps: + - name: Close inactive PRs and issues + uses: actions/github-script@v8 + env: + KILO_AUTO_CLOSE_ENABLED: ${{ vars.KILO_AUTO_CLOSE_ENABLED }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const PR_DAYS_INACTIVE = 30 + const ISSUE_DAYS_INACTIVE = 60 + const MAX_RETRIES = 3 + + // Adaptive delay: fast for small batches, slower for large to respect + // GitHub's 80 content-generating requests/minute limit + const SMALL_BATCH_THRESHOLD = 10 + const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 items) + const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 items) = ~30 ops/min, well under 80 limit + + const startTime = Date.now() + const prCutoff = new Date(Date.now() - PR_DAYS_INACTIVE * 24 * 60 * 60 * 1000) + const issueCutoff = new Date(Date.now() - ISSUE_DAYS_INACTIVE * 24 * 60 * 60 * 1000) + const { owner, repo } = context.repo + const dryRunInput = context.payload.inputs?.dryRun + const enabled = process.env.KILO_AUTO_CLOSE_ENABLED === "true" + const dryRun = dryRunInput === undefined + ? !enabled + : dryRunInput !== "false" + + core.info(`Dry run mode: ${dryRun}`) + core.info(`PR cutoff date: ${prCutoff.toISOString()}`) + core.info(`Issue cutoff date: ${issueCutoff.toISOString()}`) + + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + async function withRetry(fn, description = 'API call') { + let lastError + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const result = await fn() + return result + } catch (error) { + lastError = error + const isRateLimited = error.status === 403 && + (error.message?.includes('rate limit') || error.message?.includes('secondary')) + + if (!isRateLimited) { + throw error + } + + if (attempt === MAX_RETRIES - 1) { + break + } + + // Parse retry-after header, default to 60 seconds + const retryAfter = error.response?.headers?.['retry-after'] + ? parseInt(error.response.headers['retry-after']) + : 60 + + // Exponential backoff: retryAfter * 2^attempt + const backoffMs = retryAfter * 1000 * Math.pow(2, attempt) + + core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`) + + await sleep(backoffMs) + } + } + core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`) + throw lastError + } + + const query = ` + query($owner: String!, $repo: String!, $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequests(first: 100, states: OPEN, after: $cursor) { + pageInfo { + hasNextPage + endCursor + } + nodes { + number + title + author { + login + } + createdAt + commits(last: 1) { + nodes { + commit { + committedDate + } + } + } + comments(last: 1) { + nodes { + createdAt + } + } + reviews(last: 1) { + nodes { + createdAt + } + } + } + } + } + } + ` + + const allPrs = [] + let cursor = null + let hasNextPage = true + let pageCount = 0 + + while (hasNextPage) { + pageCount++ + core.info(`Fetching page ${pageCount} of open PRs...`) + + const result = await withRetry( + () => github.graphql(query, { owner, repo, cursor }), + `GraphQL page ${pageCount}` + ) + + allPrs.push(...result.repository.pullRequests.nodes) + hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage + cursor = result.repository.pullRequests.pageInfo.endCursor + + core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`) + + // Delay between pagination requests (use small batch delay for reads) + if (hasNextPage) { + await sleep(SMALL_BATCH_DELAY_MS) + } + } + + core.info(`Found ${allPrs.length} open pull requests`) + + const stalePrs = allPrs.filter((pr) => { + const dates = [ + new Date(pr.createdAt), + pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null, + pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null, + pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null, + ].filter((d) => d !== null) + + const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0] + + if (!lastActivity || lastActivity > prCutoff) { + core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`) + return false + } + + core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`) + return true + }) + + core.info(`Found ${stalePrs.length} stale pull requests`) + + const prDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD + ? LARGE_BATCH_DELAY_MS + : SMALL_BATCH_DELAY_MS + + core.info(`Using ${prDelayMs}ms delay between PR operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`) + + let closedPrCount = 0 + let skippedPrCount = 0 + + for (const pr of stalePrs) { + const issue_number = pr.number + const closeComment = `To stay organized pull requests are automatically closed after ${PR_DAYS_INACTIVE} days of inactivity. If the pull request is still relevant please open a new one.` + + if (dryRun) { + core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) + continue + } + + try { + // Add comment + await withRetry( + () => github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: closeComment, + }), + `Comment on PR #${issue_number}` + ) + + // Close PR + await withRetry( + () => github.rest.pulls.update({ + owner, + repo, + pull_number: issue_number, + state: "closed", + }), + `Close PR #${issue_number}` + ) + + closedPrCount++ + core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) + + // Delay before processing next PR + await sleep(prDelayMs) + } catch (error) { + skippedPrCount++ + core.error(`Failed to close PR #${issue_number}: ${error.message}`) + } + } + + let page = 1 + let stop = false + const staleIssues = [] + + while (!stop) { + core.info(`Fetching page ${page} of open issues...`) + + const result = await withRetry( + () => github.rest.issues.listForRepo({ + owner, + repo, + state: "open", + sort: "updated", + direction: "asc", + per_page: 100, + page, + }), + `Issues page ${page}` + ) + + if (!result.data.length) { + break + } + + core.info(`Page ${page}: fetched ${result.data.length} issues`) + + for (const issue of result.data) { + const updated = new Date(issue.updated_at) + + if (updated >= issueCutoff) { + core.info(`Found fresh issue/PR #${issue.number} (${updated.toISOString()}), stopping issue scan`) + stop = true + break + } + + if (issue.pull_request) { + core.info(`Skipping PR #${issue.number} in issue scan`) + continue + } + + staleIssues.push(issue) + } + + if (!stop) { + page++ + await sleep(SMALL_BATCH_DELAY_MS) + } + } + + core.info(`Found ${staleIssues.length} stale issues`) + + const issueDelayMs = staleIssues.length > SMALL_BATCH_THRESHOLD + ? LARGE_BATCH_DELAY_MS + : SMALL_BATCH_DELAY_MS + + core.info(`Using ${issueDelayMs}ms delay between issue operations (${staleIssues.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`) + + let closedIssueCount = 0 + let skippedIssueCount = 0 + + for (const issue of staleIssues) { + const closeComment = `To stay organized issues are automatically closed after ${ISSUE_DAYS_INACTIVE} days of no activity. If the issue is still relevant please open a new one.` + + if (dryRun) { + core.info(`[dry-run] Would close issue #${issue.number}: ${issue.title}`) + continue + } + + try { + await withRetry( + () => github.rest.issues.createComment({ + owner, + repo, + issue_number: issue.number, + body: closeComment, + }), + `Comment on issue #${issue.number}` + ) + + await withRetry( + () => github.rest.issues.update({ + owner, + repo, + issue_number: issue.number, + state: "closed", + state_reason: "not_planned", + }), + `Close issue #${issue.number}` + ) + + closedIssueCount++ + core.info(`Closed issue #${issue.number}: ${issue.title}`) + + await sleep(issueDelayMs) + } catch (error) { + skippedIssueCount++ + core.error(`Failed to close issue #${issue.number}: ${error.message}`) + } + } + + const elapsed = Math.round((Date.now() - startTime) / 1000) + core.info(`\n========== Summary ==========`) + core.info(`Total open PRs found: ${allPrs.length}`) + core.info(`Stale PRs identified: ${stalePrs.length}`) + core.info(`PRs closed: ${closedPrCount}`) + core.info(`PRs skipped (errors): ${skippedPrCount}`) + core.info(`Stale issues identified: ${staleIssues.length}`) + core.info(`Issues closed: ${closedIssueCount}`) + core.info(`Issues skipped (errors): ${skippedIssueCount}`) + core.info(`Elapsed time: ${elapsed}s`) + core.info(`=============================`) diff --git a/.gitignore b/.gitignore index 5611c39a76..74c51f4cea 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ tsconfig.tsbuildinfo .kilo/bun.lock .kilo/yarn.lock .kilo/node_modules +.kilo/plans/ .kilo/plans/*upstream-merge-report-*.md .kilocode/.gitignore .kilocode/package.json diff --git a/bun.lock b/bun.lock index f68abc5e03..e05ea04521 100644 --- a/bun.lock +++ b/bun.lock @@ -33,7 +33,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.3.40", + "version": "7.3.41", "bin": { "opencode": "./bin/opencode", }, @@ -96,7 +96,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@effect/platform-node": "catalog:", "effect": "catalog:", @@ -109,7 +109,7 @@ }, "packages/kilo-console": { "name": "@kilocode/kilo-console", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@kilocode/kilo-web-ui": "workspace:*", "@kilocode/sdk": "workspace:*", @@ -131,7 +131,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -161,7 +161,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -198,7 +198,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.3.40", + "version": "7.3.41", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -208,7 +208,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -240,11 +240,11 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.3.40", + "version": "7.3.41", }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -258,7 +258,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", @@ -295,7 +295,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-gateway": "workspace:*", @@ -361,7 +361,7 @@ }, "packages/kilo-web-ui": { "name": "@kilocode/kilo-web-ui", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@kilocode/kilo-ui": "workspace:*", "@kobalte/core": "catalog:", @@ -376,7 +376,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", @@ -394,7 +394,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.3.40", + "version": "7.3.41", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -562,7 +562,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -590,7 +590,7 @@ }, "packages/plugin-atomic-chat": { "name": "@kilocode/plugin-atomic-chat", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@kilocode/plugin": "workspace:*", }, @@ -604,7 +604,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "semver": "^7.6.3", }, @@ -615,7 +615,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "cross-spawn": "catalog:", }, @@ -630,7 +630,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.3.40", + "version": "7.3.41", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -653,7 +653,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.3.40", + "version": "7.3.41", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", diff --git a/package.json b/package.json index 776fafb51b..1488346535 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,6 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, - "version": "7.3.40", + "version": "7.3.41", "peerDependencies": {} } diff --git a/packages/core/package.json b/packages/core/package.json index 7d65c584e1..e88fd53812 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.40", + "version": "7.3.41", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index fca4f7c7e5..7aefe40497 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.3.40" +version = "7.3.41" 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.3.40/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.41/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.3.40/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.41/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.40/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.41/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.3.40/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.41/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.3.40/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.3.41/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index e17add2805..359c526f2f 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.40", + "version": "7.3.41", "name": "@opencode-ai/http-recorder", "type": "module", "license": "MIT", diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index b1518c177c..459e2e7cbb 100644 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.3.40", + "version": "7.3.41", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-docs/lychee.toml b/packages/kilo-docs/lychee.toml index 5b16153b70..fe1be68c3a 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -10,6 +10,7 @@ exclude_path = ["node_modules"] accept = [ "200", + "202", "403", "429", "301", diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index ab048e9388..ebf2d69e4f 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.3.40", + "version": "7.3.41", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/ai-providers/deepseek.md b/packages/kilo-docs/pages/ai-providers/deepseek.md index 3bc77aeda5..8323bc9d0c 100644 --- a/packages/kilo-docs/pages/ai-providers/deepseek.md +++ b/packages/kilo-docs/pages/ai-providers/deepseek.md @@ -8,11 +8,11 @@ sidebar_label: DeepSeek Kilo Code supports accessing models through the DeepSeek API, including `deepseek-chat` and `deepseek-reasoner`. -**Website:** [DeepSeek API documentation](https://api-docs.deepseek.com/) +**Website:** [https://platform.deepseek.com/](https://platform.deepseek.com/) ## Getting an API Key -1. **Sign Up/Sign In:** Go to the [DeepSeek API keys page](https://platform.deepseek.com/api_keys). Create an account or sign in. +1. **Sign Up/Sign In:** Go to the [DeepSeek Platform](https://platform.deepseek.com/). Create an account or sign in. 2. **Navigate to API Keys:** Find your API keys in the [API keys](https://platform.deepseek.com/api_keys) section of the platform. 3. **Create a Key:** Click "Create new API key". Give your key a descriptive name (e.g., "Kilo Code"). 4. **Copy the Key:** **Important:** Copy the API key _immediately_. You will not be able to see it again. Store it securely. diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index d9b35be6df..0a5f2c779f 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.3.40", + "version": "7.3.41", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 22feb7f1d2..457ca1cb46 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.3.40", + "version": "7.3.41", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 2f94fbd30d..a300f566ca 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.3.40", + "version": "7.3.41", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index d9a13a7427..3bb2c6abab 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -268,6 +268,22 @@ Tests for retained Swing components should assert: - `update(model)` changes existing labels/body text without duplicating components. - Updates while collapsed do not eagerly create lazy bodies. - No-op updates, empty deltas, repeated hover values, and toggling non-expandable cards do not repaint/revalidate the whole view. +- Streaming/rebuilding surfaces additionally require stress + leak tests (see below). + +### Stress and Leak Tests for Streaming UI + +Session/transcript UI that streams updates or rebuilds its component tree (markdown +views, code blocks, transcript parts, collapsible cards) must ship stress + leak tests in +addition to behavior tests. These tests must: + +- Drive many updates (hundreds of streamed deltas or `set` cycles) through the public API. +- Assert that retained component instances stay identical across updates (`assertSame`). +- Assert the component count stays bounded — no growth per update. +- Assert disposable-backed resources return to baseline after churn + clear/dispose. + For code editors, compare `EditorFactory.getInstance().allEditors.size` against a + baseline captured before the loop. + +See `MdViewHybridStressTest` for the reference pattern. ### Platform Components and Utilities diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index c4f0e8224c..14b4f73828 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -6,6 +6,26 @@ ## [Unreleased] +## [7.0.1-rc.8] - 2026-06-09 + +### Added + +- Display search results and tool output in clearer, more readable JetBrains session cards. + +### Fixed + +- Improve session transcript scrolling so streaming updates, expanded cards, reasoning blocks, and mouse wheel scrolling preserve the user's position more reliably. +- Make session transcripts easier to scan with tighter spacing, aligned icons, cleaner card outlines, relative search paths, and less visual noise. +- Keep completed reasoning blocks expanded after a response finishes. +- Improve session stability during long-running or cancelled prompts. +- Restore automatic session titles, project skill discovery, and subagent isolation in forked sessions. +- Restore imported cloud session diffs. +- Compact sessions before the configured context limit is exceeded. + +### Changed + +- Update the bundled Kilo CLI runtime with the latest fixes used by the JetBrains plugin. + ## [7.0.1-rc.7] - 2026-06-04 ### Fixed 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 99641c5e59..dd97cbabc9 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 @@ -287,7 +287,18 @@ class SessionUi( dismiss = { controller.dismissLoginRequired() }, selection = selection, ) - messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile, ::openUrl, selection) + messageBody = SessionMessageListPanel( + controller.model, + this, + question, + permission, + login, + ::openFile, + ::openUrl, + selection, + repo = workspace.directory, + resize = { anchor, fn -> scroll.preserve(anchor, fn) }, + ) header = SessionHeaderPanel(controller, this) scroll = SessionScroll(root, sessionContent, messageBody, blankBody) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt index d320d179b0..018cea2eb0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/scroll/SessionScroll.kt @@ -51,10 +51,10 @@ internal class SessionScroll( private var opening = false private var stable = -1 private var seq = 0 + private var pause = false private var user = false private var value = 0 private var question = false - private var restoring = false init { jump = JBLabel(ScrollButtonIcon.create()).apply { @@ -73,7 +73,6 @@ internal class SessionScroll( user = true } }) - component.viewport.addChangeListener { onViewport() } component.verticalScrollBar.addAdjustmentListener { onScroll() } root.addOverlay(jump) { _, child -> val size = child.preferredSize @@ -98,11 +97,10 @@ internal class SessionScroll( @RequiresEdt fun atBottom(): Boolean { - val bar = component.verticalScrollBar return when { component.viewport.view !== messages -> tail - bar.maximum <= bar.visibleAmount -> true - else -> bar.value + bar.visibleAmount >= bar.maximum - JBUI.scale(THRESHOLD) + !tail -> false + else -> near() } } @@ -114,6 +112,7 @@ internal class SessionScroll( return } user = false + pause = false tail = true stable = -1 auto = true @@ -139,11 +138,44 @@ internal class SessionScroll( return component.viewport.view === messages && tail } + @RequiresEdt + fun preserve(anchor: JComponent, action: () -> Unit) { + if (component.viewport.view !== messages) { + action() + return + } + val pos = SwingUtilities.convertPoint(anchor, Point(0, 0), messages) + val delta = pos.y - component.viewport.viewPosition.y + seq++ + stable = -1 + user = false + pause = false + auto = true + try { + action() + layoutScroll() + val next = SwingUtilities.convertPoint(anchor, Point(0, 0), messages) + val y = (next.y - delta).coerceIn(0, bottom()) + component.viewport.viewPosition = Point(0, y) + bar.value = y + } finally { + auto = false + } + tail = atBottom() + syncValue() + updateJump() + if (tail) { + stable = -1 + seq++ + } + } + @RequiresEdt fun openBottom(done: () -> Unit) { opening = true stable = -1 user = false + pause = false tail = true auto = true show(messages) @@ -169,8 +201,8 @@ internal class SessionScroll( @RequiresEdt fun applyStyle(style: SessionEditorStyle) { this.style = style - component.background = SessionUiStyle.View.transcript() - component.viewport.background = SessionUiStyle.View.transcript() + component.background = SessionUiStyle.Transcript.bgColor() + component.viewport.background = SessionUiStyle.Transcript.bgColor() syncIcon() messages.applyStyle(style) val view = component.viewport.view @@ -189,6 +221,7 @@ internal class SessionScroll( opening = false stable = -1 user = false + pause = false tail = true auto = true show(messages) @@ -271,49 +304,62 @@ internal class SessionScroll( bar.value = bottom() } - @RequiresEdt - private fun onViewport() { - if (restoring || auto || opening || user || tail || component.viewport.view !== messages) return - val y = value.coerceIn(0, bottom()) - if (component.viewport.viewPosition.y == y && bar.value == y) return - restoring = true - try { - component.viewport.viewPosition = Point(0, y) - bar.value = y - } finally { - restoring = false - } - updateJump() - } - @RequiresEdt private fun bottom(): Int { val bar = component.verticalScrollBar return (bar.maximum - bar.visibleAmount).coerceAtLeast(bar.minimum) } + @RequiresEdt + private fun near(): Boolean { + val bar = component.verticalScrollBar + return bar.maximum <= bar.visibleAmount || bar.value + bar.visibleAmount >= bar.maximum - JBUI.scale(THRESHOLD) + } + @RequiresEdt private fun onScroll() { + val prev = value val moved = bar.value != value + val down = bar.value > value syncValue() if (auto || opening) { updateJump() return } if (component.viewport.view === messages) { - val bottom = atBottom() + val bottom = near() if (bottom) { - tail = true + if (user && moved && !down) { + tail = false + pause = true + } else if (!tail && !user) { + if (moved) { + auto = true + try { + bar.value = prev.coerceIn(bar.minimum, bottom()) + } finally { + auto = false + } + syncValue() + } + tail = false + } else if (pause && !user) { + tail = false + } else { + tail = true + pause = false + } user = false updateJump() return } - if (tail && (!user || !moved)) { + if (tail && !user && !moved) { user = false followBottom(true) return } tail = false + pause = false user = false seq++ } 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 9b6beb55e8..63d42d0cb5 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 @@ -94,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(SessionUiStyle.View.line(), 1, 0, 0, 0) + border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width(), 0, 0, 0) left.add(toggle, BorderLayout.WEST) left.add(label, BorderLayout.CENTER) header.add(left, BorderLayout.CENTER) 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 bb2ed7d869..5a9d6d757c 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 @@ -5,12 +5,14 @@ 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.session.ui.style.SessionUiStyle import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.StackAxis import com.intellij.openapi.Disposable import com.intellij.ui.AnimatedIcon import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI /** * Progress footer rendered at the bottom of the session transcript while the @@ -35,6 +37,12 @@ class ProgressPanel( init { isOpaque = false isVisible = false + border = JBUI.Borders.empty( + UiStyle.Gap.sm(), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + 0, + 0, + ) applyStyle(SessionEditorStyle.current()) next(JBLabel(AnimatedIcon.Default())) 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 d2506bb67b..18c7d7410c 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 @@ -13,9 +13,11 @@ import ai.kilocode.client.session.views.MessageView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TurnView +import ai.kilocode.client.session.views.base.PartView import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI +import javax.swing.JComponent /** * Scrollable transcript panel that maps the model's turn grouping to @@ -51,13 +53,15 @@ class SessionMessageListPanel( private val openFile: (String) -> Unit, private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, + private val repo: String? = null, + private val resize: ((JComponent, () -> Unit) -> Unit)? = null, ) : SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), JBUI.insets( SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, - SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING, + SessionUiStyle.SessionLayout.TRANSCRIPT_PADDING + SessionUiStyle.SessionLayout.TRANSCRIPT_SCROLLBAR_PADDING, ), ), Disposable, SessionEditorStyleTarget { @@ -66,13 +70,14 @@ class SessionMessageListPanel( private val msgToView = HashMap() private var style = SessionEditorStyle.current() private var hiddenTool: ToolCallRef? = null + private var hovered: PartView? = null /** Progress footer — always the last child inside the scroll. */ val progress = ProgressPanel(model, parent) init { isOpaque = true - background = SessionUiStyle.View.transcript() + background = SessionUiStyle.Transcript.bgColor() Disposer.register(parent, this) model.addListener(parent) { event -> @@ -177,7 +182,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo, ::hover) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -223,6 +228,7 @@ class SessionMessageListPanel( } private fun rebuild() { + clearHover() turnViews.values.forEach { remove(it) Disposer.dispose(it) @@ -233,7 +239,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo, ::hover) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -249,6 +255,7 @@ class SessionMessageListPanel( } private fun clear() { + clearHover() turnViews.values.forEach { remove(it) Disposer.dispose(it) @@ -338,9 +345,26 @@ class SessionMessageListPanel( repaint() } + private fun hover(view: PartView, value: Boolean) { + if (value) { + val prev = hovered + if (prev === view) return + hovered = view + prev?.setHovered(false) + return + } + if (hovered === view) hovered = null + } + + private fun clearHover() { + val view = hovered ?: return + hovered = null + view.setHovered(false) + } + override fun applyStyle(style: SessionEditorStyle) { this.style = style - background = SessionUiStyle.View.transcript() + background = SessionUiStyle.Transcript.bgColor() for (view in turnViews.values) view.applyStyle(style) question?.applyStyle(style) permission?.applyStyle(style) @@ -350,6 +374,7 @@ class SessionMessageListPanel( } override fun dispose() { + clearHover() turnViews.values.forEach { remove(it) Disposer.dispose(it) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt index 4c71a93396..5c4f63dd14 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlay.kt @@ -194,7 +194,7 @@ internal class SessionAccountOverlay( @RequiresEdt private fun showPopup() { - val bg = SessionUiStyle.View.sessionViewBackground() + val bg = SessionUiStyle.AccountPopup.bgColor() val model = CollectionListModel(choices) val list = JBList(model).apply { selectionMode = ListSelectionModel.SINGLE_SELECTION @@ -285,7 +285,7 @@ internal class SessionAccountOverlay( internal fun choiceCount() = choices.size internal fun selectedIndex() = choices.indexOfFirst { it.org == currentOrgId }.takeIf { it >= 0 } ?: 0 internal fun panelBackground() = panel.background - internal fun panelBorderColor() = SessionUiStyle.View.sessionViewOutline() + internal fun panelBorderColor() = SessionUiStyle.AccountPopup.outlineColor() internal fun balanceVisible() = balance.isVisible internal fun balanceIcon() = balance.icon internal fun balanceText() = balanceText 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 1ebc2c0968..329c895e4c 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 @@ -491,10 +491,10 @@ class PromptPanel( override fun outlineColor() = if (UIUtil.isFocusAncestor(editor)) { JBUI.CurrentTheme.Focus.focusColor() } else { - SessionUiStyle.View.line() + SessionUiStyle.View.Outline.brightColor() } - override fun outlineWidth() = if (UIUtil.isFocusAncestor(editor)) focus.get() else JBUI.scale(1) + override fun outlineWidth() = if (UIUtil.isFocusAncestor(editor)) focus.get() else SessionUiStyle.View.Outline.width() override fun cornerArc() = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt index 2a52484c25..0f7fb1a14b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/selection/SessionSelection.kt @@ -157,7 +157,7 @@ class SessionSelection : Disposable { override fun clearSelection() { val pos = component.selectionStart.coerceIn(0, component.document.length) - component.select(pos, pos) + component.caretPosition = pos } override 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 index 83fc0fa200..9ffd77482d 100644 --- 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 @@ -9,51 +9,57 @@ import javax.swing.border.Border /** Static style tokens owned by the chat session UI. */ object SessionUiStyle { + object Transcript { + fun bgColor(): Color = UiStyle.Colors.bg() + } + /** Geometry for the transcript list and its scroll behavior. */ object SessionLayout { - const val GAP = 4 + const val GAP = 3 const val TRANSCRIPT_PADDING = 12 + const val TRANSCRIPT_SCROLLBAR_PADDING = 10 const val USER_PROMPT_INDENT = 100 - const val SCROLL_INCREMENT = 16 + const val SCROLL_INCREMENT = 48 } /** Shared tokens for individual transcript views and session views. */ object View { - const val SESSION_VIEW_GAP = 6 - const val SESSION_VIEW_VERTICAL_PADDING = 8 - const val SESSION_VIEW_HORIZONTAL_PADDING = 12 - const val SESSION_VIEW_BODY_EXTRA_HEIGHT = 16 + object Layout { + const val GAP = 5 + const val VERTICAL_PADDING = 7 + const val HORIZONTAL_PADDING = 12 + const val BODY_EXTRA_HEIGHT = 16 + } internal const val BORDER_DELTA = 80 internal const val HOVER_BORDER_ALPHA = 0.18f internal const val HOVER_FILL_ALPHA = 0.10f - /** Creates a visible separator against editor-derived transcript surfaces. */ - fun line(): Color = JBColor.lazy { UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) } + object Surface { + fun bgColor(): Color = UiStyle.Colors.editorBackground() - fun transcript(): Color = UiStyle.Colors.bg() + fun headerBgColor(): Color = UiStyle.Colors.editorBackground() - fun sessionViewBackground(): Color = UiStyle.Colors.contentBackground() - - fun sessionViewOutline(): Color = UiStyle.Colors.contentBorder() - - fun surface(): Color = UiStyle.Colors.editorBackground() - - fun header(): Color = UiStyle.Colors.editorBackground() - - /** Subtle hover fill, softer than the session-view outline. */ - fun headerHover(): Color = JBColor.lazy { UiStyle.Colors.blend(header(), hoverLine(), HOVER_FILL_ALPHA) } - - /** Subtle hover outline, stronger than the hover fill. */ - fun hoverLine(): Color = JBColor.lazy { - UiStyle.Colors.blend(line(), JBUI.CurrentTheme.ActionButton.hoverBackground(), HOVER_BORDER_ALPHA) + /** Subtle hover fill, softer than the session-view outline. */ + fun headerHoverBgColor(): Color = JBColor.lazy { + UiStyle.Colors.blend(headerBgColor(), Outline.hoverColor(), HOVER_FILL_ALPHA) + } } - fun sessionView(color: Color = line()): Border = outline(color) + object Outline { + fun color(): Color = UiStyle.Colors.contentBorder() - fun outline(color: Color = line()): Border = JBUI.Borders.customLine(color, 1) + fun brightColor(): Color = JBColor.lazy { + UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) + } - fun topOutline(): Border = JBUI.Borders.customLineTop(line()) + /** Subtle hover outline, stronger than the hover fill. */ + fun hoverColor(): Color = JBColor.lazy { + UiStyle.Colors.blend(brightColor(), JBUI.CurrentTheme.ActionButton.hoverBackground(), HOVER_BORDER_ALPHA) + } + + fun width(): Int = JBUI.scale(1) + } /** Prompt input dimensions and chrome inside the session view. */ object Prompt { @@ -74,6 +80,9 @@ object SessionUiStyle { /** Reasoning block preview sizing. */ object Reasoning { const val BODY_LINES = 5 + const val HEADER_VERTICAL_PADDING = 5 + const val BODY_VERTICAL_PADDING = 4 + const val BODY_HORIZONTAL_PADDING = 8 } /** Message container roles and user bubble geometry. */ @@ -119,6 +128,12 @@ object SessionUiStyle { } } + object AccountPopup { + fun bgColor(): Color = UiStyle.Colors.contentBackground() + + fun outlineColor(): Color = UiStyle.Colors.contentBorder() + } + /** Limits for the empty-state recent sessions list. */ object RecentSessions { const val LIMIT = 5 @@ -140,7 +155,13 @@ object SessionUiStyle { /** Border presets for connection dock panel. */ object Dock { fun banner(): Border = JBUI.Borders.compound( - JBUI.Borders.customLineTop(SessionUiStyle.View.line()), + JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ), JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.lg(), 0, UiStyle.Gap.lg()), )!! } 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 25435404d9..ece0854420 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 @@ -40,7 +40,7 @@ class CompactionView(@Suppress("UNUSED_PARAMETER") compaction: Compaction) : Par applyStyle(SessionEditorStyle.current()) val line = { JPanel().apply { - background = SessionUiStyle.View.line() + background = SessionUiStyle.View.Outline.color() isOpaque = true preferredSize = JBDimension(0, JBUI.scale(1)) } } 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 5b71c23b5e..d0b5e06b44 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 @@ -2,6 +2,7 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.model.Message +import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.model.StepFinish import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolCallRef @@ -18,6 +19,7 @@ import com.intellij.util.ui.JBUI import java.awt.Graphics import java.awt.Graphics2D import java.awt.RenderingHints +import javax.swing.JComponent /** * A single message container inside a [TurnView]. @@ -36,6 +38,9 @@ class MessageView( private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, + private val resize: ((JComponent, () -> Unit) -> Unit)? = null, + private val repo: String? = null, + private val hover: ((PartView, Boolean) -> Unit)? = null, ) : ai.kilocode.client.session.ui.SessionLayoutPanel( JBUI.scale(SessionUiStyle.SessionLayout.GAP), ), Disposable, SessionEditorStyleTarget, SessionView { @@ -48,6 +53,11 @@ class MessageView( get() = if (role == SessionUiStyle.View.Message.USER_ROLE) SessionView.Kind.UserPrompt else SessionView.Kind.Default private val parts = LinkedHashMap() + // Adjacent reasoning parts render through the first ReasoningView. aliases maps each + // merged child id to that owner id, and sources stores the child's latest full text + // so snapshot updates can append only deltas. + private val aliases = LinkedHashMap() + private val sources = LinkedHashMap() private var hidden: ToolCallRef? = null init { @@ -59,10 +69,7 @@ class MessageView( for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = view(content) - view.applyStyle(style) - parts[content.id] = view - add(view) + addPart(content) } } @@ -81,8 +88,11 @@ class MessageView( if (content is StepFinish) return if (isHidden(content)) { // Remove any stale view for this content so it disappears when suppressed - val stale = parts.remove(content.id) + val id = aliases.remove(content.id) + sources.remove(content.id) + val stale = if (id == null) parts.remove(content.id) else null if (stale != null) { + detach(stale) remove(stale) Disposer.dispose(stale) syncBorder() @@ -90,6 +100,16 @@ class MessageView( } return } + val id = aliases[content.id] + if (id != null && content is Reasoning) { + updateAlias(content, id) + refresh() + return + } + if (id != null) { + aliases.remove(content.id) + sources.remove(content.id) + } val existing = parts[content.id] if (existing != null) { if (ViewFactory.shouldReplace(existing, content)) { @@ -100,20 +120,56 @@ class MessageView( refresh() return } + addPart(content) + syncBorder() + refresh() + } + + private fun addPart(content: Content) { + if (content is Reasoning) { + val previous = parts.values.lastOrNull() + if (previous is ReasoningView) { + aliases[content.id] = previous.contentId + sources[content.id] = content.content.toString() + previous.update(merged(previous, content, content.content.toString())) + return + } + } val view = view(content) + view.resize = resize + view.hover = hover view.applyStyle(style) parts[content.id] = view add(view) - syncBorder() - refresh() + } + + private fun updateAlias(content: Reasoning, id: String) { + val view = parts[id] as? ReasoningView ?: return + val prev = sources[content.id].orEmpty() + val next = content.content.toString() + val delta = if (next.startsWith(prev)) next.removePrefix(prev) else next + sources[content.id] = next + if (delta.isEmpty()) return + view.update(merged(view, content, delta)) + } + + private fun merged(view: ReasoningView, content: Reasoning, delta: String) = Reasoning(view.contentId).also { + it.done = content.done + it.content.append(view.markdown()) + it.content.append(delta) } private fun replacePart(content: Content, existing: PartView) { val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount parts.remove(content.id) + aliases.values.removeAll { it == content.id } + sources.keys.removeAll { it !in aliases } + detach(existing) remove(existing) Disposer.dispose(existing) val view = view(content) + view.resize = resize + view.hover = hover view.applyStyle(style) parts[content.id] = view add(view, at) @@ -123,7 +179,14 @@ class MessageView( /** Remove the renderer for [contentId] if present. */ fun removePart(contentId: String) { + if (aliases.remove(contentId) != null) { + sources.remove(contentId) + return + } val view = parts.remove(contentId) ?: return + aliases.values.removeAll { it == contentId } + sources.keys.removeAll { it !in aliases } + detach(view) remove(view) Disposer.dispose(view) syncBorder() @@ -150,17 +213,17 @@ class MessageView( */ private fun rebuildParts() { parts.values.forEach { + detach(it) remove(it) Disposer.dispose(it) } parts.clear() + aliases.clear() + sources.clear() for ((_, content) in msg.parts) { if (content is StepFinish) continue if (isHidden(content)) continue - val view = view(content) - view.applyStyle(style) - parts[content.id] = view - add(view) + addPart(content) } syncBorder() refresh() @@ -172,20 +235,22 @@ class MessageView( } private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) { - ViewFactory.createUser(content, openFile, openUrl, selection) + ViewFactory.createUser(content, openFile, openUrl, selection, repo) } else { - ViewFactory.create(content, openFile, openUrl, selection) + ViewFactory.create(content, openFile, openUrl, selection, repo) } /** Append a streaming delta to the renderer for [contentId]. */ fun appendDelta(contentId: String, delta: String): Boolean { - val part = parts[contentId] ?: return false + val id = aliases[contentId] + if (id != null) sources[contentId] = sources[contentId].orEmpty() + delta + val part = parts[id ?: contentId] ?: return false part.appendDelta(delta) return true } /** Look up a renderer by part id. */ - fun part(id: String): PartView? = parts[id] + fun part(id: String): PartView? = parts[aliases[id] ?: id] /** Ordered part ids — stable for test assertions. */ fun partIds(): List = parts.keys.toList() @@ -202,10 +267,13 @@ class MessageView( override fun dispose() { parts.values.forEach { + detach(it) remove(it) Disposer.dispose(it) } parts.clear() + aliases.clear() + sources.clear() hidden = null } @@ -220,7 +288,7 @@ class MessageView( val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC)) g2.color = style.editorScheme.defaultBackground g2.fillRoundRect(0, 0, width, height, arc, arc) - g2.color = SessionUiStyle.View.line() + g2.color = SessionUiStyle.View.Outline.brightColor() val w = width - 1 val h = height - 1 if (w > 0 && h > 0) g2.drawRoundRect(0, 0, w, h, arc, arc) @@ -235,5 +303,10 @@ class MessageView( repaint() } + private fun detach(view: PartView) { + view.setHovered(false) + view.hover = null + } + private fun assistantBorder() = JBUI.Borders.empty() } 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 9907738612..21afb1fcab 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 @@ -12,10 +12,10 @@ import ai.kilocode.client.session.views.base.SecondarySessionPartView import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.md.MdView import ai.kilocode.client.ui.md.MdViewFactory -import com.intellij.icons.AllIcons import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI import java.awt.BorderLayout import java.awt.Dimension @@ -24,6 +24,7 @@ import java.awt.Rectangle import javax.swing.JPanel import javax.swing.ScrollPaneConstants import javax.swing.Scrollable +import javax.swing.SwingUtilities /** Renders reasoning as a secondary collapsible block. */ class ReasoningView( @@ -32,11 +33,17 @@ class ReasoningView( private val selection: SessionSelection? = null, private val parts: ReasoningParts = reasoningParts(selection), ) : - SecondarySessionPartView(parts.header, { parts.scroll(openUrl) }) { + SecondarySessionPartView( + parts.header, + { parts.scroll(openUrl) }, + expanded = reasoning.content.isNotBlank() && !reasoning.done, + ) { override val contentId: String = reasoning.id + /** Lazily creates, registers, populates, and styles the editor-backed body on first access. */ val md: MdView + @RequiresEdt get() { val fresh = !parts.bodyCreated() val view = parts.md(openUrl) @@ -50,52 +57,97 @@ class ReasoningView( private var style = SessionEditorStyle.current() private var source = reasoning.content.toString() + private var done = reasoning.done private var registered = false + private var following = false init { + row.border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Reasoning.HEADER_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ) bindHeader(parts.title, parts.icon) applyStyle(style) + if (bodyVisible()) syncBody() + syncBorder() sync() } + @RequiresEdt override fun expand(): Boolean { val changed = super.expand() if (!changed) return false + syncBorder() syncBody() applyBodyStyle() return true } + @RequiresEdt + override fun collapse(): Boolean { + val changed = super.collapse() + if (!changed) return false + syncBorder() + return true + } + + @RequiresEdt override fun update(content: Content) { if (content !is Reasoning) return var changed = false val next = content.content.toString() + val follow = tailVisible() + if (done != content.done) { + done = content.done + changed = true + } if (source != next) { source = next - if (parts.bodyCreated()) md.set(source) + if (parts.bodyCreated()) { + md.set(source) + followTail(follow) + } changed = true } changed = sync() || changed if (changed) refresh() } + @RequiresEdt override fun appendDelta(delta: String) { if (delta.isEmpty()) return + val follow = tailVisible() source += delta - if (parts.bodyCreated()) md.append(delta) + if (parts.bodyCreated()) { + md.append(delta) + followTail(follow) + } val changed = sync() if (changed || bodyVisible()) refresh() } + @RequiresEdt fun markdown(): String = source + @RequiresEdt fun hasToggle(): Boolean = arrow.isVisible + @RequiresEdt fun headerText(): String = parts.title.text + @RequiresEdt internal fun headerFont() = parts.title.font + @RequiresEdt internal fun bodyVisible() = parts.scrollOrNull?.parent === this + @RequiresEdt internal fun horizontalPolicy() = parts.scrollOrNull?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt internal fun bodyMaxRows() = SessionUiStyle.View.Reasoning.BODY_LINES + @RequiresEdt internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt + internal fun bodyScrollValue() = parts.scrollOrNull?.verticalScrollBar?.value ?: 0 + @RequiresEdt + internal fun bodyScrollBottom() = parts.scrollOrNull?.verticalScrollBar?.let { it.maximum - it.visibleAmount } ?: 0 + @RequiresEdt override fun applyStyle(style: SessionEditorStyle) { this.style = style var changed = false @@ -107,6 +159,7 @@ class ReasoningView( if (changed) refresh() } + @RequiresEdt override fun getPreferredSize(): Dimension { val size = super.getPreferredSize() if (!bodyVisible()) return size @@ -116,7 +169,34 @@ class ReasoningView( private fun canExpand(): Boolean = source.isNotBlank() - private fun sync(): Boolean = syncExpandable(canExpand()) + private fun sync(): Boolean { + var changed = false + val visible = source.isNotBlank() + if (isVisible != visible) { + isVisible = visible + changed = true + } + changed = syncExpandable(canExpand()) || changed + if (visible && !done && !parts.bodyCreated()) { + changed = expand() || changed + changed = syncExpandable(canExpand()) || changed + } + return changed + } + + private fun syncBorder() { + if (isExpanded()) { + border = JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + 0, + SessionUiStyle.View.Outline.width(), + 0, + 0, + ) + return + } + border = JBUI.Borders.empty(0, 1, 0, 0) + } private fun apply(md: MdView): Boolean { var changed = false @@ -130,10 +210,12 @@ class ReasoningView( return changed } + @RequiresEdt private fun syncBody() { val md = md registerBody(md) md.set(source) + followTail(true) } private fun applyBodyStyle(): Boolean { @@ -154,7 +236,28 @@ class ReasoningView( if (!parts.bodyCreated()) return 0 val md = md return md.component.getFontMetrics(md.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + @RequiresEdt + private fun tailVisible(): Boolean { + if (!bodyVisible()) return false + val scroll = parts.scrollOrNull ?: return false + val bar = scroll.verticalScrollBar + return bar.value >= bar.maximum - bar.visibleAmount + } + + @RequiresEdt + private fun followTail(follow: Boolean) { + if (!follow || !bodyVisible() || following) return + val scroll = parts.scrollOrNull ?: return + following = true + SwingUtilities.invokeLater { + following = false + if (!bodyVisible()) return@invokeLater + val bar = scroll.verticalScrollBar + bar.value = bar.maximum - bar.visibleAmount + } } override fun dumpLabel(): String { @@ -187,18 +290,18 @@ class ReasoningParts( } val panel = TrackPanel().apply { isOpaque = true - background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Reasoning.BODY_VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Reasoning.BODY_HORIZONTAL_PADDING), ) add(md.component, BorderLayout.CENTER) } val scroll = JBScrollPane(panel).apply { - border = SessionUiStyle.View.topOutline() + border = JBUI.Borders.empty() isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() + viewport.background = SessionUiStyle.View.Surface.bgColor() horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED } @@ -214,8 +317,8 @@ class ReasoningBody( private fun reasoningParts(selection: SessionSelection? = null): ReasoningParts { val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() } - val icon = JBLabel(AllIcons.General.InspectionsEye).apply { foreground = UiStyle.Colors.weak() } - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + val icon = JBLabel(SessionViewIcons.eye).apply { foreground = UiStyle.Colors.weak() } + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false add(icon, BorderLayout.WEST) add(title, BorderLayout.CENTER) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt new file mode 100644 index 0000000000..c59f5d371f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt @@ -0,0 +1,25 @@ +package ai.kilocode.client.session.views + +import com.intellij.openapi.util.IconLoader + +object SessionViewIcons { + val brain = icon("brain") + val bubble = icon("bubble-5") + val bulletList = icon("bullet-list") + val checklist = icon("checklist") + val chevronDown = icon("chevron-down") + val chevronLeft = icon("chevron-left") + val chevronRight = icon("chevron-right") + val code = icon("code") + val codeLines = icon("code-lines") + val console = icon("console") + val eye = icon("eye") + val glasses = icon("glasses") + val mcp = icon("mcp") + val search = icon("magnifying-glass-menu") + val task = icon("task") + val warning = icon("warning") + val windowCursor = icon("window-cursor") + + private fun icon(name: String) = IconLoader.getIcon("/icons/views/$name.svg", SessionViewIcons::class.java) +} 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 1e4358415e..fa56f6466c 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 @@ -73,7 +73,7 @@ open class TextView( protected open fun styleFont(style: SessionEditorStyle) = style.transcriptFont - protected open fun styleBackground(style: SessionEditorStyle) = SessionUiStyle.View.transcript() + protected open fun styleBackground(style: SessionEditorStyle) = SessionUiStyle.Transcript.bgColor() private fun refresh() { revalidate() 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 deleted file mode 100644 index 5951f40ff0..0000000000 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt +++ /dev/null @@ -1,655 +0,0 @@ -@file:Suppress("TooManyFunctions") - -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.Tool -import ai.kilocode.client.session.model.ToolExecState -import ai.kilocode.client.session.model.ToolKind -import ai.kilocode.client.session.ui.style.SessionEditorStyle -import ai.kilocode.client.session.ui.selection.SessionSelection -import ai.kilocode.client.session.ui.style.SessionUiStyle -import ai.kilocode.client.session.views.base.SecondarySessionPartView -import ai.kilocode.client.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.CardLayout -import java.awt.Color -import java.awt.Cursor -import java.awt.Dimension -import java.awt.Font -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent -import javax.swing.Box -import javax.swing.Icon -import javax.swing.JComponent -import javax.swing.JPanel -import javax.swing.ScrollPaneConstants - -/** Renders non-read tool calls with VS Code-inspired rows/cards. */ -class ToolView( - tool: Tool, - private val selection: SessionSelection? = null, - private val parts: ToolParts = toolParts(tool), -) : - SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { - - override val contentId: String = tool.id - - private var item = tool - private var style = SessionEditorStyle.current() - private var registered = false - - init { - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) - applyStyle(style) - sync() - } - - override fun expand(): Boolean { - val changed = super.expand() - if (!changed) return false - syncBody() - applyBodyStyle() - return true - } - - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - if (!bodyVisible()) return size - val height = row.preferredSize.height + bodyMaxHeight() - return Dimension(size.width, minOf(size.height, height)) - } - - override fun update(content: Content) { - if (content !is Tool) return - val was = item.name - item = content - var changed = false - if (was != content.name || !canExpand(content)) changed = collapse() || changed - changed = sync() || changed - changed = syncBody() || changed - if (changed) refresh() - } - - fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) - .filter { it.isNotBlank() } - .joinToString(" ") - - fun commandText(): String = command(item) - - fun outputText(): String = output(item) - fun bodyText(): String = body(item) - internal fun previewText(): String = parts.text?.text ?: preview(item) - fun hasToggle(): Boolean = arrow.isVisible - internal fun bodyFont() = parts.text?.font ?: style.transcriptFont - internal fun titleFont() = parts.title.font - internal fun subtitleFont() = parts.sub.font - internal fun stateFont() = parts.state.font - internal fun bodyEditable() = parts.text?.isEditable ?: false - internal fun bodyCaretVisible() = parts.text?.caret?.isVisible ?: false - internal fun bodyVisible() = parts.scroll?.parent === this - internal fun controlCount() = if (arrow.isVisible) 1 else 0 - internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - internal fun bodyWrap() = parts.text?.lineWrap ?: true - internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES - internal fun bodyCreated() = parts.bodyCreated() - - override fun applyStyle(style: SessionEditorStyle) { - this.style = style - var changed = false - changed = setFont(parts.title, style.boldEditorFont) || changed - changed = setFont(parts.sub, style.smallEditorFont) || changed - changed = setFont(parts.link, style.smallEditorFont) || changed - changed = setFont(parts.state, style.smallEditorFont) || changed - changed = applyBodyStyle() || changed - if (changed) refresh() - } - - private fun sync(): Boolean { - val expand = canExpand(item) - var changed = false - changed = syncExpandable(expand) || changed - changed = setVisible(parts.state, !expand) || changed - changed = syncLabels() || changed - val text = parts.text - if (text != null) changed = setForeground(text, bodyColor()) || changed - return changed - } - - private fun syncLabels(): Boolean { - var changed = false - changed = setIcon(parts.glyph, icon(item)) || changed - changed = setForeground(parts.glyph, color(item)) || changed - changed = setText(parts.title, title(item)) || changed - changed = setText(parts.sub, subtitle(item)) || changed - changed = setForeground(parts.title, titleColor(item)) || changed - changed = setText(parts.state, stateText(item)) || changed - changed = setForeground(parts.state, color(item)) || changed - return changed - } - - private fun syncBody(): Boolean { - var changed = false - val text = parts.text ?: return false - val value = preview(item) - if (text.text != value) { - text.text = value - text.caretPosition = 0 - changed = true - } - changed = setForeground(text, bodyColor()) || changed - return changed - } - - private fun applyBodyStyle(): Boolean { - val text = parts.text ?: return false - if (!registered && selection != null && text.parent != null) { - registered = true - selection.register(text, this) - } - return setFont(text, style.transcriptFont) - } - - private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - - private fun bodyMaxHeight(): Int { - val text = parts.text ?: return 0 - return text.getFontMetrics(text.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) - } - - override fun dumpLabel() = "ToolView#$contentId(${labelText()})" -} - -/** Renders read calls with secondary, borderless chrome. */ -class ReadToolView( - tool: Tool, - openFile: (String) -> Unit = {}, - private val selection: SessionSelection? = null, - private val parts: ToolParts = toolParts(tool, openFile), -) : SecondarySessionPartView(parts.header, parts.scroll(tool), expandable = false) { - - companion object { - fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ - } - - override val contentId: String = tool.id - - private var item = tool - private var style = SessionEditorStyle.current() - - init { - parts.text?.let { selection?.register(it, this) } - bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) - parts.text?.text = preview(item) - applyStyle(style) - sync() - } - - override fun getPreferredSize(): Dimension { - val size = super.getPreferredSize() - if (!bodyVisible()) return size - val height = row.preferredSize.height + bodyMaxHeight() - return Dimension(size.width, minOf(size.height, height)) - } - - override fun update(content: Content) { - if (content !is Tool) return - item = content - var changed = sync() - changed = syncBody() || changed - if (changed) refresh() - } - - fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) - .filter { it.isNotBlank() } - .joinToString(" ") - fun bodyText(): String = body(item) - internal fun bodyVisible() = parts.scroll?.parent === this - internal fun hasToggle() = arrow.isVisible - internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES - internal fun bodyFont() = parts.text?.font ?: style.transcriptFont - internal fun linkVisible() = parts.link.isVisible - internal fun linkText() = parts.label - internal fun linkMarkup() = parts.link.text ?: "" - internal fun linkForeground() = parts.link.foreground - internal fun linkFont() = parts.link.font - internal fun subtitleForeground() = parts.sub.foreground - internal fun subtitleFont() = parts.sub.font - internal fun linkHref() = parts.href - internal fun openLink() = parts.openLink() - - override fun applyStyle(style: SessionEditorStyle) { - this.style = style - var changed = false - changed = setFont(parts.title, style.boldEditorFont) || changed - changed = setFont(parts.sub, style.transcriptFont) || changed - changed = setFont(parts.link, style.transcriptFont) || changed - changed = setFont(parts.state, style.smallEditorFont) || changed - parts.text?.let { changed = setFont(it, style.transcriptFont) || changed } - if (changed) refresh() - } - - private fun sync(): Boolean { - var changed = false - changed = syncExpandable(false) || changed - changed = setVisible(parts.state, true) || changed - changed = setIcon(parts.glyph, icon(item)) || changed - changed = setForeground(parts.glyph, color(item)) || changed - changed = setText(parts.title, title(item)) || changed - changed = syncSubtitle() || changed - changed = setForeground(parts.title, titleColor(item)) || changed - changed = setForeground(parts.sub, UiStyle.Colors.fg()) || changed - changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed - changed = setText(parts.state, stateText(item)) || changed - changed = setForeground(parts.state, color(item)) || changed - parts.text?.let { changed = setForeground(it, bodyColor()) || changed } - return changed - } - - private fun syncSubtitle(): Boolean { - val target = target(item)?.takeIf { it.type == "file" } - if (target != null) { - var changed = false - if (parts.href != target.path) { - parts.href = target.path - changed = true - } - changed = setLinkText(parts, tail(target.path).ifBlank { target.path }) || changed - changed = show(parts, true) || changed - return changed - } - - var changed = false - if (parts.href != null) { - parts.href = null - changed = true - } - changed = setText(parts.sub, subtitle(item)) || changed - changed = show(parts, false) || changed - return changed - } - - private fun syncBody(): Boolean { - val value = preview(item) - val text = parts.text ?: return false - if (text.text == value) return false - text.text = value - text.caretPosition = 0 - return true - } - - private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - - private fun bodyMaxHeight(): Int { - val text = parts.text ?: return 0 - return text.getFontMetrics(text.font).height * bodyMaxRows() + - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_BODY_EXTRA_HEIGHT) - } - - override fun dumpLabel() = "ReadToolView#$contentId(${labelText()})" -} - -class ToolParts( - val header: JPanel, - val glyph: JBLabel, - val title: JBLabel, - val sub: JBLabel, - val link: JBLabel, - val slot: JPanel, - val state: JBLabel, - val center: JPanel, - val controls: JComponent, - private val open: ((String) -> Unit)? = null, -) { - var href: String? = null - var label: String = "" - private var body: ToolBody? = null - - val text: JBTextArea? - get() = body?.text - - val scroll: JBScrollPane? - get() = body?.scroll - - fun scroll(tool: Tool): JBScrollPane = body(tool).scroll - - fun bodyCreated() = body != null - - fun openLink() { - val value = href ?: return - open?.invoke(value) - } - - private fun body(tool: Tool): ToolBody { - val item = body - if (item != null) return item - val text = JBTextArea().apply { - isEditable = false - caret.isVisible = false - caret.isSelectionVisible = true - lineWrap = true - wrapStyleWord = true - foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), - ) - } - val scroll = JBScrollPane(text).apply { - border = SessionUiStyle.View.topOutline() - isOpaque = true - background = SessionUiStyle.View.surface() - viewport.background = SessionUiStyle.View.surface() - horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER - verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED - } - return ToolBody(text, scroll).also { body = it } - } -} - -class ToolBody( - val text: JBTextArea, - val scroll: JBScrollPane, -) - -private const val SUB_CARD = "sub" -private const val LINK_CARD = "link" - -private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolParts { - lateinit var parts: ToolParts - val glyph = JBLabel() - val title = JBLabel() - val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val link = JBLabel().apply { - isVisible = false - isFocusable = false - foreground = UiStyle.Colors.fg() - cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) - setRequestFocusEnabled(false) - addMouseListener(object : MouseAdapter() { - override fun mouseClicked(e: MouseEvent) { - parts.openLink() - } - }) - } - val slot = JPanel(CardLayout()).apply { - isOpaque = false - add(sub, SUB_CARD) - add(link, LINK_CARD) - } - val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { isOpaque = false } - val controls = Box.createHorizontalBox() - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { - isOpaque = false - center.add(title, BorderLayout.WEST) - center.add(slot, BorderLayout.CENTER) - add(glyph, BorderLayout.WEST) - add(center, BorderLayout.CENTER) - add(controls, BorderLayout.EAST) - } - parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, openFile) - return parts.also { - controls.add(it.state) - } -} - -private fun icon(tool: Tool) = when (tool.name) { - "read" -> AllIcons.Actions.Preview - "bash" -> AllIcons.Debugger.Console - else -> when (tool.state) { - ToolExecState.PENDING -> AllIcons.Process.Step_1 - ToolExecState.RUNNING -> AllIcons.Process.Step_2 - ToolExecState.COMPLETED -> AllIcons.Actions.Checked - ToolExecState.ERROR -> AllIcons.General.Error - } -} - -private fun title(tool: Tool) = when (tool.name) { - "read" -> KiloBundle.message("session.part.tool.read") - "bash" -> KiloBundle.message("session.part.tool.shell") - else -> toolTitle(tool) -} - -private fun subtitle(tool: Tool) = when (tool.name) { - "read" -> readPath(tool) - "bash" -> shellTitle(tool) - else -> toolSubtitle(tool) -} - -private fun setText(label: JBLabel, text: String): Boolean { - val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text)) - if (label.text == value) return false - label.text = value - return true -} - -private fun setLinkText(parts: ToolParts, text: String): Boolean { - val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(text)}") - if (parts.label == text && parts.link.text == value) return false - parts.label = text - parts.link.text = value - return true -} - -private fun show(parts: ToolParts, link: Boolean): Boolean { - if (parts.link.isVisible == link && parts.sub.isVisible != link) return false - (parts.slot.layout as CardLayout).show(parts.slot, if (link) LINK_CARD else SUB_CARD) - return true -} - -private fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text - -private fun setIcon(label: JBLabel, icon: Icon): Boolean { - if (label.icon === icon) return false - label.icon = icon - return true -} - -private fun setVisible(component: JComponent, visible: Boolean): Boolean { - if (component.isVisible == visible) return false - component.isVisible = visible - return true -} - -private fun setForeground(component: JComponent, color: Color): Boolean { - if (same(component.foreground, color)) return false - component.foreground = color - return true -} - -private fun setFont(component: JComponent, font: Font): Boolean { - if (component.font == font) return false - component.font = font - return true -} - -private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb - -private fun color(tool: Tool) = when (tool.state) { - 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.errorLabelForeground() -} else { - UiStyle.Colors.fg() -} - -private fun stateText(tool: Tool) = when (tool.state) { - ToolExecState.PENDING -> KiloBundle.message("session.part.tool.pending") - ToolExecState.RUNNING -> KiloBundle.message("session.part.tool.running") - ToolExecState.COMPLETED -> "" - ToolExecState.ERROR -> KiloBundle.message("session.part.tool.error") -} - -private fun readPath(tool: Tool): String { - val target = target(tool) - if (target != null) { - if (target.type == "file") return tail(target.path).ifBlank { target.path } - return target.path - } - val path = tool.input["filePath"] ?: tool.input["path"] ?: tool.title ?: return tool.name - return tail(path).ifBlank { path } -} - -private data class Target( - val path: String, - val type: String, -) - -private fun target(tool: Tool): Target? { - val out = output(tool) - if (out.isBlank()) return null - val path = tag(out, "path") ?: return null - val type = tag(out, "type") ?: return null - return Target(path, type.lowercase()) -} - -private fun tag(text: String, name: String): String? = - Regex("<$name>\\s*([\\s\\S]*?)\\s*") - .find(text) - ?.groupValues - ?.getOrNull(1) - ?.trim() - ?.takeIf { it.isNotBlank() } - -private fun shellTitle(tool: Tool): String = - tool.input["description"]?.takeIf { it.isNotBlank() } - ?: tool.metadata["description"]?.takeIf { it.isNotBlank() } - ?: tool.title?.takeIf { it.isNotBlank() } - ?: command(tool).lineSequence().firstOrNull { it.isNotBlank() } - ?: "" - -private fun command(tool: Tool): String = - tool.input["command"]?.takeIf { it.isNotBlank() } - ?: tool.metadata["command"]?.takeIf { it.isNotBlank() } - ?: "" - -private fun output(tool: Tool): String = - tool.output?.takeIf { it.isNotBlank() } - ?: tool.metadata["output"]?.takeIf { it.isNotBlank() } - ?: "" - -private fun preview(tool: Tool): String = if (tool.name == "bash") shellPreview(tool) else plainPreview(tool) - -private fun body(tool: Tool): String = if (tool.name == "bash") shellBody(tool) else plainBody(tool) - -private fun shellPreview(tool: Tool): String { - val cmd = command(tool) - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return Preview().apply { - if (cmd.isNotBlank()) append("$ ").append(cmd) - if (out.isNotBlank()) { - sep() - append(out) - } - if (err != null) { - sep() - append(err) - } - }.build() -} - -private fun shellBody(tool: Tool): String { - val cmd = command(tool) - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return buildString { - if (cmd.isNotBlank()) append("$ ").append(cmd) - if (out.isNotBlank()) { - if (isNotEmpty()) append("\n\n") - append(out) - } - if (err != null) { - if (isNotEmpty()) append("\n\n") - append(err) - } - } -} - -private fun plainPreview(tool: Tool): String { - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return Preview().apply { - if (out.isNotBlank()) append(out) - if (err != null) { - sep() - append(err) - } - }.build() -} - -private fun plainBody(tool: Tool): String { - val out = output(tool) - val err = tool.error?.takeIf { it.isNotBlank() } - return listOf(out, err).filter { !it.isNullOrBlank() }.joinToString("\n\n") -} - -private fun canExpand(tool: Tool): Boolean { - if (tool.name == "bash") return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank() - return output(tool).isNotBlank() || !tool.error.isNullOrBlank() -} - -private fun toolTitle(tool: Tool): String = - tool.title?.takeIf { it.isNotBlank() } - ?: tool.name.replace('_', ' ').replaceFirstChar { it.titlecase() } - -private fun toolSubtitle(tool: Tool): String { - val base = listOf("description", "query", "url", "filePath", "path", "name") - .mapNotNull { tool.input[it]?.takeIf { value -> value.isNotBlank() } } - .firstOrNull() - val args = listOf("pattern", "include", "offset", "limit") - .mapNotNull { key -> tool.input[key]?.takeIf { it.isNotBlank() }?.let { "$key=$it" } } - return listOfNotNull(base).plus(args).joinToString(" ") -} - -private fun tail(path: String): String { - val value = path.trimEnd('/', '\\') - val index = maxOf(value.lastIndexOf('/'), value.lastIndexOf('\\')) - if (index < 0) return value - return value.substring(index + 1) -} - -private class Preview { - private val text = StringBuilder() - private var cut = false - - fun append(value: String): Preview { - if (cut) return this - val rem = SessionUiStyle.View.Tool.PREVIEW_LIMIT - text.length - if (value.length <= rem) { - text.append(value) - return this - } - if (rem > 0) text.append(value, 0, rem) - cut = true - return this - } - - fun sep(): Preview { - if (text.isNotEmpty()) append("\n\n") - return this - } - - fun build(): String { - if (!cut) return text.toString() - if (text.isNotEmpty()) text.append("\n\n") - text.append(KiloBundle.message("session.part.tool.truncated")) - return text.toString() - } -} 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 5320b21dbf..459509ffc9 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 @@ -6,9 +6,11 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.PartView import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.util.ui.JBUI +import javax.swing.JComponent /** * Top-level transcript item representing one conversational turn. @@ -25,6 +27,9 @@ class TurnView( private var style: SessionEditorStyle = SessionEditorStyle.current(), private val openUrl: (String) -> Unit = {}, private val selection: SessionSelection? = null, + private val resize: ((JComponent, () -> Unit) -> Unit)? = null, + private val repo: String? = null, + private val hover: ((PartView, Boolean) -> Unit)? = null, ) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), Disposable, SessionEditorStyleTarget { constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current()) @@ -37,7 +42,7 @@ class TurnView( /** Add a new [MessageView] for [msg] at the end of this turn. */ fun addMessage(msg: Message): MessageView { - val view = MessageView(msg, openFile, style, openUrl, selection) + val view = MessageView(msg, openFile, style, openUrl, selection, resize, repo, hover) messages[msg.info.id] = view add(view) revalidate() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt index fca054a0c9..a13dde6e1c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -3,6 +3,10 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.views.base.GenericView import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.views.question.QuestionResultView +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.model.Compaction import ai.kilocode.client.session.model.Content @@ -25,19 +29,20 @@ object ViewFactory { fun create( content: Content, openFile: (String) -> Unit, - ): PartView = create(content, openFile, openUrl = {}, selection = null) + ): PartView = create(content, openFile, openUrl = {}, selection = null, repo = null) fun create( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit, - ): PartView = create(content, openFile, openUrl, selection = null) + ): PartView = create(content, openFile, openUrl, selection = null, repo = null) fun create( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, + repo: String? = null, ): PartView = when (content) { is Text -> TextView(content, openUrl = openUrl, selection = selection) is Reasoning -> ReasoningView(content, openUrl = openUrl, selection = selection) @@ -45,6 +50,8 @@ object ViewFactory { TodoWriteView.canRender(content) -> TodoWriteView(content) PlanExitView.canRender(content) -> PlanExitView(content, openFile, selection) QuestionResultView.canRender(content) -> QuestionResultView(content, selection) + GlobToolView.canRender(content) -> GlobToolView(content, selection = selection, repo = repo) + SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo) ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection) else -> ToolView(content, selection = selection) } @@ -56,22 +63,23 @@ object ViewFactory { fun createUser( content: Content, openFile: (String) -> Unit, - ): PartView = createUser(content, openFile, openUrl = {}, selection = null) + ): PartView = createUser(content, openFile, openUrl = {}, selection = null, repo = null) fun createUser( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit, - ): PartView = createUser(content, openFile, openUrl, selection = null) + ): PartView = createUser(content, openFile, openUrl, selection = null, repo = null) fun createUser( content: Content, openFile: (String) -> Unit, openUrl: (String) -> Unit = {}, selection: SessionSelection? = null, + repo: String? = null, ): PartView = when (content) { is Text -> PromptView(content, openUrl = openUrl, selection = selection) - else -> create(content, openFile, openUrl, selection) + else -> create(content, openFile, openUrl, selection, repo) } /** @@ -86,6 +94,10 @@ object ViewFactory { if (view is PlanExitView) return !PlanExitView.canRender(content) if (view !is PlanExitView && PlanExitView.canRender(content)) return true if (view is QuestionResultView) return !QuestionResultView.canRender(content) + if (view is GlobToolView) return !GlobToolView.canRender(content) || QuestionResultView.canRender(content) + if (view !is GlobToolView && GlobToolView.canRender(content)) return true + if (view is SearchToolView) return !SearchToolView.canRender(content) || QuestionResultView.canRender(content) + if (view !is SearchToolView && SearchToolView.canRender(content)) return true if (view is ReadToolView) return !ReadToolView.canRender(content) || QuestionResultView.canRender(content) if (view is ToolView && ReadToolView.canRender(content)) return true if (view is ToolView) return QuestionResultView.canRender(content) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt index 52942a6067..ac93b08e83 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartView.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.ui.style.SessionUiStyle -import com.intellij.icons.AllIcons +import ai.kilocode.client.session.views.SessionViewIcons import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.BorderLayout @@ -29,7 +29,7 @@ abstract class AbstractSessionPartView( ) : this(header, { body }, expanded, expandable) protected val arrow = JBLabel() - protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)) + protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)) private val bound = linkedSetOf() private var body: JComponent? = null @@ -41,12 +41,12 @@ abstract class AbstractSessionPartView( } private val mouse = object : MouseAdapter() { override fun mouseEntered(e: MouseEvent) { - setHover(true) + setHovered(true) } override fun mouseExited(e: MouseEvent) { if (inside(e)) return - setHover(false) + setHovered(false) } } @@ -65,7 +65,7 @@ abstract class AbstractSessionPartView( fun toggle() { if (!expandable || !arrow.isVisible) return - val changed = if (isExpanded()) collapse() else expand() + val changed = toggleLocal() if (!changed) return syncArrow() refresh() @@ -78,7 +78,7 @@ abstract class AbstractSessionPartView( return true } - fun collapse(): Boolean { + open fun collapse(): Boolean { val item = body ?: return false if (item.parent !== this) return false remove(item) @@ -89,6 +89,15 @@ abstract class AbstractSessionPartView( protected fun bodyComponent(): JComponent = body() + private fun toggleLocal(): Boolean { + val fn = resize ?: return toggleBody() + val expanded = isExpanded() + fn(this) { toggleBody() } + return expanded != isExpanded() + } + + private fun toggleBody(): Boolean = if (isExpanded()) collapse() else expand() + fun syncExpandable(expandable: Boolean): Boolean { val active = this.expandable && expandable val changed = setVisible(arrow, active) @@ -110,13 +119,11 @@ abstract class AbstractSessionPartView( protected open fun hoverColor(value: Boolean): Color? = null - protected open fun applyHover(value: Boolean, color: Color) {} - - private fun setHover(value: Boolean) { + override fun setHovered(value: Boolean) { + hover?.invoke(this, value) val color = hoverColor(value) ?: return if (row.background?.rgb == color.rgb) return row.background = color - applyHover(value, color) row.repaint() } @@ -150,7 +157,7 @@ abstract class AbstractSessionPartView( } private fun syncArrow(): Boolean { - val icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + val icon = if (isExpanded()) SessionViewIcons.chevronDown else SessionViewIcons.chevronRight if (arrow.icon === icon) return false arrow.icon = icon return true diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt index 5b14587de9..3a73b355a6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/BaseQuestionView.kt @@ -41,6 +41,8 @@ import javax.swing.JPanel class BaseQuestionView( private val selection: SessionSelection? = null, ) : RoundedContentPanel( + UiStyle.Gap.pad(), + UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad(), ), SessionEditorStyleTarget { @@ -95,6 +97,7 @@ class BaseQuestionView( private var top: JComponent? = null private var content: JComponent? = null private var actionLeft: JComponent? = null + private var gap = UiStyle.Gap.lg() // action buttons keyed by id for retained updates private val actionButtons = mutableMapOf() @@ -184,6 +187,15 @@ class BaseQuestionView( repaint() } + @RequiresEdt + fun setSpacing(top: Int, gap: Int) { + this.gap = gap + border = JBUI.Borders.empty(top, UiStyle.Gap.pad(), UiStyle.Gap.lg(), UiStyle.Gap.pad()) + syncNorth() + revalidate() + repaint() + } + /** * Configure the action buttons shown in the card's right-aligned footer. * @@ -275,9 +287,9 @@ class BaseQuestionView( // ---- contentColor override ---- - override fun contentColor(): Color = SessionUiStyle.View.surface() + override fun contentColor(): Color = SessionUiStyle.View.Surface.bgColor() - override fun outlineColor(): Color = SessionUiStyle.View.line() + override fun outlineColor(): Color = SessionUiStyle.View.Outline.brightColor() // ---- private helpers ---- @@ -285,7 +297,7 @@ class BaseQuestionView( north.removeAll() top?.let { north.next(it) } north.next(header) - if (content != null) north.fill(UiStyle.Gap.md()) + if (content != null) north.fill(gap) north.revalidate() north.repaint() } @@ -383,7 +395,7 @@ class BaseQuestionView( } private fun syncBackground() { - background = SessionUiStyle.View.surface() + background = SessionUiStyle.View.Surface.bgColor() } } btn.addActionListener { actionHandlers[id]?.invoke() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt index c0973f9d16..7f535048a7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/GenericView.kt @@ -26,6 +26,7 @@ class GenericView private constructor( label.foreground = UiStyle.Colors.weak() applyStyle(SessionEditorStyle.current()) syncExpandable(false) + border = null } override fun update(content: Content) {} // generic content has no updatable state diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt index 41d4d57853..b1b0256110 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PartView.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.session.model.Content import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import com.intellij.openapi.Disposable +import javax.swing.JComponent import javax.swing.JPanel /** @@ -20,6 +21,10 @@ abstract class PartView : JPanel(), Disposable, SessionEditorStyleTarget { /** Stable [Content.id] this renderer was created for. */ abstract val contentId: String + var resize: ((JComponent, () -> Unit) -> Unit)? = null + + var hover: ((PartView, Boolean) -> Unit)? = null + /** * Apply a full content update — replace, not append. * Called when [ai.kilocode.client.session.model.SessionModelEvent.ContentUpdated] fires. @@ -33,6 +38,8 @@ abstract class PartView : JPanel(), Disposable, SessionEditorStyleTarget { */ open fun appendDelta(delta: String) {} + open fun setHovered(value: Boolean) {} + override fun applyStyle(style: SessionEditorStyle) {} override fun dispose() {} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt index 6a4a1d89f6..d82a3e1162 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/PrimarySessionPartView.kt @@ -2,7 +2,6 @@ package ai.kilocode.client.session.views.base import ai.kilocode.client.session.ui.style.SessionUiStyle import com.intellij.util.ui.JBUI -import java.awt.Color import javax.swing.JComponent abstract class PrimarySessionPartView( @@ -13,20 +12,36 @@ abstract class PrimarySessionPartView( ) : AbstractSessionPartView(header, content, expanded, expandable) { init { isOpaque = true - background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.sessionView() + background = SessionUiStyle.View.Surface.bgColor() row.isOpaque = true - row.background = SessionUiStyle.View.header() + row.background = SessionUiStyle.View.Surface.headerBgColor() row.border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) + syncBorder() } - override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() + override fun expand(): Boolean { + val changed = super.expand() + if (changed) syncBorder() + return changed + } - override fun applyHover(value: Boolean, color: Color) { - border = if (value) SessionUiStyle.View.sessionView(SessionUiStyle.View.hoverLine()) else SessionUiStyle.View.sessionView() - repaint() + override fun collapse(): Boolean { + val changed = super.collapse() + if (changed) syncBorder() + return changed + } + + override fun hoverColor(value: Boolean) = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() + + private fun syncBorder() { + if (isExpanded()) { + border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width()) + return + } + border = JBUI.Borders.empty(1) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt index 8951a50d80..d0a0d3808e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/SecondarySessionPartView.kt @@ -19,12 +19,34 @@ abstract class SecondarySessionPartView( ) : this(header, { content }, expanded, expandable) init { row.isOpaque = true - row.background = SessionUiStyle.View.header() + row.background = SessionUiStyle.View.Surface.headerBgColor() row.border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) + syncBorder() } - override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() + override fun expand(): Boolean { + val changed = super.expand() + if (changed) syncBorder() + return changed + } + + override fun collapse(): Boolean { + val changed = super.collapse() + if (changed) syncBorder() + return changed + } + + override fun hoverColor(value: Boolean) = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() + + private fun syncBorder() { + if (isExpanded()) { + border = JBUI.Borders.customLine(SessionUiStyle.View.Outline.color(), SessionUiStyle.View.Outline.width()) + return + } + border = JBUI.Borders.empty(1) + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt index d6584f0f0b..e8f52e97de 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/permission/PermissionView.kt @@ -10,14 +10,13 @@ import ai.kilocode.client.session.ui.selection.SessionSelection 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.style.SessionUiStyle.View.SESSION_VIEW_GAP +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.Stack import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align import ai.kilocode.rpc.dto.PermissionReplyDto -import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.ColorUtil @@ -68,7 +67,7 @@ class PermissionView( isOpaque = false isVisible = false - card.setHeaderIcon(AllIcons.General.Warning, KiloBundle.message("session.permission.title")) + card.setHeaderIcon(SessionViewIcons.warning, KiloBundle.message("session.permission.title")) card.setContent(body) card.setActions(listOf( BaseQuestionView.Action(ID_DENY, KiloBundle.message("session.permission.deny"), primary = false) { decide("reject") }, @@ -128,7 +127,7 @@ class PermissionView( /** Adds a three-column permission detail row: tool, target, and changes. */ private fun addDetailRow(action: String, target: String?, diffs: List) { - val row = JPanel(BorderLayout(SESSION_VIEW_GAP, 0)).apply { + val row = JPanel(BorderLayout(SessionUiStyle.View.Layout.GAP, 0)).apply { isOpaque = false } @@ -174,7 +173,7 @@ class PermissionView( private fun applyTargetPane(pane: JBHtmlPane) { pane.font = style.transcriptFont pane.foreground = style.editorForeground - pane.background = SessionUiStyle.View.headerHover() + pane.background = SessionUiStyle.View.Surface.headerHoverBgColor() pane.reloadCssStylesheets() } @@ -182,7 +181,7 @@ class PermissionView( val sheet = StyleSheet() val font = style.transcriptFont val fg = ColorUtil.toHtmlColor(style.editorForeground) - val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.headerHover()) + val bg = ColorUtil.toHtmlColor(SessionUiStyle.View.Surface.headerHoverBgColor()) val family = font.name.replace("\\", "\\\\").replace("'", "\\'") sheet.addRule("body { margin: 0; padding: 0 ${UiStyle.Gap.xs()}px; color: $fg; background: $bg; font-family: '$family', monospace; font-size: ${font.size}pt }") sheet.addRule("pre { margin: 0; white-space: pre-wrap; font-family: '$family', monospace; font-size: ${font.size}pt }") diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt index 6672c9cf64..f1c241141f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionResultView.kt @@ -6,10 +6,10 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.PartView -import ai.kilocode.client.session.views.ToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.ui.UiStyle -import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.ui.components.JBLabel @@ -41,26 +41,26 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = override fun updateUI() { super.updateUI() isOpaque = true - background = SessionUiStyle.View.surface() - border = SessionUiStyle.View.sessionView() + background = SessionUiStyle.View.Surface.bgColor() + border = JBUI.Borders.empty(1) } } - private val header = object : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)) { + private val header = object : JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)) { override fun updateUI() { super.updateUI() isOpaque = true - background = SessionUiStyle.View.header() + background = SessionUiStyle.View.Surface.headerBgColor() border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ) } } - private val glyph = JBLabel(AllIcons.General.Balloon) + private val glyph = JBLabel(SessionViewIcons.bubble) private val title = JBLabel() private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } private val arrow = JBLabel() - private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false } private var pane: JPanel? = null @@ -70,10 +70,10 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } private val mouse = object : MouseAdapter() { - override fun mouseEntered(e: MouseEvent) { setHover(true) } + override fun mouseEntered(e: MouseEvent) { setHovered(true) } override fun mouseExited(e: MouseEvent) { if (inside(e)) return - setHover(false) + setHovered(false) } } @@ -99,6 +99,7 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = add(root, BorderLayout.CENTER) syncLabels() syncArrow() + syncBorder() } override fun update(content: Content) { @@ -122,13 +123,18 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } fun toggle() { + resize?.invoke(this) { toggleBody() } ?: toggleBody() + syncArrow() + refresh() + } + + private fun toggleBody() { if (isExpanded()) { pane?.let { root.remove(it) } } else { root.add(body(), BorderLayout.CENTER) } - syncArrow() - refresh() + syncBorder() } fun isExpanded(): Boolean = pane?.parent === root @@ -164,10 +170,19 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = override fun updateUI() { super.updateUI() isOpaque = true - background = SessionUiStyle.View.surface() - border = JBUI.Borders.empty( - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING), - JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING), + background = SessionUiStyle.View.Surface.bgColor() + border = JBUI.Borders.compound( + JBUI.Borders.customLine( + SessionUiStyle.View.Outline.brightColor(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ), + JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ), ) } }.apply { @@ -270,16 +285,28 @@ class QuestionResultView(tool: Tool, private val selection: SessionSelection? = } private fun syncArrow() { - arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight + arrow.icon = if (isExpanded()) SessionViewIcons.chevronDown else SessionViewIcons.chevronRight } - private fun setHover(value: Boolean) { - val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header() - if (header.background?.rgb == color.rgb) return - header.background = color - root.border = if (value) SessionUiStyle.View.sessionView(SessionUiStyle.View.hoverLine()) else SessionUiStyle.View.sessionView() - header.repaint() - root.repaint() + override fun setHovered(value: Boolean) { + hover?.invoke(this, value) + val color = + if (value) SessionUiStyle.View.Surface.headerHoverBgColor() else SessionUiStyle.View.Surface.headerBgColor() + if (header.background?.rgb != color.rgb) { + header.background = color + header.repaint() + } + } + + private fun syncBorder() { + if (isExpanded()) { + root.border = JBUI.Borders.customLine( + SessionUiStyle.View.Outline.brightColor(), + SessionUiStyle.View.Outline.width(), + ) + return + } + root.border = JBUI.Borders.empty(1) } private fun inside(e: MouseEvent): Boolean { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt index b924a78b30..42c61bdb25 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/question/QuestionView.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.editor.SessionEditorTextField +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -13,7 +14,6 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto -import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.project.Project @@ -79,14 +79,14 @@ class QuestionView( layout = BoxLayout(this, BoxLayout.X_AXIS) } private val back = HoverIcon().apply { - val ico = AllIcons.Actions.Back + val ico = SessionViewIcons.chevronLeft icon = ico disabledIcon = IconLoader.getDisabledIcon(ico) toolTipText = KiloBundle.message("session.question.back") addActionListener { goBack() } } private val fwd = HoverIcon().apply { - val ico = AllIcons.Actions.Forward + val ico = SessionViewIcons.chevronRight icon = ico disabledIcon = IconLoader.getDisabledIcon(ico) toolTipText = KiloBundle.message("session.question.next") @@ -94,7 +94,7 @@ class QuestionView( } private val topPanel = JPanel(BorderLayout()).apply { isOpaque = false - border = JBUI.Borders.emptyBottom(UiStyle.Gap.lg()) + border = JBUI.Borders.empty() alignmentX = Component.LEFT_ALIGNMENT } private val body = JPanel().apply { @@ -207,6 +207,12 @@ class QuestionView( summary.isVisible = total > 1 nav.isVisible = total > 1 topPanel.isVisible = total > 1 + if (total > 1) { + topPanel.border = JBUI.Borders.empty(0, 0, UiStyle.Gap.sm(), 0) + card.setSpacing(UiStyle.Gap.sm(), UiStyle.Gap.pad()) + return + } + card.setSpacing(UiStyle.Gap.xl(), UiStyle.Gap.pad()) } @RequiresEdt diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt index 7dc9cb8a40..a2dcc25d15 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/todo/TodoWriteView.kt @@ -6,9 +6,9 @@ import ai.kilocode.client.session.model.Tool import ai.kilocode.client.session.model.ToolExecState import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.PrimarySessionPartView import ai.kilocode.client.ui.UiStyle -import com.intellij.icons.AllIcons import com.intellij.ui.components.JBLabel import com.intellij.util.ui.JBUI import java.awt.BorderLayout @@ -28,7 +28,13 @@ class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) : init { bindHeader(parts.glyph, parts.title, parts.sub, parts.center, parts.controls) parts.list.border = JBUI.Borders.compound( - SessionUiStyle.View.topOutline(), + JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ), JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()), ) applyStyle(style) @@ -92,16 +98,16 @@ class TodoParts( ) private fun todoParts(): TodoParts { - val glyph = JBLabel(AllIcons.Actions.Checked) + val glyph = JBLabel(SessionViewIcons.checklist) val title = JBLabel(KiloBundle.message("session.part.todo.title")) val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } - val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false add(title, BorderLayout.WEST) add(sub, BorderLayout.CENTER) } val controls = Box.createHorizontalBox() - val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP), 0)).apply { + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false add(glyph, BorderLayout.WEST) add(center, BorderLayout.CENTER) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt new file mode 100644 index 0000000000..24d36cff64 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/BaseSearchToolView.kt @@ -0,0 +1,188 @@ +package ai.kilocode.client.session.views.tool + +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.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.Icon + +abstract class BaseSearchToolView( + tool: Tool, + private val selection: SessionSelection? = null, + private val parts: ToolParts, + private val repo: String? = null, +) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { + + override val contentId: String = tool.id + + protected var item = tool + private var style = SessionEditorStyle.current() + private var registered = false + private var disposed = false + + protected abstract fun toolIcon(tool: Tool): Icon + protected abstract fun toolTitle(tool: Tool): String + protected abstract fun targets(tool: Tool, repo: String?): List + protected abstract fun viewName(): String + + init { + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + parts.targets.forEach { bindHeader(it) } + applyStyle(style) + sync() + } + + @RequiresEdt + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + applyBodyStyle() + return true + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + item = content + var changed = sync() + changed = syncBody() || changed + if (changed) refresh() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text).plus(targetTexts()).plus(parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + @RequiresEdt + fun bodyText(): String = body(item) + @RequiresEdt + internal fun targetTexts(): List = parts.targets.map { it.text }.filter { it.isNotBlank() } + @RequiresEdt + internal fun targetVisible(index: Int): Boolean = parts.targets.getOrNull(index)?.isVisible ?: false + @RequiresEdt + internal fun bodyVisible() = parts.scroll?.parent === this + @RequiresEdt + internal fun hasToggle() = arrow.isVisible + @RequiresEdt + internal fun bodyFont() = parts.content?.font ?: style.editorFont + @RequiresEdt + internal fun titleFont() = parts.title.font + @RequiresEdt + internal fun targetFont(index: Int) = parts.targets.getOrNull(index)?.font ?: style.regularFont + @RequiresEdt + internal fun stateFont() = parts.state.font + @RequiresEdt + internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt + internal fun scrollComponent() = parts.scroll + @RequiresEdt + internal fun bodyEditor() = parts.content?.editor + @RequiresEdt + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy + @RequiresEdt + internal fun verticalPolicy() = parts.scroll?.verticalScrollBarPolicy + @RequiresEdt + internal fun bodyWrap() = parts.content?.lineWrap ?: false + @RequiresEdt + internal fun headerComponent() = parts.header + @RequiresEdt + internal fun centerComponent() = parts.center + @RequiresEdt + internal fun targetComponents() = parts.targets + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.smallEditorFont) || changed + parts.targets.forEach { changed = setFont(it, style.regularFont) || changed } + changed = setFont(parts.state, style.smallEditorFont) || changed + changed = applyBodyStyle() || changed + if (changed) refresh() + } + + private fun sync(): Boolean { + val expand = canExpand(item) + var changed = false + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, item.state != ToolExecState.COMPLETED) || changed + changed = setIcon(parts.glyph, toolIcon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, toolTitle(item)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.weak()) || changed + changed = syncTargets() || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + val body = parts.content + if (body != null && body.foreground != bodyColor()) { + body.foreground = bodyColor() + changed = true + } + return changed + } + + private fun syncTargets(): Boolean { + val values = targets(item, repo) + var changed = false + parts.targets.forEachIndexed { index, label -> + val text = values.getOrNull(index) ?: "" + changed = setVisible(label, text.isNotBlank()) || changed + changed = setTargetText(label, text) || changed + changed = setForeground(label, UiStyle.Colors.fg()) || changed + } + return changed + } + + private fun syncBody(): Boolean { + val body = parts.content ?: return false + val value = plainBody(item) + if (body.text != value) { + body.text = value + return true + } + return false + } + + private fun applyBodyStyle(): Boolean { + val body = parts.content ?: return false + if (!disposed) { + Disposer.register(this, body) + disposed = true + } + if (!registered && selection != null && parts.scroll?.parent != null) { + registered = true + body.register(selection, this) + } + return body.applyStyle(style) + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val body = parts.content ?: return 0 + return body.lineHeight() * SessionUiStyle.View.Tool.BODY_LINES + + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "${viewName()}#$contentId(${labelText()})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt new file mode 100644 index 0000000000..ccb204da04 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/GlobToolView.kt @@ -0,0 +1,23 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection + +/** Renders glob calls with a stacked, collapsible search-result header. */ +class GlobToolView( + tool: Tool, + selection: SessionSelection? = null, + parts: ToolParts = searchParts(2), + repo: String? = null, +) : BaseSearchToolView(tool, selection, parts, repo) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "glob" + } + + override fun toolIcon(tool: Tool) = icon(tool) + override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.glob") + override fun targets(tool: Tool, repo: String?) = listOf(globDirectory(tool, repo), globPattern(tool)).filter { it.isNotBlank() } + override fun viewName() = "GlobToolView" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt new file mode 100644 index 0000000000..d2c2a623ea --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ReadToolView.kt @@ -0,0 +1,170 @@ +package ai.kilocode.client.session.views.tool + +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.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.ui.UiStyle +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +/** Renders read calls with secondary, borderless chrome. */ +class ReadToolView( + tool: Tool, + openFile: (String) -> Unit = {}, + private val selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool, openFile), +) : SecondarySessionPartView(parts.header, parts.scroll(tool), expandable = false) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ + } + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + + init { + parts.text?.let { selection?.register(it, this) } + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + parts.text?.text = preview(item) + applyStyle(style) + sync() + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + item = content + var changed = sync() + changed = syncBody() || changed + if (changed) refresh() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + @RequiresEdt + fun bodyText(): String = body(item) + @RequiresEdt + internal fun bodyVisible() = parts.scroll?.parent === this + @RequiresEdt + internal fun hasToggle() = arrow.isVisible + @RequiresEdt + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt + internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES + @RequiresEdt + internal fun bodyFont() = parts.text?.font ?: style.transcriptFont + @RequiresEdt + internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt + internal fun bodyWrap() = parts.text?.lineWrap ?: false + @RequiresEdt + internal fun bodyEditor() = parts.content?.editor + @RequiresEdt + internal fun linkVisible() = parts.link.isVisible + @RequiresEdt + internal fun linkText() = parts.label + @RequiresEdt + internal fun linkMarkup() = parts.link.text ?: "" + @RequiresEdt + internal fun linkForeground() = parts.link.foreground + @RequiresEdt + internal fun linkFont() = parts.link.font + @RequiresEdt + internal fun subtitleForeground() = parts.sub.foreground + @RequiresEdt + internal fun subtitleFont() = parts.sub.font + @RequiresEdt + internal fun linkHref() = parts.href + @RequiresEdt + internal fun openLink() = parts.openLink() + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.transcriptFont) || changed + changed = setFont(parts.link, style.transcriptFont) || changed + changed = setFont(parts.state, style.smallEditorFont) || changed + parts.text?.let { changed = setFont(it, style.transcriptFont) || changed } + if (changed) refresh() + } + + private fun sync(): Boolean { + var changed = false + changed = syncExpandable(false) || changed + changed = setVisible(parts.state, true) || changed + changed = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, title(item)) || changed + changed = syncSubtitle() || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setForeground(parts.sub, UiStyle.Colors.fg()) || changed + changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + parts.text?.let { changed = setForeground(it, bodyColor()) || changed } + return changed + } + + private fun syncSubtitle(): Boolean { + val target = target(item)?.takeIf { it.type == "file" } + if (target != null) { + var changed = false + if (parts.href != target.path) { + parts.href = target.path + changed = true + } + changed = setLinkText(parts, tail(target.path).ifBlank { target.path }) || changed + changed = show(parts, true) || changed + return changed + } + + var changed = false + if (parts.href != null) { + parts.href = null + changed = true + } + changed = setText(parts.sub, subtitle(item)) || changed + changed = show(parts, false) || changed + return changed + } + + private fun syncBody(): Boolean { + val value = preview(item) + val text = parts.text ?: return false + if (text.text == value) return false + text.text = value + text.caretPosition = 0 + return true + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val text = parts.text ?: return 0 + return text.getFontMetrics(text.font).height * bodyMaxRows() + + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "ReadToolView#$contentId(${labelText()})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt new file mode 100644 index 0000000000..83084495e6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/SearchToolView.kt @@ -0,0 +1,24 @@ +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.views.SessionViewIcons + +/** Renders grep/content-search calls with stacked, clipped search targets. */ +class SearchToolView( + tool: Tool, + selection: SessionSelection? = null, + parts: ToolParts = searchParts(3), + repo: String? = null, +) : BaseSearchToolView(tool, selection, parts, repo) { + + companion object { + fun canRender(tool: Tool): Boolean = tool.name == "grep" + } + + override fun toolIcon(tool: Tool) = SessionViewIcons.search + override fun toolTitle(tool: Tool) = KiloBundle.message("session.part.tool.search") + override fun targets(tool: Tool, repo: String?) = searchTargets(tool, repo) + override fun viewName() = "SearchToolView" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt new file mode 100644 index 0000000000..81744bfb98 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolSupport.kt @@ -0,0 +1,680 @@ +@file:Suppress("TooManyFunctions") + +package ai.kilocode.client.session.views.tool + +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.ui.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.SessionViewIcons +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.HAlign +import ai.kilocode.client.ui.layout.Stack +import ai.kilocode.client.ui.layout.VAlign +import ai.kilocode.client.ui.layout.align +import ai.kilocode.log.KiloLog +import com.intellij.openapi.Disposable +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.fileTypes.PlainTextFileType +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.util.io.OSAgnosticPathUtil +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBDimension +import com.intellij.util.ui.JBUI +import com.intellij.xml.util.XmlStringUtil +import java.awt.BorderLayout +import java.awt.CardLayout +import java.awt.Color +import java.awt.Cursor +import java.awt.Font +import java.awt.event.MouseAdapter +import java.awt.event.MouseEvent +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.ScrollPaneConstants + +private val LOG = KiloLog.create(ToolParts::class.java) + +enum class ToolBodyMode { EDITOR, TEXT } + +class ToolParts( + val header: JPanel, + val glyph: JBLabel, + val title: JBLabel, + val sub: JBLabel, + val link: JBLabel, + val slot: JPanel, + val state: JBLabel, + val center: JPanel, + val controls: JComponent, + private val open: ((String) -> Unit)? = null, + val extra: JBLabel? = null, + val targets: List = emptyList(), + private val mode: ToolBodyMode = ToolBodyMode.EDITOR, +) { + var href: String? = null + var label: String = "" + private var body: ToolBody? = null + + val text: JBTextArea? + @RequiresEdt + get() = body?.area + + val content: ToolBody? + @RequiresEdt + get() = body + + val scroll: JBScrollPane? + @RequiresEdt + get() = body?.scroll + + @RequiresEdt + fun scroll(tool: Tool): JBScrollPane = body(tool).scroll + + @RequiresEdt + fun bodyCreated() = body != null + + @RequiresEdt + fun openLink() { + val value = href ?: return + open?.invoke(value) + } + + @RequiresEdt + private fun body(tool: Tool): ToolBody { + val item = body + if (item != null) return item + val body = when (mode) { + ToolBodyMode.EDITOR -> ToolBody.editor(tool) + ToolBodyMode.TEXT -> ToolBody.text(tool) + } + return body.also { this.body = it } + } +} + +class ToolBody private constructor( + val area: JBTextArea?, + val ed: EditorTextField?, + val scroll: JBScrollPane, + private val disposable: Disposable?, +) : Disposable { + var text: String + @RequiresEdt + get() = area?.text ?: ed?.text ?: "" + @RequiresEdt + set(value) { + if (text == value) return + area?.text = value + ed?.text = value + caretStart() + size() + } + + var font: Font + @RequiresEdt + get() = area?.font ?: ed?.font ?: SessionEditorStyle.current().editorFont + @RequiresEdt + set(value) { + area?.font = value + ed?.font = value + size() + } + + var foreground: Color + @RequiresEdt + get() = area?.foreground ?: ed?.foreground ?: UiStyle.Colors.fg() + @RequiresEdt + set(value) { + area?.foreground = value + ed?.foreground = value + } + + val editable: Boolean get() = area?.isEditable ?: false + val caretVisible: Boolean get() = area?.caret?.isVisible ?: false + val lineWrap: Boolean get() = area?.lineWrap ?: false + val editor: EditorTextField? get() = ed + + @RequiresEdt + fun caretStart() { + area?.caretPosition = 0 + ed?.getEditor(false)?.caretModel?.moveToOffset(0) + } + + @RequiresEdt + fun applyStyle(style: SessionEditorStyle): Boolean { + val before = font + area?.font = style.transcriptFont + ed?.font = style.editorFont + ed?.getEditor(false)?.let(style::applyToEditor) + size() + return before != font + } + + @RequiresEdt + fun register(selection: SessionSelection, parent: Disposable) { + val field = ed + if (field != null) { + selection.register(field, parent) + return + } + area?.let { selection.register(it, parent) } + } + + @RequiresEdt + fun lineHeight(): Int = ed?.getEditor(false)?.lineHeight ?: scroll.viewport.view.getFontMetrics(font).height + + override fun dispose() { + disposable?.let(Disposer::dispose) + } + + private fun size() { + val view = scroll.viewport.view as? JComponent ?: return + val height = height(view) + val width = width(view) + view.preferredSize = JBUI.size(width, height) + view.minimumSize = JBUI.size(0, height) + view.maximumSize = JBDimension(Int.MAX_VALUE, height) + val inset = scroll.viewportBorder?.getBorderInsets(scroll) ?: JBUI.emptyInsets() + val pane = height + scroll.insets.top + scroll.insets.bottom + inset.top + inset.bottom + + scroll.horizontalScrollBar.preferredSize.height + scroll.preferredSize = JBUI.size(0, pane) + scroll.minimumSize = JBUI.size(0, pane) + scroll.maximumSize = JBDimension(Int.MAX_VALUE, pane) + } + + private fun width(view: JComponent): Int { + val metrics = view.getFontMetrics(font) + return (text.lineSequence().maxOfOrNull { metrics.stringWidth(it) } ?: 0) + + JBUI.scale(SessionUiStyle.View.Code.WIDTH_PADDING) + } + + private fun height(view: JComponent): Int { + ed?.ensureWillComputePreferredSize() + val rows = text.lineSequence().count().coerceAtLeast(SessionUiStyle.View.Code.MIN_ROWS) + return maxOf(view.preferredSize.height, lineHeight() * rows) + } + + companion object { + @RequiresEdt + fun editor(tool: Tool): ToolBody { + val disposable = Disposer.newDisposable("Tool body") + val body = runCatching { + val field = ToolField(preview(tool), SessionEditorStyle.current()).also { ed -> + ed.setDisposedWith(disposable) + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } + } + ToolBody(null, field, pane(field, true), disposable) + }.getOrElse { err -> + LOG.warn("kind=tool codeEditor=true failed message=${err.message}", err) + val area = area(tool, false) + ToolBody(area, null, pane(area, true), disposable) + } + body.size() + return body + } + + @RequiresEdt + fun text(tool: Tool): ToolBody { + val area = area(tool, true) + val body = ToolBody(area, null, pane(area, false), null) + body.size() + return body + } + + private fun area(tool: Tool, wrap: Boolean) = JBTextArea().apply { + isEditable = false + caret.isVisible = false + caret.isSelectionVisible = true + lineWrap = wrap + wrapStyleWord = wrap + foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + background = SessionUiStyle.View.Surface.bgColor() + border = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ) + } + + private fun pane(view: JComponent, scrolls: Boolean) = JBScrollPane(view).apply { + border = JBUI.Borders.customLine( + SessionUiStyle.View.Outline.color(), + SessionUiStyle.View.Outline.width(), + 0, + 0, + 0, + ) + viewportBorder = JBUI.Borders.empty( + JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING), + JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), + ).takeIf { scrolls } + isOpaque = true + background = SessionUiStyle.View.Surface.bgColor() + viewport.background = SessionUiStyle.View.Surface.bgColor() + horizontalScrollBarPolicy = if (scrolls) { + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED + } else { + ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + } + verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED + } + } +} + +private class ToolField(value: String, private var style: SessionEditorStyle) : EditorTextField( + EditorFactory.getInstance().createDocument(value.trimEnd('\n')), + ProjectManager.getInstance().defaultProject, + PlainTextFileType.INSTANCE, + true, + false, +) { + init { + setFontInheritedFromLAF(false) + font = style.editorFont + addSettingsProvider { ed -> + style.applyToEditor(ed) + ed.setBorder(JBUI.Borders.empty()) + ed.scrollPane.border = JBUI.Borders.empty() + ed.scrollPane.viewportBorder = JBUI.Borders.empty() + ed.backgroundColor = SessionUiStyle.View.Surface.bgColor() + ed.scrollPane.background = SessionUiStyle.View.Surface.bgColor() + ed.scrollPane.viewport.background = SessionUiStyle.View.Surface.bgColor() + ed.settings.isUseSoftWraps = false + ed.settings.isAdditionalPageAtBottom = false + ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + ed.scrollPane.verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + } + } +} + +private const val SUB_CARD = "sub" +private const val LINK_CARD = "link" + +@RequiresEdt +internal fun toolParts( + tool: Tool, + openFile: ((String) -> Unit)? = null, + mode: ToolBodyMode = ToolBodyMode.TEXT, +): ToolParts { + lateinit var parts: ToolParts + val glyph = JBLabel() + val title = JBLabel() + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val link = JBLabel().apply { + isVisible = false + isFocusable = false + foreground = UiStyle.Colors.fg() + cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) + setRequestFocusEnabled(false) + addMouseListener(object : MouseAdapter() { + override fun mouseClicked(e: MouseEvent) { + parts.openLink() + } + }) + } + val slot = JPanel(CardLayout()).apply { + isOpaque = false + add(sub, SUB_CARD) + add(link, LINK_CARD) + } + val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { isOpaque = false } + val controls = Stack.horizontal() + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + isOpaque = false + center.add(title, BorderLayout.WEST) + center.add(slot, BorderLayout.CENTER) + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, openFile, mode = mode) + return parts.also { + controls.add(it.state) + } +} + +@RequiresEdt +internal fun searchParts(count: Int): ToolParts { + val glyph = JBLabel() + val title = JBLabel() + val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val targets = List(count) { + JBLabel().apply { + foreground = UiStyle.Colors.fg() + minimumSize = JBUI.size(0, minimumSize.height) + } + } + val link = JBLabel().apply { isVisible = false } + val slot = JPanel(CardLayout()).apply { + isOpaque = false + add(sub, SUB_CARD) + add(link, LINK_CARD) + } + val state = JBLabel().apply { foreground = UiStyle.Colors.weak() } + val stack = Stack.fitHorizontal(UiStyle.Gap.md()).apply { targets.forEach { next(it) } } + val target = stack.align(HAlign.TRACK, VAlign.CENTER) + val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + isOpaque = false + minimumSize = JBUI.size(0, minimumSize.height) + add(title, BorderLayout.WEST) + add(target, BorderLayout.CENTER) + } + val controls = Stack.horizontal() + val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply { + isOpaque = false + add(glyph, BorderLayout.WEST) + add(center, BorderLayout.CENTER) + add(controls, BorderLayout.EAST) + } + return ToolParts(header, glyph, title, sub, link, slot, state, center, controls, targets = targets, mode = ToolBodyMode.EDITOR).also { + controls.add(it.state) + } +} + +internal fun icon(tool: Tool) = when (tool.name) { + "read" -> SessionViewIcons.glasses + "list" -> SessionViewIcons.bulletList + "glob", "grep" -> SessionViewIcons.search + "webfetch", "websearch" -> SessionViewIcons.windowCursor + "codesearch" -> SessionViewIcons.code + "task" -> SessionViewIcons.task + "bash" -> SessionViewIcons.console + "edit", "write", "apply_patch" -> SessionViewIcons.codeLines + "todowrite", "todoread" -> SessionViewIcons.checklist + "question" -> SessionViewIcons.bubble + "skill" -> SessionViewIcons.brain + else -> SessionViewIcons.mcp +} + +internal fun title(tool: Tool) = when (tool.name) { + "read" -> KiloBundle.message("session.part.tool.read") + "bash" -> KiloBundle.message("session.part.tool.shell") + else -> toolTitle(tool) +} + +internal fun subtitle(tool: Tool) = when (tool.name) { + "read" -> readPath(tool) + "bash" -> shellTitle(tool) + else -> toolSubtitle(tool) +} + +@RequiresEdt +internal fun setText(label: JBLabel, text: String): Boolean { + val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml(XmlStringUtil.escapeString(text)) + if (label.text == value) return false + label.text = value + return true +} + +@RequiresEdt +internal fun setTargetText(label: JBLabel, text: String): Boolean { + if (label.text == text) return false + label.text = text + return true +} + +@RequiresEdt +internal fun setLinkText(parts: ToolParts, text: String): Boolean { + val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("${XmlStringUtil.escapeString(text)}") + if (parts.label == text && parts.link.text == value) return false + parts.label = text + parts.link.text = value + return true +} + +@RequiresEdt +internal fun show(parts: ToolParts, link: Boolean): Boolean { + if (parts.link.isVisible == link && parts.sub.isVisible != link) return false + (parts.slot.layout as CardLayout).show(parts.slot, if (link) LINK_CARD else SUB_CARD) + return true +} + +internal fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text + +@RequiresEdt +internal fun setIcon(label: JBLabel, icon: Icon): Boolean { + if (label.icon === icon) return false + label.icon = icon + return true +} + +@RequiresEdt +internal fun setVisible(component: JComponent, visible: Boolean): Boolean { + if (component.isVisible == visible) return false + component.isVisible = visible + return true +} + +@RequiresEdt +internal fun setForeground(component: JComponent, color: Color): Boolean { + if (same(component.foreground, color)) return false + component.foreground = color + return true +} + +@RequiresEdt +internal fun setFont(component: JComponent, font: Font): Boolean { + if (component.font == font) return false + component.font = font + return true +} + +private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb + +internal fun color(tool: Tool) = when (tool.state) { + ToolExecState.PENDING -> SessionUiStyle.View.Tool.pending() + ToolExecState.RUNNING -> SessionUiStyle.View.Tool.running() + ToolExecState.COMPLETED -> SessionUiStyle.View.Tool.completed() + ToolExecState.ERROR -> SessionUiStyle.View.Tool.error() +} + +internal fun titleColor(tool: Tool) = if (tool.state == ToolExecState.ERROR) { + UiStyle.Colors.errorLabelForeground() +} else { + UiStyle.Colors.fg() +} + +internal fun stateText(tool: Tool) = when (tool.state) { + ToolExecState.PENDING -> KiloBundle.message("session.part.tool.pending") + ToolExecState.RUNNING -> KiloBundle.message("session.part.tool.running") + ToolExecState.COMPLETED -> "" + ToolExecState.ERROR -> KiloBundle.message("session.part.tool.error") +} + +private fun readPath(tool: Tool): String { + val target = target(tool) + if (target != null) { + if (target.type == "file") return tail(target.path).ifBlank { target.path } + return target.path + } + val path = tool.input["filePath"] ?: tool.input["path"] ?: tool.title ?: return tool.name + return tail(path).ifBlank { path } +} + +internal fun searchPath(path: String, repo: String?): String { + val text = path.takeIf { it.isNotBlank() } ?: return "" + val root = repo?.takeIf { it.isNotBlank() }?.let(::norm) + if (root == null) return text.takeUnless { it == "." } ?: "" + val full = if (OSAgnosticPathUtil.isAbsolute(text)) norm(text) else norm(FileUtil.join(root, text)) + if (full == root) return "" + if (!OSAgnosticPathUtil.startsWith(full, root)) return full + return FileUtil.getRelativePath(root, full, '/') ?: full +} + +private fun norm(path: String): String = FileUtil.toCanonicalPath(FileUtil.toSystemIndependentName(path), '/', true) + +internal fun globDirectory(tool: Tool, repo: String?): String = + searchPath( + tool.input["path"]?.takeIf { it.isNotBlank() } + ?: tool.title?.takeIf { it.isNotBlank() } + ?: "", + repo, + ) + +internal fun globPattern(tool: Tool): String = + tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" } ?: "" + +internal fun searchTargets(tool: Tool, repo: String?): List = listOfNotNull( + tool.input["path"]?.takeIf { it.isNotBlank() }?.let { searchPath(it, repo) }?.takeIf { it.isNotBlank() }, + tool.input["pattern"]?.takeIf { it.isNotBlank() }?.let { "pattern=$it" }, + tool.input["include"]?.takeIf { it.isNotBlank() }?.let { "include=$it" }, +) + +internal data class Target( + val path: String, + val type: String, +) + +internal fun target(tool: Tool): Target? { + val out = output(tool) + if (out.isBlank()) return null + val path = tag(out, "path") ?: return null + val type = tag(out, "type") ?: return null + return Target(path, type.lowercase()) +} + +private fun tag(text: String, name: String): String? = + Regex("<$name>\\s*([\\s\\S]*?)\\s*") + .find(text) + ?.groupValues + ?.getOrNull(1) + ?.trim() + ?.takeIf { it.isNotBlank() } + +private fun shellTitle(tool: Tool): String = + tool.input["description"]?.takeIf { it.isNotBlank() } + ?: tool.metadata["description"]?.takeIf { it.isNotBlank() } + ?: tool.title?.takeIf { it.isNotBlank() } + ?: command(tool).lineSequence().firstOrNull { it.isNotBlank() } + ?: "" + +internal fun command(tool: Tool): String = + tool.input["command"]?.takeIf { it.isNotBlank() } + ?: tool.metadata["command"]?.takeIf { it.isNotBlank() } + ?: "" + +internal fun output(tool: Tool): String = + tool.output?.takeIf { it.isNotBlank() } + ?: tool.metadata["output"]?.takeIf { it.isNotBlank() } + ?: "" + +internal fun preview(tool: Tool): String = if (tool.name == "bash") shellPreview(tool) else plainPreview(tool) + +internal fun body(tool: Tool): String = if (tool.name == "bash") shellBody(tool) else plainBody(tool) + +private fun shellPreview(tool: Tool): String { + val cmd = command(tool) + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return Preview().apply { + if (cmd.isNotBlank()) append("$ ").append(cmd) + if (out.isNotBlank()) { + sep() + append(out) + } + if (err != null) { + sep() + append(err) + } + }.build() +} + +private fun shellBody(tool: Tool): String { + val cmd = command(tool) + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return buildString { + if (cmd.isNotBlank()) append("$ ").append(cmd) + if (out.isNotBlank()) { + if (isNotEmpty()) append("\n\n") + append(out) + } + if (err != null) { + if (isNotEmpty()) append("\n\n") + append(err) + } + } +} + +private fun plainPreview(tool: Tool): String { + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return Preview().apply { + if (out.isNotBlank()) append(out) + if (err != null) { + sep() + append(err) + } + }.build() +} + +internal fun plainBody(tool: Tool): String { + val out = output(tool) + val err = tool.error?.takeIf { it.isNotBlank() } + return listOf(out, err).filter { !it.isNullOrBlank() }.joinToString("\n\n") +} + +internal fun canExpand(tool: Tool): Boolean { + if (tool.name == "bash") return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank() + return output(tool).isNotBlank() || !tool.error.isNullOrBlank() +} + +private fun toolTitle(tool: Tool): String = + tool.title?.takeIf { it.isNotBlank() } + ?: tool.name.replace('_', ' ').replaceFirstChar { it.titlecase() } + +private fun toolSubtitle(tool: Tool): String { + val base = listOf("description", "query", "url", "filePath", "path", "name") + .mapNotNull { tool.input[it]?.takeIf { value -> value.isNotBlank() } } + .firstOrNull() + val args = listOf("pattern", "include", "offset", "limit") + .mapNotNull { key -> tool.input[key]?.takeIf { it.isNotBlank() }?.let { "$key=$it" } } + return listOfNotNull(base).plus(args).joinToString(" ") +} + +internal fun tail(path: String): String { + val value = path.trimEnd('/', '\\') + val index = maxOf(value.lastIndexOf('/'), value.lastIndexOf('\\')) + if (index < 0) return value + return value.substring(index + 1) +} + +private class Preview { + private val text = StringBuilder() + private var cut = false + + fun append(value: String): Preview { + if (cut) return this + val rem = SessionUiStyle.View.Tool.PREVIEW_LIMIT - text.length + if (value.length <= rem) { + text.append(value) + return this + } + if (rem > 0) text.append(value, 0, rem) + cut = true + return this + } + + fun sep(): Preview { + if (text.isNotEmpty()) append("\n\n") + return this + } + + fun build(): String { + if (!cut) return text.toString() + if (text.isNotEmpty()) text.append("\n\n") + text.append(KiloBundle.message("session.part.tool.truncated")) + return text.toString() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt new file mode 100644 index 0000000000..97324bccd2 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/tool/ToolView.kt @@ -0,0 +1,185 @@ +package ai.kilocode.client.session.views.tool + +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.selection.SessionSelection +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.ui.UiStyle +import com.intellij.openapi.util.Disposer +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +/** Renders non-read tool calls with VS Code-inspired rows/cards. */ +class ToolView( + tool: Tool, + private val selection: SessionSelection? = null, + private val parts: ToolParts = toolParts(tool, mode = ToolBodyMode.EDITOR), +) : SecondarySessionPartView(parts.header, { parts.scroll(tool) }) { + + override val contentId: String = tool.id + + private var item = tool + private var style = SessionEditorStyle.current() + private var registered = false + private var disposed = false + + init { + bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot) + applyStyle(style) + sync() + } + + @RequiresEdt + override fun expand(): Boolean { + val changed = super.expand() + if (!changed) return false + syncBody() + applyBodyStyle() + return true + } + + @RequiresEdt + override fun getPreferredSize(): Dimension { + val size = super.getPreferredSize() + if (!bodyVisible()) return size + val height = row.preferredSize.height + bodyMaxHeight() + return Dimension(size.width, minOf(size.height, height)) + } + + @RequiresEdt + override fun update(content: Content) { + if (content !is Tool) return + val was = item.name + item = content + var changed = false + if (was != content.name || !canExpand(content)) changed = collapse() || changed + changed = sync() || changed + changed = syncBody() || changed + if (changed) refresh() + } + + @RequiresEdt + fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text) + .filter { it.isNotBlank() } + .joinToString(" ") + + @RequiresEdt + fun commandText(): String = command(item) + @RequiresEdt + fun outputText(): String = output(item) + @RequiresEdt + fun bodyText(): String = body(item) + @RequiresEdt + internal fun previewText(): String = parts.content?.text ?: preview(item) + @RequiresEdt + fun hasToggle(): Boolean = arrow.isVisible + @RequiresEdt + internal fun bodyFont() = parts.content?.font ?: style.editorFont + @RequiresEdt + internal fun titleFont() = parts.title.font + @RequiresEdt + internal fun subtitleFont() = parts.sub.font + @RequiresEdt + internal fun stateFont() = parts.state.font + @RequiresEdt + internal fun bodyEditable() = parts.content?.editable ?: false + @RequiresEdt + internal fun bodyCaretVisible() = parts.content?.caretVisible ?: false + @RequiresEdt + internal fun bodyVisible() = parts.scroll?.parent === this + @RequiresEdt + internal fun controlCount() = if (arrow.isVisible) 1 else 0 + @RequiresEdt + internal fun horizontalPolicy() = parts.scroll?.horizontalScrollBarPolicy ?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER + @RequiresEdt + internal fun verticalPolicy() = parts.scroll?.verticalScrollBarPolicy ?: ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER + @RequiresEdt + internal fun bodyWrap() = parts.content?.lineWrap ?: false + @RequiresEdt + internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES + @RequiresEdt + internal fun bodyCreated() = parts.bodyCreated() + @RequiresEdt + internal fun bodyEditor() = parts.content?.editor + + @RequiresEdt + override fun applyStyle(style: SessionEditorStyle) { + this.style = style + var changed = false + changed = setFont(parts.title, style.boldEditorFont) || changed + changed = setFont(parts.sub, style.smallEditorFont) || changed + changed = setFont(parts.link, style.smallEditorFont) || changed + changed = setFont(parts.state, style.smallEditorFont) || changed + changed = applyBodyStyle() || changed + if (changed) refresh() + } + + private fun sync(): Boolean { + val expand = canExpand(item) + var changed = false + changed = syncExpandable(expand) || changed + changed = setVisible(parts.state, !expand) || changed + changed = syncLabels() || changed + val body = parts.content + if (body != null && body.foreground != bodyColor()) { + body.foreground = bodyColor() + changed = true + } + return changed + } + + private fun syncLabels(): Boolean { + var changed = false + changed = setIcon(parts.glyph, icon(item)) || changed + changed = setForeground(parts.glyph, color(item)) || changed + changed = setText(parts.title, title(item)) || changed + changed = setText(parts.sub, subtitle(item)) || changed + changed = setForeground(parts.title, titleColor(item)) || changed + changed = setText(parts.state, stateText(item)) || changed + changed = setForeground(parts.state, color(item)) || changed + return changed + } + + private fun syncBody(): Boolean { + var changed = false + val body = parts.content ?: return false + val value = preview(item) + if (body.text != value) { + body.text = value + changed = true + } + if (body.foreground != bodyColor()) { + body.foreground = bodyColor() + changed = true + } + return changed + } + + private fun applyBodyStyle(): Boolean { + val body = parts.content ?: return false + if (!disposed) { + Disposer.register(this, body) + disposed = true + } + if (!registered && selection != null && parts.scroll?.parent != null) { + registered = true + body.register(selection, this) + } + return body.applyStyle(style) + } + + private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg() + + private fun bodyMaxHeight(): Int { + val body = parts.content ?: return 0 + return body.lineHeight() * bodyMaxRows() + + JBUI.scale(SessionUiStyle.View.Layout.BODY_EXTRA_HEIGHT) + } + + override fun dumpLabel() = "ToolView#$contentId(${labelText()})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt index bc8385e96b..e043225c3c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/layout/Stack.kt @@ -51,11 +51,19 @@ open class Stack( private val mgr: Layout get() = getLayout() as Layout + internal fun fit(): Stack { + mgr.fit = true + revalidate() + return this + } + private class Layout( private val axis: StackAxis, private val gap: Int, ) : LayoutManager2 { + var fit = false + private val entries = mutableListOf() fun gap(size: Int) { @@ -83,6 +91,10 @@ open class Stack( val ins = parent.insets val w = maxOf(0, parent.width - ins.left - ins.right) val h = maxOf(0, parent.height - ins.top - ins.bottom) + if (axis == StackAxis.HORIZONTAL && fit) { + fit(parent, ins.left, ins.top, w, h) + return + } var x = ins.left var y = ins.top var seen = false @@ -131,6 +143,48 @@ open class Stack( } } + private fun fit(parent: Container, left: Int, top: Int, w: Int, h: Int) { + val items = children(parent, h) + var x = left + var rest = w + items.forEach { item -> + val gap = minOf(item.gap, rest) + x += gap + rest -= gap + val width = minOf(item.width, rest) + item.comp.setBounds(x, top, width, h) + x += width + rest -= width + } + } + + private fun children(parent: Container, h: Int): List { + val items = mutableListOf() + var seen = false + var ready = false + var pending: Int? = null + for (entry in entries) { + when (entry) { + is Entry.Gap -> if (ready) pending = safe(pending ?: 0, entry.size) + is Entry.Child -> { + val space = pending + pending = null + ready = false + if (entry.comp.isVisible) { + entry.comp.setSize(entry.comp.width.coerceAtLeast(1), h) + val pref = entry.comp.preferredSize + val min = entry.comp.minimumSize + val max = entry.comp.maximumSize + items.add(Item(entry.comp, if (seen) space ?: gap else 0, bound(pref.width, min.width, max.width))) + seen = true + ready = true + } + } + } + } + return items + } + override fun minimumLayoutSize(parent: Container) = size(parent, Size.MIN) override fun preferredLayoutSize(parent: Container) = size(parent, Size.PREF) override fun maximumLayoutSize(target: Container) = size(target, Size.MAX) @@ -204,6 +258,8 @@ open class Stack( data class Child(val comp: Component) : Entry data class Gap(val size: Int) : Entry } + + private data class Item(val comp: Component, val gap: Int, val width: Int) } private enum class Size { MIN, PREF, MAX } @@ -211,6 +267,7 @@ open class Stack( companion object { fun vertical(gap: Int = 0) = Stack(StackAxis.VERTICAL, gap) fun horizontal(gap: Int = 0) = Stack(StackAxis.HORIZONTAL, gap) + fun fitHorizontal(gap: Int = 0) = Stack(StackAxis.HORIZONTAL, gap).fit() fun verticalFiller(size: Int): Component = filler(StackAxis.VERTICAL, size) fun horizontalFiller(size: Int): Component = filler(StackAxis.HORIZONTAL, size) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt index b5579cdd4f..cd03b91b5c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdViewHybrid.kt @@ -27,6 +27,7 @@ import org.commonmark.node.Document import org.commonmark.node.FencedCodeBlock import org.commonmark.node.IndentedCodeBlock import org.commonmark.node.Node +import org.commonmark.node.ThematicBreak import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import java.awt.Color @@ -498,6 +499,9 @@ internal class MdViewHybrid( val field = runCatching { CodeField(file, opts, text).also { ed -> ed.setDisposedWith(disposable) + Disposer.register(disposable) { + ed.getEditor(false)?.let(EditorFactory.getInstance()::releaseEditor) + } selection?.register(ed, disposable) } }.getOrElse { err -> @@ -682,8 +686,14 @@ internal class MdViewHybrid( fun flush() { if (md.isEmpty()) return val doc = parser.parse(md.toString()) - html.append(renderer.render(doc)) - blocks.addAll(collect(doc)) + val descs = collect(doc) + blocks.addAll(descs) + for (desc in descs) { + when (desc) { + is Desc.Html -> html.append(desc.body) + is Desc.Code -> html.append(codeHtml(desc.text)) + } + } md.clear() } @@ -896,27 +906,39 @@ internal class MdViewHybrid( private inner class Visitor : AbstractVisitor() { val blocks = mutableListOf() + private val run = StringBuilder() override fun visit(document: Document) { visitChildren(document) + flush() } override fun visit(code: FencedCodeBlock) { + flush() blocks.add(Desc.Code(code.literal, file(code.info))) } override fun visit(code: IndentedCodeBlock) { + flush() blocks.add(Desc.Code(code.literal, file(null))) } + private fun flush() { + if (run.isEmpty()) return + blocks.add(Desc.Html(run.toString())) + run.clear() + } + public override fun visitChildren(parent: Node) { var child = parent.firstChild while (child != null) { val next = child.next - if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) - if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) { - blocks.add(Desc.Html(renderer.render(child))) + if (child is ThematicBreak) { + child = next + continue } + if (child is FencedCodeBlock || child is IndentedCodeBlock) child.accept(this) + if (child is Block && child !is FencedCodeBlock && child !is IndentedCodeBlock) run.append(renderer.render(child)) child = next } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg new file mode 100644 index 0000000000..304a93ea9c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg new file mode 100644 index 0000000000..894515662b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/brain_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg new file mode 100644 index 0000000000..b76605a472 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg new file mode 100644 index 0000000000..95762858b5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bubble-5_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg new file mode 100644 index 0000000000..c15ecbd0e4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg new file mode 100644 index 0000000000..bcce4c17ae --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/bullet-list_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg new file mode 100644 index 0000000000..11b4cac00b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg new file mode 100644 index 0000000000..3997b5ec2e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/checklist_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg new file mode 100644 index 0000000000..b916dec2a8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg new file mode 100644 index 0000000000..9d727eb37c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-down_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg new file mode 100644 index 0000000000..57c07f8de8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg new file mode 100644 index 0000000000..0db8b8f5c8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-left_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg new file mode 100644 index 0000000000..c4f66e5533 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg new file mode 100644 index 0000000000..929005b5aa --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/chevron-right_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg new file mode 100644 index 0000000000..456560f632 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg new file mode 100644 index 0000000000..947b66ac25 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code-lines_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg new file mode 100644 index 0000000000..554b8656b0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg new file mode 100644 index 0000000000..ddfe624246 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/code_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg new file mode 100644 index 0000000000..5a27e30208 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg new file mode 100644 index 0000000000..44f84beaa6 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/console_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg new file mode 100644 index 0000000000..45d7231e5e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg new file mode 100644 index 0000000000..14983a084f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/eye_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg new file mode 100644 index 0000000000..1f891fb11d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg new file mode 100644 index 0000000000..769102d69e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/glasses_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg new file mode 100644 index 0000000000..3ab3bf728c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg new file mode 100644 index 0000000000..a1eff43699 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/magnifying-glass-menu_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg new file mode 100644 index 0000000000..cee92d0c0b --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg new file mode 100644 index 0000000000..4eb477f0e3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/mcp_dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg new file mode 100644 index 0000000000..17a37afba8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg new file mode 100644 index 0000000000..6eaae56d33 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/task_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg new file mode 100644 index 0000000000..802ac7d855 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg new file mode 100644 index 0000000000..d9f9e992c5 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/warning_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg new file mode 100644 index 0000000000..b26bac3f71 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg new file mode 100644 index 0000000000..80ac013dd3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/views/window-cursor_dark.svg @@ -0,0 +1,4 @@ + + + + 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 ff2594f38f..9141c1b9a8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -88,6 +88,8 @@ session.part.tool.copy=Copy session.part.tool.error=Error session.part.tool.pending=Pending session.part.tool.read=Read +session.part.tool.glob=Glob +session.part.tool.search=Search session.part.tool.running=Running session.part.tool.shell=Shell session.part.tool.truncated=Output truncated in preview. Full output remains in session data. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt index a5c6fbcacd..6aea6cecff 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionScrollTest.kt @@ -1,23 +1,33 @@ package ai.kilocode.client.session import ai.kilocode.client.session.ui.SessionMessageListPanel +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageWithPartsDto import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionOptionDto import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto import ai.kilocode.rpc.dto.ToolRefDto import ai.kilocode.client.session.ui.prompt.PromptPanel +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.plugin.KiloBundle import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBScrollPane import com.intellij.ui.components.JBRadioButton import com.intellij.util.ui.JBUI import java.awt.Container +import java.awt.Point import javax.swing.AbstractButton import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.Scrollable +import javax.swing.SwingConstants import javax.swing.JTextArea +import javax.swing.SwingUtilities import kotlinx.coroutines.CompletableDeferred @Suppress("UnstableApiUsage") @@ -43,7 +53,7 @@ class SessionScrollTest : SessionUiTestBase() { if (bottom(bar) <= threshold) { fillTranscript(24, start = 24) } - setValue(bar, bottom(bar) - threshold + 1) + setValuePassive(bar, bottom(bar) - threshold + 1) emit(ChatEventDto.MessageUpdated("ses_test", message("tail"))) drainScroll() @@ -51,6 +61,49 @@ class SessionScrollTest : SessionUiTestBase() { assertBottom(bar) } + fun `test viewport driven scroll can move away from stale saved position`() { + showMessages() + fillTranscript(24) + val bar = scrollBar() + setValue(bar, bottom(bar) / 2) + val value = bar.value + val target = (value + JBUI.scale(96)).coerceAtMost(bottom(bar) - 1) + assertTrue("value=$value target=$target bottom=${bottom(bar)}", target > value) + + (scrollComponent() as JBScrollPane).viewport.viewPosition = Point(0, target) + drainScroll() + + assertEquals(target, bar.value) + assertTrue(jumpButton().isVisible) + } + + fun `test user scroll upward near bottom disables tail follow`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + val threshold = JBUI.scale(32) + assertTrue("bottom=${bottom(bar)} threshold=$threshold", bottom(bar) > threshold * 2) + val id = "near_bottom_user_tail" + val pid = "near_bottom_user_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n")), flush = false) + forceFlush() + drainScroll() + setBottom(bar) + setValue(bar, bottom(bar) - threshold + 1) + val value = bar.value + assertFalse(ui.scroll.following()) + + repeat(240) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "tail line $i\n"), flush = false) + } + forceFlush() + drainScroll() + + assertTrue("value=$value actual=${bar.value}", bar.value >= value) + assertFalse(ui.scroll.following()) + } + fun `test session update preserves position outside bottom threshold`() { showMessages() fillTranscript(24) @@ -161,6 +214,22 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(jumpButton().isVisible) } + fun `test physical mouse wheel uses accelerated transcript unit distance`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + setValue(bar, 0) + drainScroll() + val amount = 3 + val expected = JBUI.scale(SessionUiStyle.SessionLayout.SCROLL_INCREMENT * amount) + assertTrue("bottom=${bottom(bar)} expected=$expected", bottom(bar) >= expected * 2) + + val view = scrollView() as Scrollable + val unit = view.getScrollableUnitIncrement(scrollComponent().visibleRect, SwingConstants.VERTICAL, 1) + + assertEquals(expected, unit * amount) + } + fun `test part delta follows bottom after height growth`() { showMessages() fillTranscript(24) @@ -181,6 +250,55 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(jumpButton().isVisible) } + fun `test user scrolling to bottom during massive stream resumes following`() { + showMessages() + fillTranscript(48) + val bar = scrollBar() + val id = "stream_massive_resume" + val pid = "stream_massive_resume_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n")), flush = false) + forceFlush() + drainScroll() + setValue(bar, bottom(bar) / 2) + assertFalse(ui.scroll.following()) + assertTrue(jumpButton().isVisible) + val first = buildString { + repeat(160) { i -> append("line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n")), flush = false) + forceFlush() + settleShort(100) + layout() + setBottom(bar) + drainScroll() + setBottom(bar) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(jumpButton().isVisible) + val second = buildString { + repeat(160) { i -> append("tail line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "tail line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n${second}snapshot tail\n\n")), flush = false) + forceFlush() + settleShort(100) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(jumpButton().isVisible) + } + fun `test part delta preserves middle scroll position`() { showMessages() fillTranscript(24) @@ -201,6 +319,51 @@ class SessionScrollTest : SessionUiTestBase() { assertEquals(value, bar.value) } + fun `test expanding tool at bottom preserves clicked header position`() { + val mid = "tool_expand_bottom" + val pid = "tool_expand_bottom_part" + rpc.history.addAll(history(23) + toolHistory(mid, pid) + historyRange(1, start = 23)) + ui = newUi(id = "ses_test") + settle() + drainScroll() + val bar = scrollBar() + setBottom(bar) + drainScroll() + val view = toolView(mid, pid) + assertFalse(view.bodyVisible()) + val y = visibleY(view) + val value = bar.value + + view.toggle() + drainScroll() + + assertTrue(view.bodyVisible()) + assertEquals(y, visibleY(view)) + assertEquals(value, bar.value) + } + + fun `test expanding tool in middle preserves clicked header position`() { + val mid = "tool_expand_middle" + val pid = "tool_expand_middle_part" + rpc.history.addAll(history(12) + toolHistory(mid, pid) + historyRange(12, start = 12)) + ui = newUi(id = "ses_test") + settle() + drainScroll() + val bar = scrollBar() + val view = toolView(mid, pid) + val top = SwingUtilities.convertPoint(view, Point(0, 0), scrollView()).y + setValue(bar, top - 80) + drainScroll() + val y = visibleY(view) + + view.toggle() + drainScroll() + + assertTrue(view.bodyVisible()) + assertEquals(y, visibleY(view)) + assertTrue(jumpButton().isVisible) + } + fun `test long prompt message follows when transcript is at bottom`() { showMessages() fillTranscript(24) @@ -389,6 +552,56 @@ class SessionScrollTest : SessionUiTestBase() { assertFalse(button.isVisible) } + fun `test scroll button resumes following during massive stream`() { + showMessages() + fillTranscript(48) + val button = jumpButton() + val bar = scrollBar() + val id = "stream_massive_button" + val pid = "stream_massive_button_part" + emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n")), flush = false) + forceFlush() + drainScroll() + setValue(bar, bottom(bar) / 2) + val first = buildString { + repeat(160) { i -> append("line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n")), flush = false) + forceFlush() + settleShort(100) + drainScroll() + + assertTrue(button.isVisible) + assertFalse(ui.scroll.following()) + + click(button) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(button.isVisible) + val second = buildString { + repeat(160) { i -> append("tail line $i\n\n") } + } + + repeat(160) { i -> + emit(ChatEventDto.PartDelta("ses_test", id, pid, "text", "tail line $i\n\n"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part(pid, id, "text", "start\n\n${first}snapshot\n\n${second}snapshot tail\n\n")), flush = false) + forceFlush() + settleShort(100) + drainScroll() + + assertBottom(bar) + assertTrue(ui.scroll.following()) + assertFalse(button.isVisible) + } + fun `test scroll button remains hidden outside transcript body`() { val button = jumpButton() @@ -476,11 +689,12 @@ class SessionScrollTest : SessionUiTestBase() { assertBottom(scrollBar()) } - fun `test scroll owns the session viewport`() { + fun `test scroll owns the session viewport without overlapping content`() { settle() assertSame(scrollComponent(), scrollView()?.parent?.parent) assertFalse(scrollView() is SessionMessageListPanel) + assertFalse((scrollComponent() as JBScrollPane).isOverlappingScrollBar) } // ------ question/login-required autoscroll ------ @@ -853,6 +1067,15 @@ class SessionScrollTest : SessionUiTestBase() { private inline fun option(label: String): T where T : AbstractButton = findAll(ui).first { it.actionCommand == label } + private fun toolView(mid: String, pid: String): ToolView { + val messages = find(ui) + return messages.findMessage(mid)?.part(pid) as? ToolView + ?: error("missing tool $mid/$pid\n${messages.dumpDetailed()}") + } + + private fun visibleY(component: JComponent): Int = + SwingUtilities.convertPoint(component, Point(0, 0), scrollComponent()).y + private inline fun findAll(root: Container = ui): List = findAll(root, T::class.java) private fun findAll(root: Container, cls: Class): List { @@ -949,4 +1172,27 @@ class SessionScrollTest : SessionUiTestBase() { ), tool = ToolRefDto("msg1", "call1"), ) + + private fun toolPart(id: String, mid: String) = PartDto( + id = id, + sessionID = "ses_test", + messageID = mid, + type = "tool", + tool = "bash", + callID = "call_$id", + state = "completed", + title = "print output", + output = "output line\n".repeat(160), + ) + + private fun toolHistory(mid: String, pid: String) = MessageWithPartsDto( + message(mid).copy(role = "assistant"), + listOf(toolPart(pid, mid)), + ) + + private fun historyRange(count: Int, start: Int) = List(count) { offset -> + val i = start + offset + val id = "hist_range_$i" + MessageWithPartsDto(message(id), listOf(part("hist_range_part_$i", id, "text", text(i)))) + } } 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 e77fb3d611..4fe55cf5d2 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 @@ -26,6 +26,7 @@ import ai.kilocode.rpc.dto.PartDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionTimeDto import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.openapi.util.Disposer import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -73,6 +74,7 @@ abstract class SessionUiTestBase : BasePlatformTestCase() { override fun tearDown() { try { + Disposer.dispose(ui) scope.cancel() } finally { super.tearDown() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt index 09f1d288f3..7974709b08 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/ProgressPanelTest.kt @@ -4,9 +4,12 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.ui.UiStyle import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI /** * Verifies [ProgressPanel] show/hide behaviour driven by direct [SessionModel] @@ -45,6 +48,15 @@ class ProgressPanelTest : BasePlatformTestCase() { assertEquals("Thinking\u2026", panel.labelText()) } + fun `test panel uses transcript row padding`() { + val ins = panel.insets + + assertEquals(UiStyle.Gap.sm(), ins.top) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), ins.left) + assertEquals(0, ins.bottom) + assertEquals(0, ins.right) + } + fun `test panel hides on Idle`() { model.setState(SessionState.Busy("Thinking\u2026")) model.setState(SessionState.Idle) 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 7b13b4a5f7..8a9ae5ccd2 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 @@ -9,13 +9,14 @@ import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.LoginRequiredView import ai.kilocode.client.session.views.PlanExitView import ai.kilocode.client.session.views.permission.PermissionView import ai.kilocode.client.session.views.question.QuestionResultView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.session.views.TextView -import ai.kilocode.client.session.views.ToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.rpc.dto.MessageDto import ai.kilocode.rpc.dto.MessageTimeDto @@ -25,8 +26,13 @@ import ai.kilocode.rpc.dto.TodoDto import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.Component import java.awt.Container +import java.awt.event.MouseEvent +import java.awt.image.BufferedImage import javax.swing.JPanel +import javax.swing.border.Border /** * Tests for [SessionMessageListPanel] — structural and index integrity. @@ -537,6 +543,44 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { assertEquals(listOf(".kilo/plans/x.md"), opened) } + fun `test entering a second hoverable part clears stale first hover`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent( + "a1", + toolPart( + "tp1", "a1", "question", "call1", state = "completed", + input = mapOf("questions" to """[{"question":"First?"}]"""), + metadata = mapOf("answers" to """[["Yes"]]"""), + ), + ) + model.updateContent( + "a1", + toolPart( + "tp2", "a1", "question", "call2", state = "completed", + input = mapOf("questions" to """[{"question":"Second?"}]"""), + metadata = mapOf("answers" to """[["No"]]"""), + ), + ) + val first = panel.findMessage("a1")!!.part("tp1") as QuestionResultView + val second = panel.findMessage("a1")!!.part("tp2") as QuestionResultView + val firstRoot = root(first) + val secondRoot = root(second) + + first.toggle() + second.toggle() + + enter(header(first)) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header(first).background.rgb) + assertLine(firstRoot.border) + + enter(header(second)) + + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, header(first).background.rgb) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header(second).background.rgb) + assertLine(firstRoot.border) + assertLine(secondRoot.border) + } + // ------ helpers ------ private fun panelWithPrompts(): SessionMessageListPanel { @@ -610,4 +654,34 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, callID = callId, state = state, input = input, metadata = metadata, todos = todos, ) + + private fun root(view: QuestionResultView) = view.components[0] as JPanel + + private fun header(view: QuestionResultView) = root(view).components[0] as JPanel + + private fun enter(component: Component) { + component.dispatchEvent(MouseEvent( + component, + MouseEvent.MOUSE_ENTERED, + System.currentTimeMillis(), + 0, + 1, + 1, + 0, + false, + )) + } + + private fun assertLine(border: Border) { + val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) + val item = JPanel() + val graphics = image.createGraphics() + border.paintBorder(item, graphics, 0, 0, image.width, image.height) + graphics.dispose() + val rgb = SessionUiStyle.View.Outline.brightColor().rgb + assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) + assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt index 0c64f021c9..a266bc9143 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionSelectionCopyTest.kt @@ -1,7 +1,7 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.session.SessionUiTestBase -import ai.kilocode.client.session.views.ToolView +import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.PartDto import com.intellij.ide.CopyProvider @@ -47,7 +47,7 @@ class SessionSelectionCopyTest : SessionUiTestBase() { select(two, "bravo") copyProvider()!!.performCopy(DataContext.EMPTY_CONTEXT) - assertNull(one.selectedText) + assertTrue(one.selectedText.isNullOrEmpty()) assertEquals("bravo", CopyPasteManager.getInstance().getContents(DataFlavor.stringFlavor)) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt index 4d0177f566..880899b007 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionUiUpdateTest.kt @@ -83,7 +83,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() { model.updateContent("a1", toolPart("t1", "a1", "bash", "running")) model.updateContent("a1", toolPart("t1", "a1", "bash", "completed")) - val tv = panel.findMessage("a1")!!.part("t1") as ai.kilocode.client.session.views.ToolView + val tv = panel.findMessage("a1")!!.part("t1") as ai.kilocode.client.session.views.tool.ToolView assertFalse(tv.labelText().contains("Running")) } @@ -92,7 +92,23 @@ class SessionUiUpdateTest : BasePlatformTestCase() { model.updateContent("a1", toolPart("t1", "a1", "read", "completed")) val tv = panel.findMessage("a1")!!.part("t1") - assertTrue(tv is ai.kilocode.client.session.views.ReadToolView) + assertTrue(tv is ai.kilocode.client.session.views.tool.ReadToolView) + } + + fun `test glob tool renders as GlobToolView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("t1", "a1", "glob", "completed")) + + val tv = panel.findMessage("a1")!!.part("t1") + assertTrue(tv is ai.kilocode.client.session.views.tool.GlobToolView) + } + + fun `test grep tool renders as SearchToolView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("t1", "a1", "grep", "completed")) + + val tv = panel.findMessage("a1")!!.part("t1") + assertTrue(tv is ai.kilocode.client.session.views.tool.SearchToolView) } // ------ multiple turns update correctly ------ diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt index ce541a5e71..4aaa851181 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/account/SessionAccountOverlayTest.kt @@ -205,8 +205,8 @@ class SessionAccountOverlayTest : SessionControllerTestBase() { val prof = profile(email = "user@example.com") show(snap(prof)) edt { - assertEquals(SessionUiStyle.View.sessionViewBackground(), panel.panelBackground()) - assertEquals(SessionUiStyle.View.sessionViewOutline(), panel.panelBorderColor()) + assertEquals(SessionUiStyle.AccountPopup.bgColor(), panel.panelBackground()) + assertEquals(SessionUiStyle.AccountPopup.outlineColor(), panel.panelBorderColor()) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt new file mode 100644 index 0000000000..67c69d9bf8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/GlobToolViewTest.kt @@ -0,0 +1,155 @@ +package ai.kilocode.client.session.views + +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.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.ToolView +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class GlobToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } + + fun `test header renders title directory and pattern rows`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") + }) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + assertTrue(view.labelText().contains("Glob")) + assertEquals(listOf("/repo/src", "pattern=**/*.kt"), view.targetTexts()) + assertTrue(view.targetVisible(1)) + } + + fun `test pattern row hides when pattern is absent`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src") + }) + + assertEquals(listOf("/repo/src"), view.targetTexts()) + assertFalse(view.targetVisible(1)) + } + + fun `test repo path displays relative directory`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") + }, repo = "/repo") + + assertEquals(listOf("src", "pattern=**/*.kt"), view.targetTexts()) + } + + fun `test repo root directory is hidden`() { + val exact = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo", "pattern" to "**/*.kt") + }, repo = "/repo") + val dot = GlobToolView(tool().also { + it.input = mapOf("path" to ".", "pattern" to "**/*.kt") + }, repo = "/repo") + + assertEquals(listOf("pattern=**/*.kt"), exact.targetTexts()) + assertEquals(listOf("pattern=**/*.kt"), dot.targetTexts()) + assertFalse(exact.targetVisible(1)) + assertFalse(dot.targetVisible(1)) + } + + fun `test outside repo directory stays absolute`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/other/src", "pattern" to "**/*.kt") + }, repo = "/repo") + + assertEquals(listOf("/other/src", "pattern=**/*.kt"), view.targetTexts()) + } + + fun `test target labels use regular font`() { + val view = GlobToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "**/*.kt") + }) + val style = SessionEditorStyle.current() + + assertEquals(style.regularFont, view.targetFont(0)) + assertEquals(style.regularFont, view.targetFont(1)) + } + + fun `test completed glob starts collapsed and expands output`() { + val view = track(GlobToolView(tool().also { it.output = "/repo/src/A.kt\n/repo/src/B.kt" })) + + assertTrue(view.hasToggle()) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertEquals("/repo/src/A.kt\n/repo/src/B.kt", view.bodyText()) + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertEquals("/repo/src/A.kt\n/repo/src/B.kt", view.bodyText()) + } + + fun `test glob body is lazy and reused`() { + val view = track(GlobToolView(tool().also { it.output = "/repo/src/A.kt" })) + + assertFalse(view.bodyCreated()) + view.toggle() + val body = view.scrollComponent() + val editor = view.bodyEditor() + assertNotNull(body) + assertNotNull(editor) + assertFalse(view.bodyWrap()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) + + view.toggle() + assertFalse(view.bodyVisible()) + view.toggle() + + assertSame(body, view.scrollComponent()) + assertSame(editor, view.bodyEditor()) + assertTrue(view.bodyVisible()) + } + + fun `test collapsed update keeps glob body uncreated`() { + val view = GlobToolView(tool().also { it.output = "/repo/src/A.kt" }) + + view.update(tool().also { it.output = "/repo/src/B.kt" }) + + assertFalse(view.bodyCreated()) + assertEquals("/repo/src/B.kt", view.bodyText()) + } + + fun `test view factory routes glob to glob tool view`() { + assertTrue(ViewFactory.create(tool(), openFile = {}) is GlobToolView) + } + + fun `test should replace when glob renderer changes`() { + val glob = tool() + val read = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } + + assertTrue(ViewFactory.shouldReplace(ReadToolView(read), glob)) + assertTrue(ViewFactory.shouldReplace(ToolView(read), glob)) + assertTrue(ViewFactory.shouldReplace(GlobToolView(glob), read)) + assertFalse(ViewFactory.shouldReplace(GlobToolView(glob), glob)) + } + + private fun tool() = Tool("p1", "glob", toolKind("glob")).also { it.state = ToolExecState.COMPLETED } + + private fun track(view: GlobToolView): GlobToolView { + views.add(view) + return view + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt index 60c65070a3..5f651adcb8 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/LoginRequiredViewTest.kt @@ -63,7 +63,7 @@ class LoginRequiredViewTest : BasePlatformTestCase() { val view = LoginRequiredView(openProfile = {}, dismiss = {}) view.show("Sign in required.") val btn = view.openProfileButton() - assertEquals(SessionUiStyle.View.surface(), btn.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), btn.background) } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt index cc7a2811cf..93a3d2c173 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/PlanExitViewTest.kt @@ -3,6 +3,7 @@ package ai.kilocode.client.session.views 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.views.tool.ToolView import com.intellij.testFramework.fixtures.BasePlatformTestCase @Suppress("UnstableApiUsage") diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt index 1d2c8a8e70..5c4bb2947a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionResultViewTest.kt @@ -6,6 +6,7 @@ import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.question.QuestionResultView +import ai.kilocode.client.session.views.tool.ToolView import com.intellij.testFramework.fixtures.BasePlatformTestCase import java.awt.Color import java.awt.Component @@ -130,7 +131,7 @@ class QuestionResultViewTest : BasePlatformTestCase() { assertFalse("Should be collapsed after second toggle", view.isExpanded()) } - fun `test hover border differs from header fill`() { + fun `test hover only changes header background`() { val view = QuestionResultView(completedTool( input = mapOf("questions" to """[{"question":"Q1"}]"""), metadata = mapOf("answers" to """[["A1"]]"""), @@ -138,12 +139,18 @@ class QuestionResultViewTest : BasePlatformTestCase() { val root = view.node(0) val header = root.node(0) - enter(header) + assertEquals(0, paint(root.border).alpha) + view.toggle() + val body = root.node(1) - assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(root.border).rgb) - assertNotSameColor(SessionUiStyle.View.headerHover(), paint(root.border)) - exit(header) - assertEquals(SessionUiStyle.View.line().rgb, paint(root.border).rgb) + view.setHovered(true) + + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, header.background.rgb) + assertLine(root.border) + assertEquals(SessionUiStyle.View.Outline.brightColor().rgb, paint(body.border).rgb) + view.setHovered(false) + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, header.background.rgb) + assertLine(root.border) } // ------ view factory routing ------ @@ -290,7 +297,17 @@ class QuestionResultViewTest : BasePlatformTestCase() { return Color(image.getRGB(0, 0), true) } - private fun assertNotSameColor(left: Color, right: Color) { - assertFalse("Expected distinct colors but both were ${left.rgb}", left.rgb == right.rgb) + private fun assertLine(border: Border) { + val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) + val panel = JPanel() + val graphics = image.createGraphics() + border.paintBorder(panel, graphics, 0, 0, image.width, image.height) + graphics.dispose() + val rgb = SessionUiStyle.View.Outline.brightColor().rgb + assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) + assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) } + } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt index 6efd3d240c..9305d3114a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/QuestionViewTest.kt @@ -5,8 +5,10 @@ import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.BaseQuestionView import ai.kilocode.client.session.views.question.QuestionView import ai.kilocode.client.ui.HoverIcon +import ai.kilocode.client.ui.UiStyle import ai.kilocode.rpc.dto.QuestionReplyDto import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.testFramework.fixtures.BasePlatformTestCase @@ -15,11 +17,13 @@ import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBRadioButton import com.intellij.ui.components.JBTextArea +import java.awt.BorderLayout import java.awt.Component import java.awt.Container import kotlin.math.abs import javax.swing.AbstractButton import javax.swing.JButton +import javax.swing.JComponent import javax.swing.SwingUtilities @Suppress("UnstableApiUsage") @@ -134,6 +138,16 @@ class QuestionViewTest : BasePlatformTestCase() { assertTrue(findAll(view).none { it.text == "1 of 1 questions" && it.isVisible }) } + fun `test single question uses roomy card spacing`() { + view.show(singleSelectQuestion("q_single_spacing")) + + val card = card() + val ins = card.border.getBorderInsets(card) + + assertEquals(UiStyle.Gap.xl(), ins.top) + assertEquals(UiStyle.Gap.pad(), spacer(card).preferredSize.height) + } + fun `test single question submit sends selected answer`() { view.show(singleSelectQuestion("req_2")) @@ -294,6 +308,23 @@ class QuestionViewTest : BasePlatformTestCase() { assertEquals(listOf(listOf("Minimal"), listOf("Unit")), replies.single().second.answers) } + fun `test multi question progress header has top padding`() { + view.show(twoItemQuestion("q_progress_padding")) + + val card = card() + val outer = card.border.getBorderInsets(card) + val summary = findAll(view).first { it.text == "1 of 2 questions" } + val panel = summary.parent as JComponent + val ins = panel.border.getBorderInsets(panel) + + assertEquals(UiStyle.Gap.sm(), outer.top) + assertEquals(0, ins.top) + assertEquals(0, ins.left) + assertEquals(UiStyle.Gap.sm(), ins.bottom) + assertEquals(0, ins.right) + assertEquals(UiStyle.Gap.pad(), spacer(card).preferredSize.height) + } + fun `test multi question uses review before submit`() { view.show(twoItemQuestion("q_review")) @@ -468,8 +499,8 @@ class QuestionViewTest : BasePlatformTestCase() { val dismiss = button(view, "Dismiss") val submit = button(view, "Submit") - assertEquals(SessionUiStyle.View.surface(), dismiss.background) - assertEquals(SessionUiStyle.View.surface(), submit.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), dismiss.background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), submit.background) } fun `test review submit and back buttons have correct primary state on review page`() { @@ -929,6 +960,13 @@ class QuestionViewTest : BasePlatformTestCase() { private fun text(root: Container, value: String): JBTextArea = findAll(root).first { it.text == value } + private fun card(): BaseQuestionView = findAll(view).distinct().single() + + private fun spacer(card: BaseQuestionView): Component { + val north = (card.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH) as Container + return north.components.last() + } + private fun layout(root: Container, width: Int = 400) { root.setSize(width, root.preferredSize.height) layoutTree(root) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt index a1d27fab1b..f04fd062a3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReadToolViewTest.kt @@ -4,6 +4,9 @@ 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.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.SearchToolView import ai.kilocode.client.ui.UiStyle import com.intellij.testFramework.fixtures.BasePlatformTestCase import javax.swing.ScrollPaneConstants @@ -85,6 +88,9 @@ class ReadToolViewTest : BasePlatformTestCase() { assertFalse(view.isExpanded()) assertFalse(view.bodyVisible()) assertEquals("file contents", view.bodyText()) + assertTrue(view.bodyCreated()) + assertTrue(view.bodyWrap()) + assertNull(view.bodyEditor()) assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) view.toggle() @@ -95,8 +101,8 @@ class ReadToolViewTest : BasePlatformTestCase() { fun `test view factory routes read kind tools to read tool view`() { assertTrue(ViewFactory.create(tool(), openFile = {}) is ReadToolView) - assertTrue(ViewFactory.create(Tool("p2", "grep", toolKind("grep")), openFile = {}) is ReadToolView) - assertTrue(ViewFactory.create(Tool("p3", "glob", toolKind("glob")), openFile = {}) is ReadToolView) + assertTrue(ViewFactory.create(Tool("p2", "grep", toolKind("grep")), openFile = {}) is SearchToolView) + assertTrue(ViewFactory.create(Tool("p3", "glob", toolKind("glob")), openFile = {}) is GlobToolView) } fun `test canRender matches read kind tools only`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt new file mode 100644 index 0000000000..d9f1ac10f1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ReasoningViewStressTest.kt @@ -0,0 +1,63 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Reasoning +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.UIUtil +import java.awt.Container +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class ReasoningViewStressTest : BasePlatformTestCase() { + + fun `test streaming reasoning retains markdown body and disposes editors`() { + val base = EditorFactory.getInstance().allEditors.size + val view = ReasoningView(reasoning("r1", done = false, text = "intro\n\n```kotlin\n")) + val component = view.md.component + val scroll = scrolls(view).first() + val editor = editors(view).single() + val count = panel(view).componentCount + editor.getEditor(true) + + repeat(150) { i -> view.appendDelta("val x$i = $i\n") } + + assertSame(component, view.md.component) + assertSame(scroll, scrolls(view).first()) + assertSame(editor, editors(view).single()) + assertEquals(1, editors(view).size) + assertTrue(htmls(view).size <= 1) + assertEquals(count, panel(view).componentCount) + + view.update(reasoning("r1", done = true, text = view.markdown() + "```")) + assertTrue(view.bodyVisible()) + Disposer.dispose(view) + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun reasoning(id: String, done: Boolean, text: String) = Reasoning(id).also { + it.done = done + it.content.append(text) + } + + private fun panel(view: ReasoningView): JPanel = view.md.component as JPanel + + private fun scrolls(view: ReasoningView) = descendants(view).filterIsInstance() + + private fun htmls(view: ReasoningView) = descendants(view).filterIsInstance() + + private fun editors(view: ReasoningView) = descendants(view).filterIsInstance() + + private fun descendants(root: Container): List = root.components.flatMap { child -> + listOf(child) + ((child as? Container)?.let(::descendants) ?: emptyList()) + } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} 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 2019e30a4e..c09062ec80 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 @@ -2,8 +2,15 @@ package ai.kilocode.client.session.views import ai.kilocode.client.session.model.Reasoning import ai.kilocode.client.session.ui.style.SessionEditorStyle +import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.Component +import java.awt.Container +import javax.swing.JPanel import javax.swing.ScrollPaneConstants @Suppress("UnstableApiUsage") @@ -33,15 +40,16 @@ class ReasoningViewTest : BasePlatformTestCase() { assertTrue(view.bodyCreated()) } - fun `test streaming reasoning is collapsed by default`() { + fun `test streaming reasoning is expanded by default`() { val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) - assertFalse(view.isExpanded()) + assertTrue(view.isExpanded()) assertTrue(view.hasToggle()) + assertTrue(view.bodyVisible()) } fun `test update to done preserves collapsed reasoning`() { - val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) + val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) @@ -49,6 +57,29 @@ class ReasoningViewTest : BasePlatformTestCase() { assertEquals("one\ntwo\nthree\nfour", view.markdown()) } + fun `test live reasoning stays expanded when marked done`() { + val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo\nthree\nfour")) + + assertTrue(view.isExpanded()) + + view.update(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertTrue(view.bodyCreated()) + } + + fun `test manually expanded finished reasoning stays open on update`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo")) + + view.toggle() + view.update(reasoning("p1", done = true, text = "one\ntwo\nthree")) + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertEquals("one\ntwo\nthree", view.markdown()) + } + fun `test toggle opens and closes reasoning`() { val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour")) @@ -59,7 +90,7 @@ class ReasoningViewTest : BasePlatformTestCase() { } fun `test collapsed reasoning stays collapsed on update`() { - val view = ReasoningView(reasoning("p1", done = false, text = "one\ntwo")) + val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo")) view.update(reasoning("p1", done = true, text = "one\ntwo\nthree")) assertFalse(view.isExpanded()) @@ -72,22 +103,24 @@ class ReasoningViewTest : BasePlatformTestCase() { view.appendDelta("b") assertEquals("ab", view.markdown()) - assertFalse(view.isExpanded()) + assertTrue(view.isExpanded()) } - fun `test blank reasoning stays collapsed when delta arrives`() { + fun `test blank streaming reasoning opens when delta arrives`() { val view = ReasoningView(reasoning("p1", done = false, text = "")) + assertFalse(view.isVisible) view.appendDelta("b") assertEquals("b", view.markdown()) - assertFalse(view.bodyCreated()) - assertFalse(view.bodyVisible()) + assertTrue(view.isVisible) + assertTrue(view.bodyCreated()) + assertTrue(view.bodyVisible()) assertTrue(view.hasToggle()) } - fun `test collapsed append keeps lazy reasoning body uncreated`() { - val view = ReasoningView(reasoning("p1", done = false, text = "a")) + fun `test collapsed completed append keeps lazy reasoning body uncreated`() { + val view = ReasoningView(reasoning("p1", done = true, text = "a")) view.appendDelta("b") @@ -96,10 +129,10 @@ class ReasoningViewTest : BasePlatformTestCase() { assertFalse(view.bodyVisible()) } - fun `test collapsed update keeps lazy reasoning body uncreated`() { - val view = ReasoningView(reasoning("p1", done = false, text = "a")) + fun `test collapsed completed update keeps lazy reasoning body uncreated`() { + val view = ReasoningView(reasoning("p1", done = true, text = "a")) - view.update(reasoning("p1", done = false, text = "abc")) + view.update(reasoning("p1", done = true, text = "abc")) assertEquals("abc", view.markdown()) assertFalse(view.bodyCreated()) @@ -107,7 +140,7 @@ class ReasoningViewTest : BasePlatformTestCase() { } fun `test reasoning creates lazy markdown body once`() { - val view = ReasoningView(reasoning("p1", done = false, text = "one")) + val view = ReasoningView(reasoning("p1", done = true, text = "one")) view.toggle() val component = view.md.component @@ -121,6 +154,7 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test blank reasoning has no toggle`() { val view = ReasoningView(reasoning("p1", done = true, text = "")) + assertFalse(view.isVisible) assertFalse(view.isExpanded()) assertFalse(view.hasToggle()) } @@ -158,10 +192,60 @@ class ReasoningViewTest : BasePlatformTestCase() { fun `test expanded reasoning body is capped to five rows`() { val view = ReasoningView(reasoning("p1", done = false, text = (1..20).joinToString("\n") { "line $it" })) - view.toggle() + val taller = ReasoningView(reasoning("p2", done = false, text = (1..200).joinToString("\n") { "line $it" })) assertEquals(5, view.bodyMaxRows()) assertTrue(view.preferredSize.height > 0) + assertEquals(view.preferredSize.height, taller.preferredSize.height) + } + + fun `test appended reasoning scrolls nested body to bottom`() { + val view = ReasoningView(reasoning("p1", done = false, text = (1..20).joinToString("\n") { "line $it" })) + view.setSize(300, 80) + view.doLayout() + + view.appendDelta("\nline 21\nline 22") + UIUtil.dispatchAllInvocationEvents() + + assertEquals(view.bodyScrollBottom(), view.bodyScrollValue()) + } + + fun `test appended reasoning does not yank user scrolled above tail`() { + val view = ReasoningView(reasoning("p1", done = false, text = (1..40).joinToString("\n") { "line $it" })) + view.setSize(300, 80) + view.doLayout() + UIUtil.dispatchAllInvocationEvents() + val scroll = scroll(view) + scroll.verticalScrollBar.value = 0 + + view.appendDelta("\nline 41\nline 42") + UIUtil.dispatchAllInvocationEvents() + + assertEquals(0, scroll.verticalScrollBar.value) + } + + fun `test reasoning block uses vertical separator`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one")) + + assertEquals(1, view.border!!.getBorderInsets(view).left) + + view.toggle() + + val insets = view.border!!.getBorderInsets(view) + assertEquals(0, insets.top) + assertEquals(1, insets.left) + assertEquals(0, insets.bottom) + assertEquals(0, insets.right) + assertEquals(SessionUiStyle.View.Reasoning.BODY_LINES, view.bodyMaxRows()) + } + + fun `test reasoning toggle uses shared right rail`() { + val view = ReasoningView(reasoning("p1", done = true, text = "one")) + val row = view.components.single() as JPanel + val insets = row.border.getBorderInsets(row) + + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), insets.left) + assertEquals(JBUI.scale(SessionUiStyle.View.Layout.HORIZONTAL_PADDING), insets.right) } fun `test link opens url callback`() { @@ -190,4 +274,15 @@ class ReasoningViewTest : BasePlatformTestCase() { it.done = done it.content.append(text) } + + private fun scroll(component: Component): JBScrollPane { + if (component is JBScrollPane) return component + if (component is Container) { + component.components.forEach { child -> + val scroll = runCatching { scroll(child) }.getOrNull() + if (scroll != null) return scroll + } + } + error("scroll not found") + } } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt new file mode 100644 index 0000000000..eea06ce279 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SearchToolViewTest.kt @@ -0,0 +1,196 @@ +package ai.kilocode.client.session.views + +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.style.SessionEditorStyle +import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.ReadToolView +import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ToolView +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Container +import java.awt.Dimension +import javax.swing.ScrollPaneConstants + +@Suppress("UnstableApiUsage") +class SearchToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } + + fun `test header renders title pattern and include targets`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "class SearchToolView", "include" to "*.{kt,kts}") + }) + val base: Any = view + + assertTrue(base is SecondarySessionPartView) + assertTrue(view.labelText().contains("Search")) + assertEquals(listOf("pattern=class SearchToolView", "include=*.{kt,kts}"), view.targetTexts()) + assertTrue(view.targetVisible(0)) + assertTrue(view.targetVisible(1)) + assertFalse(view.targetVisible(2)) + } + + fun `test header includes optional path target`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "TODO", "include" to "*.kt") + }) + + assertEquals(listOf("/repo/src", "pattern=TODO", "include=*.kt"), view.targetTexts()) + } + + fun `test repo path displays relative search target`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo/src", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + + assertEquals(listOf("src", "pattern=TODO", "include=*.kt"), view.targetTexts()) + } + + fun `test repo root search path is hidden`() { + val exact = SearchToolView(tool().also { + it.input = mapOf("path" to "/repo", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + val dot = SearchToolView(tool().also { + it.input = mapOf("path" to ".", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + + assertEquals(listOf("pattern=TODO", "include=*.kt"), exact.targetTexts()) + assertEquals(listOf("pattern=TODO", "include=*.kt"), dot.targetTexts()) + assertTrue(exact.targetVisible(0)) + assertFalse(exact.targetVisible(2)) + assertTrue(dot.targetVisible(0)) + assertFalse(dot.targetVisible(2)) + } + + fun `test outside repo search path stays absolute`() { + val view = SearchToolView(tool().also { + it.input = mapOf("path" to "/other/src", "pattern" to "TODO", "include" to "*.kt") + }, repo = "/repo") + + assertEquals(listOf("/other/src", "pattern=TODO", "include=*.kt"), view.targetTexts()) + } + + fun `test target labels use plain text for clipping`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "", "include" to "*.kt") + }) + + assertEquals("pattern=", view.targetComponents().first().text) + } + + fun `test target labels use regular font`() { + val view = SearchToolView(tool().also { + it.input = mapOf("pattern" to "TODO", "include" to "*.kt") + }) + val style = SessionEditorStyle.current() + + assertEquals(style.regularFont, view.targetFont(0)) + assertEquals(style.regularFont, view.targetFont(1)) + } + + fun `test completed search starts collapsed and expands output`() { + val view = track(SearchToolView(tool().also { it.output = "src/A.kt:1:class A" })) + + assertTrue(view.hasToggle()) + assertFalse(view.isExpanded()) + assertFalse(view.bodyVisible()) + assertEquals("src/A.kt:1:class A", view.bodyText()) + + view.toggle() + + assertTrue(view.isExpanded()) + assertTrue(view.bodyVisible()) + assertEquals("src/A.kt:1:class A", view.bodyText()) + } + + fun `test search body is lazy and reused`() { + val view = track(SearchToolView(tool().also { it.output = "src/A.kt" })) + + assertFalse(view.bodyCreated()) + view.toggle() + val body = view.scrollComponent() + val editor = view.bodyEditor() + assertNotNull(body) + assertNotNull(editor) + assertFalse(view.bodyWrap()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) + + view.toggle() + assertFalse(view.bodyVisible()) + view.toggle() + + assertSame(body, view.scrollComponent()) + assertSame(editor, view.bodyEditor()) + assertTrue(view.bodyVisible()) + } + + fun `test collapsed update keeps search body uncreated`() { + val view = SearchToolView(tool().also { it.output = "src/A.kt" }) + + view.update(tool().also { it.output = "src/B.kt" }) + + assertFalse(view.bodyCreated()) + assertEquals("src/B.kt", view.bodyText()) + } + + fun `test long targets stay horizontal and do not force header wider`() { + val view = SearchToolView(tool().also { + it.input = mapOf( + "pattern" to "a".repeat(200), + "include" to "**/*.${"b".repeat(200)}.kt", + ) + }) + val header = view.headerComponent() + header.setSize(Dimension(240, header.preferredSize.height)) + + layout(header) + + assertTrue(view.centerComponent().width <= header.width) + val labels = view.targetComponents().filter { it.isVisible } + assertEquals(labels.first().y, labels.last().y) + labels.forEach { + assertTrue(it.width <= view.centerComponent().width) + } + } + + fun `test view factory routes grep to search tool view`() { + assertTrue(ViewFactory.create(tool(), openFile = {}) is SearchToolView) + } + + fun `test should replace when search renderer changes`() { + val search = tool() + val read = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } + val glob = Tool("p2", "glob", toolKind("glob")).also { it.state = ToolExecState.COMPLETED } + + assertTrue(ViewFactory.shouldReplace(ReadToolView(read), search)) + assertTrue(ViewFactory.shouldReplace(ToolView(read), search)) + assertTrue(ViewFactory.shouldReplace(SearchToolView(search), read)) + assertTrue(ViewFactory.shouldReplace(GlobToolView(glob, selection = null), search)) + assertFalse(ViewFactory.shouldReplace(SearchToolView(search), search)) + } + + private fun layout(root: Container) { + root.doLayout() + root.components.filterIsInstance().forEach { layout(it) } + } + + private fun tool() = Tool("p1", "grep", toolKind("grep")).also { it.state = ToolExecState.COMPLETED } + + private fun track(view: SearchToolView): SearchToolView { + views.add(view) + return view + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt new file mode 100644 index 0000000000..3b5171f4f3 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolBodyStressTest.kt @@ -0,0 +1,71 @@ +package ai.kilocode.client.session.views + +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.views.tool.GlobToolView +import ai.kilocode.client.session.views.tool.SearchToolView +import ai.kilocode.client.session.views.tool.ToolView +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.UIUtil + +@Suppress("UnstableApiUsage") +class ToolBodyStressTest : BasePlatformTestCase() { + + fun `test expanded tool body editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + val view = ToolView(tool(i)) + view.toggle() + view.bodyEditor()?.getEditor(true) + Disposer.dispose(view) + } + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + fun `test expanded search tool editors are disposed after churn`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + val search = SearchToolView(search(i)) + search.toggle() + search.bodyEditor()?.getEditor(true) + Disposer.dispose(search) + + val glob = GlobToolView(glob(i)) + glob.toggle() + glob.bodyEditor()?.getEditor(true) + Disposer.dispose(glob) + } + drainEdt() + + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + private fun tool(index: Int) = Tool("p$index", "bash", toolKind("bash")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("command" to "log $index") + it.output = (1..20).joinToString("\n") { line -> "line $index/$line" } + } + + private fun search(index: Int) = Tool("s$index", "grep", toolKind("grep")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("path" to "src", "pattern" to "needle$index", "include" to "*.kt") + it.output = (1..20).joinToString("\n") { line -> "src/File$line.kt: needle$index" } + } + + private fun glob(index: Int) = Tool("g$index", "glob", toolKind("glob")).also { + it.state = ToolExecState.COMPLETED + it.input = mapOf("path" to "src", "pattern" to "**/*$index.kt") + it.output = (1..20).joinToString("\n") { line -> "src/File$line.kt" } + } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} 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 44121d61e1..1de7afe7ee 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 @@ -7,14 +7,30 @@ import ai.kilocode.client.session.model.toolKind import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle import ai.kilocode.client.session.views.base.SecondarySessionPartView +import ai.kilocode.client.session.views.tool.ToolView +import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.awt.Color +import java.awt.image.BufferedImage +import javax.swing.JPanel import javax.swing.ScrollPaneConstants +import javax.swing.border.Border /** * Tests for [ToolView]. */ @Suppress("UnstableApiUsage") class ToolViewTest : BasePlatformTestCase() { + private val views = mutableListOf() + + override fun tearDown() { + try { + views.forEach(Disposer::dispose) + views.clear() + } finally { + super.tearDown() + } + } // ---- state icons ------ @@ -47,14 +63,14 @@ class ToolViewTest : BasePlatformTestCase() { fun `test title shown instead of name when title is set`() { val t = Tool("p1", "bash", toolKind("bash")).also { it.state = ToolExecState.RUNNING; it.title = "Install deps" } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Install deps")) assertTrue(view.labelText().contains("Shell")) } fun `test blank title falls back to tool name`() { val t = Tool("p1", "bash", toolKind("bash")).also { it.state = ToolExecState.COMPLETED; it.title = " " } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Shell")) } @@ -64,7 +80,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = "origin git@example.com:repo.git" } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Shell")) assertTrue(view.labelText().contains("View remotes")) @@ -94,12 +110,25 @@ class ToolViewTest : BasePlatformTestCase() { assertTrue(base is SecondarySessionPartView) } + fun `test tool outline is drawn only while expanded`() { + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { + it.input = mapOf("command" to "pwd") + it.output = "/tmp" + })) + + assertEquals(0, paint(view.border).alpha) + view.toggle() + assertEquals(SessionUiStyle.View.Outline.color().rgb, paint(view.border).rgb) + view.toggle() + assertEquals(0, paint(view.border).alpha) + } + fun `test bash toggle collapses and expands`() { val t = tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "git log") it.output = "one\ntwo\nthree\nfour" } - val view = ToolView(t) + val view = track(ToolView(t)) assertFalse(view.isExpanded()) view.toggle() @@ -113,7 +142,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "git log") it.output = "one\ntwo\nthree\nfour" } - val view = ToolView(t) + val view = track(ToolView(t)) assertEquals("$ git log\n\none\ntwo\nthree\nfour", view.bodyText()) assertTrue(view.hasToggle()) @@ -127,23 +156,24 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "pwd") it.output = "/tmp" } - val view = ToolView(t) + val view = track(ToolView(t)) assertFalse(view.bodyCreated()) view.toggle() - val font = view.bodyFont() + val body = view.bodyEditor() + assertNotNull(body) view.toggle() view.toggle() - assertSame(font, view.bodyFont()) + assertSame(body, view.bodyEditor()) assertTrue(view.bodyVisible()) } fun `test collapsed update keeps lazy tool body uncreated`() { - val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { + val view = track(ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { it.input = mapOf("command" to "pwd") it.output = "/tmp" - }) + })) view.update(tool("p1", "bash", ToolExecState.COMPLETED).also { it.input = mapOf("command" to "pwd") @@ -155,10 +185,10 @@ class ToolViewTest : BasePlatformTestCase() { } fun `test collapsed update after first expand reuses tool body text`() { - val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { + val view = track(ToolView(tool("p1", "bash", ToolExecState.RUNNING).also { it.input = mapOf("command" to "pwd") it.output = "/tmp" - }) + })) view.toggle() view.toggle() @@ -177,7 +207,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "pwd") it.output = "/tmp" } - val view = ToolView(t) + val view = track(ToolView(t)) assertFalse(view.isExpanded()) assertTrue(view.hasToggle()) @@ -191,7 +221,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("path" to "/tmp", "pattern" to "**/*.kt") it.output = "/tmp/A.kt" } - val view = ToolView(t) + val view = track(ToolView(t)) assertTrue(view.labelText().contains("Glob")) assertTrue(view.labelText().contains("/tmp")) @@ -205,18 +235,21 @@ class ToolViewTest : BasePlatformTestCase() { fun `test bash output uses editor font settings`() { val style = SessionEditorStyle.current() - val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" })) + view.toggle() - assertEditorFont(view.bodyFont(), style) + assertCodeFont(view.bodyFont(), style) assertFalse(view.bodyEditable()) assertFalse(view.bodyCaretVisible()) - assertTrue(view.bodyWrap()) - assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy()) + assertFalse(view.bodyWrap()) + assertNotNull(view.bodyEditor()) + assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED, view.horizontalPolicy()) + assertEquals(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, view.verticalPolicy()) } fun `test tool header uses editor-derived fonts`() { val style = SessionEditorStyle.current() - val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) + val view = track(ToolView(tool("p1", "bash", ToolExecState.COMPLETED).also { it.output = "done" })) assertEditorFont(view.titleFont(), style) assertTrue(view.titleFont().isBold) @@ -227,10 +260,13 @@ class ToolViewTest : BasePlatformTestCase() { fun `test applyStyle updates tool fonts in place`() { val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) val style = SessionEditorStyle.create(family = "Courier New", size = 25) + view.toggle() + val editor = view.bodyEditor() view.applyStyle(style) - assertEditorFont(view.bodyFont(), style) + assertSame(editor, view.bodyEditor()) + assertCodeFont(view.bodyFont(), style) assertEditorFont(view.titleFont(), style) assertTrue(view.titleFont().isBold) assertSmallEditorFont(view.subtitleFont(), style) @@ -243,7 +279,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = "/tmp" } - val view = ToolView(t) + val view = track(ToolView(t)) assertEquals(1, view.controlCount()) } @@ -253,7 +289,7 @@ class ToolViewTest : BasePlatformTestCase() { it.input = mapOf("command" to "log") it.output = (1..40).joinToString("\n") { line -> "line $line" } } - val view = ToolView(t) + val view = track(ToolView(t)) view.toggle() @@ -268,7 +304,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = out } - val view = ToolView(t) + val view = track(ToolView(t)) view.toggle() assertEquals("$ log\n\n$out", view.bodyText()) @@ -282,7 +318,7 @@ class ToolViewTest : BasePlatformTestCase() { it.output = out } - val view = ToolView(t) + val view = track(ToolView(t)) view.toggle() assertEquals(out, view.bodyText()) @@ -325,13 +361,32 @@ 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 track(view: ToolView): ToolView { + views.add(view) + return view + } + private fun assertEditorFont(font: java.awt.Font, style: SessionEditorStyle) { assertEquals(style.transcriptFont.name, font.name) assertEquals(style.editorSize, font.size) } + private fun assertCodeFont(font: java.awt.Font, style: SessionEditorStyle) { + assertEquals(style.editorFont.name, font.name) + assertEquals(style.editorSize, font.size) + } + private fun assertSmallEditorFont(font: java.awt.Font, style: SessionEditorStyle) { assertEquals(style.smallEditorFont.name, font.name) assertTrue(font.size < style.editorSize) } + + private fun paint(border: Border): Color { + val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB) + val item = JPanel() + val graphics = image.createGraphics() + border.paintBorder(item, graphics, 0, 0, image.width, image.height) + graphics.dispose() + return Color(image.getRGB(0, 0), true) + } } 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 5536843a8a..7a03cb5208 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 @@ -188,6 +188,75 @@ class TurnViewTest : BasePlatformTestCase() { assertEquals("hello world", view.markdown()) } + fun `test consecutive reasoning parts reuse one view`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "first ") + message.parts["r2"] = reasoning("r2", "second") + + val mv = MessageView(message, openFile) + + assertEquals(listOf("r1"), mv.partIds()) + assertSame(mv.part("r1"), mv.part("r2")) + assertEquals("first second", (mv.part("r1") as ReasoningView).markdown()) + } + + fun `test delta for aliased reasoning appends to reused view`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "first ") + message.parts["r2"] = reasoning("r2", "second") + val mv = MessageView(message, openFile) + + assertTrue(mv.appendDelta("r2", " third")) + + assertEquals("first second third", (mv.part("r1") as ReasoningView).markdown()) + } + + fun `test reasoning alias maps stay bounded across churn`() { + val mv = MessageView(msg("a1", "assistant"), openFile) + + repeat(100) { i -> + mv.upsertPart(reasoning("r${i}a", "first $i ")) + mv.upsertPart(reasoning("r${i}b", "second $i")) + + assertEquals(listOf("r${i}a"), mv.partIds()) + assertSame(mv.part("r${i}a"), mv.part("r${i}b")) + assertEquals(1, aliasSize(mv)) + assertEquals(1, sourceSize(mv)) + assertEquals(1, mv.componentCount) + + mv.removePart("r${i}b") + mv.removePart("r${i}a") + + assertTrue(mv.partIds().isEmpty()) + assertEquals(0, aliasSize(mv)) + assertEquals(0, sourceSize(mv)) + assertEquals(0, mv.componentCount) + } + } + + fun `test text between reasoning parts keeps separate views`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "first") + message.parts["t1"] = text("t1", "middle") + message.parts["r2"] = reasoning("r2", "second") + + val mv = MessageView(message, openFile) + + assertEquals(listOf("r1", "t1", "r2"), mv.partIds()) + assertNotSame(mv.part("r1"), mv.part("r2")) + } + + fun `test blank reasoning part is invisible`() { + val message = msg("a1", "assistant") + message.parts["r1"] = reasoning("r1", "") + message.parts["t1"] = text("t1", "middle") + + val mv = MessageView(message, openFile) + + assertFalse(mv.part("r1")!!.isVisible) + assertTrue(mv.part("t1")!!.isVisible) + } + fun `test appendDelta for unknown part id is noop`() { val mv = MessageView(msg("a1", "assistant"), openFile) // Must not throw @@ -226,7 +295,7 @@ class TurnViewTest : BasePlatformTestCase() { fun `test assistant card parts use shared compact gap`() { val message = msg("a1", "assistant") - val reasoning = Reasoning("r1") + val reasoning = reasoning("r1", "thinking") val tool = Tool("t1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED } message.parts["r1"] = reasoning message.parts["t1"] = tool @@ -263,6 +332,23 @@ class TurnViewTest : BasePlatformTestCase() { private fun msg(id: String, role: String): Message = Message(MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0))) + private fun reasoning(id: String, content: String) = Reasoning(id).also { + it.done = false + it.content.append(content) + } + + private fun text(id: String, content: String) = Text(id).also { it.content.append(content) } + + private fun aliasSize(view: MessageView) = mapSize(view, "aliases") + + private fun sourceSize(view: MessageView) = mapSize(view, "sources") + + private fun mapSize(view: MessageView, name: String): Int { + val field = MessageView::class.java.getDeclaredField(name) + field.isAccessible = true + return (field.get(view) as Map<*, *>).size + } + private class TrackingRepaintManager(private val watched: Set) : RepaintManager() { val dirty = mutableListOf() val invalid = mutableListOf() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt index 523087c7a2..bc8e1e9ed1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/AbstractSessionPartViewTest.kt @@ -69,21 +69,25 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { assertNull(content.parent) } - fun `test header hover is subtler than hover outline`() { - assertNotSameColor(SessionUiStyle.View.headerHover(), SessionUiStyle.View.hoverLine()) - assertNotSameColor(SessionUiStyle.View.headerHover(), SessionUiStyle.View.line()) + fun `test header hover fill differs from outline colors`() { + assertNotSameColor(SessionUiStyle.View.Surface.headerHoverBgColor(), SessionUiStyle.View.Outline.hoverColor()) + assertNotSameColor(SessionUiStyle.View.Surface.headerHoverBgColor(), SessionUiStyle.View.Outline.brightColor()) } - fun `test primary card border follows hover color`() { + fun `test primary card hover only changes header background`() { val view = TestView(content = JLabel("body")) - val row = view.component(0) + val row = view.component(0) as JPanel - enter(row) + assertEquals(0, paint(view.border).alpha) + view.expand() - assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(view.border).rgb) - assertNotSameColor(SessionUiStyle.View.headerHover(), paint(view.border)) - exit(row) - assertEquals(SessionUiStyle.View.line().rgb, paint(view.border).rgb) + view.setHovered(true) + + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor().rgb, row.background.rgb) + assertLine(view.border) + view.setHovered(false) + assertEquals(SessionUiStyle.View.Surface.headerBgColor().rgb, row.background.rgb) + assertLine(view.border) } private class TestView(content: JLabel, expanded: Boolean = false, expandable: Boolean = true) : @@ -122,6 +126,19 @@ class AbstractSessionPartViewTest : BasePlatformTestCase() { return Color(image.getRGB(0, 0), true) } + private fun assertLine(border: Border) { + val image = BufferedImage(5, 5, BufferedImage.TYPE_INT_ARGB) + val panel = JPanel() + val graphics = image.createGraphics() + border.paintBorder(panel, graphics, 0, 0, image.width, image.height) + graphics.dispose() + val rgb = SessionUiStyle.View.Outline.color().rgb + assertEquals(rgb, Color(image.getRGB(2, 0), true).rgb) + assertEquals(rgb, Color(image.getRGB(0, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(4, 2), true).rgb) + assertEquals(rgb, Color(image.getRGB(2, 4), true).rgb) + } + private fun assertNotSameColor(left: Color, right: Color) { assertFalse("Expected distinct colors but both were ${left.rgb}", left.rgb == right.rgb) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt index 41e4229f45..8136916ef4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/BaseQuestionViewTest.kt @@ -164,7 +164,7 @@ class BaseQuestionViewTest : BasePlatformTestCase() { val north = region(panel, BorderLayout.NORTH) as Container val filler = north.components.last() - assertEquals(UiStyle.Gap.md(), filler.preferredSize.height) + assertEquals(UiStyle.Gap.lg(), filler.preferredSize.height) assertEquals(0, filler.preferredSize.width) } } @@ -242,8 +242,8 @@ class BaseQuestionViewTest : BasePlatformTestCase() { BaseQuestionView.Action("a", "A", primary = false) {}, BaseQuestionView.Action("b", "B", primary = true) {}, )) - assertEquals(SessionUiStyle.View.surface(), actionButton(panel, "A").background) - assertEquals(SessionUiStyle.View.surface(), actionButton(panel, "B").background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), actionButton(panel, "A").background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), actionButton(panel, "B").background) } } @@ -296,6 +296,18 @@ class BaseQuestionViewTest : BasePlatformTestCase() { } } + fun `test card top padding uses next spacing step`() { + edt { + val panel = BaseQuestionView() + val ins = panel.border.getBorderInsets(panel) + + assertEquals(UiStyle.Gap.pad(), ins.top) + assertEquals(UiStyle.Gap.pad(), ins.left) + assertEquals(UiStyle.Gap.lg(), ins.bottom) + assertEquals(UiStyle.Gap.pad(), ins.right) + } + } + fun `test action left alone attaches footer west`() { edt { val panel = BaseQuestionView() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt index 1e6c3a2815..6b52bef002 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/permission/PermissionViewTest.kt @@ -4,12 +4,12 @@ import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionFileDiff import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.PermissionRequestState +import ai.kilocode.client.session.views.SessionViewIcons import ai.kilocode.client.session.views.base.BaseQuestionView 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.rpc.dto.PermissionReplyDto -import com.intellij.icons.AllIcons import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBLabel @@ -410,7 +410,7 @@ class PermissionViewTest : BasePlatformTestCase() { val labels = findAll(view) assertTrue( "Expected permission warning icon in header", - labels.any { it.icon == AllIcons.General.Warning }, + labels.any { it.icon == SessionViewIcons.warning }, ) } @@ -434,8 +434,8 @@ class PermissionViewTest : BasePlatformTestCase() { fun `test session question buttons use question surface background`() { view.show(permission()) - assertEquals(SessionUiStyle.View.surface(), view.runButtonForTest().background) - assertEquals(SessionUiStyle.View.surface(), view.denyButtonForTest().background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), view.runButtonForTest().background) + assertEquals(SessionUiStyle.View.Surface.bgColor(), view.denyButtonForTest().background) } // ------ code labels use transcript style ------ @@ -494,7 +494,7 @@ class PermissionViewTest : BasePlatformTestCase() { val labels = view.codeLabelsForTest() assertFalse("Expected code labels", labels.isEmpty()) - assertEquals(SessionUiStyle.View.headerHover(), labels[0].background) + assertEquals(SessionUiStyle.View.Surface.headerHoverBgColor(), labels[0].background) } private fun permission() = Permission( 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 9749a587a9..b3702301c9 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 @@ -39,9 +39,9 @@ class UiStyleTest : BasePlatformTestCase() { fun `test session layout constants provide shared geometry`() { assertTrue(JBUI.scale(SessionUiStyle.SessionLayout.GAP) > 0) - assertTrue(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_GAP) > 0) - assertTrue(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_VERTICAL_PADDING) > 0) - assertTrue(JBUI.scale(SessionUiStyle.View.SESSION_VIEW_HORIZONTAL_PADDING) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.Layout.GAP) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.Layout.VERTICAL_PADDING) > 0) + assertTrue(JBUI.scale(SessionUiStyle.View.Layout.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/layout/StackTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt index ec3b1a96a6..887556bfd3 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/layout/StackTest.kt @@ -313,6 +313,39 @@ class StackTest : BasePlatformTestCase() { assertBounds(3, 2, 10, 44, a) } + fun `test fit horizontal preserves preferred widths when there is space`() { + val a = child(pref = 10 x 5) + val b = child(pref = 20 x 7) + val stack = Stack.fitHorizontal(gap = 3).apply { + next(a) + next(b) + } + + stack.setBounds(0, 0, 100, 50) + stack.doLayout() + + assertBounds(0, 0, 10, 50, a) + assertBounds(13, 0, 20, 50, b) + } + + fun `test fit horizontal allocates tight space from the left`() { + val a = child(pref = 20 x 5) + val b = child(pref = 20 x 7) + val c = child(pref = 20 x 9) + val stack = Stack.fitHorizontal(gap = 3).apply { + next(a) + next(b) + next(c) + } + + stack.setBounds(0, 0, 45, 50) + stack.doLayout() + + assertBounds(0, 0, 20, 50, a) + assertBounds(23, 0, 20, 50, b) + assertBounds(45, 0, 0, 50, c) + } + fun `test vertical measures preferred height after width probe`() { val a = object : JBLabel("x") { override fun getMinimumSize() = Dimension(0, 0) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt new file mode 100644 index 0000000000..e9d40248cf --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridStressTest.kt @@ -0,0 +1,180 @@ +package ai.kilocode.client.ui.md + +import ai.kilocode.client.session.ui.style.SessionEditorStyle +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.ui.EditorTextField +import com.intellij.ui.components.JBHtmlPane +import com.intellij.ui.components.JBScrollPane +import com.intellij.util.ui.UIUtil +import javax.swing.Box +import javax.swing.JPanel + +/** + * Stress + leak coverage for the hybrid markdown renderer. + * + * These tests drive many updates through the public [MdView] API and inspect the real + * Swing component tree to prove that: + * - retained component instances survive heavy streaming, + * - the component tree stays bounded (no per-update growth), + * - editors created for code blocks are released (no leak) after churn + clear. + */ +@Suppress("UnstableApiUsage") +class MdViewHybridStressTest : BasePlatformTestCase() { + private lateinit var view: MdView + private var disposed = false + + override fun setUp() { + super.setUp() + view = MdViewFactory.hybrid() + disposed = false + } + + override fun tearDown() { + try { + if (this::view.isInitialized && !disposed) Disposer.dispose(view) + } finally { + super.tearDown() + } + } + + fun `test streaming a large mixed document token by token stays consistent`() { + val doc = buildString { + append("# Heading\n\n") + append("Intro paragraph with **bold** text.\n\n") + append("- one\n- two\n- three\n\n") + append("```kotlin\nval x = 1\n```\n\n") + append("middle prose paragraph\n\n") + append("```java\nclass A {}\n```\n\n") + append("closing prose") + } + + for (token in doc.chunked(3)) view.append(token) + + assertEquals(doc, view.markdown()) + assertEquals(3, htmls().size) + assertEquals(2, scrolls().size) + assertEquals(4, struts().size) // blocks - 1 + assertEquals(9, panel().componentCount) // 5 blocks + 4 struts = 2*5 - 1 + + val html = view.html() + assertTrue(html.contains("

")) + assertTrue(html.contains("
    ")) + assertTrue(html.contains("class A")) + assertFalse(html.contains(" view.append(" more$i") } + + assertSame(intro, htmls().first()) + assertSame(tail, htmls().last()) + assertSame(editor, editors().single()) + assertEquals(2, htmls().size) + assertEquals(1, scrolls().size) + assertFalse(editor.getEditor(true)!!.isDisposed) + assertTrue(view.markdown().contains("more99")) + } + + fun `test repeated same structure set reuses single editor and stays bounded`() { + repeat(150) { i -> + view.set("```kotlin\nval x = $i\n```") + editors().single().getEditor(true) + } + val editor = editors().single() + + repeat(50) { i -> view.set("```kotlin\nval y = $i\n```") } + + assertSame(editor, editors().single()) + assertEquals(1, scrolls().size) + assertEquals(1, panel().componentCount) + assertEquals("val y = 49", editor.text) + } + + fun `test structural churn releases every editor after clear`() { + val base = EditorFactory.getInstance().allEditors.size + + repeat(60) { i -> + view.set("```kotlin\nval x = $i\n```") + editors().single().getEditor(true) + view.set("```java\nclass A$i {}\n```") + editors().single().getEditor(true) + view.set("plain prose $i") + } + + view.clear() + drainEdt() + + assertTrue(scrolls().isEmpty()) + assertTrue(htmls().isEmpty()) + assertEquals(0, panel().componentCount) + assertEquals(base, EditorFactory.getInstance().allEditors.size) + } + + fun `test streaming code body reuses one editor and keeps html in sync`() { + view.append("```java\n") + val pane = scrolls().single() + val editor = editors().single() + + val body = StringBuilder() + repeat(100) { i -> + val line = "void m$i() {}\n" + body.append(line) + view.append(line) + } + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + assertEquals(body.toString().trimEnd('\n'), editor.text) + assertTrue(view.html().contains("void m0()")) + assertTrue(view.html().contains("void m99()")) + + view.append("```") + + assertSame(pane, scrolls().single()) + assertSame(editor, editors().single()) + } + + fun `test style changes during streaming do not rebuild components`() { + view.append("intro\n\n```kotlin\nval x = 1\n```\n\n") + val intro = htmls().first() + val editor = editors().single() + editor.getEditor(true) + val styled = SessionEditorStyle.create(family = "Courier New", size = 18) + val current = SessionEditorStyle.current() + + repeat(50) { i -> + view.append("line $i ") + view.applyStyle(if (i % 2 == 0) styled else current) + if (i % 5 == 0) view.resetStyles() + } + + assertSame(intro, htmls().first()) + assertSame(editor, editors().single()) + assertFalse(editor.getEditor(true)!!.isDisposed) + assertEquals(2, htmls().size) + assertEquals(1, scrolls().size) + assertTrue(view.markdown().contains("line 49")) + } + + private fun panel(): JPanel = view.component as JPanel + + private fun scrolls(): List = panel().components.filterIsInstance() + + private fun htmls(): List = panel().components.filterIsInstance() + + private fun struts(): List = panel().components.filterIsInstance() + + private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } + + private fun drainEdt() { + UIUtil.dispatchAllInvocationEvents() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt index 7d49a24661..104b5ab88d 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewHybridTest.kt @@ -14,6 +14,7 @@ import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Color +import javax.swing.Box import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -130,6 +131,97 @@ class MdViewHybridTest : BasePlatformTestCase() { assertTrue("streamed editor should not be clipped vertically", editor.height >= editor.preferredSize.height) } + fun `test consecutive prose blocks coalesce into one html pane`() { + view.set("# Title\n\npara one\n\n- a\n- b") + + val pane = htmls().single() + assertTrue(pane.text.contains("

    ")) + assertTrue(pane.text.contains("

    ")) + assertTrue(pane.text.contains("

      ")) + assertTrue(pane.text.contains("
    • ")) + } + + fun `test code block separates surrounding prose runs`() { + view.set("intro\n\n```kotlin\nval x = 1\n```\n\noutro") + + val html = htmls() + assertEquals(2, html.size) + assertEquals(1, scrolls().size) + assertTrue(html[0].text.contains("intro")) + assertTrue(html[1].text.contains("outro")) + } + + fun `test indented code block separates prose and renders as editor`() { + view.set("before\n\n code line\n\nafter") + + val html = htmls() + assertEquals(2, html.size) + assertEquals(1, editors().size) + assertTrue(html[0].text.contains("before")) + assertEquals("code line", editors().single().text) + assertTrue(html[1].text.contains("after")) + } + + fun `test coalesced prose has no inter block struts`() { + view.set("first\n\nsecond\n\n- third") + + assertEquals(1, htmls().size) + assertTrue(struts().isEmpty()) + } + + fun `test thematic break after code block is filtered`() { + view.set("```kotlin\nval x = 1\n```\n\n---\n\n# Next") + + val pane = htmls().single() + + assertEquals(1, scrolls().size) + assertTrue(pane.text.contains("

      ")) + assertFalse(pane.text.contains("")) + assertFalse(pane.text.contains(" = (view.component as JPanel).components.filterIsInstance() + private fun struts(): List = (view.component as JPanel).components.filterIsInstance() + private fun editors(): List = scrolls().mapNotNull { it.viewport.view as? EditorTextField } private fun type(ext: String): FileType { diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 4b2de32901..a09f51c5fd 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.1-rc.7 +kilo.jetbrains.version=7.0.1-rc.8 org.gradle.configuration-cache=true org.gradle.caching=true org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 90f87e4e15..ec3eeac672 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.3.40", + "version": "7.3.41", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index a4cdd4be5f..f4cc026df8 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.3.40", + "version": "7.3.41", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 4eee89384e..2861141681 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.3.40", + "version": "7.3.41", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index e93ef7bf16..f67520471a 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,34 @@ # kilo-code +## 7.3.41 + +### Minor Changes + +- [#10879](https://github.com/Kilo-Org/kilocode/pull/10879) [`b0a4f03`](https://github.com/Kilo-Org/kilocode/commit/b0a4f0391106a837b78200e6de52621a6872b890) - Show Terminal Bench completion scores and per-attempt costs in supported model details. + +- [#10948](https://github.com/Kilo-Org/kilocode/pull/10948) [`6ee090b`](https://github.com/Kilo-Org/kilocode/commit/6ee090b5a404924f00c1f4771b09c1f4a1e352ca) - Restore cloud session filesystem changes from synced session diffs when importing sessions, including inherited changes across imported session forks. + +### Patch Changes + +- [#10094](https://github.com/Kilo-Org/kilocode/pull/10094) [`7ac7c1e`](https://github.com/Kilo-Org/kilocode/commit/7ac7c1e2832235e418eb7d27f4defdb6a3d139c6) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Fix agent-manager model sync on config change + +- [#10999](https://github.com/Kilo-Org/kilocode/pull/10999) [`ad93990`](https://github.com/Kilo-Org/kilocode/commit/ad93990ca9c84215bd71dee9febe76eebf07f1b5) - Show a pointer cursor for clickable controls and links in Kilo webviews. + +- [#11038](https://github.com/Kilo-Org/kilocode/pull/11038) [`70f9441`](https://github.com/Kilo-Org/kilocode/commit/70f9441b50568d2f3330b81b316f5907f4f797e0) - Speed up long streamed responses by preserving completed Markdown blocks and updating only the active tail. + +- [#11033](https://github.com/Kilo-Org/kilocode/pull/11033) [`10520d0`](https://github.com/Kilo-Org/kilocode/commit/10520d04e19a8b85072bdc25ff8d3dd51e5c7629) - Keep Agent Manager streaming responsive when multiple sessions run concurrently. + +- [#11039](https://github.com/Kilo-Org/kilocode/pull/11039) [`422bc8a`](https://github.com/Kilo-Org/kilocode/commit/422bc8aca87794858ddd3c0b5e0081def0033758) - Remove the obsolete speech-to-text enabled setting while preserving provider-based availability and model selection. + +- [#10992](https://github.com/Kilo-Org/kilocode/pull/10992) [`2d7c49d`](https://github.com/Kilo-Org/kilocode/commit/2d7c49d2a8d8fe32f80eab732774defed55da27f) - Rename Mercury autocomplete model labels to distinguish FIM and Next Edit modes. + +- [#10787](https://github.com/Kilo-Org/kilocode/pull/10787) [`c6679ea`](https://github.com/Kilo-Org/kilocode/commit/c6679eaec5112e0355f81c140901171df64481a3) - Route Agent Manager permission approvals to the worktree that created the request. + +- Updated dependencies [[`ad93990`](https://github.com/Kilo-Org/kilocode/commit/ad93990ca9c84215bd71dee9febe76eebf07f1b5), [`b0a4f03`](https://github.com/Kilo-Org/kilocode/commit/b0a4f0391106a837b78200e6de52621a6872b890)]: + - @kilocode/kilo-ui@7.3.41 + - @kilocode/kilo-gateway@7.4.0 + - @kilocode/kilo-indexing@7.3.41 + ## 7.3.40 ## 7.3.39 diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index fdfdc866bb..e88ae4b588 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.3.40", + "version": "7.3.41", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -893,11 +893,6 @@ "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." diff --git a/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts b/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts new file mode 100644 index 0000000000..afc75f77b4 --- /dev/null +++ b/packages/kilo-vscode/tests/markdown-incremental-dom.spec.ts @@ -0,0 +1,208 @@ +import { expect, test } from "@playwright/test" +import { build } from "esbuild" +import { fileURLToPath } from "node:url" + +const source = fileURLToPath(new URL("../../ui/src/kilocode/markdown-incremental-dom.ts", import.meta.url)) +const bundle = await build({ + stdin: { + contents: ` + import { createIncrementalMarkdown } from ${JSON.stringify(source)} + globalThis.createIncrementalMarkdown = createIncrementalMarkdown + `, + resolveDir: fileURLToPath(new URL(".", import.meta.url)), + }, + bundle: true, + format: "iife", + platform: "browser", + write: false, +}) +const script = bundle.outputFiles[0]!.text + +test.beforeEach(async ({ page }) => { + await page.goto("about:blank") + await page.addScriptTag({ content: script }) +}) + +test("keeps completed Markdown nodes while replacing only the live tail", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: (decorate: () => void) => { + update: ( + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + document.body.append(container) + const renderer = create(() => {}) + const heading = { key: "0", hash: "heading", html: "

      Heading

      ", mode: "full" as const } + const tail = { key: "1", hash: "tail-a", html: "

      Tail A

      ", mode: "live" as const } + + renderer.update(container, [heading, tail], labels) + const stable = container.children[0] + const previous = container.children[1] + renderer.update(container, [heading, { ...tail, hash: "tail-b", html: "

      Tail B

      " }], labels) + + return { + stable: container.children[0] === stable, + replaced: container.children[1] !== previous, + tags: Array.from(container.children).map((child) => child.tagName), + text: container.textContent, + } + }) + + expect(result).toEqual({ stable: true, replaced: true, tags: ["H2", "P"], text: "HeadingTail B" }) +}) + +test("promotes an unchanged tail and appends the next block without wrappers", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: (decorate: () => void) => { + update: ( + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + document.body.append(container) + const renderer = create(() => {}) + const heading = { key: "0", hash: "heading", html: "

      Heading

      ", mode: "full" as const } + const paragraph = { key: "1", hash: "paragraph", html: "

      Stable

      ", mode: "live" as const } + + renderer.update(container, [heading, paragraph], labels) + const stable = container.children[1] + renderer.update( + container, + [ + heading, + { ...paragraph, mode: "full" }, + { key: "2", hash: "tail", html: "
      • Next
      ", mode: "live" as const }, + ], + labels, + ) + + return { + stable: container.children[1] === stable, + tags: Array.from(container.children).map((child) => child.tagName), + } + }) + + expect(result).toEqual({ stable: true, tags: ["H2", "P", "UL"] }) +}) + +test("runs streaming hooks only when incremental rendering handles the update", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: ( + decorate: () => void, + hooks: { + cancel: () => void + ready: (container: HTMLDivElement, labels: { copy: string; copied: string }, context: string) => void + }, + ) => { + render: ( + streaming: boolean, + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + context: string, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + document.body.append(container) + const calls: string[] = [] + const renderer = create(() => {}, { + cancel: () => calls.push("cancel"), + ready: (_container, _labels, context) => calls.push(context), + }) + const stable = { key: "0", hash: "stable", html: "

      Stable

      ", mode: "full" as const } + const tail = { key: "1", hash: "tail", html: "

      Tail

      ", mode: "live" as const } + + const completed = renderer.render(false, container, [stable, tail], labels, "ready") + const unsupported = renderer.render(true, container, [tail], labels, "unsupported") + const streaming = renderer.render(true, container, [stable, tail], labels, "ready") + return { calls, completed, streaming, unsupported } + }) + + expect(result).toEqual({ calls: ["cancel", "ready"], completed: false, streaming: true, unsupported: false }) +}) + +test("falls back when there is no stable prefix", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: (decorate: () => void) => { + update: ( + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + const renderer = create(() => {}) + const first = { key: "0", hash: "first", html: "

      First

      ", mode: "live" as const } + const second = { key: "1", hash: "second", html: "

      Second

      ", mode: "live" as const } + + return { + multiple: renderer.update(container, [first, second], labels), + single: renderer.update(container, [first], labels), + } + }) + + expect(result).toEqual({ multiple: false, single: false }) +}) + +test("rebuilds safely when a Markdown boundary disappears", async ({ page }) => { + const result = await page.evaluate(() => { + const create = ( + globalThis as typeof globalThis & { + createIncrementalMarkdown: (decorate: () => void) => { + update: ( + container: HTMLDivElement, + blocks: Array<{ key: string; hash: string; html: string; mode: "full" | "live" }>, + labels: { copy: string; copied: string }, + ) => boolean + } + } + ).createIncrementalMarkdown + const labels = { copy: "Copy", copied: "Copied" } + const container = document.createElement("div") + document.body.append(container) + const renderer = create(() => {}) + const blocks = [ + { key: "0", hash: "stable", html: "

      Stable

      ", mode: "full" as const }, + { key: "1", hash: "middle", html: "

      Middle

      ", mode: "full" as const }, + { key: "2", hash: "tail", html: "

      Tail

      ", mode: "live" as const }, + ] + + renderer.update(container, blocks, labels) + const comments = Array.from(container.childNodes).filter((node) => node.nodeType === Node.COMMENT_NODE) + comments.at(-1)?.remove() + const updated = renderer.update(container, [blocks[0]!, { ...blocks[1]!, mode: "live" }], labels) + + return { + updated, + tags: Array.from(container.children).map((child) => child.tagName), + text: container.textContent, + } + }) + + expect(result).toEqual({ updated: true, tags: ["P", "P"], text: "StableMiddle" }) +}) diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index 1d7c4450ee..b5843cf591 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.3.40", + "version": "7.3.41", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/unit/timeline-sizes.test.ts b/packages/kilo-vscode/tests/unit/timeline-sizes.test.ts index 4194e7a81c..5ff2006c0c 100644 --- a/packages/kilo-vscode/tests/unit/timeline-sizes.test.ts +++ b/packages/kilo-vscode/tests/unit/timeline-sizes.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest" -import { sizes, MAX_HEIGHT } from "../../webview-ui/src/utils/timeline/sizes" +import { sizes, pinned, MAX_HEIGHT } from "../../webview-ui/src/utils/timeline/sizes" import type { Part, TextPart, ToolPart, StepFinishPart } from "../../webview-ui/src/types/messages" function mkText(text: string): TextPart { @@ -89,4 +89,10 @@ describe("timeline sizes", () => { expect(Number.isInteger(bar.height)).toBe(true) } }) + + it("detects whether scrolling should follow new bars", () => { + expect(pinned({ scrollLeft: 100, scrollWidth: 200, clientWidth: 100 })).toBe(true) + expect(pinned({ scrollLeft: 88, scrollWidth: 200, clientWidth: 100 })).toBe(true) + expect(pinned({ scrollLeft: 87, scrollWidth: 200, clientWidth: 100 })).toBe(false) + }) }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx index 1c791cbb6f..3f4efb1db5 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskTimeline.tsx @@ -16,7 +16,7 @@ import { Component, Index, Show, createMemo, createEffect, createSignal, on, onC import { Portal } from "solid-js/web" import { useSession } from "../../context/session" import { color, label } from "../../utils/timeline/colors" -import { sizes, MAX_HEIGHT } from "../../utils/timeline/sizes" +import { sizes, pinned, MAX_HEIGHT } from "../../utils/timeline/sizes" import type { Part, Message } from "../../types/messages" export interface TimelineBar { @@ -73,19 +73,34 @@ export const TaskTimeline: Component = () => { const bars = createMemo(() => collect(messages(), allParts())) const busy = () => session.status() === "busy" - // Auto-scroll to the latest bar when new bars appear + // Reading scrollWidth and writing scrollLeft synchronously for every appended bar can + // force repeated layout during streamed part updates. Batch those appends behind one + // animation frame, after Solid has applied the current DOM updates. Only follow while + // pinned so inspecting earlier activity is not interrupted by incoming bars. let prev = 0 + let frame: number | undefined + let follow = true + const onScroll = () => { + if (ref) follow = pinned(ref) + } createEffect( on( () => bars().length, (len) => { - if (len > prev && ref) { - ref.scrollLeft = ref.scrollWidth + if (len > prev && ref && follow && frame === undefined) { + frame = requestAnimationFrame(() => { + frame = undefined + if (!ref || !follow) return + ref.scrollLeft = ref.scrollWidth + }) } prev = len }, ), ) + onCleanup(() => { + if (frame !== undefined) cancelAnimationFrame(frame) + }) const hideTip = () => { tipBar = undefined @@ -161,6 +176,7 @@ export const TaskTimeline: Component = () => { onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onPointerLeave={hideTip} + onScroll={onScroll} > {(bar) => { diff --git a/packages/kilo-vscode/webview-ui/src/utils/timeline/sizes.ts b/packages/kilo-vscode/webview-ui/src/utils/timeline/sizes.ts index 8448b2dc73..78bb05248c 100644 --- a/packages/kilo-vscode/webview-ui/src/utils/timeline/sizes.ts +++ b/packages/kilo-vscode/webview-ui/src/utils/timeline/sizes.ts @@ -20,6 +20,17 @@ export interface BarSize { content: number } +export interface TimelineScroll { + scrollLeft: number + scrollWidth: number + clientWidth: number +} + +/** Keep following updates when the viewport is within one bar of the right edge. */ +export function pinned(scroll: TimelineScroll, slack = BAR_W): boolean { + return scroll.scrollWidth - scroll.clientWidth - scroll.scrollLeft <= slack +} + // ── Content length ─────────────────────────────────────────────────── function content(part: Part): number { diff --git a/packages/kilo-web-ui/package.json b/packages/kilo-web-ui/package.json index 3108de9a01..fe668b4906 100644 --- a/packages/kilo-web-ui/package.json +++ b/packages/kilo-web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-web-ui", - "version": "7.3.40", + "version": "7.3.41", "type": "module", "license": "MIT", "exports": { diff --git a/packages/llm/package.json b/packages/llm/package.json index efaa9b57e0..bd756cc3b8 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.40", + "version": "7.3.41", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 65739c9bb8..7c691cd9b6 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,38 @@ # @kilocode/cli +## 7.3.41 + +### Minor Changes + +- [#10761](https://github.com/Kilo-Org/kilocode/pull/10761) [`82b22f7`](https://github.com/Kilo-Org/kilocode/commit/82b22f78580fb5dafee55960135edfb1066d1520) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Support reading .ods (OpenDocument Spreadsheet) files in the read tool + +- [#10879](https://github.com/Kilo-Org/kilocode/pull/10879) [`b0a4f03`](https://github.com/Kilo-Org/kilocode/commit/b0a4f0391106a837b78200e6de52621a6872b890) - Show Terminal Bench completion scores and per-attempt costs in supported model details. + +- [#10948](https://github.com/Kilo-Org/kilocode/pull/10948) [`6ee090b`](https://github.com/Kilo-Org/kilocode/commit/6ee090b5a404924f00c1f4771b09c1f4a1e352ca) - Restore cloud session filesystem changes from synced session diffs when importing sessions, including inherited changes across imported session forks. + +### Patch Changes + +- [#10996](https://github.com/Kilo-Org/kilocode/pull/10996) [`cc03ffc`](https://github.com/Kilo-Org/kilocode/commit/cc03ffc58100cddbf4e0ab1ce9ccee89afe5726c) - Preserve image attachments when Photon is unavailable, enforce attachment limits for user images, and correlate shell lifecycle events correctly. + +- [#10998](https://github.com/Kilo-Org/kilocode/pull/10998) [`a59b255`](https://github.com/Kilo-Org/kilocode/commit/a59b255b3110411b8e05a09215bb9908f8dc6462) - Restore automatic session titles for models that require reasoning without assuming a supported effort level. + +- [#11004](https://github.com/Kilo-Org/kilocode/pull/11004) [`16e334f`](https://github.com/Kilo-Org/kilocode/commit/16e334ff8ca5305b7da379710a41056a6a6752fc) - Discover project-installed skills in Agent Manager worktree sessions. + +- [#11000](https://github.com/Kilo-Org/kilocode/pull/11000) [`741b00f`](https://github.com/Kilo-Org/kilocode/commit/741b00f2e0a6a94574c506a276688fc6ca033df5) - Keep subagent sessions isolated when forking sessions through editor clients. + +- [#10991](https://github.com/Kilo-Org/kilocode/pull/10991) [`ece8453`](https://github.com/Kilo-Org/kilocode/commit/ece8453ad0e8decc39f3c2a3d05893fd70b0985b) Thanks [@shssoichiro](https://github.com/shssoichiro)! - Avoid copying visible planning chat into new sessions started from the plan follow-up prompt. + +- [#11034](https://github.com/Kilo-Org/kilocode/pull/11034) [`0d76fa6`](https://github.com/Kilo-Org/kilocode/commit/0d76fa627349061d69fd4f5d6f486640d8d7834e) - Start forked sessions at zero cost instead of carrying over the source session's spend. + +- [#10109](https://github.com/Kilo-Org/kilocode/pull/10109) [`df30123`](https://github.com/Kilo-Org/kilocode/commit/df30123e5474cdbd2ad3b56d59c6eb5d06b89189) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Prevent memory leak in KiloSessionPromptQueue.cancel for sessions without active tails + +- [#11010](https://github.com/Kilo-Org/kilocode/pull/11010) [`a130641`](https://github.com/Kilo-Org/kilocode/commit/a13064167df50862e9a4a8622e092ac518110281) - Compact sessions at the configured context percentage before sending an oversized provider request. + +- Updated dependencies [[`b0a4f03`](https://github.com/Kilo-Org/kilocode/commit/b0a4f0391106a837b78200e6de52621a6872b890)]: + - @kilocode/kilo-gateway@7.4.0 + - @kilocode/kilo-indexing@7.3.41 + - @kilocode/kilo-telemetry@7.3.41 + ## 7.3.40 ### Patch Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f76af7f7a1..5d59bb9ca7 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.3.40", + "version": "7.3.41", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index e82bad5421..2f562e6f97 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -109,6 +109,9 @@ export namespace KiloSessions { if (auth?.type === "api" && auth.key.length > 0) return auth.key if (auth?.type === "oauth" && auth.access.length > 0) return auth.access if (auth?.type === "wellknown" && auth.token.length > 0) return auth.token + + const key = process.env["KILO_API_KEY"]?.trim() + if (key) return key return undefined }) } diff --git a/packages/opencode/src/kilocode/bootstrap.ts b/packages/opencode/src/kilocode/bootstrap.ts index 2eda96077a..4a72bbf903 100644 --- a/packages/opencode/src/kilocode/bootstrap.ts +++ b/packages/opencode/src/kilocode/bootstrap.ts @@ -29,7 +29,6 @@ export namespace KilocodeBootstrap { yield* sessions.init() // kilocode_change start - session export bootstrap yield* Effect.gen(function* () { - if (!SessionExport.enabled) return const anon = yield* EffectBridge.fromPromise(() => Identity.getMachineId().catch((err) => { log.warn("session export identity failed", { err }) diff --git a/packages/opencode/src/kilocode/session-export/session-export.ts b/packages/opencode/src/kilocode/session-export/session-export.ts index 0050e5fd32..1226634b49 100644 --- a/packages/opencode/src/kilocode/session-export/session-export.ts +++ b/packages/opencode/src/kilocode/session-export/session-export.ts @@ -32,8 +32,6 @@ const instances = new Map() const maxRespawns = 3 -export const enabled = false - export const init = (opts: { agentVersion: string dbPath: string diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c244b1ae34..caac55bea8 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -412,8 +412,7 @@ const live: Layer.Layer< const opencodeProjectID = input.model.providerID.startsWith("opencode") ? instance.project.id : undefined // kilocode_change start - capture eligible session export request start - const exporting = SessionExport.enabled - const org = yield* exporting && isKilo && input.model.isFree === true + const org = yield* isKilo && input.model.isFree === true ? Effect.promise(() => getActiveOrg()) : Effect.succeed({ type: "unknown" as const }) const started = Date.now() @@ -421,7 +420,7 @@ const live: Layer.Layer< const found = KiloSession.resolveRoot(input.sessionID) const root = parent ? (found === input.sessionID ? parent : found) : input.sessionID const exportable = - exporting && isKilo && input.model.isFree === true && org.type === "personal" && input.agent.name !== "title" + isKilo && input.model.isFree === true && org.type === "personal" && input.agent.name !== "title" if (exportable) { SessionExport.beforeRequest({ input: { model: input.model, org }, diff --git a/packages/opencode/test/kilocode/kilo-sessions.test.ts b/packages/opencode/test/kilocode/kilo-sessions.test.ts index 64938ff002..321dada73d 100644 --- a/packages/opencode/test/kilocode/kilo-sessions.test.ts +++ b/packages/opencode/test/kilocode/kilo-sessions.test.ts @@ -5,6 +5,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Auth } from "../../src/auth" import { Bus } from "../../src/bus" import type { Config } from "../../src/config/config" +import { clearInFlightCache } from "../../src/kilo-sessions/inflight-cache" import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions" import { ProjectID } from "../../src/project/schema" import { Session } from "../../src/session/session" @@ -25,6 +26,12 @@ function layer(overrides: Partial = {}) { ) } +function reset(...tokens: string[]) { + clearInFlightCache("kilo-sessions:token") + clearInFlightCache("kilo-sessions:client") + for (const token of tokens) clearInFlightCache(`kilo-sessions:token-valid:${token}`) +} + it.instance("initializes once per instance through Config.Service", () => { let reads = 0 @@ -46,6 +53,86 @@ it.instance("initializes once per instance through Config.Service", () => { ) }) +it.instance("bootstraps session ingest from KILO_API_KEY without stored auth", () => { + const original = process.env.KILO_API_KEY + const calls: string[] = [] + const fetch: typeof globalThis.fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith("/api/user")) { + calls.push(new Headers(init?.headers).get("Authorization") ?? "") + return new Response("{}", { status: 200 }) + } + if (url.endsWith("/api/session")) { + calls.push(new Headers(init?.headers).get("Authorization") ?? "") + return Response.json({ id: "remote-env", ingestPath: "/api/ingest/env" }) + } + return new Response("{}", { status: 200 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const request = spyOn(globalThis, "fetch").mockImplementation(fetch) + + process.env.KILO_API_KEY = "env-token" + reset("env-token") + + return Effect.promise(() => KiloSessions.bootstrap("session-env")).pipe( + Effect.andThen(() => Effect.sync(() => expect(calls).toEqual(["Bearer env-token", "Bearer env-token"]))), + Effect.ensuring( + Effect.sync(() => { + if (original === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = original + reset("env-token") + request.mockRestore() + }), + ), + Effect.provide(layer()), + ) +}) + +it.instance("prefers stored auth over KILO_API_KEY for session ingest", () => { + const original = process.env.KILO_API_KEY + const calls: string[] = [] + const fetch: typeof globalThis.fetch = Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith("/api/user")) { + calls.push(new Headers(init?.headers).get("Authorization") ?? "") + return new Response("{}", { status: 200 }) + } + if (url.endsWith("/api/session")) { + calls.push(new Headers(init?.headers).get("Authorization") ?? "") + return Response.json({ id: "remote-auth", ingestPath: "/api/ingest/auth" }) + } + return new Response("{}", { status: 200 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const request = spyOn(globalThis, "fetch").mockImplementation(fetch) + + process.env.KILO_API_KEY = "env-token" + reset("env-token", "stored-token") + + return Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.set("kilo", { type: "api", key: "stored-token" }) + yield* Effect.promise(() => KiloSessions.bootstrap("session-auth")) + expect(calls).toEqual(["Bearer stored-token", "Bearer stored-token"]) + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const auth = yield* Auth.Service + yield* auth.remove("kilo").pipe(Effect.orDie) + if (original === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = original + reset("env-token", "stored-token") + request.mockRestore() + }), + ), + Effect.provide(layer()), + ) +}) + it.instance("does not duplicate created-session subscribers when init is repeated", () => { const calls: string[] = [] const fetch: typeof globalThis.fetch = Object.assign( @@ -62,6 +149,7 @@ it.instance("does not duplicate created-session subscribers when init is repeate ) const request = spyOn(globalThis, "fetch").mockImplementation(fetch) + reset("test-token") const id = SessionID.descending("session-created") return Effect.gen(function* () { @@ -91,6 +179,7 @@ it.instance("does not duplicate created-session subscribers when init is repeate Effect.gen(function* () { const auth = yield* Auth.Service yield* auth.remove("kilo").pipe(Effect.orDie) + reset("test-token") request.mockRestore() }), ), diff --git a/packages/plugin-atomic-chat/package.json b/packages/plugin-atomic-chat/package.json index 560e37aec6..b73966a232 100644 --- a/packages/plugin-atomic-chat/package.json +++ b/packages/plugin-atomic-chat/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin-atomic-chat", - "version": "7.3.40", + "version": "7.3.41", "description": "Kilo Code plugin for Atomic Chat: auto-detection and dynamic model discovery (OpenAI-compatible local API)", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 88f7866384..0ed2ad33df 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.3.40", + "version": "7.3.41", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 4bce46502c..416a2e030c 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.3.40", + "version": "7.3.41", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index c11468e559..0931c67e84 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.3.40", + "version": "7.3.41", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 0631e32fff..95f4cd6cbc 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.3.40", + "version": "7.3.41", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 7d2e8c46f5..7931e82f9a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.3.40", + "version": "7.3.41", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/markdown-stream.ts b/packages/ui/src/components/markdown-stream.ts index ea35b0c140..ae034275c7 100644 --- a/packages/ui/src/components/markdown-stream.ts +++ b/packages/ui/src/components/markdown-stream.ts @@ -1,5 +1,6 @@ import { marked, type Tokens } from "marked" import remend from "remend" +import { stableBlocks } from "../kilocode/markdown-stable-blocks" // kilocode_change export type Block = { raw: string @@ -31,6 +32,9 @@ export function stream(text: string, live: boolean) { const src = heal(text) if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[] const tokens = marked.lexer(text) + const candidate = tokens.findLast((token) => token.type !== "space") // kilocode_change + const blocks = candidate && !open(candidate.raw) ? stableBlocks(tokens, heal) : undefined // kilocode_change + if (blocks) return blocks // kilocode_change const tail = tokens.findLastIndex((token) => token.type !== "space") if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[] const last = tokens[tail] diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index cee1ef3bee..813bc92410 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -9,12 +9,15 @@ import { stream } from "./markdown-stream" import { tryFastRender } from "../kilocode/markdown-fast-path" // kilocode_change import { hasMermaid, preserveMermaid, renderMermaid, type MermaidLabels } from "../kilocode/markdown-mermaid" // kilocode_change import { preserveStreamingHighlight } from "../kilocode/markdown-stream-highlight" // kilocode_change +import { createIncrementalMarkdown, type MarkdownBlock } from "../kilocode/markdown-incremental-dom" // kilocode_change type Entry = { hash: string html: string } +type Rendered = { content: string; blocks: MarkdownBlock[] } // kilocode_change + const max = 200 const cache = new Map() @@ -260,34 +263,34 @@ export function Markdown( key: local.cacheKey, streaming: local.streaming ?? false, }), - async (src) => { - if (isServer) return fallback(src.text) - if (!src.text) return "" + async (src): Promise => { // kilocode_change + if (isServer) return { content: fallback(src.text), blocks: [] } // kilocode_change + if (!src.text) return { content: "", blocks: [] } // kilocode_change const base = src.key ?? checksum(src.text) return Promise.all( stream(src.text, src.streaming).map(async (block, index) => { - const hash = checksum(block.raw) + const hash = checksum(block.raw) ?? "" // kilocode_change const key = base ? `${base}:${index}:${block.mode}` : hash if (key && hash) { const cached = cache.get(key) if (cached && cached.hash === hash) { touch(key, cached) - return cached.html + return { key: `${base}:${index}`, hash, html: cached.html, mode: block.mode } // kilocode_change } } const next = await Promise.resolve(marked.parse(block.src)) const safe = sanitize(next) if (key && hash) touch(key, { hash, html: safe }) - return safe + return { key: `${base}:${index}`, hash, html: safe, mode: block.mode } // kilocode_change }), ) - .then((list) => list.join("")) - .catch(() => fallback(src.text)) + .then((blocks) => ({ content: blocks.map((block) => block.html).join(""), blocks })) // kilocode_change + .catch(() => ({ content: fallback(src.text), blocks: [] })) // kilocode_change }, - { initialValue: fallback(local.text) }, + { initialValue: { content: fallback(local.text), blocks: [] } }, // kilocode_change ) let copyCleanup: (() => void) | undefined @@ -313,10 +316,27 @@ export function Markdown( let pendingContent: string | undefined let pendingLabels: { copy: string; copied: string } | undefined // kilocode_change end + // kilocode_change start + const incremental = createIncrementalMarkdown(decorate, { + cancel: () => { + if (pendingFrame === undefined) return + cancelAnimationFrame(pendingFrame) + pendingFrame = undefined + pendingContent = undefined + pendingLabels = undefined + }, + ready: (container, labels, mermaid) => { + copyCleanup ??= setupCodeCopy(container, () => labels) + kickMermaid(container, true, mermaid) + kickHighlight(container, labels) + }, + }) + // kilocode_change end createEffect(() => { const container = root() - const content = local.text ? (html.latest ?? html() ?? "") : "" + const rendered = html.latest ?? html() ?? { content: "", blocks: [] } // kilocode_change + const content = local.text ? rendered.content : "" // kilocode_change if (!container) return if (isServer) return @@ -330,6 +350,7 @@ export function Markdown( pendingLabels = undefined } // kilocode_change end + incremental.reset() // kilocode_change container.innerHTML = "" // kilocode_change start: Mermaid diagram rendering mermaidState.signal.aborted = true @@ -371,6 +392,7 @@ export function Markdown( pendingContent = undefined pendingLabels = undefined } + incremental.reset() // kilocode_change copyCleanup = fast.copyCleanup kickMermaid(container, local.streaming ?? false, mermaid) kickHighlight(container, labels) @@ -378,6 +400,9 @@ export function Markdown( } // kilocode_change end + if (incremental.render(local.streaming ?? false, container, rendered.blocks, labels, mermaid)) return // kilocode_change + incremental.reset() // kilocode_change + // kilocode_change start: queue the latest content for a single rAF tick. // Further updates before the frame runs simply overwrite pendingContent, // so K rapid updates collapse to 1 parse instead of K. diff --git a/packages/ui/src/kilocode/markdown-incremental-dom.ts b/packages/ui/src/kilocode/markdown-incremental-dom.ts new file mode 100644 index 0000000000..bd44cbf276 --- /dev/null +++ b/packages/ui/src/kilocode/markdown-incremental-dom.ts @@ -0,0 +1,128 @@ +type Labels = { + copy: string + copied: string +} + +export type MarkdownBlock = { + key: string + hash: string + html: string + mode: "full" | "live" +} + +type Record = { + key: string + hash: string + start: Comment + end: Comment +} + +type Decorate = (root: HTMLDivElement, labels: Labels) => void + +type Hooks = { + cancel: () => void + ready: (container: HTMLDivElement, labels: Labels, context: Context) => void +} + +export function createIncrementalMarkdown(decorate: Decorate, hooks?: Hooks) { + let records: Record[] = [] + + const reset = () => { + records = [] + } + + const parse = (html: string, labels: Labels) => { + const root = document.createElement("div") + root.innerHTML = html + decorate(root, labels) + const fragment = document.createDocumentFragment() + while (root.firstChild) fragment.appendChild(root.firstChild) + return fragment + } + + const remove = (record: Record) => { + const parent = record.start.parentNode + if (!parent || record.end.parentNode !== parent) return false + + const nodes: ChildNode[] = [] + let node: ChildNode | null = record.start + while (node && node.parentNode === parent) { + nodes.push(node) + if (node === record.end) { + for (const item of nodes) parent.removeChild(item) + return true + } + node = node.nextSibling + } + return false + } + + const replace = (record: Record, block: MarkdownBlock, labels: Labels) => { + let node = record.start.nextSibling + while (node && node !== record.end) { + const next: ChildNode | null = node.nextSibling + node.parentNode?.removeChild(node) + node = next + } + record.end.parentNode?.insertBefore(parse(block.html, labels), record.end) + record.hash = block.hash + } + + const append = (container: HTMLDivElement, block: MarkdownBlock, labels: Labels) => { + // Comment boundaries preserve the exact Markdown child structure, so existing + // direct-child CSS and copy/highlight behavior do not need wrapper exceptions. + const start = document.createComment(`markdown:${block.key}:start`) + const end = document.createComment(`markdown:${block.key}:end`) + container.appendChild(start) + container.appendChild(parse(block.html, labels)) + container.appendChild(end) + records.push({ key: block.key, hash: block.hash, start, end }) + } + + const update = (container: HTMLDivElement, blocks: MarkdownBlock[], labels: Labels) => { + if (blocks.length < 2) return false + if (blocks.slice(0, -1).some((block) => block.mode !== "full")) return false + + if (records.some((record) => !record.start.isConnected || !record.end.isConnected)) reset() + const shared = Math.min(records.length, blocks.length) + if (records.slice(0, shared).some((record, index) => record.key !== blocks[index]?.key)) reset() + + if (records.length === 0) container.replaceChildren() + + while (records.length > blocks.length) { + const record = records.at(-1) + if (!record || !remove(record)) { + reset() + return false + } + records.pop() + } + + for (let index = 0; index < blocks.length; index++) { + const block = blocks[index]! + const record = records[index] + if (!record) { + append(container, block, labels) + continue + } + if (record.hash === block.hash) continue + replace(record, block, labels) + } + return true + } + + const render = ( + streaming: boolean, + container: HTMLDivElement, + blocks: MarkdownBlock[], + labels: Labels, + context: Context, + ) => { + if (!streaming || !update(container, blocks, labels)) return false + hooks?.cancel() + hooks?.ready(container, labels, context) + return true + } + + return { reset, render, update } +} diff --git a/packages/ui/src/kilocode/markdown-stable-blocks.test.ts b/packages/ui/src/kilocode/markdown-stable-blocks.test.ts new file mode 100644 index 0000000000..3dcf759247 --- /dev/null +++ b/packages/ui/src/kilocode/markdown-stable-blocks.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { marked } from "marked" +import remend from "remend" +import { stream } from "../components/markdown-stream" +import { stableBlocks } from "./markdown-stable-blocks" + +async function render(text: string) { + const html = await Promise.all(stream(text, true).map((block) => Promise.resolve(marked.parse(block.src)))) + return html.join("") +} + +describe("stable markdown blocks", () => { + test("keeps completed top-level tokens stable and only heals the tail", () => { + expect( + stableBlocks( + [ + { type: "heading", raw: "# Title\n\n" }, + { type: "paragraph", raw: "First" }, + { type: "space", raw: "\n\n" }, + { type: "paragraph", raw: "Second **open" }, + ], + (raw) => `${raw}**`, + ), + ).toEqual([ + { raw: "# Title\n\n", src: "# Title\n\n", mode: "full" }, + { raw: "First\n\n", src: "First\n\n", mode: "full" }, + { raw: "Second **open", src: "Second **open**", mode: "live" }, + ]) + }) + + test("leaves a single mutable token on the existing streaming path", () => { + expect(stableBlocks([{ type: "paragraph", raw: "Still streaming" }], (raw) => raw)).toBeUndefined() + }) + + test("matches canonical streaming HTML for mixed completed blocks", async () => { + const text = [ + "# Report", + "", + "A completed paragraph with **emphasis**.", + "", + "- first item", + "- second item", + "", + "```ts", + "export const value = 1", + "```", + "", + "The final paragraph is *still streaming", + ].join("\n") + + expect(await render(text)).toBe(await marked.parse(remend(text, { linkMode: "text-only" }))) + expect(stream(text, true).map((block) => block.mode)).toEqual(["full", "full", "full", "full", "live"]) + }) +}) diff --git a/packages/ui/src/kilocode/markdown-stable-blocks.ts b/packages/ui/src/kilocode/markdown-stable-blocks.ts new file mode 100644 index 0000000000..1878f9787d --- /dev/null +++ b/packages/ui/src/kilocode/markdown-stable-blocks.ts @@ -0,0 +1,25 @@ +import type { Block } from "../components/markdown-stream" + +type Token = { + type: string + raw: string +} + +export function stableBlocks(tokens: Token[], live: (raw: string) => string): Block[] | undefined { + const indexes = tokens.flatMap((token, index) => (token.type === "space" ? [] : [index])) + if (indexes.length < 2) return + + const raw = (start: number, end = tokens.length) => + tokens + .slice(start, end) + .map((token) => token.raw) + .join("") + // Completed top-level tokens keep stable hashes across stream updates. The + // existing parse and sanitize cache can then reuse them while only the tail changes. + const stable = indexes.slice(0, -1).map((start, index) => { + const value = raw(start, indexes[index + 1]) + return { raw: value, src: value, mode: "full" as const } + }) + const tail = raw(indexes.at(-1)!) + return [...stable, { raw: tail, src: live(tail), mode: "live" }] +} diff --git a/script/check-workflows.ts b/script/check-workflows.ts index 1fc0138d30..dbe4343a4c 100644 --- a/script/check-workflows.ts +++ b/script/check-workflows.ts @@ -43,6 +43,7 @@ const active = new Set([ "docs-check-links.yml", "duplicate-issues.yml", "generate.yml", + "kilo-auto-close.yml", "nix-eval.yml", "nix-hashes.yml", "prepare-jetbrains-release.yml", diff --git a/script/upstream/package.json b/script/upstream/package.json index 22428dbc7d..3d94d175e1 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.3.40", + "version": "7.3.41", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo",