Merge remote-tracking branch 'origin/main' into marius-kilocode/kilo-opencode-v1.14.51

This commit is contained in:
marius-kilocode
2026-06-10 10:49:54 +02:00
177 changed files with 4781 additions and 1078 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": minor
---
Support reading .ods (OpenDocument Spreadsheet) files in the read tool
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Fix agent-manager model sync on config change
-6
View File
@@ -1,6 +0,0 @@
---
"kilo-code": patch
"@kilocode/kilo-ui": patch
---
Show a pointer cursor for clickable controls and links in Kilo webviews.
@@ -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.
-5
View File
@@ -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.
+5
View File
@@ -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.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Restore automatic session titles for models that require reasoning without assuming a supported effort level.
-6
View File
@@ -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.
+5
View File
@@ -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.
@@ -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.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Discover project-installed skills in Agent Manager worktree sessions.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Prevent the JetBrains session scrollbar from covering transcript content.
+5
View File
@@ -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.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Keep subagent sessions isolated when forking sessions through editor clients.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Use matching VS Code-style icons for JetBrains session views.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Avoid copying visible planning chat into new sessions started from the plan follow-up prompt.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Keep Agent Manager streaming responsive when multiple sessions run concurrently.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Hide thematic separator lines in JetBrains chat markdown while preserving surrounding prose and code blocks.
@@ -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.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Display JetBrains search tool paths relative to the current repository when possible.
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Rename Mercury autocomplete model labels to distinguish FIM and Next Edit modes.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Start forked sessions at zero cost instead of carrying over the source session's spend.
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Re-enable free-model session and Git workspace data export.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Route Agent Manager permission approvals to the worktree that created the request.
+5
View File
@@ -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.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Prevent memory leak in KiloSessionPromptQueue.cancel for sessions without active tails
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve mouse wheel scrolling speed in the JetBrains session view.
+5
View File
@@ -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.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Compact sessions at the configured context percentage before sending an oversized provider request.
+1
View File
@@ -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
+1 -1
View File
@@ -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:
+345
View File
@@ -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(`=============================`)
+1
View File
@@ -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
+20 -20
View File
@@ -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:",
+1 -1
View File
@@ -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": {}
}
+1 -1
View File
@@ -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",
+6 -6
View File
@@ -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"]
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-console",
"version": "7.3.40",
"version": "7.3.41",
"private": true,
"type": "module",
"scripts": {
+1
View File
@@ -10,6 +10,7 @@ exclude_path = ["node_modules"]
accept = [
"200",
"202",
"403",
"429",
"301",
+1 -1
View File
@@ -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",
@@ -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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+16
View File
@@ -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
+20
View File
@@ -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
@@ -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)
@@ -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++
}
@@ -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)
@@ -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()))
@@ -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<String, MessageView>()
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)
@@ -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
@@ -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))
}
@@ -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) {
@@ -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()),
)!!
}
@@ -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))
} }
@@ -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<String, PartView>()
// 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<String, String>()
private val sources = LinkedHashMap<String, String>()
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<String> = 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()
}
@@ -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)
@@ -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)
}
@@ -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()
@@ -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("<u>${XmlStringUtil.escapeString(text)}</u>")
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*</$name>")
.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()
}
}
@@ -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()
@@ -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)
@@ -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<Component>()
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
@@ -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<String, JButton>()
@@ -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() }
@@ -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
@@ -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() {}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<PermissionFileDiff>) {
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 }")
@@ -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 {
@@ -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
@@ -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)
@@ -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<String>
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<String> = 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()})"
}
@@ -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"
}
@@ -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()})"
}
@@ -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"
}
@@ -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<JBLabel> = 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("<u>${XmlStringUtil.escapeString(text)}</u>")
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<String> = 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*</$name>")
.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()
}
}
@@ -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()})"
}
@@ -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<Entry>()
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<Item> {
val items = mutableListOf<Item>()
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)
}
@@ -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<Desc>()
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
}
}
@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="#6C707E" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 560 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="#CED0D6" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 560 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="#6C707E" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 620 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="#CED0D6" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 620 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.58342 13.7498H17.0834M9.58342 6.24984H17.0834M2.91675 6.6665L4.58341 7.9165L7.08341 4.1665M2.91675 14.1665L4.58341 15.4165L7.08341 11.6665" stroke="#6C707E" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 300 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.58342 13.7498H17.0834M9.58342 6.24984H17.0834M2.91675 6.6665L4.58341 7.9165L7.08341 4.1665M2.91675 14.1665L4.58341 15.4165L7.08341 11.6665" stroke="#CED0D6" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 300 B

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

After

Width:  |  Height:  |  Size: 206 B

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

After

Width:  |  Height:  |  Size: 206 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15L7 10L12 5" stroke="#6C707E" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15L7 10L12 5" stroke="#CED0D6" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 175 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 15L13 10L8 5" stroke="#6C707E" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 15L13 10L8 5" stroke="#CED0D6" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 174 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.08325 3.75H11.2499M14.5833 3.75H17.9166M2.08325 10L7.08325 10M10.4166 10L17.9166 10M2.08325 16.25L8.74992 16.25M12.0833 16.25L17.9166 16.25" stroke="#6C707E" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.08325 3.75H11.2499M14.5833 3.75H17.9166M2.08325 10L7.08325 10M10.4166 10L17.9166 10M2.08325 16.25L8.74992 16.25M12.0833 16.25L17.9166 16.25" stroke="#CED0D6" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.7513 7.5013L6.2513 10.0013L8.7513 12.5013M11.2513 7.5013L13.7513 10.0013L11.2513 12.5013M2.91797 2.91797H17.0846V17.0846H2.91797V2.91797Z" stroke="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 275 B

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