chore: merge latest main

This commit is contained in:
marius-kilocode
2026-07-24 13:12:52 +02:00
271 changed files with 17827 additions and 2261 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Add a `notify_user` tool that lets an agent send a push notification to the user's phone (Kilo mobile app) for explicitly requested pings and significant mid-run milestones. The tool sends a single `agent_notification` item over the session's existing authenticated ingest channel with a bounded readiness wait, returns a friendly failure when the session is not connected to Kilo cloud, and never prompts for permission. Delivery may still be suppressed server-side by the user's notification preference, per-session rate limits, or active presence in the session.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Prevent Enter from activating the first result in searchable lists when no row is highlighted.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Restore stream idle timeouts to opt-in provider configuration instead of aborting quiet model streams by default.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Remote CLI sessions no longer appear frozen on mobile when the connection to the session relay stalls; they now recover on their own instead of staying read-only until the CLI is restarted. Token acquisition and connection attempts are bounded by deadlines with a single fenced retry owner, and heartbeat session gathers are bounded so one stuck gather can no longer silently kill every future heartbeat.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix compaction failure against strict OpenAI-compatible providers during context compaction. The compaction path no longer leaks `maxOutputTokens` into provider options, which was rejected by strict upstreams with "Unsupported parameter(s)".
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix: inject `$schema` into config files using jsonc-parser, avoiding write-on-read for comment-first JSONC and preventing unnecessary file rewrites on every load
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix a fatal startup crash ("attempt to write a readonly database") when the local database or its WAL sidecar files lost write permission. Kilo now repairs the permissions automatically when it safely can, and otherwise reports the exact file to fix instead of an opaque error.
+7
View File
@@ -0,0 +1,7 @@
---
"@kilocode/cli": minor
"@kilocode/kilo-ui": minor
"kilo-code": minor
---
Support deleting queued chat messages from the VS Code chat before they run.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep Plan and Architect mode source edits denied when agent-specific permissions request edit approval.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve JetBrains diff previews by hiding hunk headers and adding full-path tooltips to clickable file links.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Render edit tool results with a clickable file target and a highlighted, simplified diff view.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Open edit tool file links directly when multiple files share the same name.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Render multi-file apply_patch edits as a "Patch" with a file-count tag and one section per file, each showing a clickable filename link and its own changes badge aligned with the diff.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Smooth out chat scrolling in large JetBrains sessions by only refreshing hover state for the message under the pointer.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Improve chat scrolling performance in large JetBrains sessions.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Size edit and shell preview popovers to their content with a wider maximum width.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Emit session queue state so remote clients can show queued messages.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
`kilo remote` instances now advertise themselves on the relay heartbeat. Each heartbeat carries the host's hostname, the project directory name, and the CLI build version, and each session entry advertises the platform it was created on. The cloud relay learns about a freshly-connected instance immediately (no 10s wait for the first timer tick), and the advertisement is race-safe across the explicit `kilo remote` command and bootstrap auto-enable (`KILO_REMOTE=1` / `remote_control` config). Legacy CLIs that send neither field remain wire-compatible.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Support file attachments in remote CLI sessions.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Run asynchronous Cloud Agent tasks with repository, model, mode, and organization defaults through `kilo cloud`. Add `--stream` to `kilo cloud start` to print admission output and then stream WebSocket events as JSONL until completion or inactivity ends the stream.
+6
View File
@@ -0,0 +1,6 @@
---
"kilo-code": minor
"@kilocode/cli": minor
---
Reference past chats inline with `@` in the prompt. Typing `@` now surfaces a "Past chats" option that opens a searchable picker of previous sessions (scoped to the current workspace/worktree, searched like the Agent Manager session search); selecting one attaches that session's transcript as context so the model can build on a prior conversation. Clicking the mention opens that session. Available in the CLI TUI and the VS Code extension.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add a searchable open-tabs switcher to the sidebar tab bar.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Show the request ID when a model response ends without a finish reason.
+6
View File
@@ -0,0 +1,6 @@
---
"@kilocode/cli": minor
"@kilocode/sdk": minor
---
Show tokens-per-second text-generation throughput (TG) on each assistant message and in the usage sidebar, computed from step duration and tokens. The toggle "Show Token Throughput" in Display settings controls both surfaces. PP (prompt-processing) support lands in a follow-up once the upstream llama.cpp metadata wiring ships.
+1 -1
View File
@@ -305,7 +305,7 @@
},
"packages/kilo-jetbrains": {
"name": "@kilocode/kilo-jetbrains",
"version": "7.4.13",
"version": "7.4.15",
},
"packages/kilo-memory": {
"name": "@kilocode/kilo-memory",
+3 -3
View File
@@ -6,7 +6,8 @@
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev": "bun run --cwd packages/opencode --conditions=node src/index.ts",
"dev:local": "bun run packages/opencode/script/dev-local.ts",
"dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
@@ -18,8 +19,7 @@
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1",
"extension": "bun --cwd packages/kilo-vscode script/launch.ts",
"dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup",
"dev:local": "bun run packages/opencode/script/dev-local.ts"
"dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup"
},
"workspaces": {
"packages": [
+2
View File
@@ -7,6 +7,7 @@ import { Global } from "../global"
import { Flag } from "../flag/flag"
import { isAbsolute, join } from "path"
import { existsSync } from "fs" // kilocode_change
import { DbPreflight } from "../kilocode/db-preflight" // kilocode_change
import { DatabaseMigration } from "./migration"
import { InstallationChannel } from "../installation/version"
import { LayerNode } from "../effect/layer-node"
@@ -38,6 +39,7 @@ export const layer = Layer.effect(
)
export function layerFromPath(filename: string) {
DbPreflight.assertWritable(filename) // kilocode_change - actionable error (and self-heal for kilo-owned files) instead of an opaque wal_checkpoint crash on read-only db files
return layer.pipe(Layer.provide(sqliteLayer({ filename })))
}
+22 -2
View File
@@ -17,7 +17,7 @@ declare const $ErrorType: unique symbol
export type Node<A, E = never> = {
readonly kind: "layer" | "group"
readonly implementation?: Layer.Any
readonly dependencies: readonly AnyNode[]
readonly dependencies: readonly AnyNode[] | (() => readonly AnyNode[])
readonly [$OutputType]?: () => A
readonly [$ErrorType]?: () => E
}
@@ -78,7 +78,7 @@ export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacem
visiting.add(node)
stack.push(node)
try {
const dependencies = node.dependencies.map(visit)
const dependencies = (typeof node.dependencies === "function" ? node.dependencies() : node.dependencies).map(visit)
const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]]
const result =
node.kind === "group"
@@ -99,4 +99,24 @@ export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacem
return visit(node) as unknown as Layer.Layer<A, E, never>
}
// kilocode_change start - defer node construction to break circular dependency chains
export function suspend<A, E>(fn: () => Node<A, E>): Node<A, E> {
let cached: Node<A, E> | undefined
const getNode = () => {
if (!cached) cached = fn()
return cached
}
return {
kind: "layer",
implementation: Layer.suspend(
() => (getNode().implementation ?? Layer.empty) as Layer.Layer<unknown, unknown, unknown>,
),
dependencies: () => {
const raw = getNode().dependencies
return typeof raw === "function" ? raw() : raw
},
}
}
// kilocode_change end
export * as LayerNode from "./layer-node"
@@ -0,0 +1,68 @@
export * as DbPreflight from "./db-preflight"
import { accessSync, chmodSync, constants, statSync } from "fs"
import path from "path"
import { Global } from "../global"
import { Log } from "../util/log"
const log = Log.create({ service: "db-preflight" })
function writable(target: string) {
try {
accessSync(target, constants.W_OK)
return true
} catch {
return false
}
}
function exists(target: string) {
try {
statSync(target)
return true
} catch {
return false
}
}
// Startup runs `PRAGMA wal_checkpoint(PASSIVE)`, which must write the database and its
// WAL sidecars. A stray read-only file otherwise kills the process deep inside Effect
// with an opaque "attempt to write a readonly database".
export function assertWritable(filename: string, trusted: string = Global.Path.data) {
if (!filename || filename === ":memory:" || filename.startsWith("file:")) return
const dir = path.dirname(filename)
const owned = path.resolve(dir) === path.resolve(trusted)
let missing = false
for (const file of [filename, `${filename}-wal`, `${filename}-shm`]) {
if (!exists(file)) {
missing = true
continue
}
if (writable(file)) continue
let cause: unknown
if (owned) {
// chmod only succeeds for files the current user owns, which is exactly the safe repair scope
try {
chmodSync(file, statSync(file).mode | 0o600)
} catch (err) {
cause = err
}
if (writable(file)) {
// visible trail: if files keep losing their write bit, something outside kilo is doing it
log.warn("repaired read-only database file", { file })
continue
}
}
throw new Error(
`Database file is not writable: ${file}. Fix its permissions (chmod u+w "${file}") or point KILO_DB at a writable location.`,
cause === undefined ? undefined : { cause },
)
}
if (missing && !writable(dir)) {
if (!exists(dir))
throw new Error(`Database directory does not exist: ${dir}. Create it or point KILO_DB at an existing location.`)
throw new Error(
`Database directory is not writable: ${dir}. SQLite must create WAL files next to the database. Fix its permissions or point KILO_DB at a writable location.`,
)
}
}
+2 -4
View File
@@ -115,11 +115,9 @@ export const Info = Schema.Struct({
description:
"Timeout in milliseconds to wait for response headers. Provider integrations may set defaults. Set to false to disable timeout.",
}),
// kilocode_change: accept `false` so internal callers can disable the
// watchdog. PositiveInt already excludes 0, so a public zero stays invalid.
chunkTimeout: Schema.optional(Schema.Union([PositiveInt, Schema.Literal(false)])).annotate({
chunkTimeout: Schema.optional(PositiveInt).annotate({
description:
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted. Set to false to disable the idle watchdog.",
"Timeout in milliseconds between streamed SSE chunks for this provider. If no chunk arrives within this window, the request is aborted.",
}),
}),
[Schema.Record(Schema.String, Schema.Any)],
+29
View File
@@ -228,6 +228,16 @@ export const StepStartPart = Schema.Struct({
...partBase,
type: Schema.Literal("step-start"),
snapshot: Schema.optional(Schema.String),
// kilocode_change start - wall-clock timestamps captured at the processor
// and consumed by the webview's weighted throughput aggregator. Marked
// optional so older persisted sessions (and synthetic messages) without
// timing still decode cleanly.
time: Schema.optional(
Schema.Struct({
start: NonNegativeInt,
}),
),
// kilocode_change end
}).annotate({ identifier: "StepStartPart" })
export type StepStartPart = Types.DeepMutable<Schema.Schema.Type<typeof StepStartPart>>
@@ -243,6 +253,25 @@ export const StepFinishPart = Schema.Struct({
modelID: ModelV2.ID,
}),
),
metrics: Schema.optional(
Schema.Struct({
prompt: Schema.optional(Schema.Finite),
generation: Schema.optional(Schema.Finite),
source: Schema.Literals(["provider", "computed"]),
}),
),
// Wall-clock timestamps + active generation duration captured at the
// session processor. The webview's weighted throughput aggregator uses
// `time.elapsed` (active model-generation duration in milliseconds,
// excluding tool execution and idle waiting) to weight the per-turn
// rate. Optional so legacy persisted sessions keep decoding.
time: Schema.optional(
Schema.Struct({
start: NonNegativeInt,
end: NonNegativeInt,
elapsed: Schema.Finite,
}),
),
// kilocode_change end
cost: Schema.Finite,
tokens: Schema.Struct({
@@ -0,0 +1,123 @@
import { describe, expect, test } from "bun:test"
import { Database } from "bun:sqlite"
import { accessSync, chmodSync, constants } from "fs"
import path from "path"
import { DbPreflight } from "@opencode-ai/core/kilocode/db-preflight"
import { Database as KiloDatabase } from "@opencode-ai/core/database/database"
import { tmpdir } from "../fixture/tmpdir"
const writable = (file: string) => {
try {
accessSync(file, constants.W_OK)
return true
} catch {
return false
}
}
// Windows: chmod is a no-op, so non-writable files cannot be staged; root ignores permission bits
const skip = process.platform === "win32" || process.getuid?.() === 0
function createWalDb(file: string) {
const db = new Database(file)
db.run("PRAGMA journal_mode = WAL")
db.run("CREATE TABLE t (x)")
db.run("INSERT INTO t VALUES (1)")
db.close()
}
// leaves committed-but-uncheckpointed frames in the WAL by SIGKILLing the writer,
// reproducing the state a crashed kilo process leaves behind
async function createWalDbWithPendingFrames(file: string) {
const script = [
`const { Database } = require("bun:sqlite")`,
`const db = new Database(${JSON.stringify(file)})`,
`db.run("PRAGMA journal_mode = WAL")`,
`db.run("PRAGMA wal_autocheckpoint = 0")`,
`db.run("CREATE TABLE t (x)")`,
`db.run("INSERT INTO t VALUES (1)")`,
`console.log("ready")`,
`setInterval(() => {}, 1000)`,
].join("\n")
const child = Bun.spawn([process.execPath, "-e", script], { stdout: "pipe" })
const reader = child.stdout.getReader()
await reader.read()
child.kill("SIGKILL")
await child.exited
}
describe("DbPreflight", () => {
test("skips in-memory databases", () => {
expect(() => DbPreflight.assertWritable(":memory:")).not.toThrow()
})
test("accepts a writable database", async () => {
await using tmp = await tmpdir()
const file = path.join(tmp.path, "kilo.db")
createWalDb(file)
expect(() => DbPreflight.assertWritable(file)).not.toThrow()
})
test("names the offending file for a read-only sidecar outside the kilo data dir", async () => {
if (skip) return
await using tmp = await tmpdir()
const file = path.join(tmp.path, "kilo.db")
// a clean close deletes the sidecars on some platforms; a killed writer always leaves them
await createWalDbWithPendingFrames(file)
chmodSync(`${file}-wal`, 0o444)
expect(() => DbPreflight.assertWritable(file)).toThrow(`Database file is not writable: ${file}-wal`)
chmodSync(`${file}-wal`, 0o644)
})
test("repairs read-only files inside the trusted dir", async () => {
if (skip) return
await using tmp = await tmpdir()
const file = path.join(tmp.path, "kilo.db")
await createWalDbWithPendingFrames(file)
chmodSync(file, 0o444)
chmodSync(`${file}-wal`, 0o444)
expect(() => DbPreflight.assertWritable(file, tmp.path)).not.toThrow()
expect(writable(file)).toBe(true)
expect(writable(`${file}-wal`)).toBe(true)
})
test("reports a missing directory as missing, not as read-only", async () => {
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "absent")
const file = path.join(dir, "kilo.db")
expect(() => DbPreflight.assertWritable(file)).toThrow(`Database directory does not exist: ${dir}`)
})
test("rejects a read-only directory when WAL files must be created", async () => {
if (skip) return
await using tmp = await tmpdir()
const dir = path.join(tmp.path, "locked")
const file = path.join(dir, "kilo.db")
await Bun.write(path.join(dir, ".keep"), "")
chmodSync(dir, 0o555)
try {
expect(() => DbPreflight.assertWritable(file)).toThrow(`Database directory is not writable: ${dir}`)
} finally {
chmodSync(dir, 0o755)
}
})
test("pending WAL frames with a read-only sidecar fail with the actionable error, and repair recovers the data", async () => {
if (skip) return
await using tmp = await tmpdir()
const file = path.join(tmp.path, "kilo.db")
await createWalDbWithPendingFrames(file)
chmodSync(`${file}-wal`, 0o444)
// without repair (untrusted dir) the wiring in layerFromPath surfaces the clear error
expect(() => KiloDatabase.layerFromPath(file)).toThrow(`Database file is not writable: ${file}-wal`)
// with repair the startup pragma sequence succeeds and the committed row survives
DbPreflight.assertWritable(file, tmp.path)
const db = new Database(file, { readwrite: true, create: true })
db.run("PRAGMA journal_mode = WAL")
db.run("PRAGMA wal_checkpoint(PASSIVE)")
expect(db.query("SELECT x FROM t").all()).toEqual([{ x: 1 }])
db.close()
})
})
+1
View File
@@ -50,6 +50,7 @@ exclude = [
'^https?://vercel\.link/',
# API base URL, returns 404 when fetched directly
'^https?://api\.apertis\.ai/v1/?$',
'^https?://cloud-agent-next\.kilosessions\.ai/?$',
# Redirects to authenticated Google Cloud console
'^https?://console\.cloud\.google\.com',
# Google AI Studio API keys page redirects to Google sign-in
@@ -26,6 +26,7 @@
| `kilo remote` | enable remote connection for real-time session relay |
| `kilo daemon` | manage the local kilo daemon |
| `kilo console` | open or stop the local Kilo Console |
| `kilo cloud` | run Cloud Agent tasks |
| `kilo db` | database tools |
| `kilo config` | configuration tools |
| `kilo plugin <module>` | install plugin and update config |
@@ -968,6 +968,76 @@ Options:
--json print daemon details as JSON [boolean]
```
## kilo cloud
```
run Cloud Agent tasks
Commands:
kilo cloud start start a Cloud Agent task
kilo cloud send send a follow-up prompt to a Cloud Agent task
kilo cloud status show Cloud Agent task status
kilo cloud result show a Cloud Agent task result
Options:
--help Show help [boolean]
--version Show version number [boolean]
```
### kilo cloud start
```
start a Cloud Agent task
Options:
--help Show help [boolean]
--version Show version number [boolean]
--prompt prompt for the Cloud Agent [string] [required]
--repo repository shorthand or URL [string]
--repo-type repository provider type [string] [choices: "github", "gitlab", "git"]
--branch repository branch [string]
--model Cloud Agent model [string]
--mode Cloud Agent mode [string]
--org-id Kilo organization ID [string]
--stream connect to the WebSocket stream and print events as JSONL [boolean]
```
### kilo cloud send
```
send a follow-up prompt to a Cloud Agent task
Options:
--help Show help [boolean]
--version Show version number [boolean]
--session-id Cloud Agent session ID [string] [required]
--prompt follow-up prompt for the Cloud Agent [string] [required]
```
### kilo cloud status
```
show Cloud Agent task status
Options:
--help Show help [boolean]
--version Show version number [boolean]
--session-id Cloud Agent session ID [string] [required]
--message-id Cloud Agent message ID [string] [required]
```
### kilo cloud result
```
show a Cloud Agent task result
Options:
--help Show help [boolean]
--version Show version number [boolean]
--session-id Cloud Agent session ID [string] [required]
--message-id Cloud Agent message ID [string] [required]
```
## kilo db
```
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:273c76976df0e83fa404ae2af2d561f8c38458e3ef1b32f4f5b1990a5906a195
size 14239
+3
View File
@@ -50,6 +50,8 @@
<!-- packages/opencode/src/provider/error.ts -->
- <https://cli.github.com/>
<!-- packages/kilo-vscode/src/agent-manager/WorktreeManager.ts -->
- <https://cloud-agent-next.kilosessions.ai>
<!-- packages/opencode/src/kilocode/cloud/origin.ts -->
- <https://cloud.digitalocean.com/v1/oauth/authorize>
<!-- packages/opencode/src/plugin/digitalocean.ts -->
- <https://cloudflare.com/cdn-cgi/trace>
@@ -106,6 +108,7 @@
<!-- packages/opencode/src/plugin/digitalocean.ts -->
- <https://kilo.ai>
<!-- packages/opencode/src/cli/cmd/github.handler.ts -->
<!-- packages/opencode/src/kilocode/cloud/origin.ts -->
<!-- packages/opencode/src/mcp/oauth-provider.ts -->
<!-- packages/opencode/src/session/network.ts -->
- <https://kilo.ai/>
@@ -346,7 +346,7 @@ internal class SessionScroll(
@RequiresEdt
private fun layoutScroll() {
root.validate()
component.validate()
}
@RequiresEdt
@@ -8,6 +8,7 @@ import java.awt.Container
import java.awt.Dimension
import java.awt.Insets
import java.awt.LayoutManager
import java.util.IdentityHashMap
/**
* A vertical, width-aware layout manager for the session transcript.
@@ -33,8 +34,12 @@ class SessionLayout(
private val basePad: Insets = JBUI.emptyInsets(),
) : LayoutManager {
private val cache = IdentityHashMap<Component, Measured>()
override fun addLayoutComponent(name: String, comp: Component) = Unit
override fun removeLayoutComponent(comp: Component) = Unit
override fun removeLayoutComponent(comp: Component) {
cache.remove(comp)
}
override fun preferredLayoutSize(parent: Container): Dimension {
val ins = insets(parent)
@@ -46,9 +51,7 @@ class SessionLayout(
if (!first) h += gap(comp)
first = false
val child = bounds(ins, w, comp)
// Pre-size to available width so HTML panes reflow before we measure
comp.setSize(child.width, comp.height.coerceAtLeast(1))
h += comp.preferredSize.height
h += measure(comp, child.width)
}
// w and h are already scaled px (child preferred heights + scaled gaps/insets) and
// match what layoutContainer stacks, so return a plain Dimension. A JBDimension would
@@ -68,14 +71,36 @@ class SessionLayout(
if (!first) y += gap(comp)
first = false
val child = bounds(ins, w, comp)
// Fix width first so HTML reflows, then read the resulting height
comp.setSize(child.width, comp.height.coerceAtLeast(1))
val h = comp.preferredSize.height
val h = measure(comp, child.width)
comp.setBounds(child.left, y, child.width, h)
y += h
}
}
/**
* Drop the cached measurement for [comp] so the next layout pass re-measures it.
*
* [measure] trusts `comp.isValid` as a freshness signal, which is safe only while `comp` is
* invalidated through this container. A child that is its own validate root (see
* [ai.kilocode.client.session.views.TurnView.isValidateRoot]) can be re-validated independently
* by `RepaintManager` — its `isValid` flips back to `true` before this layout re-measures it,
* so a content change that grows/shrinks its height would otherwise return a stale cached value.
* Callers that mutate such a child's content must forget it here so the cache stays honest.
*/
fun forget(comp: Component) {
cache.remove(comp)
}
private fun measure(comp: Component, width: Int): Int {
val hit = cache[comp]
if (comp.isValid && hit?.width == width) return hit.height
// Pre-size to available width so HTML panes reflow before we measure.
comp.setSize(width, comp.height.coerceAtLeast(1))
val h = comp.preferredSize.height
cache[comp] = Measured(width, h)
return h
}
private fun bounds(ins: Insets, width: Int, comp: Component): Bounds {
val view = view(comp) ?: return Bounds(ins.left, width)
if (view.sessionViewKind != SessionView.Kind.UserPrompt) return Bounds(ins.left, width)
@@ -103,6 +128,8 @@ class SessionLayout(
private fun view(comp: Component): SessionView? = comp as? SessionView
private data class Bounds(val left: Int, val width: Int)
private data class Measured(val width: Int, val height: Int)
}
/**
@@ -96,34 +96,37 @@ class SessionMessageListPanel(
is SessionModelEvent.TurnRemoved -> onTurnRemoved(event.id)
is SessionModelEvent.ContentAdded -> {
msgToView[event.messageId]?.upsertPart(event.content)
msgToTurn[event.messageId]?.syncCopyToolbars()
refresh()
if (msgToView[event.messageId]?.upsertPartChanged(event.content) == true) {
onContentChanged(event.messageId)
}
}
is SessionModelEvent.ContentUpdated -> {
msgToView[event.messageId]?.upsertPart(event.content)
msgToTurn[event.messageId]?.syncCopyToolbars()
refresh()
if (msgToView[event.messageId]?.upsertPartChanged(event.content) == true) {
onContentChanged(event.messageId)
}
}
is SessionModelEvent.ContentRemoved -> {
msgToView[event.messageId]?.removePart(event.contentId)
msgToTurn[event.messageId]?.syncCopyToolbars()
refresh()
if (msgToView[event.messageId]?.removePartChanged(event.contentId) == true) {
onContentChanged(event.messageId)
}
}
is SessionModelEvent.ContentDelta -> {
if (event.created) return@addListener
if (event.delta.isEmpty()) return@addListener
val handled = msgToView[event.messageId]?.appendDelta(event.contentId, event.delta) == true
if (handled) {
msgToTurn[event.messageId]?.syncCopyToolbars()
forgetTurn(event.messageId)
return@addListener
}
val content = model.content(event.messageId, event.contentId)
if (content != null) {
msgToView[event.messageId]?.upsertPart(content)
msgToTurn[event.messageId]?.syncCopyToolbars()
if (msgToView[event.messageId]?.upsertPartChanged(content) == true) {
onContentChanged(event.messageId)
}
}
}
@@ -132,6 +135,7 @@ class SessionMessageListPanel(
is SessionModelEvent.StateChanged -> {
syncActive(event.state)
syncSettled(event.state)
syncReverted()
syncReverting(event.state)
anchorFooter()
@@ -222,6 +226,7 @@ class SessionMessageListPanel(
tv.syncCopyToolbars()
syncReverted()
add(tv)
syncSettled()
anchorFooter()
refresh()
}
@@ -234,8 +239,7 @@ class SessionMessageListPanel(
// Remove messages no longer in this turn
for (id in prev) {
if (id !in next) {
tv.removeMessage(id)
unregister(id)
if (tv.removeMessageChanged(id)) unregister(id)
}
}
@@ -248,6 +252,7 @@ class SessionMessageListPanel(
}
tv.syncCopyToolbars()
syncReverted()
syncSettled()
refresh()
}
@@ -257,6 +262,7 @@ class SessionMessageListPanel(
for (msgId in tv.messageIds()) unregister(msgId)
remove(tv)
Disposer.dispose(tv)
syncSettled()
anchorFooter()
refresh()
}
@@ -285,6 +291,7 @@ class SessionMessageListPanel(
}
syncActive(model.state)
syncSettled(model.state)
syncReverted()
syncReverting(model.state)
banner?.update()
@@ -313,6 +320,7 @@ class SessionMessageListPanel(
revertingMessage = null
removeAll()
syncActive(model.state)
syncSettled(model.state)
syncReverting(model.state)
banner?.update()
anchorFooter()
@@ -375,6 +383,11 @@ class SessionMessageListPanel(
for (mv in msgToView.values) mv.setHiddenQuestionTool(ref)
}
private fun syncSettled(state: SessionState = model.state) {
val active = if (state.isBusy()) turnViews.values.lastOrNull() else null
for (view in turnViews.values) view.setSettled(view !== active)
}
/**
* Re-insert [question], [permission], [login], and [progress] as the last children
* so active views always render after all turn views, and progress is last.
@@ -413,6 +426,25 @@ class SessionMessageListPanel(
repaint()
}
/**
* Handle a content mutation that changed an already-rendered message: sync the turn's copy
* toolbars, forget its cached height, then relayout. [forgetTurn] is essential when the update
* lands on a settled turn — a settled [TurnView] is its own validate root, so `RepaintManager`
* re-validates it independently and its `isValid` flag no longer signals the height change to
* [SessionLayout]'s measurement cache.
*/
private fun onContentChanged(messageId: String) {
msgToTurn[messageId]?.syncCopyToolbars()
forgetTurn(messageId)
refresh()
}
/** Drop [SessionLayout]'s cached height for the turn holding [messageId] after its content changes. */
private fun forgetTurn(messageId: String) {
val tv = msgToTurn[messageId] ?: return
(layout as? SessionLayout)?.forget(tv)
}
private fun hover(view: PartView, value: Boolean) {
if (value) {
val prev = hovered
@@ -1,14 +1,20 @@
package ai.kilocode.client.session.ui.popup
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.openapi.Disposable
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
import java.awt.Container
import java.awt.Dimension
import java.awt.Insets
import javax.swing.JComponent
import javax.swing.JEditorPane
import javax.swing.JPanel
import javax.swing.JScrollPane
class HeaderPopupRequest(
val anchor: JComponent,
@@ -20,11 +26,15 @@ class HeaderPopupBody(
component: JComponent,
val disposable: Disposable,
val background: Color,
maxWidth: Int = SessionUiStyle.View.Popup.MAX_WIDTH,
) {
val component: JComponent = HeaderPopupPanel(component)
val component: JComponent = HeaderPopupPanel(component, JBUI.scale(maxWidth))
}
private class HeaderPopupPanel(private val child: JComponent) : JPanel(BorderLayout()) {
private class HeaderPopupPanel(
private val child: JComponent,
private val maxWidth: Int,
) : JPanel(BorderLayout()) {
init {
// Transparent so the balloon fill shows uniformly behind nested popup content.
isOpaque = false
@@ -32,14 +42,32 @@ private class HeaderPopupPanel(private val child: JComponent) : JPanel(BorderLay
}
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
val cap = JBUI.scale(350)
val width = size.width.takeIf { it > 0 }?.coerceAtMost(cap) ?: cap
val width = contentWidth(child).takeIf { it > 0 }?.coerceAtMost(maxWidth) ?: maxWidth
fit(child, width)
val height = super.getPreferredSize().height.coerceAtMost(JBUI.scale(450))
val height = super.getPreferredSize().height.coerceAtMost(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT))
return Dimension(width, height)
}
private fun contentWidth(item: Component): Int = when (item) {
is EditorTextField -> item.preferredSize.width
is JBTextArea -> item.preferredSize.width
is JEditorPane -> item.preferredSize.width
is JScrollPane -> {
val view = item.viewport?.view?.let(::contentWidth) ?: 0
view + horiz(item.insets) + horiz(item.viewportBorder?.getBorderInsets(item))
}
// JComponent is a Container, so leaf components (labels, buttons, icons) reach here with no
// children — fall back to their own preferred width instead of measuring an empty child set.
is Container -> {
val kids = item.components
if (kids.isEmpty()) (item as? JComponent)?.preferredSize?.width ?: 0
else (kids.maxOfOrNull(::contentWidth) ?: 0) + horiz((item as? JComponent)?.insets)
}
else -> 0
}
private fun horiz(insets: Insets?): Int = (insets?.left ?: 0) + (insets?.right ?: 0)
private fun fit(item: JComponent, width: Int) {
if (width <= 0) return
// JBHtmlPane derives wrapped preferred height from the current width, not just HTML content.
@@ -37,6 +37,12 @@ object SessionUiStyle {
const val BODY_EXTRA_HEIGHT = 16
}
object Popup {
const val MAX_WIDTH = 350
const val WIDE_MAX_WIDTH = MAX_WIDTH * 2
const val MAX_HEIGHT = 450
}
internal const val BORDER_DELTA = 80
internal const val HOVER_BORDER_ALPHA = 0.18f
internal const val HOVER_FILL_ALPHA = 0.10f
@@ -169,6 +175,7 @@ object SessionUiStyle {
object Tool {
const val BODY_LINES = 15
const val TASK_LINES = 10
const val DIFF_LINES = 20
const val PREVIEW_LIMIT = 20_000
fun pending(): Color = UiStyle.Colors.weak()
@@ -7,6 +7,7 @@ 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
import ai.kilocode.client.session.model.Text
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.session.model.ToolExecState
@@ -110,7 +111,12 @@ class MessageView(
/** Add or update the renderer for [content]. */
@RequiresEdt
fun upsertPart(content: Content) {
if (content is StepFinish) return
upsertPartChanged(content)
}
@RequiresEdt
fun upsertPartChanged(content: Content): Boolean {
if (content is StepFinish) return false
if (isHidden(content)) {
if (isPromptMention(content)) syncPromptMentions()
// Remove any stale view for this content so it disappears when suppressed
@@ -122,7 +128,7 @@ class MessageView(
stale.remove(content.id)
if (!stale.isEmpty()) {
refresh()
return
return true
}
attachments = null
}
@@ -131,14 +137,15 @@ class MessageView(
Disposer.dispose(stale)
syncBorder()
refresh()
return true
}
return
return false
}
val id = aliases[content.id]
if (id != null && content is Reasoning) {
updateAlias(content, id)
if (!updateAlias(content, id)) return false
refresh()
return
return true
}
if (id != null) {
aliases.remove(content.id)
@@ -149,20 +156,24 @@ class MessageView(
if (existing is PromptAttachmentView && content is FileAttachment) {
existing.upsert(content)
refresh()
return
return true
}
if (ViewFactory.shouldReplace(existing, content)) {
replacePart(content, existing)
return
return true
}
if (content is Text && existing is TextView && existing !is PromptView && existing.markdown() == content.content.toString()) {
return false
}
existing.update(content)
syncPromptToolbar()
refresh()
return
return true
}
addPart(content)
syncBorder()
refresh()
return true
}
@RequiresEdt
@@ -203,14 +214,15 @@ class MessageView(
}
@RequiresEdt
private fun updateAlias(content: Reasoning, id: String) {
val view = parts[id] as? ReasoningView ?: return
private fun updateAlias(content: Reasoning, id: String): Boolean {
val view = parts[id] as? ReasoningView ?: return false
val prev = sources[content.id].orEmpty()
val next = content.content.toString()
val delta = if (next.startsWith(prev)) next.removePrefix(prev) else next
sources[content.id] = next
if (delta.isEmpty()) return
if (delta.isEmpty()) return false
view.update(merged(view, content, delta))
return true
}
private fun merged(view: ReasoningView, content: Reasoning, delta: String) = Reasoning(view.contentId).also {
@@ -242,16 +254,21 @@ class MessageView(
/** Remove the renderer for [contentId] if present. */
@RequiresEdt
fun removePart(contentId: String) {
removePartChanged(contentId)
}
@RequiresEdt
fun removePartChanged(contentId: String): Boolean {
if (aliases.remove(contentId) != null) {
sources.remove(contentId)
return
return true
}
val view = parts.remove(contentId) ?: return
val view = parts.remove(contentId) ?: return false
if (view is PromptAttachmentView) {
view.remove(contentId)
if (!view.isEmpty()) {
refresh()
return
return true
}
attachments = null
}
@@ -262,6 +279,7 @@ class MessageView(
Disposer.dispose(view)
syncBorder()
refresh()
return true
}
/**
@@ -335,6 +353,7 @@ class MessageView(
/** Append a streaming delta to the renderer for [contentId]. */
@RequiresEdt
fun appendDelta(contentId: String, delta: String): Boolean {
if (delta.isEmpty()) return false
val id = aliases[contentId]
if (id != null) sources[contentId] = sources[contentId].orEmpty() + delta
val part = parts[id ?: contentId] ?: return false
@@ -12,6 +12,7 @@ import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.PartView
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.registry.Registry
import com.intellij.util.concurrency.annotations.RequiresEdt
import javax.swing.JComponent
@@ -38,6 +39,7 @@ class TurnView(
) : SessionLayoutPanel(SessionUiStyle.SessionLayout.GAP), Disposable, SessionEditorStyleTarget, SessionView {
private val messages = LinkedHashMap<String, MessageView>()
private var settled = true
override val sessionViewKind = SessionView.Kind.Default
@@ -48,6 +50,17 @@ class TurnView(
isOpaque = false
}
@RequiresEdt
fun setSettled(value: Boolean) {
if (settled == value) return
settled = value
revalidate()
}
override fun isValidateRoot(): Boolean {
return Registry.`is`("kilo.session.validateRoots", true) && settled
}
/** Add a new [MessageView] for [msg] at the end of this turn. */
fun addMessage(msg: Message): MessageView {
val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert)
@@ -60,11 +73,17 @@ class TurnView(
/** Remove the [MessageView] for [msgId] if present. */
fun removeMessage(msgId: String) {
val view = messages.remove(msgId) ?: return
removeMessageChanged(msgId)
}
@RequiresEdt
fun removeMessageChanged(msgId: String): Boolean {
val view = messages.remove(msgId) ?: return false
remove(view)
Disposer.dispose(view)
syncCopyToolbars()
revalidate()
return true
}
@RequiresEdt
@@ -4,6 +4,7 @@ import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.views.base.GenericView
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.views.question.QuestionResultView
import ai.kilocode.client.session.views.tool.EditToolView
import ai.kilocode.client.session.views.tool.GlobToolView
import ai.kilocode.client.session.views.tool.ReadToolView
import ai.kilocode.client.session.views.tool.SearchToolView
@@ -54,6 +55,7 @@ object ViewFactory {
GlobToolView.canRender(content) -> GlobToolView(content, selection = selection, repo = repo)
SearchToolView.canRender(content) -> SearchToolView(content, selection = selection, repo = repo)
ReadToolView.canRender(content) -> ReadToolView(content, openFile, selection = selection)
EditToolView.canRender(content) -> EditToolView(content, openFile, selection = selection)
TaskToolView.canRender(content) -> TaskToolView(content, selection = selection)
else -> ToolView(content, selection = selection)
}
@@ -100,6 +102,8 @@ object ViewFactory {
if (view !is SearchToolView && SearchToolView.canRender(content)) return true
if (view is ReadToolView) return !ReadToolView.canRender(content) || QuestionResultView.canRender(content)
if (view is ToolView && ReadToolView.canRender(content)) return true
if (view is EditToolView) return !EditToolView.canRender(content) || QuestionResultView.canRender(content)
if (view is ToolView && EditToolView.canRender(content)) return true
if (view is TaskToolView) return !TaskToolView.canRender(content) || QuestionResultView.canRender(content)
if (view !is TaskToolView && TaskToolView.canRender(content)) return true
if (view is ToolView) return QuestionResultView.canRender(content)
@@ -0,0 +1,276 @@
package ai.kilocode.client.session.views.tool
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolKind
import ai.kilocode.client.session.ui.popup.HeaderPopupBody
import ai.kilocode.client.session.ui.popup.HeaderPopupRequest
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.telemetry.Telemetry
import ai.kilocode.client.ui.DiffStatBadge
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.md.MdCodeBlockBorder
import ai.kilocode.client.ui.md.MdCodeBlockOptions
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.util.Disposer
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBLabel
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBFont
import com.intellij.util.ui.JBUI
import java.awt.Dimension
import javax.swing.ScrollPaneConstants
/**
* Renders write tools (edit/write/apply_patch) with a Read-style header — an "Edit" title and a
* clickable file link — plus a diff-stat changes tag. The expandable body and the collapsed hover
* popup both render the unified diff via the shared markdown code editor, which colors it as a diff.
*/
class EditToolView(
tool: Tool,
private val openFile: SessionFileOpener = { _, _ -> },
private val selection: SessionSelection? = null,
private val parts: ToolParts = toolParts(tool, openFile),
private var body: EditBody = editBody(tool, selection, openFile),
) : SecondarySessionPartView(parts.header, { body.mount(tool) }), UiDataProvider {
override val contentId: String = tool.id
private var item = tool
private var style = SessionEditorStyle.current()
private var multi = editFiles(tool).size > 1
private val badge = DiffStatBadge(0, 0)
private val filesTag = JBLabel().apply {
foreground = UiStyle.Colors.weak()
font = JBFont.small()
border = JBUI.Borders.emptyRight(SessionUiStyle.View.Layout.HORIZONTAL_PADDING)
isVisible = false
}
init {
body.parent = this
parts.controls.add(filesTag)
parts.controls.add(badge)
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot, filesTag, badge)
applyStyle(style)
sync()
}
override fun uiDataSnapshot(sink: DataSink) {
selection?.provideCopy(sink) { body.markdown() ?: diffMarkdown(item) }
}
@RequiresEdt
override fun expand(): Boolean {
val changed = super.expand()
if (!changed) return false
syncBody()
body.applyStyle(style)
return true
}
@RequiresEdt
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
val height = row.preferredSize.height + (body.panel()?.preferredSize?.height ?: 0)
return Dimension(size.width, minOf(size.height, height))
}
@RequiresEdt
override fun update(content: Content) {
if (content !is Tool) return
item = content
var changed = if (!expandable()) collapse() else false
changed = swapBody() || changed
changed = sync() || changed
changed = syncBody() || changed
if (changed) refresh()
}
/** Rebuild the body delegate when a streaming tool crosses the single/multi-file boundary. */
@RequiresEdt
private fun swapBody(): Boolean {
val next = editFiles(item).size > 1
if (next == multi) return false
multi = next
val expanded = isExpanded()
discardBody()
body.disposeBody()
body = editBody(item, selection, openFile).also { it.parent = this }
if (expanded) expand()
return true
}
@RequiresEdt
fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text)
.filter { it.isNotBlank() }
.joinToString(" ")
@RequiresEdt
fun bodyText(): String = editDiff(item)
@RequiresEdt
fun hasToggle(): Boolean = arrow.isVisible
@RequiresEdt
fun diffStat(): Pair<Int, Int> = diffStat(item)
@RequiresEdt
internal fun badgeVisible() = badge.isVisible
@RequiresEdt
internal fun filesTagVisible() = filesTag.isVisible
@RequiresEdt
internal fun filesTagText() = filesTag.text
@RequiresEdt
internal fun linkVisible() = parts.link.isVisible
@RequiresEdt
internal fun linkLabel() = parts.label
@RequiresEdt
internal fun linkHref() = parts.href
@RequiresEdt
internal fun linkTooltip() = parts.link.toolTipText
@RequiresEdt
internal fun openLink() = parts.openLink()
@RequiresEdt
internal fun bodyCreated() = body.created()
@RequiresEdt
internal fun bodyVisible() = body.attached(this)
@RequiresEdt
internal fun markdown() = body.markdown() ?: diffMarkdown(item)
@RequiresEdt
internal fun codeEditors(): List<EditorTextField> = body.codeEditors()
@RequiresEdt
override fun headerPopup(): HeaderPopupRequest? {
if (isExpanded()) return null
if (editDiff(item).isBlank()) return null
return HeaderPopupRequest(row, build = { buildPopupBody() }) {
Telemetry.send("Header Popup Shown", mapOf("surface" to "session", "tool" to "edit"))
}
}
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
var changed = false
changed = setFont(parts.title, style.boldEditorFont) || changed
changed = setFont(parts.sub, style.transcriptFont) || changed
changed = setFont(parts.link, style.transcriptFont) || changed
changed = setFont(parts.state, style.smallEditorFont) || changed
changed = body.applyStyle(style) || changed
if (changed) refresh()
}
private fun expandable(): Boolean =
editDiff(item).isNotBlank() || output(item).isNotBlank() || !item.error.isNullOrBlank()
private fun sync(): Boolean {
val expand = expandable()
var changed = false
changed = syncExpandable(expand) || changed
changed = setVisible(parts.state, !expand) || changed
changed = setIcon(parts.glyph, icon(item)) || changed
changed = setForeground(parts.glyph, color(item)) || changed
val count = editFiles(item).size
val titleText = if (count > 1) KiloBundle.message("session.part.tool.patch") else title(item)
changed = setText(parts.title, titleText) || changed
val path = if (count > 1) null else editPath(item)
changed = setFileTarget(parts, path, if (path == null) "" else tail(path)) || changed
changed = setForeground(parts.title, titleColor(item)) || changed
changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed
changed = setText(parts.state, stateText(item)) || changed
changed = setForeground(parts.state, color(item)) || changed
changed = syncFilesTag(count) || changed
changed = syncBadge() || changed
return changed
}
private fun syncFilesTag(count: Int): Boolean {
val show = count > 1
var changed = setVisible(filesTag, show)
if (show) changed = setText(filesTag, KiloBundle.message("session.part.tool.edit.files", count)) || changed
return changed
}
private fun syncBadge(): Boolean {
val (added, removed) = diffStat(item)
val show = added > 0 || removed > 0
val changed = setVisible(badge, show)
if (show) badge.update(added, removed)
return changed
}
private fun syncBody(): Boolean = body.update(item)
@RequiresEdt
private fun buildPopupBody(): HeaderPopupBody {
val owner = Disposer.newDisposable("Edit popup body")
val popup = popupBody(item, selection, openFile).also { it.parent = owner }
// mount() already renders the current item (ToolMarkdownBody.mount calls update; PatchBody.mount
// calls rebuild and sets its signature), so a follow-up update() here would be a no-op.
val panel = popup.mount(item)
popup.applyStyle(style)
return HeaderPopupBody(panel, owner, style.editorBackground, SessionUiStyle.View.Popup.WIDE_MAX_WIDTH)
}
override fun dumpLabel() = "EditToolView#$contentId(${labelText()})"
companion object {
fun canRender(tool: Tool) = tool.kind == ToolKind.WRITE
}
}
/** Picks the multi-file patch body for apply_patch spanning several files, else the single diff. */
private fun editBody(tool: Tool, selection: SessionSelection?, openFile: SessionFileOpener): EditBody =
if (editFiles(tool).size > 1) PatchBody(selection, openFile) else diffBody(selection)
private fun popupBody(tool: Tool, selection: SessionSelection?, openFile: SessionFileOpener): EditBody =
if (editFiles(tool).size > 1) PatchBody(selection, openFile, POPUP_OPTS) else popupDiffBody(selection)
private fun diffBody(selection: SessionSelection?) = ToolMarkdownBody(
MdCodeBlockOptions(
border = MdCodeBlockBorder.Bottom,
maxLines = SessionUiStyle.View.Tool.DIFF_LINES,
verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
editorOnly = true,
),
selection,
render = ::diffMarkdown,
)
private fun popupDiffBody(selection: SessionSelection?) = ToolMarkdownBody(
POPUP_OPTS,
selection,
render = ::diffMarkdown,
)
private val POPUP_OPTS = MdCodeBlockOptions(
border = MdCodeBlockBorder.None,
verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
editorOnly = true,
)
/**
* Diff body markdown: per-file sections when an apply_patch touched multiple files, otherwise the
* single unified patch, falling back to the tool output/error when no diff is available.
*/
@RequiresEdt
internal fun diffMarkdown(tool: Tool): String {
val files = editFiles(tool)
if (files.count { it.patch.isNotBlank() } > 1) return multiFileDiffMarkdown(files)
val diff = editDiff(tool)
if (diff.isNotBlank()) return patchMarkdown(diff)
val body = plainBody(tool)
if (body.isBlank()) return ""
val fence = fence(body)
return buildString {
append(fence).append('\n')
append(body)
if (!body.endsWith('\n')) append('\n')
append(fence)
}
}
@@ -0,0 +1,191 @@
package ai.kilocode.client.session.views.tool
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.ui.DiffStatBadge
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.client.ui.md.MdCodeBlockBorder
import ai.kilocode.client.ui.md.MdCodeBlockFactory
import ai.kilocode.client.ui.md.MdCodeBlockOptions
import ai.kilocode.client.ui.md.MdView
import ai.kilocode.client.ui.md.MdViewFactory
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Component
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
/**
* Body surface shared by the single-file markdown diff ([ToolMarkdownBody]) and the multi-file
* apply_patch view ([PatchBody]), so [EditToolView] can hold either behind one type and swap between
* them when a streaming tool crosses the single/multi boundary.
*/
interface EditBody {
var parent: Disposable?
@RequiresEdt fun mount(tool: Tool): JComponent
@RequiresEdt fun created(): Boolean
@RequiresEdt fun panel(): JComponent?
@RequiresEdt fun attached(host: Component): Boolean
@RequiresEdt fun update(tool: Tool): Boolean
@RequiresEdt fun applyStyle(style: SessionEditorStyle): Boolean
@RequiresEdt fun markdown(): String?
@RequiresEdt fun codeEditors(): List<EditorTextField>
@RequiresEdt fun disposeBody()
}
/**
* Renders an apply_patch that touched several files as one section per file: a clickable filename
* link (same chrome as the Read/Edit header link) plus a per-file changes badge, left-aligned to the
* diff's own text inset, followed by that file's unified diff. Sections are rebuilt as a group when
* the underlying file set changes, matching the retained-Swing rebuild-on-add/remove convention.
*/
class PatchBody(
private val selection: SessionSelection?,
private val openFile: SessionFileOpener,
private val opts: MdCodeBlockOptions = DIFF_OPTS,
) : EditBody {
override var parent: Disposable? = null
private var root: Stack? = null
private var owner: Disposable? = null
private val views = mutableListOf<MdView>()
private val links = mutableListOf<FileLinkLabel>()
private var style = SessionEditorStyle.current()
private var signature = ""
@RequiresEdt
override fun mount(tool: Tool): JComponent {
root?.let { return it }
val panel = Stack.vertical()
root = panel
rebuild(tool)
return panel
}
@RequiresEdt
override fun created(): Boolean = root != null
@RequiresEdt
override fun panel(): JComponent? = root
@RequiresEdt
override fun attached(host: Component): Boolean = root?.parent === host
@RequiresEdt
override fun update(tool: Tool): Boolean {
if (root == null) return false
if (signatureOf(tool) == signature) return false
rebuild(tool)
return true
}
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle): Boolean {
this.style = style
var changed = false
views.forEach { changed = applyMd(it) || changed }
links.forEach { if (it.font != style.transcriptFont) { it.font = style.transcriptFont; changed = true } }
return changed
}
@RequiresEdt
override fun markdown(): String? {
if (views.isEmpty()) return null
return views.joinToString("\n\n") { it.markdown() }
}
@RequiresEdt
override fun codeEditors(): List<EditorTextField> = views.flatMap { view ->
(view.component as? JPanel)?.components
?.filterIsInstance<JBScrollPane>()
?.mapNotNull { it.viewport.view as? EditorTextField }
?: emptyList()
}
@RequiresEdt
override fun disposeBody() {
val panel = root
owner?.let(Disposer::dispose)
owner = null
views.clear()
links.clear()
panel?.removeAll()
signature = ""
}
@RequiresEdt
private fun rebuild(tool: Tool) {
val panel = root ?: return
val parent = parent ?: error("Patch body has no parent")
disposeBody()
val disposable = Disposer.newDisposable("Patch body")
Disposer.register(parent, disposable)
owner = disposable
editFiles(tool).filter { it.patch.isNotBlank() }.forEachIndexed { index, file ->
if (index > 0) panel.gap(JBUI.scale(SessionUiStyle.View.Code.BLOCK_GAP))
panel.next(header(file))
panel.gap(UiStyle.Gap.sm())
val md = MdViewFactory.create(style, selection, MdCodeBlockFactory.default(opts))
Disposer.register(disposable, md)
applyMd(md)
md.set(patchMarkdown(file.patch))
views.add(md)
panel.next(md.component)
}
signature = signatureOf(tool)
panel.revalidate()
panel.repaint()
}
private fun signatureOf(tool: Tool): String = editFiles(tool)
.joinToString("\u0000") { "${it.path}\u0001${it.additions}\u0001${it.deletions}\u0001${it.patch}" }
@RequiresEdt
private fun header(file: EditFileChange): JComponent {
val link = FileLinkLabel(openFile).apply {
foreground = UiStyle.Colors.fg()
font = style.transcriptFont
setTarget(file.path, tail(file.path))
isVisible = true
}
links.add(link)
val row = Stack.horizontal(UiStyle.Gap.sm())
.next(link)
.next(DiffStatBadge(file.additions, file.deletions))
return JBUI.Panels.simplePanel(row).apply {
isOpaque = false
border = JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING)
}
}
private fun applyMd(md: MdView): Boolean {
val before = md.font
md.applyStyle(style)
md.font = style.editorFont
md.foreground = style.editorForeground
md.background = style.editorBackground
md.preBg = style.editorBackground
md.codeFont = style.editorFamily
md.component.border = JBUI.Borders.empty()
return before != md.font
}
private companion object {
val DIFF_OPTS = MdCodeBlockOptions(
border = MdCodeBlockBorder.Bottom,
maxLines = SessionUiStyle.View.Tool.DIFF_LINES,
verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
editorOnly = true,
)
}
}
@@ -96,6 +96,8 @@ class ReadToolView(
@RequiresEdt
internal fun linkHref() = parts.href
@RequiresEdt
internal fun linkTooltip() = parts.link.toolTipText
@RequiresEdt
internal fun openLink() = parts.openLink()
@RequiresEdt
@@ -129,24 +131,9 @@ class ReadToolView(
private fun syncSubtitle(): Boolean {
val target = target(item)?.takeIf { it.type == "file" }
if (target != null) {
var changed = false
if (parts.href != target.path) {
parts.href = target.path
changed = true
}
changed = setLinkText(parts, tail(target.path).ifBlank { target.path }) || changed
changed = show(parts, true) || changed
return changed
}
var changed = false
if (parts.href != null) {
parts.href = null
changed = true
}
if (target != null) return setFileTarget(parts, target.path, tail(target.path))
var changed = setFileTarget(parts, null, "")
changed = setText(parts.sub, subtitle(item)) || changed
changed = show(parts, false) || changed
return changed
}
@@ -14,12 +14,11 @@ import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.md.MdCodeBlockBorder
import ai.kilocode.client.ui.md.MdCodeBlockFactory
import ai.kilocode.client.ui.md.MdCodeBlockOptions
import ai.kilocode.client.ui.md.MdView
import ai.kilocode.client.ui.md.MdViewFactory
import ai.kilocode.client.ui.md.hybrid.MdTerminal
import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBHtmlPane
import com.intellij.ui.components.JBScrollPane
@@ -34,8 +33,8 @@ class ShellToolView(
tool: Tool,
private val selection: SessionSelection? = null,
private val parts: ToolParts = toolParts(tool),
private val holder: ShellHolder = ShellHolder(tool, selection),
) : SecondarySessionPartView(parts.header, { holder.body().panel }), UiDataProvider {
private val body: ToolMarkdownBody = shellBody(selection),
) : SecondarySessionPartView(parts.header, { body.mount(tool) }), UiDataProvider {
override val contentId: String = tool.id
@@ -43,14 +42,14 @@ class ShellToolView(
private var style = SessionEditorStyle.current()
init {
holder.parent = this
body.parent = this
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
applyStyle(style)
sync()
}
override fun uiDataSnapshot(sink: DataSink) {
selection?.provideCopy(sink) { holder.shell?.markdown() ?: fallbackText() }
selection?.provideCopy(sink) { body.markdown() ?: fallbackText() }
}
private fun fallbackText() = ShellContent(item).body
@@ -60,7 +59,7 @@ class ShellToolView(
val changed = super.expand()
if (!changed) return false
syncBody()
holder.shell?.applyStyle(style)
body.applyStyle(style)
return true
}
@@ -68,7 +67,7 @@ class ShellToolView(
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
val height = row.preferredSize.height + (holder.shell?.panel?.preferredSize?.height ?: 0)
val height = row.preferredSize.height + (body.panel()?.preferredSize?.height ?: 0)
return Dimension(size.width, minOf(size.height, height))
}
@@ -105,16 +104,16 @@ class ShellToolView(
fun hasToggle(): Boolean = arrow.isVisible
@RequiresEdt
internal fun bodyCreated() = holder.shell != null
internal fun bodyCreated() = body.created()
@RequiresEdt
internal fun bodyVisible() = holder.shell?.panel?.parent === this
internal fun bodyVisible() = body.attached(this)
@RequiresEdt
internal fun markdown() = holder.shell?.markdown() ?: ShellContent(item).markdown
internal fun markdown() = body.markdown() ?: ShellContent(item).markdown
@RequiresEdt
internal fun codeEditors(): List<EditorTextField> = holder.shell?.codeEditors() ?: emptyList()
internal fun codeEditors(): List<EditorTextField> = body.codeEditors()
@RequiresEdt
internal fun commandFont() = codeEditors().firstOrNull()?.font ?: style.editorFont
@@ -138,10 +137,10 @@ class ShellToolView(
internal fun controlCount() = if (arrow.isVisible) 1 else 0
@RequiresEdt
internal fun mdComponent() = holder.shell?.mdComponent()
internal fun mdComponent() = body.panel()
@RequiresEdt
internal fun horizontalPolicy() = holder.shell?.scrolls()?.firstOrNull()?.horizontalScrollBarPolicy
internal fun horizontalPolicy() = body.scrolls().firstOrNull()?.horizontalScrollBarPolicy
?: ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
@RequiresEdt
@@ -161,7 +160,7 @@ class ShellToolView(
changed = setFont(parts.sub, style.transcriptFont) || changed
changed = setFont(parts.link, style.smallEditorFont) || changed
changed = setFont(parts.state, style.smallEditorFont) || changed
holder.shell?.let { changed = it.applyStyle(style) || changed }
changed = body.applyStyle(style) || changed
if (changed) refresh()
}
@@ -181,10 +180,7 @@ class ShellToolView(
return changed
}
private fun syncBody(): Boolean {
val body = holder.shell ?: return false
return body.update(item)
}
private fun syncBody(): Boolean = body.update(item)
@RequiresEdt
private fun buildPopupBody(cmd: String): HeaderPopupBody {
@@ -208,7 +204,7 @@ class ShellToolView(
md.component.border = JBUI.Borders.empty()
md.set(popupMd(formatCommand(cmd)))
padPopup(md.component)
return HeaderPopupBody(md.component, md, style.editorBackground)
return HeaderPopupBody(md.component, md, style.editorBackground, SessionUiStyle.View.Popup.WIDE_MAX_WIDTH)
}
override fun dumpLabel() = "ShellToolView#$contentId(${labelText()})"
@@ -234,96 +230,28 @@ private fun padPopup(root: JComponent) {
private fun grow(size: Dimension, pad: Int) = Dimension(size.width, size.height + pad)
class ShellHolder(
private val tool: Tool,
private val selection: SessionSelection?,
) {
var parent: Disposable? = null
var shell: ShellBody? = null
private fun shellBody(selection: SessionSelection?) = ToolMarkdownBody(
MdCodeBlockOptions(
border = MdCodeBlockBorder.Bottom,
maxLines = 15,
verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
editorOnly = true,
),
selection,
render = { ShellContent(it).markdown },
font = SessionEditorStyle::transcriptFont,
chrome = ::styleShellHtml,
)
@RequiresEdt
fun body(): ShellBody {
val current = shell
if (current != null) return current
val owner = parent ?: error("Shell holder has no parent")
return ShellBody(tool, selection, owner).also {
shell = it
Disposer.register(owner, it)
}
/** Pads the left edge of shell section headers ("Command"/"Output") to line up with code text. */
@RequiresEdt
private fun styleShellHtml(md: MdView) {
val root = md.component as? JPanel ?: return
root.components.filterIsInstance<JBHtmlPane>().forEach {
it.border = JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING)
}
}
class ShellBody(
tool: Tool,
selection: SessionSelection?,
parent: Disposable,
) : Disposable {
private val md = MdViewFactory.create(
SessionEditorStyle.current(),
selection,
MdCodeBlockFactory.default(
MdCodeBlockOptions(
border = MdCodeBlockBorder.Bottom,
maxLines = 15,
verticalPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED,
editorOnly = true,
),
),
)
val panel = md.component
init {
Disposer.register(parent, md)
applyStyle(SessionEditorStyle.current())
update(tool)
}
@RequiresEdt
fun update(tool: Tool): Boolean {
val content = ShellContent(tool)
if (md.markdown() == content.markdown) return false
md.set(content.markdown)
styleShell()
return true
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle): Boolean {
val before = md.font
md.applyStyle(style)
md.font = style.transcriptFont
md.foreground = style.editorForeground
md.background = style.editorBackground
md.preBg = style.editorBackground
md.codeFont = style.editorFamily
md.component.border = JBUI.Borders.empty()
styleShell()
return before != md.font
}
@RequiresEdt
private fun styleShell() {
val root = md.component as? JPanel ?: return
root.components.filterIsInstance<JBHtmlPane>().forEach {
it.border = JBUI.Borders.emptyLeft(SessionUiStyle.View.Code.VIEWPORT_HORIZONTAL_PADDING)
}
}
@RequiresEdt
fun markdown() = md.markdown()
@RequiresEdt
fun mdComponent() = md.component
@RequiresEdt
fun scrolls(): List<JBScrollPane> = (md.component as? JPanel)?.components?.filterIsInstance<JBScrollPane>() ?: emptyList()
@RequiresEdt
fun codeEditors(): List<EditorTextField> = scrolls().mapNotNull { it.viewport.view as? EditorTextField }
override fun dispose() = Unit
}
private data class ShellContent(
val command: String,
val output: String,
@@ -406,9 +334,4 @@ private fun StringBuilder.section(title: String, text: String, lang: String) {
append(fence)
}
private fun fence(text: String): String {
val size = Regex("`+").findAll(text).maxOfOrNull { it.value.length } ?: 0
return "`".repeat(maxOf(3, size + 1))
}
private fun clean(text: String): String = MdTerminal.strip(MdTerminal.reduce(text, keepSgr = false))
@@ -0,0 +1,102 @@
package ai.kilocode.client.session.views.tool
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.md.MdCodeBlockFactory
import ai.kilocode.client.ui.md.MdCodeBlockOptions
import ai.kilocode.client.ui.md.MdView
import ai.kilocode.client.ui.md.MdViewFactory
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Component
import java.awt.Font
import javax.swing.JComponent
import javax.swing.JPanel
/**
* A markdown-backed tool body (unified diff, shell transcript, ...) that is built lazily on first
* expansion and then mutated in place. Shared by [ShellToolView] and [EditToolView] so the
* lazy-init, styling, disposal, and editor-lookup logic lives in one place instead of being
* duplicated per tool.
*
* [render] turns the current [Tool] into the markdown to display, [font] picks the body font from
* the active style, and [chrome] applies any per-view tweaks after the markdown is (re)built.
*/
class ToolMarkdownBody(
private val opts: MdCodeBlockOptions,
private val selection: SessionSelection?,
private val render: (Tool) -> String,
private val font: (SessionEditorStyle) -> Font = SessionEditorStyle::editorFont,
private val chrome: (MdView) -> Unit = {},
) : EditBody {
override var parent: Disposable? = null
private var view: MdView? = null
/** Builds the body on first call, wiring it into [parent]'s disposable tree, then returns it. */
@RequiresEdt
override fun mount(tool: Tool): JComponent {
view?.let { return it.component }
val owner = parent ?: error("Tool markdown body has no parent")
val md = MdViewFactory.create(SessionEditorStyle.current(), selection, MdCodeBlockFactory.default(opts))
Disposer.register(owner, md)
view = md
applyStyle(SessionEditorStyle.current())
update(tool)
return md.component
}
@RequiresEdt
override fun created(): Boolean = view != null
@RequiresEdt
override fun panel(): JComponent? = view?.component
@RequiresEdt
override fun attached(host: Component): Boolean = view?.component?.parent === host
@RequiresEdt
override fun update(tool: Tool): Boolean {
val md = view ?: return false
val value = render(tool)
if (md.markdown() == value) return false
md.set(value)
chrome(md)
return true
}
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle): Boolean {
val md = view ?: return false
val before = md.font
md.applyStyle(style)
md.font = font(style)
md.foreground = style.editorForeground
md.background = style.editorBackground
md.preBg = style.editorBackground
md.codeFont = style.editorFamily
md.component.border = JBUI.Borders.empty()
chrome(md)
return before != md.font
}
@RequiresEdt
override fun markdown(): String? = view?.markdown()
@RequiresEdt
fun scrolls(): List<JBScrollPane> =
(view?.component as? JPanel)?.components?.filterIsInstance<JBScrollPane>() ?: emptyList()
@RequiresEdt
override fun codeEditors(): List<EditorTextField> = scrolls().mapNotNull { it.viewport.view as? EditorTextField }
@RequiresEdt
override fun disposeBody() {
view?.let(Disposer::dispose)
view = null
}
}
@@ -6,6 +6,7 @@ import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionFileOpener
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.ToolKind
import ai.kilocode.client.session.ui.selection.SessionSelection
import ai.kilocode.client.session.ui.selection.SessionCopyTarget
import ai.kilocode.client.session.ui.style.SessionEditorStyle
@@ -36,8 +37,14 @@ import com.intellij.ui.components.JBTextArea
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import com.intellij.xml.util.XmlStringUtil
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.Color
import java.awt.Cursor
import java.awt.Dimension
@@ -59,18 +66,17 @@ class ToolParts(
val glyph: JBLabel,
val title: JBLabel,
val sub: JBLabel,
val link: JBLabel,
val link: FileLinkLabel,
val slot: JPanel,
val state: JBLabel,
val center: JPanel,
val controls: JComponent,
private val open: SessionFileOpener? = null,
val extra: JBLabel? = null,
val targets: List<JBLabel> = emptyList(),
private val mode: ToolBodyMode = ToolBodyMode.EDITOR,
) {
var href: String? = null
var label: String = ""
val href: String? get() = link.href
val label: String get() = link.label
private var body: ToolBody? = null
val text: JBTextArea?
@@ -93,8 +99,7 @@ class ToolParts(
@RequiresEdt
fun openLink(anchor: RelativePoint? = null) {
val value = href ?: return
open?.invoke(value, anchor)
link.openLink(anchor)
}
@RequiresEdt
@@ -109,6 +114,52 @@ class ToolParts(
}
}
class FileLinkLabel(
private val open: SessionFileOpener? = null,
) : JBLabel() {
var href: String? = null
private set
var label: String = ""
private set
init {
isVisible = false
isFocusable = false
foreground = UiStyle.Colors.fg()
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
setRequestFocusEnabled(false)
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
openLink(RelativePoint(this@FileLinkLabel, Point(width / 2, height)))
}
})
}
@RequiresEdt
fun setTarget(path: String?, text: String): Boolean {
val next = single(text.ifBlank { path.orEmpty() })
val value = if (next.isBlank()) "" else XmlStringUtil.wrapInHtml("<nobr><u>${XmlStringUtil.escapeString(next)}</u></nobr>")
var changed = false
if (href != path) {
href = path
toolTipText = path
changed = true
}
if (label != next || this.text != value) {
label = next
this.text = value
changed = true
}
return changed
}
@RequiresEdt
fun openLink(anchor: RelativePoint? = null) {
val value = href ?: return
open?.invoke(value, anchor)
}
}
class ToolBody private constructor(
val area: JBTextArea?,
val ed: EditorTextField?,
@@ -345,36 +396,20 @@ private class ToolField(value: String, private var style: SessionEditorStyle, pr
}
}
private const val SUB_CARD = "sub"
private const val LINK_CARD = "link"
@RequiresEdt
internal fun toolParts(
tool: Tool,
openFile: SessionFileOpener? = null,
mode: ToolBodyMode = ToolBodyMode.TEXT,
): ToolParts {
lateinit var parts: ToolParts
val glyph = JBLabel()
val title = clip(JBLabel())
val sub = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() }
val link = clip(JBLabel()).apply {
isVisible = false
isFocusable = false
foreground = UiStyle.Colors.fg()
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
setRequestFocusEnabled(false)
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
parts.openLink(RelativePoint(this@apply, Point(width / 2, 0)))
}
})
}
val slot = JPanel(CardLayout()).apply {
isOpaque = false
val link = clip(FileLinkLabel(openFile))
val slot = Stack.fitHorizontal().apply {
minimumSize = Dimension(0, minimumSize.height)
add(sub, SUB_CARD)
add(link, LINK_CARD)
next(sub)
next(link)
}
val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() }
val center = JPanel(BorderLayout(UiStyle.Gap.md(), 0)).apply {
@@ -390,7 +425,7 @@ internal fun toolParts(
add(center, BorderLayout.CENTER)
add(controls, BorderLayout.EAST)
}
parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, openFile, mode = mode)
val parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, mode = mode)
return parts.also {
controls.add(it.state)
}
@@ -406,12 +441,11 @@ internal fun searchParts(count: Int): ToolParts {
foreground = UiStyle.Colors.fg()
}
}
val link = clip(JBLabel()).apply { isVisible = false }
val slot = JPanel(CardLayout()).apply {
isOpaque = false
val link = clip(FileLinkLabel())
val slot = Stack.fitHorizontal().apply {
minimumSize = Dimension(0, minimumSize.height)
add(sub, SUB_CARD)
add(link, LINK_CARD)
next(sub)
next(link)
}
val state = clip(JBLabel()).apply { foreground = UiStyle.Colors.weak() }
val stack = Stack.fitHorizontal(UiStyle.Gap.md()).apply { targets.forEach { next(it) } }
@@ -449,9 +483,10 @@ internal fun icon(tool: Tool) = when (tool.name) {
else -> SessionViewIcons.mcp
}
internal fun title(tool: Tool) = when (tool.name) {
"read" -> KiloBundle.message("session.part.tool.read")
"bash" -> KiloBundle.message("session.part.tool.shell")
internal fun title(tool: Tool) = when {
tool.name == "read" -> KiloBundle.message("session.part.tool.read")
tool.name == "bash" -> KiloBundle.message("session.part.tool.shell")
tool.kind == ToolKind.WRITE -> KiloBundle.message("session.part.tool.edit")
else -> toolTitle(tool)
}
@@ -477,17 +512,18 @@ internal fun setTargetText(label: JBLabel, text: String): Boolean {
return true
}
/**
* Shows [path] as a clickable file link in the header slot, or clears the link when [path] is null.
* Shared by [ai.kilocode.client.session.views.tool.ReadToolView] and
* [ai.kilocode.client.session.views.tool.EditToolView] so both render file targets identically.
*/
@RequiresEdt
internal fun setLinkText(parts: ToolParts, text: String): Boolean {
val label = single(text)
val value = if (label.isBlank()) "" else XmlStringUtil.wrapInHtml("<nobr><u>${XmlStringUtil.escapeString(label)}</u></nobr>")
if (parts.label == label && parts.link.text == value) return false
parts.label = label
parts.link.text = value
return true
internal fun setFileTarget(parts: ToolParts, path: String?, label: String): Boolean {
val changed = parts.link.setTarget(path, label)
return show(parts, path != null) || changed
}
private fun clip(label: JBLabel): JBLabel = label.apply {
private fun <T : JBLabel> clip(label: T): T = label.apply {
minimumSize = Dimension(0, minimumSize.height)
}
@@ -504,9 +540,10 @@ private fun single(text: String): String = text.lineSequence()
@RequiresEdt
internal fun show(parts: ToolParts, link: Boolean): Boolean {
if (parts.link.isVisible == link && parts.sub.isVisible != link) return false
(parts.slot.layout as CardLayout).show(parts.slot, if (link) LINK_CARD else SUB_CARD)
return true
var changed = false
changed = setVisible(parts.link, link) || changed
changed = setVisible(parts.sub, !link) || changed
return changed
}
internal fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text
@@ -704,6 +741,161 @@ private fun toolSubtitle(tool: Tool): String {
return listOfNotNull(base).plus(args).joinToString(" ")
}
/** File path targeted by a write tool, preferring the most specific resolvable path. */
internal fun editPath(tool: Tool): String = editPaths(tool).maxWithOrNull(
compareBy<String>({ OSAgnosticPathUtil.isAbsolute(it) }, { depth(it) }),
) ?: tool.name
private fun editPaths(tool: Tool): List<String> {
val direct = listOf(tool.input["filePath"], tool.input["path"])
val diff = listOfNotNull(editFile(parseJsonObject(tool.metadata["filediff"])))
val files = parseJsonArray(tool.metadata["files"])?.mapNotNull { editFile(it.jsonObject) } ?: emptyList()
return (direct + diff + files + listOf(tool.title, tool.name))
.mapNotNull { it?.takeIf { value -> value.isNotBlank() } }
}
private fun editFile(obj: JsonObject?): String? = listOf("filePath", "path", "file", "relativePath")
.firstNotNullOfOrNull { key -> obj?.get(key)?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } }
private fun depth(path: String): Int = path.count { it == '/' || it == '\\' }
private val DIFF_JSON = Json { ignoreUnknownKeys = true; isLenient = true }
private fun parseJsonObject(raw: String?): JsonObject? =
raw?.takeIf { it.isNotBlank() }?.let { runCatching { DIFF_JSON.parseToJsonElement(it).jsonObject }.getOrNull() }
private fun parseJsonArray(raw: String?): JsonArray? =
raw?.takeIf { it.isNotBlank() }?.let { runCatching { DIFF_JSON.parseToJsonElement(it) as? JsonArray }.getOrNull() }
private fun patchOf(obj: JsonObject?): String? =
obj?.get("patch")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
/**
* Unified diff patch produced by a write tool, or empty when none is available. Kilo strips the raw
* `diff` field from stored parts (see stripPartMetadata) but keeps `filediff.patch` (edit/write) and
* per-file `files[].patch` (apply_patch) when under the size cap, so read those first.
*/
internal fun editDiff(tool: Tool): String {
tool.metadata["diff"]?.takeIf { it.isNotBlank() }?.let { return it }
patchOf(parseJsonObject(tool.metadata["filediff"]))?.let { return it }
parseJsonArray(tool.metadata["files"])?.let { files ->
val joined = files.mapNotNull { patchOf(it.jsonObject) }.joinToString("\n")
if (joined.isNotBlank()) return joined
}
return ""
}
/** One file touched by an apply_patch call, parsed from the tool's `files[]` metadata. */
internal data class EditFileChange(
val path: String,
val type: String,
val additions: Int,
val deletions: Int,
val patch: String,
)
/** Per-file changes from an apply_patch tool; empty for single-file edit/write tools (`filediff`). */
internal fun editFiles(tool: Tool): List<EditFileChange> =
parseJsonArray(tool.metadata["files"])?.mapNotNull { element ->
val obj = element.jsonObject
val path = editFile(obj) ?: return@mapNotNull null
EditFileChange(
path = path,
type = obj["type"]?.jsonPrimitive?.contentOrNull.orEmpty(),
additions = obj["additions"]?.jsonPrimitive?.intOrNull ?: 0,
deletions = obj["deletions"]?.jsonPrimitive?.intOrNull ?: 0,
patch = patchOf(obj).orEmpty(),
)
} ?: emptyList()
/**
* Sectioned markdown for a multi-file patch: each file gets a labeled header line (path plus its own
* add/remove counts) followed by its own fenced diff, so the joined apply_patch diff no longer runs
* together into one indistinguishable block. The path is wrapped in inline code so characters like
* underscores are not parsed as markdown emphasis.
*/
internal fun multiFileDiffMarkdown(files: List<EditFileChange>): String =
files.filter { it.patch.isNotBlank() }.joinToString("\n\n") { file ->
buildString {
append('`').append(tail(file.path)).append('`')
append(" +").append(file.additions).append(" -").append(file.deletions)
append("\n\n")
append(patchMarkdown(file.patch))
}
}
/** Added/removed line counts, preferring the counts computed by the CLI, else counting patch lines. */
internal fun diffStat(tool: Tool): Pair<Int, Int> {
parseJsonObject(tool.metadata["filediff"])?.let { fd ->
val add = fd["additions"]?.jsonPrimitive?.intOrNull
val del = fd["deletions"]?.jsonPrimitive?.intOrNull
if (add != null || del != null) return (add ?: 0) to (del ?: 0)
}
parseJsonArray(tool.metadata["files"])?.let { files ->
var add = 0
var del = 0
var found = false
files.forEach {
it.jsonObject["additions"]?.jsonPrimitive?.intOrNull?.let { v -> add += v; found = true }
it.jsonObject["deletions"]?.jsonPrimitive?.intOrNull?.let { v -> del += v; found = true }
}
if (found) return add to del
}
val patch = editDiff(tool)
if (patch.isBlank()) return 0 to 0
var added = 0
var removed = 0
for (line in patch.lineSequence()) {
when {
line.startsWith("+++") || line.startsWith("---") -> Unit
line.startsWith("+") -> added++
line.startsWith("-") -> removed++
}
}
return added to removed
}
/** Display-only diff body without VCS/file metadata headers (Index, diff --git, ---, +++, etc.). */
internal fun pureDiff(diff: String): String = diff.lineSequence()
.filterNot(::diffMeta)
.joinToString("\n")
.trim('\n')
private fun diffMeta(line: String): Boolean = line.startsWith("Index:") ||
line.startsWith("====") ||
line.startsWith("diff --git ") ||
line.startsWith("@@") ||
line.startsWith("index ") ||
line.startsWith("--- ") ||
line.startsWith("+++ ") ||
line.startsWith("new file mode ") ||
line.startsWith("deleted file mode ") ||
line.startsWith("old mode ") ||
line.startsWith("new mode ") ||
line.startsWith("similarity index ") ||
line.startsWith("dissimilarity index ") ||
line.startsWith("rename from ") ||
line.startsWith("rename to ") ||
line.startsWith("copy from ") ||
line.startsWith("copy to ")
/** Wraps a unified patch in a fenced `patch` block so the markdown code editor highlights it. */
internal fun patchMarkdown(diff: String): String = buildString {
// Fall back to the raw patch when stripping metadata leaves nothing (e.g. a pure rename or
// mode-only change with no +/-/context lines) so we never render an empty fenced block.
val body = pureDiff(diff).ifBlank { diff.trim('\n') }
val fence = fence(body)
append(fence).append("patch-pure\n")
append(body)
if (!body.endsWith('\n')) append('\n')
append(fence)
}
internal fun fence(text: String): String {
val size = Regex("`+").findAll(text).maxOfOrNull { it.value.length } ?: 0
return "`".repeat(maxOf(3, size + 1))
}
internal fun tail(path: String): String {
val value = path.trimEnd('/', '\\')
val index = maxOf(value.lastIndexOf('/'), value.lastIndexOf('\\'))
@@ -0,0 +1,90 @@
package ai.kilocode.client.ui.md.hybrid
import com.intellij.openapi.diff.DiffColors
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.openapi.editor.colors.TextAttributesKey
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.editor.markup.HighlighterLayer
import com.intellij.openapi.editor.markup.HighlighterTargetArea
/**
* Overlays unified-diff coloring on a plain-text code editor: added lines get the theme's diff
* "inserted" background, removed lines the "deleted" background, hunk headers a keyword color, and
* file/index headers a dimmed comment color. Colors come from the active scheme via [DiffColors]
* and [DefaultLanguageHighlighterColors], so the result tracks the IDE theme like the diff viewer.
*/
internal object MdDiffHighlight {
data class Span(val key: TextAttributesKey, val area: HighlighterTargetArea)
data class Display(val text: String, val spans: List<Range>)
data class Range(val start: Int, val end: Int, val span: Span)
fun apply(editor: EditorEx, text: String) {
editor.markupModel.removeAllHighlighters()
val doc = editor.document
val size = doc.textLength
for (n in 0 until doc.lineCount) {
val start = doc.getLineStartOffset(n).coerceAtMost(size)
val end = doc.getLineEndOffset(n).coerceAtMost(size)
if (start >= end) continue
val span = classify(doc.charsSequence.subSequence(start, end).toString()) ?: continue
editor.markupModel.addRangeHighlighter(span.key, start, end, HighlighterLayer.SYNTAX + 1, span.area)
}
}
fun applyPure(editor: EditorEx, text: String) {
editor.markupModel.removeAllHighlighters()
val doc = editor.document
for (range in display(text).spans) {
val start = range.start.coerceAtMost(doc.textLength)
val end = range.end.coerceAtMost(doc.textLength)
if (start >= end) continue
editor.markupModel.addRangeHighlighter(range.span.key, start, end, HighlighterLayer.SYNTAX + 1, range.span.area)
}
}
fun display(text: String): Display {
val out = StringBuilder()
val ranges = mutableListOf<Range>()
text.lineSequence().forEachIndexed { i, line ->
if (i > 0) out.append('\n')
val span = classify(line)
val body = when {
line.startsWith("+") || line.startsWith("-") || line.startsWith(" ") -> line.drop(1)
else -> line
}
val start = out.length
out.append(body)
if (span != null) ranges.add(Range(start, out.length, span))
}
return Display(out.toString(), ranges)
}
private fun classify(line: String): Span? = when {
fileHeader(line) || meta(line) -> comment
line.startsWith("@@") -> hunk
line.startsWith("+") -> inserted
line.startsWith("-") -> deleted
else -> null
}
// Unified-diff file headers are the marker followed by a space (or the bare marker), e.g. "+++ b/f".
// Guarding on that shape keeps content lines like "++x;" (an inserted "+x;") from being dimmed.
private fun fileHeader(line: String): Boolean =
(line.startsWith("+++") || line.startsWith("---")) &&
(line.length == 3 || line[3] == ' ' || line[3] == '\t')
private fun meta(line: String): Boolean = line.startsWith("diff ") ||
line.startsWith("index ") ||
line.startsWith("Index:") ||
line.startsWith("===") ||
line.startsWith("new file") ||
line.startsWith("deleted file") ||
line.startsWith("rename ") ||
line.startsWith("similarity ") ||
line.startsWith("\\ No newline")
private val inserted = Span(DiffColors.DIFF_INSERTED, HighlighterTargetArea.LINES_IN_RANGE)
private val deleted = Span(DiffColors.DIFF_DELETED, HighlighterTargetArea.LINES_IN_RANGE)
private val hunk = Span(DefaultLanguageHighlighterColors.KEYWORD, HighlighterTargetArea.EXACT_RANGE)
private val comment = Span(DefaultLanguageHighlighterColors.LINE_COMMENT, HighlighterTargetArea.EXACT_RANGE)
}
@@ -6,7 +6,7 @@ import com.intellij.openapi.fileTypes.PlainTextFileType
import com.intellij.openapi.fileTypes.UnknownFileType
internal sealed class Kind {
data class Source(val file: FileType) : Kind()
data class Source(val file: FileType, val highlight: Highlight = Highlight.None) : Kind()
data class Terminal(val stream: Stream, val mode: Mode) : Kind()
}
@@ -14,6 +14,9 @@ internal enum class Stream { Stdout, Stderr }
internal enum class Mode { Ansi, Shell, Command }
/** Extra overlay highlighting applied on top of a source code block. */
internal enum class Highlight { None, Diff, DiffPure }
internal object MdLanguage {
/** Internal terminal fence tags produced by ShellToolView shell transcript markdown. */
private val terms = mapOf(
@@ -58,11 +61,16 @@ internal object MdLanguage {
"terraform" to "tf",
)
private val diffs = setOf("diff", "patch", "udiff")
private val pure = setOf("diff-pure", "patch-pure")
fun kind(lang: String?): Kind {
val key = lang?.trim()?.split(Regex("\\s+"))?.take(2)?.joinToString(" ")?.lowercase().orEmpty()
terms[key]?.let { return it }
if (key == "shell script") return Kind.Source(type("sh"))
val single = key.substringBefore(' ')
if (key in pure || single in pure) return Kind.Source(PlainTextFileType.INSTANCE, Highlight.DiffPure)
if (key in diffs || single in diffs) return Kind.Source(PlainTextFileType.INSTANCE, Highlight.Diff)
terms[single]?.let { return it }
files[key]?.let { return Kind.Source(type(it)) }
files[single]?.let { return Kind.Source(type(it)) }
@@ -314,7 +314,7 @@ internal open class MdViewHybrid(
is Desc.Html -> HtmlView(desc, htmlBlock(desc.body, disposable), disposable)
is Desc.Table -> TableView(desc, tableBlock(desc.body, disposable), disposable)
is Desc.Code -> when (val kind = desc.kind) {
is Kind.Source -> CodeView(desc, codeBlock(desc.text, kind.file, disposable), disposable)
is Kind.Source -> CodeView(desc, codeBlock(desc.text, kind, disposable), disposable)
is Kind.Terminal -> TermView(desc, terminalBlock(desc.text, kind, disposable), disposable)
}
}
@@ -331,27 +331,38 @@ internal open class MdViewHybrid(
customStyleSheetProvider { sheet() }
},
), UiDataProvider {
// A stationary pointer over scrolling content must keep this pane's hovered link and
// cursor fresh, so we replay a synthetic mouse move whenever the enclosing viewport
// scrolls. Only the pane under the pointer subscribes — otherwise every prose block in a
// large transcript would run a native pointer query + event dispatch on every scroll tick.
private var viewport: JViewport? = null
private var listening = false
private val scroll = ChangeListener { hover() }
private val pointer = object : java.awt.event.MouseAdapter() {
override fun mouseEntered(e: MouseEvent) = listen(true)
override fun mouseExited(e: MouseEvent) = listen(false)
}
private val hierarchy = java.awt.event.HierarchyListener { event ->
if (event.changeFlags and HierarchyEvent.PARENT_CHANGED.toLong() != 0L) attach()
if (event.changeFlags and HierarchyEvent.PARENT_CHANGED.toLong() != 0L) retarget()
}
init {
addMouseListener(pointer)
addHierarchyListener(hierarchy)
Disposer.register(disposable) {
viewport?.removeChangeListener(scroll)
listen(false)
removeMouseListener(pointer)
removeHierarchyListener(hierarchy)
}
}
override fun addNotify() {
super.addNotify()
attach()
retarget()
}
override fun removeNotify() {
viewport?.removeChangeListener(scroll)
listen(false)
viewport = null
super.removeNotify()
}
@@ -360,12 +371,20 @@ internal open class MdViewHybrid(
selection?.provideCopy(sink) { document.getText(0, document.length).trim() }
}
private fun attach() {
// Follow the enclosing viewport as this pane is reparented, keeping any live subscription.
private fun retarget() {
val next = SwingUtilities.getAncestorOfClass(JViewport::class.java, this) as? JViewport
if (viewport === next) return
viewport?.removeChangeListener(scroll)
if (listening) viewport?.removeChangeListener(scroll)
viewport = next
next?.addChangeListener(scroll)
if (listening) viewport?.addChangeListener(scroll)
}
// Track viewport scrolls only while the pointer is over this pane.
private fun listen(on: Boolean) {
if (listening == on) return
listening = on
if (on) viewport?.addChangeListener(scroll) else viewport?.removeChangeListener(scroll)
}
private fun hover() {
@@ -424,20 +443,20 @@ internal open class MdViewHybrid(
return pane
}
private fun codeBlock(text: String, file: FileType, disposable: Disposable): JBScrollPane {
private fun codeBlock(text: String, kind: Kind.Source, disposable: Disposable): JBScrollPane {
val opts = opts()
val value = text.trimEnd('\n')
val value = sourceText(text, kind)
val field = runCatching {
codeField(file, opts, text, false, disposable)
codeField(kind.file, opts, value, false, disposable)
}.getOrElse { err ->
LOG.warn("kind=markdown codeEditor=true failed message=${err.message}", err)
if (code.opts.editorOnly) runCatching {
codeField(PlainTextFileType.INSTANCE, opts, text, false, disposable)
codeField(PlainTextFileType.INSTANCE, opts, value, false, disposable)
}.getOrElse { fallback ->
LOG.warn("kind=markdown codeEditor=true fallback=plain failed message=${fallback.message}", fallback)
throw fallback
} else {
textArea(text, opts, disposable)
textArea(value, opts, disposable)
}
}
sizeCodeField(field, value)
@@ -451,6 +470,12 @@ internal open class MdViewHybrid(
return pane
}
private fun sourceText(text: String, kind: Kind.Source): String {
val value = text.trimEnd('\n')
if (kind.highlight == Highlight.DiffPure) return MdDiffHighlight.display(value).text
return value
}
private fun terminalBlock(text: String, kind: Kind.Terminal, disposable: Disposable): JBScrollPane {
val opts = opts()
val term = MdTerminal.decode(text, kind.stream)
@@ -823,12 +848,18 @@ internal open class MdViewHybrid(
private inner class CodeView(desc: Desc.Code, private val pane: JBScrollPane, disposable: Disposable) :
View(desc, pane, disposable) {
init {
overlay()
}
override fun compatible(desc: Desc) = desc is Desc.Code && (this.desc as Desc.Code).kind == desc.kind
override fun update(desc: Desc) {
if (this.desc == desc) return
this.desc = desc
val value = (desc as Desc.Code).text.trimEnd('\n')
val item = desc as Desc.Code
val kind = item.kind as? Kind.Source
val value = if (kind == null) item.text.trimEnd('\n') else sourceText(item.text, kind)
val view = pane.viewport.view
when (view) {
is CodeField -> view.text = value
@@ -838,6 +869,20 @@ internal open class MdViewHybrid(
sizeCodeField(view, value)
sizeCodePane(pane, view)
}
overlay()
}
/** Applies unified-diff coloring on top of a `diff`/`patch` block; a no-op otherwise. */
private fun overlay() {
val kind = (desc as Desc.Code).kind
if (kind !is Kind.Source || kind.highlight == Highlight.None) return
val field = pane.viewport.view as? CodeField ?: return
val editor = field.getEditor(true) ?: return
if (kind.highlight == Highlight.DiffPure) {
MdDiffHighlight.applyPure(editor, (desc as Desc.Code).text.trimEnd('\n'))
return
}
MdDiffHighlight.apply(editor, field.text)
}
override fun grow(delta: String) {
@@ -861,6 +906,7 @@ internal open class MdViewHybrid(
sizeCodeField(view, text)
sizeCodePane(pane, view)
}
overlay()
}
}
@@ -121,6 +121,11 @@
defaultValue="180000"
restartRequired="false"
overrides="false"/>
<registryKey key="kilo.session.validateRoots"
description="Treat settled (non-streaming) transcript turns as Swing validate roots so their internal repaints do not relayout the whole transcript."
defaultValue="true"
restartRequired="false"
overrides="false"/>
</extensions>
<applicationListeners>
@@ -126,6 +126,9 @@ session.part.tool.error=Error
session.part.tool.agent={0} Agent
session.part.tool.pending=Pending
session.part.tool.read=Read
session.part.tool.edit=Edit
session.part.tool.edit.files={0} files
session.part.tool.patch=Patch
session.part.tool.glob=Glob
session.part.tool.search=Search
session.part.tool.running=Running
@@ -57,6 +57,7 @@ session.part.tool.copy=نسخ
session.part.tool.error=خطأ
session.part.tool.pending=معلق
session.part.tool.read=قراءة
session.part.tool.edit=تحرير
session.part.tool.running=قيد التشغيل
session.part.tool.shell=Shell
session.part.tool.truncated=المخرجات مختصرة في المعاينة المسبقة. المخرجات الكاملة لا تزال في بيانات الجلسة.
@@ -57,6 +57,7 @@ session.part.tool.copy=Kopiraj
session.part.tool.error=Greška
session.part.tool.pending=Na čekanju
session.part.tool.read=Čita
session.part.tool.edit=Uredi
session.part.tool.running=Pokrenuto
session.part.tool.shell=Shell
session.part.tool.truncated=Izlaz skraćen u pregledu. Potpuni izlaz ostaje u podacima sesije.
@@ -57,6 +57,7 @@ session.part.tool.copy=Kopiér
session.part.tool.error=Fejl
session.part.tool.pending=Afventer
session.part.tool.read=Læs
session.part.tool.edit=Rediger
session.part.tool.running=Kører
session.part.tool.shell=Shell
session.part.tool.truncated=Output afkortet i forhåndsvisning. Fuldt output forbliver i sessionsdata.
@@ -57,6 +57,7 @@ session.part.tool.copy=Kopieren
session.part.tool.error=Fehler
session.part.tool.pending=Ausstehend
session.part.tool.read=Lesen
session.part.tool.edit=Bearbeiten
session.part.tool.running=Läuft
session.part.tool.shell=Shell
session.part.tool.truncated=Ausgabe in der Vorschau gekürzt. Vollständige Ausgabe verbleibt in den Sitzungsdaten.
@@ -57,6 +57,7 @@ session.part.tool.copy=Copiar
session.part.tool.error=Error
session.part.tool.pending=Pendiente
session.part.tool.read=Leer
session.part.tool.edit=Editar
session.part.tool.running=Ejecutando
session.part.tool.shell=Shell
session.part.tool.truncated=Salida truncada en la vista previa. La salida completa permanece en los datos de la sesión.
@@ -57,6 +57,7 @@ session.part.tool.copy=Copier
session.part.tool.error=Erreur
session.part.tool.pending=En attente
session.part.tool.read=Lire
session.part.tool.edit=Modifier
session.part.tool.running=En cours
session.part.tool.shell=Shell
session.part.tool.truncated=Sortie tronquée dans l'aperçu. La sortie complète reste dans les données de session.
@@ -57,6 +57,7 @@ session.part.tool.copy=コピー
session.part.tool.error=エラー
session.part.tool.pending=保留中
session.part.tool.read=読み取り
session.part.tool.edit=編集
session.part.tool.running=実行中
session.part.tool.shell=シェル
session.part.tool.truncated=プレビューでは出力が切り詰められています。完全な出力はセッションデータに残っています。
@@ -57,6 +57,7 @@ session.part.tool.copy=복사
session.part.tool.error=오류
session.part.tool.pending=대기 중
session.part.tool.read=읽기
session.part.tool.edit=편집
session.part.tool.running=실행 중
session.part.tool.shell=
session.part.tool.truncated=미리보기에서 출력이 잘렸습니다. 전체 출력은 세션 데이터에 남아 있습니다.
@@ -57,6 +57,7 @@ session.part.tool.copy=Kopiëren
session.part.tool.error=Fout
session.part.tool.pending=In afwachting
session.part.tool.read=Lezen
session.part.tool.edit=Bewerken
session.part.tool.running=Actief
session.part.tool.shell=Shell
session.part.tool.truncated=Uitvoer ingekort in voorvertoning. Volledige uitvoer blijft beschikbaar in sessiegegevens.
@@ -57,6 +57,7 @@ session.part.tool.copy=Kopier
session.part.tool.error=Feil
session.part.tool.pending=Venter
session.part.tool.read=Les
session.part.tool.edit=Rediger
session.part.tool.running=Kjører
session.part.tool.shell=Shell
session.part.tool.truncated=Utdata avkortet i forhåndsvisning. Fullstendig utdata finnes fortsatt i øktdata.
@@ -57,6 +57,7 @@ session.part.tool.copy=Kopiuj
session.part.tool.error=Błąd
session.part.tool.pending=Oczekuje
session.part.tool.read=Odczyt
session.part.tool.edit=Edycja
session.part.tool.running=Uruchomione
session.part.tool.shell=Powłoka
session.part.tool.truncated=Wyjście skrócone w podglądzie. Pełne wyjście pozostaje w danych sesji.
@@ -57,6 +57,7 @@ session.part.tool.copy=Copiar
session.part.tool.error=Erro
session.part.tool.pending=Pendente
session.part.tool.read=Ler
session.part.tool.edit=Editar
session.part.tool.running=Executando
session.part.tool.shell=Shell
session.part.tool.truncated=Saída truncada na pré-visualização. A saída completa permanece nos dados da sessão.
@@ -57,6 +57,7 @@ session.part.tool.copy=Копировать
session.part.tool.error=Ошибка
session.part.tool.pending=Ожидание
session.part.tool.read=Чтение
session.part.tool.edit=Редактирование
session.part.tool.running=Выполняется
session.part.tool.shell=Shell
session.part.tool.truncated=Вывод усечён в предпросмотре. Полный вывод сохраняется в данных сессии.
@@ -57,6 +57,7 @@ session.part.tool.copy=คัดลอก
session.part.tool.error=ข้อผิดพลาด
session.part.tool.pending=รอดำเนินการ
session.part.tool.read=อ่าน
session.part.tool.edit=แก้ไข
session.part.tool.running=กำลังทำงาน
session.part.tool.shell=Shell
session.part.tool.truncated=ผลลัพธ์ถูกตัดทอนในส่วนตัวอย่าง ผลลัพธ์ทั้งหมดยังคงอยู่ในข้อมูลเซสชัน
@@ -57,6 +57,7 @@ session.part.tool.copy=Kopyala
session.part.tool.error=Hata
session.part.tool.pending=Bekliyor
session.part.tool.read=Oku
session.part.tool.edit=Düzenle
session.part.tool.running=Çalışıyor
session.part.tool.shell=Kabuk
session.part.tool.truncated=Önizlemede çıktı kısaltıldı. Tam çıktı oturum verilerinde kalıyor.
@@ -57,6 +57,7 @@ session.part.tool.copy=Копіювати
session.part.tool.error=Помилка
session.part.tool.pending=Очікується
session.part.tool.read=Читання
session.part.tool.edit=Редагування
session.part.tool.running=Виконується
session.part.tool.shell=Shell
session.part.tool.truncated=Вивід у попередньому перегляді усічено. Повний вивід зберігається в даних сесії.
@@ -57,6 +57,7 @@ session.part.tool.copy=复制
session.part.tool.error=错误
session.part.tool.pending=待处理
session.part.tool.read=读取
session.part.tool.edit=编辑
session.part.tool.running=运行中
session.part.tool.shell=Shell
session.part.tool.truncated=预览中的输出已截断。完整输出仍保留在会话数据中。
@@ -57,6 +57,7 @@ session.part.tool.copy=複製
session.part.tool.error=錯誤
session.part.tool.pending=待處理
session.part.tool.read=讀取
session.part.tool.edit=編輯
session.part.tool.running=執行中
session.part.tool.shell=Shell
session.part.tool.truncated=預覽中的輸出已截斷。完整輸出仍保留在工作階段資料中。
@@ -7,6 +7,7 @@ import com.intellij.util.ui.JBUI
import com.intellij.util.ui.components.BorderLayoutPanel
import java.awt.Dimension
import java.awt.Insets
import javax.swing.JPanel
import javax.swing.JLabel
/**
@@ -281,6 +282,66 @@ class SessionLayoutTest : BasePlatformTestCase() {
assertEquals(20 + JBUI.scale(8), c2.y)
}
fun `test valid child reuses cached preferred height`() {
val p = panel(width = 300)
val child = probe(height = 20)
p.add(child)
p.doLayout()
child.markValid()
val count = child.count
p.doLayout()
assertEquals(count, child.count)
assertEquals(20, child.height)
}
fun `test invalid child is measured again`() {
val p = panel(width = 300)
val child = probe(height = 20)
p.add(child)
p.doLayout()
child.markValid()
val count = child.count
child.invalidate()
p.doLayout()
assertEquals(count + 1, child.count)
}
fun `test width change forces cached child remeasure`() {
val p = panel(width = 300)
val child = probe(height = 20)
p.add(child)
p.doLayout()
child.markValid()
val count = child.count
p.setSize(320, 2000)
p.doLayout()
assertEquals(count + 1, child.count)
assertEquals(320, child.width)
}
fun `test forget re-measures a valid child`() {
val p = panel(width = 300)
val child = probe(height = 20)
p.add(child)
p.doLayout()
child.markValid()
val count = child.count
// A settled turn is its own validate root, so it can be re-validated independently and its
// isValid flag flips back to true even after its content (and height) changed. forget()
// drops the stale cached height so the next layout pass re-measures the child.
(p.layout as SessionLayout).forget(child)
p.doLayout()
assertEquals(count + 1, child.count)
}
// ---- helpers ------
/** A fixed-height JLabel. The width is reported as 0 until layout sets it. */
@@ -293,4 +354,25 @@ class SessionLayoutTest : BasePlatformTestCase() {
override fun getPreferredSize(): Dimension = Dimension(0, height)
}
private fun probe(height: Int) = object : JPanel() {
var count = 0
private var valid = false
override fun isValid() = valid
override fun invalidate() {
valid = false
super.invalidate()
}
fun markValid() {
valid = true
}
override fun getPreferredSize(): Dimension {
count++
return Dimension(0, height)
}
}
}
@@ -39,6 +39,8 @@ import ai.kilocode.rpc.dto.TodoDto
import com.intellij.ide.ui.laf.darcula.ui.DarculaButtonUI
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.registry.Registry
import com.intellij.openapi.util.registry.RegistryKeyDescriptor
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.ActionLink
import com.intellij.ui.components.JBLabel
@@ -53,7 +55,9 @@ import java.awt.Point
import java.awt.event.MouseEvent
import java.awt.image.BufferedImage
import javax.swing.JButton
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.RepaintManager
import javax.swing.SwingUtilities
import javax.swing.border.Border
@@ -401,6 +405,151 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertEquals("hello world", tv.markdown())
}
fun `test empty ContentDelta does not refresh panel`() {
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", part("p1", "a1", "text", text = "hello"))
val mv = panel.findMessage("a1")!!
val tv = mv.part("p1") as TextView
val repaint = TrackingRepaintManager(setOf(panel, mv, tv))
val old = RepaintManager.currentManager(panel)
try {
RepaintManager.setCurrentManager(repaint)
model.appendDelta("a1", "p1", "")
assertEquals("hello", tv.markdown())
assertTrue(repaint.dirty.isEmpty())
assertTrue(repaint.invalid.isEmpty())
} finally {
RepaintManager.setCurrentManager(old)
}
}
fun `test identical ContentUpdated does not refresh panel`() {
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", part("p1", "a1", "text", text = "hello"))
val mv = panel.findMessage("a1")!!
val tv = mv.part("p1") as TextView
val comp = tv.md.component
val repaint = TrackingRepaintManager(setOf(panel, mv, tv))
val old = RepaintManager.currentManager(panel)
try {
RepaintManager.setCurrentManager(repaint)
model.updateContent("a1", part("p1", "a1", "text", text = "hello"))
assertSame(tv, mv.part("p1"))
assertSame(comp, tv.md.component)
assertTrue(repaint.dirty.isEmpty())
assertTrue(repaint.invalid.isEmpty())
} finally {
RepaintManager.setCurrentManager(old)
}
}
// ------ settled turns / validate roots (B) ------
fun `test turns are validate roots when idle`() {
model.upsertMessage(msg("u1", "user"))
model.upsertMessage(msg("a1", "assistant"))
model.upsertMessage(msg("u2", "user"))
assertTrue(panel.findTurn("u1")!!.isValidateRoot())
assertTrue(panel.findTurn("u2")!!.isValidateRoot())
}
fun `test streaming turn is not a validate root while busy`() {
model.upsertMessage(msg("u1", "user"))
model.upsertMessage(msg("a1", "assistant"))
model.upsertMessage(msg("u2", "user"))
model.upsertMessage(msg("a2", "assistant"))
model.setState(SessionState.Busy("thinking"))
assertTrue("prior turn stays a validate root", panel.findTurn("u1")!!.isValidateRoot())
assertFalse("streaming turn must not be a validate root", panel.findTurn("u2")!!.isValidateRoot())
}
fun `test turns settle again when idle`() {
model.upsertMessage(msg("u1", "user"))
model.upsertMessage(msg("u2", "user"))
model.setState(SessionState.Busy("thinking"))
model.setState(SessionState.Idle)
assertTrue(panel.findTurn("u1")!!.isValidateRoot())
assertTrue(panel.findTurn("u2")!!.isValidateRoot())
}
fun `test turn added while busy becomes the active non-root turn`() {
model.upsertMessage(msg("u1", "user"))
model.setState(SessionState.Busy("thinking"))
assertFalse(panel.findTurn("u1")!!.isValidateRoot())
model.upsertMessage(msg("u2", "user"))
assertTrue("previous turn settles once a newer turn is active", panel.findTurn("u1")!!.isValidateRoot())
assertFalse("newest turn is the active streaming turn", panel.findTurn("u2")!!.isValidateRoot())
}
fun `test validate roots flag disables turn isolation`() {
disableValidateRoots()
model.upsertMessage(msg("u1", "user"))
assertFalse(panel.findTurn("u1")!!.isValidateRoot())
}
fun `test settled turns still follow panel width top down`() {
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", part("p1", "a1", "text", text = "answer"))
val turn = panel.findTurn("a1")!!
assertTrue("idle turn is a validate root", turn.isValidateRoot())
panel.setSize(600, 2000)
layout(panel)
val wide = turn.width
panel.setSize(500, 2000)
layout(panel)
assertTrue("validate-root turns must still relayout top-down", turn.width < wide)
assertTrue(turn.isValidateRoot())
}
// ------ streaming stress / teardown ------
fun `test many streamed turns stay bounded and fully tear down`() {
val empty = count(panel)
repeat(40) { i ->
model.upsertMessage(msg("u$i", "user"))
model.updateContent("u$i", part("up$i", "u$i", "text", text = "q$i"))
model.upsertMessage(msg("a$i", "assistant"))
model.updateContent("a$i", part("ap$i", "a$i", "text", text = "```kotlin\nval x = $i\n```"))
repeat(20) { j -> model.appendDelta("a$i", "ap$i", " tok$j") }
}
assertEquals(40, panel.turnCount())
// Retained instances stay identical while streaming into an earlier message,
// and streaming deltas must not grow the component tree.
val tv = panel.findMessage("a0")!!.part("ap0") as TextView
val comp = tv.md.component
val count = count(panel)
repeat(50) { model.appendDelta("a0", "ap0", " x$it") }
assertSame(tv, panel.findMessage("a0")!!.part("ap0"))
assertSame(comp, tv.md.component)
assertEquals(count, count(panel))
model.clear()
assertEquals(0, panel.turnCount())
assertTrue("transcript turns must be removed on clear", panel.components.none { it is TurnView })
assertEquals("clear must return the transcript to its empty component tree", empty, count(panel))
}
fun `test ContentDelta preserves TextView and markdown component`() {
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", part("p1", "a1", "text", text = "first\n\nsecond"))
@@ -1152,6 +1301,18 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
for (child in root.components) if (child is Container) layout(child)
}
/** The plugin's `<registryKey>` extensions are not loaded in tests, so contribute the key here. */
private fun disableValidateRoots() {
val key = "kilo.session.validateRoots"
Registry.mutateContributedKeys {
it + (key to RegistryKeyDescriptor(key, "test", "true", false, false, null, null))
}
Disposer.register(testRootDisposable) {
Registry.mutateContributedKeys { it - key }
}
Registry.get(key).setValue(false, testRootDisposable)
}
private fun promptBox(root: MessageView): Component {
return components(root).first { it.parent != root && it is JPanel && it.componentCount == 1 && it.components.single() is TextView }
}
@@ -1175,4 +1336,19 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
.joinToString(" ")
}
}
private class TrackingRepaintManager(private val watched: Set<JComponent>) : RepaintManager() {
val dirty = mutableListOf<JComponent>()
val invalid = mutableListOf<JComponent>()
override fun addDirtyRegion(c: JComponent, x: Int, y: Int, w: Int, h: Int) {
if (c in watched) dirty.add(c)
super.addDirtyRegion(c, x, y, w, h)
}
override fun addInvalidComponent(invalidComponent: JComponent) {
if (invalidComponent in watched) invalid.add(invalidComponent)
super.addInvalidComponent(invalidComponent)
}
}
}
@@ -0,0 +1,476 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.session.views.tool.EditToolView
import ai.kilocode.client.session.views.tool.ReadToolView
import ai.kilocode.client.session.views.tool.ToolView
import ai.kilocode.client.ui.DiffStatBadge
import com.intellij.openapi.diff.DiffColors
import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.awt.Component
import java.awt.Container
import java.awt.event.MouseEvent
@Suppress("UnstableApiUsage")
class EditToolViewTest : BasePlatformTestCase() {
private val views = mutableListOf<EditToolView>()
override fun tearDown() {
views.forEach { Disposer.dispose(it) }
views.clear()
super.tearDown()
}
fun `test edit tool shows Edit title and clickable file link`() {
val opened = mutableListOf<String>()
val view = track(EditToolView(tool(), openFile = { href, _ -> opened.add(href) }))
val base: Any = view
assertTrue(base is SecondarySessionPartView)
assertTrue(view.labelText().contains("Edit"))
assertTrue(view.linkVisible())
assertEquals("App.kt", view.linkLabel())
assertEquals("/repo/src/App.kt", view.linkHref())
assertEquals("/repo/src/App.kt", view.linkTooltip())
assertTrue(view.labelText().contains("App.kt"))
view.openLink()
assertEquals(listOf("/repo/src/App.kt"), opened)
}
fun `test edit link uses metadata path when input is only filename`() {
val opened = mutableListOf<String>()
val path = "backend/src/com/kirillk/watcher/dao/GameApi.java"
val view = track(EditToolView(tool().also {
it.title = "GameApi.java"
it.input = mapOf("filePath" to "GameApi.java")
it.metadata = mapOf("filediff" to fileDiff(1, 0, PATCH, path))
}, openFile = { href, _ -> opened.add(href) }))
assertEquals("GameApi.java", view.linkLabel())
assertEquals(path, view.linkHref())
view.openLink()
assertEquals(listOf(path), opened)
}
fun `test changes tag shows additions and deletions`() {
val view = track(EditToolView(tool()))
assertTrue(view.badgeVisible())
assertEquals(2 to 1, view.diffStat())
}
fun `test changes tag hidden without diff`() {
val view = track(EditToolView(tool().also { it.metadata = emptyMap() }))
assertFalse(view.badgeVisible())
assertEquals(0 to 0, view.diffStat())
}
fun `test multi file apply_patch shows file count tag and aggregated changes`() {
val view = track(EditToolView(tool().also {
it.input = emptyMap()
it.metadata = mapOf("files" to filesMeta(
FileChange("src/A.kt", 2, 0, ADD_HUNK),
FileChange("src/B.kt", 1, 1, UPDATE_HUNK),
))
}))
assertTrue(view.labelText().contains("Patch"))
assertFalse(view.labelText().contains("Edit"))
assertTrue(view.filesTagVisible())
assertTrue(view.filesTagText()!!.contains("2 files"))
assertFalse(view.linkVisible())
assertTrue(view.badgeVisible())
assertEquals(3 to 1, view.diffStat())
}
fun `test multi file patch body renders a link and diff per file`() {
val opened = mutableListOf<String>()
val view = track(EditToolView(tool().also {
it.input = emptyMap()
it.metadata = mapOf("files" to filesMeta(
FileChange("src/A.kt", 2, 0, ADD_HUNK),
FileChange("pkg/B.kt", 1, 1, UPDATE_HUNK),
))
}, openFile = { href, _ -> opened.add(href) }))
view.toggle()
assertTrue(view.isExpanded())
assertEquals(2, view.codeEditors().size)
val fileLinks = labels(view).filter { it.text?.contains("<u>") == true }
assertTrue(fileLinks.any { it.text!!.contains("A.kt") && !it.text!!.contains("src/") })
assertTrue(fileLinks.any { it.text!!.contains("B.kt") && !it.text!!.contains("pkg/") })
assertTrue(fileLinks.any { it.text!!.contains("A.kt") && it.toolTipText == "src/A.kt" })
assertTrue(fileLinks.any { it.text!!.contains("B.kt") && it.toolTipText == "pkg/B.kt" })
// The per-file header renders one changes badge per file (plus the aggregate header badge).
assertEquals(3, badges(view).size)
click(fileLinks.first { it.text!!.contains("A.kt") }, 1)
assertEquals(listOf("src/A.kt"), opened)
}
fun `test single file apply_patch keeps link and hides count tag`() {
val view = track(EditToolView(tool().also {
it.input = emptyMap()
it.title = "src/Only.kt"
it.metadata = mapOf("files" to filesMeta(FileChange("src/Only.kt", 1, 1, UPDATE_HUNK)))
}))
assertFalse(view.filesTagVisible())
assertTrue(view.linkVisible())
assertEquals(1 to 1, view.diffStat())
assertFalse(view.markdown().contains("src/Only.kt"))
assertEquals(1, Regex("```patch-pure").findAll(view.markdown()).count())
}
fun `test edit body renders unified diff and expands`() {
val view = track(EditToolView(tool()))
assertTrue(view.hasToggle())
assertFalse(view.isExpanded())
assertFalse(view.bodyVisible())
assertTrue(view.markdown().contains("```patch-pure"))
assertTrue(view.markdown().contains("+new1"))
view.toggle()
assertTrue(view.isExpanded())
assertTrue(view.bodyVisible())
assertTrue(view.bodyCreated())
assertTrue(view.codeEditors().single().text.contains("new1"))
assertFalse(view.codeEditors().single().text.contains("+new1"))
assertFalse(view.codeEditors().single().text.contains("-old"))
}
fun `test edit body strips patch metadata headers`() {
// Relative-path headers so the `--- `/`+++ ` file-header assertions below actually exercise
// stripping: the header text (`--- src/App.kt`) shares its prefix with nothing in the body.
val patch = """
Index: src/App.kt
===================================================================
--- src/App.kt
+++ src/App.kt
@@ -1,2 +1,2 @@
keep
-old
+new
""".trimIndent()
val view = track(EditToolView(tool().also { it.metadata = mapOf("filediff" to fileDiff(1, 1, patch)) }))
assertFalse(view.markdown().contains("@@ -1,2 +1,2 @@"))
assertTrue(view.markdown().contains("-old"))
assertTrue(view.markdown().contains("+new"))
assertFalse(view.markdown().contains("Index:"))
assertFalse(view.markdown().contains("--- src/App.kt"))
assertFalse(view.markdown().contains("+++ src/App.kt"))
assertFalse(view.markdown().contains("===="))
view.toggle()
assertTrue(view.codeEditors().single().text.contains("old"))
assertTrue(view.codeEditors().single().text.contains("new"))
assertFalse(view.codeEditors().single().text.contains("-old"))
assertFalse(view.codeEditors().single().text.contains("+new"))
}
fun `test edit body colors added and removed diff lines`() {
val view = track(EditToolView(tool()))
view.toggle()
val editor = view.codeEditors().single().getEditor(true)!!
val chars = editor.document.charsSequence
val spans = editor.markupModel.allHighlighters.mapNotNull { h ->
val key = h.textAttributesKey ?: return@mapNotNull null
key to chars.subSequence(h.startOffset, h.endOffset).toString()
}
assertTrue(spans.any { it.first == DiffColors.DIFF_INSERTED && it.second.startsWith("new1") })
assertTrue(spans.any { it.first == DiffColors.DIFF_DELETED && it.second.startsWith("old") })
}
fun `test clicking link text opens file but empty slot toggles body`() {
val opened = mutableListOf<String>()
val view = track(EditToolView(tool(), openFile = { href, _ -> opened.add(href) }))
val link = linkLabel(view)
val slot = link.parent
click(slot, link.preferredSize.width + 50)
assertTrue(opened.isEmpty())
assertTrue(view.isExpanded())
click(link, 0)
assertEquals(listOf("/repo/src/App.kt"), opened)
}
fun `test metadata only patch falls back to raw text`() {
// A pure rename (no +/-/context lines) is entirely metadata: stripping it leaves nothing, so
// the raw patch must survive rather than render an empty fenced block.
val patch = """
diff --git a/src/Old.kt b/src/New.kt
similarity index 100%
rename from src/Old.kt
rename to src/New.kt
""".trimIndent()
val view = track(EditToolView(tool().also { it.metadata = mapOf("filediff" to fileDiff(0, 0, patch)) }))
assertTrue(view.markdown().contains("rename from src/Old.kt"))
assertTrue(view.markdown().contains("rename to src/New.kt"))
}
fun `test collapsed hover popup shows diff and none when expanded`() {
val view = track(EditToolView(tool()))
assertNotNull(view.headerPopup())
view.toggle()
assertNull(view.headerPopup())
}
fun `test edit header popup widens to diff content`() {
val patch = """
--- src/App.kt
+++ src/App.kt
@@ -1 +1 @@
-old
+${"x".repeat(180)}
""".trimIndent()
val view = track(EditToolView(tool().also {
it.metadata = mapOf("filediff" to fileDiff(1, 1, patch))
}))
val body = view.headerPopup()!!.build()
try {
assertTrue(body.component.preferredSize.width > JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH))
assertTrue(body.component.preferredSize.width <= JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH))
} finally {
Disposer.dispose(body.disposable)
}
}
fun `test edit header popup stays narrow for short diff`() {
val patch = """
--- src/App.kt
+++ src/App.kt
@@ -1 +1 @@
-old
+new
""".trimIndent()
val view = track(EditToolView(tool().also {
it.metadata = mapOf("filediff" to fileDiff(1, 1, patch))
}))
val body = view.headerPopup()!!.build()
try {
assertTrue(body.component.preferredSize.width < JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH))
} finally {
Disposer.dispose(body.disposable)
}
}
fun `test multi file patch popup reuses patch body links`() {
val opened = mutableListOf<String>()
val view = track(EditToolView(tool().also {
it.input = emptyMap()
it.metadata = mapOf("files" to filesMeta(
FileChange("src/A.kt", 2, 0, ADD_HUNK),
FileChange("pkg/B.kt", 1, 1, UPDATE_HUNK),
))
}, openFile = { href, _ -> opened.add(href) }))
val body = view.headerPopup()!!.build()
try {
val fileLinks = labels(body.component).filter { it.text?.contains("<u>") == true }
assertTrue(fileLinks.any { it.text!!.contains("A.kt") && it.toolTipText == "src/A.kt" })
assertTrue(fileLinks.any { it.text!!.contains("B.kt") && it.toolTipText == "pkg/B.kt" })
click(fileLinks.first { it.text!!.contains("A.kt") }, 1)
assertEquals(listOf("src/A.kt"), opened)
} finally {
Disposer.dispose(body.disposable)
}
}
fun `test no hover popup without diff`() {
val view = track(EditToolView(tool().also { it.metadata = emptyMap() }))
assertNull(view.headerPopup())
}
fun `test view factory routes write tools to edit tool view`() {
assertTrue(ViewFactory.create(tool(), openFile = { _, _ -> }) is EditToolView)
assertTrue(ViewFactory.create(write("write"), openFile = { _, _ -> }) is EditToolView)
assertTrue(ViewFactory.create(write("apply_patch"), openFile = { _, _ -> }) is EditToolView)
}
fun `test canRender matches write kind tools only`() {
assertTrue(EditToolView.canRender(tool()))
assertTrue(EditToolView.canRender(write("write")))
assertFalse(EditToolView.canRender(Tool("p2", "read", toolKind("read"))))
assertFalse(EditToolView.canRender(Tool("p3", "bash", toolKind("bash"))))
}
fun `test shouldReplace swaps generic and edit views`() {
val edit = tool()
val other = Tool("p9", "mystery", toolKind("mystery")).also { it.state = ToolExecState.COMPLETED }
assertTrue(ViewFactory.shouldReplace(ToolView(edit), edit))
assertTrue(ViewFactory.shouldReplace(EditToolView(edit), other))
assertFalse(ViewFactory.shouldReplace(EditToolView(edit), edit))
}
fun `test edit editors are disposed after churn`() {
val base = EditorFactory.getInstance().allEditors.size
repeat(40) { i ->
val view = EditToolView(tool().also { it.metadata = mapOf("diff" to patch(i)) })
view.toggle()
view.codeEditors().forEach { it.getEditor(true) }
Disposer.dispose(view)
}
UIUtil.dispatchAllInvocationEvents()
assertEquals(base, EditorFactory.getInstance().allEditors.size)
}
fun `test multi file patch editors are disposed after churn`() {
val base = EditorFactory.getInstance().allEditors.size
repeat(20) { i ->
val view = EditToolView(tool().also {
it.input = emptyMap()
it.metadata = mapOf("files" to filesMeta(
FileChange("src/A$i.kt", 2, 0, ADD_HUNK),
FileChange("src/B$i.kt", 1, 1, UPDATE_HUNK),
))
})
view.toggle()
view.codeEditors().forEach { it.getEditor(true) }
Disposer.dispose(view)
}
UIUtil.dispatchAllInvocationEvents()
assertEquals(base, EditorFactory.getInstance().allEditors.size)
}
private fun track(view: EditToolView): EditToolView {
views.add(view)
return view
}
private fun click(component: Component, x: Int) {
component.dispatchEvent(MouseEvent(component, MouseEvent.MOUSE_CLICKED, System.currentTimeMillis(), 0, x, 1, 1, false))
}
private fun linkLabel(view: EditToolView): JBLabel =
labels(view).first { it.text?.contains("<u>") == true }
private fun labels(root: Container): List<JBLabel> = root.components.flatMap { child ->
val nested = if (child is Container) labels(child) else emptyList()
if (child is JBLabel) nested + child else nested
}
private fun badges(root: Container): List<DiffStatBadge> = root.components.flatMap { child ->
val nested = if (child is Container) badges(child) else emptyList()
if (child is DiffStatBadge) nested + child else nested
}
private fun tool() = Tool("p1", "edit", toolKind("edit")).also {
it.state = ToolExecState.COMPLETED
it.title = "src/App.kt"
it.input = mapOf("filePath" to "/repo/src/App.kt")
it.output = "Edit applied successfully."
it.metadata = mapOf("filediff" to fileDiff(2, 1, PATCH))
}
private fun write(name: String) = Tool("p1", name, toolKind(name)).also {
it.state = ToolExecState.COMPLETED
it.input = mapOf("filePath" to "/repo/src/App.kt")
it.metadata = mapOf("filediff" to fileDiff(2, 1, PATCH))
}
private fun patch(i: Int) = """
--- src/App.kt
+++ src/App.kt
@@ -1,2 +1,2 @@
line$i
-old$i
+new$i
""".trimIndent()
private data class FileChange(val path: String, val additions: Int, val deletions: Int, val patch: String)
// Mirrors how the CLI serializes metadata.files (a JsonArray of per-file changes rendered to string).
private fun filesMeta(vararg files: FileChange): String = buildJsonArray {
files.forEach { file ->
addJsonObject {
put("relativePath", file.path)
put("type", "update")
put("additions", file.additions)
put("deletions", file.deletions)
put("patch", file.patch)
}
}
}.toString()
// Mirrors how the CLI serializes metadata.filediff (a JsonObject rendered to string).
private fun fileDiff(
additions: Int,
deletions: Int,
patch: String,
path: String = "src/App.kt",
): String = buildJsonObject {
put("file", path)
put("additions", additions)
put("deletions", deletions)
put("patch", patch)
}.toString()
companion object {
private val PATCH = """
--- src/App.kt
+++ src/App.kt
@@ -1,3 +1,4 @@
line1
-old
+new1
+new2
line3
""".trimIndent()
private val ADD_HUNK = """
@@ -0,0 +1,2 @@
+alpha
+beta
""".trimIndent()
private val UPDATE_HUNK = """
@@ -1,2 +1,2 @@
keep
-old
+new
""".trimIndent()
}
}
@@ -51,6 +51,7 @@ class ReadToolViewTest : BasePlatformTestCase() {
assertTrue(view.linkVisible())
assertEquals("SessionUiLayoutTest.kt", view.linkText())
assertEquals(path, view.linkHref())
assertEquals(path, view.linkTooltip())
assertTrue(view.linkMarkup().contains("<nobr><u>SessionUiLayoutTest.kt</u></nobr>"))
assertEquals(UiStyle.Colors.fg().rgb, view.linkForeground().rgb)
assertEquals(view.linkFont(), view.bodyFont())
@@ -75,6 +76,7 @@ class ReadToolViewTest : BasePlatformTestCase() {
assertFalse(view.linkVisible())
assertNull(view.linkHref())
assertNull(view.linkTooltip())
assertEquals(UiStyle.Colors.fg().rgb, view.subtitleForeground().rgb)
assertEquals(view.subtitleFont(), view.bodyFont())
assertTrue(view.labelText().contains(path))
@@ -290,8 +290,8 @@ class ReasoningViewTest : BasePlatformTestCase() {
val panel = scroll.viewport.view as JPanel
assertEquals(1, panel.components.filterIsInstance<JComponent>().size)
assertTrue(body.component.preferredSize.width in 1..JBUI.scale(350))
assertEquals(JBUI.scale(450), body.component.preferredSize.height)
assertTrue(body.component.preferredSize.width in 1..JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH))
assertEquals(JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT), body.component.preferredSize.height)
} finally {
Disposer.dispose(body.disposable)
}
@@ -416,9 +416,9 @@ class ShellToolViewTest : BasePlatformTestCase() {
assertTrue(field.preferredSize.height - border.top >= editor.lineHeight * lines)
assertTrue(field.minimumSize.height - border.top >= editor.lineHeight * lines)
assertTrue(pane.preferredSize.height >= field.preferredSize.height + pad.top + pad.bottom)
assertTrue(body.component.preferredSize.width in 1..JBUI.scale(350))
assertTrue(body.component.preferredSize.width in 1..JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH))
assertTrue(body.component.preferredSize.height > 0)
assertTrue(body.component.preferredSize.height <= JBUI.scale(450))
assertTrue(body.component.preferredSize.height <= JBUI.scale(SessionUiStyle.View.Popup.MAX_HEIGHT))
} finally {
Disposer.dispose(body.disposable)
}
@@ -427,6 +427,33 @@ class ShellToolViewTest : BasePlatformTestCase() {
assertEquals(base, EditorFactory.getInstance().allEditors.size)
}
fun `test shell header popup widens to command content`() {
val view = track(ShellToolView(tool().also {
it.input = mapOf("command" to "echo ${"x".repeat(180)}")
}))
val body = view.headerPopup()!!.build()
try {
assertTrue(body.component.preferredSize.width > JBUI.scale(SessionUiStyle.View.Popup.MAX_WIDTH))
assertTrue(body.component.preferredSize.width <= JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH))
} finally {
Disposer.dispose(body.disposable)
}
}
fun `test shell header popup stays narrow for short command`() {
val view = track(ShellToolView(tool().also {
it.input = mapOf("command" to "ls")
}))
val body = view.headerPopup()!!.build()
try {
assertTrue(body.component.preferredSize.width < JBUI.scale(SessionUiStyle.View.Popup.WIDE_MAX_WIDTH))
} finally {
Disposer.dispose(body.disposable)
}
}
fun `test shell header popup breaks chained operators outside quotes`() {
val view = track(ShellToolView(tool().also {
it.input = mapOf(
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import ai.kilocode.client.session.views.tool.EditToolView
import ai.kilocode.client.session.views.tool.GlobToolView
import ai.kilocode.client.session.views.tool.SearchToolView
import ai.kilocode.client.session.views.tool.ShellToolView
@@ -11,6 +12,8 @@ import com.intellij.openapi.editor.EditorFactory
import com.intellij.openapi.util.Disposer
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.util.ui.UIUtil
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
@Suppress("UnstableApiUsage")
class ToolBodyStressTest : BasePlatformTestCase() {
@@ -62,11 +65,46 @@ class ToolBodyStressTest : BasePlatformTestCase() {
assertEquals(base, EditorFactory.getInstance().allEditors.size)
}
fun `test expanded edit tool editors are disposed after churn`() {
val base = EditorFactory.getInstance().allEditors.size
repeat(60) { i ->
val view = EditToolView(edit(i))
view.toggle()
view.codeEditors().forEach { it.getEditor(true) }
Disposer.dispose(view)
}
drainEdt()
assertEquals(base, EditorFactory.getInstance().allEditors.size)
}
private fun tool(index: Int) = Tool("p$index", "mystery", toolKind("mystery")).also {
it.state = ToolExecState.COMPLETED
it.output = (1..20).joinToString("\n") { line -> "line $index/$line" }
}
private fun edit(index: Int) = Tool("e$index", "edit", toolKind("edit")).also {
it.state = ToolExecState.COMPLETED
it.input = mapOf("filePath" to "/repo/src/File$index.kt")
val patch = buildString {
append("--- src/File$index.kt\n")
append("+++ src/File$index.kt\n")
append("@@ -1,3 +1,4 @@\n")
append(" line1\n")
append("-old$index\n")
append("+new$index\n")
}
it.metadata = mapOf(
"filediff" to buildJsonObject {
put("file", "src/File$index.kt")
put("additions", 1)
put("deletions", 1)
put("patch", patch)
}.toString(),
)
}
private fun shell(index: Int) = Tool("p$index", "bash", toolKind("bash")).also {
it.state = ToolExecState.COMPLETED
it.input = mapOf("command" to "log $index")
@@ -273,6 +273,8 @@ class MdViewHybridTest : BasePlatformTestCase() {
assertTrue(iter.isValid)
val rect = pane.modelToView2D(iter.startOffset)!!.bounds
// Real AWT delivers MOUSE_ENTERED before MOUSE_MOVED; the enter arms scroll tracking.
pane.dispatchEvent(MouseEvent(pane, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, rect.x + 1, rect.y + rect.height / 2, 0, false, MouseEvent.NOBUTTON))
pane.dispatchEvent(MouseEvent(pane, MouseEvent.MOUSE_MOVED, System.currentTimeMillis(), 0, rect.x + 1, rect.y + rect.height / 2, 0, false, MouseEvent.NOBUTTON))
host.viewport.viewPosition = Point(0, 32)
drainEdt()
@@ -281,6 +283,24 @@ class MdViewHybridTest : BasePlatformTestCase() {
assertTrue(events.contains(HyperlinkEvent.EventType.EXITED))
}
fun `test prose pane tracks viewport scrolls only while hovered`() {
view.set("See [docs](https://example.com)\n\n" + (1..20).joinToString("\n") { "line $it" })
val pane = htmls().single()
val host = JBScrollPane(view.component)
host.setSize(420, 64)
view.component.setSize(420, view.component.preferredSize.height)
host.doLayout()
view.component.doLayout()
drainEdt()
val base = host.viewport.changeListeners.size
pane.dispatchEvent(MouseEvent(pane, MouseEvent.MOUSE_ENTERED, System.currentTimeMillis(), 0, 1, 1, 0, false, MouseEvent.NOBUTTON))
assertEquals("hovered prose pane must follow viewport scrolls", base + 1, host.viewport.changeListeners.size)
pane.dispatchEvent(MouseEvent(pane, MouseEvent.MOUSE_EXITED, System.currentTimeMillis(), 0, -1, -1, 0, false, MouseEvent.NOBUTTON))
assertEquals("pane must stop following scrolls once the pointer leaves", base, host.viewport.changeListeners.size)
}
fun `test file ref links include line suffix and exclude punctuation`() {
view.set("See kilocode/session/prompt.ts:302, native-plan-prompt.txt:37-38.")
val html = view.html()
@@ -0,0 +1,31 @@
package ai.kilocode.client.ui.md.hybrid
import com.intellij.openapi.diff.DiffColors
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors
import com.intellij.testFramework.fixtures.BasePlatformTestCase
class MdDiffHighlightTest : BasePlatformTestCase() {
fun `test inserted line whose content starts with plus plus is not dimmed as a header`() {
// "++x;" is an inserted line ("+" marker + "+x;" content), not a "+++" file header.
val out = MdDiffHighlight.display("++x;")
assertEquals(1, out.spans.size)
assertEquals(DiffColors.DIFF_INSERTED, out.spans.single().span.key)
}
fun `test deleted line whose content starts with a dash is not dimmed as a header`() {
// "--x" is a deleted line ("-" marker + "-x" content), not a "---" file header.
val out = MdDiffHighlight.display("--x")
assertEquals(1, out.spans.size)
assertEquals(DiffColors.DIFF_DELETED, out.spans.single().span.key)
}
fun `test real file headers are dimmed as comments`() {
val out = MdDiffHighlight.display("--- a/File.kt\n+++ b/File.kt")
assertEquals(2, out.spans.size)
assertTrue(out.spans.all { it.span.key == DefaultLanguageHighlighterColors.LINE_COMMENT })
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
"test": "./gradlew test",
"test:ci": "bun script/test-ci.ts"
},
"version": "7.4.13",
"version": "7.4.15",
"dependencies": {},
"devDependencies": {},
"peerDependencies": {}
+4
View File
@@ -54,6 +54,10 @@ const icons: Record<string, { path: string; viewBox: string }> = {
viewBox: "0 0 24 24",
path: `<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M21 3v5h-5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M3 21v-5h5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>`,
},
gauge: {
viewBox: "0 0 24 24",
path: `<path d="M12 14L9 10M12 14L15 10M21 15C21 18.866 17.866 22 14 22H10C6.134 22 3 18.866 3 15V9C3 5.134 6.134 2 10 2H14C17.866 2 21 5.134 21 9V15Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
},
}
type Name = keyof typeof icons
@@ -37,6 +37,14 @@
[data-component="icon-button"][data-icon="thumbs-down"]:hover [data-slot="icon-svg"] path {
fill: currentColor;
}
/* Throughput badge sits to the right of the copy/feedback buttons,
beside them rather than beneath the message. */
[data-slot="assistant-throughput-inline"] {
margin-left: 6px;
display: flex;
align-items: center;
}
}
}
@@ -403,6 +411,9 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] {
}
[data-slot="user-message-queued-indicator"] {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 6px;
margin-right: 2px;
font-size: var(--font-size-small);
@@ -157,6 +157,7 @@ export interface MessagePartProps {
animate?: boolean
working?: boolean
feedback?: MessageFeedbackControls
throughput?: JSX.Element
}
export type PartComponent = Component<MessagePartProps>
@@ -743,6 +744,7 @@ export function UserMessageDisplay(props: {
text?: string
copyText?: string
header?: JSX.Element
onDelete?: () => void
onFork?: () => void
onRevert?: () => void
}) {
@@ -816,6 +818,23 @@ export function UserMessageDisplay(props: {
setTimeout(() => setCopied(false), 2000)
}
const Delete = () => (
<Show when={props.onDelete}>
<IconButton
data-slot="user-message-delete"
icon="close-small"
size="normal"
variant="ghost"
onMouseDown={(e) => e.preventDefault()}
onClick={(event) => {
event.stopPropagation()
props.onDelete?.()
}}
aria-label={i18n.t("ui.message.deleteQueued")}
/>
</Show>
)
return (
<GrowBox animate={!!props.animate} fade class="w-full min-w-0 self-stretch max-w-full">
<div data-component="user-message" data-interrupted={props.interrupted ? "" : undefined}>
@@ -852,6 +871,12 @@ export function UserMessageDisplay(props: {
</For>
</div>
</Show>
<Show when={!text() && !props.header && props.queued}>
<div data-slot="user-message-queued-indicator">
<TextShimmer text={i18n.t("ui.message.queued")} />
<Delete />
</div>
</Show>
<Show when={text() || props.header}>
<>
<div data-slot="user-message-body">
@@ -864,6 +889,7 @@ export function UserMessageDisplay(props: {
<GrowBox animate={!!props.animate} open={!!props.queued}>
<div data-slot="user-message-queued-indicator">
<TextShimmer text={i18n.t("ui.message.queued")} />
<Delete />
</div>
</GrowBox>
</div>
@@ -950,9 +976,23 @@ function HighlightedText(props: { text: string; references: FilePart[]; agents:
const data = useData()
const session = (segment: HighlightSegment) => {
const ref = props.references.find((ref) => ref.source?.text?.value === segment.text)
const url = (ref as { url?: unknown } | undefined)?.url
if (typeof url !== "string" || !url.startsWith("session:")) return
return url.slice("session:".length)
}
const click = (segment: HighlightSegment, e: MouseEvent) => {
if (segment.type !== "file" || !data.openFile) return
if (segment.type !== "file") return
e.preventDefault()
// Past-chat mentions carry a session: URL — open that session instead of a file.
const id = session(segment)
if (id) {
data.navigateToSession?.(id)
return
}
if (!data.openFile) return
const path = segment.text.replace(/^@/, "")
if (path) data.openFile(path)
}
@@ -962,7 +1002,9 @@ function HighlightedText(props: { text: string; references: FilePart[]; agents:
{(segment) => (
<span
data-highlight={segment.type}
data-clickable={segment.type === "file" && data.openFile ? "" : undefined}
data-clickable={
segment.type === "file" && (session(segment) ? data.navigateToSession : data.openFile) ? "" : undefined
}
onClick={[click, segment]}
>
{segment.text}
@@ -991,6 +1033,7 @@ export function Part(props: MessagePartProps) {
animate={props.animate}
working={props.working}
feedback={props.feedback}
throughput={props.throughput}
/>
</Show>
)
@@ -1448,6 +1491,9 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
/>
</Tooltip>
</Show>
<Show when={props.throughput}>
{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}
</Show>
</div>
</Show>
<Show when={summary()}>
+40
View File
@@ -77,6 +77,26 @@ const pierreWorkerAliasPlugin = {
},
}
/**
* Replace Markdown's Vite-only worker URL import with the URI injected by the
* extension host. The worker itself is emitted as a separate dist asset below.
*
* @type {import('esbuild').Plugin}
*/
const markdownWorkerUrlPlugin = {
name: "markdown-worker-url",
setup(build) {
build.onResolve({ filter: /markdown-shiki\.worker\.ts\?worker&url$/ }, () => ({
path: "markdown-shiki-worker-url",
namespace: "kilo-worker-url",
}))
build.onLoad({ filter: /.*/, namespace: "kilo-worker-url" }, () => ({
contents: "export default window.KILO_MARKDOWN_SHIKI_WORKER_URI",
loader: "js",
}))
},
}
/**
* Resolve the synthetic `kilo-shiki-worker` entry point to Pierre's Shiki worker
* so esbuild can bundle it (and its inlined oniguruma WebAssembly) into a single
@@ -159,6 +179,7 @@ function createBrowserWebviewContext(entryPoint, outfile) {
plugins: [
solidDedupePlugin,
pierreWorkerAliasPlugin,
markdownWorkerUrlPlugin,
svgSpritePlugin,
cssPackageResolvePlugin,
solidPlugin(),
@@ -184,6 +205,21 @@ function createShikiWorkerContext() {
})
}
function createMarkdownShikiWorkerContext() {
return esbuild.context({
entryPoints: [path.join(__dirname, "..", "ui", "src", "components", "markdown-shiki.worker.ts")],
bundle: true,
format: "esm",
minify: production,
sourcemap: !production,
sourcesContent: false,
platform: "browser",
outfile: "dist/markdown-shiki-worker.js",
logLevel: "silent",
plugins: [esbuildProblemMatcherPlugin],
})
}
async function main() {
// Build extension
const extensionCtx = await esbuild.context({
@@ -229,6 +265,7 @@ async function main() {
// Build the shared Shiki highlighting worker asset
const shikiWorkerCtx = await createShikiWorkerContext()
const markdownShikiWorkerCtx = await createMarkdownShikiWorkerContext()
if (watch) {
await Promise.all([
@@ -240,6 +277,7 @@ async function main() {
kiloClawCtx.watch(),
marketplaceCtx.watch(),
shikiWorkerCtx.watch(),
markdownShikiWorkerCtx.watch(),
])
} else {
await Promise.all([
@@ -251,6 +289,7 @@ async function main() {
diffViewerCtx.rebuild(),
diffVirtualCtx.rebuild(),
shikiWorkerCtx.rebuild(),
markdownShikiWorkerCtx.rebuild(),
])
await Promise.all([
extensionCtx.dispose(),
@@ -261,6 +300,7 @@ async function main() {
kiloClawCtx.dispose(),
marketplaceCtx.dispose(),
shikiWorkerCtx.dispose(),
markdownShikiWorkerCtx.dispose(),
])
}
}
+5
View File
@@ -1129,6 +1129,11 @@
"default": true,
"description": "Show the task timeline graph in the chat header"
},
"kilo-code.new.showTokenThroughput": {
"type": "boolean",
"default": false,
"description": "Show tokens-per-second (prompt-processing / text-generation) badges on assistant messages and the task header"
},
"kilo-code.new.chat.shiftTabCyclesVariant": {
"type": "boolean",
"default": true,
+10 -8
View File
@@ -30,6 +30,7 @@ const forceRebuild = process.argv.includes("--force")
const kiloVscodeDir = join(import.meta.dir, "..")
const packagesDir = join(kiloVscodeDir, "..")
const repoDir = join(packagesDir, "..")
const opencodeDir = join(packagesDir, "opencode")
const coreDir = join(packagesDir, "core")
const gatewayDir = join(packagesDir, "kilo-gateway")
@@ -150,20 +151,20 @@ async function ensureBuiltBinary(): Promise<string> {
`No prebuilt binary found under ${relative(kiloVscodeDir, join(opencodeDir, "dist"))} - attempting build via bun.`,
)
const bunPath = Bun.which("bun")
if (!bunPath) {
if (!Bun.which("bun")) {
throw new Error(
`Bun is required to build the CLI binary, but was not found on PATH. ` +
`Install bun, or build the CLI separately in ${opencodeDir} and re-run.`,
)
}
// Ensure dependencies are installed before building.
// Use the repository-pinned Bun version throughout. Newer canaries can fail compilation
// and must not cause packaged snapshots to fall back to the browser-mode source wrapper.
const pkg = await Bun.file(join(repoDir, "package.json")).json()
const bun = String(pkg.packageManager)
log("Installing dependencies in opencode package...")
await $`bun install --frozen-lockfile`.cwd(opencodeDir)
// Build using the opencode package script.
await $`bun run build --single`.cwd(opencodeDir)
await $`bunx ${bun} install --frozen-lockfile`.cwd(opencodeDir)
await $`bunx ${bun} run build --single --skip-install`.cwd(opencodeDir)
const built = await findKiloBinaryInOpencodeDist()
if (!built) {
@@ -205,7 +206,7 @@ async function writeSourceWrapper() {
"#!/usr/bin/env bash",
"set -euo pipefail",
`cd ${JSON.stringify(opencodeDir)}`,
`exec ${JSON.stringify(bun)} --conditions=browser src/index.ts "$@"`,
`exec ${JSON.stringify(bun)} --conditions=node src/index.ts "$@"`,
"",
].join("\n"),
)
@@ -255,6 +256,7 @@ async function main() {
}
const sourceBinPath = await ensureBuiltBinary().catch(async (err) => {
if (forceRebuild) throw err
await writeSourceWrapper()
log(`Wrapper fallback reason: ${err instanceof Error ? err.message : String(err)}`)
return null
+77 -13
View File
@@ -63,6 +63,7 @@ import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree"
import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files"
import { renameSession } from "./kilo-provider/rename-session"
import { handleFileSearch } from "./kilo-provider/file-search"
import { handleSessionSearch } from "./kilo-provider/session-search"
import { handleFilePicker } from "./kilo-provider/file-picker"
import { watchFontSizeConfig } from "./kilo-provider/font-size"
import { getTerminalContents } from "./services/terminal/context"
@@ -172,6 +173,7 @@ import {
watchIndexingConfig,
} from "./kilo-provider/indexing-settings"
import { buildChatSettingsMessage, validChatSetting, watchChatConfig } from "./kilo-provider/chat-settings"
import { buildThroughputSettingMessage, watchThroughputConfig } from "./kilo-provider/throughput-settings"
let maxCost = 0
@@ -289,6 +291,12 @@ export function unwrapSyncEvent(event: SSEPayload | RawSyncPayload): ProviderEve
}
}
type ContextRequestMessage =
| { type: "requestFileSearch"; query: string; requestId: string; sessionID?: string }
| { type: "requestSessionSearch"; requestId: string; sessionID?: string }
| { type: "requestFilePicker"; requestId: string }
| { type: "requestTerminalContext"; requestId: string; sessionID?: string }
export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider {
public static readonly viewType = "kilo-code.SidebarProvider"
private readonly instanceId = crypto.randomUUID()
@@ -393,6 +401,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private autocompleteConfigDisposable: vscode.Disposable | null = null
private indexingConfigDisposable: vscode.Disposable | null = null
private chatConfigDisposable: vscode.Disposable | null = null
private throughputConfigDisposable: vscode.Disposable | null = null
private telemetryStateDisposable: vscode.Disposable | null = null
private viewStateDisposable: vscode.Disposable | null = null
private visibilityDisposable: vscode.Disposable | null = null
@@ -917,6 +926,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.indexingConfigDisposable = watchIndexingConfig((msg) => this.postMessage(msg))
this.chatConfigDisposable?.dispose()
this.chatConfigDisposable = watchChatConfig((msg) => this.postMessage(msg))
this.throughputConfigDisposable?.dispose()
this.throughputConfigDisposable = watchThroughputConfig((msg) => this.postMessage(msg))
this.telemetryStateDisposable?.dispose()
this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg))
this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => {
@@ -1031,6 +1042,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "unrevertSession":
this.checkpoint(message.sessionID, () => this.handleUnrevertSession(message.sessionID))
break
case "deleteMessage":
await this.handleDeleteMessage(message.sessionID, message.messageID)
break
case "permissionResponse":
await handlePermissionResponse(
this.permissionCtx,
@@ -1298,21 +1312,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
break
}
case "requestFileSearch":
await handleFileSearch({
client: this.client,
message,
current: this.currentSession?.id,
context: this.contextSessionID,
dir: (id) => this.getWorkspaceDirectory(id),
open: (dir) => this.getOpenTabPaths(dir),
post: (msg) => this.postMessage(msg),
})
break
case "requestSessionSearch":
case "requestFilePicker":
await handleFilePicker({ requestId: message.requestId, post: (msg) => this.postMessage(msg) })
break
case "requestTerminalContext":
void this.handleTerminalContext(message.requestId)
await this.handleContextRequest(message)
break
case "chatCompletionAccepted":
this.chatAutocomplete?.telemetry.captureAcceptSuggestion(message.suggestionLength)
@@ -1351,6 +1354,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "requestTimelineSetting":
this.sendTimelineSetting()
break
case "requestThroughputSetting":
this.postMessage(buildThroughputSettingMessage())
break
case "requestNotifications":
this.fetchAndSendNotifications().catch((e) =>
console.error("[Kilo New] fetchAndSendNotifications failed:", e),
@@ -1734,6 +1740,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage({ type: "gitStatus", repo: this.cachedGitRepo })
this.sendNotificationSettings()
this.sendTimelineSetting()
this.postMessage(buildThroughputSettingMessage())
this.postMessage({ type: "extensionDataReady" })
if (this.cachedGitRepo) this.startStatsPolling()
@@ -2041,6 +2048,40 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.pendingSessionRefresh = ctx.pendingSessionRefresh
}
private async handleContextRequest(message: ContextRequestMessage): Promise<void> {
if (message.type === "requestFileSearch") {
await handleFileSearch({
client: this.client,
message,
current: this.currentSession?.id,
context: this.contextSessionID,
dir: (id) => this.getWorkspaceDirectory(id),
open: (dir) => this.getOpenTabPaths(dir),
post: (msg) => this.postMessage(msg),
})
return
}
if (message.type === "requestSessionSearch") {
await handleSessionSearch({
client: this.client,
message,
current: this.currentSession?.id,
context: this.contextSessionID,
dir: (id) => this.getWorkspaceDirectory(id),
exclude: this.currentSession?.id,
post: (msg) => this.postMessage(msg),
})
return
}
if (message.type === "requestFilePicker") {
await handleFilePicker({ requestId: message.requestId, post: (msg) => this.postMessage(msg) })
return
}
if (message.type === "requestTerminalContext") {
void this.handleTerminalContext(message.requestId)
}
}
private async handleTerminalContext(requestId: string): Promise<void> {
try {
const output = await getTerminalContents(-1)
@@ -2133,6 +2174,27 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
private async handleDeleteMessage(sessionID: string, messageID: string): Promise<void> {
if (!this.client) {
this.postMessage({ type: "error", message: "Not connected to CLI backend", sessionID })
return
}
try {
await this.client.session.deleteMessage(
{ sessionID, messageID, directory: this.getWorkspaceDirectory(sessionID) },
{ throwOnError: true },
)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to delete message:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to delete message",
sessionID,
})
}
}
/**
* Handle renaming a session.
*/
@@ -3724,6 +3786,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.sendBrowserSettings()
this.sendNotificationSettings()
this.sendTimelineSetting()
this.postMessage(buildThroughputSettingMessage())
this.sendWorkStyle()
await ModelState.reset(this.client, (msg) => this.postMessage(msg))
@@ -4518,6 +4581,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.autocompleteConfigDisposable?.dispose()
this.indexingConfigDisposable?.dispose()
this.chatConfigDisposable?.dispose()
this.throughputConfigDisposable?.dispose()
this.telemetryStateDisposable?.dispose()
this.autoApproveBridge?.dispose()
this.visibleTaskStreams.clear()
@@ -12,7 +12,8 @@ const source = z.object({
const file = z.object({
mime: z.string(),
url: z.string().refine((url) => url.startsWith("file://") || url.startsWith("data:")),
// session: URLs reference a past chat; the backend resolves them into transcript context
url: z.string().refine((url) => url.startsWith("file://") || url.startsWith("data:") || url.startsWith("session:")),
filename: z.string().optional(),
source: source.optional(),
})
@@ -0,0 +1,51 @@
import type { KiloClient } from "@kilocode/sdk/v2/client"
type Item = {
id: string
title: string
updated: number
}
type Message = {
requestId: string
sessionID?: string
}
type Input = {
client: KiloClient | null
message: Message
current?: string
context?: string
dir: (id?: string) => string
exclude?: string
post: (message: unknown) => void
}
/**
* Past-chat mention search. Lists root sessions for the directory the current
* chat runs in (workspace root for the sidebar, the worktree for Agent Manager
* sessions) the same directory-scoped `session.list` the session history and
* Agent Manager search are built on. Fuzzy title filtering happens in the
* webview (same mechanism as the Agent Manager sidebar search).
*/
export async function handleSessionSearch(input: Input): Promise<void> {
const client = input.client
if (!client) {
input.post({ type: "sessionSearchResult", sessions: [], requestId: input.message.requestId })
return
}
const id = input.message.sessionID ?? input.current ?? input.context
const dir = input.dir(id)
try {
const res = await client.session.list({ directory: dir, roots: true, limit: 50 }, { throwOnError: true })
const sessions: Item[] = res.data
.filter((session) => session.id !== input.exclude && session.title)
.map((session) => ({ id: session.id, title: session.title, updated: session.time.updated }))
input.post({ type: "sessionSearchResult", sessions, requestId: input.message.requestId })
} catch (err) {
console.error("[Kilo New] Session search failed:", err)
input.post({ type: "sessionSearchResult", sessions: [], requestId: input.message.requestId })
}
}
@@ -0,0 +1,19 @@
import * as vscode from "vscode"
type Post = (msg: unknown) => void
export function buildThroughputSettingMessage() {
const config = vscode.workspace.getConfiguration("kilo-code.new")
return {
type: "throughputSettingLoaded" as const,
visible: config.get<boolean>("showTokenThroughput", false),
}
}
export function watchThroughputConfig(post: Post): vscode.Disposable {
return vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration("kilo-code.new.showTokenThroughput")) {
post(buildThroughputSettingMessage())
}
})
}
+2 -1
View File
@@ -45,6 +45,7 @@ export function buildWebviewHtml(
): string {
const nonce = getNonce()
const csp = buildCspString(webview.cspSource, nonce, opts.port)
const markdownWorkerUri = opts.workerUri.toString().replace(/shiki-worker\.js$/, "markdown-shiki-worker.js")
return `<!DOCTYPE html>
<html lang="en" data-theme="kilo-vscode">
@@ -82,7 +83,7 @@ export function buildWebviewHtml(
</head>
<body>
<div id="root"></div>
<script nonce="${nonce}">window.ICONS_BASE_URI = "${opts.iconsBaseUri}"; window.KILO_SHIKI_WORKER_URI = "${opts.workerUri}";</script>
<script nonce="${nonce}">window.ICONS_BASE_URI = "${opts.iconsBaseUri}"; window.KILO_SHIKI_WORKER_URI = "${opts.workerUri}"; window.KILO_MARKDOWN_SHIKI_WORKER_URI = "${markdownWorkerUri}";</script>
<script nonce="${nonce}" src="${opts.scriptUri}"></script>
</body>
</html>`
@@ -13,6 +13,7 @@ const STORIES = [
{ id: "settings--providers-configure", name: "Settings / providers empty state" },
{ id: "marketplace--empty-list", name: "Marketplace / empty state" },
{ id: "agentmanager--sidebar-search-open", name: "Agent Manager / sidebar search" },
{ id: "session-tabs--switcher-open", name: "Session tabs / switcher" },
]
function url(id: string) {
@@ -178,4 +179,53 @@ test.describe("webview accessibility ratchet", () => {
).toBeVisible()
await expect(page.getByText("⌘F", { exact: true })).toBeVisible()
})
test("Search lists do not select an unhighlighted result on Enter by default", async ({ page }) => {
await open(page, "agentmanager--sidebar-search-open")
const input = page.getByPlaceholder("Search worktrees and sessions", { exact: true })
const row = page.locator('[data-slot="list-item"]').first()
const selected = page.locator('[data-slot="sidebar-search-selection"]')
await input.fill("Render")
await expect(row).toContainText("Render images in diff viewer")
await row.dispatchEvent("mousemove", { movementX: 1 })
await expect(row).toHaveAttribute("data-active", "true")
await row.dispatchEvent("mouseleave")
await expect(page.locator('[data-slot="list-item"][data-active="true"]')).toHaveCount(0)
await input.press("Enter")
await expect(selected).toHaveText("worktree:wt-search")
await expect(input).toBeFocused()
})
test("Session tab switcher restores chat focus after keyboard and mouse selection", async ({ page }) => {
await open(page, "session-tabs--switcher-open")
const input = page.getByPlaceholder("Search open tabs")
const prompt = page.getByRole("textbox", { name: "Chat input" })
await expect(page.locator('[data-slot="list-item"][data-active="true"]')).toHaveCount(0)
await expect(page.locator('[data-slot="list-item"][data-key="current"]')).toHaveAttribute("data-selected", "true")
await expect(page.locator('[data-slot="list-item"][data-key="refactor"]')).toHaveAttribute("data-selected", "false")
await input.press("ArrowDown")
await input.press("Enter")
await expect(prompt).toBeFocused()
await page.getByRole("button", { name: "Show open tabs" }).click()
await page.locator('[data-slot="list-item"][data-key="current"]').click()
await expect(prompt).toBeFocused()
// Enter without prior ArrowDown selects the first filtered result (noInitialSelection)
await page.getByRole("button", { name: "Show open tabs" }).click()
await input.fill("Review")
await input.press("Enter")
await expect(prompt).toBeFocused()
})
test("Search popovers expose accessible dialog names", async ({ page }) => {
for (const id of ["agentmanager--sidebar-search-open", "session-tabs--switcher-open"]) {
await open(page, id)
await expect(page.getByRole("dialog")).toHaveAccessibleName(/.+/)
}
})
})
@@ -0,0 +1,194 @@
import assert from "node:assert/strict"
import { Window } from "happy-dom"
const window = new Window({ url: "http://localhost" })
const style = window.getComputedStyle.bind(window)
Object.assign(globalThis, {
window,
document: window.document,
navigator: window.navigator,
Node: window.Node,
Element: window.Element,
HTMLElement: window.HTMLElement,
HTMLInputElement: window.HTMLInputElement,
HTMLTextAreaElement: window.HTMLTextAreaElement,
SVGElement: window.SVGElement,
MutationObserver: window.MutationObserver,
ResizeObserver: window.ResizeObserver,
CustomEvent: window.CustomEvent,
Event: window.Event,
FocusEvent: window.FocusEvent,
InputEvent: window.InputEvent,
KeyboardEvent: window.KeyboardEvent,
MouseEvent: window.MouseEvent,
PointerEvent: window.PointerEvent,
getComputedStyle: (node: Element) => {
const value = style(node)
Object.defineProperty(value, "animationName", { configurable: true, value: "none" })
return value
},
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
})
const { Show, createSignal } = await import("solid-js")
const { render } = await import("solid-js/web")
const { SessionTabSwitcher } = await import("../../webview-ui/src/components/chat/SessionTabSwitcher")
const rows = [
{ id: "alpha", title: "Alpha", active: true, busy: false, pending: false },
{ id: "beta", title: "Beta", active: false, busy: true, pending: false },
{ id: "gamma", title: "Gamma", active: false, busy: false, pending: false },
]
const [items, setItems] = createSignal(rows)
const selected: string[] = []
const restored: boolean[] = []
const closed: string[] = []
const target = document.createElement("textarea")
const root = document.createElement("div")
document.body.append(root, target)
const dispose = render(
() => (
<Show when={items().length > 1}>
<SessionTabSwitcher
items={items}
labels={{
open: "Show open tabs",
search: "Search open tabs",
close: "Close tab",
current: "Current",
pending: "New",
busy: "Working",
}}
onSelect={(id) => selected.push(id)}
onRestore={() => {
restored.push(true)
target.focus()
}}
onClose={(id) => {
closed.push(id)
setItems((value) => value.filter((item) => item.id !== id))
}}
portal={false}
/>
</Show>
),
root,
)
function query<T extends Element>(selector: string, message: string) {
const node = root.querySelector<T>(selector)
assert(node, message)
return node
}
const settle = async () => {
await Promise.resolve()
await window.happyDOM.waitUntilComplete()
}
const open = async () => {
query<HTMLButtonElement>('[aria-label="Show open tabs"]', "Switcher trigger did not render").click()
await settle()
assert.equal(root.querySelector('[data-slot="list-item"][data-active="true"]'), null, "First tab was highlighted")
assert.equal(
query('[data-slot="list-item"][data-key="alpha"]', "Current tab did not render").getAttribute("data-selected"),
"true",
"Current tab was not selected",
)
}
async function closeFiltered() {
await open()
const input = query<HTMLInputElement>('[data-slot="list-search"] input', "Switcher search did not render")
input.value = "be"
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "be", inputType: "insertText" }))
await settle()
const close = query<HTMLButtonElement>(
'[aria-label="Close tab: Beta"]',
"Filtered result close button did not render",
)
assert.equal(close.tabIndex, 0, "Close button is not keyboard reachable")
close.click()
await settle()
assert.deepEqual(closed, ["beta"], "Unexpected closed tabs")
assert.equal(input.value, "be", "Closing a result cleared the filter")
assert.equal(document.activeElement, input, "Search input was not refocused after closing a result")
}
async function selectFiltered() {
setItems(rows)
await settle()
query<HTMLButtonElement>('[data-slot="list-item"][data-key="beta"]', "Filtered result did not return").click()
await settle()
assert.deepEqual(selected, ["beta"], "Unexpected selected tabs")
assert.deepEqual(restored, [true], "Prompt focus was not restored")
assert.equal(document.activeElement, target, "Popover close stole focus from the prompt")
}
async function enterSelectsFirst() {
setItems(rows)
selected.length = 0
restored.length = 0
await settle()
await open()
const input = query<HTMLInputElement>('[data-slot="list-search"] input', "Switcher search did not render")
input.value = "ga"
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "ga", inputType: "insertText" }))
await settle()
input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }))
await settle()
assert.deepEqual(selected, ["gamma"], "Enter did not select the first filtered result")
assert.deepEqual(restored, [true], "Prompt focus was not restored after Enter")
assert.equal(document.activeElement, target, "Popover close stole focus from the prompt")
}
async function deleteReopened() {
await open()
const alpha = query<HTMLButtonElement>(
'[data-slot="list-item"][data-key="alpha"]',
"Switcher did not reset its filter when reopened",
)
alpha.focus()
alpha.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete" }))
await settle()
assert.deepEqual(closed, ["beta", "alpha"], "Keyboard close failed")
}
async function closeToOne() {
closed.length = 0
restored.length = 0
const beta = query<HTMLButtonElement>(
'[data-slot="list-item"][data-key="beta"]',
"Switcher did not retain the remaining tabs",
)
beta.focus()
beta.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Delete" }))
await settle()
assert.deepEqual(closed, ["beta"], "Final visible close failed")
assert.deepEqual(restored, [true], "Prompt did not receive the focus handoff")
assert.equal(root.querySelector('[aria-label="Show open tabs"]'), null, "Switcher did not unmount")
assert.equal(document.activeElement, target, "Prompt was not focused after the switcher unmounted")
}
await closeFiltered()
await selectFiltered()
await enterSelectsFirst()
await deleteReopened()
await closeToOne()
dispose()
@@ -5,11 +5,18 @@ import {
buildTextAfterMentionSelect,
buildFileAttachments,
buildMentionResults,
buildSessionAttachments,
filterMentionResults,
getMentionRemovalRange,
getPastChatsMentionResult,
isCursorAtMentionEnd,
findMentionRange,
sessionMentionFilename,
sessionMentionText,
sessionMentionToken,
syncMentionedSessions,
FILE_PICKER_RESULT,
PAST_CHATS_RESULT,
TERMINAL_RESULT,
GIT_CHANGES_RESULT,
} from "../../webview-ui/src/hooks/file-mention-utils"
@@ -89,6 +96,7 @@ describe("buildMentionResults", () => {
expect(result).toEqual([
TERMINAL_RESULT,
GIT_CHANGES_RESULT,
PAST_CHATS_RESULT,
{ type: "file", value: "src/index.ts" },
FILE_PICKER_RESULT,
])
@@ -552,3 +560,111 @@ describe("findMentionRange", () => {
expect(findMentionRange(text, 4, paths)).toEqual({ start: 3, end: 6 })
})
})
describe("session mentions", () => {
const now = Date.now()
const sessions = [
{ id: "ses_a", title: "Fix auth bug", updated: now },
{ id: "ses_b", title: "Rotate signing keys", updated: now - 1000 },
{ id: "ses_c", title: "Refactor cache layer", updated: now - 2000 },
]
describe("getPastChatsMentionResult", () => {
it("offers the past-chats picker for an empty query", () => {
expect(getPastChatsMentionResult("")).toEqual([PAST_CHATS_RESULT])
})
it("offers the picker for alias prefixes", () => {
expect(getPastChatsMentionResult("pas")).toEqual([PAST_CHATS_RESULT])
expect(getPastChatsMentionResult("sess")).toEqual([PAST_CHATS_RESULT])
expect(getPastChatsMentionResult("hist")).toEqual([PAST_CHATS_RESULT])
})
it("hides the picker for unrelated queries", () => {
expect(getPastChatsMentionResult("index")).toEqual([])
})
})
describe("sessionMentionText / filename", () => {
it("collapses whitespace in titles", () => {
expect(sessionMentionText("Fix\nauth bug")).toBe("Fix auth bug")
})
it("slugifies titles for the attachment filename", () => {
expect(sessionMentionFilename("Fix auth bug", "ses_a")).toBe("Fix-auth-bug.md")
})
it("falls back to the session id when the slug is empty", () => {
expect(sessionMentionFilename("???", "ses_a")).toBe("ses_a.md")
})
it("disambiguates sessions with the same title", () => {
const known = new Map([["Fix auth bug", sessions[0]!]])
expect(sessionMentionToken({ ...sessions[1]!, title: "Fix auth bug" }, known)).toBe("Fix auth bug (2)")
})
it("reuses the token already assigned to a session", () => {
const known = new Map([["Fix auth bug (2)", sessions[1]!]])
expect(sessionMentionToken(sessions[1]!, known)).toBe("Fix auth bug (2)")
})
})
describe("buildMentionResults", () => {
it("offers the past-chats picker alongside the other special mentions", () => {
const result = buildMentionResults("", [])
expect(result[0]).toEqual(TERMINAL_RESULT)
expect(result).toContainEqual(PAST_CHATS_RESULT)
expect(result[result.length - 1]).toEqual(FILE_PICKER_RESULT)
})
})
describe("filterMentionResults", () => {
it("keeps the past-chats picker for alias queries", () => {
const filtered = filterMentionResults("sess", buildMentionResults("", []))
expect(filtered).toContainEqual(PAST_CHATS_RESULT)
})
})
describe("syncMentionedSessions", () => {
it("drops sessions whose token is no longer present in the text", () => {
const prev = new Map([
["Fix auth bug", sessions[0]!],
["Rotate signing keys", sessions[1]!],
])
const kept = syncMentionedSessions(prev, "see @Fix auth bug here")
expect(kept.has("Fix auth bug")).toBe(true)
expect(kept.has("Rotate signing keys")).toBe(false)
})
})
describe("buildSessionAttachments", () => {
it("builds a session: attachment with span offsets and a readable filename", () => {
const mentioned = new Map([["Fix auth bug", sessions[0]!]])
const attachments = buildSessionAttachments("check @Fix auth bug out", mentioned)
expect(attachments).toHaveLength(1)
const att = attachments[0]!
expect(att.mime).toBe("text/plain")
expect(att.url).toBe("session:ses_a")
expect(att.filename).toBe("Fix-auth-bug.md")
expect(att.source?.type).toBe("file")
expect(att.source?.text.value).toBe("@Fix auth bug")
expect(att.source?.text.start).toBe(6)
expect(att.source?.text.end).toBe(19)
})
it("skips sessions whose token is not present in the text", () => {
const mentioned = new Map([["Fix auth bug", sessions[0]!]])
expect(buildSessionAttachments("nothing here", mentioned)).toEqual([])
})
it("attaches distinct sessions whose titles collide", () => {
const mentioned = new Map([
["Fix auth bug", sessions[0]!],
["Fix auth bug (2)", { ...sessions[1]!, title: "Fix auth bug" }],
])
const attachments = buildSessionAttachments("compare @Fix auth bug with @Fix auth bug (2)", mentioned)
expect(attachments.map((item) => item.url)).toEqual(["session:ses_a", "session:ses_b"])
expect(attachments.map((item) => item.source?.text.value)).toEqual(["@Fix auth bug", "@Fix auth bug (2)"])
})
})
})
@@ -23,4 +23,22 @@ describe("parseMessageFiles", () => {
it("rejects unsupported URLs", () => {
expect(parseMessageFiles([{ mime: "text/plain", url: "https://example.com/file.txt" }])).toBeUndefined()
})
it("accepts past-chat session attachments", () => {
const files = parseMessageFiles([
{
mime: "text/plain",
url: "session:ses_07c08a2ddffeXample",
filename: "fix-auth-bug.md",
source: {
type: "file",
path: "session:ses_07c08a2ddffeXample",
text: { value: "@Fix auth bug", start: 0, end: 13 },
},
},
])
expect(files?.[0]?.url).toBe("session:ses_07c08a2ddffeXample")
expect(files?.[0]?.filename).toBe("fix-auth-bug.md")
})
})
@@ -43,6 +43,28 @@ describe("terminal", () => {
expect(terminal({ reason: "completed", messages: [message("unknown")], todos: [] })?.kind).toBe("unknown")
})
it("includes the Vercel response ID for an unknown finish", () => {
expect(
terminal({
reason: "completed",
messages: [
message("unknown", {
name: "APIError",
data: { responseHeaders: { "X-Vercel-Id": "fra1::abc" } },
}),
],
todos: [],
hidden: () => true,
}),
).toEqual({
kind: "unknown",
tone: "warning",
finish: "unknown",
remaining: 0,
vercelID: "fra1::abc",
})
})
it("surfaces filtered and unexpected provider finishes", () => {
expect(terminal({ reason: "completed", messages: [message("content-filter")], todos: [] })?.kind).toBe("filtered")
expect(terminal({ reason: "completed", messages: [message("other")], todos: [] })?.kind).toBe("unexpected")

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