Merge branch 'main' into chore/remove-unused-deps

This commit is contained in:
Mark IJbema
2026-06-16 13:36:04 +02:00
committed by GitHub
360 changed files with 11296 additions and 3401 deletions
+7
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Support multiple provider APIs, adaptive thinking, split reasoning, and output effort variants for custom providers.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Hide TUI news after they have been opened and add a button to close the news dialog.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Remove stray rounded corners between files in the Changes diff viewer.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix upgrades to resolve Kilo CLI packages and releases instead of OpenCode packages and versions.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Open embedded transcript attachments in stable Kilo editor tabs.
-236
View File
@@ -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(`=============================`)
@@ -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:
@@ -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 Codes `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.
+1
View File
@@ -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:",
+5 -2
View File
@@ -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
View File
@@ -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="
}
}
+3 -2
View File
@@ -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.
+12 -14
View File
@@ -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 })
+11
View File
@@ -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
@@ -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()
@@ -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,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"
@@ -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()
}
@@ -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()
}
}
}
@@ -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>
@@ -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"}"""
@@ -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 ------
@@ -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(
@@ -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('"')
}
}
@@ -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()
}
}
@@ -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 {
@@ -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
@@ -181,4 +181,5 @@ class KiloWorkspaceService internal constructor(
done(ok)
}
}
}
@@ -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)
}
}
@@ -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)
@@ -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) {
@@ -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
@@ -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())}"
@@ -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}")
@@ -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)
}
}
@@ -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
@@ -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
}
@@ -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) }
}
@@ -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)
@@ -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()
}
}
}
@@ -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)
}
@@ -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"
@@ -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
}
}
@@ -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)
}
}
}
}
}
@@ -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))
}
}
@@ -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. */
@@ -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)
}
}
@@ -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)
@@ -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
@@ -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)
}
}
@@ -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)
@@ -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")
@@ -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