mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge branch 'main' into chore/remove-unused-deps
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"@kilocode/kilo-indexing": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Support unauthenticated OpenAI-compatible endpoints for codebase indexing without requiring a placeholder API key.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Support multiple provider APIs, adaptive thinking, split reasoning, and output effort variants for custom providers.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Hide TUI news after they have been opened and add a button to close the news dialog.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Remove stray rounded corners between files in the Changes diff viewer.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix upgrades to resolve Kilo CLI packages and releases instead of OpenCode packages and versions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Show current-worktree sessions by default in the TUI sessions dialog and keep all/current scope toggling working when a scope has no sessions.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Highlight shell tool commands in JetBrains chat transcripts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Improve markdown readability in JetBrains chat transcripts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Hide generated read-tool payload lines from JetBrains prompt bubbles while keeping attachments and assistant tool output visible.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show JetBrains prompt attachments in one horizontal scrolling row in session history.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Support pasting files and images into JetBrains chat prompts as attachments.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Show JetBrains prompt attachments inside the prompt bubble with previews and open embedded attachments in editor tabs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Support sending file and image attachments from the JetBrains chat prompt.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Limit JetBrains prompt input growth to the session while preserving scrolling for long prompts.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Stop Kilo backend processes and clear JetBrains UI resources during restartless plugin unloads.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Support dropping files anywhere in a JetBrains chat session to add them to the prompt.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Polish session header controls and align session view icons.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Open embedded JetBrains message attachments in frontend-managed Kilo editor tabs with loading and connection retry states.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Replace remaining OpenCode-branded CLI and TUI copy with Kilo branding.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Show Agent Manager and other Git worktrees in the Kilo Console project view.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Load the bundled Atomic Chat integration without attempting to install an unpublished npm plugin.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Add an instant/thinking reasoning toggle for MiniMax M-series models, matching the existing glm/kimi/qwen behavior.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Make native Plan mode follow Architect-style planning behavior while preserving Plan mode restrictions and repo-root plan files.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Offer opt-in attention sounds when sessions finish, error, or need input across the sidebar, editor tabs, and Agent Manager.
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Changes from opencode v1.14.51 to v1.15.4 upstream:
|
||||
|
||||
- Core Improvements: Clarified how to recover when the npm package is installed without its native binary.
|
||||
- Core Improvements: Reduced unnecessary prompting around shell, task, and todo flows.
|
||||
- Core Bugfixes: Ignored invalid exports in custom tool modules instead of failing tool loading.
|
||||
- Core Bugfixes: Ignored project instruction lookup errors so sessions keep loading when project instruction discovery fails.
|
||||
- Core Bugfixes: Fixed versioned event projector lookups so event replay uses the right handlers.
|
||||
- Core Bugfixes: Avoid duplicate consecutive entries in prompt history.
|
||||
- Core Bugfixes: Show full config validation errors during TUI startup instead of a generic failure.
|
||||
- Core Bugfixes: Fixed npm installs so the CLI can recover and fetch the right native binary on more setups.
|
||||
- Core Bugfixes: Fixed multiline `@` mentions in prompts.
|
||||
- Core Bugfixes: Preserved custom tool metadata from Zod schemas.
|
||||
- Core Bugfixes: Preserved custom tool argument descriptions in generated schemas.
|
||||
- Core Bugfixes: Fixed file watching in repos where `.git` is a symlink. (@kagura-agent)
|
||||
- Core Bugfixes: Fixed sync events not reaching project-scoped subscribers in injected instances.
|
||||
- Core Bugfixes: Reduced wasted work when reading very large files after output truncation.
|
||||
- Core Bugfixes: Fixed project-scoped bus events so file watcher and update notifications reach the right instance.
|
||||
- Core Bugfixes: Fixed custom LSP servers not sending refresh events after they initialize.
|
||||
- Core Bugfixes: Hid background subagent task instructions unless experimental background mode is enabled.
|
||||
- TUI Improvements: Added a collapsed thinking view that can be expanded inline.
|
||||
- TUI Improvements: Added pinned sessions with quick-switch slots in the session picker.
|
||||
- TUI Improvements: Newly pinned sessions now stay at the end of the pinned list instead of jumping to the top.
|
||||
- TUI Improvements: Made Markdown H1 headings easier to distinguish.
|
||||
- TUI Bugfixes: Fixed thinking mode defaults so reasoning starts collapsed consistently.
|
||||
- TUI Bugfixes: Limited session quick-switching to pinned sessions.
|
||||
- TUI Bugfixes: Fixed Markdown table rendering in chat output.
|
||||
- TUI Bugfixes: Fixed `kilo run --agent` resolving project-local agents.
|
||||
- TUI Bugfixes: Fixed async commands losing the active instance context, which could break agent generation and GitHub-driven runs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Restore session timestamp prefixes for generated plan filenames while preserving descriptive model-chosen names.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep expanded Kilo Console file diffs open while resizing the context sidebar.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep Kilo Console terminal sessions open when changing diff layout and other console preferences.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Render file attachments as attachment cards in JetBrains prompts and session history.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Render JetBrains shell tool output with markdown code blocks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Open embedded transcript attachments in stable Kilo editor tabs.
|
||||
@@ -1,236 +0,0 @@
|
||||
name: close-stale-prs # kilocode_change
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dryRun:
|
||||
description: "Log actions without closing PRs"
|
||||
type: boolean
|
||||
default: false
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
close-stale-prs:
|
||||
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:
|
||||
- name: Close inactive PRs
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const 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 PRs)
|
||||
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
|
||||
|
||||
const startTime = Date.now()
|
||||
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
|
||||
const { owner, repo } = context.repo
|
||||
const dryRun = context.payload.inputs?.dryRun === "true"
|
||||
|
||||
core.info(`Dry run mode: ${dryRun}`)
|
||||
core.info(`Cutoff date: ${cutoff.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
|
||||
}
|
||||
|
||||
// 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 > cutoff) {
|
||||
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
|
||||
})
|
||||
|
||||
if (!stalePrs.length) {
|
||||
core.info("No stale pull requests found.")
|
||||
return
|
||||
}
|
||||
|
||||
core.info(`Found ${stalePrs.length} stale pull requests`)
|
||||
|
||||
// ============================================
|
||||
// Close stale PRs
|
||||
// ============================================
|
||||
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
|
||||
? LARGE_BATCH_DELAY_MS
|
||||
: SMALL_BATCH_DELAY_MS
|
||||
|
||||
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
|
||||
|
||||
let closedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
for (const pr of stalePrs) {
|
||||
const issue_number = pr.number
|
||||
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
|
||||
|
||||
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}`
|
||||
)
|
||||
|
||||
closedCount++
|
||||
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
|
||||
|
||||
// Delay before processing next PR
|
||||
await sleep(requestDelayMs)
|
||||
} catch (error) {
|
||||
skippedCount++
|
||||
core.error(`Failed to close PR #${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: ${closedCount}`)
|
||||
core.info(`PRs skipped (errors): ${skippedCount}`)
|
||||
core.info(`Elapsed time: ${elapsed}s`)
|
||||
core.info(`=============================`)
|
||||
+1
-2
@@ -7,13 +7,12 @@ on:
|
||||
|
||||
jobs:
|
||||
close:
|
||||
if: github.repository == 'anomalyco/opencode' # kilocode_change - Kilo uses kilo-auto-close.yml
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6 # kilocode_change
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
+11
-19
@@ -6,28 +6,25 @@ on:
|
||||
|
||||
jobs:
|
||||
check-duplicates:
|
||||
if: false # kilocode_change - disabled: not needed in kilocode repo
|
||||
if: github.event.action == 'opened'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6 # kilocode_change
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
# kilocode_change start
|
||||
- name: Setup Kilo
|
||||
uses: ./.github/actions/setup-kilo
|
||||
# kilocode_change end
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://kilo.ai/install | bash
|
||||
|
||||
- name: Check duplicates and compliance
|
||||
env:
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
KILO_PERMISSION: |
|
||||
{
|
||||
@@ -38,7 +35,7 @@ jobs:
|
||||
"webfetch": "deny"
|
||||
}
|
||||
run: |
|
||||
kilo run -m "kilo/anthropic/claude-haiku-4.5" "A new issue has been created:
|
||||
opencode run -m opencode/claude-sonnet-4-6 "A new issue has been created:
|
||||
|
||||
Issue number: ${{ github.event.issue.number }}
|
||||
|
||||
@@ -128,23 +125,18 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6 # kilocode_change
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
# kilocode_change start
|
||||
- name: Setup Kilo
|
||||
uses: ./.github/actions/setup-kilo
|
||||
# kilocode_change end
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://kilo.ai/install | bash
|
||||
|
||||
- name: Recheck compliance
|
||||
env:
|
||||
# kilocode_change start
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
|
||||
# kilocode_change end
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
KILO_PERMISSION: |
|
||||
{
|
||||
@@ -155,7 +147,7 @@ jobs:
|
||||
"webfetch": "deny"
|
||||
}
|
||||
run: |
|
||||
kilo run -m "kilo/anthropic/claude-haiku-4.5" "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
|
||||
opencode run -m opencode/claude-sonnet-4-6 "Issue #${{ github.event.issue.number }} was previously flagged as non-compliant and has been edited.
|
||||
|
||||
Lookup this issue with gh issue view ${{ github.event.issue.number }}.
|
||||
|
||||
@@ -6,33 +6,31 @@ on:
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6 # kilocode_change
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Setup Kilo
|
||||
uses: ./.github/actions/setup-kilo
|
||||
- name: Install opencode
|
||||
run: curl -fsSL https://kilo.ai/install | bash
|
||||
|
||||
- name: Triage issue
|
||||
env:
|
||||
KILO_API_KEY: ${{ secrets.KILO_API_KEY }}
|
||||
KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
run: |
|
||||
kilo run --agent triage "The following issue was just opened, triage it:
|
||||
opencode run --agent triage "The following issue was just opened, triage it:
|
||||
|
||||
Title: $ISSUE_TITLE
|
||||
|
||||
@@ -12,7 +12,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-release:
|
||||
if: vars.OPENCODE_WATCH_ENABLED != 'false'
|
||||
if: github.repository == 'Kilo-Org/kilocode' && vars.OPENCODE_WATCH_ENABLED != 'false'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# JetBrains MdView Editor Theme Colors Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Keep JetBrains markdown layout compact while making markdown colors and backgrounds come from the active editor color scheme, and ensure existing theme/editor-setting listeners refresh all existing `MdView` instances after changes.
|
||||
|
||||
## Findings
|
||||
|
||||
- `SessionUi` already subscribes to `EditorColorsManager.TOPIC` and `LafManagerListener.TOPIC`, then calls `applyStyle(SessionEditorStyle.current())` on the session tree.
|
||||
- Most markdown consumers already propagate that style through `MdView.applyStyle(style)` via `TextView`, `ReasoningView`, message lists, and session panels.
|
||||
- `PlanExitView.applyStyle()` sets only font/code font/foreground and does not call `md.applyStyle(style)`, so internal markdown role colors can stay tied to the initial style.
|
||||
- `MdViewHybrid.applyStyle()` restyles retained HTML panes and code block containers, but retained `CodeField` editors need explicit reapplication of `SessionEditorStyle.applyToEditor()` after creation so syntax highlighting and editor colors follow scheme changes.
|
||||
- `MdCommon.defaults()` currently mixes editor colors, UI theme colors, and one literal inline-code color fallback. The literal fallback should be removed, and markdown roles should derive from `EditorColorsScheme`/syntax attributes wherever possible.
|
||||
- `TextView` currently overrides markdown background with `SessionUiStyle.Transcript.bgColor()` (`UiStyle.Colors.bg()`), while prompt markdown already uses `style.editorBackground`. If markdown surfaces should consistently use editor background, normal text views need to use `style.editorBackground` too.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Keep compact CSS unchanged.
|
||||
- Do not reintroduce line-height, margin, padding, heading sizing, or other geometry rules.
|
||||
- Keep the current role color/background selectors only.
|
||||
|
||||
2. Derive markdown role defaults from editor settings.
|
||||
- Update `MdCommon.defaults(style)` to use `style.editorForeground` and `style.editorBackground` for primary text/background.
|
||||
- Add small local helper functions for editor attributes, for example foreground/background from `style.editorScheme.getAttributes(key)` and color keys from `style.editorScheme.getColor(key)`.
|
||||
- Use public IntelliJ editor keys for role colors:
|
||||
- Links: `CodeInsightColors.HYPERLINK_ATTRIBUTES.foregroundColor`, fallback to platform link color if absent.
|
||||
- Inline code foreground/background: `DefaultLanguageHighlighterColors.DOC_CODE_INLINE`, fallback to `STRING`, then editor foreground/background.
|
||||
- Code block foreground/background: `DefaultLanguageHighlighterColors.DOC_CODE_BLOCK`, fallback to editor foreground/background.
|
||||
- Quote/emphasis/list weak text: comment/doc-comment attributes, fallback to editor foreground or `UIUtil.getContextHelpForeground()` only when the scheme has no useful value.
|
||||
- Borders/HR/table/code border: editor preview/border color keys such as `EditorColors.PREVIEW_BORDER_COLOR`, fallback to `UiStyle.Colors.contentBorder()`.
|
||||
- Remove `JBColor(0x...)` or other literal runtime color fallbacks from `MdCommon`.
|
||||
- Keep public `MdView` API unchanged; role colors stay internal unless a concrete external override need appears.
|
||||
|
||||
3. Ensure style propagation reaches every markdown instance.
|
||||
- Update `PlanExitView.applyStyle(style)` to call `md.applyStyle(style)` before applying its explicit font/code-font/foreground overrides.
|
||||
- Audit existing markdown callers after the change; keep using the existing `SessionEditorStyleTarget` propagation path rather than adding per-`MdView` theme listeners.
|
||||
- Keep explicit foreground overrides in `TextView`, `ReasoningView`, and `PlanExitView` where they intentionally set body text role, but let internal markdown role colors refresh from the new style snapshot.
|
||||
|
||||
4. Ensure retained hybrid code blocks update after editor setting changes.
|
||||
- In `MdViewHybrid.CodeView.style(opts)`, for retained `CodeField` blocks, reapply `style.applyToEditor(editor)` to the underlying editor if it exists.
|
||||
- Reapply code editor background/scroll pane/viewport backgrounds from `opts.preBg` in the same path.
|
||||
- Preserve retained component/editor reuse: do not rebuild code block panes just to update style.
|
||||
|
||||
5. Align markdown backgrounds with editor settings.
|
||||
- Keep `MdCommon` default background as `style.editorBackground`.
|
||||
- Change normal `TextView` markdown background to `style.editorBackground` if the intent is that all markdown surfaces use editor background, matching `PromptView` and session root behavior.
|
||||
- Preserve `transparent` handling: when `md.opaque = false`, background should still be the editor-derived value for child/code surfaces, but the Swing component should remain non-opaque.
|
||||
|
||||
6. Update tests.
|
||||
- Extend `MdViewTest` to verify markdown role CSS changes when applying a `SessionEditorStyle` backed by a customized editor scheme, especially inline code, code block, link, and border colors.
|
||||
- Extend `MdViewHybridTest` to assert `applyStyle()` updates retained HTML panes and retained code editors without replacing them, including editor scheme/background changes.
|
||||
- Add or extend `PlanExitViewTest` so `applyStyle()` refreshes the nested markdown style, not just foreground/font overrides.
|
||||
- Keep existing compactness expectations: tests should not assert new spacing, padding, margin, line-height, or size rules.
|
||||
|
||||
## Verification
|
||||
|
||||
- From `packages/kilo-jetbrains/`, run `./gradlew frontend:test --tests '*MdView*'`.
|
||||
- From `packages/kilo-jetbrains/`, run `./gradlew frontend:test --tests '*PlanExitViewTest*'` if the focused test is not covered by the MdView filter.
|
||||
- From `packages/kilo-jetbrains/`, run `bun run typecheck`.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not introduce JCEF, Compose, or Kotlin UI DSL.
|
||||
- Do not add new theme/editor listeners in `MdView`; use the existing `SessionUi` listener and `SessionEditorStyleTarget` propagation path.
|
||||
- Avoid hardcoded runtime colors in markdown styling; prefer editor scheme attributes/color keys, then platform/theme APIs as non-literal fallbacks.
|
||||
- Preserve retained Swing behavior and `MdViewHybrid.sync()` component reuse.
|
||||
- Keep the public `MdView` override API stable unless implementation proves a new external override is required.
|
||||
@@ -0,0 +1,69 @@
|
||||
# JetBrains MdView VS Code Styling Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Improve JetBrains markdown output so assistant/user transcript markdown visually matches the VS Code webview markdown style while keeping the existing Swing/JBHtmlPane + editor-backed code block architecture.
|
||||
|
||||
## Findings
|
||||
|
||||
- VS Code markdown styling is split across `packages/ui/src/components/markdown.css`, `packages/kilo-ui/src/components/markdown.css`, `packages/kilo-ui/src/styles/vscode-bridge.css`, and message-part overrides.
|
||||
- Base VS Code markdown uses 14px sans text, 160% line height, tight first/last margins, same-size medium headings, 12px paragraph spacing, link-colored anchors, compact lists, weak list markers, weak blockquotes with a 2px left border, invisible HR spacing, bordered/padded code blocks, green inline code, and lightly bordered tables.
|
||||
- The VS Code theme bridge maps markdown roles to editor/theme tokens: heading/link/list/image use `textLinkForeground`, text/strong/code-block use editor foreground, inline code uses charts/syntax green, quote/emphasis use description foreground, HR uses panel border.
|
||||
- JetBrains markdown is rendered by `MdViewHybrid` and `MdViewHtmlPane`, with shared CSS from `MdCommon.rules()` and defaults from `MdCommon.defaults()`.
|
||||
- JetBrains currently styles only broad tag font/color, links, code/pre colors, blockquote border/text color, and table border. It lacks VS Code-equivalent spacing, heading/strong/emphasis/list marker/table cell rules, inline-code foreground, blockquote geometry, HR spacing, and code block surface polish.
|
||||
- JetBrains fenced code blocks are already stronger than VS Code in one respect: they use `EditorTextField` with real IDE syntax highlighting and streaming retention. Preserve this instead of switching to web/JCEF rendering.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Expand JetBrains markdown style tokens.
|
||||
- Add internal fields to `MdStyle` for heading, strong, emphasis, inline code foreground, list marker, HR, table/header, and code block border colors.
|
||||
- Keep the public `MdView` override API stable unless a new external override is clearly needed.
|
||||
- Compute defaults in `MdCommon.defaults(style)` from IntelliJ/editor theme sources and centralized Kilo semantic colors where no platform key matches.
|
||||
- Use `JBColor.namedColor("Kilo.Markdown.*", fallback)` for Kilo-specific markdown palette fallbacks, so themes can override them and runtime code avoids scattered hardcoded colors.
|
||||
|
||||
2. Mirror VS Code markdown CSS in `MdCommon.rules()`.
|
||||
- Add root/body wrapping rules: max width behavior, break-word wrapping, base line-height, and first/last-child margin trimming where supported by `JBHtmlPane` CSS.
|
||||
- Add heading rules: same base size, medium/bold weight, role-specific color, line height, and bottom spacing.
|
||||
- Add paragraph, list, list item, nested list, and marker rules. If Swing HTML does not support `::marker`, fall back to `li { color: ... }` plus child text color reset only if supported; otherwise keep list text normal and document the limitation in tests.
|
||||
- Add strong/emphasis colors matching VS Code token roles.
|
||||
- Add anchor styling matching VS Code: themed link color, no forced background, and underline behavior where `JBHtmlPane` supports it.
|
||||
- Add blockquote geometry: 2px left border, 24px vertical margin, 8px left padding, weak text, and normal style.
|
||||
- Add table layout rules: collapse borders, full width where possible, 24px vertical margin, 12px cell padding, weak row borders, stronger header text.
|
||||
- Keep HRs visually hidden but spaced consistently with VS Code if the renderer includes them. `MdViewHybrid` currently filters thematic breaks, so this mainly benefits `MdViewHtmlPane` and future reuse.
|
||||
- Add inline-code foreground and medium font weight. Avoid inline code backgrounds unless the current `JBHtmlPane` configuration already draws them acceptably.
|
||||
|
||||
3. Polish JetBrains code block containers without losing IDE highlighting.
|
||||
- Keep `EditorTextField` for fenced/indented blocks and `JBTextArea` fallback.
|
||||
- Style `JBScrollPane` code blocks to match VS Code’s `markdown-code` wrapper feel: subtle background, subtle border, rounded-ish platform arc if feasible, 12px-ish padding, and thin horizontal scrollbar behavior.
|
||||
- Use `SessionUiStyle.View.Code` for geometry constants. Add only minimal new constants there if current values cannot represent the VS Code spacing.
|
||||
- Separate code block border color from table border internally so table styling can change without affecting code boxes.
|
||||
- Continue applying `SessionEditorStyle.applyToEditor(ed)` so code blocks follow IDE syntax highlighting and editor font changes.
|
||||
|
||||
4. Add file/path affordance parity where safe.
|
||||
- For markdown links whose `href` looks like a relative file path, keep existing link dispatch so the current caller can open files/URLs appropriately.
|
||||
- Consider decorating inline code that looks like a path with a `file-link` class only when an `openFile` callback is available through the existing usage path. If the current `MdView` abstraction only has `openUrl`, do not widen it unless the call sites can pass file opening cleanly.
|
||||
- At minimum, make inline code/path-looking content visually closer to VS Code by using the inline-code foreground and dotted underline for explicit file links where generated HTML contains link/code classes.
|
||||
|
||||
5. Preserve retained Swing behavior.
|
||||
- Keep `MdViewHybrid.sync()` prefix reuse logic unchanged unless necessary.
|
||||
- Ensure style updates call `reloadCssStylesheets()` and reassign text only for retained `JBHtmlPane` blocks, not by rebuilding all blocks.
|
||||
- Keep streaming fenced-code fast path and editor disposal behavior intact.
|
||||
|
||||
6. Add focused tests.
|
||||
- Extend `MdViewTest` and/or `MdViewHybridTest` to assert `overrideSheet()` contains the new VS Code-equivalent rules for headings, strong/emphasis, links, inline code foreground, list/table/blockquote spacing, HR, and code block/table border separation.
|
||||
- Add component tests for code block pane styling: background, viewport background, border color, padding, scrollbar policy, and retained editor instance after `applyStyle()`.
|
||||
- Keep existing stress/leak tests green. Add a small stress assertion only if the implementation changes style application semantics.
|
||||
- Add a changeset: `@kilocode/kilo-jetbrains` patch with user-facing wording such as `Improve markdown readability in JetBrains chat transcripts.`
|
||||
|
||||
## Verification
|
||||
|
||||
- Run targeted JetBrains markdown tests first from `packages/kilo-jetbrains/`: `./gradlew frontend:test --tests '*MdView*'` if the Gradle module supports it; otherwise run the closest supported targeted Gradle test command.
|
||||
- Run `bun run typecheck` from `packages/kilo-jetbrains/`.
|
||||
- If targeted Gradle filtering is unreliable, run `./gradlew test` from `packages/kilo-jetbrains/`.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not introduce JCEF, Compose, or Kotlin UI DSL.
|
||||
- Keep changes inside `packages/kilo-jetbrains/` and `.changeset/` unless a shared Kilo UI source of truth is explicitly required.
|
||||
- No `kilocode_change` markers are needed for JetBrains or Kilo UI paths.
|
||||
- Prefer IntelliJ theme APIs and centralized semantic tokens over scattered literal colors.
|
||||
@@ -0,0 +1,63 @@
|
||||
# JetBrains Session UI Icons And Header Layout Plan
|
||||
|
||||
## Goal
|
||||
Improve JetBrains session UI icon consistency and reduce accidental header interactions:
|
||||
- Use Kilo/VS Code-aligned session icons in session views.
|
||||
- Fix the reasoning header icon.
|
||||
- Normalize session part collapse/expand chevrons so collapsed/expanded states do not jump between differently sized glyphs.
|
||||
- Move the session-details toggle away from compaction and place it before the session title.
|
||||
|
||||
## Findings
|
||||
- Session view icons are centralized in `packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionViewIcons.kt` and loaded from `frontend/src/main/resources/icons/views/*.svg`.
|
||||
- The JetBrains `views` SVGs already mirror the shared VS Code/UI icon paths from `packages/ui/src/components/icon.tsx` for the audited names, including `brain`, `chevron-down`, `chevron-right`, `checklist`, `console`, `warning`, etc.
|
||||
- The reasoning view currently renders `SessionViewIcons.eye` in `ReasoningView.kt`; VS Code/shared UI uses the `brain` icon for reasoning/thinking surfaces, and `SessionViewIcons.brain` already exists.
|
||||
- Standard collapsible session parts use `SessionViewIcons.chevronDown` when expanded and `SessionViewIcons.chevronRight` when collapsed in `AbstractSessionPartView.kt`; `QuestionResultView.kt` repeats this pattern manually. The down/right SVG paths have different visual extents.
|
||||
- The session header currently places the details toggle next to the compact button in the right-side controls in `SessionHeaderPanel.kt`, making the two actions easy to confuse.
|
||||
|
||||
## Implementation Steps
|
||||
1. **Keep icon sources aligned with VS Code/shared UI**
|
||||
- Treat `packages/ui/src/components/icon.tsx` as the source for Kilo web/session glyph shapes.
|
||||
- Re-check `SessionViewIcons.kt` entries against available JetBrains assets; only update or add SVGs if a session view uses a Kilo icon missing from `frontend/src/main/resources/icons/views/`.
|
||||
- Preserve JetBrains SVG theming rules: no `currentColor`; use literal palette colors and dark variants where assets are added or changed.
|
||||
|
||||
2. **Fix reasoning icon**
|
||||
- In `ReasoningView.kt`, change the reasoning header glyph from `SessionViewIcons.eye` to `SessionViewIcons.brain`.
|
||||
- Add or update test coverage in `ReasoningViewTest.kt` by inspecting the rendered Swing label tree and asserting the reasoning icon is `SessionViewIcons.brain`.
|
||||
|
||||
3. **Normalize collapse/expand chevrons for session parts**
|
||||
- Stop using the mixed `chevronRight`/`chevronDown` pair for collapsible session content.
|
||||
- Use a single base Kilo chevron glyph for both states, matching the current custom chevron used by the session header (`/icons/chevron-down.svg` / equivalent `SessionViewIcons.chevronDown`).
|
||||
- Add a shared rotated icon for the opposite state instead of switching to a differently sized right-facing asset. Prefer a small reusable helper or centralized icon field rather than importing header-specific UI into session views.
|
||||
- Update `AbstractSessionPartView.kt` and `QuestionResultView.kt` to use the normalized chevron pair.
|
||||
- Leave `QuestionView.kt` navigation chevrons alone unless auditing shows they are being used for collapse/expand; those are previous/next controls, not expand/collapse controls.
|
||||
|
||||
4. **Relocate and change header show/hide details toggle**
|
||||
- In `SessionHeaderPanel.kt`, replace the custom header details chevron with platform `AllIcons` arrows/chevrons, e.g. collapsed = `AllIcons.General.ArrowRight`, expanded = `AllIcons.General.ArrowDown`.
|
||||
- Move the details toggle out of the right-side controls and into `BorderLayout.WEST` of the header row.
|
||||
- Rebuild the top header as:
|
||||
- outer border layout
|
||||
- west: details toggle button
|
||||
- center: inner border layout
|
||||
- inner center: session title
|
||||
- inner east: horizontal stack/row with price/context and compact button
|
||||
- Remove the details toggle from the right-side row so compaction remains visually separate from show/hide details.
|
||||
- Keep existing tooltip/accessibility strings and expansion persistence behavior unchanged.
|
||||
|
||||
5. **Tests**
|
||||
- Update `SessionHeaderPanelTest.kt` to assert:
|
||||
- collapsed/expanded header details icons use the selected `AllIcons` constants;
|
||||
- the details toggle persists expansion state as before;
|
||||
- the details toggle is parented/laid out separately from the compact button.
|
||||
- Update `AbstractSessionPartViewTest.kt` to assert collapsible parts keep the same icon dimensions across collapsed/expanded states and no longer use the mismatched right/down pair.
|
||||
- Update `QuestionResultViewTest.kt` similarly because it has its own chevron implementation.
|
||||
- Update `ReasoningViewTest.kt` for the brain icon.
|
||||
|
||||
6. **Verification**
|
||||
- Run the smallest relevant JetBrains checks from `packages/kilo-jetbrains/`:
|
||||
- `./gradlew test --tests "ai.kilocode.client.session.views.ReasoningViewTest" --tests "ai.kilocode.client.session.views.base.AbstractSessionPartViewTest" --tests "ai.kilocode.client.session.views.QuestionResultViewTest" --tests "ai.kilocode.client.session.ui.header.SessionHeaderPanelTest"`
|
||||
- `./gradlew typecheck`
|
||||
- If the filtered Gradle test syntax is not accepted by the project, run `./gradlew test` from `packages/kilo-jetbrains/` instead.
|
||||
|
||||
## Notes
|
||||
- No shared upstream `opencode` files are involved; changes stay under `packages/kilo-jetbrains/`.
|
||||
- A changeset may be needed because this is user-facing JetBrains UI polish; confirm existing changeset policy for the private JetBrains package during implementation.
|
||||
@@ -291,6 +291,7 @@
|
||||
"@kilocode/kilo-i18n": "workspace:*",
|
||||
"@kilocode/kilo-indexing": "workspace:*",
|
||||
"@kilocode/kilo-ui": "workspace:*",
|
||||
"@kilocode/plugin": "workspace:*",
|
||||
"@kilocode/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/diffs": "catalog:",
|
||||
|
||||
@@ -76,8 +76,11 @@
|
||||
};
|
||||
|
||||
kilo-dev = pkgs.writeShellScriptBin "kilo-dev" ''
|
||||
cd "$KILO_ROOT"
|
||||
exec ${bun}/bin/bun dev "$@"
|
||||
set -euo pipefail
|
||||
|
||||
: "''${KILO_ROOT:?KILO_ROOT is not set. Enter the flake dev shell from the repo root.}"
|
||||
export KILO_DEV_CWD="$PWD"
|
||||
exec ${bun}/bin/bun --cwd "$KILO_ROOT/packages/opencode" --conditions=browser ./src/index.ts "$@"
|
||||
'';
|
||||
|
||||
kilo-install-bin = pkgs.writeShellScriptBin "kilo-install" ''
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-eDZ7bR8/xjtQj8Y+X02h/D7afn2TGC3xOTnDmwFKkwo=",
|
||||
"aarch64-linux": "sha256-CvsqQ8uSoZmc/9ClO4I/Wwb2NIQqynt57n/G0P9mFPY=",
|
||||
"aarch64-darwin": "sha256-mIC3G1zON6WmKyZT5YaYGc0Wh6v/tQ+aJaZcU7/nyzE=",
|
||||
"x86_64-darwin": "sha256-U6A3ZPFUVUDdgwxCJXX5X0HzR6DiLzIoHViDoQDiMMY="
|
||||
"x86_64-linux": "sha256-jvRriUZ4wVKCs5Lek4b+Ln98WJe3QUZ9xzLX7E6XUG4=",
|
||||
"aarch64-linux": "sha256-tcr6VJXR0H0iCS4LaRPb8tjapNIt8+WVn1jyWWiwUdg=",
|
||||
"aarch64-darwin": "sha256-Rwg8EbHvxNo1mDr2RqPUDAX7QR//pFcnmgXfDbIx4oY=",
|
||||
"x86_64-darwin": "sha256-Wsu1lxKb/gzdi7i0iwok9Uejk9QLEn/K3jaqURjsyT0="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
VcsInfo,
|
||||
Worktree,
|
||||
WorktreeDiffItem,
|
||||
WorktreeListResponse,
|
||||
} from "@kilocode/sdk/v2/client"
|
||||
|
||||
export type Scope = "global" | "project"
|
||||
@@ -54,7 +55,7 @@ export type ProjectConsoleSnapshot = {
|
||||
project: ProjectItem
|
||||
config: EffectiveConfig
|
||||
vcs: VcsInfo
|
||||
worktrees: string[]
|
||||
worktrees: WorktreeListResponse
|
||||
terminals: ProjectTerminalItem[]
|
||||
}
|
||||
|
||||
@@ -447,7 +448,7 @@ export async function loadProjectConsole(input: ProjectConsoleQuery): Promise<Pr
|
||||
])
|
||||
const dirs = demand("Worktrees", worktrees)
|
||||
const terminals = await Promise.all(
|
||||
[query.dir, ...dirs].map((dir) => loadProjectTerminals({ url: input.url, dir }, dir)),
|
||||
[query.dir, ...dirs.map((item) => item.directory)].map((dir) => loadProjectTerminals({ url: input.url, dir }, dir)),
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -58,6 +58,7 @@ type Context = {
|
||||
dir: string
|
||||
label: string
|
||||
kind: "local" | "worktree"
|
||||
managed: boolean
|
||||
}
|
||||
|
||||
type Editor = { kind: "create"; value: string } | { kind: "rename"; item: Context; value: string }
|
||||
@@ -135,6 +136,7 @@ function refreshEvent(event: ProjectConsoleEvent) {
|
||||
if (type.startsWith("permission.")) return true
|
||||
if (type.startsWith("question.")) return true
|
||||
if (type.startsWith("message.")) return true
|
||||
if (type === "global.config.updated") return true
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -179,8 +181,14 @@ export function ProjectConsoleRoute() {
|
||||
const data = snap()
|
||||
if (!data) return []
|
||||
return [
|
||||
{ id: "local", dir: data.project.worktree, label: "Local", kind: "local" },
|
||||
...data.worktrees.map((dir) => ({ id: dir, dir, label: title(dir), kind: "worktree" as const })),
|
||||
{ id: "local", dir: data.project.worktree, label: "Local", kind: "local", managed: false },
|
||||
...data.worktrees.map((item) => ({
|
||||
id: item.directory,
|
||||
dir: item.directory,
|
||||
label: title(item.directory),
|
||||
kind: "worktree" as const,
|
||||
managed: item.managed,
|
||||
})),
|
||||
]
|
||||
})
|
||||
const terminals = createMemo(() => {
|
||||
@@ -499,8 +507,12 @@ export function ProjectConsoleRoute() {
|
||||
setEditor({ kind: "rename", item, value: displayLabel(item) })
|
||||
}
|
||||
|
||||
function canManage(item: Context | undefined) {
|
||||
return item?.kind === "worktree" && item.managed
|
||||
}
|
||||
|
||||
function removeWorktree(item: Context) {
|
||||
if (!projectInput() || item.kind === "local") return
|
||||
if (!projectInput() || !canManage(item)) return
|
||||
setPending({ kind: "delete", item })
|
||||
}
|
||||
|
||||
@@ -512,7 +524,7 @@ export function ProjectConsoleRoute() {
|
||||
|
||||
function resetSelected() {
|
||||
const item = current()
|
||||
if (!projectInput() || !item || item.kind === "local") return
|
||||
if (!projectInput() || !canManage(item)) return
|
||||
setPending({ kind: "reset", item })
|
||||
}
|
||||
|
||||
@@ -653,11 +665,14 @@ export function ProjectConsoleRoute() {
|
||||
const base = query()
|
||||
const data = snap()
|
||||
if (!base || !data) return
|
||||
const dirs = new Set([data.project.worktree, ...data.worktrees])
|
||||
const dirs = new Set([data.project.worktree, ...data.worktrees.map((item) => item.directory)])
|
||||
const stop = subscribeProjectEvents({ url: base.url, dir: data.project.worktree }, (event) => {
|
||||
if (event.directory !== "global" && !dirs.has(event.directory)) return
|
||||
const id = eventSession(event)
|
||||
if (id && messageEvent(event)) markUnread(id)
|
||||
// Terminal fitting emits pty.updated for every width change. Ignore those refreshes while
|
||||
// dragging so the controlled review accordion keeps its expanded files mounted.
|
||||
if (resize.pending && eventType(event) === "pty.updated") return
|
||||
if (refreshEvent(event)) scheduleRefetch()
|
||||
})
|
||||
onCleanup(stop)
|
||||
@@ -765,19 +780,21 @@ export function ProjectConsoleRoute() {
|
||||
>
|
||||
<Icon name="edit" size="small" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="project-inline-action danger"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
removeWorktree(item)
|
||||
}}
|
||||
disabled={!!saving()}
|
||||
title={`Delete ${displayLabel(item)}`}
|
||||
aria-label={`Delete ${displayLabel(item)}`}
|
||||
>
|
||||
<Icon name="trash" size="small" />
|
||||
</button>
|
||||
<Show when={item.managed}>
|
||||
<button
|
||||
type="button"
|
||||
class="project-inline-action danger"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
removeWorktree(item)
|
||||
}}
|
||||
disabled={!!saving()}
|
||||
title={`Delete ${displayLabel(item)}`}
|
||||
aria-label={`Delete ${displayLabel(item)}`}
|
||||
>
|
||||
<Icon name="trash" size="small" />
|
||||
</button>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
@@ -913,7 +930,7 @@ export function ProjectConsoleRoute() {
|
||||
<code class="project-info-path" title={current()?.dir}>
|
||||
{current()?.dir ?? snap()?.project.worktree ?? project()}
|
||||
</code>
|
||||
<Show when={current()?.kind === "worktree"}>
|
||||
<Show when={canManage(current())}>
|
||||
<div class="project-info-actions">
|
||||
<Button variant="secondary" size="small" onClick={resetSelected} disabled={!!saving()}>
|
||||
Reset
|
||||
|
||||
@@ -136,6 +136,12 @@ Imported work stays associated with its branch or worktree and can be continued
|
||||
- Use session history to reopen local sessions or preview cloud sessions
|
||||
- Continue a cloud session locally from Agent Manager using the same extension sign-in and provider settings
|
||||
|
||||
### Renaming Worktrees
|
||||
|
||||
Double-click a worktree name to edit its label inline. You can also right-click the worktree and choose **Rename**. Press `Enter` or click outside the field to save, or press `Escape` to cancel.
|
||||
|
||||
Renaming a worktree changes only the label shown in Agent Manager. It does not rename the underlying git branch.
|
||||
|
||||
## Starting Sessions From Chat
|
||||
|
||||
Kilo can start Agent Manager sessions from chat with the `agent_manager` tool. It is available by default only in the VS Code extension because Agent Manager is an extension feature.
|
||||
@@ -168,7 +174,7 @@ Sections let you group worktrees into collapsible, color-coded folders in the si
|
||||
|
||||
Multi-version worktrees (created via Multi-Version Mode) are moved together — assigning one version to a section moves all versions in the group.
|
||||
|
||||
### Renaming
|
||||
### Renaming Sections
|
||||
|
||||
Right-click the section header and select **Rename Section**. An inline text field appears — type the new name and press `Enter` to confirm or `Escape` to cancel.
|
||||
|
||||
|
||||
@@ -82,6 +82,12 @@ Run `/export` in chat, or open a local session's **History** context menu and ch
|
||||
|
||||
Kilo builds the export from the complete local session history, not only the messages currently loaded in the chat view.
|
||||
|
||||
**Renaming sessions:**
|
||||
|
||||
Double-click the current session title at the top of the chat to edit it inline. Press `Enter` or click outside the field to save, or press `Escape` to cancel.
|
||||
|
||||
You can also rename local sessions from **History** using the edit button or the session's context menu.
|
||||
|
||||
{% /tab %}
|
||||
{% tab label="CLI" %}
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ Operations are queued to prevent concurrent Git operations that might corrupt re
|
||||
|
||||
## Git Installation
|
||||
|
||||
Checkpoints require Git to be installed on your system.
|
||||
Checkpoints require Git to be installed on your system. If Git is unavailable or the workspace is not a Git repository, Kilo skips checkpoints automatically; you do not need to disable them manually.
|
||||
|
||||
### macOS
|
||||
|
||||
|
||||
@@ -650,7 +650,7 @@ Positionals:
|
||||
Options:
|
||||
--help Show help [boolean]
|
||||
--version Show version number [boolean]
|
||||
-m, --method installation method to use [string] [choices: "curl", "npm", "pnpm", "bun", "brew", "choco", "scoop"]
|
||||
-m, --method installation method to use [string] [choices: "curl", "npm", "yarn", "pnpm", "bun", "brew", "choco", "scoop"]
|
||||
```
|
||||
|
||||
## kilo uninstall
|
||||
|
||||
@@ -64,7 +64,7 @@ You can also edit the `indexing` section in `kilo.jsonc` directly:
|
||||
|---|---|---|
|
||||
| **OpenAI** | API key | Default model: `text-embedding-3-small`. `text-embedding-3-large` for higher accuracy. |
|
||||
| **Ollama** | Local base URL | No API costs. Runs fully offline. |
|
||||
| **OpenAI-Compatible** | Base URL + API key | For self-hosted or third-party OpenAI-compatible endpoints. |
|
||||
| **OpenAI-Compatible** | Base URL + optional API key | For self-hosted or third-party OpenAI-compatible endpoints, including unauthenticated local servers. |
|
||||
| **Gemini** | Google AI API key | Supports `gemini-embedding-001` and other Gemini embedding models. |
|
||||
| **Mistral** | API key from [La Plateforme](https://console.mistral.ai/api-keys/) | Use a standard Mistral API key. The Codestral-specific keys from the [Mistral autocomplete setup guide](/docs/code-with-ai/features/autocomplete/mistral-setup) are **not** interchangeable — those only work for completion. |
|
||||
| **Vercel AI Gateway** | API key | Routes requests through [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). |
|
||||
@@ -143,7 +143,7 @@ You can also edit the `indexing` section directly. This is the full shape of the
|
||||
|---|---|---|---|
|
||||
| **OpenAI** | `openai` | `{ apiKey }` | Default: `text-embedding-3-small`. |
|
||||
| **Ollama** | `ollama` | `{ baseUrl }` | No API costs. Runs fully offline. |
|
||||
| **OpenAI-Compatible** | `openai-compatible` | `{ baseUrl, apiKey }` | For self-hosted or third-party endpoints. |
|
||||
| **OpenAI-Compatible** | `openai-compatible` | `{ baseUrl, apiKey? }` | For self-hosted or third-party endpoints, including unauthenticated local servers. |
|
||||
| **Gemini** | `gemini` | `{ apiKey }` | Supports `gemini-embedding-001`. |
|
||||
| **Mistral** | `mistral` | `{ apiKey }` | Use a [La Plateforme](https://console.mistral.ai/api-keys/) key — the Codestral-specific keys from the [autocomplete setup guide](/docs/code-with-ai/features/autocomplete/mistral-setup) don't work for embeddings. |
|
||||
| **Vercel AI Gateway** | `vercel-ai-gateway` | `{ apiKey }` | Routes through [Vercel AI Gateway](https://vercel.com/docs/ai-gateway). |
|
||||
|
||||
@@ -65,7 +65,7 @@ By default, autocomplete routes through the Kilo provider and uses credits. If y
|
||||
|
||||
### How to Get It Free
|
||||
|
||||
Add your own Mistral AI (Codestral) API key via **BYOK (Bring Your Own Key)** on the Kilo Gateway. Mistral offers a free tier for Codestral. When you configure a BYOK key, autocomplete requests use your key directly — at no cost on your Kilo balance.
|
||||
Add your own Mistral AI API key via **BYOK (Bring Your Own Key)** on the Kilo Gateway. Mistral offers a free tier for Codestral. When you configure a BYOK key, autocomplete requests use your key directly — at no cost on your Kilo balance.
|
||||
|
||||
See the [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup) for step-by-step instructions.
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
<!-- packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts -->
|
||||
- <https://app.kilo.ai/profile>
|
||||
<!-- packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx -->
|
||||
- <https://app.kilo.ai/tui.json>
|
||||
<!-- packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts -->
|
||||
- <https://app.kilo.ai/usage>
|
||||
<!-- packages/opencode/src/kilocode/components/dialog-kilo-profile.tsx -->
|
||||
- <https://auth.x.ai>
|
||||
@@ -116,15 +118,25 @@
|
||||
- <https://kilo.ai/docs>
|
||||
<!-- packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts -->
|
||||
<!-- packages/opencode/src/kilocode/cli/cmd/tui/app.tsx -->
|
||||
- <https://kilo.ai/docs/ai-providers/cloudflare>
|
||||
<!-- packages/opencode/src/cli/cmd/providers.ts -->
|
||||
- <https://kilo.ai/docs/ai-providers#custom-provider>
|
||||
<!-- packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx -->
|
||||
- <https://kilo.ai/docs/code-with-ai/platforms/vscode/whats-new>
|
||||
<!-- packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx -->
|
||||
- <https://kilo.ai/docs/customize/custom-subagents>
|
||||
<!-- packages/opencode/src/config/config.ts -->
|
||||
- <https://kilo.ai/docs/customize/workflows>
|
||||
<!-- packages/opencode/src/config/config.ts -->
|
||||
- <https://kilo.ai/gateway>
|
||||
<!-- packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx -->
|
||||
- <https://kilo.ai/install>
|
||||
<!-- packages/opencode/src/kilocode/installation/index.ts -->
|
||||
- <https://kilo.ai/kiloclaw>
|
||||
<!-- packages/kilo-vscode/webview-ui/kiloclaw/components/SetupView.tsx -->
|
||||
<!-- packages/opencode/src/kilocode/components/dialog-claw-setup.tsx -->
|
||||
- <https://kilo.ai/pricing>
|
||||
<!-- packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx -->
|
||||
- <https://kilo.ai/support>
|
||||
<!-- packages/kilo-vscode/webview-ui/src/components/chat/FeedbackDialog.tsx -->
|
||||
<!-- packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx -->
|
||||
@@ -133,20 +145,6 @@
|
||||
- <https://opencode.ai/>
|
||||
<!-- packages/opencode/src/cli/cmd/generate.ts -->
|
||||
<!-- packages/opencode/src/kilocode/server/httpapi/public.ts -->
|
||||
- <https://opencode.ai/auth>
|
||||
<!-- packages/opencode/src/cli/cmd/providers.ts -->
|
||||
- <https://opencode.ai/docs/agents>
|
||||
<!-- packages/opencode/src/config/config.ts -->
|
||||
- <https://opencode.ai/docs/commands>
|
||||
<!-- packages/opencode/src/config/config.ts -->
|
||||
- <https://opencode.ai/docs/providers/#cloudflare-ai-gateway>
|
||||
<!-- packages/opencode/src/cli/cmd/providers.ts -->
|
||||
- <https://opencode.ai/go>
|
||||
<!-- packages/opencode/src/cli/cmd/tui/component/dialog-retry-action.tsx -->
|
||||
- <https://opencode.ai/install>
|
||||
<!-- packages/opencode/src/installation/index.ts -->
|
||||
- <https://opencode.ai/tui.json>
|
||||
<!-- packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts -->
|
||||
- <https://opencode.ai/zen>
|
||||
<!-- packages/kilo-vscode/webview-ui/src/i18n/en.ts -->
|
||||
- <https://openrouter.ai/docs/cookbook/administration/usage-accounting>
|
||||
|
||||
@@ -57,7 +57,7 @@ export class CodeIndexConfigManager {
|
||||
private kiloOptions?: { apiKey: string; baseUrl?: string; organizationId?: string }
|
||||
private openAiOptions?: { apiKey: string }
|
||||
private ollamaOptions?: { baseUrl: string; modelId?: string }
|
||||
private openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
|
||||
private openAiCompatibleOptions?: { baseUrl: string; apiKey?: string }
|
||||
private geminiOptions?: { apiKey: string }
|
||||
private mistralOptions?: { apiKey: string }
|
||||
private vercelAiGatewayOptions?: { apiKey: string }
|
||||
@@ -112,10 +112,9 @@ export class CodeIndexConfigManager {
|
||||
this.openAiOptions = input.openAiKey ? { apiKey: input.openAiKey } : undefined
|
||||
const url = input.ollamaBaseUrl ?? (input.embedderProvider === "ollama" ? "http://localhost:11434" : undefined)
|
||||
this.ollamaOptions = url ? { baseUrl: url, modelId: input.modelId } : undefined
|
||||
this.openAiCompatibleOptions =
|
||||
input.openAiCompatibleBaseUrl && input.openAiCompatibleApiKey
|
||||
? { baseUrl: input.openAiCompatibleBaseUrl, apiKey: input.openAiCompatibleApiKey }
|
||||
: undefined
|
||||
this.openAiCompatibleOptions = input.openAiCompatibleBaseUrl
|
||||
? { baseUrl: input.openAiCompatibleBaseUrl, apiKey: input.openAiCompatibleApiKey?.trim() || undefined }
|
||||
: undefined
|
||||
this.geminiOptions = input.geminiApiKey ? { apiKey: input.geminiApiKey } : undefined
|
||||
this.mistralOptions = input.mistralApiKey ? { apiKey: input.mistralApiKey } : undefined
|
||||
this.vercelAiGatewayOptions = input.vercelAiGatewayApiKey ? { apiKey: input.vercelAiGatewayApiKey } : undefined
|
||||
@@ -168,8 +167,7 @@ export class CodeIndexConfigManager {
|
||||
return !!(this.kiloOptions?.apiKey && this.modelId && this.currentModelDimension && hasStore)
|
||||
if (provider === "openai") return !!(this.openAiOptions?.apiKey && hasStore)
|
||||
if (provider === "ollama") return !!(this.ollamaOptions?.baseUrl && hasStore)
|
||||
if (provider === "openai-compatible")
|
||||
return !!(this.openAiCompatibleOptions?.baseUrl && this.openAiCompatibleOptions?.apiKey && hasStore)
|
||||
if (provider === "openai-compatible") return !!(this.openAiCompatibleOptions?.baseUrl && hasStore)
|
||||
if (provider === "gemini") return !!(this.geminiOptions?.apiKey && hasStore)
|
||||
if (provider === "mistral") return !!(this.mistralOptions?.apiKey && hasStore)
|
||||
if (provider === "vercel-ai-gateway") return !!(this.vercelAiGatewayOptions?.apiKey && hasStore)
|
||||
|
||||
@@ -42,7 +42,7 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
|
||||
private embeddingsClient: OpenAI
|
||||
private readonly defaultModelId: string
|
||||
private readonly baseUrl: string
|
||||
private readonly apiKey: string
|
||||
private readonly apiKey?: string
|
||||
private readonly isFullUrl: boolean
|
||||
private readonly maxItemTokens: number
|
||||
private readonly headers: Record<string, string>
|
||||
@@ -61,13 +61,13 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
|
||||
/**
|
||||
* Creates a new OpenAI Compatible embedder
|
||||
* @param baseUrl The base URL for the OpenAI-compatible API endpoint
|
||||
* @param apiKey The API key for authentication
|
||||
* @param apiKey Optional API key for authentication
|
||||
* @param modelId Optional model identifier (defaults to "text-embedding-3-small")
|
||||
* @param maxItemTokens Optional maximum tokens per item (defaults to MAX_ITEM_TOKENS)
|
||||
*/
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
apiKey?: string,
|
||||
modelId?: string,
|
||||
maxItemTokens?: number,
|
||||
options: OpenAICompatibleOptions = {},
|
||||
@@ -75,22 +75,24 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
|
||||
if (!baseUrl) {
|
||||
throw new Error("Base URL is required for OpenAI-compatible embedder")
|
||||
}
|
||||
if (!apiKey) {
|
||||
throw new Error("API key is required for OpenAI-compatible embedder")
|
||||
}
|
||||
|
||||
this.baseUrl = baseUrl
|
||||
this.apiKey = apiKey
|
||||
this.apiKey = apiKey?.trim() || undefined
|
||||
|
||||
try {
|
||||
this.embeddingsClient = new OpenAI({
|
||||
baseURL: baseUrl,
|
||||
apiKey: apiKey,
|
||||
defaultHeaders: options.headers,
|
||||
})
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
const defaults = new Headers(options.headers)
|
||||
this.embeddingsClient = new OpenAI({
|
||||
baseURL: baseUrl,
|
||||
apiKey: this.apiKey ?? "EMPTY",
|
||||
defaultHeaders: options.headers,
|
||||
fetch: this.apiKey
|
||||
? undefined
|
||||
: async (input, init) => {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (!defaults.has("authorization")) headers.delete("authorization")
|
||||
if (!defaults.has("api-key")) headers.delete("api-key")
|
||||
return globalThis.fetch(input, { ...init, headers: Object.fromEntries(headers) })
|
||||
},
|
||||
})
|
||||
|
||||
this.defaultModelId = modelId || getDefaultModelId("openai-compatible")
|
||||
// Cache the URL type check for performance
|
||||
@@ -213,10 +215,12 @@ export class OpenAICompatibleEmbedder implements IEmbedder {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...this.headers,
|
||||
// Azure OpenAI uses 'api-key' header, while OpenAI uses 'Authorization'
|
||||
// We'll try 'api-key' first for Azure compatibility
|
||||
"api-key": this.apiKey,
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
...(this.apiKey
|
||||
? {
|
||||
"api-key": this.apiKey,
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input: batchTexts,
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface CodeIndexConfig {
|
||||
kiloOptions?: { apiKey: string; baseUrl?: string; organizationId?: string }
|
||||
openAiOptions?: { apiKey: string }
|
||||
ollamaOptions?: { baseUrl: string; modelId?: string }
|
||||
openAiCompatibleOptions?: { baseUrl: string; apiKey: string }
|
||||
openAiCompatibleOptions?: { baseUrl: string; apiKey?: string }
|
||||
geminiOptions?: { apiKey: string }
|
||||
mistralOptions?: { apiKey: string }
|
||||
vercelAiGatewayOptions?: { apiKey: string }
|
||||
|
||||
@@ -85,8 +85,7 @@ export class CodeIndexServiceFactory {
|
||||
return new CodeIndexOllamaEmbedder(config.ollamaOptions.baseUrl, config.modelId, config.modelDimension)
|
||||
}
|
||||
if (provider === "openai-compatible") {
|
||||
if (!config.openAiCompatibleOptions?.baseUrl || !config.openAiCompatibleOptions?.apiKey)
|
||||
throw new Error("OpenAI-compatible base URL and API key are required.")
|
||||
if (!config.openAiCompatibleOptions?.baseUrl) throw new Error("OpenAI-compatible base URL is required.")
|
||||
return new OpenAICompatibleEmbedder(
|
||||
config.openAiCompatibleOptions.baseUrl,
|
||||
config.openAiCompatibleOptions.apiKey,
|
||||
|
||||
@@ -26,6 +26,34 @@ describe("CodeIndexConfigManager", () => {
|
||||
expect(cfg.getConfig().ollamaOptions?.baseUrl).toBe("http://localhost:11434")
|
||||
})
|
||||
|
||||
test("configures an OpenAI-compatible endpoint without an API key", () => {
|
||||
const cfg = new CodeIndexConfigManager(
|
||||
createInput({
|
||||
embedderProvider: "openai-compatible",
|
||||
openAiKey: undefined,
|
||||
openAiCompatibleBaseUrl: "http://localhost:1234/v1",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(cfg.isFeatureConfigured).toBe(true)
|
||||
expect(cfg.getConfig().openAiCompatibleOptions).toEqual({
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKey: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test("requires a base URL for an OpenAI-compatible endpoint", () => {
|
||||
const cfg = new CodeIndexConfigManager(
|
||||
createInput({
|
||||
embedderProvider: "openai-compatible",
|
||||
openAiKey: undefined,
|
||||
openAiCompatibleApiKey: "sk-test",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(cfg.isFeatureConfigured).toBe(false)
|
||||
})
|
||||
|
||||
test("defaults vector store to LanceDB when omitted", () => {
|
||||
const cfg = new CodeIndexConfigManager(createInput({ vectorStoreProvider: undefined }))
|
||||
|
||||
@@ -142,6 +170,19 @@ describe("CodeIndexConfigManager", () => {
|
||||
expect(result.requiresRestart).toBe(true)
|
||||
})
|
||||
|
||||
test("requires restart when OpenAI-compatible auth is added or removed", () => {
|
||||
const input = createInput({
|
||||
embedderProvider: "openai-compatible",
|
||||
openAiKey: undefined,
|
||||
openAiCompatibleBaseUrl: "http://localhost:1234/v1",
|
||||
})
|
||||
const cfg = new CodeIndexConfigManager(input)
|
||||
|
||||
expect(cfg.loadConfiguration({ ...input, openAiCompatibleApiKey: "sk-test" }).requiresRestart).toBe(true)
|
||||
expect(cfg.loadConfiguration(input).requiresRestart).toBe(true)
|
||||
expect(cfg.loadConfiguration(input).requiresRestart).toBe(false)
|
||||
})
|
||||
|
||||
test("requires restart when Kilo auth changes", () => {
|
||||
const cfg = new CodeIndexConfigManager(
|
||||
createInput({
|
||||
|
||||
@@ -63,10 +63,10 @@ describe("OpenAICompatibleEmbedder", () => {
|
||||
)
|
||||
})
|
||||
|
||||
test("should throw error when apiKey is missing", () => {
|
||||
expect(() => new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId)).toThrow(
|
||||
"API key is required for OpenAI-compatible embedder",
|
||||
)
|
||||
test("should create embedder without an API key", () => {
|
||||
embedder = new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId)
|
||||
|
||||
expect(embedder).toBeDefined()
|
||||
})
|
||||
|
||||
test("should throw error when both baseUrl and apiKey are missing", () => {
|
||||
@@ -649,6 +649,69 @@ describe("OpenAICompatibleEmbedder", () => {
|
||||
expect(baseResult.embeddings[0]).toEqual([0.4, 0.5, 0.6])
|
||||
})
|
||||
|
||||
test("should omit auth headers for a keyless full endpoint", async () => {
|
||||
const embedder = new OpenAICompatibleEmbedder(azureUrl, undefined, testModelId)
|
||||
const base64String = createBase64Embedding([0.1, 0.2, 0.3])
|
||||
mockFetch.mockResolvedValue(
|
||||
createMockResponse({
|
||||
data: [{ embedding: base64String }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}) as any,
|
||||
)
|
||||
|
||||
await embedder.createEmbeddings(["test"])
|
||||
|
||||
const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get("authorization")).toBeNull()
|
||||
expect(headers.get("api-key")).toBeNull()
|
||||
})
|
||||
|
||||
test("should preserve custom headers for a keyless full endpoint", async () => {
|
||||
const embedder = new OpenAICompatibleEmbedder(azureUrl, undefined, testModelId, undefined, {
|
||||
headers: { Authorization: "Custom token", "x-fixture": "present" },
|
||||
})
|
||||
const base64String = createBase64Embedding([0.1, 0.2, 0.3])
|
||||
mockFetch.mockResolvedValue(
|
||||
createMockResponse({
|
||||
data: [{ embedding: base64String }],
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
}) as any,
|
||||
)
|
||||
|
||||
await embedder.createEmbeddings(["test"])
|
||||
|
||||
const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(headers.get("authorization")).toBe("Custom token")
|
||||
expect(headers.get("x-fixture")).toBe("present")
|
||||
})
|
||||
|
||||
test("should omit generated SDK auth headers when no key is configured", async () => {
|
||||
let request: ((input: string | URL | Request, init?: RequestInit) => Promise<Response>) | undefined
|
||||
setOpenAIConstructorHook((config) => {
|
||||
request = config.fetch
|
||||
})
|
||||
new OpenAICompatibleEmbedder(baseUrl, undefined, testModelId)
|
||||
mockFetch.mockResolvedValue(new Response())
|
||||
|
||||
if (!request) throw new Error("Missing OpenAI-compatible fetch adapter")
|
||||
await request("https://api.example.com/v1/embeddings", {
|
||||
headers: {
|
||||
Authorization: "Bearer EMPTY",
|
||||
"api-key": "EMPTY",
|
||||
"x-fixture": "present",
|
||||
},
|
||||
})
|
||||
|
||||
const init = mockFetch.mock.calls[0]?.[1] as RequestInit | undefined
|
||||
const headers = new Headers(init?.headers)
|
||||
expect(init?.headers).not.toBeInstanceOf(Headers)
|
||||
expect(headers.get("authorization")).toBeNull()
|
||||
expect(headers.get("api-key")).toBeNull()
|
||||
expect(headers.get("x-fixture")).toBe("present")
|
||||
})
|
||||
|
||||
test.each([
|
||||
[401, "Authentication failed. Please check your API key."],
|
||||
[500, "Embedding request failed after 3 attempts"],
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CodeIndexManager } from "../../../src/indexing/manager"
|
||||
import type { IndexingConfigInput } from "../../../src/indexing/config-manager"
|
||||
import type { IndexingTelemetryEvent, IndexingTelemetryTrigger } from "../../../src/indexing/interfaces/telemetry"
|
||||
@@ -100,6 +103,73 @@ describe("CodeIndexManager", () => {
|
||||
expect(mgr.getCurrentStatus().message).toContain("not configured")
|
||||
})
|
||||
|
||||
test("initializes an unauthenticated OpenAI-compatible endpoint without auth headers", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "kilo-keyless-indexing-"))
|
||||
const workspace = join(root, "workspace")
|
||||
const cache = join(root, "cache")
|
||||
const requests: Array<{ authorization: string | null; apiKey: string | null }> = []
|
||||
await Bun.write(join(workspace, ".gitkeep"), "")
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
requests.push({
|
||||
authorization: req.headers.get("authorization"),
|
||||
apiKey: req.headers.get("api-key"),
|
||||
})
|
||||
return Response.json({
|
||||
object: "list",
|
||||
data: [{ object: "embedding", index: 0, embedding: [0.1, 0.2, 0.3] }],
|
||||
model: "fixture-model",
|
||||
usage: { prompt_tokens: 1, total_tokens: 1 },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const script = `
|
||||
import { CodeIndexManager } from "./src/indexing/manager.ts"
|
||||
import { normalizeIndexingStatus } from "./src/status.ts"
|
||||
const mgr = new CodeIndexManager(${JSON.stringify(workspace)}, ${JSON.stringify(cache)})
|
||||
try {
|
||||
await mgr.initialize({
|
||||
enabled: true,
|
||||
embedderProvider: "openai-compatible",
|
||||
vectorStoreProvider: "lancedb",
|
||||
modelId: "fixture-model",
|
||||
modelDimension: 3,
|
||||
openAiCompatibleBaseUrl: ${JSON.stringify(`http://127.0.0.1:${server.port}/v1`)},
|
||||
})
|
||||
if (!mgr.isInitialized) throw new Error("Manager did not initialize")
|
||||
if (normalizeIndexingStatus(mgr).state === "Disabled") throw new Error("Indexing remained disabled")
|
||||
} finally {
|
||||
await mgr.dispose()
|
||||
}
|
||||
`
|
||||
const child = Bun.spawn([process.execPath, "-e", script], {
|
||||
cwd: join(import.meta.dir, "../../.."),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true,
|
||||
})
|
||||
const gate = Promise.withResolvers<never>()
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill()
|
||||
gate.reject(new Error("Indexing subprocess timed out"))
|
||||
}, 10_000)
|
||||
const [exit, stderr] = await Promise.race([
|
||||
Promise.all([child.exited, new Response(child.stderr).text()]),
|
||||
gate.promise,
|
||||
]).finally(() => clearTimeout(timeout))
|
||||
if (exit !== 0) throw new Error(stderr)
|
||||
|
||||
expect(requests).toEqual([{ authorization: null, apiKey: null }])
|
||||
} finally {
|
||||
server.stop(true)
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("cancels active indexing when configuration is removed", async () => {
|
||||
const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache")
|
||||
let stop = 0
|
||||
|
||||
@@ -29,6 +29,16 @@ describe("CodeIndexServiceFactory", () => {
|
||||
setOpenAIConstructorHook(undefined)
|
||||
})
|
||||
|
||||
test("creates an OpenAI-compatible embedder without an API key", () => {
|
||||
const factory = createFactory({
|
||||
embedderProvider: "openai-compatible",
|
||||
openAiKey: undefined,
|
||||
openAiCompatibleBaseUrl: "http://localhost:1234/v1",
|
||||
})
|
||||
|
||||
expect(factory.createEmbedder().embedderInfo).toEqual({ name: "openai-compatible" })
|
||||
})
|
||||
|
||||
test("uses default LanceDB directory when config is unset", () => {
|
||||
const factory = createFactory({ vectorStoreProvider: "lancedb", lancedbVectorStoreDirectory: undefined })
|
||||
|
||||
|
||||
@@ -32,6 +32,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [7.0.1-rc.9] - 2026-06-15
|
||||
|
||||
### Added
|
||||
|
||||
- Add prompt enhancement support.
|
||||
- Support prompt and transcript attachments, including paste, drop, preview, and editor tab opening flows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improve shell and markdown rendering, including code block spacing, terminal block retention, shell command highlighting, and session layout polish.
|
||||
|
||||
## [7.0.1-rc.8] - 2026-06-09
|
||||
|
||||
### Added
|
||||
|
||||
+13
@@ -110,6 +110,7 @@ class KiloBackendAppService private constructor(
|
||||
private var watcher: Job? = null
|
||||
private var eventWatcher: Job? = null
|
||||
private var loader: Job? = null
|
||||
private var closed = false
|
||||
private val loadLock = Any()
|
||||
|
||||
private val _appState = MutableStateFlow<KiloAppState>(KiloAppState.Disconnected)
|
||||
@@ -159,6 +160,12 @@ class KiloBackendAppService private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun shutdownForUnload() {
|
||||
mutex.withLock {
|
||||
shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun retry() {
|
||||
mutex.withLock {
|
||||
when (val current = _appState.value) {
|
||||
@@ -806,6 +813,12 @@ class KiloBackendAppService private constructor(
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
shutdown()
|
||||
}
|
||||
|
||||
private fun shutdown() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
watcher?.cancel()
|
||||
watcher = null
|
||||
clear()
|
||||
|
||||
+31
-9
@@ -10,6 +10,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import ai.kilocode.rpc.dto.QuestionRequestDto
|
||||
@@ -79,6 +80,7 @@ class KiloBackendChatManager(
|
||||
private var client: OkHttpClient? = null
|
||||
private var base: String? = null
|
||||
private var watcher: Job? = null
|
||||
private var normalizer = KiloCliDataParser.ChatEventNormalizer()
|
||||
|
||||
fun start(http: OkHttpClient, port: Int, sse: SharedFlow<SseEvent>) {
|
||||
client = http
|
||||
@@ -87,16 +89,18 @@ class KiloBackendChatManager(
|
||||
watcher = cs.launch {
|
||||
sse.collect { event ->
|
||||
if (event.type in CHAT_EVENTS) {
|
||||
val parsed = KiloCliDataParser.parseChatEvent(event.type, event.data)
|
||||
if (parsed != null) {
|
||||
log.debug { ChatLogSummary.event(parsed) }
|
||||
if (parsed is ChatEventDto.SessionStatusChanged && parsed.status.type != "busy") {
|
||||
log.info(
|
||||
"${ChatLogSummary.sid(parsed.sessionID)} kind=status route=chat-events emit=true " +
|
||||
"${ChatLogSummary.status(parsed.status)} bytes=${event.data.length}",
|
||||
)
|
||||
val events = normalizer.parse(event.type, event.data)
|
||||
if (events != null) {
|
||||
for (parsed in events) {
|
||||
log.debug { ChatLogSummary.event(parsed) }
|
||||
if (parsed is ChatEventDto.SessionStatusChanged && parsed.status.type != "busy") {
|
||||
log.info(
|
||||
"${ChatLogSummary.sid(parsed.sessionID)} kind=status route=chat-events emit=true " +
|
||||
"${ChatLogSummary.status(parsed.status)} bytes=${event.data.length}",
|
||||
)
|
||||
}
|
||||
_events.emit(parsed)
|
||||
}
|
||||
_events.emit(parsed)
|
||||
} else {
|
||||
log.warn("SSE parse returned null for type=${event.type} bytes=${event.data.length}")
|
||||
}
|
||||
@@ -111,6 +115,7 @@ class KiloBackendChatManager(
|
||||
watcher = null
|
||||
client = null
|
||||
base = null
|
||||
normalizer = KiloCliDataParser.ChatEventNormalizer()
|
||||
log.info("Chat manager stopped")
|
||||
}
|
||||
|
||||
@@ -251,6 +256,17 @@ class KiloBackendChatManager(
|
||||
}
|
||||
}
|
||||
|
||||
fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? {
|
||||
return messages(id, dir)
|
||||
.firstOrNull { it.info.id == message }
|
||||
?.parts
|
||||
?.firstOrNull {
|
||||
if (it.type != "file") return@firstOrNull false
|
||||
if (!key.isNullOrBlank()) attachmentKey(it.id, it.filename.orEmpty(), it.url.orEmpty()) == key
|
||||
else it.id == part
|
||||
}
|
||||
}
|
||||
|
||||
// ------ config update ------
|
||||
|
||||
fun updateConfig(dir: String, update: ConfigUpdateDto) {
|
||||
@@ -370,4 +386,10 @@ class KiloBackendChatManager(
|
||||
|
||||
private fun encode(value: String): String =
|
||||
java.net.URLEncoder.encode(value, "UTF-8")
|
||||
|
||||
private fun attachmentKey(part: String, name: String, url: String): String {
|
||||
val value = listOf(part, name, url).joinToString("\u0000")
|
||||
val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
|
||||
return bytes.take(16).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
+40
-11
@@ -40,7 +40,10 @@ class KiloBackendCliManager(
|
||||
|
||||
@Volatile
|
||||
private var process: Process? = null
|
||||
@Volatile
|
||||
private var closing: Process? = null
|
||||
private var hook: Thread? = null
|
||||
private var stderr: Thread? = null
|
||||
|
||||
@Volatile
|
||||
override var forceExtract = false
|
||||
@@ -59,8 +62,7 @@ class KiloBackendCliManager(
|
||||
process?.let { proc ->
|
||||
log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})")
|
||||
process = null
|
||||
uninstall()
|
||||
kill(proc, "startup failure cleanup")
|
||||
cleanup(proc, "startup failure cleanup")
|
||||
}
|
||||
CliServer.State.Error(
|
||||
message = e.message ?: "Unknown error",
|
||||
@@ -73,13 +75,13 @@ class KiloBackendCliManager(
|
||||
if (process != proc) return
|
||||
process = null
|
||||
uninstall()
|
||||
stderr = null
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
val proc = process ?: return
|
||||
process = null
|
||||
uninstall()
|
||||
kill(proc, "stop()")
|
||||
cleanup(proc, "stop()")
|
||||
}
|
||||
|
||||
private fun extractCli(): File {
|
||||
@@ -158,14 +160,19 @@ class KiloBackendCliManager(
|
||||
|
||||
val stderr = StringBuilder()
|
||||
|
||||
Thread({
|
||||
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
|
||||
reader.lineSequence().forEach { line ->
|
||||
log.warn("CLI stderr: $line")
|
||||
synchronized(stderr) { stderr.appendLine(line) }
|
||||
val err = Thread({
|
||||
runCatching {
|
||||
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
|
||||
reader.lineSequence().forEach { line ->
|
||||
log.warn("CLI stderr: $line")
|
||||
synchronized(stderr) { stderr.appendLine(line) }
|
||||
}
|
||||
}
|
||||
}.onFailure { err ->
|
||||
if (proc.isAlive && closing !== proc) log.warn("CLI stderr reader failed", err)
|
||||
}
|
||||
}, "kilo-cli-stderr").apply { isDaemon = true; start() }
|
||||
this@KiloBackendCliManager.stderr = err
|
||||
|
||||
BufferedReader(InputStreamReader(proc.inputStream)).use { reader ->
|
||||
for (line in reader.lineSequence()) {
|
||||
@@ -185,6 +192,7 @@ class KiloBackendCliManager(
|
||||
val details = synchronized(stderr) { stderr.toString().trim() }
|
||||
process = null
|
||||
uninstall()
|
||||
this@KiloBackendCliManager.stderr = null
|
||||
log.warn("CLI process exited with code $code before announcing a port: $details")
|
||||
CliServer.State.Error(
|
||||
message = "CLI process exited with code $code before announcing a port",
|
||||
@@ -195,8 +203,23 @@ class KiloBackendCliManager(
|
||||
override fun dispose() {
|
||||
val proc = process ?: return
|
||||
process = null
|
||||
uninstall()
|
||||
kill(proc, "Disposing")
|
||||
cleanup(proc, "Disposing")
|
||||
}
|
||||
|
||||
private fun cleanup(proc: Process, source: String) {
|
||||
closing = proc
|
||||
try {
|
||||
uninstall()
|
||||
close(proc)
|
||||
kill(proc, source)
|
||||
val thread = stderr
|
||||
stderr = null
|
||||
if (thread != null && thread != Thread.currentThread()) {
|
||||
thread.join(TimeUnit.SECONDS.toMillis(1))
|
||||
}
|
||||
} finally {
|
||||
closing = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun install(proc: Process) {
|
||||
@@ -237,6 +260,12 @@ class KiloBackendCliManager(
|
||||
private fun children(proc: Process): List<ProcessHandle> =
|
||||
proc.toHandle().descendants().toList().asReversed()
|
||||
|
||||
private fun close(proc: Process) {
|
||||
runCatching { proc.errorStream.close() }.onFailure { log.info("CLI stderr stream close skipped: ${it.message}") }
|
||||
runCatching { proc.inputStream.close() }.onFailure { log.info("CLI stdout stream close skipped: ${it.message}") }
|
||||
runCatching { proc.outputStream.close() }.onFailure { log.info("CLI stdin stream close skipped: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun platform(): String {
|
||||
val os = when {
|
||||
SystemInfo.isMac -> "darwin"
|
||||
|
||||
+135
-3
@@ -24,6 +24,7 @@ import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.PartTimeDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.PromptPartDto
|
||||
import ai.kilocode.rpc.dto.QuestionInfoDto
|
||||
import ai.kilocode.rpc.dto.QuestionOptionDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
@@ -68,6 +69,8 @@ object KiloCliDataParser {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val pretty = Json { ignoreUnknownKeys = true; prettyPrint = true }
|
||||
private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""")
|
||||
private val READ_TOOL_LINE = Regex("^\\s*Called\\s+the\\s+Read\\s+tool\\s+with\\s+the\\s+following\\s+input:", RegexOption.IGNORE_CASE)
|
||||
private val READ_TOOL_PATH = Regex("\"(?:filePath|path)\"\\s*:")
|
||||
private val FIELD_RE = ConcurrentHashMap<String, Regex>()
|
||||
|
||||
// ================================================================
|
||||
@@ -233,6 +236,88 @@ object KiloCliDataParser {
|
||||
}
|
||||
}
|
||||
|
||||
class ChatEventNormalizer {
|
||||
private val roles = mutableMapOf<String, String>()
|
||||
private val raw = mutableMapOf<Key, String>()
|
||||
private val text = mutableMapOf<Key, String>()
|
||||
|
||||
fun parse(type: String, data: String): List<ChatEventDto>? {
|
||||
val event = parseChatEvent(type, data) ?: return null
|
||||
return when (event) {
|
||||
is ChatEventDto.MessageUpdated -> {
|
||||
roles[event.info.id] = event.info.role
|
||||
listOf(event)
|
||||
}
|
||||
|
||||
is ChatEventDto.MessageRemoved -> {
|
||||
roles.remove(event.messageID)
|
||||
clear(event.messageID)
|
||||
listOf(event)
|
||||
}
|
||||
|
||||
is ChatEventDto.PartUpdated -> listOf(update(event))
|
||||
|
||||
is ChatEventDto.PartDelta -> delta(event)
|
||||
|
||||
is ChatEventDto.PartRemoved -> {
|
||||
val key = Key(event.messageID, event.partID)
|
||||
raw.remove(key)
|
||||
text.remove(key)
|
||||
listOf(event)
|
||||
}
|
||||
|
||||
else -> listOf(event)
|
||||
}
|
||||
}
|
||||
|
||||
private fun update(event: ChatEventDto.PartUpdated): ChatEventDto {
|
||||
val part = event.part
|
||||
val key = Key(part.messageID, part.id)
|
||||
if (roles[part.messageID] != "user" || part.type != "text") {
|
||||
raw.remove(key)
|
||||
text.remove(key)
|
||||
return event
|
||||
}
|
||||
|
||||
val value = part.text.orEmpty()
|
||||
val clean = sanitizeUserPromptText(value)
|
||||
raw[key] = value
|
||||
text[key] = clean
|
||||
return event.copy(part = part.copy(text = clean))
|
||||
}
|
||||
|
||||
private fun delta(event: ChatEventDto.PartDelta): List<ChatEventDto> {
|
||||
if (event.field != "text" || roles[event.messageID] != "user") return listOf(event)
|
||||
|
||||
val key = Key(event.messageID, event.partID)
|
||||
val prev = text[key].orEmpty()
|
||||
val next = raw[key].orEmpty() + event.delta
|
||||
val clean = sanitizeUserPromptText(next)
|
||||
raw[key] = next
|
||||
text[key] = clean
|
||||
|
||||
if (clean == prev) return emptyList()
|
||||
if (clean.startsWith(prev)) return listOf(event.copy(delta = clean.removePrefix(prev)))
|
||||
return listOf(ChatEventDto.PartUpdated(
|
||||
sessionID = event.sessionID,
|
||||
part = PartDto(
|
||||
id = event.partID,
|
||||
sessionID = event.sessionID,
|
||||
messageID = event.messageID,
|
||||
type = "text",
|
||||
text = clean,
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
private fun clear(id: String) {
|
||||
raw.keys.filter { it.messageID == id }.forEach(raw::remove)
|
||||
text.keys.filter { it.messageID == id }.forEach(text::remove)
|
||||
}
|
||||
|
||||
private data class Key(val messageID: String, val partID: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SSE `session.status` event into a (sessionID, [SessionStatusDto]) pair.
|
||||
* Returns null if the required fields are missing.
|
||||
@@ -267,14 +352,36 @@ object KiloCliDataParser {
|
||||
return arr.mapNotNull { elem ->
|
||||
val obj = elem.jsonObject
|
||||
val info = obj["info"]?.jsonObject ?: return@mapNotNull null
|
||||
val msg = parseMessage(info)
|
||||
val parts = obj["parts"]?.jsonArray ?: JsonArray(emptyList())
|
||||
MessageWithPartsDto(
|
||||
info = parseMessage(info),
|
||||
parts = parts.map { parsePart(it.jsonObject) },
|
||||
info = msg,
|
||||
parts = parts.map { sanitizePart(parsePart(it.jsonObject), msg.role) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun sanitizeUserPromptText(text: String): String {
|
||||
val lines = text.lines()
|
||||
if (lines.none(::readPayload)) return text
|
||||
|
||||
val out = mutableListOf<String>()
|
||||
var gap = false
|
||||
for (line in lines) {
|
||||
if (readPayload(line)) {
|
||||
gap = true
|
||||
continue
|
||||
}
|
||||
if (line.isBlank() && out.lastOrNull()?.isBlank() == true && gap) {
|
||||
gap = false
|
||||
continue
|
||||
}
|
||||
out.add(line)
|
||||
if (line.isNotBlank()) gap = false
|
||||
}
|
||||
return out.joinToString("\n")
|
||||
}
|
||||
|
||||
fun parseCloudSessions(raw: String): CloudSessionListDto {
|
||||
val obj = tryParseObject(raw) ?: return CloudSessionListDto(emptyList())
|
||||
val items = obj["cliSessions"]?.jsonArray ?: JsonArray(emptyList())
|
||||
@@ -377,7 +484,7 @@ object KiloCliDataParser {
|
||||
*/
|
||||
fun buildPromptJson(prompt: PromptDto): String {
|
||||
val parts = prompt.parts.joinToString(",") { part ->
|
||||
"""{"type":"${part.type}","text":${escape(part.text)}}"""
|
||||
buildPromptPartJson(part)
|
||||
}
|
||||
val sb = StringBuilder()
|
||||
sb.append("""{"parts":[$parts]""")
|
||||
@@ -406,6 +513,18 @@ object KiloCliDataParser {
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private fun buildPromptPartJson(part: PromptPartDto): String {
|
||||
val fields = mutableListOf("\"type\":${escape(part.type)}")
|
||||
if (part.type == "file") {
|
||||
part.mime?.let { fields += "\"mime\":${escape(it)}" }
|
||||
part.url?.let { fields += "\"url\":${escape(it)}" }
|
||||
part.filename?.let { fields += "\"filename\":${escape(it)}" }
|
||||
return "{${fields.joinToString(",")}}"
|
||||
}
|
||||
fields += "\"text\":${escape(part.text.orEmpty())}"
|
||||
return "{${fields.joinToString(",")}}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSON body for `POST /session/{id}/summarize`.
|
||||
*/
|
||||
@@ -509,6 +628,9 @@ object KiloCliDataParser {
|
||||
messageID = obj.str("messageID") ?: "",
|
||||
type = obj.str("type") ?: "unknown",
|
||||
text = obj.str("text"),
|
||||
mime = obj.str("mime"),
|
||||
url = obj.str("url"),
|
||||
filename = obj.str("filename"),
|
||||
tool = obj.str("tool"),
|
||||
callID = obj.str("callID"),
|
||||
state = state?.str("status"),
|
||||
@@ -526,6 +648,16 @@ object KiloCliDataParser {
|
||||
)
|
||||
}
|
||||
|
||||
private fun sanitizePart(part: PartDto, role: String): PartDto {
|
||||
if (role != "user" || part.type != "text") return part
|
||||
return part.copy(text = part.text?.let(::sanitizeUserPromptText))
|
||||
}
|
||||
|
||||
private fun readPayload(line: String): Boolean {
|
||||
if (!READ_TOOL_LINE.containsMatchIn(line)) return false
|
||||
return READ_TOOL_PATH.containsMatchIn(line)
|
||||
}
|
||||
|
||||
internal fun parseTodos(raw: JsonElement?): List<TodoDto> {
|
||||
return parseTodosOrNull(raw) ?: emptyList()
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package ai.kilocode.backend.plugin
|
||||
|
||||
import ai.kilocode.KiloPlugin
|
||||
import ai.kilocode.backend.app.KiloBackendAppService
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.ide.plugins.DynamicPluginListener
|
||||
import com.intellij.ide.plugins.IdeaPluginDescriptor
|
||||
import com.intellij.openapi.components.service
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
class KiloBackendDynamicPluginListener : DynamicPluginListener {
|
||||
private val log = KiloLog.create(KiloBackendDynamicPluginListener::class.java)
|
||||
|
||||
override fun beforePluginUnload(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) {
|
||||
if (pluginDescriptor.pluginId != KiloPlugin.id) return
|
||||
log.info("Shutting down Kilo backend for plugin unload (isUpdate=$isUpdate)")
|
||||
runBlocking {
|
||||
service<KiloBackendAppService>().shutdownForUnload()
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -16,6 +16,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import ai.kilocode.rpc.dto.QuestionRequestDto
|
||||
@@ -118,6 +119,9 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi {
|
||||
override suspend fun messages(id: String, directory: String): List<MessageWithPartsDto> =
|
||||
ready { chat.messages(id, directory) }
|
||||
|
||||
override suspend fun attachmentPart(id: String, directory: String, messageId: String, partId: String, attachmentKey: String?): PartDto? =
|
||||
ready { chat.attachmentPart(id, directory, messageId, partId, attachmentKey) }
|
||||
|
||||
override suspend fun events(id: String, directory: String): Flow<ChatEventDto> =
|
||||
chat.events.filter { event ->
|
||||
val sid = when (event) {
|
||||
|
||||
@@ -12,4 +12,9 @@
|
||||
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloMigrationRpcApiProvider"/>
|
||||
<applicationService serviceImplementation="ai.kilocode.backend.migration.KiloBackendLegacyMigrationStoreService"/>
|
||||
</extensions>
|
||||
|
||||
<applicationListeners>
|
||||
<listener class="ai.kilocode.backend.plugin.KiloBackendDynamicPluginListener"
|
||||
topic="com.intellij.ide.plugins.DynamicPluginListener"/>
|
||||
</applicationListeners>
|
||||
</idea-plugin>
|
||||
|
||||
+22
@@ -54,6 +54,28 @@ class KiloBackendAppServiceTest {
|
||||
assertNotNull(ready.data.notifications)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shutdown for unload clears runtime and disposes server once`() = runBlocking {
|
||||
val server = FakeCliServer(mock)
|
||||
val svc = KiloBackendAppService.create(scope, server, log)
|
||||
svc.connect()
|
||||
|
||||
withTimeout(10_000) {
|
||||
svc.appState.first { it is KiloAppState.Ready }
|
||||
}
|
||||
|
||||
svc.shutdownForUnload()
|
||||
svc.shutdownForUnload()
|
||||
svc.dispose()
|
||||
|
||||
assertEquals(KiloAppState.Disconnected, svc.appState.value)
|
||||
assertNull(svc.profile)
|
||||
assertNull(svc.config)
|
||||
assertTrue(svc.notifications.isEmpty())
|
||||
assertTrue(svc.warnings.isEmpty())
|
||||
assertEquals(1, server.disposeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `config is loaded`() = runBlocking {
|
||||
mock.config = """{"model":"claude-4","username":"testuser"}"""
|
||||
|
||||
+27
-2
@@ -216,10 +216,30 @@ class ChatDtoSerializationTest {
|
||||
assertEquals(2.0, decoded.time?.end)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PartDto file fields are preserved in round-trip`() {
|
||||
val part = PartDto(
|
||||
id = "p1", sessionID = "s1", messageID = "m1",
|
||||
type = "file", mime = "image/png", url = "file:///tmp/a.png", filename = "a.png",
|
||||
)
|
||||
val encoded = json.encodeToString(PartDto.serializer(), part)
|
||||
assertTrue(encoded.contains(""""mime":"image/png""""))
|
||||
assertTrue(encoded.contains(""""url":"file:///tmp/a.png""""))
|
||||
assertTrue(encoded.contains(""""filename":"a.png""""))
|
||||
|
||||
val decoded = json.decodeFromString(PartDto.serializer(), encoded)
|
||||
assertEquals("image/png", decoded.mime)
|
||||
assertEquals("file:///tmp/a.png", decoded.url)
|
||||
assertEquals("a.png", decoded.filename)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PromptDto variant is preserved in round-trip`() {
|
||||
val prompt = PromptDto(
|
||||
parts = listOf(PromptPartDto("text", "hello")),
|
||||
parts = listOf(
|
||||
PromptPartDto("text", "hello"),
|
||||
PromptPartDto(type = "file", mime = "image/png", url = "file:///tmp/a.png", filename = "a.png"),
|
||||
),
|
||||
providerID = "kilo",
|
||||
modelID = "gpt-5",
|
||||
agent = "code",
|
||||
@@ -228,7 +248,12 @@ class ChatDtoSerializationTest {
|
||||
val encoded = json.encodeToString(PromptDto.serializer(), prompt)
|
||||
|
||||
assertTrue(encoded.contains(""""variant":"medium""""))
|
||||
assertEquals("medium", json.decodeFromString(PromptDto.serializer(), encoded).variant)
|
||||
val decoded = json.decodeFromString(PromptDto.serializer(), encoded)
|
||||
assertEquals("medium", decoded.variant)
|
||||
assertEquals("file", decoded.parts[1].type)
|
||||
assertEquals("image/png", decoded.parts[1].mime)
|
||||
assertEquals("file:///tmp/a.png", decoded.parts[1].url)
|
||||
assertEquals("a.png", decoded.parts[1].filename)
|
||||
}
|
||||
|
||||
// ------ helpers ------
|
||||
|
||||
+20
@@ -154,6 +154,26 @@ class ChatLogSummaryTest {
|
||||
assertTrue(out.contains("variant=medium"), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prompt dto summary redacts file attachment urls`() {
|
||||
System.setProperty("kilo.dev.log.chat.content", "preview")
|
||||
|
||||
val out = ChatLogSummary.prompt(
|
||||
PromptDto(
|
||||
parts = listOf(
|
||||
PromptPartDto(type = "text", text = "inspect"),
|
||||
PromptPartDto(type = "file", mime = "image/png", url = "file:///secret/path.png", filename = "path.png"),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assertTrue(out.contains("attachments=1"), out)
|
||||
assertTrue(out.contains("media=1"), out)
|
||||
assertTrue(out.contains("attachmentTypes=image/png"), out)
|
||||
assertFalse(out.contains("secret"), out)
|
||||
assertFalse(out.contains("file:///"), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message updated summary includes role and model`() {
|
||||
val out = ChatLogSummary.event(
|
||||
|
||||
+240
@@ -17,6 +17,7 @@ import org.junit.jupiter.api.Nested
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
@@ -162,6 +163,107 @@ class KiloCliDataParserTest {
|
||||
assertEquals("Hello", result.part.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseChatEvent - file part preserves metadata`() {
|
||||
val data = globalEvent("""
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"sessionID": "ses_1",
|
||||
"part": {
|
||||
"id": "file_1",
|
||||
"sessionID": "ses_1",
|
||||
"messageID": "msg_1",
|
||||
"type": "file",
|
||||
"mime": "image/png",
|
||||
"url": "file:///tmp/a.png",
|
||||
"filename": "a.png"
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
val result = KiloCliDataParser.parseChatEvent("message.part.updated", data)
|
||||
assertNotNull(result)
|
||||
assertTrue(result is ChatEventDto.PartUpdated)
|
||||
assertEquals("file", result.part.type)
|
||||
assertEquals("image/png", result.part.mime)
|
||||
assertEquals("file:///tmp/a.png", result.part.url)
|
||||
assertEquals("a.png", result.part.filename)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ChatEventNormalizer - user part updated sanitizes text`() {
|
||||
val norm = KiloCliDataParser.ChatEventNormalizer()
|
||||
norm.parse("message.updated", messageUpdated("m1", "user"))
|
||||
|
||||
val events = norm.parse("message.part.updated", partUpdated(
|
||||
"m1",
|
||||
"p1",
|
||||
"text",
|
||||
"before\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}\nafter",
|
||||
))
|
||||
|
||||
val event = events!!.single() as ChatEventDto.PartUpdated
|
||||
assertEquals("before\nafter", event.part.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ChatEventNormalizer - assistant part updated preserves text`() {
|
||||
val norm = KiloCliDataParser.ChatEventNormalizer()
|
||||
norm.parse("message.updated", messageUpdated("m1", "assistant"))
|
||||
val payload = "Called the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}"
|
||||
|
||||
val events = norm.parse("message.part.updated", partUpdated("m1", "p1", "text", payload))
|
||||
|
||||
val event = events!!.single() as ChatEventDto.PartUpdated
|
||||
assertEquals(payload, event.part.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ChatEventNormalizer - user text deltas append normally`() {
|
||||
val norm = KiloCliDataParser.ChatEventNormalizer()
|
||||
norm.parse("message.updated", messageUpdated("m1", "user"))
|
||||
|
||||
val first = norm.parse("message.part.delta", partDelta("m1", "p1", "hello"))
|
||||
val second = norm.parse("message.part.delta", partDelta("m1", "p1", " world"))
|
||||
|
||||
assertEquals("hello", (first!!.single() as ChatEventDto.PartDelta).delta)
|
||||
assertEquals(" world", (second!!.single() as ChatEventDto.PartDelta).delta)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ChatEventNormalizer - split generated payload delta is suppressed`() {
|
||||
val norm = KiloCliDataParser.ChatEventNormalizer()
|
||||
norm.parse("message.updated", messageUpdated("m1", "user"))
|
||||
|
||||
val first = norm.parse("message.part.delta", partDelta("m1", "p1", "hello\n"))
|
||||
val second = norm.parse(
|
||||
"message.part.delta",
|
||||
partDelta("m1", "p1", "Called the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}"),
|
||||
)
|
||||
|
||||
assertEquals("hello\n", (first!!.single() as ChatEventDto.PartDelta).delta)
|
||||
val event = second!!.single() as ChatEventDto.PartUpdated
|
||||
assertEquals("hello", event.part.text)
|
||||
assertFalse(event.part.text!!.contains("Read tool"))
|
||||
assertFalse(event.part.text!!.contains("/tmp/a.kt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ChatEventNormalizer - partial noisy line is replaced when identified`() {
|
||||
val norm = KiloCliDataParser.ChatEventNormalizer()
|
||||
norm.parse("message.updated", messageUpdated("m1", "user"))
|
||||
|
||||
val first = norm.parse("message.part.delta", partDelta("m1", "p1", "before\nCalled the Read"))
|
||||
val second = norm.parse(
|
||||
"message.part.delta",
|
||||
partDelta("m1", "p1", " tool with the following input: {\"path\":\"/tmp/a.kt\"}\nafter"),
|
||||
)
|
||||
|
||||
assertEquals("before\nCalled the Read", (first!!.single() as ChatEventDto.PartDelta).delta)
|
||||
val event = second!!.single() as ChatEventDto.PartUpdated
|
||||
assertEquals("before\nafter", event.part.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseChatEvent - read tool part preserves input metadata and time`() {
|
||||
val data = globalEvent("""
|
||||
@@ -982,6 +1084,34 @@ class KiloCliDataParserTest {
|
||||
assertEquals("Hi there", result[1].parts[0].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseMessages - sanitizes user text read payloads only`() {
|
||||
val raw = """[
|
||||
{
|
||||
"info": { "id": "m1", "sessionID": "s1", "role": "user", "time": { "created": 1.0 } },
|
||||
"parts": [
|
||||
{ "id": "p1", "sessionID": "s1", "messageID": "m1", "type": "text", "text": "before\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/user.kt\"}\nafter" },
|
||||
{ "id": "f1", "sessionID": "s1", "messageID": "m1", "type": "file", "filename": "a.png", "url": "file:///tmp/a.png" },
|
||||
{ "id": "t1", "sessionID": "s1", "messageID": "m1", "type": "tool", "tool": "read", "state": { "input": { "filePath": "/tmp/tool.kt" } } }
|
||||
]
|
||||
},
|
||||
{
|
||||
"info": { "id": "m2", "sessionID": "s1", "role": "assistant", "time": { "created": 2.0 } },
|
||||
"parts": [{ "id": "p2", "sessionID": "s1", "messageID": "m2", "type": "text", "text": "Called the Read tool with the following input: {\"filePath\":\"/tmp/assistant.kt\"}" }]
|
||||
}
|
||||
]"""
|
||||
|
||||
val result = KiloCliDataParser.parseMessages(raw)
|
||||
|
||||
assertEquals("before\nafter", result[0].parts[0].text)
|
||||
assertEquals("a.png", result[0].parts[1].filename)
|
||||
assertEquals("/tmp/tool.kt", result[0].parts[2].input["filePath"])
|
||||
assertEquals(
|
||||
"Called the Read tool with the following input: {\"filePath\":\"/tmp/assistant.kt\"}",
|
||||
result[1].parts[0].text,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseMessages - message with tool parts`() {
|
||||
val raw = """[{
|
||||
@@ -1370,6 +1500,48 @@ class KiloCliDataParserTest {
|
||||
assertTrue(result.contains("""line1\nline2\t\"quoted\""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildPromptJson - mixed text and file parts`() {
|
||||
val prompt = PromptDto(
|
||||
parts = listOf(
|
||||
PromptPartDto(type = "text", text = "see this"),
|
||||
PromptPartDto(type = "file", mime = "image/png", url = "file:///tmp/a.png", filename = "a.png"),
|
||||
)
|
||||
)
|
||||
|
||||
val result = KiloCliDataParser.buildPromptJson(prompt)
|
||||
|
||||
assertEquals(
|
||||
"""{"parts":[{"type":"text","text":"see this"},{"type":"file","mime":"image/png","url":"file:///tmp/a.png","filename":"a.png"}]}""",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildPromptJson - file only omits optional filename`() {
|
||||
val prompt = PromptDto(
|
||||
parts = listOf(PromptPartDto(type = "file", mime = "application/pdf", url = "file:///tmp/a.pdf"))
|
||||
)
|
||||
|
||||
val result = KiloCliDataParser.buildPromptJson(prompt)
|
||||
|
||||
assertEquals(
|
||||
"""{"parts":[{"type":"file","mime":"application/pdf","url":"file:///tmp/a.pdf"}]}""",
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildPromptJson - escapes file metadata`() {
|
||||
val prompt = PromptDto(
|
||||
parts = listOf(PromptPartDto(type = "file", mime = "text/plain", url = "file:///tmp/a%20b.txt", filename = "a \"b\".txt"))
|
||||
)
|
||||
|
||||
val result = KiloCliDataParser.buildPromptJson(prompt)
|
||||
|
||||
assertTrue(result.contains(""""filename":"a \"b\".txt""""), result)
|
||||
}
|
||||
|
||||
// ---- buildSummarizeJson ----
|
||||
|
||||
@Test
|
||||
@@ -1716,6 +1888,34 @@ class KiloCliDataParserTest {
|
||||
assertNull(result[0].message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sanitizeUserPromptText - removes read payload lines`() {
|
||||
val text = "before\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}\nafter"
|
||||
|
||||
assertEquals("before\nafter", KiloCliDataParser.sanitizeUserPromptText(text))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sanitizeUserPromptText - handles read case variants and path key`() {
|
||||
val text = "before\nCalled the READ tool with the following input: {\"path\":\"/tmp/a.kt\"}\nafter"
|
||||
|
||||
assertEquals("before\nafter", KiloCliDataParser.sanitizeUserPromptText(text))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sanitizeUserPromptText - preserves ordinary prose without path key`() {
|
||||
val text = "Called the Read tool with the following input: please inspect the file"
|
||||
|
||||
assertEquals(text, KiloCliDataParser.sanitizeUserPromptText(text))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sanitizeUserPromptText - collapses only blanks introduced by payload removal`() {
|
||||
val text = "before\n\nCalled the Read tool with the following input: {\"filePath\":\"/tmp/a.kt\"}\n\nafter\n\n\nkeep"
|
||||
|
||||
assertEquals("before\n\nafter\n\n\nkeep", KiloCliDataParser.sanitizeUserPromptText(text))
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Helpers
|
||||
// ================================================================
|
||||
@@ -1723,4 +1923,44 @@ class KiloCliDataParserTest {
|
||||
/** Wrap payload content in a GlobalEvent structure. */
|
||||
private fun globalEvent(payload: String): String =
|
||||
"""{"directory":"/tmp","payload":{$payload}}"""
|
||||
|
||||
private fun messageUpdated(id: String, role: String): String = globalEvent("""
|
||||
"type": "message.updated",
|
||||
"properties": {
|
||||
"sessionID": "s1",
|
||||
"info": { "id": "$id", "sessionID": "s1", "role": "$role", "time": { "created": 1.0 } }
|
||||
}
|
||||
""")
|
||||
|
||||
private fun partUpdated(mid: String, pid: String, type: String, text: String): String = globalEvent("""
|
||||
"type": "message.part.updated",
|
||||
"properties": {
|
||||
"sessionID": "s1",
|
||||
"part": { "id": "$pid", "sessionID": "s1", "messageID": "$mid", "type": "$type", "text": ${escape(text)} }
|
||||
}
|
||||
""")
|
||||
|
||||
private fun partDelta(mid: String, pid: String, delta: String): String = globalEvent("""
|
||||
"type": "message.part.delta",
|
||||
"properties": {
|
||||
"sessionID": "s1",
|
||||
"messageID": "$mid",
|
||||
"partID": "$pid",
|
||||
"field": "text",
|
||||
"delta": ${escape(delta)}
|
||||
}
|
||||
""")
|
||||
|
||||
private fun escape(text: String) = buildString {
|
||||
append('"')
|
||||
for (ch in text) {
|
||||
when (ch) {
|
||||
'\\' -> append("\\\\")
|
||||
'"' -> append("\\\"")
|
||||
'\n' -> append("\\n")
|
||||
else -> append(ch)
|
||||
}
|
||||
}
|
||||
append('"')
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -13,6 +13,10 @@ import ai.kilocode.backend.cli.CliServer
|
||||
class FakeCliServer(private val mock: MockCliServer) : CliServer {
|
||||
|
||||
override var forceExtract = false
|
||||
var stopCount = 0
|
||||
private set
|
||||
var disposeCount = 0
|
||||
private set
|
||||
|
||||
override fun process(): Process? = null
|
||||
|
||||
@@ -23,11 +27,13 @@ class FakeCliServer(private val mock: MockCliServer) : CliServer {
|
||||
|
||||
/** Shutdown the server socket but keep the mock alive for restart. */
|
||||
override fun stop() {
|
||||
stopCount++
|
||||
mock.shutdown()
|
||||
}
|
||||
|
||||
/** Final cleanup. */
|
||||
override fun dispose() {
|
||||
disposeCount++
|
||||
mock.close()
|
||||
}
|
||||
}
|
||||
|
||||
+13
-7
@@ -6,6 +6,7 @@ import ai.kilocode.client.session.SessionSidePanelManager
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.openapi.actionSystem.ActionManager
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.DumbAware
|
||||
import com.intellij.openapi.project.Project
|
||||
@@ -14,7 +15,6 @@ import com.intellij.openapi.wm.ToolWindowFactory
|
||||
import com.intellij.ui.content.ContentFactory
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@@ -27,16 +27,22 @@ import kotlinx.coroutines.withContext
|
||||
* completes.
|
||||
*/
|
||||
class KiloToolWindowFactory : ToolWindowFactory, DumbAware {
|
||||
|
||||
companion object {
|
||||
private val LOG = KiloLog.create(KiloToolWindowFactory::class.java)
|
||||
}
|
||||
|
||||
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
|
||||
project.service<KiloToolWindowSetupService>().create(toolWindow)
|
||||
}
|
||||
}
|
||||
|
||||
private val LOG = KiloLog.create(KiloToolWindowFactory::class.java)
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
internal class KiloToolWindowSetupService(
|
||||
private val project: Project,
|
||||
private val cs: CoroutineScope,
|
||||
) {
|
||||
fun create(toolWindow: ToolWindow) {
|
||||
val start = System.currentTimeMillis()
|
||||
try {
|
||||
val workspaces = service<KiloWorkspaceService>()
|
||||
val cs = CoroutineScope(SupervisorJob())
|
||||
val hint = project.basePath ?: ""
|
||||
|
||||
cs.launch {
|
||||
|
||||
+4
@@ -13,6 +13,7 @@ import ai.kilocode.rpc.dto.ModelSelectionDto
|
||||
import ai.kilocode.rpc.dto.PermissionAlwaysRulesDto
|
||||
import ai.kilocode.rpc.dto.PermissionReplyDto
|
||||
import ai.kilocode.rpc.dto.PermissionRequestDto
|
||||
import ai.kilocode.rpc.dto.PartDto
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.QuestionReplyDto
|
||||
import ai.kilocode.rpc.dto.QuestionRequestDto
|
||||
@@ -185,6 +186,9 @@ class KiloSessionService internal constructor(
|
||||
call { messages(id, dir) }
|
||||
.also { LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(it)} ${ChatLogSummary.dir(dir)}" } }
|
||||
|
||||
suspend fun attachmentPart(id: String, dir: String, message: String, part: String, key: String?): PartDto? =
|
||||
call { attachmentPart(id, dir, message, part, key) }
|
||||
|
||||
/** Subscribe to streaming chat events for a session. */
|
||||
fun events(id: String, dir: String): Flow<ChatEventDto> {
|
||||
val api = rpc
|
||||
|
||||
+1
@@ -181,4 +181,5 @@ class KiloWorkspaceService internal constructor(
|
||||
done(ok)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package ai.kilocode.client.plugin
|
||||
|
||||
import ai.kilocode.KiloPlugin
|
||||
import ai.kilocode.client.session.ui.attachment.unregisterAttachmentEditorKind
|
||||
import ai.kilocode.client.vfs.KiloEditorKindRegistry
|
||||
import ai.kilocode.client.vfs.KiloVirtualFileSystem
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.ide.plugins.DynamicPluginListener
|
||||
import com.intellij.ide.plugins.IdeaPluginDescriptor
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.project.ProjectManager
|
||||
import com.intellij.openapi.wm.ToolWindowManager
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
class KiloFrontendDynamicPluginListener : DynamicPluginListener {
|
||||
override fun beforePluginUnload(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) {
|
||||
if (pluginDescriptor.pluginId != KiloPlugin.id) return
|
||||
KiloFrontendUnloadCleanup.cleanup(isUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
object KiloFrontendUnloadCleanup {
|
||||
private val log = KiloLog.create(KiloFrontendUnloadCleanup::class.java)
|
||||
|
||||
fun cleanup(isUpdate: Boolean) {
|
||||
log.info("Cleaning up Kilo frontend for plugin unload (isUpdate=$isUpdate)")
|
||||
runEdt {
|
||||
ProjectManager.getInstance().openProjects.forEach { project ->
|
||||
if (project.isDisposed) return@forEach
|
||||
ToolWindowManager.getInstance(project).getToolWindow("Kilo Code")
|
||||
?.contentManager
|
||||
?.removeAllContents(true)
|
||||
val editors = FileEditorManager.getInstance(project).openFiles
|
||||
.filter { it.fileSystem === KiloVirtualFileSystem.getInstance() }
|
||||
editors.forEach { file -> FileEditorManager.getInstance(project).closeFile(file) }
|
||||
}
|
||||
}
|
||||
unregisterAttachmentEditorKind()
|
||||
service<KiloEditorKindRegistry>().clear()
|
||||
KiloVirtualFileSystem.getInstance().clear()
|
||||
}
|
||||
|
||||
private fun runEdt(block: () -> Unit) {
|
||||
if (SwingUtilities.isEventDispatchThread()) {
|
||||
block()
|
||||
return
|
||||
}
|
||||
SwingUtilities.invokeAndWait(block)
|
||||
}
|
||||
}
|
||||
+110
-10
@@ -8,6 +8,7 @@ import ai.kilocode.client.migration.KiloMigrationService
|
||||
import ai.kilocode.client.migration.MigrationUiController
|
||||
import ai.kilocode.client.migration.MigrationUiState
|
||||
import ai.kilocode.client.migration.ui.MigrationOverlayPanel
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.scroll.SessionScroll
|
||||
@@ -19,8 +20,13 @@ import ai.kilocode.client.session.ui.mode.ModePicker
|
||||
import ai.kilocode.client.session.ui.model.ModelPicker
|
||||
import ai.kilocode.client.session.ui.prompt.PromptPanel
|
||||
import ai.kilocode.client.session.ui.account.SessionAccountOverlay
|
||||
import ai.kilocode.client.session.ui.SessionDropOverlay
|
||||
import ai.kilocode.client.session.ui.SessionRootPanel
|
||||
import ai.kilocode.client.session.ui.SessionMessageListPanel
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentEditorKind
|
||||
import ai.kilocode.client.session.ui.attachment.attachmentParams
|
||||
import ai.kilocode.client.session.ui.attachment.ensureAttachmentEditorKind
|
||||
import ai.kilocode.client.session.ui.attachment.isEmbeddedAttachment
|
||||
import ai.kilocode.client.session.ui.header.SessionHeaderPanel
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
@@ -35,7 +41,10 @@ import ai.kilocode.client.session.views.question.QuestionView
|
||||
import ai.kilocode.client.settings.profile.UserProfileConfigurable
|
||||
import ai.kilocode.client.telemetry.Telemetry
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.vfs.KiloVfsManager
|
||||
import ai.kilocode.log.ChatLogSummary
|
||||
import ai.kilocode.rpc.dto.PromptDto
|
||||
import ai.kilocode.rpc.dto.PromptPartDto
|
||||
import com.intellij.util.ui.JBUI
|
||||
import ai.kilocode.log.KiloLog
|
||||
import com.intellij.ide.BrowserUtil
|
||||
@@ -64,8 +73,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.event.HierarchyEvent
|
||||
import java.net.URI
|
||||
import java.nio.file.Path
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.Timer
|
||||
import javax.swing.UIManager
|
||||
|
||||
/**
|
||||
@@ -89,6 +101,7 @@ class SessionUi(
|
||||
|
||||
companion object {
|
||||
private val LOG = KiloLog.create(SessionUi::class.java)
|
||||
private const val HIDE_MS = 120
|
||||
}
|
||||
|
||||
private val project = project
|
||||
@@ -125,6 +138,13 @@ class SessionUi(
|
||||
|
||||
private lateinit var root: SessionRootPanel
|
||||
private lateinit var account: SessionAccountOverlay
|
||||
private lateinit var drop: SessionDropOverlay
|
||||
private val hide = Timer(HIDE_MS) {
|
||||
if (disposed || !this::drop.isInitialized) return@Timer
|
||||
drop.setActive(false)
|
||||
}.apply {
|
||||
isRepeats = false
|
||||
}
|
||||
|
||||
private lateinit var sessionContent: JPanel
|
||||
|
||||
@@ -296,6 +316,7 @@ class SessionUi(
|
||||
::openFile,
|
||||
::openUrl,
|
||||
selection,
|
||||
::openAttachment,
|
||||
repo = workspace.directory,
|
||||
resize = { anchor, fn -> scroll.preserve(anchor, fn) },
|
||||
)
|
||||
@@ -306,11 +327,21 @@ class SessionUi(
|
||||
|
||||
prompt = PromptPanel(
|
||||
project = project,
|
||||
onSend = { text -> sendPrompt(text) },
|
||||
onSend = { text, files -> sendPrompt(text, files) },
|
||||
onAbort = { controller.abort() },
|
||||
onEnhance = controller::enhancePrompt,
|
||||
)
|
||||
|
||||
drop = SessionDropOverlay()
|
||||
root.addOverlay(drop) { pane, _ ->
|
||||
java.awt.Rectangle(0, 0, pane.width, pane.height)
|
||||
}
|
||||
root.overlay.setComponentZOrder(drop, 0)
|
||||
prompt.onFileDrag = ::syncDrop
|
||||
prompt.installFileDrop(root, "session-root")
|
||||
// The visual overlay returns contains(false) so normal UI remains clickable.
|
||||
// Registering it as a native DnD target makes IntelliJ resolve a null over-component.
|
||||
|
||||
sessionContent.add(header, BorderLayout.NORTH)
|
||||
sessionContent.add(scroll.component, BorderLayout.CENTER)
|
||||
root.content.add(sessionContent, BorderLayout.CENTER)
|
||||
@@ -320,7 +351,10 @@ class SessionUi(
|
||||
|
||||
private fun bindUi() {
|
||||
prompt.mode.onSelect = { item -> controller.selectAgent(item.id) }
|
||||
prompt.model.onSelect = { item -> controller.selectModel(item.provider, item.id) }
|
||||
prompt.model.onSelect = { item ->
|
||||
prompt.setAttachmentEnabled(item.attachment)
|
||||
controller.selectModel(item.provider, item.id)
|
||||
}
|
||||
prompt.reasoning.onSelect = { item -> controller.selectVariant(item.id) }
|
||||
prompt.onReset = { controller.clearModelOverride() }
|
||||
prompt.onChange = { scroll.refresh() }
|
||||
@@ -356,14 +390,16 @@ class SessionUi(
|
||||
it.display,
|
||||
it.provider,
|
||||
it.providerName,
|
||||
it.recommendedIndex,
|
||||
it.free,
|
||||
it.variants,
|
||||
)
|
||||
it.recommendedIndex,
|
||||
it.free,
|
||||
it.variants,
|
||||
it.attachment,
|
||||
)
|
||||
}
|
||||
val selected =
|
||||
m.model?.let { full -> items.firstOrNull { it.key == full }?.key }
|
||||
prompt.model.setItems(items, selected)
|
||||
prompt.setAttachmentEnabled(items.firstOrNull { it.key == selected }?.attachment ?: true)
|
||||
prompt.reasoning.setItems(m.variants.map { ReasoningPicker.Item(it, variantTitle(it)) }, m.variant)
|
||||
prompt.setResetVisible(m.modelOverride)
|
||||
prompt.setReady(m.isReady())
|
||||
@@ -432,6 +468,17 @@ class SessionUi(
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncDrop(value: Boolean) {
|
||||
if (disposed) return
|
||||
if (value) {
|
||||
hide.stop()
|
||||
drop.setActive(true)
|
||||
return
|
||||
}
|
||||
hide.restart()
|
||||
}
|
||||
|
||||
private fun bindMigration() {
|
||||
cs.launch {
|
||||
migration.state.collect { state ->
|
||||
@@ -515,16 +562,20 @@ class SessionUi(
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendPrompt(text: String) {
|
||||
if (text.isBlank()) return
|
||||
private fun sendPrompt(text: String, files: List<PromptPartDto>) {
|
||||
if (text.isBlank() && files.isEmpty()) return
|
||||
val parts = buildList {
|
||||
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
|
||||
addAll(files)
|
||||
}
|
||||
LOG.debug {
|
||||
val agent = controller.model.agent ?: "none"
|
||||
val model = controller.model.model ?: "none"
|
||||
"${ChatLogSummary.prompt(text)} agent=$agent model=$model ready=${controller.ready}"
|
||||
"${ChatLogSummary.prompt(PromptDto(parts = parts))} agent=$agent model=$model ready=${controller.ready}"
|
||||
}
|
||||
prompt.clear()
|
||||
val follow = scroll.atBottom()
|
||||
controller.prompt(text)
|
||||
controller.prompt(text, files)
|
||||
scroll.followBottom(follow)
|
||||
}
|
||||
|
||||
@@ -538,6 +589,54 @@ class SessionUi(
|
||||
BrowserUtil.browse(url)
|
||||
}
|
||||
|
||||
private fun openAttachment(messageId: String, item: FileAttachment) {
|
||||
val url = item.url.takeIf { it.isNotBlank() } ?: run {
|
||||
LOG.info("kind=attachment-open skipped=true reason=blank-url message=$messageId part=${item.id} name=${attachmentName(item)} mime=${item.mime}")
|
||||
return
|
||||
}
|
||||
LOG.info(
|
||||
"kind=attachment-open session=${controller.id ?: "none"} message=$messageId part=${item.id} " +
|
||||
"name=${attachmentName(item)} mime=${item.mime} url=${attachmentUrl(url)} dir=${workspace.directory}"
|
||||
)
|
||||
if (isEmbeddedAttachment(url)) {
|
||||
val id = controller.id ?: run {
|
||||
LOG.info("kind=attachment-open skipped=true reason=missing-session message=$messageId part=${item.id} name=${attachmentName(item)}")
|
||||
return
|
||||
}
|
||||
LOG.info("kind=attachment-open route=kilo-vfs session=$id message=$messageId part=${item.id} name=${attachmentName(item)}")
|
||||
ensureAttachmentEditorKind()
|
||||
project.service<KiloVfsManager>().open(
|
||||
AttachmentEditorKind.ID,
|
||||
attachmentParams(id, messageId, item, attachmentName(item), workspace.directory),
|
||||
)
|
||||
return
|
||||
}
|
||||
val uri = runCatching { URI.create(url) }.getOrNull() ?: run {
|
||||
LOG.info("kind=attachment-open skipped=true reason=invalid-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}")
|
||||
return
|
||||
}
|
||||
if (uri.scheme == "file") {
|
||||
val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: run {
|
||||
LOG.info("kind=attachment-open skipped=true reason=invalid-file-uri message=$messageId part=${item.id} url=${attachmentUrl(url)}")
|
||||
return
|
||||
}
|
||||
LOG.info("kind=attachment-open route=file session=${controller.id ?: "none"} message=$messageId part=${item.id} path=$path")
|
||||
openFile(path)
|
||||
return
|
||||
}
|
||||
LOG.info("kind=attachment-open route=browser session=${controller.id ?: "none"} message=$messageId part=${item.id} url=${attachmentUrl(url)}")
|
||||
openUrl(url)
|
||||
}
|
||||
|
||||
private fun attachmentName(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() }
|
||||
?: item.url.substringBefore(',').substringAfterLast('/').takeIf { it.isNotBlank() }
|
||||
?: "attachment"
|
||||
|
||||
private fun attachmentUrl(url: String): String {
|
||||
val scheme = url.substringBefore(':', missingDelimiterValue = "none")
|
||||
return "scheme=$scheme chars=${url.length} embedded=${isEmbeddedAttachment(url)}"
|
||||
}
|
||||
|
||||
private fun onStateChanged(state: SessionState) {
|
||||
if (disposed) return
|
||||
prompt.setBusy(state.isBusy())
|
||||
@@ -596,6 +695,7 @@ class SessionUi(
|
||||
|
||||
override fun dispose() {
|
||||
disposed = true
|
||||
hide.stop()
|
||||
modalFocus = null
|
||||
empty = null
|
||||
if (this::root.isInitialized) root.setModalContent(null)
|
||||
|
||||
+21
-12
@@ -229,13 +229,13 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
|
||||
fun prompt(text: String) {
|
||||
fun prompt(text: String, files: List<PromptPartDto> = emptyList()) {
|
||||
assertEdt()
|
||||
val start = sid ?: ref?.key ?: "pending"
|
||||
val exists = sid != null
|
||||
val dto = promptDto(text)
|
||||
val props = promptProps()
|
||||
LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" }
|
||||
val dto = promptDto(text, files)
|
||||
val props = promptProps(files)
|
||||
LOG.debug { "${ChatLogSummary.sid(start)} ${ChatLogSummary.prompt(dto)} ${ChatLogSummary.dir(directory)}" }
|
||||
capture("Conversation Send Clicked", sessionProps(sid ?: ref?.key) + mapOf(
|
||||
"source" to "user",
|
||||
"hasExistingSession" to exists.toString(),
|
||||
@@ -658,11 +658,12 @@ class SessionController(
|
||||
info.name,
|
||||
provider.id,
|
||||
provider.name,
|
||||
info.recommendedIndex,
|
||||
info.free,
|
||||
info.variants,
|
||||
info.limit?.let { ModelLimitItem(it.context, it.input, it.output) },
|
||||
)
|
||||
info.recommendedIndex,
|
||||
info.free,
|
||||
info.variants,
|
||||
info.limit?.let { ModelLimitItem(it.context, it.input, it.output) },
|
||||
info.attachment,
|
||||
)
|
||||
}
|
||||
}
|
||||
} ?: emptyList()
|
||||
@@ -1237,12 +1238,16 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun promptDto(text: String): PromptDto {
|
||||
private fun promptDto(text: String, files: List<PromptPartDto> = emptyList()): PromptDto {
|
||||
val full = model.model
|
||||
val sel = full?.let(::parseModel)
|
||||
val variant = model.variant?.takeIf { it in model.variants }
|
||||
val parts = buildList {
|
||||
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
|
||||
addAll(files)
|
||||
}
|
||||
return PromptDto(
|
||||
parts = listOf(PromptPartDto(type = "text", text = text)),
|
||||
parts = parts,
|
||||
providerID = sel?.first,
|
||||
modelID = sel?.second,
|
||||
agent = model.agent,
|
||||
@@ -1455,7 +1460,7 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
|
||||
private fun promptProps(): Map<String, String> = buildMap {
|
||||
private fun promptProps(files: List<PromptPartDto> = emptyList()): Map<String, String> = buildMap {
|
||||
model.agent?.let { put("agent", it) }
|
||||
model.model?.let { key ->
|
||||
put("model", key)
|
||||
@@ -1465,6 +1470,10 @@ class SessionController(
|
||||
}
|
||||
}
|
||||
model.variant?.takeIf { it in model.variants }?.let { put("variant", it) }
|
||||
if (files.isNotEmpty()) {
|
||||
put("attachmentCount", files.size.toString())
|
||||
put("mediaAttachmentCount", files.count { it.mime?.startsWith("image/") == true || it.mime == "application/pdf" }.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun bucket(text: String): String = when (text.length) {
|
||||
|
||||
+7
@@ -66,6 +66,13 @@ class Reasoning(id: String) : Content(id) {
|
||||
var done: Boolean = true
|
||||
}
|
||||
|
||||
/** User-provided file or image attachment. */
|
||||
class FileAttachment(id: String) : Content(id) {
|
||||
var mime: String = "application/octet-stream"
|
||||
var url: String = ""
|
||||
var filename: String? = null
|
||||
}
|
||||
|
||||
/** Tool invocation with lifecycle state. */
|
||||
class Tool(id: String, val name: String, var kind: ToolKind) : Content(id) {
|
||||
var state: ToolExecState = ToolExecState.PENDING
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package ai.kilocode.client.session.model
|
||||
|
||||
import ai.kilocode.rpc.dto.PromptPartDto
|
||||
import java.awt.Image
|
||||
import java.awt.image.BufferedImage
|
||||
import java.awt.image.MultiResolutionImage
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.Base64
|
||||
import java.util.UUID
|
||||
import javax.imageio.ImageIO
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.name
|
||||
import kotlin.io.path.readBytes
|
||||
|
||||
data class PromptAttachment(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val mime: String,
|
||||
val url: String,
|
||||
val path: Path? = null,
|
||||
) {
|
||||
fun part() = PromptPartDto(
|
||||
type = "file",
|
||||
mime = mime,
|
||||
url = path?.let { data(it, mime) } ?: url,
|
||||
filename = name,
|
||||
)
|
||||
}
|
||||
|
||||
object PromptAttachmentExtractor {
|
||||
private const val MAX_BYTES = 10 * 1024 * 1024
|
||||
|
||||
fun files(files: List<java.io.File>): List<PromptAttachment> = files
|
||||
.filter { it.exists() && it.isFile && it.canRead() && it.length() <= MAX_BYTES }
|
||||
.map { file ->
|
||||
val path = file.toPath()
|
||||
val mime = mime(file)
|
||||
if (!media(mime)) return@map null
|
||||
PromptAttachment(
|
||||
id = path.toAbsolutePath().normalize().toString(),
|
||||
name = path.fileName?.toString() ?: path.name,
|
||||
mime = mime,
|
||||
url = path.toUri().toString(),
|
||||
path = path,
|
||||
)
|
||||
}
|
||||
.filterNotNull()
|
||||
|
||||
fun media(mime: String): Boolean = mime.startsWith("image/") || mime == "text/plain"
|
||||
|
||||
fun image(raw: Any): PromptAttachment? {
|
||||
val image = when (raw) {
|
||||
is MultiResolutionImage -> raw.resolutionVariants.firstOrNull()?.buffered()
|
||||
is BufferedImage -> raw
|
||||
is Image -> raw.buffered()
|
||||
else -> null
|
||||
} ?: return null
|
||||
val out = ByteArrayOutputStream()
|
||||
ImageIO.write(image, "png", out)
|
||||
val id = UUID.randomUUID().toString()
|
||||
val data = Base64.getEncoder().encodeToString(out.toByteArray())
|
||||
return PromptAttachment(
|
||||
id = "clipboard-image:$id",
|
||||
name = "pasted-image-$id.png",
|
||||
mime = "image/png",
|
||||
url = "data:image/png;base64,$data",
|
||||
)
|
||||
}
|
||||
|
||||
private fun mime(file: java.io.File): String {
|
||||
if (file.isDirectory) return "application/x-directory"
|
||||
return when (file.extension.lowercase()) {
|
||||
"png" -> "image/png"
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"gif" -> "image/gif"
|
||||
"webp" -> "image/webp"
|
||||
"bmp" -> "image/bmp"
|
||||
"svg" -> "image/svg+xml"
|
||||
"pdf" -> "application/pdf"
|
||||
"txt", "md", "kt", "kts", "java", "js", "jsx", "ts", "tsx", "json", "xml", "html", "css", "scss", "yml", "yaml", "toml", "sh", "py", "rb", "go", "rs", "c", "cc", "cpp", "h", "hpp" -> "text/plain"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
private fun Image.buffered(): BufferedImage? {
|
||||
if (this is BufferedImage) return this
|
||||
val width = getWidth(null)
|
||||
val height = getHeight(null)
|
||||
if (width <= 0 || height <= 0) return null
|
||||
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB)
|
||||
val g = image.createGraphics()
|
||||
try {
|
||||
g.drawImage(this, 0, 0, null)
|
||||
} finally {
|
||||
g.dispose()
|
||||
}
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
private fun data(path: Path, mime: String) = "data:$mime;base64,${Base64.getEncoder().encodeToString(path.readBytes())}"
|
||||
+21
@@ -158,6 +158,10 @@ class SessionModel {
|
||||
if (dto.type in SILENT_PART_TYPES) return
|
||||
val msg = entries[messageId] ?: return
|
||||
val existing = msg.parts[dto.id]
|
||||
if (empty(dto)) {
|
||||
if (existing is Text) removeContent(messageId, dto.id)
|
||||
return
|
||||
}
|
||||
if (existing != null) {
|
||||
updateExisting(messageId, existing, dto)
|
||||
return
|
||||
@@ -243,6 +247,7 @@ class SessionModel {
|
||||
val item = Message(msg.info)
|
||||
for (part in msg.parts) {
|
||||
if (part.type in SILENT_PART_TYPES) continue
|
||||
if (empty(part)) continue
|
||||
val content = fromDto(part, part.text)
|
||||
item.parts[content.id] = content
|
||||
}
|
||||
@@ -373,6 +378,11 @@ class SessionModel {
|
||||
existing.content.append(text)
|
||||
existing.done = dto.time?.end != null || dto.time == null
|
||||
}
|
||||
is FileAttachment -> {
|
||||
existing.mime = dto.mime ?: "application/octet-stream"
|
||||
existing.url = dto.url ?: ""
|
||||
existing.filename = dto.filename
|
||||
}
|
||||
is Tool -> {
|
||||
existing.kind = toolKind(dto.tool)
|
||||
existing.state = parseToolState(dto.state)
|
||||
@@ -398,6 +408,8 @@ class SessionModel {
|
||||
updateHeader()
|
||||
}
|
||||
|
||||
private fun empty(dto: PartDto) = dto.type == "text" && dto.text?.isNotBlank() != true
|
||||
|
||||
private fun fromDto(dto: PartDto, text: CharSequence? = null): Content {
|
||||
val content = text ?: dto.text
|
||||
return when (dto.type) {
|
||||
@@ -408,6 +420,11 @@ class SessionModel {
|
||||
if (content != null && content.isNotEmpty()) this.content.append(content)
|
||||
done = dto.time?.end != null || dto.time == null
|
||||
}
|
||||
"file" -> FileAttachment(dto.id).apply {
|
||||
mime = dto.mime ?: "application/octet-stream"
|
||||
url = dto.url ?: ""
|
||||
filename = dto.filename
|
||||
}
|
||||
"tool" -> Tool(dto.id, dto.tool ?: "unknown", toolKind(dto.tool)).apply {
|
||||
state = parseToolState(dto.state)
|
||||
callId = dto.callID
|
||||
@@ -568,6 +585,7 @@ data class ModelItem(
|
||||
val free: Boolean,
|
||||
val variants: List<String>,
|
||||
val limit: ModelLimitItem?,
|
||||
val attachment: Boolean = false,
|
||||
) {
|
||||
val key: String get() = "$provider/$id"
|
||||
}
|
||||
@@ -602,6 +620,7 @@ private fun parseModelKey(value: String): Pair<String, String>? {
|
||||
private fun Content.timelineTitle(): String = when (this) {
|
||||
is Text -> "Text"
|
||||
is Reasoning -> "Reasoning"
|
||||
is FileAttachment -> filename?.takeIf { it.isNotBlank() } ?: "File"
|
||||
is Tool -> fileActionTitle() ?: title?.takeIf { it.isNotBlank() } ?: name
|
||||
is Compaction -> "Compaction"
|
||||
is StepFinish -> "Step finish"
|
||||
@@ -635,6 +654,7 @@ private fun tail(path: String): String {
|
||||
private fun Content.weight(): Int = when (this) {
|
||||
is Text -> content.length / 200 + 1
|
||||
is Reasoning -> content.length / 200 + 1
|
||||
is FileAttachment -> 1
|
||||
is Tool -> listOf(input.size, output?.length?.div(400) ?: 0, error?.length?.div(200) ?: 0).sum() + 1
|
||||
is Compaction -> 2
|
||||
is StepFinish -> tokens?.stepWeight() ?: 1
|
||||
@@ -661,6 +681,7 @@ private fun renderMessage(msg: Message): List<String> {
|
||||
out.add("reasoning#${part.id} done=${part.done}:")
|
||||
out.addAll(renderText(part.content))
|
||||
}
|
||||
is FileAttachment -> out.add("file#${part.id} ${part.mime} ${part.filename ?: tail(part.url)}")
|
||||
is Tool -> out.add(renderTool(part))
|
||||
is Compaction -> out.add("compaction#${part.id}")
|
||||
is StepFinish -> out.add("step-finish#${part.id}")
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package ai.kilocode.client.session.ui
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.RoundedContentPanel
|
||||
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 com.intellij.icons.AllIcons
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.IconUtil
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBFont
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Color
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
|
||||
class SessionDropOverlay : BorderLayoutPanel() {
|
||||
private val title = KiloBundle.message("session.drop.files.title")
|
||||
private val subtitle = KiloBundle.message("session.drop.files.subtitle")
|
||||
private val text = "$title $subtitle"
|
||||
private val card = Card()
|
||||
private var active = false
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
accessibleContext?.accessibleName = text
|
||||
|
||||
val primary = JBLabel(title).apply {
|
||||
font = JBFont.h0()
|
||||
foreground = UIUtil.getLabelForeground()
|
||||
}
|
||||
val secondary = JBLabel(subtitle).apply {
|
||||
font = JBFont.h2()
|
||||
foreground = UIUtil.getLabelForeground()
|
||||
}
|
||||
val icon = JBLabel(IconUtil.scale(AllIcons.Actions.Download, null, 3f))
|
||||
val labels = Stack.vertical(JBUI.scale(SessionUiStyle.View.DropOverlay.LABEL_GAP))
|
||||
.next(primary.align(HAlign.CENTER, VAlign.CENTER))
|
||||
.next(secondary.align(HAlign.CENTER, VAlign.CENTER))
|
||||
.gap(JBUI.scale(SessionUiStyle.View.DropOverlay.ICON_GAP))
|
||||
.next(icon.align(HAlign.CENTER, VAlign.CENTER))
|
||||
card.apply {
|
||||
isVisible = false
|
||||
add(labels, BorderLayout.CENTER)
|
||||
}
|
||||
add(card.align(HAlign.CENTER, VAlign.CENTER), BorderLayout.CENTER)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setActive(value: Boolean) {
|
||||
if (active == value) return
|
||||
active = value
|
||||
card.isVisible = value
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun contains(x: Int, y: Int): Boolean = false
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
if (!active) {
|
||||
super.paintComponent(g)
|
||||
return
|
||||
}
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.color = SessionUiStyle.View.DropOverlay.scrim()
|
||||
g2.fillRect(0, 0, width, height)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
|
||||
private class Card : RoundedContentPanel(
|
||||
JBUI.scale(SessionUiStyle.View.DropOverlay.CARD_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.DropOverlay.CARD_HORIZONTAL_PADDING),
|
||||
) {
|
||||
override fun contentColor(): Color = SessionUiStyle.View.DropOverlay.card()
|
||||
|
||||
override fun outlineColor(): Color? = null
|
||||
|
||||
override fun cornerArc(): Int = JBUI.scale(SessionUiStyle.View.DropOverlay.CARD_ARC)
|
||||
}
|
||||
}
|
||||
+8
-6
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui
|
||||
import ai.kilocode.client.session.model.SessionModel
|
||||
import ai.kilocode.client.session.model.SessionModelEvent
|
||||
import ai.kilocode.client.session.model.SessionState
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.model.ToolCallRef
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.selection.SessionSelection
|
||||
@@ -53,15 +54,16 @@ class SessionMessageListPanel(
|
||||
private val openFile: (String) -> Unit,
|
||||
private val openUrl: (String) -> Unit = {},
|
||||
private val selection: SessionSelection? = null,
|
||||
private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> ai.kilocode.client.session.views.AttachmentView.openDefault(item, openFile, openUrl) },
|
||||
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_SCROLLBAR_PADDING,
|
||||
SessionUiStyle.SessionLayout.InnerInsets.top,
|
||||
SessionUiStyle.SessionLayout.InnerInsets.left,
|
||||
SessionUiStyle.SessionLayout.InnerInsets.bottom,
|
||||
SessionUiStyle.SessionLayout.InnerInsets.right + SessionUiStyle.SessionLayout.TRANSCRIPT_SCROLLBAR_PADDING,
|
||||
),
|
||||
), Disposable, SessionEditorStyleTarget {
|
||||
|
||||
@@ -182,7 +184,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, resize, repo, ::hover)
|
||||
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover)
|
||||
turnViews[turn.id] = tv
|
||||
for (msgId in turn.messageIds) {
|
||||
val msg = model.message(msgId) ?: continue
|
||||
@@ -239,7 +241,7 @@ class SessionMessageListPanel(
|
||||
removeAll()
|
||||
|
||||
for (turn in model.turns()) {
|
||||
val tv = TurnView(turn.id, openFile, style, openUrl, selection, resize, repo, ::hover)
|
||||
val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover)
|
||||
turnViews[turn.id] = tv
|
||||
for (msgId in turn.messageIds) {
|
||||
val msg = model.message(msgId) ?: continue
|
||||
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
package ai.kilocode.client.session.ui.attachment
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.iconButton
|
||||
import ai.kilocode.client.ui.layout.HAlign
|
||||
import ai.kilocode.client.ui.layout.VAlign
|
||||
import ai.kilocode.client.ui.layout.align
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.openapi.util.text.StringUtil
|
||||
import com.intellij.xml.util.XmlStringUtil
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.Component
|
||||
import java.awt.Container
|
||||
import java.awt.Cursor
|
||||
import java.awt.Dimension
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.Image
|
||||
import java.awt.LayoutManager2
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.nio.file.Path
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.Icon
|
||||
import javax.swing.ImageIcon
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingConstants
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
data class AttachmentCardItem(
|
||||
val name: String,
|
||||
val mime: String,
|
||||
val url: String,
|
||||
val path: Path? = null,
|
||||
)
|
||||
|
||||
open class AttachmentCard(
|
||||
private val item: AttachmentCardItem,
|
||||
remove: (() -> Unit)? = null,
|
||||
open: (() -> Unit)? = null,
|
||||
) : JPanel(CardLayout()) {
|
||||
private var gen = 0
|
||||
private var loaded = false
|
||||
private val icon = attachmentIcon(item.mime, item.name)
|
||||
private val tip = tooltip(item)
|
||||
private val open = open?.let { callback ->
|
||||
object : MouseAdapter() {
|
||||
override fun mouseClicked(e: MouseEvent) {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
}
|
||||
private val hover = object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) {
|
||||
showAction(true)
|
||||
}
|
||||
|
||||
override fun mouseMoved(e: MouseEvent) {
|
||||
showAction(true)
|
||||
}
|
||||
|
||||
override fun mouseExited(e: MouseEvent) {
|
||||
val point = SwingUtilities.convertPoint(e.component, e.point, this@AttachmentCard)
|
||||
showAction(contains(point))
|
||||
}
|
||||
}
|
||||
private val preview = PreviewPanel(::watch).apply { setIcon(icon) }
|
||||
private val content = JPanel(BorderLayout()).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.xs())
|
||||
add(preview, BorderLayout.CENTER)
|
||||
}
|
||||
private val action = remove?.let { callback ->
|
||||
CloseButton().apply {
|
||||
isVisible = false
|
||||
toolTipText = KiloBundle.message("prompt.attachment.remove", item.name)
|
||||
accessibleContext?.accessibleName = toolTipText
|
||||
addActionListener { callback() }
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
toolTipText = tip
|
||||
accessibleContext?.accessibleName = KiloBundle.message("prompt.attachment.open", item.name)
|
||||
add(content)
|
||||
if (action != null) {
|
||||
add(action)
|
||||
setComponentZOrder(action, 0)
|
||||
}
|
||||
watch(this)
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension = JBUI.size(
|
||||
SessionUiStyle.View.Attachment.CARD_WIDTH,
|
||||
SessionUiStyle.View.Attachment.CARD_HEIGHT,
|
||||
)
|
||||
|
||||
override fun getMinimumSize(): Dimension = preferredSize
|
||||
|
||||
override fun getMaximumSize(): Dimension = preferredSize
|
||||
|
||||
override fun addNotify() {
|
||||
super.addNotify()
|
||||
if (loaded) return
|
||||
loaded = true
|
||||
load()
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC)
|
||||
g2.color = SessionUiStyle.View.Surface.bgColor()
|
||||
g2.fillRoundRect(0, 0, width, height, arc, arc)
|
||||
g2.color = SessionUiStyle.View.Outline.color()
|
||||
g2.drawRoundRect(0, 0, width - 1, height - 1, arc, arc)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun load() {
|
||||
if (!item.mime.startsWith("image/")) return
|
||||
val stamp = ++gen
|
||||
val size = JBUI.size(
|
||||
SessionUiStyle.View.Attachment.CARD_WIDTH - UiStyle.Gap.xs() * 2,
|
||||
SessionUiStyle.View.Attachment.CARD_HEIGHT - UiStyle.Gap.xs() * 2,
|
||||
)
|
||||
ApplicationManager.getApplication().executeOnPooledThread {
|
||||
val image = runCatching {
|
||||
val data = decodeDataImage(item.url)
|
||||
val path = local(item)
|
||||
if (data != null) ImageIO.read(ByteArrayInputStream(data)) else path?.let { ImageIO.read(it.toFile()) }
|
||||
}.getOrNull()
|
||||
val scaled = image?.let { scale(it, size.width, size.height) }
|
||||
if (scaled == null) return@executeOnPooledThread
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (gen != stamp || !isDisplayable) return@invokeLater
|
||||
preview.setIcon(ImageIcon(scaled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun watch(node: Component) {
|
||||
if (node is JComponent && node !is JButton) node.toolTipText = tip
|
||||
node.removeMouseListener(hover)
|
||||
node.removeMouseMotionListener(hover)
|
||||
node.addMouseListener(hover)
|
||||
node.addMouseMotionListener(hover)
|
||||
open?.let {
|
||||
node.removeMouseListener(it)
|
||||
if (node !is JButton) {
|
||||
node.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
|
||||
node.addMouseListener(it)
|
||||
}
|
||||
}
|
||||
if (node is Container) node.components.forEach(::watch)
|
||||
}
|
||||
|
||||
private fun showAction(value: Boolean) {
|
||||
val button = action ?: return
|
||||
if (button.isVisible == value) return
|
||||
button.isVisible = value
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private class PreviewPanel(private val watch: (Component) -> Unit) : JPanel(BorderLayout()) {
|
||||
init {
|
||||
isOpaque = false
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension = JBUI.size(
|
||||
SessionUiStyle.View.Attachment.CARD_WIDTH - UiStyle.Gap.xs() * 2,
|
||||
SessionUiStyle.View.Attachment.CARD_HEIGHT - UiStyle.Gap.xs() * 2,
|
||||
)
|
||||
|
||||
fun setIcon(next: Icon) {
|
||||
val label = JBLabel(next, SwingConstants.CENTER).align(HAlign.CENTER, VAlign.CENTER)
|
||||
removeAll()
|
||||
add(label, BorderLayout.CENTER)
|
||||
watch(label)
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||
val arc = JBUI.scale(SessionUiStyle.View.Attachment.CORNER_ARC)
|
||||
g2.color = SessionUiStyle.View.Surface.headerHoverBgColor()
|
||||
g2.fillRoundRect(0, 0, width, height, arc, arc)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
super.paintComponent(g)
|
||||
}
|
||||
}
|
||||
|
||||
private class CardLayout : LayoutManager2 {
|
||||
override fun addLayoutComponent(comp: Component, constraints: Any?) = Unit
|
||||
override fun addLayoutComponent(name: String?, comp: Component) = Unit
|
||||
override fun removeLayoutComponent(comp: Component) = Unit
|
||||
override fun minimumLayoutSize(parent: Container) = preferredLayoutSize(parent)
|
||||
override fun preferredLayoutSize(parent: Container) = JBUI.size(
|
||||
SessionUiStyle.View.Attachment.CARD_WIDTH,
|
||||
SessionUiStyle.View.Attachment.CARD_HEIGHT,
|
||||
)
|
||||
|
||||
override fun maximumLayoutSize(target: Container) = preferredLayoutSize(target)
|
||||
override fun getLayoutAlignmentX(target: Container) = 0f
|
||||
override fun getLayoutAlignmentY(target: Container) = 0f
|
||||
override fun invalidateLayout(target: Container) = Unit
|
||||
|
||||
override fun layoutContainer(parent: Container) {
|
||||
val size = JBUI.scale(SessionUiStyle.View.Attachment.CLOSE_SIZE)
|
||||
for (i in 0 until parent.componentCount) {
|
||||
val child = parent.getComponent(i)
|
||||
if (child is JButton) {
|
||||
child.setBounds(parent.width - size - UiStyle.Gap.xs(), UiStyle.Gap.xs(), size, size)
|
||||
continue
|
||||
}
|
||||
child.setBounds(0, 0, parent.width, parent.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class CloseButton : JButton() {
|
||||
init {
|
||||
iconButton(this)
|
||||
icon = REMOVE_ICON
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseEntered(e: MouseEvent) {
|
||||
icon = REMOVE_HOVER_ICON
|
||||
}
|
||||
|
||||
override fun mouseExited(e: MouseEvent) {
|
||||
icon = REMOVE_ICON
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val REMOVE_ICON: Icon = IconLoader.getIcon("/icons/remove.svg", AttachmentCard::class.java)
|
||||
private val REMOVE_HOVER_ICON: Icon = IconLoader.getIcon("/icons/remove-hover.svg", AttachmentCard::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scale(image: Image, width: Int, height: Int): Image {
|
||||
val iw = image.getWidth(null)
|
||||
val ih = image.getHeight(null)
|
||||
if (iw <= 0 || ih <= 0) return image
|
||||
val ratio = minOf(width.toDouble() / iw, height.toDouble() / ih)
|
||||
val w = maxOf(1, (iw * ratio).toInt())
|
||||
val h = maxOf(1, (ih * ratio).toInt())
|
||||
return image.getScaledInstance(w, h, Image.SCALE_SMOOTH)
|
||||
}
|
||||
|
||||
private fun local(item: AttachmentCardItem): Path? {
|
||||
if (item.path != null) return item.path
|
||||
val uri = runCatching { URI.create(item.url) }.getOrNull() ?: return null
|
||||
if (uri.scheme != "file") return null
|
||||
return runCatching { Path.of(uri) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun tooltip(item: AttachmentCardItem): String = XmlStringUtil.wrapInHtml(
|
||||
StringUtil.escapeXmlEntities(
|
||||
KiloBundle.message("prompt.attachment.tooltip", item.name, item.mime, location(item)),
|
||||
).replace("\n", "<br>"),
|
||||
)
|
||||
|
||||
private fun location(item: AttachmentCardItem): String {
|
||||
if (item.path != null) return item.path.toString()
|
||||
val uri = runCatching { URI.create(item.url) }.getOrNull()
|
||||
if (uri?.scheme == "data") return KiloBundle.message("prompt.attachment.embedded")
|
||||
if (uri?.scheme == "file") return runCatching { Path.of(uri).toString() }
|
||||
.getOrElse { URLDecoder.decode(uri.rawSchemeSpecificPart.removePrefix("//"), StandardCharsets.UTF_8) }
|
||||
return item.url
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
package ai.kilocode.client.session.ui.attachment
|
||||
|
||||
import ai.kilocode.client.app.KiloAppService
|
||||
import ai.kilocode.client.app.KiloSessionService
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.client.vfs.KiloEditorKind
|
||||
import ai.kilocode.client.vfs.KiloEditorKindRegistry
|
||||
import ai.kilocode.client.vfs.KiloVirtualFile
|
||||
import ai.kilocode.log.KiloLog
|
||||
import ai.kilocode.rpc.dto.KiloAppStatusDto
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.ui.AnimatedIcon
|
||||
import com.intellij.ui.components.ActionLink
|
||||
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.Centerizer
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.BorderLayout
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.security.MessageDigest
|
||||
import javax.imageio.ImageIO
|
||||
import javax.swing.Icon
|
||||
import javax.swing.ImageIcon
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.JPanel
|
||||
import javax.swing.SwingConstants
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal object AttachmentEditorKind : KiloEditorKind {
|
||||
const val ID = "attachment"
|
||||
|
||||
override val id: String = ID
|
||||
|
||||
override fun title(params: Map<String, String>): String = ref(params)?.filename ?: KiloBundle.message("session.attachment.title")
|
||||
override fun icon(params: Map<String, String>): Icon? = attachmentIcon(params["mime"].orEmpty(), title(params))
|
||||
override fun presentablePath(params: Map<String, String>): String {
|
||||
val ref = ref(params)
|
||||
return KiloBundle.message("session.attachment.path", ref?.sessionId.orEmpty(), ref?.filename ?: title(params))
|
||||
}
|
||||
|
||||
override fun isValid(params: Map<String, String>): Boolean = ref(params) != null
|
||||
|
||||
@RequiresEdt
|
||||
override fun createContent(project: Project, file: KiloVirtualFile, parent: Disposable): JComponent {
|
||||
val panel = JPanel(BorderLayout()).apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad())
|
||||
}
|
||||
panel.add(component(AttachmentData.Connecting), BorderLayout.CENTER)
|
||||
val ref = ref(file.path.params)
|
||||
LOG.info("kind=attachment-editor phase=create-content valid=${ref != null} project=${project.name} hash=${project.locationHash} ref=${ref?.let(::brief) ?: "invalid"}")
|
||||
if (ref == null) {
|
||||
panel.removeAll()
|
||||
panel.add(component(AttachmentData.Missing), BorderLayout.CENTER)
|
||||
return panel
|
||||
}
|
||||
project.service<KiloAttachmentEditorService>().load(ref, parent) { data ->
|
||||
LOG.info("kind=attachment-editor phase=render data=${describe(data)} ref=${brief(ref)}")
|
||||
panel.removeAll()
|
||||
panel.add(component(data), BorderLayout.CENTER)
|
||||
panel.revalidate()
|
||||
panel.repaint()
|
||||
}
|
||||
return panel
|
||||
}
|
||||
|
||||
private fun ref(params: Map<String, String>): AttachmentRef? {
|
||||
val session = params["sessionId"].takeIfPresent() ?: return null
|
||||
val message = params["messageId"].takeIfPresent() ?: return null
|
||||
val part = params["partId"].takeIfPresent() ?: return null
|
||||
val dir = params["directory"].takeIfPresent() ?: return null
|
||||
return AttachmentRef(
|
||||
directory = dir,
|
||||
sessionId = session,
|
||||
messageId = message,
|
||||
partId = part,
|
||||
attachmentKey = params["attachmentKey"].takeIfPresent(),
|
||||
filename = params["filename"].takeIfPresent() ?: part,
|
||||
mime = params["mime"].orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
private val LOG = KiloLog.create(AttachmentEditorKind::class.java)
|
||||
}
|
||||
|
||||
private fun component(data: AttachmentData): JComponent = when (data) {
|
||||
is AttachmentData.Text -> text(data.text)
|
||||
is AttachmentData.Image -> JBScrollPane(JBLabel(ImageIcon(data.image), SwingConstants.CENTER))
|
||||
is AttachmentData.Binary -> metadata(data.name, data.mime, data.size)
|
||||
is AttachmentData.Missing -> center(KiloBundle.message("session.attachment.missing"))
|
||||
is AttachmentData.Error -> center(KiloBundle.message("session.attachment.error", data.message))
|
||||
AttachmentData.Connecting -> connecting()
|
||||
AttachmentData.ConnectionFailed -> failed()
|
||||
}
|
||||
|
||||
private fun connecting(): JComponent {
|
||||
return Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad())
|
||||
next(JBLabel(AnimatedIcon.Default()))
|
||||
next(JBLabel(KiloBundle.message("session.connection.connecting")))
|
||||
}.let { Centerizer(it, Centerizer.TYPE.BOTH) }
|
||||
}
|
||||
|
||||
private fun failed(): JComponent {
|
||||
return Stack.horizontal(gap = UiStyle.Gap.sm()).apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad())
|
||||
next(JBLabel(KiloBundle.message("session.connection.error.app")))
|
||||
next(ActionLink(KiloBundle.message("session.connection.retry")) {
|
||||
service<KiloAppService>().retryAsync()
|
||||
})
|
||||
}.let { Centerizer(it, Centerizer.TYPE.BOTH) }
|
||||
}
|
||||
|
||||
private fun text(value: String): JComponent {
|
||||
val area = JBTextArea(value).apply {
|
||||
isEditable = false
|
||||
lineWrap = false
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.sm())
|
||||
}
|
||||
return JBScrollPane(area)
|
||||
}
|
||||
|
||||
private fun metadata(name: String, mime: String, size: Int): JComponent {
|
||||
return Stack.vertical(gap = UiStyle.Gap.sm()).apply {
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.pad())
|
||||
next(JBLabel(KiloBundle.message("session.attachment.unsupported", name)))
|
||||
next(JBLabel(KiloBundle.message("session.attachment.mime", mime.ifBlank { "unknown" })))
|
||||
next(JBLabel(KiloBundle.message("session.attachment.size", size)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun center(value: String): JComponent = Centerizer(JBLabel(value), Centerizer.TYPE.BOTH)
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
internal class KiloAttachmentEditorService(
|
||||
private val project: Project,
|
||||
private val cs: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
private val LOG = KiloLog.create(KiloAttachmentEditorService::class.java)
|
||||
}
|
||||
|
||||
fun load(ref: AttachmentRef, parent: Disposable, done: (AttachmentData) -> Unit) {
|
||||
LOG.info("kind=attachment-load phase=start project=${project.name} hash=${project.locationHash} ref=${brief(ref)}")
|
||||
val disposed = AtomicBoolean(false)
|
||||
val job = cs.launch {
|
||||
val app = service<KiloAppService>()
|
||||
app.connect()
|
||||
while (!disposed.get()) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (alive(disposed)) {
|
||||
LOG.info("kind=attachment-load phase=connecting ref=${brief(ref)}")
|
||||
done(AttachmentData.Connecting)
|
||||
}
|
||||
}
|
||||
val state = app.state.first { it.status == KiloAppStatusDto.READY || it.status == KiloAppStatusDto.ERROR }
|
||||
LOG.info("kind=attachment-load phase=app-state status=${state.status} ref=${brief(ref)}")
|
||||
if (state.status == KiloAppStatusDto.ERROR) {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (alive(disposed)) {
|
||||
LOG.info("kind=attachment-load phase=connection-failed ref=${brief(ref)}")
|
||||
done(AttachmentData.ConnectionFailed)
|
||||
}
|
||||
}
|
||||
app.state.first { it.status != KiloAppStatusDto.ERROR }
|
||||
continue
|
||||
}
|
||||
val data = runCatching { fetch(ref) }
|
||||
.getOrElse {
|
||||
LOG.warn("kind=attachment-load phase=fetch-error ref=${brief(ref)} message=${it.message}", it)
|
||||
AttachmentData.Error(it.message ?: it::class.java.simpleName)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (alive(disposed)) {
|
||||
LOG.info("kind=attachment-load phase=done data=${describe(data)} ref=${brief(ref)}")
|
||||
done(data)
|
||||
}
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
Disposer.register(parent) {
|
||||
disposed.set(true)
|
||||
LOG.info("kind=attachment-load phase=dispose ref=${brief(ref)}")
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun alive(disposed: AtomicBoolean): Boolean = !project.isDisposed && !disposed.get()
|
||||
|
||||
private suspend fun fetch(ref: AttachmentRef): AttachmentData {
|
||||
val item = project.service<KiloSessionService>().attachmentPart(
|
||||
ref.sessionId,
|
||||
ref.directory,
|
||||
ref.messageId,
|
||||
ref.partId,
|
||||
ref.attachmentKey,
|
||||
) ?: run {
|
||||
LOG.info("kind=attachment-fetch result=missing reason=part-not-found session=${ref.sessionId} message=${ref.messageId} part=${ref.partId} key=${ref.attachmentKey ?: "none"}")
|
||||
return AttachmentData.Missing
|
||||
}
|
||||
val mode = if (ref.attachmentKey.isPresent()) "attachmentKey" else "partId"
|
||||
LOG.info("kind=attachment-fetch phase=matched mode=$mode session=${ref.sessionId} message=${ref.messageId} part=${item.id} name=${item.filename.orEmpty()} mime=${item.mime.orEmpty()} url=${urlInfo(item.url.orEmpty())}")
|
||||
val data = parseDataUrl(item.url.orEmpty()) ?: run {
|
||||
LOG.info("kind=attachment-fetch result=missing reason=parse-data-url session=${ref.sessionId} message=${ref.messageId} part=${item.id} url=${urlInfo(item.url.orEmpty())}")
|
||||
return AttachmentData.Missing
|
||||
}
|
||||
val mime = item.mime?.takeIf { it.isNotBlank() } ?: data.mime
|
||||
val name = item.filename?.takeIf { it.isNotBlank() } ?: ref.filename
|
||||
LOG.info("kind=attachment-fetch phase=parsed session=${ref.sessionId} message=${ref.messageId} part=${item.id} name=$name dtoMime=${item.mime.orEmpty()} dataMime=${data.mime} mime=$mime bytes=${data.bytes.size}")
|
||||
if (textual(mime)) return AttachmentData.Text(data.bytes.toString(Charsets.UTF_8))
|
||||
if (mime.startsWith("image/")) {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val image = ImageIO.read(ByteArrayInputStream(data.bytes)) ?: return@withContext AttachmentData.Binary(name, mime, data.bytes.size)
|
||||
LOG.info("kind=attachment-fetch phase=image session=${ref.sessionId} message=${ref.messageId} part=${item.id} width=${image.width} height=${image.height} bytes=${data.bytes.size}")
|
||||
AttachmentData.Image(image)
|
||||
}
|
||||
}
|
||||
return AttachmentData.Binary(name, mime, data.bytes.size)
|
||||
}
|
||||
}
|
||||
|
||||
internal data class AttachmentRef(
|
||||
val directory: String,
|
||||
val sessionId: String,
|
||||
val messageId: String,
|
||||
val partId: String,
|
||||
val attachmentKey: String?,
|
||||
val filename: String,
|
||||
val mime: String,
|
||||
)
|
||||
|
||||
fun ensureAttachmentEditorKind() {
|
||||
service<KiloEditorKindRegistry>().register(AttachmentEditorKind)
|
||||
}
|
||||
|
||||
internal fun unregisterAttachmentEditorKind() {
|
||||
service<KiloEditorKindRegistry>().unregister(AttachmentEditorKind.ID)
|
||||
}
|
||||
|
||||
internal fun attachmentParams(
|
||||
sessionId: String,
|
||||
messageId: String,
|
||||
item: FileAttachment,
|
||||
filename: String,
|
||||
directory: String,
|
||||
): Map<String, String> = linkedMapOf(
|
||||
"directory" to directory,
|
||||
"sessionId" to sessionId,
|
||||
"messageId" to messageId,
|
||||
"partId" to item.id,
|
||||
"attachmentKey" to attachmentKey(item.id, item.filename.orEmpty(), item.url),
|
||||
"filename" to filename,
|
||||
"mime" to item.mime,
|
||||
)
|
||||
|
||||
internal sealed interface AttachmentData {
|
||||
data class Text(val text: String) : AttachmentData
|
||||
data class Image(val image: BufferedImage) : AttachmentData
|
||||
data class Binary(val name: String, val mime: String, val size: Int) : AttachmentData
|
||||
data object Missing : AttachmentData
|
||||
data class Error(val message: String) : AttachmentData
|
||||
data object Connecting : AttachmentData
|
||||
data object ConnectionFailed : AttachmentData
|
||||
}
|
||||
|
||||
private fun brief(ref: AttachmentRef): String {
|
||||
return listOf(
|
||||
"sessionId=${ref.sessionId}",
|
||||
"messageId=${ref.messageId}",
|
||||
"partId=${ref.partId}",
|
||||
"attachmentKey=${ref.attachmentKey ?: ""}",
|
||||
"filename=${ref.filename}",
|
||||
"mime=${ref.mime}",
|
||||
"directory=${ref.directory}",
|
||||
).joinToString(prefix = "{", postfix = "}")
|
||||
}
|
||||
|
||||
private fun String?.isPresent(): Boolean = !this.isNullOrBlank()
|
||||
private fun String?.takeIfPresent(): String? = takeIf { !it.isNullOrBlank() }
|
||||
|
||||
private fun describe(data: AttachmentData): String = when (data) {
|
||||
is AttachmentData.Text -> "text chars=${data.text.length}"
|
||||
is AttachmentData.Image -> "image width=${data.image.width} height=${data.image.height}"
|
||||
is AttachmentData.Binary -> "binary name=${data.name} mime=${data.mime} bytes=${data.size}"
|
||||
is AttachmentData.Error -> "error message=${data.message}"
|
||||
AttachmentData.Missing -> "missing"
|
||||
AttachmentData.Connecting -> "connecting"
|
||||
AttachmentData.ConnectionFailed -> "connection-failed"
|
||||
}
|
||||
|
||||
private fun urlInfo(url: String): String {
|
||||
val scheme = url.substringBefore(':', missingDelimiterValue = "none")
|
||||
return "urlScheme=$scheme urlChars=${url.length} embedded=${isEmbeddedAttachment(url)}"
|
||||
}
|
||||
|
||||
private fun attachmentKey(part: String, name: String, url: String): String {
|
||||
val value = listOf(part, name, url).joinToString("\u0000")
|
||||
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
|
||||
return bytes.take(16).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package ai.kilocode.client.session.ui.attachment
|
||||
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.openapi.fileTypes.FileTypeManager
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Base64
|
||||
import javax.swing.Icon
|
||||
|
||||
fun decodeDataImage(url: String): ByteArray? {
|
||||
val data = parseDataUrl(url) ?: return null
|
||||
if (!data.mime.startsWith("image/")) return null
|
||||
return data.bytes
|
||||
}
|
||||
|
||||
internal data class DataUrl(val mime: String, val bytes: ByteArray)
|
||||
|
||||
internal fun parseDataUrl(url: String): DataUrl? {
|
||||
if (!url.startsWith("data:")) return null
|
||||
val comma = url.indexOf(',')
|
||||
if (comma < 0) return null
|
||||
val meta = url.substring(5, comma)
|
||||
val body = url.substring(comma + 1)
|
||||
val parts = meta.split(';').filter { it.isNotBlank() }
|
||||
val mime = parts.firstOrNull()?.takeIf { it.contains('/') } ?: "text/plain"
|
||||
val bytes = if (parts.any { it.equals("base64", ignoreCase = true) }) {
|
||||
runCatching { Base64.getDecoder().decode(body) }.getOrNull() ?: return null
|
||||
} else {
|
||||
URLDecoder.decode(body, StandardCharsets.UTF_8).toByteArray(StandardCharsets.UTF_8)
|
||||
}
|
||||
return DataUrl(mime, bytes)
|
||||
}
|
||||
|
||||
internal fun textual(mime: String) = mime.startsWith("text/") || mime in setOf(
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"application/xml",
|
||||
"application/x-yaml",
|
||||
)
|
||||
|
||||
internal fun attachmentIcon(mime: String, name: String = "attachment"): Icon = when {
|
||||
mime.startsWith("image/") -> AllIcons.FileTypes.Image
|
||||
mime == "application/x-directory" -> AllIcons.Nodes.Folder
|
||||
else -> FileTypeManager.getInstance().getFileTypeByFileName(name).icon ?: AllIcons.FileTypes.Text
|
||||
}
|
||||
|
||||
fun isEmbeddedAttachment(url: String) = url.startsWith("data:")
|
||||
|
||||
fun isLocalAttachment(url: String) = runCatching { java.net.URI.create(url).scheme == "file" }.getOrDefault(false)
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
package ai.kilocode.client.session.ui.header
|
||||
|
||||
import java.awt.Component
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.geom.AffineTransform
|
||||
import javax.swing.Icon
|
||||
|
||||
internal class RotatedIcon(private val base: Icon) : Icon {
|
||||
override fun getIconWidth(): Int = base.iconWidth
|
||||
|
||||
override fun getIconHeight(): Int = base.iconHeight
|
||||
|
||||
override fun paintIcon(c: Component?, g: Graphics, x: Int, y: Int) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
try {
|
||||
val tx = AffineTransform()
|
||||
tx.translate((x + iconWidth / 2.0), (y + iconHeight / 2.0))
|
||||
tx.rotate(Math.PI)
|
||||
tx.translate((-iconWidth / 2.0), (-iconHeight / 2.0))
|
||||
g2.transform(tx)
|
||||
base.paintIcon(c, g2, 0, 0)
|
||||
} finally {
|
||||
g2.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
-21
@@ -10,12 +10,14 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.todo.TodoListPanel
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.rpc.dto.TodoDto
|
||||
import ai.kilocode.rpc.dto.TokensDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ide.util.PropertiesComponent
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.ui.components.JBLabel
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.components.BorderLayoutPanel
|
||||
@@ -42,8 +44,6 @@ class SessionHeaderPanel(
|
||||
|
||||
companion object {
|
||||
private val COMPRESS_ICON: Icon = IconLoader.getIcon("/icons/compress.svg", SessionHeaderPanel::class.java)
|
||||
private val CHEVRON_ICON: Icon = IconLoader.getIcon("/icons/chevron-down.svg", SessionHeaderPanel::class.java)
|
||||
private val CHEVRON_UP_ICON: Icon = RotatedIcon(CHEVRON_ICON)
|
||||
private val UP_ICON: Icon = IconLoader.getIcon("/icons/arrow-up.svg", SessionHeaderPanel::class.java)
|
||||
private val DOWN_ICON: Icon = IconLoader.getIcon("/icons/arrow-down-to-line.svg", SessionHeaderPanel::class.java)
|
||||
private const val TOUCH_BEGIN = 2
|
||||
@@ -60,15 +60,20 @@ class SessionHeaderPanel(
|
||||
private val todoList = TodoListPanel()
|
||||
private val compact = HoverIcon().apply {
|
||||
icon = COMPRESS_ICON
|
||||
cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)
|
||||
toolTipText = KiloBundle.message("session.header.compact.description")
|
||||
accessibleContext.accessibleName = KiloBundle.message("session.header.compact")
|
||||
addActionListener { controller.compact() }
|
||||
}
|
||||
private val expand = HoverIcon().apply {
|
||||
icon = CHEVRON_ICON
|
||||
private val expand = JBLabel().apply {
|
||||
cursor = java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR)
|
||||
toolTipText = KiloBundle.message("session.header.expand")
|
||||
accessibleContext.accessibleName = KiloBundle.message("session.header.expand")
|
||||
addActionListener { toggle() }
|
||||
addMouseListener(object : MouseAdapter() {
|
||||
override fun mouseClicked(event: MouseEvent) {
|
||||
toggle()
|
||||
}
|
||||
})
|
||||
}
|
||||
private val timeline = TimelinePanel()
|
||||
private val viewport = JViewport().apply {
|
||||
@@ -97,16 +102,18 @@ class SessionHeaderPanel(
|
||||
iconTextGap = UiStyle.Gap.xs()
|
||||
}
|
||||
private val top = BorderLayoutPanel()
|
||||
private val right = JPanel(FlowLayout(FlowLayout.RIGHT, UiStyle.Gap.md(), 0)).apply {
|
||||
isOpaque = false
|
||||
add(cost)
|
||||
add(context)
|
||||
add(compact)
|
||||
add(expand)
|
||||
private val center = BorderLayoutPanel().apply {
|
||||
border = JBUI.Borders.empty(0, UiStyle.Gap.md(), 0, 0)
|
||||
}
|
||||
private val right = Stack.horizontal()
|
||||
.next(cost)
|
||||
.gap(UiStyle.Gap.xl())
|
||||
.next(context)
|
||||
.gap(UiStyle.Gap.sm())
|
||||
.next(compact)
|
||||
private val tokens = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.pad(), 0, UiStyle.Gap.pad())
|
||||
add(tokenTitle)
|
||||
add(Box.createHorizontalStrut(UiStyle.Gap.md()))
|
||||
add(input)
|
||||
@@ -134,20 +141,28 @@ class SessionHeaderPanel(
|
||||
private val body = JPanel().apply {
|
||||
isOpaque = false
|
||||
layout = BoxLayout(this, BoxLayout.Y_AXIS)
|
||||
border = JBUI.Borders.empty(UiStyle.Gap.sm(), 0, 0, 0)
|
||||
border = JBUI.Borders.empty(
|
||||
UiStyle.Gap.sm(),
|
||||
UiStyle.Gap.xl(),
|
||||
UiStyle.Gap.md(),
|
||||
UiStyle.Gap.xl(),
|
||||
)
|
||||
add(viewport)
|
||||
add(tokens)
|
||||
add(bar)
|
||||
add(todoBox)
|
||||
}
|
||||
private var style = SessionEditorStyle.current()
|
||||
private var costValue = ""
|
||||
|
||||
init {
|
||||
isOpaque = true
|
||||
updateUI()
|
||||
|
||||
top.add(title, BorderLayout.CENTER)
|
||||
top.add(right, BorderLayout.EAST)
|
||||
center.add(title, BorderLayout.CENTER)
|
||||
center.add(right, BorderLayout.EAST)
|
||||
top.add(expand, BorderLayout.WEST)
|
||||
top.add(center, BorderLayout.CENTER)
|
||||
add(top, BorderLayout.NORTH)
|
||||
timeline.addMouseListener(object : MouseAdapter() {
|
||||
override fun mousePressed(event: MouseEvent) {
|
||||
@@ -208,11 +223,13 @@ class SessionHeaderPanel(
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
border = JBUI.Borders.compound(
|
||||
JBUI.Borders.customLine(JBUI.CurrentTheme.ToolWindow.borderColor(), 1, 0, 1, 0),
|
||||
JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.pad(), UiStyle.Gap.sm(), UiStyle.Gap.pad()),
|
||||
JBUI.Borders.customLine(separator(), 0, 0, 1, 0),
|
||||
JBUI.Borders.empty(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun separator() = JBColor.namedColor("EditorTabs.underTabsBorderColor", JBUI.CurrentTheme.EditorTabs.borderColor())
|
||||
|
||||
fun update(header: SessionHeaderSnapshot) {
|
||||
val before = isVisible
|
||||
title.text = header.title
|
||||
@@ -227,7 +244,7 @@ class SessionHeaderPanel(
|
||||
|
||||
syncExpanded(expanded())
|
||||
|
||||
set(cost, money(header.cost))
|
||||
setCost(money(header.cost))
|
||||
set(context, contextText(header.context))
|
||||
context.toolTipText = contextTip(header.context)
|
||||
setTokens(header.tokens)
|
||||
@@ -247,6 +264,10 @@ class SessionHeaderPanel(
|
||||
background = style.editorBackground
|
||||
foreground = style.editorForeground
|
||||
top.background = style.editorBackground
|
||||
top.isOpaque = true
|
||||
top.border = JBUI.Borders.empty(UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.md(), UiStyle.Gap.sm())
|
||||
center.background = style.editorBackground
|
||||
center.isOpaque = true
|
||||
right.background = style.editorBackground
|
||||
tokens.background = style.editorBackground
|
||||
todoRow.background = style.editorBackground
|
||||
@@ -257,6 +278,7 @@ class SessionHeaderPanel(
|
||||
title.foreground = style.editorForeground
|
||||
cost.font = style.regularFont
|
||||
cost.foreground = style.editorForeground
|
||||
cost.icon = null
|
||||
context.font = style.regularFont
|
||||
context.foreground = style.editorForeground
|
||||
todos.font = style.smallFont
|
||||
@@ -279,7 +301,9 @@ class SessionHeaderPanel(
|
||||
|
||||
internal fun titleText(): String = title.text
|
||||
|
||||
internal fun costText(): String = cost.text
|
||||
internal fun costText(): String = costValue
|
||||
|
||||
internal fun costTip() = cost.toolTipText
|
||||
|
||||
internal fun contextText(): String = context.text
|
||||
|
||||
@@ -392,6 +416,16 @@ class SessionHeaderPanel(
|
||||
tokens.isVisible = total > 0
|
||||
}
|
||||
|
||||
private fun setCost(value: String?) {
|
||||
costValue = value.orEmpty()
|
||||
cost.text = costValue
|
||||
cost.icon = null
|
||||
val tip = costValue.takeIf { it.isNotBlank() }?.let { KiloBundle.message("session.header.cost.tooltip", it) }
|
||||
cost.toolTipText = tip
|
||||
cost.accessibleContext.accessibleName = tip
|
||||
cost.isVisible = costValue.isNotBlank()
|
||||
}
|
||||
|
||||
private fun syncTodos(items: List<TodoDto>) {
|
||||
val total = items.size
|
||||
val done = items.count { it.status == "completed" }
|
||||
@@ -447,7 +481,10 @@ class SessionHeaderPanel(
|
||||
|
||||
private fun collapse(): Boolean {
|
||||
val attached = body.parent === this
|
||||
if (!attached) return false
|
||||
if (!attached) {
|
||||
setExpand(false)
|
||||
return false
|
||||
}
|
||||
remove(body)
|
||||
setExpand(false)
|
||||
return attached
|
||||
@@ -455,7 +492,8 @@ class SessionHeaderPanel(
|
||||
|
||||
private fun setExpand(expanded: Boolean) {
|
||||
val key = if (expanded) "session.header.collapse" else "session.header.expand"
|
||||
expand.icon = if (expanded) CHEVRON_UP_ICON else CHEVRON_ICON
|
||||
expand.text = ""
|
||||
expand.icon = if (expanded) AllIcons.General.ChevronDown else AllIcons.General.ChevronRight
|
||||
expand.toolTipText = KiloBundle.message(key)
|
||||
expand.accessibleContext.accessibleName = KiloBundle.message(key)
|
||||
}
|
||||
|
||||
+1
@@ -57,6 +57,7 @@ class ModelPicker : PickerButton() {
|
||||
val recommendedIndex: Double? = null,
|
||||
val free: Boolean = false,
|
||||
val variants: List<String> = emptyList(),
|
||||
val attachment: Boolean = false,
|
||||
) {
|
||||
val key: String get() = "$provider/$id"
|
||||
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package ai.kilocode.client.session.ui.prompt
|
||||
|
||||
import com.intellij.ide.PasteProvider
|
||||
import com.intellij.ide.dnd.FileCopyPasteUtil
|
||||
import com.intellij.openapi.actionSystem.ActionUpdateThread
|
||||
import com.intellij.openapi.actionSystem.CommonDataKeys
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import com.intellij.openapi.editor.actions.PasteAction
|
||||
import com.intellij.openapi.util.Key
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.Transferable
|
||||
|
||||
internal fun interface PromptAttachmentPasteHandler {
|
||||
fun paste(transferable: Transferable)
|
||||
}
|
||||
|
||||
internal val PROMPT_ATTACHMENT_PASTE_HANDLER_KEY: Key<PromptAttachmentPasteHandler> =
|
||||
Key.create("ai.kilocode.client.session.ui.prompt.PromptAttachmentPasteHandler")
|
||||
|
||||
internal class PromptAttachmentPasteProvider : PasteProvider {
|
||||
override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT
|
||||
|
||||
override fun isPastePossible(dataContext: DataContext): Boolean = transferable(dataContext) != null
|
||||
|
||||
override fun isPasteEnabled(dataContext: DataContext): Boolean = isPastePossible(dataContext)
|
||||
|
||||
override fun performPaste(dataContext: DataContext) {
|
||||
val editor = dataContext.getData(CommonDataKeys.EDITOR) ?: return
|
||||
val handler = editor.getUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY) ?: return
|
||||
val item = transferable(dataContext) ?: return
|
||||
handler.paste(item)
|
||||
}
|
||||
|
||||
private fun transferable(dataContext: DataContext): Transferable? {
|
||||
val editor = dataContext.getData(CommonDataKeys.EDITOR) ?: return null
|
||||
if (editor.getUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY) == null) return null
|
||||
val item = dataContext.getData(PasteAction.TRANSFERABLE_PROVIDER)?.produce() ?: return null
|
||||
if (FileCopyPasteUtil.isFileListFlavorAvailable(item.transferDataFlavors)) return item
|
||||
if (item.isDataFlavorSupported(DataFlavor.imageFlavor)) return item
|
||||
return null
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package ai.kilocode.client.session.ui.prompt
|
||||
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.model.PromptAttachment
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentCard
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.notification.Notification
|
||||
import com.intellij.notification.NotificationType
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.fileEditor.FileEditorManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.vfs.LocalFileSystem
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.FlowLayout
|
||||
import javax.swing.JPanel
|
||||
|
||||
class PromptAttachmentStrip(
|
||||
private val project: Project,
|
||||
private val removed: (PromptAttachment) -> Unit,
|
||||
) : JPanel(FlowLayout(FlowLayout.LEFT, UiStyle.Gap.sm(), UiStyle.Gap.sm())) {
|
||||
private val chips = LinkedHashMap<String, PromptAttachmentChip>()
|
||||
|
||||
init {
|
||||
border = JBUI.Borders.emptyBottom(UiStyle.Gap.sm())
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
val count: Int get() = chips.size
|
||||
|
||||
@RequiresEdt
|
||||
fun add(item: PromptAttachment) {
|
||||
if (chips.containsKey(item.id)) return
|
||||
val chip = PromptAttachmentChip(project, item, remove = { removed(item) })
|
||||
chips[item.id] = chip
|
||||
add(chip)
|
||||
sync()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun remove(item: PromptAttachment) {
|
||||
val chip = chips.remove(item.id) ?: return
|
||||
remove(chip)
|
||||
sync()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun clear() {
|
||||
if (chips.isEmpty()) return
|
||||
chips.clear()
|
||||
removeAll()
|
||||
sync()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun sync() {
|
||||
isVisible = chips.isNotEmpty()
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
}
|
||||
|
||||
private class PromptAttachmentChip(
|
||||
project: Project,
|
||||
item: PromptAttachment,
|
||||
remove: () -> Unit,
|
||||
) : AttachmentCard(
|
||||
AttachmentCardItem(item.name, item.mime, item.url, item.path),
|
||||
remove = remove,
|
||||
open = { open(project, item) },
|
||||
) {
|
||||
companion object {
|
||||
private fun open(project: Project, item: PromptAttachment) {
|
||||
val path = item.path ?: return
|
||||
ApplicationManager.getApplication().executeOnPooledThread {
|
||||
val file = LocalFileSystem.getInstance().refreshAndFindFileByNioFile(path)
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
if (file == null) {
|
||||
Notification("Kilo Code", KiloBundle.message("prompt.attachment.missing", item.name), NotificationType.WARNING).notify(project)
|
||||
return@invokeLater
|
||||
}
|
||||
FileEditorManager.getInstance(project).openFile(file, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+249
-47
@@ -5,19 +5,25 @@ import ai.kilocode.client.actions.SendPromptAction
|
||||
import ai.kilocode.client.actions.StopSessionAction
|
||||
import ai.kilocode.client.plugin.KiloBundle
|
||||
import ai.kilocode.client.session.ui.ReasoningPicker
|
||||
import ai.kilocode.client.session.ui.SessionRootPanel
|
||||
import ai.kilocode.client.session.model.PromptAttachment
|
||||
import ai.kilocode.client.session.model.PromptAttachmentExtractor
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyle
|
||||
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.ui.mode.ModePicker
|
||||
import ai.kilocode.client.session.ui.model.ModelPicker
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.client.ui.RoundedContentPanel
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.iconButton
|
||||
import ai.kilocode.log.ChatLogSummary
|
||||
import ai.kilocode.log.KiloLog
|
||||
import ai.kilocode.rpc.dto.PromptPartDto
|
||||
import com.intellij.icons.AllIcons
|
||||
import com.intellij.ide.DataManager
|
||||
import com.intellij.ide.dnd.DnDEvent
|
||||
import com.intellij.ide.dnd.DnDSupport
|
||||
import com.intellij.ide.dnd.FileCopyPasteUtil
|
||||
import com.intellij.openapi.actionSystem.ActionManager
|
||||
import com.intellij.openapi.actionSystem.ActionPlaces
|
||||
import com.intellij.openapi.actionSystem.ActionUiKind
|
||||
@@ -35,9 +41,9 @@ import com.intellij.openapi.keymap.KeymapUtil
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import com.intellij.ui.AnimatedIcon
|
||||
import com.intellij.ui.JBColor
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.xml.util.XmlStringUtil
|
||||
import com.intellij.util.ui.JBValue
|
||||
import com.intellij.util.ui.JBDimension
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
@@ -49,24 +55,30 @@ import java.awt.Cursor
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.datatransfer.DataFlavor
|
||||
import java.awt.datatransfer.Transferable
|
||||
import java.awt.event.FocusAdapter
|
||||
import java.awt.event.FocusEvent
|
||||
import java.awt.event.ComponentAdapter
|
||||
import java.awt.event.ComponentEvent
|
||||
import java.awt.event.MouseAdapter
|
||||
import java.awt.event.MouseEvent
|
||||
import java.util.concurrent.Future
|
||||
import javax.swing.Box
|
||||
import javax.swing.BoxLayout
|
||||
import javax.swing.Icon
|
||||
import javax.swing.JButton
|
||||
import javax.swing.JComponent
|
||||
import javax.swing.ScrollPaneConstants
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
/**
|
||||
* Prompt input panel with borderless IntelliJ editor text field and
|
||||
* mode/model controls grouped inside one rounded editor-background shell.
|
||||
* Prompt input panel with a borderless IntelliJ editor text field and
|
||||
* mode/model controls in the full bottom session area.
|
||||
*/
|
||||
class PromptPanel(
|
||||
private val project: Project,
|
||||
private val onSend: (String) -> Unit,
|
||||
private val onSend: (String, List<PromptPartDto>) -> Unit,
|
||||
private val onAbort: () -> Unit,
|
||||
private val onEnhance: (String, (Result<String>) -> Unit) -> Unit,
|
||||
) : BorderLayoutPanel(), SessionEditorStyleTarget, SendPromptContext {
|
||||
@@ -88,10 +100,29 @@ class PromptPanel(
|
||||
var onReset: () -> Unit = {}
|
||||
var onChange: () -> Unit = {}
|
||||
var onAutoApproveToggle: (Boolean) -> Unit = {}
|
||||
var onFileDrag: (Boolean) -> Unit = {}
|
||||
private var style = SessionEditorStyle.current()
|
||||
private val shell = PromptShell()
|
||||
private val shell = BorderLayoutPanel().apply {
|
||||
isOpaque = true
|
||||
border = JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
|
||||
)
|
||||
}
|
||||
private val attachments = mutableListOf<PromptAttachment>()
|
||||
private val strip = PromptAttachmentStrip(project) { removeAttachment(it) }
|
||||
private var bus: MessageBusConnection? = null
|
||||
private var autoApprove = false
|
||||
private var attachment = true
|
||||
private var submitting = false
|
||||
private var root: SessionRootPanel? = null
|
||||
private val resize = object : ComponentAdapter() {
|
||||
override fun componentResized(e: ComponentEvent) {
|
||||
syncEditorHeight()
|
||||
}
|
||||
}
|
||||
|
||||
private val editor = PromptEditorTextField(project, this).apply {
|
||||
border = JBUI.Borders.empty()
|
||||
@@ -108,16 +139,22 @@ class PromptPanel(
|
||||
ed.scrollPane.background = style.editorScheme.defaultBackground
|
||||
ed.scrollPane.viewport.background = style.editorScheme.defaultBackground
|
||||
ed.settings.isUseSoftWraps = true
|
||||
ed.settings.isPaintSoftWraps = false
|
||||
ed.settings.isAdditionalPageAtBottom = false
|
||||
ed.putUserData(PROMPT_ATTACHMENT_PASTE_HANDLER_KEY, PromptAttachmentPasteHandler { processPaste(it) })
|
||||
ed.scrollPane.verticalScrollBarPolicy =
|
||||
ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
|
||||
ed.scrollPane.horizontalScrollBarPolicy =
|
||||
ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
|
||||
installFileDrop(ed.contentComponent, "editor")
|
||||
installFileDrop(ed.scrollPane, "scroll")
|
||||
ed.contentComponent.addFocusListener(object : FocusAdapter() {
|
||||
override fun focusGained(e: FocusEvent) {
|
||||
shell.repaint()
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun focusLost(e: FocusEvent) {
|
||||
shell.repaint()
|
||||
repaint()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -166,22 +203,12 @@ class PromptPanel(
|
||||
private var request = 0L
|
||||
|
||||
override val isSendEnabled: Boolean
|
||||
get() = ready && !busy && text().isNotEmpty()
|
||||
get() = ready && !busy && !submitting && (text().isNotEmpty() || attachments.isNotEmpty())
|
||||
|
||||
override val isStopEnabled: Boolean
|
||||
get() = busy
|
||||
|
||||
init {
|
||||
border = JBUI.Borders.compound(
|
||||
JBUI.Borders.customLineTop(JBUI.CurrentTheme.ToolWindow.borderColor()),
|
||||
JBUI.Borders.empty(
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.PANEL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.PANEL_HORIZONTAL_PADDING),
|
||||
),
|
||||
)
|
||||
|
||||
applyStyle(style)
|
||||
editor.text = ""
|
||||
editor.addDocumentListener(object : DocumentListener {
|
||||
@@ -191,6 +218,7 @@ class PromptPanel(
|
||||
onChange()
|
||||
}
|
||||
})
|
||||
shell.add(strip, BorderLayout.NORTH)
|
||||
shell.add(editor, BorderLayout.CENTER)
|
||||
|
||||
val bar = BorderLayoutPanel().apply {
|
||||
@@ -213,17 +241,32 @@ class PromptPanel(
|
||||
bar.add(button)
|
||||
shell.add(bar, BorderLayout.SOUTH)
|
||||
add(shell, BorderLayout.CENTER)
|
||||
addComponentListener(resize)
|
||||
installFileDrop(shell, "shell")
|
||||
syncTooltip()
|
||||
syncAutoApprove()
|
||||
syncEnhance()
|
||||
}
|
||||
|
||||
override fun updateUI() {
|
||||
super.updateUI()
|
||||
border = JBUI.Borders.compound(
|
||||
JBUI.Borders.customLineTop(separator()),
|
||||
JBUI.Borders.empty(),
|
||||
)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setReady(value: Boolean) {
|
||||
ready = value
|
||||
if (!value) invalidateEnhancement() else syncEnhance()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setAttachmentEnabled(value: Boolean) {
|
||||
attachment = value
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setBusy(value: Boolean) {
|
||||
busy = value
|
||||
@@ -270,11 +313,15 @@ class PromptPanel(
|
||||
|
||||
internal fun buttonForTest(): JButton = button
|
||||
|
||||
internal fun attachmentCountForTest(): Int = attachments.size
|
||||
|
||||
internal val defaultFocusedComponent: JComponent get() = editor
|
||||
|
||||
@RequiresEdt
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
this.style = style
|
||||
background = style.editorScheme.defaultBackground
|
||||
shell.background = style.editorScheme.defaultBackground
|
||||
editor.font = style.editorFont
|
||||
editor.getEditor(false)?.let(style::applyToEditor)
|
||||
editor.background = style.editorScheme.defaultBackground
|
||||
@@ -285,9 +332,18 @@ class PromptPanel(
|
||||
@RequiresEdt
|
||||
fun clear() {
|
||||
editor.text = ""
|
||||
attachments.clear()
|
||||
strip.clear()
|
||||
syncEditorHeight()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun addAttachmentForTest(item: PromptAttachment) {
|
||||
addAttachment(item)
|
||||
}
|
||||
|
||||
internal fun processPasteForTest(transferable: Transferable): Future<*> = processPaste(transferable)
|
||||
|
||||
@RequiresEdt
|
||||
fun focus() {
|
||||
editor.requestFocusInWindow()
|
||||
@@ -295,10 +351,13 @@ class PromptPanel(
|
||||
|
||||
override fun addNotify() {
|
||||
super.addNotify()
|
||||
bindRoot()
|
||||
bindKeymap()
|
||||
}
|
||||
|
||||
override fun removeNotify() {
|
||||
root?.removeComponentListener(resize)
|
||||
root = null
|
||||
bus?.disconnect()
|
||||
bus = null
|
||||
super.removeNotify()
|
||||
@@ -310,6 +369,7 @@ class PromptPanel(
|
||||
val source = editor.text
|
||||
if (source.isBlank()) {
|
||||
editor.text = KiloBundle.message("prompt.action.enhance.description")
|
||||
syncEditorHeight()
|
||||
focus()
|
||||
return
|
||||
}
|
||||
@@ -326,6 +386,7 @@ class PromptPanel(
|
||||
syncEnhance()
|
||||
result.onSuccess {
|
||||
editor.text = it
|
||||
syncEditorHeight()
|
||||
focus()
|
||||
}.onFailure {
|
||||
if (it is CancellationException) return@onFailure
|
||||
@@ -359,12 +420,136 @@ class PromptPanel(
|
||||
private fun submit(src: String) {
|
||||
if (!isSendEnabled) return
|
||||
val txt = text()
|
||||
LOG.debug { "${ChatLogSummary.prompt(txt)} src=$src busy=$busy" }
|
||||
if (txt.isNotEmpty()) {
|
||||
onSend(txt)
|
||||
val items = attachments.toList()
|
||||
submitting = true
|
||||
ApplicationManager.getApplication().executeOnPooledThread {
|
||||
try {
|
||||
val files = items.map { it.part() }
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
submitting = false
|
||||
if (project.isDisposed) return@invokeLater
|
||||
LOG.debug { "${ChatLogSummary.prompt(promptDto(txt, files))} src=$src busy=$busy" }
|
||||
onSend(txt, files)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
submitting = false
|
||||
if (project.isDisposed) return@invokeLater
|
||||
LOG.warn("kind=prompt-submit src=$src failed message=${e.message}", e)
|
||||
notify(KiloBundle.message("prompt.attachment.send.failed", e.message ?: e.javaClass.simpleName))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun addAttachment(item: PromptAttachment) {
|
||||
if (!attachment && PromptAttachmentExtractor.media(item.mime)) {
|
||||
LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=unsupported-model" }
|
||||
notify(KiloBundle.message("prompt.attachment.unsupported.model"))
|
||||
return
|
||||
}
|
||||
if (attachments.any { it.id == item.id }) {
|
||||
LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} blocked=duplicate" }
|
||||
return
|
||||
}
|
||||
attachments += item
|
||||
strip.add(item)
|
||||
LOG.debug { "kind=prompt-attachment add name=${item.name} mime=${item.mime} count=${attachments.size}" }
|
||||
syncEditorHeight()
|
||||
onChange()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun removeAttachment(item: PromptAttachment) {
|
||||
if (!attachments.removeIf { it.id == item.id }) return
|
||||
strip.remove(item)
|
||||
syncEditorHeight()
|
||||
onChange()
|
||||
}
|
||||
|
||||
private fun promptDto(text: String, files: List<PromptPartDto>) = ai.kilocode.rpc.dto.PromptDto(
|
||||
parts = buildList {
|
||||
text.takeIf { it.isNotBlank() }?.let { add(PromptPartDto(type = "text", text = it)) }
|
||||
addAll(files)
|
||||
}
|
||||
)
|
||||
|
||||
internal fun installFileDrop(target: JComponent, area: String) {
|
||||
LOG.debug { "kind=prompt-dnd install area=$area component=${target.javaClass.name}" }
|
||||
DnDSupport.createBuilder(target)
|
||||
.enableAsNativeTarget()
|
||||
.setTargetChecker { event ->
|
||||
if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) {
|
||||
onFileDrag(false)
|
||||
LOG.debug { "kind=prompt-dnd check area=$area accept=false flavor=false" }
|
||||
return@setTargetChecker true
|
||||
}
|
||||
event.setDropPossible(true)
|
||||
onFileDrag(true)
|
||||
LOG.debug { "kind=prompt-dnd check area=$area accept=true flavor=true" }
|
||||
false
|
||||
}
|
||||
.setCleanUpOnLeaveCallback {
|
||||
onFileDrag(false)
|
||||
}
|
||||
.setDropHandlerWithResult { event ->
|
||||
val start = System.nanoTime()
|
||||
val files = dropFiles(event)
|
||||
val ms = elapsedMs(start)
|
||||
LOG.debug { "kind=prompt-dnd drop area=$area files=${files.size} extractMs=$ms queued=${files.isNotEmpty()}" }
|
||||
onFileDrag(false)
|
||||
if (files.isEmpty()) return@setDropHandlerWithResult false
|
||||
processAttachments("prompt-dnd", area, files, null, ms)
|
||||
true
|
||||
}
|
||||
.install()
|
||||
}
|
||||
|
||||
private fun processPaste(transferable: Transferable): Future<*> {
|
||||
return processAttachments("prompt-paste", "editor", null, transferable, 0)
|
||||
}
|
||||
|
||||
private fun processAttachments(
|
||||
kind: String,
|
||||
area: String,
|
||||
files: List<java.io.File>?,
|
||||
transferable: Transferable?,
|
||||
sourceMs: Long,
|
||||
): Future<*> {
|
||||
return ApplicationManager.getApplication().executeOnPooledThread {
|
||||
val start = System.nanoTime()
|
||||
try {
|
||||
val list = files ?: transferable?.let { FileCopyPasteUtil.getFileList(it).orEmpty() }.orEmpty()
|
||||
val image = transferable?.takeIf { list.isEmpty() && it.isDataFlavorSupported(DataFlavor.imageFlavor) }
|
||||
?.getTransferData(DataFlavor.imageFlavor)
|
||||
?.let(PromptAttachmentExtractor::image)
|
||||
val items = PromptAttachmentExtractor.files(list) + listOfNotNull(image)
|
||||
val ms = elapsedMs(start)
|
||||
LOG.debug { "kind=$kind extract area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" }
|
||||
if (items.isEmpty()) return@executeOnPooledThread
|
||||
ApplicationManager.getApplication().invokeLater {
|
||||
if (project.isDisposed) return@invokeLater
|
||||
LOG.debug { "kind=$kind attach area=$area files=${list.size} image=${image != null} attachments=${items.size} extractMs=$ms sourceMs=$sourceMs" }
|
||||
items.forEach(::addAttachment)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("kind=$kind extract area=$area failed message=${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dropFiles(event: DnDEvent): List<java.io.File> {
|
||||
if (!FileCopyPasteUtil.isFileListFlavorAvailable(event)) return emptyList()
|
||||
return FileCopyPasteUtil.getFileListFromAttachedObject(event.attachedObject).orEmpty()
|
||||
}
|
||||
|
||||
private fun elapsedMs(start: Long) = (System.nanoTime() - start) / 1_000_000
|
||||
|
||||
private fun notify(text: String) {
|
||||
com.intellij.notification.Notification("Kilo Code", text, com.intellij.notification.NotificationType.WARNING).notify(project)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun bindKeymap() {
|
||||
if (bus != null) return
|
||||
@@ -435,21 +620,56 @@ class PromptPanel(
|
||||
return KiloBundle.message("prompt.placeholder")
|
||||
}
|
||||
|
||||
private fun separator() = JBColor.namedColor("EditorTabs.underTabsBorderColor", JBUI.CurrentTheme.EditorTabs.borderColor())
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncEditorHeight() {
|
||||
val count = ApplicationManager.getApplication().runReadAction<Int> { editor.document.lineCount }
|
||||
val lines = (count + SessionUiStyle.View.Prompt.EDITOR_SPARE_LINES).coerceIn(
|
||||
SessionUiStyle.View.Prompt.EDITOR_LINES,
|
||||
SessionUiStyle.View.Prompt.EDITOR_MAX_LINES,
|
||||
)
|
||||
val height = style.editorFont.size * lines + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_CHROME)
|
||||
if (editor.preferredSize.height == height && editor.minimumSize.height == height) return
|
||||
val before = editor.preferredSize.height
|
||||
val lower = editor.minimumSize.height
|
||||
editor.setPreferredSize(null)
|
||||
editor.setMinimumSize(null)
|
||||
editor.getEditor(false)?.let {
|
||||
it.contentComponent.invalidate()
|
||||
it.component.invalidate()
|
||||
it.scrollPane.invalidate()
|
||||
}
|
||||
editor.invalidate()
|
||||
editor.ensureWillComputePreferredSize()
|
||||
val view = editor.getEditor(false)
|
||||
val line = view?.lineHeight ?: editor.getFontMetrics(editor.font).height
|
||||
val min = line * SessionUiStyle.View.Prompt.EDITOR_LINES + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_CHROME)
|
||||
val content = editor.preferredSize.height
|
||||
val sessionCap = rootCap(min)
|
||||
val height = minOf(content, sessionCap ?: content).coerceAtLeast(min)
|
||||
if (before == height && lower == height) {
|
||||
editor.preferredSize = JBDimension(0, height)
|
||||
editor.minimumSize = JBDimension(0, height)
|
||||
return
|
||||
}
|
||||
editor.preferredSize = JBDimension(0, height)
|
||||
editor.minimumSize = JBDimension(0, height)
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun rootCap(min: Int): Int? {
|
||||
val root = root ?: return null
|
||||
if (root.height <= 0) return null
|
||||
val chrome = (shell.preferredSize.height - editor.preferredSize.height).coerceAtLeast(0)
|
||||
return (root.height / 3 - chrome).coerceAtLeast(min)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun bindRoot() {
|
||||
val next = SwingUtilities.getAncestorOfClass(SessionRootPanel::class.java, this) as? SessionRootPanel
|
||||
if (root === next) return
|
||||
root?.removeComponentListener(resize)
|
||||
root = next
|
||||
root?.addComponentListener(resize)
|
||||
syncEditorHeight()
|
||||
}
|
||||
|
||||
private inner class SendButton : JButton(), UiDataProvider {
|
||||
private var over = false
|
||||
|
||||
@@ -552,22 +772,4 @@ class PromptPanel(
|
||||
}
|
||||
}
|
||||
|
||||
private inner class PromptShell : RoundedContentPanel(
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
|
||||
) {
|
||||
private val focus = JBValue.UIInteger("Component.focusWidth", SessionUiStyle.View.Prompt.FOCUS_WIDTH)
|
||||
|
||||
override fun contentColor() = style.editorScheme.defaultBackground
|
||||
|
||||
override fun outlineColor() = if (UIUtil.isFocusAncestor(editor)) {
|
||||
JBUI.CurrentTheme.Focus.focusColor()
|
||||
} else {
|
||||
SessionUiStyle.View.Outline.brightColor()
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
+30
-6
@@ -5,6 +5,7 @@ import com.intellij.ui.JBColor
|
||||
import com.intellij.util.ui.JBUI
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import java.awt.Color
|
||||
import java.awt.Insets
|
||||
import javax.swing.border.Border
|
||||
|
||||
/** Static style tokens owned by the chat session UI. */
|
||||
@@ -16,7 +17,7 @@ object SessionUiStyle {
|
||||
/** Geometry for the transcript list and its scroll behavior. */
|
||||
object SessionLayout {
|
||||
const val GAP = 3
|
||||
const val TRANSCRIPT_PADDING = 12
|
||||
val InnerInsets = Insets(UiStyle.Gap.md(), UiStyle.Gap.md(), UiStyle.Gap.sm(), UiStyle.Gap.sm())
|
||||
const val TRANSCRIPT_SCROLLBAR_PADDING = 10
|
||||
const val USER_PROMPT_INDENT = 100
|
||||
const val SCROLL_INCREMENT = 48
|
||||
@@ -64,8 +65,6 @@ object SessionUiStyle {
|
||||
/** Prompt input dimensions and chrome inside the session view. */
|
||||
object Prompt {
|
||||
const val EDITOR_LINES = 3
|
||||
const val EDITOR_MAX_LINES = 8
|
||||
const val EDITOR_SPARE_LINES = 1
|
||||
const val EDITOR_CHROME = 16
|
||||
const val SEND_BUTTON_SIZE = 24
|
||||
const val CORNER_ARC = 6
|
||||
@@ -77,6 +76,31 @@ object SessionUiStyle {
|
||||
const val SHELL_HORIZONTAL_PADDING = 8
|
||||
}
|
||||
|
||||
/** Attachment preview card geometry. */
|
||||
object Attachment {
|
||||
const val CARD_WIDTH = 80
|
||||
const val CARD_HEIGHT = 59
|
||||
const val CLOSE_SIZE = 18
|
||||
const val CORNER_ARC = 8
|
||||
}
|
||||
|
||||
/** Full-session file drop overlay geometry and colors. */
|
||||
object DropOverlay {
|
||||
const val CARD_VERTICAL_PADDING = 16
|
||||
const val CARD_HORIZONTAL_PADDING = 20
|
||||
const val CARD_ARC = 12
|
||||
const val LABEL_GAP = 2
|
||||
const val ICON_GAP = 10
|
||||
private const val SCRIM_ALPHA = 210
|
||||
|
||||
fun scrim(): Color = JBColor.lazy {
|
||||
val bg = UiStyle.Colors.bg()
|
||||
Color(bg.red, bg.green, bg.blue, SCRIM_ALPHA)
|
||||
}
|
||||
|
||||
fun card(): Color = UiStyle.Colors.contentBackground()
|
||||
}
|
||||
|
||||
/** Reasoning block preview sizing. */
|
||||
object Reasoning {
|
||||
const val BODY_LINES = 5
|
||||
@@ -96,16 +120,16 @@ object SessionUiStyle {
|
||||
|
||||
/** Markdown code block geometry inside assistant messages. */
|
||||
object Code {
|
||||
const val BLOCK_GAP = 6
|
||||
const val BLOCK_GAP = SessionLayout.GAP
|
||||
const val MIN_ROWS = 1
|
||||
const val BORDER_WIDTH = 1
|
||||
const val VIEWPORT_TOP_PADDING = 6
|
||||
const val VIEWPORT_HORIZONTAL_PADDING = 8
|
||||
const val VIEWPORT_BOTTOM_PADDING = 0
|
||||
const val VIEWPORT_BOTTOM_PADDING = 6
|
||||
const val SCROLLBAR_HEIGHT = 12
|
||||
const val WIDTH_PADDING = 16
|
||||
|
||||
fun topPadding(): Int = VIEWPORT_TOP_PADDING + SCROLLBAR_HEIGHT
|
||||
fun topPadding(): Int = VIEWPORT_TOP_PADDING
|
||||
}
|
||||
|
||||
/** Permission session-view command preview limits. */
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentCard
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
|
||||
import ai.kilocode.client.session.views.base.PartView
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.FlowLayout
|
||||
import java.net.URI
|
||||
import java.nio.file.Path
|
||||
|
||||
class AttachmentView(
|
||||
private var item: FileAttachment,
|
||||
private val openAttachment: (FileAttachment) -> Unit,
|
||||
) : PartView() {
|
||||
constructor(
|
||||
item: FileAttachment,
|
||||
openFile: (String) -> Unit,
|
||||
openUrl: (String) -> Unit,
|
||||
) : this(item, { openDefault(it, openFile, openUrl) })
|
||||
|
||||
override val contentId: String = item.id
|
||||
private var chip = chip(item)
|
||||
|
||||
init {
|
||||
layout = FlowLayout(FlowLayout.LEFT, 0, UiStyle.Gap.xs())
|
||||
border = JBUI.Borders.empty(0, UiStyle.Gap.pad(), UiStyle.Gap.pad(), UiStyle.Gap.pad())
|
||||
add(chip)
|
||||
}
|
||||
|
||||
override fun update(content: Content) {
|
||||
if (content !is FileAttachment) return
|
||||
if (same(content)) {
|
||||
item = content
|
||||
return
|
||||
}
|
||||
item = content
|
||||
remove(chip)
|
||||
chip = chip(content)
|
||||
add(chip)
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun dumpLabel(): String = "AttachmentView#${item.id}:${name(item)}"
|
||||
|
||||
private fun chip(item: FileAttachment) = AttachmentCard(
|
||||
AttachmentCardItem(name(item), item.mime, item.url),
|
||||
open = { openAttachment(item) },
|
||||
)
|
||||
|
||||
private fun same(next: FileAttachment) = item.mime == next.mime && item.url == next.url && item.filename == next.filename
|
||||
|
||||
companion object {
|
||||
fun openDefault(item: FileAttachment, openFile: (String) -> Unit, openUrl: (String) -> Unit) {
|
||||
val url = item.url.takeIf { it.isNotBlank() } ?: return
|
||||
val uri = runCatching { URI.create(url) }.getOrNull() ?: return
|
||||
if (uri.scheme == "file") {
|
||||
val path = runCatching { Path.of(uri).toString() }.getOrNull() ?: return
|
||||
openFile(path)
|
||||
return
|
||||
}
|
||||
openUrl(url)
|
||||
}
|
||||
}
|
||||
|
||||
private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() }
|
||||
?: tail(item.url).takeIf { it.isNotBlank() }
|
||||
?: "attachment"
|
||||
|
||||
private fun tail(value: String): String {
|
||||
val clean = value.trimEnd('/', '\\')
|
||||
val index = maxOf(clean.lastIndexOf('/'), clean.lastIndexOf('\\'))
|
||||
if (index < 0) return clean
|
||||
return clean.substring(index + 1)
|
||||
}
|
||||
}
|
||||
+40
-4
@@ -1,6 +1,7 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.model.Message
|
||||
import ai.kilocode.client.session.model.Reasoning
|
||||
import ai.kilocode.client.session.model.StepFinish
|
||||
@@ -15,6 +16,7 @@ import ai.kilocode.client.session.views.base.PartView
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
@@ -38,6 +40,7 @@ class MessageView(
|
||||
private var style: SessionEditorStyle = SessionEditorStyle.current(),
|
||||
private val openUrl: (String) -> Unit = {},
|
||||
private val selection: SessionSelection? = null,
|
||||
private val openAttachment: (String, FileAttachment) -> Unit = { _, item -> AttachmentView.openDefault(item, openFile, openUrl) },
|
||||
private val resize: ((JComponent, () -> Unit) -> Unit)? = null,
|
||||
private val repo: String? = null,
|
||||
private val hover: ((PartView, Boolean) -> Unit)? = null,
|
||||
@@ -58,6 +61,7 @@ class MessageView(
|
||||
// so snapshot updates can append only deltas.
|
||||
private val aliases = LinkedHashMap<String, String>()
|
||||
private val sources = LinkedHashMap<String, String>()
|
||||
private var attachments: PromptAttachmentView? = null
|
||||
private var hidden: ToolCallRef? = null
|
||||
|
||||
init {
|
||||
@@ -112,6 +116,11 @@ class MessageView(
|
||||
}
|
||||
val existing = parts[content.id]
|
||||
if (existing != null) {
|
||||
if (existing is PromptAttachmentView && content is FileAttachment) {
|
||||
existing.upsert(content)
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
if (ViewFactory.shouldReplace(existing, content)) {
|
||||
replacePart(content, existing)
|
||||
return
|
||||
@@ -126,6 +135,10 @@ class MessageView(
|
||||
}
|
||||
|
||||
private fun addPart(content: Content) {
|
||||
if (content is FileAttachment && role == SessionUiStyle.View.Message.USER_ROLE) {
|
||||
addAttachment(content)
|
||||
return
|
||||
}
|
||||
if (content is Reasoning) {
|
||||
val previous = parts.values.lastOrNull()
|
||||
if (previous is ReasoningView) {
|
||||
@@ -143,6 +156,19 @@ class MessageView(
|
||||
add(view)
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun addAttachment(content: FileAttachment) {
|
||||
val view = attachments ?: PromptAttachmentView(msg.info.id) { openAttachment(msg.info.id, it) }.also {
|
||||
it.resize = resize
|
||||
it.hover = hover
|
||||
it.applyStyle(style)
|
||||
attachments = it
|
||||
add(it)
|
||||
}
|
||||
view.upsert(content)
|
||||
parts[content.id] = view
|
||||
}
|
||||
|
||||
private fun updateAlias(content: Reasoning, id: String) {
|
||||
val view = parts[id] as? ReasoningView ?: return
|
||||
val prev = sources[content.id].orEmpty()
|
||||
@@ -184,6 +210,14 @@ class MessageView(
|
||||
return
|
||||
}
|
||||
val view = parts.remove(contentId) ?: return
|
||||
if (view is PromptAttachmentView) {
|
||||
view.remove(contentId)
|
||||
if (!view.isEmpty()) {
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
attachments = null
|
||||
}
|
||||
aliases.values.removeAll { it == contentId }
|
||||
sources.keys.removeAll { it !in aliases }
|
||||
detach(view)
|
||||
@@ -199,6 +233,7 @@ class MessageView(
|
||||
*/
|
||||
private fun isHidden(content: Content): Boolean {
|
||||
if (content !is Tool) return false
|
||||
if (role == SessionUiStyle.View.Message.USER_ROLE && content.name == "read") return true
|
||||
if (content.name == "todoread") return true
|
||||
if (content.name == "todowrite" && content.state != ToolExecState.COMPLETED) return true
|
||||
val ref = hidden ?: return false
|
||||
@@ -212,7 +247,7 @@ class MessageView(
|
||||
* Called only when the hidden ref changes to avoid unnecessary rebuilds.
|
||||
*/
|
||||
private fun rebuildParts() {
|
||||
parts.values.forEach {
|
||||
parts.values.distinct().forEach {
|
||||
detach(it)
|
||||
remove(it)
|
||||
Disposer.dispose(it)
|
||||
@@ -220,6 +255,7 @@ class MessageView(
|
||||
parts.clear()
|
||||
aliases.clear()
|
||||
sources.clear()
|
||||
attachments = null
|
||||
for ((_, content) in msg.parts) {
|
||||
if (content is StepFinish) continue
|
||||
if (isHidden(content)) continue
|
||||
@@ -235,9 +271,9 @@ class MessageView(
|
||||
}
|
||||
|
||||
private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) {
|
||||
ViewFactory.createUser(content, openFile, openUrl, selection, repo)
|
||||
ViewFactory.createUser(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) }
|
||||
} else {
|
||||
ViewFactory.create(content, openFile, openUrl, selection, repo)
|
||||
ViewFactory.create(content, openFile, openUrl, selection, repo) { openAttachment(msg.info.id, it) }
|
||||
}
|
||||
|
||||
/** Append a streaming delta to the renderer for [contentId]. */
|
||||
@@ -288,7 +324,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.Outline.brightColor()
|
||||
g2.color = SessionUiStyle.View.Outline.color()
|
||||
val w = width - 1
|
||||
val h = height - 1
|
||||
if (w > 0 && h > 0) g2.drawRoundRect(0, 0, w, h, arc, arc)
|
||||
|
||||
+1
@@ -40,6 +40,7 @@ class PlanExitView(tool: Tool, openFile: (String) -> Unit, selection: SessionSel
|
||||
}
|
||||
|
||||
override fun applyStyle(style: SessionEditorStyle) {
|
||||
md.applyStyle(style)
|
||||
md.font = style.transcriptFont
|
||||
md.codeFont = style.editorFamily
|
||||
md.foreground = style.editorForeground
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.FileAttachment
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentCard
|
||||
import ai.kilocode.client.session.ui.attachment.AttachmentCardItem
|
||||
import ai.kilocode.client.session.ui.style.SessionUiStyle
|
||||
import ai.kilocode.client.session.views.base.PartView
|
||||
import ai.kilocode.client.ui.UiStyle
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import com.intellij.ui.components.JBScrollPane
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.JBUI
|
||||
import java.awt.Dimension
|
||||
import javax.swing.ScrollPaneConstants
|
||||
|
||||
class PromptAttachmentView(
|
||||
messageId: String,
|
||||
private val openAttachment: (FileAttachment) -> Unit,
|
||||
) : PartView() {
|
||||
override val contentId: String = "attachments:$messageId"
|
||||
|
||||
private val items = LinkedHashMap<String, FileAttachment>()
|
||||
private val cards = LinkedHashMap<String, AttachmentCard>()
|
||||
private val row = Stack.horizontal(gap = UiStyle.Gap.sm())
|
||||
private val scroll = JBScrollPane(row).apply {
|
||||
border = null
|
||||
isOpaque = false
|
||||
viewport.isOpaque = false
|
||||
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED
|
||||
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER
|
||||
}
|
||||
|
||||
init {
|
||||
isOpaque = false
|
||||
border = JBUI.Borders.empty(
|
||||
0,
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
|
||||
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
|
||||
)
|
||||
add(scroll)
|
||||
}
|
||||
|
||||
fun contains(id: String) = items.containsKey(id)
|
||||
|
||||
fun isEmpty() = items.isEmpty()
|
||||
|
||||
fun ids(): List<String> = items.keys.toList()
|
||||
|
||||
fun scrollPane(): JBScrollPane = scroll
|
||||
|
||||
@RequiresEdt
|
||||
fun upsert(item: FileAttachment) {
|
||||
val old = items[item.id]
|
||||
items[item.id] = item
|
||||
if (old != null && same(old, item)) return
|
||||
val next = card(item)
|
||||
val prev = cards.put(item.id, next)
|
||||
if (prev == null) {
|
||||
row.next(next)
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
val at = row.components.indexOfFirst { it === prev }.takeIf { it >= 0 } ?: return refresh()
|
||||
row.remove(prev)
|
||||
row.add(next, at)
|
||||
refresh()
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun remove(id: String): Boolean {
|
||||
val item = items.remove(id) ?: return false
|
||||
cards.remove(item.id)?.let { row.remove(it) }
|
||||
refresh()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun update(content: Content) {
|
||||
if (content is FileAttachment) upsert(content)
|
||||
}
|
||||
|
||||
override fun getPreferredSize(): Dimension {
|
||||
val ins = insets
|
||||
val pref = scroll.preferredSize
|
||||
return Dimension(0, pref.height + bar() + ins.top + ins.bottom)
|
||||
}
|
||||
|
||||
override fun getMinimumSize() = preferredSize
|
||||
|
||||
override fun doLayout() {
|
||||
val ins = insets
|
||||
scroll.setBounds(
|
||||
ins.left,
|
||||
ins.top,
|
||||
maxOf(0, width - ins.left - ins.right),
|
||||
maxOf(0, height - ins.top - ins.bottom),
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
row.removeAll()
|
||||
cards.clear()
|
||||
items.clear()
|
||||
}
|
||||
|
||||
override fun dumpLabel(): String = "PromptAttachmentView#$contentId[${items.keys.joinToString(",")}]"
|
||||
|
||||
private fun refresh() {
|
||||
revalidate()
|
||||
repaint()
|
||||
}
|
||||
|
||||
private fun card(item: FileAttachment) = AttachmentCard(
|
||||
AttachmentCardItem(name(item), item.mime, item.url),
|
||||
open = { openAttachment(item) },
|
||||
)
|
||||
|
||||
private fun same(a: FileAttachment, b: FileAttachment) = a.mime == b.mime && a.url == b.url && a.filename == b.filename
|
||||
|
||||
private fun bar() = scroll.horizontalScrollBar.preferredSize.height
|
||||
|
||||
private fun name(item: FileAttachment) = item.filename?.takeIf { it.isNotBlank() }
|
||||
?: tail(item.url).takeIf { it.isNotBlank() }
|
||||
?: "attachment"
|
||||
|
||||
private fun tail(value: String): String {
|
||||
val clean = value.trimEnd('/', '\\')
|
||||
val index = maxOf(clean.lastIndexOf('/'), clean.lastIndexOf('\\'))
|
||||
if (index < 0) return clean
|
||||
return clean.substring(index + 1)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -317,7 +317,7 @@ 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(SessionViewIcons.eye).apply { foreground = UiStyle.Colors.weak() }
|
||||
val icon = JBLabel(SessionViewIcons.brain).apply { foreground = UiStyle.Colors.weak() }
|
||||
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.Layout.GAP), 0)).apply {
|
||||
isOpaque = false
|
||||
add(icon, BorderLayout.WEST)
|
||||
|
||||
+4
-1
@@ -1,15 +1,18 @@
|
||||
package ai.kilocode.client.session.views
|
||||
|
||||
import com.intellij.openapi.util.IconLoader
|
||||
import javax.swing.Icon
|
||||
|
||||
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 chevronDown: Icon = icon("chevron-down")
|
||||
val chevronLeft = icon("chevron-left")
|
||||
val chevronRight = icon("chevron-right")
|
||||
val chevronCollapsed: Icon = chevronRight
|
||||
val chevronExpanded: Icon = chevronDown
|
||||
val code = icon("code")
|
||||
val codeLines = icon("code-lines")
|
||||
val console = icon("console")
|
||||
|
||||
+2
-3
@@ -4,7 +4,6 @@ import ai.kilocode.client.session.model.Content
|
||||
import ai.kilocode.client.session.model.Text
|
||||
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.PartView
|
||||
import ai.kilocode.client.ui.md.MdView
|
||||
import ai.kilocode.client.ui.md.MdViewFactory
|
||||
@@ -18,7 +17,7 @@ import java.awt.BorderLayout
|
||||
*/
|
||||
open class TextView(
|
||||
text: Text,
|
||||
transparent: Boolean = false,
|
||||
transparent: Boolean = true,
|
||||
openUrl: (String) -> Unit = {},
|
||||
selection: SessionSelection? = null,
|
||||
) : PartView() {
|
||||
@@ -73,7 +72,7 @@ open class TextView(
|
||||
|
||||
protected open fun styleFont(style: SessionEditorStyle) = style.transcriptFont
|
||||
|
||||
protected open fun styleBackground(style: SessionEditorStyle) = SessionUiStyle.Transcript.bgColor()
|
||||
protected open fun styleBackground(style: SessionEditorStyle) = style.editorBackground
|
||||
|
||||
private fun refresh() {
|
||||
revalidate()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user