Merge remote-tracking branch 'origin/main' into bottled-barometer

# Conflicts:
#	packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx
This commit is contained in:
marius-kilocode
2026-05-28 10:36:57 +02:00
228 changed files with 9285 additions and 1820 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Center local session history delete buttons within their rows.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Improve the size and readability of the local History session context menu.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show question, permission, plan, and login-required badges for active JetBrains sessions in recent and history lists.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---
Support toggling auto-approve for permission prompts from the JetBrains chat input.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Refresh JetBrains history and recent-session rows when active session titles change, and keep pending inactive sessions alive when switching views.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show a question indicator on the JetBrains session scroll overlay when user input is needed.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Resize the JetBrains prompt editor as prompt lines are added or removed.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Refine JetBrains session transcript styling with subtler tool rows, prompt-styled user messages, and underlined read file links that open files in the IDE.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Support opening links in JetBrains session markdown transcripts.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Keep JetBrains chat scrolled to the latest prompt and question updates when following the bottom.
@@ -0,0 +1,8 @@
---
"kilo-code": patch
"@kilocode/cli": patch
"@kilocode/kilo-indexing": patch
"@kilocode/sdk": patch
---
Use supported hosted model presets for Kilo indexing and clear obsolete model and dimension overrides.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add Mercury Next Edit as an opt-in autocomplete mode. Predicts multi-line edits beyond the cursor (including off-cursor and pure-insertion edits) and surfaces them with a Tab-to-jump / Tab-to-apply affordance. Select "Mercury Next Edit" under the autocomplete model setting to enable it (requires an Inception API key). Thanks [@tfiras](https://github.com/tfiras)!
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Make the Agent Manager tool available by default in VS Code.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Restore readable diff highlighting and collapsed unchanged sections in VS Code themes.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prevent saved global indexing provider changes from temporarily reverting in active workspaces.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Show running badges on active sessions in JetBrains recent and history lists.
@@ -28,6 +28,31 @@ Before installing the Kilo Code plugin, ensure you have:
4. Search for "Kilo Code"
5. Click **Install** and restart your IDE
### Try the v7 Early Access Program plugin
The v7 EAP plugin is available for users who want to try the newest JetBrains experience before it reaches the default Marketplace channel. It uses a JetBrains-native UI and is designed to work well with JetBrains remote development.
{% callout type="info" %}
EAP builds may update more frequently than the default Marketplace release. Share feedback in the JetBrains channel on the [Kilo Discord](https://kilo.ai/discord).
{% /callout %}
To install the EAP build and receive updates:
1. Open IntelliJ IDEA or another JetBrains IDE
2. Go to **Settings/Preferences → Plugins**
3. Click the gear icon and choose **Manage Plugin Repositories**
4. Add this repository URL:
```text
https://plugins.jetbrains.com/plugins/list?channel=eap&pluginId=28350
```
5. Return to the **Marketplace** tab
6. Search for **Kilo Code**
7. Click **Install** or **Update** and restart your IDE if prompted
After the custom repository is added, JetBrains will offer EAP updates through the normal plugin update flow.
### Supported IDEs
- IntelliJ IDEA
+16 -16
View File
@@ -1,6 +1,6 @@
---
title: "Using xAI Grok with Kilo Code"
description: "Connect xAI's Grok models to Kilo Code. Use a SuperGrok subscription via OAuth or a paid API key. Guide to setup in VS Code and the CLI."
description: "Connect xAI's Grok models to Kilo Code. Use a SuperGrok or X Premium subscription via OAuth or a paid API key. Guide to setup in VS Code and the CLI."
sidebar_label: xAI (Grok)
---
@@ -12,33 +12,33 @@ xAI is the company behind Grok, a large language model known for its conversatio
Kilo Code supports two ways to connect xAI:
- **SuperGrok subscription (OAuth):** If you subscribe to SuperGrok, you can sign in with OAuth — no separate API key or pay-as-you-go charges required.
- **SuperGrok or X Premium subscription (OAuth):** If you subscribe to SuperGrok or X Premium, you can sign in with OAuth — no separate API key or pay-as-you-go charges required.
- **API key:** For pay-as-you-go access via the xAI API.
---
## Option 1: SuperGrok Subscription (OAuth)
## Option 1: SuperGrok or X Premium Subscription (OAuth)
If you have an active [SuperGrok subscription](https://x.ai/grok), you can authenticate with xAI using OAuth and use Grok models directly without needing a separate API key.
If you have an active [SuperGrok or X Premium subscription](https://x.ai/grok), you can authenticate with xAI using OAuth and use Grok models directly without needing a separate API key.
### Why use SuperGrok?
### Why use SuperGrok or X Premium?
- **No API billing:** Usage counts against your SuperGrok subscription, not a pay-per-token API account.
- **No API billing:** Usage counts against your subscription, not a pay-per-token API account.
- **OAuth login — no API keys:** Sign in through your browser and Kilo Code handles token management automatically.
- **Automatic token refresh:** Kilo Code refreshes your access token in the background so long-running sessions stay authenticated.
{% callout type="note" %}
SuperGrok subscription access works with Kilo Code's core functionality (VS Code extension and CLI). For cloud features such as Cloud Agents or KiloClaw, use the [Kilo Gateway](/docs/gateway) — the Gateway supports xAI via [BYOK](/docs/getting-started/byok) with an API key (OAuth/SuperGrok is not supported through the Gateway).
SuperGrok and X Premium subscription access works with Kilo Code's core functionality (VS Code extension and CLI). For cloud features such as Cloud Agents or KiloClaw, use the [Kilo Gateway](/docs/gateway) — the Gateway supports xAI via [BYOK](/docs/getting-started/byok) with an API key (OAuth and subscription-based access are not supported through the Gateway).
{% /callout %}
### Setup with SuperGrok
### Setup with SuperGrok / X Premium
{% tabs %}
{% tab label="VSCode (Legacy)" %}
1. Open Kilo Code settings (click the gear icon {% codicon name="gear" /%} in the Kilo Code panel).
2. In **API Provider**, select **xAI**.
3. Click **Sign in with xAI (SuperGrok)**.
3. Click **Sign in with xAI (SuperGrok / X Premium)**.
4. Complete the authorization flow in your browser.
5. Back in Kilo Code settings, select your desired Grok model.
6. Save.
@@ -46,22 +46,22 @@ SuperGrok subscription access works with Kilo Code's core functionality (VS Code
{% /tab %}
{% tab label="VSCode" %}
Open **Settings** (gear icon) and go to the **Providers** tab. Click **Show more providers**, then search for or select **xAI**. Choose the **xAI Grok OAuth (SuperGrok Subscription)** sign-in option and complete the OAuth flow in your browser.
Open **Settings** (gear icon) and go to the **Providers** tab. Click **Show more providers**, then search for or select **xAI**. Choose the **xAI Grok OAuth (SuperGrok / X Premium)** sign-in option and complete the OAuth flow in your browser.
For headless or remote environments (VPS, SSH, Docker, WSL) where a browser redirect to `127.0.0.1` is not reachable, choose **xAI Grok OAuth (Headless / Remote / VPS)** instead. You will be shown a short code to enter at a URL you open on any device with a browser.
{% /tab %}
{% tab label="CLI" %}
Run the auth command and follow the xAI SuperGrok sign-in flow:
Run the auth command and follow the xAI sign-in flow:
```bash
kilo auth login --provider xai
```
Kilo Code offers two OAuth methods at the prompt:
Kilo Code offers three methods at the prompt:
- **xAI Grok OAuth (SuperGrok Subscription)** — opens `https://auth.x.ai` in your browser for a standard PKCE OAuth flow. Best for local desktop environments.
- **xAI Grok OAuth (SuperGrok / X Premium)** — opens `https://auth.x.ai` in your browser for a standard PKCE OAuth flow. Best for local desktop environments.
- **xAI Grok OAuth (Headless / Remote / VPS)** — uses the RFC 8628 device-code flow. The CLI displays a short code and a URL; open the URL on any device with a browser, enter the code, and the CLI completes the login. Use this when running on a VPS, behind SSH, inside Docker, WSL, or CI where `127.0.0.1:56121` is not accessible from your browser.
- **Manually enter API Key** — fall back to a standard API key if you prefer.
@@ -76,9 +76,9 @@ Then set your default model:
{% /tab %}
{% /tabs %}
### Tips for SuperGrok
### Tips for SuperGrok and X Premium
- **Subscription required:** You need an active SuperGrok subscription. This option will not work with a free xAI account.
- **Subscription required:** You need an active SuperGrok or X Premium subscription. This option will not work with a free xAI account.
- **Sign out:** To disconnect in VS Code, use the "Disconnect" button in the provider settings. In the CLI, run `kilo auth logout` and choose xAI.
- **Port 56121:** The browser OAuth flow (PKCE) starts a short-lived local server on `127.0.0.1:56121` to receive the OAuth callback. If another application is already using that port, use the headless device-code method instead.
- **Token rotation:** xAI rotates refresh tokens on each use. Kilo Code persists the latest tokens automatically. If you run Kilo Code from multiple processes simultaneously, the first refresh can invalidate the other process's token — re-run `kilo auth login --provider xai` to restore the session.
@@ -87,7 +87,7 @@ Then set your default model:
## Option 2: API Key
If you prefer pay-as-you-go access or do not have a SuperGrok subscription, you can use an xAI API key.
If you prefer pay-as-you-go access or do not have a SuperGrok or X Premium subscription, you can use an xAI API key.
### Getting an API Key
@@ -138,9 +138,9 @@ Imported work stays associated with its branch or worktree and can be continued
## Starting Sessions From Chat
Kilo can start Agent Manager sessions from chat with the experimental `agent_manager` tool. Enable it in **Settings > Experimental > Agent Manager Tool**, or set `experimental.agent_manager_tool` to `true` in `kilo.jsonc`.
Kilo can start Agent Manager sessions from chat with the `agent_manager` tool. It is available by default only in the VS Code extension because Agent Manager is an extension feature.
The tool is available only in the VS Code extension because Agent Manager is an extension feature. It supports two modes:
The tool supports two modes:
| Mode | Behavior |
|---|---|
@@ -24,7 +24,7 @@ Tools are organized into logical groups based on their functionality:
| **Web Group** | Fetch and search web content | `webfetch`, `websearch`, `codesearch` | Research, documentation lookup |
| **Browser Group** | Web browser automation | `kilo-playwright_*` (via built-in Playwright MCP) | Browser testing and interaction |
| **MCP Group** | External tool integration | MCP server tools (namespaced as `{server}_{tool}`) | Specialized functionality via MCP |
| **Workflow Group** | Sub-agents and task management | `question`, `task`, `todowrite`, `todoread`, `plan`, `skill`, `agent_manager` (experimental) | Context switching and task organization |
| **Workflow Group** | Sub-agents and task management | `question`, `task`, `todowrite`, `todoread`, `plan`, `skill`, `agent_manager` | Context switching and task organization |
### Always Available Tools
@@ -94,7 +94,7 @@ These tools help manage the conversation and task flow:
- `todoread` - Reads the current session TODO list
- `plan` - Enters structured planning mode
- `skill` - Invokes a reusable skill (Markdown instruction module)
- `agent_manager` - Starts Agent Manager local or worktree sessions when the experimental Agent Manager Tool setting is enabled in VS Code
- `agent_manager` - Starts Agent Manager local or worktree sessions in VS Code
{% /tab %}
{% tab label="VSCode (Legacy)" %}
@@ -66,7 +66,7 @@ Use the shield button in the prompt controls to toggle runtime auto-approve for
Expand **Manage Auto-Approve Rules** to add commands or patterns to your allowed or denied lists. These rules are then appended to the bottom of the approval rules in settings and the config file.
For the experimental `agent_manager` tool, runtime approvals use the requested mode as the pattern: `worktree` or `local`.
For the `agent_manager` tool, runtime approvals use the requested mode as the pattern: `worktree` or `local`.
## MCP Tool Permissions
@@ -200,7 +200,6 @@ Available experimental settings include:
- **Paste summary** - summarize large clipboard pastes before including them
- **Speech to Text Model** - optionally select the transcription model
- **Batch tool** - allow the agent to batch multiple tool calls in one step
- **Agent Manager Tool** - allow agents to start Agent Manager local and worktree sessions from chat
- **OpenTelemetry** - enable Kilo telemetry and optional OTLP export when configured
Voice input appears automatically when the Kilo provider is enabled and you are signed in. Choosing **Speech to Text Model** stores `experimental.speech_to_text_model` in your global Kilo CLI config (`~/.config/kilo/kilo.jsonc`):
@@ -220,7 +219,6 @@ Advanced options not exposed in the UI can be configured via the `experimental`
"experimental": {
"codebase_search": true,
"batch_tool": false,
"agent_manager_tool": false,
"openTelemetry": true,
"disable_paste_summary": false,
"mcp_timeout": 30000
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f5e9dd9a8b232e960fbc1925a2447a7634b2db241f6fe9b9ba8527665a5ca4c4
size 14248
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:65686e2d0978adcf09ae504d9414e2d81f304eb18ff2275be7a33a9c3579a5e5
size 18209
oid sha256:32fa296aeee4f45f1cefe49d8eaca08099a8b3b8c23d4e9cdec803ce7fd1f0a1
size 18207
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9d70e12fc78142601de74977fea330a91f3662851cc8f56023a6ec819724dbb8
size 48958
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d7ad739b2099b5b35abf3989bca8e8f63c2926906decfddf7530d6b97fb0e6b1
size 50711
+2
View File
@@ -47,6 +47,8 @@
<!-- packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts -->
- <https://docs.github.com/en/actions/how-tos/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services>
<!-- packages/opencode/src/cli/cmd/github.ts -->
- <https://docs.inceptionlabs.ai/capabilities/next-edit>
<!-- packages/opencode/src/kilocode/server/httpapi/groups/kilo-gateway.ts -->
- <https://docs.mistral.ai/capabilities/reasoning/adjustable>
<!-- packages/opencode/src/provider/transform.ts -->
- <https://docs.venice.ai/overview/guides/reasoning-models#reasoning-effort>
+1
View File
@@ -0,0 +1 @@
.artifacts
+4 -1
View File
@@ -19,6 +19,8 @@
".": "./src/index.ts",
"./autocomplete": "./src/autocomplete.ts",
"./fim": "./src/fim.ts",
"./edit": "./src/edit.ts",
"./edit-prompt": "./src/edit-prompt.ts",
"./tui": "./src/tui.ts"
},
"files": [
@@ -26,7 +28,8 @@
],
"scripts": {
"typecheck": "tsgo --noEmit",
"build": "tsc"
"build": "tsc",
"test:ci": "mkdir -p .artifacts/unit && bun test test --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"dependencies": {
"@kilocode/plugin": "workspace:*",
+24 -4
View File
@@ -4,7 +4,7 @@ export type DirectAutocompleteProviderID = Exclude<AutocompleteProviderID, "kilo
export interface AutocompleteModelDef {
/** Stable combined value for internal comparisons. */
readonly id: string
/** Model value stored in settings and sent to the FIM API. */
/** Model value stored in settings and sent to the autocomplete API. */
readonly modelID: string
/** Human-readable label shown in settings. */
readonly label: string
@@ -12,12 +12,18 @@ export interface AutocompleteModelDef {
readonly providerID: AutocompleteProviderID
/** Provider display name for status bar / telemetry. */
readonly provider: string
/** Full model ID sent upstream by the FIM route. */
/** Full model ID sent upstream by the autocomplete route. */
readonly requestModel: string
/** Provider key to use for direct BYOK FIM. Empty means Kilo Gateway. */
/** Provider key to use for direct BYOK. Empty means Kilo Gateway. */
readonly directProvider?: DirectAutocompleteProviderID
/** FIM request temperature. */
/** Request temperature. */
readonly temperature: number
/**
* Which gateway endpoint this model targets. Defaults to "fim" if omitted
* (back-compat with existing entries). Models with `kind: "edit"` route
* through `/kilo/edit` and use Mercury's Next Edit pipeline.
*/
readonly kind?: "fim" | "edit"
}
const models: AutocompleteModelDef[] = [
@@ -59,6 +65,20 @@ const models: AutocompleteModelDef[] = [
directProvider: "inception",
temperature: 0,
},
{
// Same wire-level model as `mercury-edit-2`, but routed through the
// Mercury Next Edit endpoint instead of FIM. Picked by users who want
// multi-line next-edit predictions with the jump-to-edit UX.
id: "inception/mercury-next-edit",
modelID: "mercury-next-edit",
label: "Mercury Next Edit",
providerID: "inception",
provider: "Inception",
requestModel: "mercury-edit-2",
directProvider: "inception",
temperature: 0,
kind: "edit",
},
]
export const AUTOCOMPLETE_MODELS: readonly AutocompleteModelDef[] = models
+107
View File
@@ -0,0 +1,107 @@
/**
* Mercury Next Edit prompt assembly. Lives in the gateway so every client
* (VS Code, JetBrains, TUI) sends the same structured editor context and the
* Mercury-specific sentinel format is defined in exactly one place.
*
* Tag set is defined by the model and must be reproduced verbatim see
* https://docs.inceptionlabs.ai/capabilities/next-edit
*/
const RECENTLY_VIEWED_SNIPPETS_OPEN = "<|recently_viewed_code_snippets|>"
const RECENTLY_VIEWED_SNIPPETS_CLOSE = "<|/recently_viewed_code_snippets|>"
const RECENTLY_VIEWED_SNIPPET_OPEN = "<|recently_viewed_code_snippet|>"
const RECENTLY_VIEWED_SNIPPET_CLOSE = "<|/recently_viewed_code_snippet|>"
const CURRENT_FILE_CONTENT_OPEN = "<|current_file_content|>"
const CURRENT_FILE_CONTENT_CLOSE = "<|/current_file_content|>"
const CODE_TO_EDIT_OPEN = "<|code_to_edit|>"
const CODE_TO_EDIT_CLOSE = "<|/code_to_edit|>"
const EDIT_DIFF_HISTORY_OPEN = "<|edit_diff_history|>"
const EDIT_DIFF_HISTORY_CLOSE = "<|/edit_diff_history|>"
const CURSOR = "<|cursor|>"
/** Trailing token that tells the model this is a next-edit (not chat) request. */
const UNIQUE_TOKEN = "<|!@#IS_NEXT_EDIT!@#|>"
export interface MercuryRecentSnippet {
filepath: string
content: string
}
/** Editor-derived context a client sends; the gateway turns it into a prompt. */
export interface MercuryEditContext {
currentFilePath: string
currentFileContent: string
cursorLine: number
cursorCharacter: number
editableRegionStartLine: number
editableRegionEndLine: number
recentlyViewedSnippets: MercuryRecentSnippet[]
editDiffHistory: string[]
}
function insertCursorToken(lines: string[], cursorLine: number, cursorCharacter: number): string[] {
if (cursorLine < 0 || cursorLine >= lines.length) return lines
const line = lines[cursorLine]
const safeChar = Math.min(Math.max(cursorCharacter, 0), line.length)
const next = line.slice(0, safeChar) + CURSOR + line.slice(safeChar)
return [...lines.slice(0, cursorLine), next, ...lines.slice(cursorLine + 1)]
}
export function recentlyViewedSnippetsBlock(snippets: MercuryRecentSnippet[]): string {
const inner = snippets
.map((s) =>
[RECENTLY_VIEWED_SNIPPET_OPEN, `code_snippet_file_path: ${s.filepath}`, s.content, RECENTLY_VIEWED_SNIPPET_CLOSE].join("\n"),
)
.join("\n")
return [RECENTLY_VIEWED_SNIPPETS_OPEN, inner, RECENTLY_VIEWED_SNIPPETS_CLOSE].join("\n")
}
export function currentFileContentBlock(
currentFilePath: string,
currentFileContent: string,
editableRegionStartLine: number,
editableRegionEndLine: number,
cursorLine: number,
cursorCharacter: number,
): string {
const rawLines = currentFileContent.split("\n")
const withCursor = insertCursorToken(rawLines, cursorLine, cursorCharacter)
const start = Math.max(0, Math.min(editableRegionStartLine, withCursor.length))
const end = Math.max(start, Math.min(editableRegionEndLine, withCursor.length - 1))
const instrumented = [
...withCursor.slice(0, start),
CODE_TO_EDIT_OPEN,
...withCursor.slice(start, end + 1),
CODE_TO_EDIT_CLOSE,
...withCursor.slice(end + 1),
]
return [CURRENT_FILE_CONTENT_OPEN, `current_file_path: ${currentFilePath}`, instrumented.join("\n"), CURRENT_FILE_CONTENT_CLOSE].join("\n")
}
export function editDiffHistoryBlock(diffs: string[]): string {
// Each unidiff from `diff.createPatch` opens with an Index line + separator we
// strip. Diffs are blank-line separated so the model reads them as distinct hunks.
const trimmed = diffs.map((d) => {
const lines = d.split("\n")
return lines.length > 2 ? lines.slice(2).join("\n") : d
})
return [EDIT_DIFF_HISTORY_OPEN, trimmed.join("\n\n"), EDIT_DIFF_HISTORY_CLOSE].join("\n")
}
export function buildMercuryEditPrompt(ctx: MercuryEditContext): string {
return [
recentlyViewedSnippetsBlock(ctx.recentlyViewedSnippets),
"",
currentFileContentBlock(
ctx.currentFilePath,
ctx.currentFileContent,
ctx.editableRegionStartLine,
ctx.editableRegionEndLine,
ctx.cursorLine,
ctx.cursorCharacter,
),
"",
editDiffHistoryBlock(ctx.editDiffHistory),
"",
UNIQUE_TOKEN,
].join("\n")
}
+61
View File
@@ -0,0 +1,61 @@
import { getAutocompleteModel, type DirectAutocompleteProviderID } from "./autocomplete.js"
/**
* Env var(s) consulted as a fallback for BYOK keys when the provider hasn't
* been authenticated via the gateway's Auth store. Mirrors `DIRECT_FIM_ENV`.
*/
export const DIRECT_EDIT_ENV: Record<DirectAutocompleteProviderID, string[]> = {
mistral: ["MISTRAL_API_KEY"],
inception: ["INCEPTION_API_KEY"],
}
export type EditTarget =
| { provider: "inception"; model: string; url: string }
| { provider: "kilo"; model: string; url: string }
/** Shape of the upstream (Mercury) chat/edit completion response we read from. */
export interface EditUpstreamResponse {
choices?: Array<{ message?: { content?: string } }>
usage?: { prompt_tokens?: number; completion_tokens?: number }
}
const INCEPTION_EDIT_URL = "https://api.inceptionlabs.ai/v1/edit/completions"
/**
* Pick the upstream edit endpoint for a (provider, model) pair. Only Inception
* is wired up today Mercury is the only model family with a documented
* /v1/edit/completions endpoint. Mistral does not expose a comparable surface.
*/
export function resolveEditTarget(provider?: string, model?: string): EditTarget {
const info = getAutocompleteModel(provider, model)
if (info.kind === "edit" && info.directProvider === "inception") {
return { provider: "inception", model: info.requestModel, url: INCEPTION_EDIT_URL }
}
// Kilo Gateway does not currently proxy an edit endpoint; callers should
// fall back to FIM. We still return a kilo target so the handler can surface
// a 400 rather than silently routing somewhere unexpected.
return { provider: "kilo", model: info.requestModel, url: "" }
}
/**
* Mercury wraps the rewritten editable region in a triple-backtick fence,
* sometimes with a language tag and sometimes with `<|code_to_edit|>` sentinels
* inside. Strip all of that down to the bare code. Shared by both the hono and
* the Effect HttpApi edit handlers so the parsing can't drift between them.
*/
export function extractFencedBody(message: string): string {
if (!message) return ""
const fenceOpen = message.indexOf("```")
if (fenceOpen === -1) return message
const afterFenceOpen = message.indexOf("\n", fenceOpen + 3)
if (afterFenceOpen === -1) return ""
// A missing closing fence means the replacement was truncated. Applying a
// partial editable region can delete valid trailing code, so suppress it.
const fenceClose = message.indexOf("```", afterFenceOpen + 1)
if (fenceClose === -1) return ""
let body = message.slice(afterFenceOpen + 1, fenceClose)
if (body.endsWith("\n")) body = body.slice(0, -1)
body = body.replace(/^<\|code_to_edit\|>\n?/, "")
body = body.replace(/\n?<\|\/code_to_edit\|>$/, "")
return body
}
+90
View File
@@ -0,0 +1,90 @@
import { DIRECT_EDIT_ENV, extractFencedBody, resolveEditTarget, type EditTarget, type EditUpstreamResponse } from "../edit.js"
import { buildMercuryEditPrompt, type MercuryEditContext } from "../edit-prompt.js"
import type { DirectAutocompleteProviderID } from "../autocomplete.js"
import type { AuthStore } from "./handlers.js"
type Auth = Pick<AuthStore, "get">
const EDIT_TIMEOUT_MS = 30_000
const MAX_TOKENS_DEFAULT = 512
async function getProviderKey(Auth: Auth, provider: DirectAutocompleteProviderID): Promise<string | undefined> {
const auth = await Auth.get(provider)
if (auth?.type === "api") return auth.key
return DIRECT_EDIT_ENV[provider].map((key) => process.env[key]).find(Boolean)
}
export function createEditHandler(Auth: Auth) {
return async (c: any) => {
const { provider, model, maxTokens, ...context } = c.req.valid("json")
const target = resolveEditTarget(provider, model)
if (target.provider !== "inception") {
return c.json({ error: "Next Edit currently requires the Inception provider (mercury-edit-2)." }, 400 as any)
}
const token = await getProviderKey(Auth, target.provider)
if (!token) {
return c.json({ error: `Missing ${target.provider} provider API key` }, 401 as any)
}
// Build the Mercury sentinel prompt here so every client only sends
// structured editor context.
const content = buildMercuryEditPrompt(context as MercuryEditContext)
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(EDIT_TIMEOUT_MS)])
let response: Response
try {
response = await fetch(target.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
signal,
body: JSON.stringify({
model: target.model,
max_tokens: maxTokens ?? MAX_TOKENS_DEFAULT,
// Mercury rejects role:"system" on this endpoint — must be a single
// user message. See the integration's constants.ts for context.
messages: [{ role: "user", content }],
}),
})
} catch (err) {
if (err instanceof DOMException && err.name === "TimeoutError") {
return c.json({ error: "Edit request timed out" }, 504 as any)
}
if (signal.aborted) return c.json({ error: "Edit request canceled" }, 499 as any)
throw err
}
if (!response.ok) {
const text = await safeText(response)
return c.json({ error: `Edit request failed: ${response.status} ${text}` }, response.status as any)
}
const json = (await response.json()) as EditUpstreamResponse
const replyContent = json.choices?.[0]?.message?.content ?? ""
const body = extractFencedBody(replyContent)
return c.json({
content: body,
usage: json.usage
? {
prompt_tokens: json.usage.prompt_tokens,
completion_tokens: json.usage.completion_tokens,
}
: undefined,
})
}
}
async function safeText(res: Response): Promise<string> {
try {
return await res.text()
} catch {
return "<unreadable>"
}
}
// Re-export the target type for tests + the opencode handler
export type { EditTarget }
@@ -11,6 +11,7 @@ import { KILO_API_BASE, HEADER_FEATURE, HEADER_ORGANIZATIONID } from "../api/con
import { buildKiloHeaders } from "../headers.js"
import type { ImportDeps, DrizzleDb } from "../cloud-sessions.js"
import { fetchCloudSession, fetchCloudSessionForImport, importSessionToDb } from "../cloud-sessions.js"
import { createEditHandler } from "./edit.js"
import { createFimHandler } from "./fim.js"
import {
GatewayError,
@@ -112,6 +113,16 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
currentOrgId: z.string().nullable(),
})
const EditCompletionResponse = z.object({
content: z.string(),
usage: z
.object({
prompt_tokens: z.number().optional(),
completion_tokens: z.number().optional(),
})
.optional(),
})
const FimStreamChunk = z.object({
choices: z
.array(
@@ -325,6 +336,44 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
),
createFimHandler(Auth),
)
.post(
"/edit",
describeRoute({
summary: "Next Edit completion",
description:
"Proxy a Mercury-style Next Edit request. The client supplies structured editor " +
"context; the gateway assembles the sentinel-tagged prompt and forwards to the upstream edit endpoint.",
operationId: "kilo.edit",
responses: {
200: {
description: "Next Edit completion",
content: {
"application/json": {
schema: resolver(EditCompletionResponse),
},
},
},
...errors(400, 401),
},
}),
validator(
"json",
z.object({
provider: z.string().optional(),
model: z.string().optional(),
maxTokens: z.number().optional(),
currentFilePath: z.string(),
currentFileContent: z.string(),
cursorLine: z.number(),
cursorCharacter: z.number(),
editableRegionStartLine: z.number(),
editableRegionEndLine: z.number(),
recentlyViewedSnippets: z.array(z.object({ filepath: z.string(), content: z.string() })),
editDiffHistory: z.array(z.string()),
}),
),
createEditHandler(Auth),
)
.post(
"/audio/transcriptions",
describeRoute({
@@ -0,0 +1,92 @@
import { describe, expect, test } from "bun:test"
import {
buildMercuryEditPrompt,
currentFileContentBlock,
editDiffHistoryBlock,
recentlyViewedSnippetsBlock,
} from "../src/edit-prompt"
describe("recentlyViewedSnippetsBlock", () => {
test("wraps in open/close sentinels even when empty", () => {
const out = recentlyViewedSnippetsBlock([])
expect(out.startsWith("<|recently_viewed_code_snippets|>")).toBe(true)
expect(out.endsWith("<|/recently_viewed_code_snippets|>")).toBe(true)
})
test("emits one inner block per snippet with the file-path header", () => {
const out = recentlyViewedSnippetsBlock([
{ filepath: "src/a.ts", content: "const a = 1" },
{ filepath: "src/b.ts", content: "const b = 2" },
])
expect(out).toContain("code_snippet_file_path: src/a.ts")
expect(out).toContain("code_snippet_file_path: src/b.ts")
expect(out).toContain("const a = 1")
expect(out).toContain("const b = 2")
})
})
describe("currentFileContentBlock", () => {
test("inserts <|cursor|> at the right character and wraps the editable region", () => {
const file = ["function foo() {", " return 1", "}"].join("\n")
const out = currentFileContentBlock("src/foo.ts", file, 1, 1, 1, 2)
expect(out).toContain("<|current_file_content|>")
expect(out).toContain("<|/current_file_content|>")
expect(out).toContain("current_file_path: src/foo.ts")
expect(out).toContain(" <|cursor|>return 1")
const openIdx = out.indexOf("<|code_to_edit|>")
const lineIdx = out.indexOf("return 1")
const closeIdx = out.indexOf("<|/code_to_edit|>")
expect(openIdx).toBeGreaterThan(-1)
expect(closeIdx).toBeGreaterThan(openIdx)
expect(lineIdx).toBeGreaterThan(openIdx)
expect(lineIdx).toBeLessThan(closeIdx)
})
test("clamps an out-of-range cursor instead of throwing", () => {
const out = currentFileContentBlock("p.ts", "only-line", 0, 0, 0, 9999)
expect(out).toContain("only-line<|cursor|>")
})
})
describe("editDiffHistoryBlock", () => {
test("strips the createPatch index+separator lines from each diff", () => {
const fakeDiff = ["Index: foo.ts", "===", "@@ -1,1 +1,1 @@", "-old", "+new"].join("\n")
const out = editDiffHistoryBlock([fakeDiff])
expect(out).toContain("@@ -1,1 +1,1 @@")
expect(out).not.toContain("Index: foo.ts")
expect(out.startsWith("<|edit_diff_history|>")).toBe(true)
expect(out.endsWith("<|/edit_diff_history|>")).toBe(true)
})
test("separates multiple diffs with a blank line", () => {
const diff1 = ["Index: a.ts", "===", "@@ -1,1 +1,1 @@", "-a", "+aa"].join("\n")
const diff2 = ["Index: b.ts", "===", "@@ -2,1 +2,1 @@", "-b", "+bb"].join("\n")
const out = editDiffHistoryBlock([diff1, diff2])
const idx1 = out.indexOf("@@ -1,1 +1,1 @@")
const idx2 = out.indexOf("@@ -2,1 +2,1 @@")
expect(idx2).toBeGreaterThan(idx1)
expect(out.slice(idx1, idx2)).toContain("\n\n")
})
})
describe("buildMercuryEditPrompt", () => {
test("assembles the three blocks in order and ends with the NES token", () => {
const out = buildMercuryEditPrompt({
currentFilePath: "p.ts",
currentFileContent: "a\nb\nc",
cursorLine: 1,
cursorCharacter: 0,
editableRegionStartLine: 1,
editableRegionEndLine: 1,
recentlyViewedSnippets: [],
editDiffHistory: [],
})
const snippetsIdx = out.indexOf("<|recently_viewed_code_snippets|>")
const fileIdx = out.indexOf("<|current_file_content|>")
const diffIdx = out.indexOf("<|edit_diff_history|>")
expect(snippetsIdx).toBeGreaterThan(-1)
expect(fileIdx).toBeGreaterThan(snippetsIdx)
expect(diffIdx).toBeGreaterThan(fileIdx)
expect(out.endsWith("<|!@#IS_NEXT_EDIT!@#|>")).toBe(true)
})
})
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, test } from "bun:test"
import { extractFencedBody, resolveEditTarget } from "../src/edit"
describe("Edit target resolution", () => {
test("routes the Inception next-edit model to Inception's edit endpoint", () => {
expect(resolveEditTarget("inception", "mercury-next-edit")).toEqual({
provider: "inception",
model: "mercury-edit-2",
url: "https://api.inceptionlabs.ai/v1/edit/completions",
})
})
test("does NOT route the FIM Mercury model to the edit endpoint", () => {
// `mercury-edit-2` (kind: fim) must fall through to the kilo placeholder,
// not the edit endpoint — only `mercury-next-edit` (kind: edit) is NES.
expect(resolveEditTarget("inception", "mercury-edit-2").provider).toBe("kilo")
})
test("falls back to a kilo placeholder (no upstream) for non-edit models", () => {
expect(resolveEditTarget("kilo", "mistralai/codestral-2508")).toEqual({
provider: "kilo",
model: "mistralai/codestral-2508",
url: "",
})
expect(resolveEditTarget()).toMatchObject({ provider: "kilo", url: "" })
})
})
describe("extractFencedBody", () => {
test("extracts a plain triple-backtick fenced body", () => {
expect(extractFencedBody("```\nconst x = 1\n```")).toBe("const x = 1")
})
test("handles a language tag on the opening fence", () => {
expect(extractFencedBody("```typescript\nconst x = 1\n```")).toBe("const x = 1")
})
test("strips embedded <|code_to_edit|> sentinels", () => {
expect(extractFencedBody("```\n<|code_to_edit|>\nconst x = 2\n<|/code_to_edit|>\n```")).toBe("const x = 2")
})
test("returns the raw message when there is no fence", () => {
expect(extractFencedBody("just text, no fence")).toBe("just text, no fence")
})
test("returns the empty string for empty input", () => {
expect(extractFencedBody("")).toBe("")
})
test("suppresses a replacement when the closing fence is missing", () => {
expect(extractFencedBody("```\nconst x = 1\nconst y = ")).toBe("")
})
test("preserves internal blank lines and indentation", () => {
const body = "def f():\n if True:\n\n return 1"
expect(extractFencedBody("```python\n" + body + "\n```")).toBe(body)
})
})
+6 -5
View File
@@ -21,11 +21,12 @@ export const IndexingConfig = z
.object({
enabled: z.boolean().optional().describe("Enable codebase indexing"),
provider: z.enum(providers).optional().describe("Embedding provider to use for codebase indexing"),
model: z.string().optional().describe("Embedding model ID (uses provider default if omitted)"),
model: z.string().nullable().optional().describe("Embedding model ID (uses provider default if omitted)"),
dimension: z
.number()
.int()
.positive()
.nullable()
.optional()
.describe("Override embedding vector dimension (auto-detected from model if omitted)"),
vectorStore: z.enum(stores).optional().describe("Vector store backend (default: qdrant)"),
@@ -140,10 +141,10 @@ export const IndexingSchema = Schema.Struct({
provider: Schema.optional(Provider).annotate({
description: "Embedding provider to use for codebase indexing",
}),
model: Schema.optional(Schema.String).annotate({
model: Schema.optional(Schema.NullOr(Schema.String)).annotate({
description: "Embedding model ID (uses provider default if omitted)",
}),
dimension: Schema.optional(PositiveInt).annotate({
dimension: Schema.optional(Schema.NullOr(PositiveInt)).annotate({
description: "Override embedding vector dimension (auto-detected from model if omitted)",
}),
vectorStore: Schema.optional(Store).annotate({ description: "Vector store backend (default: qdrant)" }),
@@ -237,8 +238,8 @@ export function toIndexingConfigInput(cfg: IndexingConfig | undefined): Indexing
enabled: cfg?.enabled ?? false,
embedderProvider: provider,
vectorStoreProvider: cfg?.vectorStore,
modelId: cfg?.model,
modelDimension: cfg?.dimension,
modelId: cfg?.model ?? undefined,
modelDimension: cfg?.dimension ?? undefined,
lancedbVectorStoreDirectory: cfg?.lancedb?.directory,
qdrantUrl: cfg?.qdrant?.url,
qdrantApiKey: cfg?.qdrant?.apiKey,
@@ -84,10 +84,12 @@ val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
tasks.named("compileKotlin") {
dependsOn(fixGeneratedApi)
inputs.dir(generatedApi)
}
tasks.named("compileTestKotlin") {
dependsOn(fixGeneratedApi)
inputs.dir(generatedApi)
}
val cliDir = layout.buildDirectory.dir("generated/cli/cli")
@@ -4,6 +4,7 @@ package ai.kilocode.client.app
import ai.kilocode.log.ChatLogSummary
import ai.kilocode.rpc.KiloSessionRpcApi
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionListDto
import ai.kilocode.rpc.dto.ConfigUpdateDto
@@ -85,6 +86,11 @@ class KiloSessionService internal constructor(
}
}
internal fun activity(): Map<String, SessionActivityKind> =
statuses.value
.filterValues { it.type == "busy" }
.mapValues { SessionActivityKind.RUNNING }
suspend fun list(dir: String): SessionListDto {
val result = call { list(dir) }
_sessions.value = result.sessions
@@ -0,0 +1,17 @@
package ai.kilocode.client.plugin
import com.intellij.ide.util.PropertiesComponent
object KiloPluginSettings {
private const val AUTO_APPROVE_KEY = "kilo.session.autoApprove"
fun getAutoApprove(): Boolean = PropertiesComponent.getInstance().getBoolean(AUTO_APPROVE_KEY, false)
fun setAutoApprove(value: Boolean) {
PropertiesComponent.getInstance().setValue(AUTO_APPROVE_KEY, value.toString())
}
internal fun unsetAutoApprove() {
PropertiesComponent.getInstance().unsetValue(AUTO_APPROVE_KEY)
}
}
@@ -0,0 +1,32 @@
package ai.kilocode.client.session
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.UiStyle
import java.awt.Color
enum class SessionActivityKind {
RUNNING,
LOGIN_REQUIRED,
PERMISSION,
PLAN,
QUESTION,
;
fun label(): String = when (this) {
RUNNING -> KiloBundle.message("session.part.tool.running")
LOGIN_REQUIRED -> KiloBundle.message("history.badge.loginRequired")
PERMISSION -> KiloBundle.message("history.badge.permission")
PLAN -> KiloBundle.message("history.badge.plan")
QUESTION -> KiloBundle.message("history.badge.question")
}
fun bg(): Color = when (this) {
RUNNING -> UiStyle.Colors.runningBadgeBg()
LOGIN_REQUIRED, PERMISSION, PLAN, QUESTION -> UiStyle.Colors.activityBadgeBg()
}
fun fg(): Color = when (this) {
RUNNING -> UiStyle.Colors.runningBadgeFg()
LOGIN_REQUIRED, PERMISSION, PLAN, QUESTION -> UiStyle.Colors.activityBadgeFg()
}
}
@@ -14,6 +14,12 @@ interface SessionManager {
fun openSession(ref: SessionRef)
fun activity(): Map<String, SessionActivityKind> = emptyMap()
fun titles(): Map<String, String> = emptyMap()
fun activityChanged() {}
fun openSession(session: SessionDto) {
openSession(SessionRef.Local(session))
}
@@ -12,7 +12,9 @@ import com.intellij.openapi.application.ModalityState
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.registry.Registry
import com.intellij.openapi.wm.IdeFocusManager
import com.intellij.util.concurrency.annotations.RequiresEdt
import kotlinx.coroutines.cancel
import java.awt.BorderLayout
import javax.swing.JComponent
@@ -25,6 +27,7 @@ class SessionSidePanelManager(
service<SessionUiFactory>().create(project, workspace, manager, ref)
},
private val resolve: (String) -> Workspace = { dir -> service<KiloWorkspaceService>().workspace(dir) },
private val status: () -> Map<String, SessionActivityKind> = { project.service<KiloSessionService>().activity() },
private val history: ((Disposable, (SessionRef) -> Unit, (String) -> Unit) -> JComponent)? = null,
) : SessionManager, Disposable {
val component: JPanel = object : JPanel(BorderLayout()), DataProvider {
@@ -62,6 +65,30 @@ class SessionSidePanelManager(
show(ui)
}
@RequiresEdt
override fun activity(): Map<String, SessionActivityKind> {
val base = status()
val live = all.mapNotNull { ui ->
val id = ui.id ?: return@mapNotNull null
val kind = ui.activityKind() ?: return@mapNotNull null
id to kind
}.toMap()
return base + live
}
@RequiresEdt
override fun titles(): Map<String, String> = all.mapNotNull { ui ->
val id = ui.id ?: return@mapNotNull null
val title = ui.title() ?: return@mapNotNull null
id to title
}.toMap()
@RequiresEdt
override fun activityChanged() {
(panel as? HistoryPanel)?.syncActivity()
current?.syncActivity()
}
private fun create(ref: SessionRef): SessionUi {
val workspace = when (ref) {
is SessionRef.Local -> ref.session?.directory?.let(resolve) ?: root
@@ -76,8 +103,9 @@ class SessionSidePanelManager(
}
override fun showHistory() {
register(current)
release(current)
val active = current
register(active)
release(active)
val cached = panel
val view = cached ?: createHistory().also { panel = it }
if (cached != null && view is HistoryPanel) view.refresh()
@@ -128,11 +156,7 @@ class SessionSidePanelManager(
private fun removeSession(id: String) {
val ui = opened.remove(id) ?: return
opened.entries.removeIf { it.value === ui }
all.remove(ui)
if (current === ui) current = null
if (latest === ui) latest = null
Disposer.dispose(ui)
disposeUi(ui)
}
private fun show(ui: SessionUi) {
@@ -155,14 +179,31 @@ class SessionSidePanelManager(
private fun release(ui: SessionUi?) {
if (ui == null) return
if (ui.cacheKey != null) {
if (ui.cacheKey == null) {
disposeUi(ui)
return
}
if (!disposeInactiveUi()) {
register(ui)
return
}
if (ui.canDisposeInactive()) {
disposeUi(ui)
return
}
register(ui)
}
private fun disposeUi(ui: SessionUi) {
opened.entries.removeIf { it.value === ui }
all.remove(ui)
if (current === ui) current = null
if (latest === ui) latest = null
Disposer.dispose(ui)
}
private fun disposeInactiveUi() = Registry.`is`("kilo.session.inactive.dispose", false)
override fun dispose() {
val items = all.toList()
opened.clear()
@@ -36,6 +36,7 @@ import ai.kilocode.client.ui.layout.Stack
import ai.kilocode.log.ChatLogSummary
import com.intellij.util.ui.JBUI
import ai.kilocode.log.KiloLog
import com.intellij.ide.BrowserUtil
import com.intellij.ide.ui.LafManagerListener
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
@@ -130,6 +131,7 @@ class SessionUi(
private lateinit var prompt: PromptPanel
private lateinit var load: LoadingPanel
private lateinit var migrationOverlay: MigrationOverlayPanel
private var empty: EmptySessionPanel? = null
private var modalFocus: (() -> JComponent)? = null
private var style = SessionEditorStyle.current()
@@ -162,6 +164,31 @@ class SessionUi(
internal fun currentStyle() = style
@RequiresEdt
internal fun canDisposeInactive(): Boolean = controller.model.state is SessionState.Idle
@RequiresEdt
internal fun activityKind(): SessionActivityKind? = when (val state = controller.model.state) {
is SessionState.Idle,
is SessionState.Loading,
is SessionState.Busy,
is SessionState.Retry,
is SessionState.Offline,
is SessionState.Error -> null
is SessionState.LoginRequired -> SessionActivityKind.LOGIN_REQUIRED
is SessionState.AwaitingPermission -> SessionActivityKind.PERMISSION
is SessionState.AwaitingQuestion ->
SessionActivityKind.PLAN.takeIf { state.question.items.any { it.planFollowup() } } ?: SessionActivityKind.QUESTION
}
@RequiresEdt
internal fun title(): String? = controller.model.session?.title?.takeIf { it.isNotBlank() }
@RequiresEdt
internal fun syncActivity() {
empty?.syncActivity()
}
val defaultFocusedComponent: JComponent get() {
modalFocus?.invoke()?.let { return it }
return prompt.defaultFocusedComponent
@@ -216,13 +243,14 @@ class SessionUi(
project = project,
reply = { id, dto, opts -> controller.replyQuestion(id, dto, opts) },
reject = { id -> controller.rejectQuestion(id) },
scroll = { scroll.followBottom(true) },
follow = { scroll.following() },
scroll = { scroll.followBottom(it) },
)
permission = PermissionView(
reply = { id, dto -> controller.replyPermission(id, dto) },
)
login = LoginRequiredView(openProfile = { controller.openProfile() }, dismiss = { controller.dismissLoginRequired() })
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile)
messageBody = SessionMessageListPanel(controller.model, this, question, permission, login, ::openFile, ::openUrl)
header = SessionHeaderPanel(controller, this)
scroll = SessionScroll(root, sessionContent, messageBody, blankBody)
@@ -246,6 +274,12 @@ class SessionUi(
prompt.model.onSelect = { item -> controller.selectModel(item.provider, item.id) }
prompt.reasoning.onSelect = { item -> controller.selectVariant(item.id) }
prompt.onReset = { controller.clearModelOverride() }
prompt.onChange = { scroll.followTail() }
prompt.onAutoApproveToggle = { value ->
controller.setAutoApprove(value)
prompt.setAutoApprove(controller.autoApprove)
}
prompt.setAutoApprove(controller.autoApprove)
prompt.model.favorites = { app.favorites.value }
prompt.model.onFavoriteToggle = { item -> app.toggleModelFavorite(item.provider, item.id) }
@@ -281,15 +315,25 @@ class SessionUi(
}
is SessionControllerEvent.ViewChanged.ShowProgress -> {
empty = null
scroll.show(progressBody)
}
is SessionControllerEvent.ViewChanged.ShowRecents -> {
val panel = EmptySessionPanel(this, controller, event.recents) { manager?.showHistory() }
val panel = EmptySessionPanel(
this,
controller,
event.recents,
history = { manager?.showHistory() },
activity = { manager?.activity() ?: sessions.activity() },
titles = { manager?.titles().orEmpty() },
)
empty = panel
scroll.show(panel.view)
}
is SessionControllerEvent.ViewChanged.ShowSession -> {
empty = null
scroll.show(messageBody)
}
@@ -311,6 +355,8 @@ class SessionUi(
when (event) {
is SessionModelEvent.StateChanged -> onStateChanged(event.state)
is SessionModelEvent.SessionUpdated -> onSessionUpdated()
is SessionModelEvent.TurnAdded,
is SessionModelEvent.TurnUpdated,
is SessionModelEvent.ContentAdded,
@@ -324,7 +370,6 @@ class SessionUi(
is SessionModelEvent.ContentRemoved,
is SessionModelEvent.DiffUpdated,
is SessionModelEvent.TodosUpdated,
is SessionModelEvent.SessionUpdated,
is SessionModelEvent.HeaderUpdated,
is SessionModelEvent.Compacted,
is SessionModelEvent.Cleared -> Unit
@@ -414,8 +459,10 @@ class SessionUi(
val model = controller.model.model ?: "none"
"${ChatLogSummary.prompt(text)} agent=$agent model=$model ready=${controller.ready}"
}
controller.prompt(text)
prompt.clear()
val follow = scroll.atBottom()
controller.prompt(text)
scroll.followBottom(follow)
}
private fun openFile(path: String) {
@@ -424,13 +471,23 @@ class SessionUi(
}
}
private fun openUrl(url: String) {
BrowserUtil.browse(url)
}
private fun onStateChanged(state: SessionState) {
prompt.setBusy(state.isBusy())
load.setState(state)
scroll.setQuestionPending(questionPending(state))
scroll.show(body(state))
manager?.activityChanged()
refresh()
}
private fun onSessionUpdated() {
manager?.activityChanged()
}
private fun refresh() {
scroll.refresh()
root.revalidate()
@@ -460,3 +517,11 @@ class SessionUi(
}
private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() }
private fun questionPending(state: SessionState): Boolean {
if (state !is SessionState.AwaitingQuestion) return false
return state.question.items.none { it.planFollowup() }
}
private fun ai.kilocode.client.session.model.QuestionItem.planFollowup() =
questionKey == "plan.followup.question" || headerKey == "plan.followup.header"
@@ -18,6 +18,7 @@ import ai.kilocode.client.session.model.Question
import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.QuestionOption
import ai.kilocode.client.session.model.ToolCallRef
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.session.SessionRef
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.ConfigWarningDto
@@ -112,13 +113,15 @@ class SessionController(
flushMs,
::handle,
condense,
ref != null
ref != null,
::handleHidden,
) { sid ?: ref?.key ?: "pending" }
private var disposed = false
private var partType: String? = null
private var tool: String? = null
private var eventJob: Job? = null
private var drainJob: Job? = null
private val childJobs: MutableMap<String, Job> = mutableMapOf()
private val childIds: MutableSet<String> = mutableSetOf()
private var sessionLoadState: SessionLoadState = SessionLoadState.Idle
@@ -140,6 +143,7 @@ class SessionController(
private var modelTime: Double? = null
val ready: Boolean get() = model.isReady()
val autoApprove: Boolean get() = KiloPluginSettings.getAutoApprove()
internal val blank: Boolean get() = ref == null && model.isEmpty() && !model.showSession
internal val id: String? get() = sid
internal val refKey: String? get() = ref?.key
@@ -235,6 +239,24 @@ class SessionController(
}
}
fun setAutoApprove(value: Boolean) {
assertEdt()
KiloPluginSettings.setAutoApprove(value)
if (!value) {
drainJob?.cancel()
drainJob = null
return
}
val current = model.state
val skip = if (current is SessionState.AwaitingPermission) {
approve(current.permission)
setOf(current.permission.id)
} else {
emptySet()
}
drainAutoApprove(skip)
}
fun compact() {
assertEdt()
val id = sid ?: return
@@ -357,6 +379,77 @@ class SessionController(
}
}
private fun approve(request: PermissionRequestDto) {
approve(request.id) { toPermission(request) }
}
private fun approve(permission: Permission) {
approve(permission.id) { permission }
}
private fun approve(id: String, restore: () -> Permission) {
assertEdt()
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id" }
cs.launch {
try {
if (!autoApprove) {
edt {
if (disposed) return@edt
model.setState(SessionState.AwaitingPermission(restore()))
}
return@launch
}
edt {
if (disposed) return@edt
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
}
sessions.replyPermission(id, directory, PermissionReplyDto("once"))
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id ok=true" }
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
edt {
if (disposed) return@edt
model.setState(SessionState.AwaitingPermission(restore().copy(
state = PermissionRequestState.ERROR,
message = e.message ?: KiloBundle.message("session.permission.error"),
)))
}
}
}
}
private fun drainAutoApprove(skip: Set<String> = emptySet()) {
val id = sid ?: return
val ids = (childIds + id).toSet()
drainJob?.cancel()
drainJob = cs.launch {
try {
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID in ids && it.id !in skip }
val count = replyAll(permissions)
if (count == 0) return@launch
runEdt {
if (disposed) return@runEdt
val current = model.state
if (current is SessionState.AwaitingPermission && current.permission.sessionId in ids) {
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
}
}
} catch (e: Exception) {
LOG.warn("${ChatLogSummary.sid(id)} kind=permission-auto-drain dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e)
}
}
}
private suspend fun replyAll(permissions: List<PermissionRequestDto>): Int {
var count = 0
for (request in permissions) {
if (!autoApprove) return count
sessions.replyPermission(request.id, directory, PermissionReplyDto("once"))
count++
}
return count
}
private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) {
assertEdt()
val current = model.state
@@ -657,6 +750,10 @@ class SessionController(
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child }
if (permissions.isEmpty()) return
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" }
if (autoApprove) {
replyAll(permissions)
return
}
val last = toPermission(permissions.last())
runEdt {
if (disposed) return@runEdt
@@ -675,6 +772,17 @@ class SessionController(
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id }
val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id }
val status = sessions.statuses.value[id]
if (permissions.isNotEmpty() && autoApprove) {
val count = replyAll(permissions)
if (count > 0) {
runEdt {
if (disposed) return@runEdt
if (sid != id) return@runEdt
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
}
return
}
}
val branch = when {
permissions.isNotEmpty() -> "permission"
questions.isNotEmpty() -> "question"
@@ -777,16 +885,7 @@ class SessionController(
is ChatEventDto.SessionCreated -> adoptFollowup(event.info)
is ChatEventDto.Error -> {
partType = null
tool = null
if (isPaidModelAuthRequired(event.error)) {
loginRetry = retryPrompt()
showSession()
model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description")))
} else {
val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown")
model.setState(SessionState.Error(msg, event.error?.type))
}
error(event, true)
}
is ChatEventDto.MessageRemoved -> {
@@ -794,75 +893,33 @@ class SessionController(
}
is ChatEventDto.PermissionAsked -> {
val perm = toPermission(event.request)
model.setState(SessionState.AwaitingPermission(perm))
asked(event)
}
is ChatEventDto.PermissionReplied -> {
val current = model.state
if (current is SessionState.AwaitingPermission && current.permission.id == event.requestID) {
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
}
replied(event)
}
is ChatEventDto.QuestionAsked -> {
model.setState(SessionState.AwaitingQuestion(toQuestion(event.request)))
asked(event)
}
is ChatEventDto.QuestionReplied -> {
val current = model.state
if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) {
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
}
replied(event)
}
is ChatEventDto.QuestionRejected -> {
val current = model.state
if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) {
model.setState(SessionState.Idle)
}
rejected(event)
}
is ChatEventDto.SessionStatusChanged -> {
val state = when (event.status.type) {
"idle" -> {
val current = model.state
if (current is SessionState.LoginRequired) return
SessionState.Idle
}
"busy" -> {
val current = model.state
if (current is SessionState.Idle || current is SessionState.Error)
SessionState.Busy(KiloBundle.message("session.status.considering"))
else return // already in a more specific phase
}
"retry" -> SessionState.Retry(
message = event.status.message ?: "",
attempt = event.status.attempt ?: 0,
next = event.status.next ?: 0L,
)
"offline" -> SessionState.Offline(
message = event.status.message ?: "",
requestId = event.status.requestID ?: "",
)
else -> return
}
model.setState(state)
status(event.status)
}
is ChatEventDto.SessionUpdated -> model.setSession(event.session)
is ChatEventDto.SessionIdle -> {
// Treat session.idle as an explicit signal to return to Idle.
// Only apply if we're not in a more specific non-terminal state.
val current = model.state
if (current !is SessionState.Error
&& current !is SessionState.AwaitingPermission
&& current !is SessionState.AwaitingQuestion
&& current !is SessionState.LoginRequired
) {
model.setState(SessionState.Idle)
}
idle()
}
is ChatEventDto.SessionCompacted -> model.markCompacted()
@@ -871,6 +928,128 @@ class SessionController(
}
}
private fun handleHidden(event: ChatEventDto): Boolean = when (event) {
is ChatEventDto.Error,
is ChatEventDto.PermissionAsked,
is ChatEventDto.PermissionReplied,
is ChatEventDto.QuestionAsked,
is ChatEventDto.QuestionReplied,
is ChatEventDto.QuestionRejected,
is ChatEventDto.SessionStatusChanged,
is ChatEventDto.SessionUpdated,
is ChatEventDto.SessionIdle -> {
edt {
if (disposed) return@edt
updateModel { handleMetadata(event) }
}
true
}
else -> false
}
private fun handleMetadata(event: ChatEventDto) {
LOG.debug { ChatLogSummary.event(event) }
when (event) {
is ChatEventDto.Error -> error(event, false)
is ChatEventDto.PermissionAsked -> asked(event)
is ChatEventDto.PermissionReplied -> replied(event)
is ChatEventDto.QuestionAsked -> asked(event)
is ChatEventDto.QuestionReplied -> replied(event)
is ChatEventDto.QuestionRejected -> rejected(event)
is ChatEventDto.SessionStatusChanged -> status(event.status)
is ChatEventDto.SessionUpdated -> model.setSession(event.session)
is ChatEventDto.SessionIdle -> idle()
else -> Unit
}
}
private fun error(event: ChatEventDto.Error, reveal: Boolean) {
partType = null
tool = null
if (isPaidModelAuthRequired(event.error)) {
loginRetry = retryPrompt()
if (reveal) showSession()
model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description")))
return
}
val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown")
model.setState(SessionState.Error(msg, event.error?.type))
}
private fun asked(event: ChatEventDto.PermissionAsked) {
if (autoApprove) {
approve(event.request)
return
}
val perm = toPermission(event.request)
model.setState(SessionState.AwaitingPermission(perm))
}
private fun replied(event: ChatEventDto.PermissionReplied) {
val current = model.state
if (current is SessionState.AwaitingPermission && current.permission.id == event.requestID) {
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
}
}
private fun asked(event: ChatEventDto.QuestionAsked) {
model.setState(SessionState.AwaitingQuestion(toQuestion(event.request)))
}
private fun replied(event: ChatEventDto.QuestionReplied) {
val current = model.state
if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) {
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
}
}
private fun rejected(event: ChatEventDto.QuestionRejected) {
val current = model.state
if (current is SessionState.AwaitingQuestion && current.question.id == event.requestID) {
model.setState(SessionState.Idle)
}
}
private fun status(dto: SessionStatusDto) {
val state = when (dto.type) {
"idle" -> {
val current = model.state
if (current is SessionState.LoginRequired) return
SessionState.Idle
}
"busy" -> {
val current = model.state
if (current is SessionState.Idle || current is SessionState.Error)
SessionState.Busy(KiloBundle.message("session.status.considering"))
else return // already in a more specific phase
}
"retry" -> SessionState.Retry(
message = dto.message ?: "",
attempt = dto.attempt ?: 0,
next = dto.next ?: 0L,
)
"offline" -> SessionState.Offline(
message = dto.message ?: "",
requestId = dto.requestID ?: "",
)
else -> return
}
model.setState(state)
}
private fun idle() {
// Treat session.idle as an explicit signal to return to Idle.
// Only apply if we're not in a more specific non-terminal state.
val current = model.state
if (current !is SessionState.Error
&& current !is SessionState.AwaitingPermission
&& current !is SessionState.AwaitingQuestion
&& current !is SessionState.LoginRequired
) {
model.setState(SessionState.Idle)
}
}
private fun retryPrompt(): PromptDto? {
val msg = model.messages().lastOrNull { it.info.role == "user" } ?: return null
return PromptDto(
@@ -1372,6 +1551,7 @@ class SessionController(
disposed = true
connectionDelay.dispose()
eventJob?.cancel()
drainJob?.cancel()
childJobs.values.forEach { it.cancel() }
childJobs.clear()
childIds.clear()
@@ -23,6 +23,7 @@ internal class SessionUpdateQueue(
private val fire: (List<ChatEventDto>) -> Unit,
private val condense: Boolean = true,
hold: Boolean,
private val hidden: (ChatEventDto) -> Boolean = { false },
private val sid: () -> String,
) : Disposable {
companion object {
@@ -59,6 +60,10 @@ internal class SessionUpdateQueue(
}
fun enqueue(event: ChatEventDto) {
if (!visible.get() && hidden(event)) {
LOG.debug { "${ChatLogSummary.sid(sid())} enqueue hidden=true visible=false" }
return
}
val size = synchronized(lock) {
pending.add(event)
pending.size
@@ -0,0 +1,13 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.session.SessionActivityKind
internal data class HistoryActivitySnapshot(
val activity: Map<String, SessionActivityKind> = emptyMap(),
val titles: Map<String, String> = emptyMap(),
) {
fun changed(next: HistoryActivitySnapshot): Set<String> =
(activity.keys + next.activity.keys + titles.keys + next.titles.keys).filterTo(mutableSetOf()) {
activity[it] != next.activity[it] || titles[it] != next.titles[it]
}
}
@@ -60,6 +60,8 @@ class HistoryController(
reloadCloud()
}
internal fun activity() = sessions.activity()
fun reloadLocal() {
edt { local.start() }
cs.launch {
@@ -1,6 +1,8 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.GroupHeaderSeparator
@@ -11,8 +13,10 @@ import com.intellij.util.ui.EmptyIcon
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.BorderLayout
import java.awt.FlowLayout
import java.awt.Point
import java.awt.Rectangle
import java.awt.Component
import javax.swing.Icon
import javax.swing.JList
import javax.swing.JPanel
@@ -24,6 +28,8 @@ private const val DELETE_AREA_WIDTH = 32
internal open class HistoryRenderer<T : HistoryItem>(
private val model: HistoryModel<T>,
private val deletable: Boolean,
private val activity: () -> Map<String, SessionActivityKind>,
private val titles: () -> Map<String, String> = { emptyMap() },
) : JPanel(BorderLayout()), ListCellRenderer<T> {
companion object {
private val icon: Icon = AllIcons.Actions.GC
@@ -55,14 +61,19 @@ internal open class HistoryRenderer<T : HistoryItem>(
add(sep, BorderLayout.NORTH)
}
private val title = SimpleColoredComponent()
private val badge = BadgeLabel()
private val time = JBLabel()
private val del = JBLabel().apply {
horizontalAlignment = SwingConstants.CENTER
verticalAlignment = SwingConstants.CENTER
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
add(title)
add(badge)
}
private val main = JPanel(BorderLayout()).apply {
add(title, BorderLayout.CENTER)
add(head, BorderLayout.CENTER)
add(time, BorderLayout.EAST)
}
private val row = JPanel(BorderLayout()).apply {
@@ -70,12 +81,13 @@ internal open class HistoryRenderer<T : HistoryItem>(
if (deletable) add(del, BorderLayout.EAST)
}
private val wrap = PickerRow()
private var text = ""
init {
isOpaque = true
top.isOpaque = true
row.border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg())
UiStyle.Components.transparent(row, main, title, time, del)
UiStyle.Components.transparent(row, main, head, title, badge, time, del)
wrap.setContent(row)
add(top, BorderLayout.NORTH)
add(wrap, BorderLayout.CENTER)
@@ -100,19 +112,50 @@ internal open class HistoryRenderer<T : HistoryItem>(
top.isVisible = sep.caption != null
title.clear()
text = value?.let { titles()[it.id] ?: title(it) }.orEmpty()
title.append(
value?.let(::title).orEmpty(),
text,
SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg),
)
time.text = value?.let(HistoryTime::relative).orEmpty()
time.foreground = weak
badge.setKind(value?.id?.let(activity()::get))
if (deletable) del.icon = if (selected) icon else empty
top.invalidate()
return this
}
internal fun runningVisible() = badge.isVisible
internal fun badgeText() = badge.kind?.label()
internal fun titleText() = text
private class BadgeLabel : JBLabel() {
var kind: SessionActivityKind? = null
private set
init {
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
alignmentY = Component.CENTER_ALIGNMENT
}
fun setKind(value: SessionActivityKind?) {
kind = value
isVisible = value != null
icon = value?.let { FilledBadgeIcon(it.label(), it.bg(), it.fg()) }
}
}
}
internal class LocalHistoryRenderer(model: HistoryModel<LocalHistoryItem>) : HistoryRenderer<LocalHistoryItem>(model, deletable = true)
internal class LocalHistoryRenderer(
model: HistoryModel<LocalHistoryItem>,
activity: () -> Map<String, SessionActivityKind> = { emptyMap() },
titles: () -> Map<String, String> = { emptyMap() },
) : HistoryRenderer<LocalHistoryItem>(model, deletable = true, activity, titles)
internal class CloudHistoryRenderer(model: HistoryModel<CloudHistoryItem>) : HistoryRenderer<CloudHistoryItem>(model, deletable = false)
internal class CloudHistoryRenderer(
model: HistoryModel<CloudHistoryItem>,
activity: () -> Map<String, SessionActivityKind> = { emptyMap() },
) : HistoryRenderer<CloudHistoryItem>(model, deletable = false, activity)
@@ -16,6 +16,7 @@ import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.ActionPlaces
import com.intellij.openapi.actionSystem.DataProvider
import com.intellij.openapi.util.Disposer
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.ui.DocumentAdapter
import com.intellij.ui.PopupHandler
import com.intellij.ui.SearchTextField
@@ -48,6 +49,7 @@ import javax.swing.JList
import javax.swing.KeyStroke
import javax.swing.ListSelectionModel
import javax.swing.SwingUtilities
import javax.swing.Timer
import javax.swing.event.DocumentEvent
import javax.swing.event.ListDataEvent
import javax.swing.event.ListDataListener
@@ -60,6 +62,7 @@ class HistoryPanel(
) : BorderLayoutPanel(), Disposable, DataProvider {
private val localSearch = search(controller.local)
private val cloudSearch = search(controller.cloud)
private var snapshot = HistoryActivitySnapshot()
private val localList = localList()
private val cloudList = cloudList()
private val more = LoadMoreButton()
@@ -80,6 +83,7 @@ class HistoryPanel(
.setText(KiloBundle.message("history.tab.cloud"))
.setForeSideComponent(back())
private var stale = false
private val timer = Timer(ACTIVITY_MS) { syncActivity() }
private val tabs: JBTabs = JBTabsFactory.createTabs(null, this).apply {
presentation.setSingleRow(true)
presentation.setTabsPosition(JBTabsPosition.top)
@@ -105,11 +109,14 @@ class HistoryPanel(
}
addHierarchyListener { e ->
if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener
if (isShowing && stale) {
refresh()
if (isShowing) {
syncActivity()
timer.start()
if (stale) refresh()
return@addHierarchyListener
}
if (!isShowing) stale = true
timer.stop()
stale = true
}
body.add(load, CARD_LOAD)
body.add(tabs.component, CARD_TABS)
@@ -215,7 +222,7 @@ class HistoryPanel(
private fun localList() = JBList(controller.local).apply {
selectionMode = ListSelectionModel.MULTIPLE_INTERVAL_SELECTION
isFocusable = true
cellRenderer = LocalHistoryRenderer(controller.local)
cellRenderer = LocalHistoryRenderer(controller.local, { snapshot.activity }, { snapshot.titles })
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
emptyText.text = KiloBundle.message("history.empty")
addMouseListener(object : MouseAdapter() {
@@ -243,7 +250,7 @@ class HistoryPanel(
private fun cloudList() = JBList(controller.cloud).apply {
selectionMode = ListSelectionModel.SINGLE_SELECTION
isFocusable = true
cellRenderer = CloudHistoryRenderer(controller.cloud)
cellRenderer = CloudHistoryRenderer(controller.cloud) { snapshot.activity }
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
emptyText.text = KiloBundle.message("history.empty")
addMouseListener(object : MouseAdapter() {
@@ -284,6 +291,26 @@ class HistoryPanel(
repaint()
}
@RequiresEdt
internal fun syncActivity() {
val next = HistoryActivitySnapshot(
activity = manager?.activity() ?: controller.activity(),
titles = manager?.titles().orEmpty(),
)
val changed = snapshot.changed(next)
snapshot = next
repaintRows(localList, controller.local, changed)
repaintRows(cloudList, controller.cloud, changed)
}
private fun <T : HistoryItem> repaintRows(list: JBList<T>, model: HistoryModel<T>, ids: Set<String>) {
if (ids.isEmpty()) return
model.visibleItems.forEachIndexed { index, item ->
if (item.id !in ids) return@forEachIndexed
list.getCellBounds(index, index)?.let(list::repaint)
}
}
private fun loading(): Boolean {
if (controller.local.loaded || controller.cloud.loaded) return false
return controller.local.loading || controller.cloud.loading
@@ -418,6 +445,34 @@ class HistoryPanel(
return items.indices.mapNotNull { HistoryRenderer.section(items, it) }
}
internal fun runningBadgeVisible(index: Int): Boolean {
return badgeText(index) != null
}
internal fun badgeText(index: Int): String? {
val list = activeList()
val item = list.model.getElementAt(index) ?: return null
@Suppress("UNCHECKED_CAST")
val renderer = list.cellRenderer as javax.swing.ListCellRenderer<HistoryItem>
@Suppress("UNCHECKED_CAST")
val typed = list as JList<HistoryItem>
val view = renderer.getListCellRendererComponent(typed, item, index, false, false)
if (view !is HistoryRenderer<*>) return null
return view.badgeText()
}
internal fun titleText(index: Int): String? {
val list = activeList()
val item = list.model.getElementAt(index) ?: return null
@Suppress("UNCHECKED_CAST")
val renderer = list.cellRenderer as javax.swing.ListCellRenderer<HistoryItem>
@Suppress("UNCHECKED_CAST")
val typed = list as JList<HistoryItem>
val view = renderer.getListCellRendererComponent(typed, item, index, false, false)
if (view !is HistoryRenderer<*>) return null
return view.titleText()
}
internal fun repoOnlyVisible() = repoOnly.isVisible
internal fun repoOnlySelected() = repoOnly.isSelected
@@ -445,6 +500,7 @@ class HistoryPanel(
}
override fun dispose() {
timer.stop()
controller.onRepoOnlyChanged = null
}
@@ -495,5 +551,6 @@ class HistoryPanel(
private companion object {
const val CARD_LOAD = "load"
const val CARD_TABS = "tabs"
const val ACTIVITY_MS = 3_000
}
}
@@ -35,7 +35,7 @@ class SessionModel {
companion object {
/** Part types that are internal server markers and must never be stored or rendered. */
val SILENT_PART_TYPES = setOf("step-start")
val SILENT_PART_TYPES = setOf("step-start", "patch")
}
private val entries = LinkedHashMap<String, Message>()
@@ -1,17 +1,31 @@
package ai.kilocode.client.session.scroll
import ai.kilocode.client.ui.colorizeIfPossible
import ai.kilocode.client.ui.UiStyle
import com.intellij.openapi.util.IconLoader
import com.intellij.util.ui.JBUI
import java.awt.Color
import javax.swing.Icon
internal object ScrollButtonIcon {
private val icon = IconLoader.getIcon("/icons/scroll-bottom.svg", ScrollButtonIcon::class.java)
private val bottom = IconLoader.getIcon("/icons/scroll-bottom.svg", ScrollButtonIcon::class.java)
private val prompt = IconLoader.getIcon("/icons/scroll-question.svg", ScrollButtonIcon::class.java)
fun create(): Icon = icon.colorizeIfPossible(
fillColor = JBUI.CurrentTheme.Button.defaultButtonColorStart(),
borderColor = JBUI.CurrentTheme.Button.defaultButtonForeground(),
fillId = "ScrollButton.Background",
strokeId = "ScrollButton.Foreground",
)
fun create(question: Boolean = false): Icon {
if (question) {
return prompt.colorizeIfPossible(
fillColor = UiStyle.Colors.warningLabelForeground(),
borderColor = Color.WHITE,
fillId = "ScrollQuestion.Background",
strokeId = "ScrollQuestion.Foreground",
)
}
return bottom.colorizeIfPossible(
fillColor = JBUI.CurrentTheme.Button.defaultButtonColorStart(),
borderColor = JBUI.CurrentTheme.Button.defaultButtonForeground(),
fillId = "ScrollButton.Background",
strokeId = "ScrollButton.Foreground",
)
}
}
@@ -9,15 +9,18 @@ import ai.kilocode.client.ui.UiStyle
import com.intellij.openapi.application.ApplicationManager
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.ui.JBUI
import java.awt.Cursor
import java.awt.Point
import java.awt.Rectangle
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseWheelListener
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JScrollBar
import javax.swing.SwingUtilities
internal class SessionScroll(
private val root: SessionRootPanel,
@@ -47,6 +50,9 @@ internal class SessionScroll(
private var opening = false
private var stable = -1
private var seq = 0
private var user = false
private var value = 0
private var question = false
init {
jump = JBLabel(ScrollButtonIcon.create()).apply {
@@ -59,6 +65,12 @@ internal class SessionScroll(
}
})
}
component.addMouseWheelListener(MouseWheelListener { user = true })
component.verticalScrollBar.addMouseListener(object : MouseAdapter() {
override fun mousePressed(e: MouseEvent) {
user = true
}
})
component.verticalScrollBar.addAdjustmentListener { onScroll() }
root.addOverlay(jump) { _, child ->
val size = child.preferredSize
@@ -72,6 +84,7 @@ internal class SessionScroll(
}
}
@RequiresEdt
fun show(panel: JPanel) {
if (component.viewport.view === panel) return
(panel as? SessionEditorStyleTarget)?.applyStyle(style)
@@ -80,6 +93,7 @@ internal class SessionScroll(
updateJump()
}
@RequiresEdt
fun atBottom(): Boolean {
val bar = component.verticalScrollBar
return when {
@@ -89,26 +103,44 @@ internal class SessionScroll(
}
}
@RequiresEdt
fun followBottom(follow: Boolean) {
if (!follow) {
seq++
updateJump()
return
}
user = false
tail = true
stable = -1
auto = true
show(messages)
auto = false
val id = ++seq
if (SwingUtilities.isEventDispatchThread()) {
followPass(id, FOLLOW_PASSES)
return
}
ApplicationManager.getApplication().invokeLater {
followPass(id, FOLLOW_PASSES)
}
}
@RequiresEdt
fun followTail() {
followBottom(component.viewport.view === messages && tail)
}
@RequiresEdt
fun following(): Boolean {
return component.viewport.view === messages && tail
}
@RequiresEdt
fun openBottom(done: () -> Unit) {
opening = true
stable = -1
user = false
tail = true
auto = true
show(messages)
@@ -119,22 +151,39 @@ internal class SessionScroll(
}
}
@RequiresEdt
fun refresh() {
updateJump()
}
@RequiresEdt
fun setQuestionPending(value: Boolean) {
if (question == value) return
question = value
syncIcon()
}
@RequiresEdt
fun applyStyle(style: SessionEditorStyle) {
this.style = style
jump.icon = ScrollButtonIcon.create()
syncIcon()
messages.applyStyle(style)
val view = component.viewport.view
if (view !== messages) (view as? SessionEditorStyleTarget)?.applyStyle(style)
refresh()
}
@RequiresEdt
private fun syncIcon() {
jump.icon = ScrollButtonIcon.create(question)
jump.toolTipText = KiloBundle.message(if (question) "session.scroll.question" else "session.scroll.bottom")
}
@RequiresEdt
private fun jumpBottom() {
opening = false
stable = -1
user = false
tail = true
auto = true
show(messages)
@@ -145,6 +194,7 @@ internal class SessionScroll(
}
}
@RequiresEdt
private fun followPass(id: Int, remaining: Int) {
if (id != seq || !tail) return
auto = true
@@ -156,6 +206,7 @@ internal class SessionScroll(
} finally {
auto = false
}
syncValue()
if (remaining <= 0) {
stable = -1
return
@@ -168,6 +219,7 @@ internal class SessionScroll(
}
}
@RequiresEdt
private fun openPass(id: Int, remaining: Int, done: () -> Unit) {
if (id != seq) {
opening = false
@@ -184,6 +236,7 @@ internal class SessionScroll(
} finally {
auto = false
}
syncValue()
if (remaining <= 0) {
opening = false
stable = -1
@@ -198,10 +251,12 @@ internal class SessionScroll(
}
}
@RequiresEdt
private fun layoutScroll() {
root.validate()
}
@RequiresEdt
private fun scrollToBottom() {
val view = component.viewport.view ?: return
val y = (view.height - component.viewport.extentSize.height).coerceAtLeast(0)
@@ -211,23 +266,41 @@ internal class SessionScroll(
bar.value = bottom()
}
@RequiresEdt
private fun bottom(): Int {
val bar = component.verticalScrollBar
return (bar.maximum - bar.visibleAmount).coerceAtLeast(bar.minimum)
}
@RequiresEdt
private fun onScroll() {
val moved = bar.value != value
syncValue()
if (auto || opening) {
updateJump()
return
}
if (component.viewport.view === messages) {
tail = atBottom()
if (!tail) seq++
val bottom = atBottom()
if (bottom) {
tail = true
user = false
updateJump()
return
}
if (tail && (!user || !moved)) {
user = false
followBottom(true)
return
}
tail = false
user = false
seq++
}
updateJump()
}
@RequiresEdt
private fun updateJump() {
val visible = component.viewport.view === messages && !atBottom()
if (jump.isVisible == visible) return
@@ -235,4 +308,9 @@ internal class SessionScroll(
root.overlay.revalidate()
root.overlay.repaint()
}
@RequiresEdt
private fun syncValue() {
value = bar.value
}
}
@@ -1,7 +1,9 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.history.HistoryActivitySnapshot
import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.session.history.itemAt
@@ -10,6 +12,7 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.layout.Align
import ai.kilocode.client.ui.layout.HAlign
@@ -20,6 +23,7 @@ import com.intellij.icons.AllIcons
import com.intellij.openapi.Disposable
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.IconLoader
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBList
import com.intellij.util.ui.Centerizer
@@ -31,9 +35,11 @@ import java.awt.BorderLayout
import java.awt.Component
import java.awt.Cursor
import java.awt.Dimension
import java.awt.FlowLayout
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
import java.awt.event.HierarchyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import java.awt.event.MouseMotionAdapter
@@ -42,6 +48,7 @@ import javax.swing.JButton
import javax.swing.JList
import javax.swing.ListCellRenderer
import javax.swing.ListSelectionModel
import javax.swing.Timer
/**
* Empty-session panel.
@@ -54,12 +61,16 @@ class EmptySessionPanel(
private val controller: SessionController,
recents: List<SessionDto>,
private val history: () -> Unit = {},
private val activity: () -> Map<String, SessionActivityKind> = { emptyMap() },
private val titles: () -> Map<String, String> = { emptyMap() },
) : BorderLayoutPanel(), Disposable, SessionEditorStyleTarget {
val view: Align = align(HAlign.CENTER, VAlign.CENTER)
private val model = DefaultListModel<LocalHistoryItem>()
private var hover = -1
private var style = SessionEditorStyle.current()
private var snapshot = HistoryActivitySnapshot()
private val timer = Timer(ACTIVITY_MS) { syncActivity() }
private val recentTitle = JBLabel(KiloBundle.message("session.empty.recent")).apply {
foreground = UIUtil.getContextHelpForeground()
@@ -124,6 +135,15 @@ class EmptySessionPanel(
isOpaque = false
applyStyle(SessionEditorStyle.current())
setSessions(recents)
addHierarchyListener { e ->
if (e.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@addHierarchyListener
if (isShowing) {
syncActivity()
timer.start()
return@addHierarchyListener
}
timer.stop()
}
val gap = UiStyle.Gap.pad()
layout = BorderLayout(0, gap)
@@ -220,6 +240,22 @@ class EmptySessionPanel(
}
}
@RequiresEdt
internal fun syncActivity() {
val next = HistoryActivitySnapshot(activity(), titles())
val changed = snapshot.changed(next)
snapshot = next
repaintRows(changed)
}
private fun repaintRows(ids: Set<String>) {
if (ids.isEmpty()) return
repeat(model.size()) { index ->
if (model.getElementAt(index).id !in ids) return@repeat
list.getCellBounds(index, index)?.let(list::repaint)
}
}
private fun index(e: MouseEvent): Int {
val idx = list.locationToIndex(e.point)
if (idx < 0) return -1
@@ -230,12 +266,24 @@ class EmptySessionPanel(
private inner class SessionRenderer : BorderLayoutPanel(), ListCellRenderer<LocalHistoryItem> {
private val title = JBLabel()
private val badge = JBLabel().apply {
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
}
private val time = JBLabel()
private val head = BorderLayoutPanel().apply {
add(BorderLayoutPanel().apply {
layout = FlowLayout(FlowLayout.LEFT, 0, 0)
isOpaque = false
add(title)
add(badge)
}, BorderLayout.CENTER)
}
init {
layout = BorderLayout(UiStyle.Gap.pad(), 0)
border = JBUI.Borders.empty(UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg(), UiStyle.Gap.lg())
add(title, BorderLayout.CENTER)
head.isOpaque = false
add(head, BorderLayout.CENTER)
add(time, BorderLayout.EAST)
}
@@ -246,15 +294,21 @@ class EmptySessionPanel(
selected: Boolean,
focus: Boolean,
): Component {
val active = selected || hover == index
isOpaque = active
background = if (active) list.selectionBackground else list.background
title.foreground = if (active) list.selectionForeground else UIUtil.getLabelForeground()
time.foreground = if (active) list.selectionForeground else UIUtil.getContextHelpForeground()
title.text = value?.let(::title) ?: ""
val over = selected || hover == index
isOpaque = over
background = if (over) list.selectionBackground else list.background
title.foreground = if (over) list.selectionForeground else UIUtil.getLabelForeground()
time.foreground = if (over) list.selectionForeground else UIUtil.getContextHelpForeground()
title.text = value?.let { snapshot.titles[it.id] ?: title(it) } ?: ""
time.text = value?.let(HistoryTime::relative) ?: ""
setBadge(value?.id?.let(snapshot.activity::get))
return this
}
private fun setBadge(kind: SessionActivityKind?) {
badge.isVisible = kind != null
badge.icon = kind?.let { FilledBadgeIcon(it.label(), it.bg(), it.fg()) }
}
}
private inner class ShowHistoryButton : JButton(KiloBundle.message("session.showHistory"), AllIcons.Vcs.History) {
@@ -301,7 +355,7 @@ class EmptySessionPanel(
}
override fun dispose() {
// no-op
timer.stop()
}
override fun applyStyle(style: SessionEditorStyle) {
@@ -315,4 +369,12 @@ class EmptySessionPanel(
private fun welcomeHtml() = XmlStringUtil.wrapInHtml(
"<div style='text-align:center'>${XmlStringUtil.escapeString(KiloBundle.message("session.empty.welcome"))}</div>"
)
private companion object {
const val ACTIVITY_MS = 3_000
}
}
private fun Map<String, String>.changed(next: Map<String, String>) = (keys + next.keys).filterTo(mutableSetOf()) {
this[it] != next[it]
}
@@ -47,6 +47,7 @@ class SessionMessageListPanel(
private val permission: PermissionView? = null,
private val login: LoginRequiredView? = null,
private val openFile: (String) -> Unit,
private val openUrl: (String) -> Unit = {},
) : SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
JBUI.insets(
@@ -174,7 +175,7 @@ class SessionMessageListPanel(
// ------ private event handlers ------
private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) {
val tv = TurnView(turn.id, openFile, style)
val tv = TurnView(turn.id, openFile, style, openUrl)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -225,7 +226,7 @@ class SessionMessageListPanel(
removeAll()
for (turn in model.turns()) {
val tv = TurnView(turn.id, openFile, style)
val tv = TurnView(turn.id, openFile, style, openUrl)
turnViews[turn.id] = tv
for (msgId in turn.messageIds) {
val msg = model.message(msgId) ?: continue
@@ -26,11 +26,14 @@ import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.actionSystem.ex.ActionUtil
import com.intellij.openapi.actionSystem.IdeActions
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.editor.event.DocumentEvent
import com.intellij.openapi.editor.event.DocumentListener
import com.intellij.openapi.keymap.Keymap
import com.intellij.openapi.keymap.KeymapManagerListener
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.IconLoader
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.xml.util.XmlStringUtil
import com.intellij.util.ui.JBValue
import com.intellij.util.ui.JBDimension
@@ -68,15 +71,20 @@ class PromptPanel(
private val LOG = KiloLog.create(PromptPanel::class.java)
private val SEND_ICON: Icon = IconLoader.getIcon("/icons/send.svg", PromptPanel::class.java)
private val STOP_ICON: Icon = IconLoader.getIcon("/icons/stop.svg", PromptPanel::class.java)
private val SHIELD_ICON: Icon = IconLoader.getIcon("/icons/shield.svg", PromptPanel::class.java)
private val SHIELD_FILLED_ICON: Icon = IconLoader.getIcon("/icons/shield-filled.svg", PromptPanel::class.java)
}
val mode = ModePicker()
val model = ModelPicker()
val reasoning = ReasoningPicker()
var onReset: () -> Unit = {}
var onChange: () -> Unit = {}
var onAutoApproveToggle: (Boolean) -> Unit = {}
private var style = SessionEditorStyle.current()
private val shell = PromptShell()
private var bus: MessageBusConnection? = null
private var autoApprove = false
private val editor = PromptEditorTextField(project, this).apply {
border = JBUI.Borders.empty()
@@ -131,6 +139,11 @@ class PromptPanel(
addActionListener { onReset() }
}
private val auto = AutoApproveButton().apply {
icon = SHIELD_ICON
addActionListener { onAutoApproveToggle(!autoApprove) }
}
@Volatile
private var busy = false
private var ready = false
@@ -153,6 +166,13 @@ class PromptPanel(
)
applyStyle(style)
editor.text = ""
editor.addDocumentListener(object : DocumentListener {
override fun documentChanged(e: DocumentEvent) {
syncEditorHeight()
onChange()
}
})
shell.add(editor, BorderLayout.CENTER)
val bar = BorderLayoutPanel().apply {
@@ -168,34 +188,50 @@ class PromptPanel(
bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP)))
bar.add(reset)
bar.add(Box.createHorizontalGlue())
bar.add(auto)
bar.add(Box.createHorizontalStrut(JBUI.scale(SessionUiStyle.View.Prompt.CONTROL_GAP)))
bar.add(button)
shell.add(bar, BorderLayout.SOUTH)
add(shell, BorderLayout.CENTER)
syncTooltip()
syncAutoApprove()
}
@RequiresEdt
fun setReady(value: Boolean) {
ready = value
}
@RequiresEdt
fun setBusy(value: Boolean) {
busy = value
button.icon = if (value) STOP_ICON else SEND_ICON
syncTooltip()
}
@RequiresEdt
fun setAutoApprove(value: Boolean) {
if (autoApprove == value) return
autoApprove = value
syncAutoApprove()
}
@RequiresEdt
fun setResetVisible(value: Boolean) {
reset.isVisible = value
revalidate()
repaint()
}
@RequiresEdt
fun text(): String = editor.text.trim()
@RequiresEdt
override fun send() {
submit("action")
}
@RequiresEdt
override fun stop() {
if (!isStopEnabled) return
onAbort()
@@ -213,23 +249,23 @@ class PromptPanel(
internal val defaultFocusedComponent: JComponent get() = editor
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
editor.font = style.transcriptFont
editor.getEditor(false)?.let(style::applyToEditor)
editor.background = style.editorScheme.defaultBackground
val height = style.transcriptFont.size * SessionUiStyle.View.Prompt.EDITOR_LINES + JBUI.scale(
SessionUiStyle.View.Prompt.EDITOR_CHROME)
editor.preferredSize = JBDimension(0, height)
editor.minimumSize = JBDimension(0, height)
revalidate()
repaint()
syncEditorHeight()
syncAutoApprove()
}
@RequiresEdt
fun clear() {
editor.text = ""
syncEditorHeight()
}
@RequiresEdt
fun focus() {
editor.requestFocusInWindow()
}
@@ -245,6 +281,7 @@ class PromptPanel(
super.removeNotify()
}
@RequiresEdt
private fun submit(src: String) {
if (!isSendEnabled) return
val txt = text()
@@ -254,6 +291,7 @@ class PromptPanel(
}
}
@RequiresEdt
private fun bindKeymap() {
if (bus != null) return
val connection = ApplicationManager.getApplication().messageBus.connect()
@@ -274,10 +312,27 @@ class PromptPanel(
})
}
@RequiresEdt
private fun syncTooltip() {
button.toolTipText = tooltip()
}
private fun syncAutoApprove() {
auto.isSelected = autoApprove
auto.icon = if (autoApprove) SHIELD_FILLED_ICON else SHIELD_ICON
auto.toolTipText = if (autoApprove) {
KiloBundle.message("prompt.action.autoApprove.enabled.tooltip")
} else {
KiloBundle.message("prompt.action.autoApprove.disabled.tooltip")
}
auto.accessibleContext.accessibleName = if (autoApprove) {
KiloBundle.message("prompt.action.autoApprove.disable")
} else {
KiloBundle.message("prompt.action.autoApprove.enable")
}
auto.repaint()
}
private fun tooltip(): String {
val id = if (busy) StopSessionAction.ID else SendPromptAction.ID
val text = if (busy) {
@@ -306,6 +361,20 @@ class PromptPanel(
return KiloBundle.message("prompt.placeholder")
}
@RequiresEdt
private fun syncEditorHeight() {
val lines = (editor.document.lineCount + SessionUiStyle.View.Prompt.EDITOR_SPARE_LINES).coerceIn(
SessionUiStyle.View.Prompt.EDITOR_LINES,
SessionUiStyle.View.Prompt.EDITOR_MAX_LINES,
)
val height = style.transcriptFont.size * lines + JBUI.scale(SessionUiStyle.View.Prompt.EDITOR_CHROME)
if (editor.preferredSize.height == height && editor.minimumSize.height == height) return
editor.preferredSize = JBDimension(0, height)
editor.minimumSize = JBDimension(0, height)
revalidate()
repaint()
}
private inner class SendButton : JButton(), UiDataProvider {
private var over = false
@@ -358,6 +427,56 @@ class PromptPanel(
}
}
private inner class AutoApproveButton : JButton() {
private var over = false
init {
iconButton(this)
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
addMouseListener(object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
sync(true)
}
override fun mouseExited(e: MouseEvent) {
sync(false)
}
})
}
override fun getPreferredSize() = JBUI.size(
SessionUiStyle.View.Prompt.SEND_BUTTON_SIZE,
SessionUiStyle.View.Prompt.SEND_BUTTON_SIZE,
)
override fun getMinimumSize() = preferredSize
override fun getMaximumSize() = preferredSize
override fun paintComponent(g: Graphics) {
if (over) paintHover(g)
super.paintComponent(g)
}
private fun paintHover(g: Graphics) {
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
g2.color = JBUI.CurrentTheme.ActionButton.hoverBackground()
val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC))
g2.fillRoundRect(0, 0, width, height, arc, arc)
} finally {
g2.dispose()
}
}
private fun sync(value: Boolean) {
if (over == value) return
over = value
repaint()
}
}
private inner class PromptShell : RoundedContentPanel(
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
@@ -3,7 +3,6 @@ package ai.kilocode.client.session.ui.style
import ai.kilocode.client.ui.UiStyle
import com.intellij.ui.JBColor
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.JBUI.Borders.customLine
import com.intellij.util.ui.UIUtil
import java.awt.Color
import javax.swing.border.Border
@@ -26,7 +25,8 @@ object SessionUiStyle {
const val CARD_BODY_EXTRA_HEIGHT = 16
internal const val BORDER_DELTA = 64
internal const val HOVER_ALPHA = 0.35f
internal const val HOVER_BORDER_ALPHA = 0.18f
internal const val HOVER_FILL_ALPHA = 0.10f
/** Creates a visible separator against editor-derived transcript surfaces. */
fun line(): Color = JBColor.lazy { UiStyle.Colors.contrast(UiStyle.Colors.editorBackground(), BORDER_DELTA) }
@@ -35,18 +35,25 @@ object SessionUiStyle {
fun header(): Color = UiStyle.Colors.editorBackground()
/** Local hover color for collapsible transcript card headers. */
fun headerHover(): Color = JBColor.lazy { UiStyle.Colors.blend(header(), line(), HOVER_ALPHA) }
/** Subtle hover fill, softer than the card outline. */
fun headerHover(): Color = JBColor.lazy { UiStyle.Colors.blend(header(), hoverLine(), HOVER_FILL_ALPHA) }
fun card(): Border = cardBorder()
/** Subtle hover outline, stronger than the hover fill. */
fun hoverLine(): Color = JBColor.lazy {
UiStyle.Colors.blend(line(), JBUI.CurrentTheme.ActionButton.hoverBackground(), HOVER_BORDER_ALPHA)
}
fun cardBorder(): Border = JBUI.Borders.customLine(line(), 1)
fun card(color: Color = line()): Border = cardBorder(color)
fun cardBorder(color: Color = line()): Border = JBUI.Borders.customLine(color, 1)
fun cardTop(): Border = JBUI.Borders.customLineTop(line())
/** Prompt input dimensions and chrome inside the session view. */
object Prompt {
const val EDITOR_LINES = 3
const val EDITOR_MAX_LINES = 8
const val EDITOR_SPARE_LINES = 1
const val EDITOR_CHROME = 16
const val SEND_BUTTON_SIZE = 24
const val CORNER_ARC = 6
@@ -11,8 +11,10 @@ import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.ui.RoundedLineBorder
import com.intellij.util.ui.JBUI
import java.awt.Graphics
import java.awt.Graphics2D
import java.awt.RenderingHints
/**
* A single message container inside a [TurnView].
@@ -29,6 +31,7 @@ class MessageView(
val msg: Message,
private val openFile: (String) -> Unit,
private var style: SessionEditorStyle = SessionEditorStyle.current(),
private val openUrl: (String) -> Unit = {},
) : ai.kilocode.client.session.ui.SessionLayoutPanel(
JBUI.scale(SessionUiStyle.SessionLayout.GAP),
), SessionEditorStyleTarget, SessionView {
@@ -45,6 +48,7 @@ class MessageView(
init {
isOpaque = false
if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = style.editorScheme.defaultBackground
border = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) {
userBorder()
} else {
@@ -55,7 +59,7 @@ class MessageView(
for ((_, content) in msg.parts) {
if (content is StepFinish) continue
if (isHidden(content)) continue
val view = ViewFactory.create(content, openFile)
val view = view(content)
view.applyStyle(style)
parts[content.id] = view
add(view)
@@ -95,7 +99,7 @@ class MessageView(
refresh()
return
}
val view = ViewFactory.create(content, openFile)
val view = view(content)
view.applyStyle(style)
parts[content.id] = view
add(view)
@@ -107,7 +111,7 @@ class MessageView(
val at = components.indexOfFirst { it === existing }.takeIf { it >= 0 } ?: componentCount
parts.remove(content.id)
remove(existing)
val view = ViewFactory.create(content, openFile)
val view = view(content)
view.applyStyle(style)
parts[content.id] = view
add(view, at)
@@ -147,7 +151,7 @@ class MessageView(
for ((_, content) in msg.parts) {
if (content is StepFinish) continue
if (isHidden(content)) continue
val view = ViewFactory.create(content, openFile)
val view = view(content)
view.applyStyle(style)
parts[content.id] = view
add(view)
@@ -161,6 +165,12 @@ class MessageView(
border = assistantBorder()
}
private fun view(content: Content) = if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) {
ViewFactory.createUser(content, openFile, openUrl)
} else {
ViewFactory.create(content, openFile, openUrl)
}
/** Append a streaming delta to the renderer for [contentId]. */
fun appendDelta(contentId: String, delta: String) {
val part = parts[contentId] ?: return
@@ -179,22 +189,41 @@ class MessageView(
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
if (msg.info.role == SessionUiStyle.View.Message.USER_ROLE) background = style.editorScheme.defaultBackground
for (view in parts.values) view.applyStyle(style)
refresh()
}
override fun paintComponent(g: Graphics) {
if (msg.info.role != SessionUiStyle.View.Message.USER_ROLE) {
super.paintComponent(g)
return
}
val g2 = g.create() as Graphics2D
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
val arc = JBUI.scale(JBUI.getInt("Button.arc", SessionUiStyle.View.Prompt.CORNER_ARC))
g2.color = style.editorScheme.defaultBackground
g2.fillRoundRect(0, 0, width, height, arc, arc)
g2.color = SessionUiStyle.View.line()
val w = width - 1
val h = height - 1
if (w > 0 && h > 0) g2.drawRoundRect(0, 0, w, h, arc, arc)
} finally {
g2.dispose()
}
super.paintComponent(g)
}
private fun refresh() {
revalidate()
repaint()
}
private fun userBorder() = JBUI.Borders.compound(
RoundedLineBorder(SessionUiStyle.View.line(), JBUI.scale(SessionUiStyle.View.Message.USER_BORDER_ARC)),
JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Message.USER_BORDER_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Message.USER_BORDER_HORIZONTAL_PADDING),
),
)!!
private fun userBorder() = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.Prompt.SHELL_HORIZONTAL_PADDING),
)
private fun assistantBorder() = JBUI.Borders.empty()
}
@@ -6,8 +6,8 @@ import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Reasoning
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.ui.UiStyle
import ai.kilocode.client.ui.md.MdView
import com.intellij.icons.AllIcons
@@ -15,101 +15,35 @@ import com.intellij.ui.components.JBLabel
import com.intellij.ui.components.JBScrollPane
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Font
import java.awt.Rectangle
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
import javax.swing.Scrollable
import javax.swing.SwingUtilities
/** Renders reasoning as a VS Code-style collapsible block. */
class ReasoningView(reasoning: Reasoning) : PartView() {
/** Renders reasoning as a secondary collapsible block. */
class ReasoningView(
reasoning: Reasoning,
openUrl: (String) -> Unit = {},
private val parts: ReasoningParts = reasoningParts(),
) :
SecondarySessionPartView(parts.header, parts.scroll) {
override val contentId: String = reasoning.id
val md: MdView = MdView.html()
private val arrow = JBLabel()
private val body = TrackPanel().apply {
isOpaque = true
background = SessionUiStyle.View.surface()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
private val scroll = JBScrollPane(body).apply {
border = SessionUiStyle.View.cardTop()
isOpaque = true
background = SessionUiStyle.View.surface()
viewport.background = SessionUiStyle.View.surface()
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
}
private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = true
background = SessionUiStyle.View.header()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
private val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply {
foreground = UiStyle.Colors.weak()
}
private val icon = JBLabel(AllIcons.General.InspectionsEye).apply {
foreground = UiStyle.Colors.weak()
}
val md: MdView = parts.md
private var style = SessionEditorStyle.current()
private var source = reasoning.content.toString()
private val click = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (!canExpand()) return
toggle()
}
}
private val mouse = object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
setHover(true)
}
override fun mouseExited(e: MouseEvent) {
if (inside(e)) return
setHover(false)
}
}
init {
layout = BorderLayout()
isOpaque = false
border = SessionUiStyle.View.card()
val left = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = false
add(icon, BorderLayout.WEST)
add(title, BorderLayout.CENTER)
}
header.add(left, BorderLayout.CENTER)
header.add(arrow, BorderLayout.EAST)
listOf(header, left, title, icon, arrow).forEach {
it.addMouseListener(click)
it.addMouseListener(mouse)
}
applyStyle(SessionEditorStyle.current())
bindHeader(parts.title, parts.icon)
applyStyle(style)
md.opaque = false
md.addLinkListener { openUrl(it.href) }
md.set(source)
body.add(md.component, BorderLayout.CENTER)
add(header, BorderLayout.NORTH)
parts.panel.add(md.component, BorderLayout.CENTER)
sync()
}
@@ -135,106 +69,35 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
}
fun markdown(): String = source
fun isExpanded(): Boolean = bodyVisible()
fun hasToggle(): Boolean = arrow.isVisible
fun headerText(): String = title.text
internal fun headerFont() = title.font
internal fun bodyVisible() = scroll.parent === this
internal fun horizontalPolicy() = scroll.horizontalScrollBarPolicy
fun headerText(): String = parts.title.text
internal fun headerFont() = parts.title.font
internal fun bodyVisible() = parts.scroll.parent === this
internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy
internal fun bodyMaxRows() = SessionUiStyle.View.Reasoning.BODY_LINES
internal fun bodyCreated() = true
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
var changed = false
if (title.font != style.smallEditorFont) {
title.font = style.smallEditorFont
if (parts.title.font != style.smallEditorFont) {
parts.title.font = style.smallEditorFont
changed = true
}
changed = apply(md) || changed
if (changed) refresh()
}
fun toggle() {
if (!canExpand()) return
var changed = if (bodyVisible()) collapse() else expand()
changed = sync() || changed
if (changed) refresh()
}
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
val height = header.preferredSize.height + bodyMaxHeight()
val height = row.preferredSize.height + bodyMaxHeight()
return Dimension(size.width, minOf(size.height, height))
}
private fun setHover(value: Boolean) {
val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
if (header.background?.rgb == color.rgb) return
header.background = color
header.repaint()
}
private fun inside(e: MouseEvent): Boolean {
val point = SwingUtilities.convertPoint(e.component, e.point, header)
return header.contains(point)
}
private fun canExpand(): Boolean = source.isNotBlank()
private fun sync(): Boolean {
val expand = canExpand()
if (!expand) collapse()
var changed = false
changed = setVisible(arrow, expand) || changed
changed = syncArrow() || changed
val cursor = if (expand) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor()
listOf(header, title, icon, arrow).forEach {
if (it.cursor?.type != cursor.type) {
it.cursor = cursor
changed = true
}
}
return changed
}
private fun setVisible(component: JBLabel, visible: Boolean): Boolean {
if (component.isVisible == visible) return false
component.isVisible = visible
return true
}
private fun setIcon(label: JBLabel, icon: javax.swing.Icon): Boolean {
if (label.icon === icon) return false
label.icon = icon
return true
}
private fun syncArrow(): Boolean {
val icon = if (bodyVisible()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
return setIcon(arrow, icon)
}
private fun expand(): Boolean {
if (bodyVisible()) return false
add(scroll, BorderLayout.CENTER)
return true
}
private fun collapse(): Boolean {
val attached = scroll.parent === this
if (attached) remove(scroll)
return attached
}
private fun sync(): Boolean = syncExpandable(canExpand())
private fun apply(md: MdView): Boolean {
var changed = false
@@ -248,11 +111,6 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
return changed
}
private fun refresh() {
revalidate()
repaint()
}
private fun bodyMaxHeight(): Int = md.component.getFontMetrics(md.font).height * bodyMaxRows() +
JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT)
@@ -262,7 +120,44 @@ class ReasoningView(reasoning: Reasoning) : PartView() {
}
}
private class TrackPanel : JPanel(BorderLayout()), Scrollable {
class ReasoningParts(
val md: MdView,
val panel: TrackPanel,
val scroll: JBScrollPane,
val header: JPanel,
val title: JBLabel,
val icon: JBLabel,
)
private fun reasoningParts(): ReasoningParts {
val md = MdView.html()
val panel = TrackPanel().apply {
isOpaque = true
background = SessionUiStyle.View.surface()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
val scroll = JBScrollPane(panel).apply {
border = SessionUiStyle.View.cardTop()
isOpaque = true
background = SessionUiStyle.View.surface()
viewport.background = SessionUiStyle.View.surface()
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
}
val title = JBLabel(KiloBundle.message("session.part.reasoning")).apply { foreground = UiStyle.Colors.weak() }
val icon = JBLabel(AllIcons.General.InspectionsEye).apply { foreground = UiStyle.Colors.weak() }
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = false
add(icon, BorderLayout.WEST)
add(title, BorderLayout.CENTER)
}
return ReasoningParts(md, panel, scroll, header, title, icon)
}
class TrackPanel : JPanel(BorderLayout()), Scrollable {
override fun getScrollableTracksViewportWidth() = true
override fun getScrollableTracksViewportHeight() = false
override fun getPreferredScrollableViewportSize(): Dimension = preferredSize
@@ -12,7 +12,11 @@ import java.awt.BorderLayout
*
* Supports both full-replacement ([update]) and streaming append ([appendDelta]).
*/
class TextView(text: Text) : PartView() {
class TextView(
text: Text,
transparent: Boolean = false,
openUrl: (String) -> Unit = {},
) : PartView() {
override val contentId: String = text.id
@@ -21,6 +25,8 @@ class TextView(text: Text) : PartView() {
init {
layout = BorderLayout()
isOpaque = false
md.opaque = !transparent
md.addLinkListener { openUrl(it.href) }
applyStyle(SessionEditorStyle.current())
add(md.component, BorderLayout.CENTER)
if (text.content.isNotEmpty()) md.set(text.content.toString())
@@ -41,6 +47,8 @@ class TextView(text: Text) : PartView() {
/** Current markdown source — used by tests to assert rendered content. */
fun markdown(): String = md.markdown()
internal fun contentOpaque() = md.opaque
override fun applyStyle(style: SessionEditorStyle) {
val changed = md.font != style.transcriptFont || md.codeFont != style.editorFamily
if (md.font != style.transcriptFont) md.font = style.transcriptFont
@@ -6,9 +6,10 @@ import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.model.Content
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.ToolKind
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLabel
@@ -17,8 +18,8 @@ import com.intellij.ui.components.JBTextArea
import com.intellij.util.ui.JBUI
import com.intellij.xml.util.XmlStringUtil
import java.awt.BorderLayout
import java.awt.CardLayout
import java.awt.Color
import java.awt.Component
import java.awt.Cursor
import java.awt.Dimension
import java.awt.Font
@@ -29,109 +30,27 @@ import javax.swing.Icon
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.ScrollPaneConstants
import javax.swing.SwingUtilities
/** Renders tool calls with VS Code-inspired rows/cards. */
class ToolView(tool: Tool) : PartView() {
/** Renders non-read tool calls with VS Code-inspired rows/cards. */
class ToolView(tool: Tool, private val parts: ToolParts = toolParts(tool)) :
SecondarySessionPartView(parts.header, parts.scroll) {
override val contentId: String = tool.id
private var item = tool
private var style = SessionEditorStyle.current()
private val root = JPanel(BorderLayout()).apply {
isOpaque = true
background = SessionUiStyle.View.surface()
border = SessionUiStyle.View.card()
}
private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = true
background = SessionUiStyle.View.header()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
private val glyph = JBLabel()
private val title = JBLabel()
private val sub = JBLabel().apply {
foreground = UiStyle.Colors.weak()
}
private val state = JBLabel().apply {
foreground = UiStyle.Colors.weak()
}
private val arrow = JBLabel()
private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = false
}
private val controls: JComponent = Box.createHorizontalBox().apply {
add(state)
add(arrow)
}
private val text = JBTextArea().apply {
isEditable = false
caret.isVisible = false
caret.isSelectionVisible = false
lineWrap = true
wrapStyleWord = true
foreground = bodyColor()
background = SessionUiStyle.View.surface()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
private val scroll = JBScrollPane(text).apply {
border = SessionUiStyle.View.cardTop()
isOpaque = true
background = SessionUiStyle.View.surface()
viewport.background = SessionUiStyle.View.surface()
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
}
private val click = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (!canExpand(item)) return
toggle()
}
}
private val mouse = object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
setHover(true)
}
override fun mouseExited(e: MouseEvent) {
if (inside(e)) return
setHover(false)
}
}
init {
layout = BorderLayout()
isOpaque = false
center.add(title, BorderLayout.WEST)
center.add(sub, BorderLayout.CENTER)
header.add(glyph, BorderLayout.WEST)
header.add(center, BorderLayout.CENTER)
header.add(controls, BorderLayout.EAST)
root.add(header, BorderLayout.NORTH)
listOf(header, glyph, title, sub, state, arrow, center, controls).forEach {
bind(it)
it.addMouseListener(click)
}
text.text = preview(item)
applyStyle(SessionEditorStyle.current())
add(root, BorderLayout.CENTER)
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
parts.text.text = preview(item)
applyStyle(style)
sync()
}
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
val height = header.preferredSize.height + bodyMaxHeight()
val height = row.preferredSize.height + bodyMaxHeight()
return Dimension(size.width, minOf(size.height, height))
}
@@ -140,164 +59,302 @@ class ToolView(tool: Tool) : PartView() {
val was = item.name
item = content
var changed = false
if (was != content.name || !canExpand(content)) changed = detach() || changed
if (was != content.name || !canExpand(content)) changed = collapse() || changed
changed = sync() || changed
changed = syncBody() || changed
if (changed) refresh()
}
fun labelText(): String = listOf(title.text, sub.text, state.text).filter { it.isNotBlank() }.joinToString(" ")
fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text)
.filter { it.isNotBlank() }
.joinToString(" ")
fun commandText(): String = command(item)
fun outputText(): String = output(item)
fun bodyText(): String = body(item)
internal fun previewText(): String = text.text
fun isExpanded(): Boolean = bodyVisible()
internal fun previewText(): String = parts.text.text
fun hasToggle(): Boolean = arrow.isVisible
internal fun bodyFont() = text.font
internal fun titleFont() = title.font
internal fun subtitleFont() = sub.font
internal fun stateFont() = state.font
internal fun bodyEditable() = text.isEditable
internal fun bodyCaretVisible() = text.caret.isVisible
internal fun bodyVisible() = scroll.parent === root
internal fun bodyFont() = parts.text.font
internal fun titleFont() = parts.title.font
internal fun subtitleFont() = parts.sub.font
internal fun stateFont() = parts.state.font
internal fun bodyEditable() = parts.text.isEditable
internal fun bodyCaretVisible() = parts.text.caret.isVisible
internal fun bodyVisible() = parts.scroll.parent === this
internal fun controlCount() = if (arrow.isVisible) 1 else 0
internal fun horizontalPolicy() = scroll.horizontalScrollBarPolicy
internal fun bodyWrap() = text.lineWrap
internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy
internal fun bodyWrap() = parts.text.lineWrap
internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES
internal fun bodyCreated() = true
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
var changed = false
changed = setFont(title, style.boldEditorFont) || changed
changed = setFont(sub, style.smallEditorFont) || changed
changed = setFont(state, style.smallEditorFont) || changed
changed = setFont(text, style.transcriptFont) || changed
changed = setFont(parts.title, style.boldEditorFont) || changed
changed = setFont(parts.sub, style.smallEditorFont) || changed
changed = setFont(parts.link, style.smallEditorFont) || changed
changed = setFont(parts.state, style.smallEditorFont) || changed
changed = setFont(parts.text, style.transcriptFont) || changed
if (changed) refresh()
}
fun toggle() {
if (!canExpand(item)) return
var changed = if (bodyVisible()) detach() else attach()
changed = syncArrow() || changed
if (changed) refresh()
}
private fun setHover(value: Boolean) {
val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
if (same(header.background, color)) return
header.background = color
header.repaint()
}
private fun inside(e: MouseEvent): Boolean {
val point = SwingUtilities.convertPoint(e.component, e.point, header)
return header.contains(point)
}
private fun bind(component: Component) {
component.addMouseListener(mouse)
}
private fun sync(): Boolean {
val expand = canExpand(item)
val cursor = if (expand) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor()
var changed = false
changed = syncCursor(cursor) || changed
changed = setVisible(arrow, expand) || changed
changed = setVisible(state, !expand) || changed
changed = syncArrow() || changed
changed = syncExpandable(expand) || changed
changed = setVisible(parts.state, !expand) || changed
changed = syncLabels() || changed
changed = setForeground(text, bodyColor()) || changed
changed = setForeground(parts.text, bodyColor()) || changed
return changed
}
private fun syncLabels(): Boolean {
var changed = false
changed = setIcon(glyph, icon(item)) || changed
changed = setForeground(glyph, color(item)) || changed
changed = setText(title, title(item)) || changed
changed = setText(sub, subtitle(item)) || changed
changed = setForeground(title, titleColor(item)) || changed
changed = setText(state, stateText(item)) || changed
changed = setForeground(state, color(item)) || changed
changed = setIcon(parts.glyph, icon(item)) || changed
changed = setForeground(parts.glyph, color(item)) || changed
changed = setText(parts.title, title(item)) || changed
changed = setText(parts.sub, subtitle(item)) || changed
changed = setForeground(parts.title, titleColor(item)) || changed
changed = setText(parts.state, stateText(item)) || changed
changed = setForeground(parts.state, color(item)) || changed
return changed
}
private fun syncArrow(): Boolean {
val icon = if (bodyVisible()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
return setIcon(arrow, icon)
}
private fun syncBody(): Boolean {
var changed = false
val value = preview(item)
if (text.text != value) {
text.text = value
text.caretPosition = 0
if (parts.text.text != value) {
parts.text.text = value
parts.text.caretPosition = 0
changed = true
}
changed = setForeground(text, bodyColor()) || changed
changed = setForeground(parts.text, bodyColor()) || changed
return changed
}
private fun attach(): Boolean {
if (bodyVisible()) return false
syncBody()
root.add(scroll, BorderLayout.CENTER)
return true
}
private fun detach(): Boolean {
val attached = scroll.parent === root
if (attached) root.remove(scroll)
return attached
}
private fun syncCursor(cursor: Cursor): Boolean {
var changed = false
listOf(header, glyph, title, sub, state, arrow, center, controls).forEach {
if (it.cursor?.type != cursor.type) {
it.cursor = cursor
changed = true
}
}
return changed
}
private fun refresh() {
revalidate()
repaint()
}
private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg()
private fun bodyMaxHeight(): Int {
return text.getFontMetrics(text.font).height * bodyMaxRows() +
return parts.text.getFontMetrics(parts.text.font).height * bodyMaxRows() +
JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT)
}
override fun dumpLabel() = "ToolView#$contentId(${labelText()})"
}
/** Renders read calls with secondary, borderless chrome. */
class ReadToolView(
tool: Tool,
openFile: (String) -> Unit = {},
private val parts: ToolParts = toolParts(tool, openFile),
) : SecondarySessionPartView(parts.header, parts.scroll, expandable = false) {
companion object {
fun canRender(tool: Tool): Boolean = tool.kind == ToolKind.READ
}
override val contentId: String = tool.id
private var item = tool
private var style = SessionEditorStyle.current()
init {
bindHeader(parts.glyph, parts.title, parts.sub, parts.state, parts.center, parts.controls, parts.slot)
parts.text.text = preview(item)
applyStyle(style)
sync()
}
override fun getPreferredSize(): Dimension {
val size = super.getPreferredSize()
if (!bodyVisible()) return size
val height = row.preferredSize.height + bodyMaxHeight()
return Dimension(size.width, minOf(size.height, height))
}
override fun update(content: Content) {
if (content !is Tool) return
item = content
var changed = sync()
changed = syncBody() || changed
if (changed) refresh()
}
fun labelText(): String = listOf(parts.title.text, subtitleText(parts), parts.state.text)
.filter { it.isNotBlank() }
.joinToString(" ")
fun bodyText(): String = body(item)
internal fun bodyVisible() = parts.scroll.parent === this
internal fun hasToggle() = arrow.isVisible
internal fun horizontalPolicy() = parts.scroll.horizontalScrollBarPolicy
internal fun bodyMaxRows() = SessionUiStyle.View.Tool.BODY_LINES
internal fun bodyFont() = parts.text.font
internal fun linkVisible() = parts.link.isVisible
internal fun linkText() = parts.label
internal fun linkMarkup() = parts.link.text ?: ""
internal fun linkForeground() = parts.link.foreground
internal fun linkFont() = parts.link.font
internal fun subtitleForeground() = parts.sub.foreground
internal fun subtitleFont() = parts.sub.font
internal fun linkHref() = parts.href
internal fun openLink() = parts.openLink()
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
var changed = false
changed = setFont(parts.title, style.boldEditorFont) || changed
changed = setFont(parts.sub, style.transcriptFont) || changed
changed = setFont(parts.link, style.transcriptFont) || changed
changed = setFont(parts.state, style.smallEditorFont) || changed
changed = setFont(parts.text, style.transcriptFont) || changed
if (changed) refresh()
}
private fun sync(): Boolean {
var changed = false
changed = syncExpandable(false) || changed
changed = setVisible(parts.state, true) || changed
changed = setIcon(parts.glyph, icon(item)) || changed
changed = setForeground(parts.glyph, color(item)) || changed
changed = setText(parts.title, title(item)) || changed
changed = syncSubtitle() || changed
changed = setForeground(parts.title, titleColor(item)) || changed
changed = setForeground(parts.sub, UiStyle.Colors.fg()) || changed
changed = setForeground(parts.link, UiStyle.Colors.fg()) || changed
changed = setText(parts.state, stateText(item)) || changed
changed = setForeground(parts.state, color(item)) || changed
changed = setForeground(parts.text, bodyColor()) || changed
return changed
}
private fun syncSubtitle(): Boolean {
val target = target(item)?.takeIf { it.type == "file" }
if (target != null) {
var changed = false
if (parts.href != target.path) {
parts.href = target.path
changed = true
}
changed = setLinkText(parts, tail(target.path).ifBlank { target.path }) || changed
changed = show(parts, true) || changed
return changed
}
var changed = false
if (parts.href != null) {
parts.href = null
changed = true
}
changed = setText(parts.sub, subtitle(item)) || changed
changed = show(parts, false) || changed
return changed
}
private fun syncBody(): Boolean {
val value = preview(item)
if (parts.text.text == value) return false
parts.text.text = value
parts.text.caretPosition = 0
return true
}
private fun bodyColor() = if (item.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg()
private fun bodyMaxHeight(): Int {
return parts.text.getFontMetrics(parts.text.font).height * bodyMaxRows() +
JBUI.scale(SessionUiStyle.View.CARD_BODY_EXTRA_HEIGHT)
}
override fun dumpLabel() = "ReadToolView#$contentId(${labelText()})"
}
class ToolParts(
val header: JPanel,
val glyph: JBLabel,
val title: JBLabel,
val sub: JBLabel,
val link: JBLabel,
val slot: JPanel,
val state: JBLabel,
val center: JPanel,
val controls: JComponent,
val text: JBTextArea,
val scroll: JBScrollPane,
private val open: ((String) -> Unit)? = null,
) {
var href: String? = null
var label: String = ""
fun openLink() {
val value = href ?: return
open?.invoke(value)
}
}
private const val SUB_CARD = "sub"
private const val LINK_CARD = "link"
private fun toolParts(tool: Tool, openFile: ((String) -> Unit)? = null): ToolParts {
lateinit var parts: ToolParts
val glyph = JBLabel()
val title = JBLabel()
val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() }
val link = JBLabel().apply {
isVisible = false
isFocusable = false
foreground = UiStyle.Colors.fg()
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
setRequestFocusEnabled(false)
addMouseListener(object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
parts.openLink()
}
})
}
val slot = JPanel(CardLayout()).apply {
isOpaque = false
add(sub, SUB_CARD)
add(link, LINK_CARD)
}
val state = JBLabel().apply { foreground = UiStyle.Colors.weak() }
val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply { isOpaque = false }
val text = JBTextArea().apply {
isEditable = false
caret.isVisible = false
caret.isSelectionVisible = false
lineWrap = true
wrapStyleWord = true
foreground = if (tool.state == ToolExecState.ERROR) UiStyle.Colors.errorLabelForeground() else UiStyle.Colors.fg()
background = SessionUiStyle.View.surface()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
val scroll = JBScrollPane(text).apply {
border = SessionUiStyle.View.cardTop()
isOpaque = true
background = SessionUiStyle.View.surface()
viewport.background = SessionUiStyle.View.surface()
horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER
verticalScrollBarPolicy = ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED
}
val controls = Box.createHorizontalBox()
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = false
center.add(title, BorderLayout.WEST)
center.add(slot, BorderLayout.CENTER)
add(glyph, BorderLayout.WEST)
add(center, BorderLayout.CENTER)
add(controls, BorderLayout.EAST)
}
parts = ToolParts(header, glyph, title, sub, link, slot, state, center, controls, text, scroll, openFile)
return parts.also {
controls.add(it.state)
}
}
private fun icon(tool: Tool) = when (tool.name) {
"read" -> AllIcons.Actions.Preview
"bash" -> AllIcons.Debugger.Console
@@ -328,6 +385,22 @@ private fun setText(label: JBLabel, text: String): Boolean {
return true
}
private fun setLinkText(parts: ToolParts, text: String): Boolean {
val value = if (text.isBlank()) "" else XmlStringUtil.wrapInHtml("<u>${XmlStringUtil.escapeString(text)}</u>")
if (parts.label == text && parts.link.text == value) return false
parts.label = text
parts.link.text = value
return true
}
private fun show(parts: ToolParts, link: Boolean): Boolean {
if (parts.link.isVisible == link && parts.sub.isVisible != link) return false
(parts.slot.layout as CardLayout).show(parts.slot, if (link) LINK_CARD else SUB_CARD)
return true
}
private fun subtitleText(parts: ToolParts): String = if (parts.link.isVisible) parts.label else parts.sub.text
private fun setIcon(label: JBLabel, icon: Icon): Boolean {
if (label.icon === icon) return false
label.icon = icon
@@ -375,10 +448,36 @@ private fun stateText(tool: Tool) = when (tool.state) {
}
private fun readPath(tool: Tool): String {
val target = target(tool)
if (target != null) {
if (target.type == "file") return tail(target.path).ifBlank { target.path }
return target.path
}
val path = tool.input["filePath"] ?: tool.input["path"] ?: tool.title ?: return tool.name
return tail(path).ifBlank { path }
}
private data class Target(
val path: String,
val type: String,
)
private fun target(tool: Tool): Target? {
val out = output(tool)
if (out.isBlank()) return null
val path = tag(out, "path") ?: return null
val type = tag(out, "type") ?: return null
return Target(path, type.lowercase())
}
private fun tag(text: String, name: String): String? =
Regex("<$name>\\s*([\\s\\S]*?)\\s*</$name>")
.find(text)
?.groupValues
?.getOrNull(1)
?.trim()
?.takeIf { it.isNotBlank() }
private fun shellTitle(tool: Tool): String =
tool.input["description"]?.takeIf { it.isNotBlank() }
?: tool.metadata["description"]?.takeIf { it.isNotBlank() }
@@ -453,9 +552,7 @@ private fun plainBody(tool: Tool): String {
}
private fun canExpand(tool: Tool): Boolean {
if (tool.name == "bash") {
return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank()
}
if (tool.name == "bash") return command(tool).isNotBlank() || output(tool).isNotBlank() || !tool.error.isNullOrBlank()
return output(tool).isNotBlank() || !tool.error.isNullOrBlank()
}
@@ -20,6 +20,7 @@ class TurnView(
val id: String,
private val openFile: (String) -> Unit,
private var style: SessionEditorStyle = SessionEditorStyle.current(),
private val openUrl: (String) -> Unit = {},
) : SessionLayoutPanel(JBUI.scale(SessionUiStyle.SessionLayout.GAP)), SessionEditorStyleTarget {
constructor(id: String, openFile: (String) -> Unit) : this(id, openFile, SessionEditorStyle.current())
@@ -32,7 +33,7 @@ class TurnView(
/** Add a new [MessageView] for [msg] at the end of this turn. */
fun addMessage(msg: Message): MessageView {
val view = MessageView(msg, openFile, style)
val view = MessageView(msg, openFile, style, openUrl)
messages[msg.info.id] = view
add(view)
revalidate()
@@ -21,13 +21,18 @@ import ai.kilocode.client.session.views.todo.TodoWriteView
* 3. Add a branch here the exhaustive `when` will surface the gap as a compile error.
*/
object ViewFactory {
fun create(content: Content, openFile: (String) -> Unit): PartView = when (content) {
is Text -> TextView(content)
is Reasoning -> ReasoningView(content)
fun create(
content: Content,
openFile: (String) -> Unit,
openUrl: (String) -> Unit = {},
): PartView = when (content) {
is Text -> TextView(content, openUrl = openUrl)
is Reasoning -> ReasoningView(content, openUrl = openUrl)
is Tool -> when {
TodoWriteView.canRender(content) -> TodoWriteView(content)
PlanExitView.canRender(content) -> PlanExitView(content, openFile)
QuestionResultView.canRender(content) -> QuestionResultView(content)
ReadToolView.canRender(content) -> ReadToolView(content, openFile)
else -> ToolView(content)
}
is Compaction -> CompactionView(content)
@@ -35,6 +40,15 @@ object ViewFactory {
is Generic -> GenericView(content)
}
fun createUser(
content: Content,
openFile: (String) -> Unit,
openUrl: (String) -> Unit = {},
): PartView = when (content) {
is Text -> TextView(content, transparent = true, openUrl = openUrl)
else -> create(content, openFile, openUrl)
}
/**
* Returns true when [view] must be replaced by a new renderer for [content].
* This happens when a running question tool (rendered as [ToolView]) completes
@@ -47,6 +61,8 @@ object ViewFactory {
if (view is PlanExitView) return !PlanExitView.canRender(content)
if (view !is PlanExitView && PlanExitView.canRender(content)) return true
if (view is QuestionResultView) return !QuestionResultView.canRender(content)
if (view is ReadToolView) return !ReadToolView.canRender(content) || QuestionResultView.canRender(content)
if (view is ToolView && ReadToolView.canRender(content)) return true
if (view is ToolView) return QuestionResultView.canRender(content)
return false
}
@@ -0,0 +1,145 @@
package ai.kilocode.client.session.views.base
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
import java.awt.Cursor
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.SwingUtilities
abstract class AbstractSessionPartView(
header: JComponent,
protected val body: JComponent,
expanded: Boolean = false,
private val expandable: Boolean = true,
) : PartView() {
protected val arrow = JBLabel()
protected val row = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0))
private val bound = linkedSetOf<Component>()
private val click = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
if (!arrow.isVisible) return
toggle()
}
}
private val mouse = object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
setHover(true)
}
override fun mouseExited(e: MouseEvent) {
if (inside(e)) return
setHover(false)
}
}
init {
layout = BorderLayout()
isOpaque = false
row.add(header, BorderLayout.CENTER)
row.add(arrow, BorderLayout.EAST)
add(row, BorderLayout.NORTH)
bindHeader(row, header, arrow)
if (expanded && expandable) add(body, BorderLayout.CENTER)
if (!expandable) syncExpandable(false) else syncArrow()
}
fun isExpanded(): Boolean = body.parent === this
fun toggle() {
if (!expandable || !arrow.isVisible) return
val changed = if (isExpanded()) collapse() else expand()
if (!changed) return
syncArrow()
refresh()
}
fun expand(): Boolean {
if (!expandable) return false
if (isExpanded()) return false
add(body, BorderLayout.CENTER)
return true
}
fun collapse(): Boolean {
if (!isExpanded()) return false
remove(body)
return true
}
fun syncExpandable(expandable: Boolean): Boolean {
val active = this.expandable && expandable
val changed = setVisible(arrow, active)
val detached = if (active) false else collapse()
val cursor = if (active) Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) else Cursor.getDefaultCursor()
val moved = syncCursor(cursor)
val icon = syncArrow()
return changed || detached || moved || icon
}
protected fun bindHeader(vararg items: Component) {
items.forEach { bind(it) }
}
protected fun refresh() {
revalidate()
repaint()
}
protected open fun hoverColor(value: Boolean): Color? = null
protected open fun applyHover(value: Boolean, color: Color) {}
private fun setHover(value: Boolean) {
val color = hoverColor(value) ?: return
if (row.background?.rgb == color.rgb) return
row.background = color
applyHover(value, color)
row.repaint()
}
private fun inside(e: MouseEvent): Boolean {
val point = SwingUtilities.convertPoint(e.component, e.point, row)
return row.contains(point)
}
private fun bind(component: Component) {
if (bound.contains(component)) return
bound.add(component)
component.addMouseListener(click)
component.addMouseListener(mouse)
}
private fun syncCursor(cursor: Cursor): Boolean {
var changed = false
bound.forEach {
if (it.cursor?.type != cursor.type) {
it.cursor = cursor
changed = true
}
}
return changed
}
private fun syncArrow(): Boolean {
val icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
if (arrow.icon === icon) return false
arrow.icon = icon
return true
}
private fun setVisible(component: JComponent, visible: Boolean): Boolean {
if (component.isVisible == visible) return false
component.isVisible = visible
return true
}
}
@@ -5,7 +5,6 @@ import ai.kilocode.client.session.model.Generic
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.ui.UiStyle
import com.intellij.ui.components.JBLabel
import java.awt.BorderLayout
/**
* Fallback renderer for part types that have no dedicated view.
@@ -14,20 +13,19 @@ import java.awt.BorderLayout
* confusing empty gaps), this shows a dim label with the raw type name.
* This makes it easy to spot new part types that need a proper renderer.
*/
class GenericView(content: Generic) : PartView() {
class GenericView private constructor(
content: Generic,
private val label: JBLabel,
) : SecondarySessionPartView(label, JBLabel()) {
constructor(content: Generic) : this(content, JBLabel("[${content.type}]"))
override val contentId: String = content.id
private val label = JBLabel("[${content.type}]").apply {
foreground = UiStyle.Colors.weak()
border = com.intellij.util.ui.JBUI.Borders.empty(UiStyle.Gap.xs(), 0)
}
init {
layout = BorderLayout()
isOpaque = false
label.foreground = UiStyle.Colors.weak()
applyStyle(SessionEditorStyle.current())
add(label, BorderLayout.CENTER)
syncExpandable(false)
}
override fun update(content: Content) {} // generic content has no updatable state
@@ -0,0 +1,32 @@
package ai.kilocode.client.session.views.base
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.util.ui.JBUI
import java.awt.Color
import javax.swing.JComponent
abstract class PrimarySessionPartView(
header: JComponent,
content: JComponent,
expanded: Boolean = false,
expandable: Boolean = true,
) : AbstractSessionPartView(header, content, expanded, expandable) {
init {
isOpaque = true
background = SessionUiStyle.View.surface()
border = SessionUiStyle.View.card()
row.isOpaque = true
row.background = SessionUiStyle.View.header()
row.border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
override fun applyHover(value: Boolean, color: Color) {
border = if (value) SessionUiStyle.View.card(SessionUiStyle.View.hoverLine()) else SessionUiStyle.View.card()
repaint()
}
}
@@ -0,0 +1,23 @@
package ai.kilocode.client.session.views.base
import ai.kilocode.client.session.ui.style.SessionUiStyle
import com.intellij.util.ui.JBUI
import javax.swing.JComponent
abstract class SecondarySessionPartView(
header: JComponent,
content: JComponent,
expanded: Boolean = false,
expandable: Boolean = true,
) : AbstractSessionPartView(header, content, expanded, expandable) {
init {
row.isOpaque = true
row.background = SessionUiStyle.View.header()
row.border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
override fun hoverColor(value: Boolean) = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
}
@@ -258,7 +258,9 @@ class QuestionResultView(tool: Tool) : PartView() {
val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
if (header.background?.rgb == color.rgb) return
header.background = color
root.border = if (value) SessionUiStyle.View.card(SessionUiStyle.View.hoverLine()) else SessionUiStyle.View.card()
header.repaint()
root.repaint()
}
private fun inside(e: MouseEvent): Boolean {
@@ -38,13 +38,15 @@ import javax.swing.ButtonGroup
import javax.swing.JPanel
import com.intellij.openapi.editor.event.DocumentEvent
import com.intellij.openapi.editor.event.DocumentListener
import com.intellij.util.concurrency.annotations.RequiresEdt
/** Question tool form rendered inside the session transcript. */
class QuestionView(
private val project: Project,
private val reply: (String, QuestionReplyDto, List<List<String>>) -> Unit,
private val reject: (String) -> Unit,
private val scroll: () -> Unit = {},
private val follow: () -> Boolean = { true },
private val scroll: (Boolean) -> Unit = {},
) : BorderLayoutPanel(), SessionEditorStyleTarget, SessionView {
override val sessionViewKind = SessionView.Kind.Default
@@ -113,6 +115,7 @@ class QuestionView(
add(card, BorderLayout.CENTER)
}
@RequiresEdt
fun show(q: Question) {
if (q.items.isEmpty()) {
hideView()
@@ -121,14 +124,17 @@ class QuestionView(
request = q.id
question = q
idx = 0
val tail = follow()
selections = List(q.items.size) { mutableSetOf() }
customTexts = List(q.items.size) { "" }
customOpen = List(q.items.size) { false }
isVisible = true
applyStyle(SessionEditorStyle.current())
syncPage()
scroll(tail)
}
@RequiresEdt
fun hideView() {
request = null
question = null
@@ -145,6 +151,7 @@ class QuestionView(
refresh()
}
@RequiresEdt
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
card.applyStyle(style)
@@ -158,6 +165,7 @@ class QuestionView(
refresh()
}
@RequiresEdt
private fun syncPage() {
val q = question ?: return
texts.clear()
@@ -181,6 +189,7 @@ class QuestionView(
refresh()
}
@RequiresEdt
private fun syncHeader(q: Question) {
val total = q.items.size
val shown = minOf(idx + 1, total)
@@ -191,6 +200,7 @@ class QuestionView(
topPanel.isVisible = total > 1
}
@RequiresEdt
private fun syncFooter(q: Question) {
val actions = mutableListOf<BaseQuestionView.Action>()
actions.add(BaseQuestionView.Action(ID_DISMISS, KiloBundle.message("session.question.dismiss"), primary = false) { doReject() })
@@ -216,6 +226,7 @@ class QuestionView(
card.setActions(actions)
}
@RequiresEdt
private fun syncControls(q: Question) {
val ready = isReady(idx)
back.isEnabled = idx > 0
@@ -260,12 +271,14 @@ class QuestionView(
private fun optionAnswers(i: Int): List<String> = selections.getOrNull(i)?.toList() ?: emptyList()
@RequiresEdt
private fun addContent(item: QuestionItem, set: MutableSet<String>) {
val opts = optionList(item, set)
opts.alignmentX = Component.LEFT_ALIGNMENT
body.add(opts)
}
@RequiresEdt
private fun addReview(q: Question) {
for ((i, item) in q.items.withIndex()) {
val row = reviewRow(item, i)
@@ -276,6 +289,7 @@ class QuestionView(
(body.components.lastOrNull() as? JPanel)?.border = JBUI.Borders.empty()
}
@RequiresEdt
private fun reviewRow(item: QuestionItem, i: Int): JPanel {
val row = JPanel().apply {
isOpaque = false
@@ -298,6 +312,7 @@ class QuestionView(
return row
}
@RequiresEdt
private fun optionList(item: QuestionItem, set: MutableSet<String>): JPanel {
val panel = JPanel().apply {
isOpaque = false
@@ -320,6 +335,7 @@ class QuestionView(
return panel
}
@RequiresEdt
private fun customRow(item: QuestionItem, set: MutableSet<String>): JPanel {
val open = customOpen.getOrElse(idx) { false }
val existing = customTexts.getOrElse(idx) { "" }.trim()
@@ -431,12 +447,14 @@ class QuestionView(
return row
}
@RequiresEdt
internal fun testFocusCustomEditor() {
val ed = customEditor ?: return
val focus = customFocus ?: return
focus.focusGained(FocusEvent(ed, FocusEvent.FOCUS_GAINED))
}
@RequiresEdt
private fun selectCustom(item: QuestionItem, set: MutableSet<String>) {
if (customOpen.getOrElse(idx) { false }) return
if (!item.multiple) set.clear()
@@ -453,6 +471,7 @@ class QuestionView(
* the first time the component becomes visible, satisfying the platform's
* read-context requirement without any additional wrapping here.
*/
@RequiresEdt
private fun buildCustomEditor(): SessionEditorTextField {
val ed = SessionEditorTextField(project)
ed.border = JBUI.Borders.empty()
@@ -485,13 +504,14 @@ class QuestionView(
// The document was already created above (ed.text = saved ensures getDocument()
// was called), so installDocumentListener succeeds.
ed.addDocumentListener(object : DocumentListener {
@RequiresEdt
override fun documentChanged(e: DocumentEvent) {
val txt = ed.text
customTexts = customTexts.toMutableList().also { it[idx] = txt }
syncEditorHeight(ed)
question?.let(::syncControls)
refresh()
scroll()
scroll(follow())
}
})
@@ -499,6 +519,7 @@ class QuestionView(
return ed
}
@RequiresEdt
private fun syncEditorHeight(ed: SessionEditorTextField) {
val editor = ed.getEditor(false)
val estimated = estimatedLines(ed)
@@ -509,6 +530,7 @@ class QuestionView(
ed.minimumSize = Dimension(0, height)
}
@RequiresEdt
private fun estimatedLines(ed: SessionEditorTextField): Int {
val width = space(ed)
if (width <= 0) return (ed.text.count { it == '\n' } + 1).coerceAtLeast(1)
@@ -519,6 +541,7 @@ class QuestionView(
}.coerceAtLeast(1)
}
@RequiresEdt
private fun space(component: Component): Int {
if (component.width > 0) return component.width
var node = component.parent
@@ -533,6 +556,7 @@ class QuestionView(
}
/** Re-syncs the current page after the custom row toggle changes. */
@RequiresEdt
private fun refreshCustomRow() {
val q = question ?: return
syncPage()
@@ -541,9 +565,10 @@ class QuestionView(
customEditor?.requestFocusInWindow()
}
syncControls(q)
scroll()
scroll(follow())
}
@RequiresEdt
private fun radioRow(opt: QuestionOption, set: MutableSet<String>, group: ButtonGroup): JPanel {
val radio = JBRadioButton().apply {
actionCommand = opt.label
@@ -565,6 +590,7 @@ class QuestionView(
return optionRow(radio, opt)
}
@RequiresEdt
private fun checkboxRow(opt: QuestionOption, set: MutableSet<String>): JPanel {
val box = JBCheckBox().apply {
actionCommand = opt.label
@@ -578,6 +604,7 @@ class QuestionView(
return optionRow(box, opt)
}
@RequiresEdt
private fun optionRow(toggle: AbstractButton, opt: QuestionOption): JPanel {
val row = JPanel(BorderLayout()).apply {
isOpaque = false
@@ -620,6 +647,7 @@ class QuestionView(
return row
}
@RequiresEdt
private fun text(value: String, color: Color, bold: Boolean = false): JBTextArea {
val area = object : JBTextArea(value) {
override fun getPreferredSize() = withWidth(super.getPreferredSize().height)
@@ -674,13 +702,15 @@ class QuestionView(
private fun direct(q: Question): Boolean = single(q)
@RequiresEdt
private fun goBack() {
if (idx <= 0) return
idx--
syncPage()
scroll()
scroll(true)
}
@RequiresEdt
private fun goForward() {
val q = question ?: return
if (idx >= q.items.size || !isReady(idx)) return
@@ -691,25 +721,28 @@ class QuestionView(
if (!toReview) {
idx++
syncPage()
scroll()
scroll(true)
}
}
@RequiresEdt
private fun goReview() {
val q = question ?: return
if (idx == q.items.size - 1 && isReady(idx)) {
idx = q.items.size
syncPage()
scroll()
scroll(true)
}
}
@RequiresEdt
private fun refreshSelection() {
question?.let(::syncControls)
refresh()
scroll()
scroll(follow())
}
@RequiresEdt
private fun doReply() {
val id = request ?: return
if ((question?.items?.indices ?: return).any { !isReady(it) }) return
@@ -717,14 +750,18 @@ class QuestionView(
val opts = (question?.items?.indices ?: return).map { optionAnswers(it) }
reply(id, QuestionReplyDto(answers), opts)
hideView()
scroll(follow())
}
@RequiresEdt
private fun doReject() {
val id = request ?: return
reject(id)
hideView()
scroll(follow())
}
@RequiresEdt
private fun setFont(area: JBTextArea, bold: Boolean): Boolean {
val font = if (bold) style.boldFont else style.regularFont
if (area.font == font) return false
@@ -732,6 +769,7 @@ class QuestionView(
return true
}
@RequiresEdt
private fun refresh() {
revalidate()
repaint()
@@ -6,89 +6,32 @@ import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.base.PartView
import ai.kilocode.client.session.views.base.PrimarySessionPartView
import ai.kilocode.client.ui.UiStyle
import com.intellij.icons.AllIcons
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.JBUI
import java.awt.BorderLayout
import java.awt.Color
import java.awt.Component
import java.awt.Cursor
import java.awt.Font
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.Box
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.SwingUtilities
class TodoWriteView(tool: Tool) : PartView() {
class TodoWriteView(tool: Tool, private val parts: TodoParts = todoParts()) :
PrimarySessionPartView(parts.header, parts.list, expanded = true) {
override val contentId = tool.id
private var item = tool
private var style = SessionEditorStyle.current()
private val root = JPanel(BorderLayout()).apply {
isOpaque = true
background = SessionUiStyle.View.surface()
border = SessionUiStyle.View.card()
}
private val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = true
background = SessionUiStyle.View.header()
border = JBUI.Borders.empty(
JBUI.scale(SessionUiStyle.View.CARD_VERTICAL_PADDING),
JBUI.scale(SessionUiStyle.View.CARD_HORIZONTAL_PADDING),
)
}
private val glyph = JBLabel(AllIcons.Actions.Checked)
private val title = JBLabel(KiloBundle.message("session.part.todo.title"))
private val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() }
private val arrow = JBLabel(AllIcons.General.ArrowDown)
private val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = false
}
private val controls: JComponent = Box.createHorizontalBox().apply { add(arrow) }
private val list = TodoListPanel()
private val click = object : MouseAdapter() {
override fun mouseClicked(e: MouseEvent) {
toggle()
}
}
private val mouse = object : MouseAdapter() {
override fun mouseEntered(e: MouseEvent) {
setHover(true)
}
override fun mouseExited(e: MouseEvent) {
if (inside(e)) return
setHover(false)
}
}
init {
layout = BorderLayout()
isOpaque = false
center.add(title, BorderLayout.WEST)
center.add(sub, BorderLayout.CENTER)
header.add(glyph, BorderLayout.WEST)
header.add(center, BorderLayout.CENTER)
header.add(controls, BorderLayout.EAST)
root.add(header, BorderLayout.NORTH)
root.add(list, BorderLayout.CENTER)
list.border = JBUI.Borders.compound(
bindHeader(parts.glyph, parts.title, parts.sub, parts.center, parts.controls)
parts.list.border = JBUI.Borders.compound(
SessionUiStyle.View.cardTop(),
JBUI.Borders.empty(UiStyle.Gap.sm(), UiStyle.Gap.md()),
)
listOf(header, glyph, title, sub, arrow, center, controls).forEach {
bind(it)
it.addMouseListener(click)
}
applyStyle(style)
add(root, BorderLayout.CENTER)
sync()
}
@@ -101,98 +44,72 @@ class TodoWriteView(tool: Tool) : PartView() {
override fun applyStyle(style: SessionEditorStyle) {
this.style = style
var changed = false
changed = setFont(title, style.boldEditorFont) || changed
changed = setFont(sub, style.transcriptFont) || changed
list.applyStyle(style)
changed = setFont(parts.title, style.boldEditorFont) || changed
changed = setFont(parts.sub, style.transcriptFont) || changed
parts.list.applyStyle(style)
if (changed) refresh()
}
fun toggle() {
val changed = if (isExpanded()) detach() else attach()
if (!changed) return
syncArrow()
refresh()
}
fun isExpanded() = list.parent === root
fun labelText(): String = listOf(title.text, sub.text).filter { it.isNotBlank() }.joinToString(" ")
internal fun rowCount() = list.rowCount()
internal fun rowText(index: Int) = list.rowText(index)
internal fun rowChecked(index: Int) = list.rowChecked(index)
internal fun rowCheckboxOpaque(index: Int) = list.rowCheckboxOpaque(index)
internal fun rowForeground(index: Int) = list.rowForeground(index)
internal fun hiddenText() = list.hiddenText()
internal fun titleFont() = title.font
internal fun subtitleFont() = sub.font
fun labelText(): String = listOf(parts.title.text, parts.sub.text).filter { it.isNotBlank() }.joinToString(" ")
internal fun rowCount() = parts.list.rowCount()
internal fun rowText(index: Int) = parts.list.rowText(index)
internal fun rowChecked(index: Int) = parts.list.rowChecked(index)
internal fun rowCheckboxOpaque(index: Int) = parts.list.rowCheckboxOpaque(index)
internal fun rowForeground(index: Int) = parts.list.rowForeground(index)
internal fun hiddenText() = parts.list.hiddenText()
internal fun titleFont() = parts.title.font
internal fun subtitleFont() = parts.sub.font
override fun dumpLabel() = "TodoWriteView#$contentId(${labelText()})"
private fun sync() {
sub.text = subtitle(item)
parts.sub.text = subtitle(item)
val view = item.todoView
val compact = view?.mode == "compact"
val rows = if (compact) view.todos else item.todos
list.update(
parts.list.update(
rows,
hiddenBefore = if (compact) view.hiddenBefore else 0,
hiddenAfter = if (compact) view.hiddenAfter else 0,
)
syncArrow()
syncExpandable(true)
refresh()
}
private fun attach(): Boolean {
if (isExpanded()) return false
root.add(list, BorderLayout.CENTER)
return true
}
private fun detach(): Boolean {
if (!isExpanded()) return false
root.remove(list)
return true
}
private fun syncArrow() {
arrow.icon = if (isExpanded()) AllIcons.General.ArrowDown else AllIcons.General.ArrowRight
}
private fun setHover(value: Boolean) {
val color = if (value) SessionUiStyle.View.headerHover() else SessionUiStyle.View.header()
if (same(header.background, color)) return
header.background = color
header.repaint()
}
private fun inside(e: MouseEvent): Boolean {
val point = SwingUtilities.convertPoint(e.component, e.point, header)
return header.contains(point)
}
private fun bind(component: Component) {
component.cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
component.addMouseListener(mouse)
}
private fun refresh() {
revalidate()
repaint()
}
companion object {
fun canRender(tool: Tool) = tool.name == "todowrite" && tool.state == ToolExecState.COMPLETED
}
}
class TodoParts(
val header: JPanel,
val glyph: JBLabel,
val title: JBLabel,
val sub: JBLabel,
val center: JPanel,
val controls: JComponent,
val list: TodoListPanel,
)
private fun todoParts(): TodoParts {
val glyph = JBLabel(AllIcons.Actions.Checked)
val title = JBLabel(KiloBundle.message("session.part.todo.title"))
val sub = JBLabel().apply { foreground = UiStyle.Colors.weak() }
val center = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = false
add(title, BorderLayout.WEST)
add(sub, BorderLayout.CENTER)
}
val controls = Box.createHorizontalBox()
val header = JPanel(BorderLayout(JBUI.scale(SessionUiStyle.View.CARD_LAYOUT_GAP), 0)).apply {
isOpaque = false
add(glyph, BorderLayout.WEST)
add(center, BorderLayout.CENTER)
add(controls, BorderLayout.EAST)
}
return TodoParts(header, glyph, title, sub, center, controls, TodoListPanel())
}
private fun subtitle(tool: Tool): String {
val total = tool.todos.size
if (total == 0) return ""
@@ -205,5 +122,3 @@ private fun setFont(component: JComponent, font: Font): Boolean {
component.font = font
return true
}
private fun same(a: Color?, b: Color): Boolean = a?.rgb == b.rgb
@@ -11,7 +11,7 @@ import java.awt.font.FontRenderContext
import javax.swing.Icon
internal class FilledBadgeIcon(
private val text: String,
internal val text: String,
private val bg: Color,
private val fg: Color,
) : Icon {
@@ -70,6 +70,26 @@ object UiStyle {
/** Filled badge text color paired with [badgeBg]. */
fun badgeFg(): Color = JBColor(Color.BLACK, UIUtil.getLabelForeground())
fun runningBadgeBg(): Color = JBColor.namedColor(
"Kilo.History.runningBadgeBackground",
JBColor(0xF5C542, 0x7A5A00),
)
fun runningBadgeFg(): Color = JBColor.namedColor(
"Kilo.History.runningBadgeForeground",
JBColor(Color.BLACK, Color.WHITE),
)
fun activityBadgeBg(): Color = JBColor.namedColor(
"Kilo.History.activityBadgeBackground",
JBUI.CurrentTheme.Link.Foreground.ENABLED,
)
fun activityBadgeFg(): Color = JBColor.namedColor(
"Kilo.History.activityBadgeForeground",
Color.WHITE,
)
/** Card border color shared across profile cards. */
fun cardBorder(): Color = JBColor.namedColor("Component.borderColor", JBColor.border())
@@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle id="ScrollQuestion.Background" cx="20" cy="20" r="18" fill="#E08800"/>
<path id="ScrollQuestion.Foreground" d="M15.75 15.5C15.95 12.95 17.85 11.5 20.25 11.5C22.95 11.5 25 13.2 25 15.8C25 17.7 24 18.85 22.3 19.9C20.75 20.85 20.15 21.55 20.15 23.1V24" stroke="#FFFFFF" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle id="ScrollQuestion.ForegroundDot" cx="19.95" cy="28.25" r="1.25" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 542 B

@@ -0,0 +1,5 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle id="ScrollQuestion.Background" cx="20" cy="20" r="18" fill="#E08800"/>
<path id="ScrollQuestion.Foreground" d="M15.75 15.5C15.95 12.95 17.85 11.5 20.25 11.5C22.95 11.5 25 13.2 25 15.8C25 17.7 24 18.85 22.3 19.9C20.75 20.85 20.15 21.55 20.15 23.1V24" stroke="#FFFFFF" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<circle id="ScrollQuestion.ForegroundDot" cx="19.95" cy="28.25" r="1.25" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 542 B

@@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.99935 2.08203L17.0827 4.3737V9.92565C17.0827 14.0694 13.3327 16.2487 9.99935 18.047C6.66602 16.2487 2.91602 14.0694 2.91602 9.92565V4.3737L9.99935 2.08203Z" fill="#6C707E"/>
<path d="M7.49935 9.3737L9.16602 11.0404L12.4994 7.70703" stroke="#FFFFFF" stroke-width="1.4" stroke-linecap="square" stroke-linejoin="round"/>
<circle cx="15" cy="15" r="2.5" fill="#73C991"/>
<circle cx="15" cy="15" r="3" stroke="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 539 B

@@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.99935 2.08203L17.0827 4.3737V9.92565C17.0827 14.0694 13.3327 16.2487 9.99935 18.047C6.66602 16.2487 2.91602 14.0694 2.91602 9.92565V4.3737L9.99935 2.08203Z" fill="#CED0D6"/>
<path d="M7.49935 9.3737L9.16602 11.0404L12.4994 7.70703" stroke="#000000" stroke-width="1.4" stroke-linecap="square" stroke-linejoin="round"/>
<circle cx="15" cy="15" r="2.5" fill="#73C991"/>
<circle cx="15" cy="15" r="3" stroke="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 539 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.49935 9.3737L9.16602 11.0404L12.4994 7.70703M9.99935 2.08203L17.0827 4.3737V9.92565C17.0827 14.0694 13.3327 16.2487 9.99935 18.047C6.66602 16.2487 2.91602 14.0694 2.91602 9.92565V4.3737L9.99935 2.08203Z" stroke="#6C707E" stroke-linecap="square"/>
</svg>

After

Width:  |  Height:  |  Size: 364 B

@@ -1,3 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path fill="#AFB1B3" d="M8 1L2 3.5v4C2 11.1 4.7 14.1 8 15c3.3-.9 6-3.9 6-7.5v-4L8 1zm0 6.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3zm0 3c-1.7 0-2-.75-2-.75V10c0-.55 1-1 2-1s2 .45 2 1v-.25S9.7 10.5 8 10.5z"/>
<svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.49935 9.3737L9.16602 11.0404L12.4994 7.70703M9.99935 2.08203L17.0827 4.3737V9.92565C17.0827 14.0694 13.3327 16.2487 9.99935 18.047C6.66602 16.2487 2.91602 14.0694 2.91602 9.92565V4.3737L9.99935 2.08203Z" stroke="#CED0D6" stroke-linecap="square"/>
</svg>

Before

Width:  |  Height:  |  Size: 308 B

After

Width:  |  Height:  |  Size: 364 B

@@ -39,6 +39,11 @@
defaultValue="150"
restartRequired="false"
overrides="false"/>
<registryKey key="kilo.session.inactive.dispose"
description="Dispose inactive session UI when switching sessions instead of retaining it in memory."
defaultValue="false"
restartRequired="false"
overrides="false"/>
</extensions>
<actions>
@@ -80,6 +85,10 @@
description="Send the current Kilo prompt">
<keyboard-shortcut keymap="$default"
first-keystroke="ENTER"/>
<keyboard-shortcut keymap="Mac OS X 10.5+"
first-keystroke="meta ENTER"/>
<keyboard-shortcut keymap="$default"
first-keystroke="control ENTER"/>
</action>
<action id="Kilo.StopSession"
@@ -12,6 +12,7 @@ session.empty.loading=Loading...
session.empty.recent=RECENT
session.showHistory=Show History
session.scroll.bottom=Scroll to bottom
session.scroll.question=Scroll to question
session.tab.new=New Session
session.tab.untitled=Untitled Session
@@ -125,6 +126,10 @@ prompt.placeholder.with.newline=Type a message... ({0} for new line)
prompt.button.send=Send
prompt.button.stop=Stop
prompt.button.send.tooltip.stop=To stop, press {0}
prompt.action.autoApprove.enable=Enable auto-approve
prompt.action.autoApprove.disable=Disable auto-approve
prompt.action.autoApprove.enabled.tooltip=Auto-approve is enabled. Permission prompts will be approved automatically.
prompt.action.autoApprove.disabled.tooltip=Auto-approve is disabled. Click to approve permission prompts automatically.
mode.picker.tooltip=Select mode
mode.picker.deprecated=deprecated
model.picker.tooltip=Select model
@@ -153,6 +158,10 @@ history.rename.title=Rename Session
history.rename.prompt=New session name:
history.cloud.load.more=Load more
history.cloud.repo.only=Only this repository
history.badge.loginRequired=Login Required
history.badge.permission=Permission
history.badge.plan=Plan
history.badge.question=Question
history.group.today=Today
history.group.yesterday=Yesterday
history.group.week=This Week
@@ -56,6 +56,19 @@ class SendPromptActionTest : BasePlatformTestCase() {
assertTrue(action.promote(listOf(action), absent).isEmpty())
}
fun `test frontend descriptor registers send prompt shortcuts`() {
val xml = javaClass.classLoader.getResourceAsStream("kilo.jetbrains.frontend.xml")
?.bufferedReader()
?.use { it.readText() }
?: error("missing frontend descriptor")
assertTrue(xml.contains("id=\"Kilo.SendPrompt\""))
assertTrue(xml.contains("first-keystroke=\"ENTER\""))
assertTrue(xml.contains("keymap=\"Mac OS X 10.5+\""))
assertTrue(xml.contains("first-keystroke=\"meta ENTER\""))
assertTrue(xml.contains("first-keystroke=\"control ENTER\""))
}
private fun event(action: SendPromptAction, ctx: SendPromptContext?): AnActionEvent {
val presentation = Presentation().apply { copyFrom(action.templatePresentation) }
return AnActionEvent.createFromDataContext("", presentation, context(ctx))
@@ -9,7 +9,14 @@ import ai.kilocode.rpc.dto.QuestionOptionDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionStatusDto
import ai.kilocode.rpc.dto.ToolRefDto
import ai.kilocode.client.session.ui.prompt.PromptPanel
import ai.kilocode.client.plugin.KiloBundle
import com.intellij.ui.EditorTextField
import com.intellij.ui.components.JBRadioButton
import com.intellij.util.ui.JBUI
import java.awt.Container
import javax.swing.AbstractButton
import javax.swing.JButton
import kotlinx.coroutines.CompletableDeferred
@Suppress("UnstableApiUsage")
@@ -137,6 +144,22 @@ class SessionScrollTest : SessionUiTestBase() {
assertBottom(bar)
}
fun `test no-op wheel at bottom does not cancel following`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setBottom(bar)
wheelNoop()
emit(ChatEventDto.MessageUpdated("ses_test", message("noop_wheel_tail")), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("noop_wheel_part", "noop_wheel_tail", "text", "tail line\n".repeat(120))), flush = false)
forceFlush()
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test part delta follows bottom after height growth`() {
showMessages()
fillTranscript(24)
@@ -177,6 +200,131 @@ class SessionScrollTest : SessionUiTestBase() {
assertEquals(value, bar.value)
}
fun `test long prompt message follows when transcript is at bottom`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setBottom(bar)
val id = "long_prompt_bottom"
emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("long_prompt_part", id, "text", "prompt line\n".repeat(120))), flush = false)
forceFlush()
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test long prompt message preserves middle scroll position`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setValue(bar, bottom(bar) / 2)
val value = bar.value
val id = "long_prompt_middle"
emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("long_prompt_part", id, "text", "prompt line\n".repeat(120))), flush = false)
forceFlush()
drainScroll()
assertEquals(value, bar.value)
assertTrue(jumpButton().isVisible)
}
fun `test sending long prompt follows after prompt editor shrinks`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setBottom(bar)
findAll<EditorTextField>(ui).first().text = "prompt line\n".repeat(80)
drainScroll()
assertBottom(bar)
find<PromptPanel>(ui).send()
settleShort(100)
val text = rpc.prompts.last().third.parts.single().text
val id = "long_prompt_send"
emit(ChatEventDto.MessageUpdated("ses_test", message(id)), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("long_prompt_send_part", id, "text", text)), flush = false)
forceFlush()
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test long prompt followed by instant reasoning stays at bottom`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setBottom(bar)
findAll<EditorTextField>(ui).first().text = "prompt line\n".repeat(80)
drainScroll()
find<PromptPanel>(ui).send()
settleShort(100)
val text = rpc.prompts.last().third.parts.single().text
emit(ChatEventDto.MessageUpdated("ses_test", message("prompt_reasoning_user")), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("prompt_reasoning_text", "prompt_reasoning_user", "text", text)), flush = false)
emit(ChatEventDto.MessageUpdated("ses_test", message("prompt_reasoning_assistant").copy(role = "assistant")), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("prompt_reasoning_part", "prompt_reasoning_assistant", "reasoning", "thinking")), flush = false)
forceFlush()
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test prompt editor growth preserves middle scroll position`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setValue(bar, bottom(bar) / 2)
val value = bar.value
findAll<EditorTextField>(ui).first().text = "prompt line\n".repeat(80)
drainScroll()
assertEquals(value, bar.value)
assertTrue(jumpButton().isVisible)
}
fun `test prompt editor growth in middle does not resume following`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setValue(bar, bottom(bar) / 2)
findAll<EditorTextField>(ui).first().text = "prompt line\n".repeat(80)
drainScroll()
val value = bar.value
emit(ChatEventDto.MessageUpdated("ses_test", message("prompt_growth_middle")), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("prompt_growth_part", "prompt_growth_middle", "text", "tail line\n".repeat(80))), flush = false)
forceFlush()
drainScroll()
assertEquals(value, bar.value)
assertTrue(jumpButton().isVisible)
}
fun `test large question after reasoning stays at bottom`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
setBottom(bar)
val mid = "question_reasoning_assistant"
emit(ChatEventDto.MessageUpdated("ses_test", message(mid).copy(role = "assistant")), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("question_reasoning_part", mid, "reasoning", "thinking")), flush = false)
emit(ChatEventDto.QuestionAsked("ses_test", largeQuestion("q_large_after_reasoning")), flush = false)
forceFlush()
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test batched update samples scroll once before model changes`() {
showMessages()
fillTranscript(24)
@@ -363,6 +511,293 @@ class SessionScrollTest : SessionUiTestBase() {
assertTrue(jumpButton().isVisible)
}
fun `test question overlay replaces scroll icon and still jumps to bottom`() {
showMessages()
fillTranscript(24)
val button = jumpButton()
val bar = scrollBar()
setValue(bar, bottom(bar) / 2)
drainScroll()
val icon = button.icon
emit(ChatEventDto.QuestionAsked("ses_test", question("q_overlay")))
drainScroll()
assertTrue(button.isVisible)
assertNotSame(icon, button.icon)
assertEquals(KiloBundle.message("session.scroll.question"), button.toolTipText)
click(button)
drainScroll()
assertBottom(bar)
assertFalse(button.isVisible)
}
fun `test question overlay returns to scroll icon when question resolves`() {
showMessages()
fillTranscript(24)
val button = jumpButton()
val bar = scrollBar()
setValue(bar, bottom(bar) / 2)
drainScroll()
emit(ChatEventDto.QuestionAsked("ses_test", question("q_resolve")))
drainScroll()
val icon = button.icon
val value = bar.value
emit(ChatEventDto.QuestionReplied("ses_test", "q_resolve"))
drainScroll()
assertEquals(value, bar.value)
assertTrue(button.isVisible)
assertNotSame(icon, button.icon)
assertEquals(KiloBundle.message("session.scroll.bottom"), button.toolTipText)
}
fun `test plan followup question keeps scroll icon`() {
showMessages()
fillTranscript(24)
val button = jumpButton()
val bar = scrollBar()
setValue(bar, bottom(bar) / 2)
drainScroll()
val icon = button.icon
emit(ChatEventDto.QuestionAsked("ses_test", question("q_plan", plan = true)))
drainScroll()
assertTrue(button.isVisible)
assertSame(icon, button.icon)
}
fun `test question carousel navigation follows when transcript is at bottom`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
emit(ChatEventDto.QuestionAsked("ses_test", multiQuestion("q_nav_bottom")))
drainScroll()
setBottom(bar)
option<JBRadioButton>("Minimal").doClick()
button("Next").doClick()
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
option<JBRadioButton>("Unit").doClick()
button("Review").doClick()
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
button("Back").doClick()
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test question carousel navigation follows even when transcript is in middle`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
emit(ChatEventDto.QuestionAsked("ses_test", multiQuestion("q_nav_middle")))
drainScroll()
setValue(bar, bottom(bar) / 2)
option<JBRadioButton>("Minimal").doClick()
button("Next").doClick()
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test question top forward icon follows immediately from middle`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
emit(ChatEventDto.QuestionAsked("ses_test", multiQuestion("q_icon_next_middle")))
drainScroll()
setValue(bar, bottom(bar) / 2)
option<JBRadioButton>("Minimal").doClick()
icon(KiloBundle.message("session.question.next")).doClick()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test question review navigation follows immediately from middle`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
emit(ChatEventDto.QuestionAsked("ses_test", multiQuestion("q_review_middle")))
drainScroll()
option<JBRadioButton>("Minimal").doClick()
button("Next").doClick()
drainScroll()
option<JBRadioButton>("Unit").doClick()
setValue(bar, bottom(bar) / 2)
button("Review").doClick()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test question review back footer follows immediately from middle`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
emit(ChatEventDto.QuestionAsked("ses_test", multiQuestion("q_review_back_middle")))
drainScroll()
option<JBRadioButton>("Minimal").doClick()
button("Next").doClick()
drainScroll()
option<JBRadioButton>("Unit").doClick()
button("Review").doClick()
drainScroll()
setValue(bar, bottom(bar) / 2)
button("Back").doClick()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test forced question navigation resumes following subsequent updates`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
emit(ChatEventDto.QuestionAsked("ses_test", multiQuestion("q_nav_resume_follow")))
drainScroll()
setValue(bar, bottom(bar) / 2)
option<JBRadioButton>("Minimal").doClick()
icon(KiloBundle.message("session.question.next")).doClick()
emit(ChatEventDto.MessageUpdated("ses_test", message("q_nav_resume_tail")), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("q_nav_resume_part", "q_nav_resume_tail", "text", "tail line\n".repeat(80))), flush = false)
forceFlush()
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test question carousel back to large question follows immediately`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
emit(ChatEventDto.QuestionAsked("ses_test", largeQuestion("q_nav_large")))
drainScroll()
setValue(bar, bottom(bar) / 2)
option<JBRadioButton>("Go").doClick()
button("Next").doClick()
drainScroll()
setValue(bar, bottom(bar) / 2)
icon(KiloBundle.message("session.question.back")).doClick()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test question reply follows after card hides when transcript is at bottom`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
emit(ChatEventDto.QuestionAsked("ses_test", question("q_reply_bottom")))
drainScroll()
setBottom(bar)
option<JBRadioButton>("A").doClick()
button("Submit").doClick()
settleShort(100)
drainScroll()
assertEquals("q_reply_bottom", rpc.questionReplies.single().first)
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test question reply preserves middle scroll position after card hides`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
emit(ChatEventDto.QuestionAsked("ses_test", question("q_reply_middle")))
drainScroll()
setValue(bar, bottom(bar) / 2)
val value = bar.value
option<JBRadioButton>("A").doClick()
button("Submit").doClick()
settleShort(100)
drainScroll()
assertEquals("q_reply_middle", rpc.questionReplies.single().first)
assertEquals(value, bar.value)
assertTrue(jumpButton().isVisible)
}
fun `test custom question answer growth follows when transcript is at bottom`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
emit(ChatEventDto.QuestionAsked("ses_test", customQuestion("q_custom_bottom")))
drainScroll()
setBottom(bar)
option<JBRadioButton>("").doClick()
drainScroll()
findAll<EditorTextField>(ui).last().text = "custom line\n".repeat(80)
drainScroll()
assertBottom(bar)
assertFalse(jumpButton().isVisible)
}
fun `test custom question answer growth preserves middle scroll position`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
emit(ChatEventDto.QuestionAsked("ses_test", customQuestion("q_custom_middle")))
drainScroll()
setValue(bar, bottom(bar) / 2)
val value = bar.value
option<JBRadioButton>("").doClick()
drainScroll()
findAll<EditorTextField>(ui).last().text = "custom line\n".repeat(80)
drainScroll()
assertEquals(value, bar.value)
assertTrue(jumpButton().isVisible)
}
fun `test question option selection in middle does not resume following`() {
showMessages()
fillTranscript(24)
val bar = scrollBar()
emit(ChatEventDto.QuestionAsked("ses_test", question("q_select_middle")))
drainScroll()
setValue(bar, bottom(bar) / 2)
val value = bar.value
option<JBRadioButton>("A").doClick()
drainScroll()
emit(ChatEventDto.MessageUpdated("ses_test", message("q_select_tail")), flush = false)
emit(ChatEventDto.PartUpdated("ses_test", part("q_select_part", "q_select_tail", "text", "tail line\n".repeat(80))), flush = false)
forceFlush()
drainScroll()
assertEquals(value, bar.value)
assertTrue(jumpButton().isVisible)
}
fun `test login required appearing at bottom keeps scroll at bottom`() {
showMessages()
fillTranscript(24)
@@ -394,7 +829,29 @@ class SessionScrollTest : SessionUiTestBase() {
// ------ helpers ------
private fun question(id: String) = QuestionRequestDto(
private fun button(text: String): JButton = findAll<JButton>(ui).first { it.text == text }
private fun icon(text: String): JButton = findAll<JButton>(ui).first { it.toolTipText == text }
private inline fun <reified T> option(label: String): T where T : AbstractButton =
findAll<T>(ui).first { it.actionCommand == label }
private inline fun <reified T> findAll(root: Container = ui): List<T> = findAll(root, T::class.java)
private fun <T> findAll(root: Container, cls: Class<T>): List<T> {
val out = mutableListOf<T>()
if (cls.isInstance(root)) out.add(cls.cast(root))
for (child in root.components) {
if (child is Container && child !is AbstractButton) {
out.addAll(findAll(child, cls))
} else if (cls.isInstance(child)) {
out.add(cls.cast(child))
}
}
return out
}
private fun question(id: String, plan: Boolean = false) = QuestionRequestDto(
id = id,
sessionID = "ses_test",
questions = listOf(
@@ -404,6 +861,73 @@ class SessionScrollTest : SessionUiTestBase() {
options = listOf(QuestionOptionDto("A", "Option A")),
multiple = false,
custom = true,
questionKey = if (plan) "plan.followup.question" else null,
),
),
tool = ToolRefDto("msg1", "call1"),
)
private fun multiQuestion(id: String) = QuestionRequestDto(
id = id,
sessionID = "ses_test",
questions = listOf(
QuestionInfoDto(
question = "Choose approach",
header = "Approach",
options = listOf(
QuestionOptionDto("Minimal", "Smallest safe change"),
QuestionOptionDto("Balanced", "Focused implementation"),
),
multiple = false,
custom = false,
),
QuestionInfoDto(
question = "Choose test level",
header = "Test Level",
options = listOf(
QuestionOptionDto("Unit", "Unit tests"),
QuestionOptionDto("Integration", "Integration tests"),
),
multiple = false,
custom = false,
),
),
tool = ToolRefDto("msg1", "call1"),
)
private fun largeQuestion(id: String) = QuestionRequestDto(
id = id,
sessionID = "ses_test",
questions = listOf(
QuestionInfoDto(
question = "Which backend programming language do you prefer for your project?",
header = "Backend Language",
options = listOf(
QuestionOptionDto("TypeScript", "Offers excellent ecosystem with Node.js and npm, strong typing for maintainability, good performance via V8, but may have higher memory usage than compiled languages; learning curve is moderate if you know JavaScript."),
QuestionOptionDto("Go", "Provides high performance with compiled binaries, simple concurrency model, growing ecosystem, and fast compile times; learning curve is gentle due to minimalistic language design."),
QuestionOptionDto("Rust", "Delivers top-tier performance and memory safety without garbage collector, steep learning curve due to ownership concepts, but expanding ecosystem and excellent for system-level services."),
QuestionOptionDto("Python", "Boasts vast ecosystem, ease of use and rapid development, but interpreted performance is lower than compiled languages; learning curve is very gentle, ideal for prototyping."),
),
multiple = false,
custom = true,
),
QuestionInfoDto("Choose database", "Database", listOf(QuestionOptionDto("Postgres", "Reliable relational default")), false, false),
QuestionInfoDto("Choose deployment target", "Deploy", listOf(QuestionOptionDto("Cloud", "Managed environment")), false, false),
QuestionInfoDto("Choose testing style", "Testing", listOf(QuestionOptionDto("Integration", "Exercise real implementation")), false, false),
),
tool = ToolRefDto("msg1", "call1"),
)
private fun customQuestion(id: String) = QuestionRequestDto(
id = id,
sessionID = "ses_test",
questions = listOf(
QuestionInfoDto(
question = "Describe approach",
header = "Approach",
options = emptyList(),
multiple = false,
custom = true,
),
),
tool = ToolRefDto("msg1", "call1"),
@@ -4,19 +4,34 @@ import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.history.HistoryController
import ai.kilocode.client.session.history.HistoryDataKeys
import ai.kilocode.client.session.history.HistoryPanel
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.session.model.Permission
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.Question
import ai.kilocode.client.session.model.QuestionItem
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionStatusDto
import ai.kilocode.rpc.dto.SessionTimeDto
import com.intellij.openapi.actionSystem.DataProvider
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
@@ -120,6 +135,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
fun `test opening same existing session reuses component`() {
disableInactiveDispose()
val manager = manager()
val session = session("ses_1")
@@ -134,6 +150,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
fun `test prompted blank session is reused from recents`() {
disableInactiveDispose()
val manager = manager()
manager.newSession()
val first = active(manager)
@@ -180,6 +197,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
fun `test inactive sessions keep queued style updates`() {
disableInactiveDispose()
val manager = manager()
manager.openSession(session("ses_1"))
val first = active(manager) as SessionUi
@@ -193,6 +211,115 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
assertSame(style, first.currentStyle())
}
fun `test dispose inactive flag disposes previous session ui`() {
enableDisposeInactiveUi()
val manager = manager()
manager.openSession(session("ses_1"))
val first = active(manager)
settle()
manager.openSession(session("ses_2"))
assertFalse(ui.contains(first))
assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2"), created)
}
fun `test dispose inactive flag recreates reopened session ui`() {
enableDisposeInactiveUi()
val manager = manager()
manager.openSession(session("ses_1"))
val first = active(manager)
settle()
manager.openSession(session("ses_2"))
manager.openSession(session("ses_1"))
assertNotSame(first, active(manager))
assertEquals(listOf("/test" to "ses_1", "/test" to "ses_2", "/test" to "ses_1"), created)
}
fun `test dispose inactive flag keeps pending session for history overlays`() {
enableDisposeInactiveUi()
val history = JLabel("History")
val manager = manager(history = { _, _, _ -> history })
manager.openSession(session("ses_1"))
val first = active(manager)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
first.controller().model.setState(SessionState.AwaitingQuestion(question(plan = false)))
}
manager.showHistory()
assertSame(history, manager.component.getComponent(0))
assertTrue(ui.contains(first))
}
fun `test history overlays update with inactive dispose enabled`() {
enableDisposeInactiveUi()
lateinit var history: HistoryPanel
val manager = manager(history = { parent, _, _ ->
val controller = HistoryController(sessions, workspace, scope)
controller.local.replace(listOf(LocalHistoryItem(session("ses_1", "/test", "Stored"))))
HistoryPanel(parent, controller, manager = parent as SessionManager).also { history = it }
})
manager.openSession(session("ses_1", "/test", "Stored"))
val first = active(manager)
settle()
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
first.controller().model.setState(SessionState.AwaitingQuestion(question(plan = false)))
}
manager.showHistory()
settle()
val controller = history.getData(HistoryDataKeys.CONTROLLER.name) as HistoryController
controller.local.replace(listOf(LocalHistoryItem(session("ses_1", "/test", "Stored"))))
assertEquals(1, history.itemCount())
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
first.controller().model.setSession(session("ses_1", "/test", "Live"))
}
settle()
history.syncActivity()
assertTrue(ui.contains(first))
assertEquals("Live", history.titleText(0))
assertEquals(KiloBundle.message("history.badge.question"), history.badgeText(0))
}
fun `test history overlays update from hidden session stream metadata`() {
disableInactiveDispose()
lateinit var history: HistoryPanel
val manager = manager(history = { parent, _, _ ->
val controller = HistoryController(sessions, workspace, scope)
controller.local.replace(listOf(LocalHistoryItem(session("ses_1", "/test", "Stored"))))
HistoryPanel(parent, controller, manager = parent as SessionManager).also { history = it }
})
manager.openSession(session("ses_1", "/test", "Stored"))
settle()
manager.showHistory()
settle()
val controller = history.getData(HistoryDataKeys.CONTROLLER.name) as HistoryController
controller.local.replace(listOf(LocalHistoryItem(session("ses_1", "/test", "Stored"))))
assertEquals(1, history.itemCount())
kotlinx.coroutines.runBlocking {
rpc.events.emit(ChatEventDto.QuestionAsked("ses_1", rpcQuestion("q1")))
}
settle()
assertEquals(mapOf("ses_1" to SessionActivityKind.QUESTION), manager.activity())
assertEquals(KiloBundle.message("history.badge.question"), history.badgeText(0))
kotlinx.coroutines.runBlocking {
rpc.events.emit(ChatEventDto.SessionUpdated("ses_1", session("ses_1", "/test", "Live")))
}
settle()
assertEquals(mapOf("ses_1" to "Live"), manager.titles())
assertEquals("Live", history.titleText(0))
}
fun `test dispose removes active component`() {
val manager = manager()
@@ -215,6 +342,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
fun `test history back restores latest open session`() {
disableInactiveDispose()
val manager = manager()
manager.openSession(session("ses_1"))
@@ -287,6 +415,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
fun `test opening same cloud session while in-flight reuses existing ui`() {
disableInactiveDispose()
rpc.historyGate = kotlinx.coroutines.CompletableDeferred()
rpc.importedCloudSession = session("ses_imported")
val manager = manager()
@@ -306,6 +435,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
fun `test imported cloud session is reused when opened as local`() {
disableInactiveDispose()
rpc.importedCloudSession = session("ses_imported")
val manager = manager()
@@ -331,6 +461,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
fun `test opening same local session while in-flight reuses existing ui`() {
disableInactiveDispose()
val gate = kotlinx.coroutines.CompletableDeferred<Unit>()
rpc.historyGate = gate
val manager = manager()
@@ -368,6 +499,99 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
assertEquals(listOf("/test" to "ses_1", "/test" to "ses_1"), created)
}
fun `test activity reports permission for live session ui`() {
val manager = manager()
rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
manager.openSession(session("ses_1"))
val active = active(manager)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
active.controller().model.setState(SessionState.AwaitingPermission(permission("ses_1")))
}
assertEquals(mapOf("ses_1" to SessionActivityKind.PERMISSION), manager.activity())
}
fun `test activity includes service running without retained ui`() {
val manager = manager()
rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
settle()
assertEquals(mapOf("ses_1" to SessionActivityKind.RUNNING), manager.activity())
}
fun `test titles reports live session ui title`() {
val manager = manager()
manager.openSession(session("ses_1", "/test", "Stored"))
val active = active(manager)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
active.controller().model.setSession(session("ses_1", "/test", "Live"))
}
assertEquals(mapOf("ses_1" to "Live"), manager.titles())
}
fun `test activity reports plan and question separately`() {
disableInactiveDispose()
val manager = manager()
manager.openSession(session("ses_plan"))
val plan = active(manager)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
plan.controller().model.setState(SessionState.AwaitingQuestion(question(plan = true)))
}
manager.openSession(session("ses_question"))
val question = active(manager)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
question.controller().model.setState(SessionState.AwaitingQuestion(question(plan = false)))
}
assertEquals(
mapOf(
"ses_plan" to SessionActivityKind.PLAN,
"ses_question" to SessionActivityKind.QUESTION,
),
manager.activity(),
)
}
fun `test inactive dispose keeps permission session ui`() {
enableDisposeInactiveUi()
val manager = manager()
manager.openSession(session("ses_1"))
val first = active(manager)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
first.controller().model.setState(SessionState.AwaitingPermission(permission("ses_1")))
}
manager.openSession(session("ses_2"))
assertTrue(ui.contains(first))
assertEquals(mapOf("ses_1" to SessionActivityKind.PERMISSION), manager.activity())
}
fun `test inactive dispose keeps busy session ui`() {
enableDisposeInactiveUi()
val manager = manager()
manager.openSession(session("ses_1"))
val first = active(manager)
com.intellij.openapi.application.ApplicationManager.getApplication().invokeAndWait {
first.controller().model.setState(SessionState.Busy("running"))
}
manager.openSession(session("ses_2"))
assertTrue(ui.contains(first))
}
fun `test activity ignores disposed idle session ui`() {
enableDisposeInactiveUi()
val manager = manager()
manager.openSession(session("ses_1"))
val first = active(manager)
settle()
manager.openSession(session("ses_2"))
assertFalse(ui.contains(first))
assertEquals(emptyMap<String, SessionActivityKind>(), manager.activity())
}
private fun manager(
history: ((com.intellij.openapi.Disposable, (SessionRef) -> Unit, (String) -> Unit) -> JComponent)? = null,
): SessionSidePanelManager {
@@ -388,6 +612,7 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
}
},
resolve = { workspaces.workspace(it) },
status = { sessions.activity() },
history = history,
)
managers.add(manager)
@@ -396,6 +621,29 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
private fun active(manager: SessionSidePanelManager) = manager.component.getComponent(0) as JPanel
private fun enableDisposeInactiveUi() = setInactiveDispose(true)
private fun disableInactiveDispose() = setInactiveDispose(false)
private fun setInactiveDispose(enabled: Boolean) {
val key = "kilo.session.inactive.dispose"
Registry.mutateContributedKeys {
it + (key to RegistryKeyDescriptor(
key,
"Dispose inactive session UI when switching sessions instead of retaining it in memory.",
"false",
false,
false,
null,
null,
))
}
Disposer.register(testRootDisposable) {
Registry.mutateContributedKeys { it - key }
}
Registry.get(key).setValue(enabled, testRootDisposable)
}
private fun JPanel.controller(): ai.kilocode.client.session.controller.SessionController {
val field = SessionUi::class.java.getDeclaredField("controller")
field.isAccessible = true
@@ -440,4 +688,41 @@ class SessionSidePanelManagerTest : BasePlatformTestCase() {
version = 1.0,
)
private fun permission(id: String) = Permission(
id = "perm_$id",
sessionId = id,
name = "bash",
patterns = emptyList(),
always = emptyList(),
meta = PermissionMeta(),
)
private fun question(plan: Boolean) = Question(
id = "qst",
items = listOf(
QuestionItem(
question = "Question?",
header = "Header",
options = emptyList(),
multiple = false,
custom = false,
questionKey = if (plan) "plan.followup.question" else null,
),
),
)
private fun rpcQuestion(id: String) = QuestionRequestDto(
id = id,
sessionID = "ses_1",
questions = listOf(
QuestionInfoDto(
question = "Pick one",
header = "Choice",
options = emptyList(),
multiple = false,
custom = true,
),
),
)
}
@@ -88,7 +88,8 @@ class SessionUiFactoryTest : BasePlatformTestCase() {
testRootDisposable,
controller,
emptyList(),
) { manager.showHistory() }
history = { manager.showHistory() },
)
panel.clickShowHistory()
@@ -34,6 +34,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.awt.Container
import java.awt.event.MouseEvent
import java.awt.event.MouseWheelEvent
import javax.swing.JLabel
import javax.swing.JComponent
import javax.swing.JScrollBar
@@ -189,9 +190,19 @@ abstract class SessionUiTestBase : BasePlatformTestCase() {
}
protected fun setValue(bar: JScrollBar, value: Int) {
wheelNoop()
setValuePassive(bar, value)
}
protected fun setValuePassive(bar: JScrollBar, value: Int) {
bar.value = value.coerceIn(bar.minimum, bottom(bar))
}
protected fun wheelNoop() {
val event = MouseWheelEvent(scrollComponent(), MouseEvent.MOUSE_WHEEL, System.currentTimeMillis(), 0, 1, 1, 0, false, MouseWheelEvent.WHEEL_UNIT_SCROLL, 1, 1)
for (listener in scrollComponent().mouseWheelListeners) listener.mouseWheelMoved(event)
}
protected fun assertBottom(bar: JScrollBar) {
assertTrue("value=${bar.value} bottom=${bottom(bar)} max=${bar.maximum} visible=${bar.visibleAmount}", bar.value >= bottom(bar) - 1)
}
@@ -1,5 +1,6 @@
package ai.kilocode.client.session.controller
import ai.kilocode.client.plugin.KiloPluginSettings
import ai.kilocode.client.session.model.PermissionFileDiff
import ai.kilocode.client.session.model.PermissionMeta
import ai.kilocode.client.session.model.SessionState
@@ -18,10 +19,22 @@ import ai.kilocode.rpc.dto.QuestionOptionDto
import ai.kilocode.rpc.dto.QuestionReplyDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.ToolRefDto
import com.intellij.ide.util.PropertiesComponent
class PromptLifecycleTest : SessionControllerTestBase() {
override fun setUp() {
super.setUp()
edt { KiloPluginSettings.unsetAutoApprove() }
}
override fun tearDown() {
try {
edt { KiloPluginSettings.unsetAutoApprove() }
} finally {
super.tearDown()
}
}
fun `test PermissionAsked moves state to AwaitingPermission`() {
val (m, _, _) = prompted()
@@ -117,6 +130,103 @@ class PromptLifecycleTest : SessionControllerTestBase() {
assertTrue(m.model.state is SessionState.AwaitingPermission)
}
fun `test auto approve replies once to permission request`() {
val (m, _, _) = prompted()
edt { m.setAutoApprove(true) }
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
assertEquals(1, rpc.permissionReplies.size)
assertEquals("perm1", rpc.permissionReplies[0].first)
assertEquals("once", rpc.permissionReplies[0].third.reply)
assertSession(
"""
[code] [kilo/gpt-5] [busy] [considering next steps]
""",
m,
)
}
fun `test disabling auto approve before reply restores awaiting permission`() {
val (m, _, _) = prompted()
edt { m.setAutoApprove(true) }
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")), flush = false)
edt { m.setAutoApprove(false) }
flush()
assertTrue(rpc.permissionReplies.isEmpty())
assertSession(
"""
permission#perm1
tool: msg1/call1
name: edit
patterns: *.kt
always: <none>
file: src/A.kt
state: RESPONDING
metadata: kind=edit
[code] [kilo/gpt-5] [awaiting-permission]
""",
m,
)
}
fun `test enabling auto approve drains current permission`() {
val (m, _, _) = prompted()
emit(ChatEventDto.PermissionAsked("ses_test", permission("perm1")))
edt { m.setAutoApprove(true) }
flush()
assertEquals(1, rpc.permissionReplies.size)
assertEquals("perm1", rpc.permissionReplies[0].first)
assertEquals("once", rpc.permissionReplies[0].third.reply)
assertSession(
"""
[code] [kilo/gpt-5] [busy] [considering next steps]
""",
m,
)
}
fun `test enabling auto approve drains pending permissions`() {
val (m, _, _) = prompted()
rpc.pendingPermissionList.add(permission("perm_pending"))
edt { m.setAutoApprove(true) }
flush()
assertEquals(1, rpc.permissionReplies.size)
assertEquals("perm_pending", rpc.permissionReplies[0].first)
assertEquals("once", rpc.permissionReplies[0].third.reply)
}
fun `test auto approve drains pending permissions during recovery`() {
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
rpc.pendingPermissionList.add(permission("perm_pending"))
edt { KiloPluginSettings.setAutoApprove(true) }
val m = controller("ses_test")
flush()
assertEquals(1, rpc.permissionReplies.size)
assertEquals("perm_pending", rpc.permissionReplies[0].first)
assertFalse(m.model.state is SessionState.AwaitingPermission)
}
fun `test auto approve persists in properties`() {
val (m, _, _) = prompted()
assertFalse(KiloPluginSettings.getAutoApprove())
edt { m.setAutoApprove(true) }
assertTrue(KiloPluginSettings.getAutoApprove())
assertTrue(m.autoApprove)
}
fun `test QuestionReplied with wrong requestID is ignored`() {
val (m, _, _) = prompted()
@@ -6,8 +6,12 @@ import ai.kilocode.client.session.model.SessionModelEvent
import ai.kilocode.client.session.model.SessionState
import ai.kilocode.rpc.dto.ChatEventDto
import ai.kilocode.rpc.dto.DiffFileDto
import ai.kilocode.rpc.dto.QuestionInfoDto
import ai.kilocode.rpc.dto.QuestionOptionDto
import ai.kilocode.rpc.dto.QuestionRequestDto
import ai.kilocode.rpc.dto.SessionStatusDto
import ai.kilocode.rpc.dto.TodoDto
import ai.kilocode.rpc.dto.ToolRefDto
class SessionUpdateQueueTest : SessionControllerTestBase() {
@@ -38,6 +42,66 @@ class SessionUpdateQueueTest : SessionControllerTestBase() {
assertTrue(m.model.state is SessionState.Busy)
}
fun `test hidden controller applies question metadata without flushing transcript`() {
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test", flushMs = 250L)
val modelEvents = collectModelEvents(m)
flush()
modelEvents.clear()
hide(m)
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")), flush = false)
emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false)
settle()
assertModelEvents("""
StateChanged AwaitingQuestion
""", modelEvents)
assertTrue(m.model.state is SessionState.AwaitingQuestion)
assertNull(m.model.message("msg1"))
show(m)
settle()
assertTrue(m.model.state is SessionState.AwaitingQuestion)
assertNotNull(m.model.message("msg1"))
}
fun `test hidden controller applies session title metadata without show`() {
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test", flushMs = 250L)
flush()
hide(m)
emit(ChatEventDto.SessionUpdated("ses_test", session("ses_test", title = "Hidden title")), flush = false)
settle()
assertEquals("Hidden title", m.model.session?.title)
assertNull(m.model.message("msg1"))
}
fun `test hidden controller consumes matching question reply metadata`() {
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
val m = controller("ses_test", flushMs = 250L)
val modelEvents = collectModelEvents(m)
flush()
modelEvents.clear()
hide(m)
emit(ChatEventDto.QuestionAsked("ses_test", question("q1")), flush = false)
emit(ChatEventDto.QuestionReplied("ses_test", "q1"), flush = false)
settle()
assertModelEvents("""
StateChanged AwaitingQuestion
StateChanged Busy
""", modelEvents)
assertTrue(m.model.state is SessionState.Busy)
}
fun `test hidden controller condenses while hidden but does not flush`() {
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY)
projectRpc.state.value = workspaceReady()
@@ -363,6 +427,21 @@ class SessionUpdateQueueTest : SessionControllerTestBase() {
assertTrue(m.model.state is SessionState.Busy)
}
private fun question(id: String) = QuestionRequestDto(
id = id,
sessionID = "ses_test",
questions = listOf(
QuestionInfoDto(
question = "Pick one",
header = "Choice",
options = listOf(QuestionOptionDto("A", "Option A")),
multiple = false,
custom = true,
),
),
tool = ToolRefDto("msg1", "call1"),
)
private fun corpus(): List<ChatEventDto> = buildList {
add(ChatEventDto.TurnOpen("ses_test"))
add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")))
@@ -0,0 +1,69 @@
package ai.kilocode.client.session.history
import ai.kilocode.client.session.SessionActivityKind
import junit.framework.TestCase
class HistoryActivitySnapshotTest : TestCase() {
fun `test activity kind change is changed`() {
val prev = HistoryActivitySnapshot(activity = mapOf("ses_1" to SessionActivityKind.RUNNING))
val next = HistoryActivitySnapshot(activity = mapOf("ses_1" to SessionActivityKind.QUESTION))
assertEquals(setOf("ses_1"), prev.changed(next))
}
fun `test activity removal is changed`() {
val prev = HistoryActivitySnapshot(activity = mapOf("ses_1" to SessionActivityKind.RUNNING))
assertEquals(setOf("ses_1"), prev.changed(HistoryActivitySnapshot()))
}
fun `test title change is changed`() {
val prev = HistoryActivitySnapshot(
activity = mapOf("ses_1" to SessionActivityKind.RUNNING),
titles = mapOf("ses_1" to "Old"),
)
val next = HistoryActivitySnapshot(
activity = mapOf("ses_1" to SessionActivityKind.RUNNING),
titles = mapOf("ses_1" to "New"),
)
assertEquals(setOf("ses_1"), prev.changed(next))
}
fun `test title removal is changed`() {
val prev = HistoryActivitySnapshot(titles = mapOf("ses_1" to "Live"))
assertEquals(setOf("ses_1"), prev.changed(HistoryActivitySnapshot()))
}
fun `test disposed overlay removal is changed once`() {
val prev = HistoryActivitySnapshot(
activity = mapOf("ses_1" to SessionActivityKind.PERMISSION),
titles = mapOf("ses_1" to "Live"),
)
assertEquals(setOf("ses_1"), prev.changed(HistoryActivitySnapshot()))
}
fun `test unchanged maps are not changed`() {
val prev = HistoryActivitySnapshot(
activity = mapOf("ses_1" to SessionActivityKind.PERMISSION),
titles = mapOf("ses_1" to "Live"),
)
assertEquals(emptySet<String>(), prev.changed(prev.copy()))
}
fun `test changed ids are unioned`() {
val prev = HistoryActivitySnapshot(
activity = mapOf("ses_1" to SessionActivityKind.RUNNING),
titles = mapOf("ses_2" to "Old"),
)
val next = HistoryActivitySnapshot(
activity = mapOf("ses_1" to SessionActivityKind.QUESTION),
titles = mapOf("ses_2" to "New"),
)
assertEquals(setOf("ses_1", "ses_2"), prev.changed(next))
}
}
@@ -5,6 +5,7 @@ import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionManager
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
@@ -12,6 +13,7 @@ import ai.kilocode.rpc.dto.CloudSessionDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionStatusDto
import ai.kilocode.rpc.dto.SessionTimeDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
@@ -115,6 +117,133 @@ class HistoryControllerTest : BasePlatformTestCase() {
assertTrue(controller.local.items.isEmpty())
}
fun `test activity returns typed items`() {
rpc.statuses.value = mapOf(
"ses_busy" to SessionStatusDto("busy"),
"ses_idle" to SessionStatusDto("idle"),
"ses_retry" to SessionStatusDto("retry"),
"ses_offline" to SessionStatusDto("offline"),
)
flush()
val activity = sessions.activity()
assertEquals(mapOf("ses_busy" to SessionActivityKind.RUNNING), activity)
}
fun `test controller activity returns service activity`() {
rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
flush()
val activity = controller().activity()
assertEquals(mapOf("ses_1" to SessionActivityKind.RUNNING), activity)
}
fun `test local history renderer shows running badge for active id`() {
val item = LocalHistoryItem(session("ses_1", "Running"))
val controller = controller()
controller.local.replace(listOf(item))
val renderer = LocalHistoryRenderer(controller.local, activity = { mapOf("ses_1" to SessionActivityKind.RUNNING) })
renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false)
assertTrue(renderer.runningVisible())
}
fun `test local history renderer uses title overlay`() {
val item = LocalHistoryItem(session("ses_1", "Stored"))
val controller = controller()
controller.local.replace(listOf(item))
val renderer = LocalHistoryRenderer(controller.local, titles = { mapOf("ses_1" to "Live") })
renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false)
assertEquals("Live", renderer.titleText())
}
fun `test cloud history renderer hides running badge for inactive id`() {
val item = CloudHistoryItem(cloud("cloud_1", "Cloud"))
val controller = controller()
controller.cloud.replace(listOf(item), null)
val renderer = CloudHistoryRenderer(controller.cloud) { emptyMap() }
renderer.getListCellRendererComponent(javax.swing.JList(arrayOf(item)), item, 0, false, false)
assertFalse(renderer.runningVisible())
}
fun `test history panel sync updates running badges`() {
rpc.listed += session("ses_1", "Local One")
val panel = HistoryPanel(parent, controller())
flush()
assertFalse(panel.runningBadgeVisible(0))
rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
flush()
panel.syncActivity()
assertTrue(panel.runningBadgeVisible(0))
assertEquals(KiloBundle.message("session.part.tool.running"), panel.badgeText(0))
}
fun `test history panel overlay shows specific badge`() {
rpc.listed += session("ses_1", "Local One")
val panel = HistoryPanel(parent, controller(), manager = object : SessionManager {
override fun newSession() {}
override fun showHistory() {}
override fun openSession(ref: SessionRef) {}
override fun activity() = mapOf("ses_1" to SessionActivityKind.PERMISSION)
})
flush()
panel.syncActivity()
assertEquals(KiloBundle.message("history.badge.permission"), panel.badgeText(0))
}
fun `test history panel sync repaints activity kind change`() {
rpc.listed += session("ses_1", "Local One")
var kind: SessionActivityKind? = null
val panel = HistoryPanel(parent, controller(), manager = object : SessionManager {
override fun newSession() {}
override fun showHistory() {}
override fun openSession(ref: SessionRef) {}
override fun activity() = sessions.activity() + kind?.let { mapOf("ses_1" to it) }.orEmpty()
})
rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
flush()
panel.syncActivity()
assertEquals(KiloBundle.message("session.part.tool.running"), panel.badgeText(0))
kind = SessionActivityKind.QUESTION
panel.syncActivity()
assertEquals(KiloBundle.message("history.badge.question"), panel.badgeText(0))
}
fun `test history panel sync uses live title overlay`() {
rpc.listed += session("ses_1", "Stored")
var title = "Live"
val panel = HistoryPanel(parent, controller(), manager = object : SessionManager {
override fun newSession() {}
override fun showHistory() {}
override fun openSession(ref: SessionRef) {}
override fun titles() = title.takeIf { it.isNotBlank() }?.let { mapOf("ses_1" to it) }.orEmpty()
})
flush()
panel.syncActivity()
assertEquals("Live", panel.titleText(0))
title = ""
panel.syncActivity()
assertEquals("Stored", panel.titleText(0))
}
fun `test panel filters and switches source`() {
rpc.listed += session("ses_1", "Alpha")
rpc.listed += session("ses_2", "Beta")
@@ -509,18 +509,29 @@ class SessionModelTest : UsefulTestCase() {
assertEquals("snapshot", (entry.parts["p2"] as Generic).type)
}
fun `test loadHistory drops step-start and preserves step-finish parts`() {
fun `test loadHistory drops silent parts and preserves step-finish parts`() {
val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "visible")
val stepStart = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "step-start")
val stepFinish = PartDto(id = "p3", sessionID = "s1", messageID = "m1", type = "step-finish")
val patch = PartDto(id = "p4", sessionID = "s1", messageID = "m1", type = "patch")
model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, stepStart, stepFinish))))
model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, stepStart, stepFinish, patch))))
val entry = model.message("m1")!!
assertEquals(listOf("p1", "p3"), entry.parts.keys.toList())
assertTrue(entry.parts["p3"] is StepFinish)
}
fun `test updateContent drops patch parts`() {
model.addMessage(msg("m1", "assistant"))
events.clear()
model.updateContent("m1", PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "patch"))
assertFalse(model.message("m1")!!.parts.containsKey("p1"))
assertTrue(events.isEmpty())
}
fun `test upsertMessage adds new message and returns true`() {
val added = model.upsertMessage(msg("m1", "user"))
@@ -4,11 +4,14 @@ import ai.kilocode.client.app.KiloAppService
import ai.kilocode.client.app.KiloSessionService
import ai.kilocode.client.app.KiloWorkspaceService
import ai.kilocode.client.app.Workspace
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.SessionActivityKind
import ai.kilocode.client.session.SessionRef
import ai.kilocode.client.session.history.HistoryTime
import ai.kilocode.client.session.history.LocalHistoryItem
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.controller.SessionController
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.testing.FakeAppRpcApi
import ai.kilocode.client.testing.FakeSessionRpcApi
import ai.kilocode.client.testing.FakeWorkspaceRpcApi
@@ -17,6 +20,7 @@ import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.KiloWorkspaceStateDto
import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto
import ai.kilocode.rpc.dto.SessionDto
import ai.kilocode.rpc.dto.SessionStatusDto
import ai.kilocode.rpc.dto.SessionTimeDto
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.components.JBLabel
@@ -25,6 +29,8 @@ import com.intellij.util.ui.components.BorderLayoutPanel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import java.awt.BorderLayout
import java.awt.Cursor
@@ -34,6 +40,8 @@ class EmptySessionPanelTest : BasePlatformTestCase() {
private lateinit var app: KiloAppService
private lateinit var workspace: Workspace
private lateinit var controller: SessionController
private lateinit var rpc: FakeSessionRpcApi
private lateinit var sessions: KiloSessionService
private val opened = mutableListOf<String>()
override fun setUp() {
@@ -46,10 +54,12 @@ class EmptySessionPanelTest : BasePlatformTestCase() {
it.state.value = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.READY)
})
workspace = workspaces.workspace("/test")
rpc = FakeSessionRpcApi()
sessions = KiloSessionService(project, scope, rpc)
controller = SessionController(
parent = testRootDisposable,
ref = null,
sessions = KiloSessionService(project, scope, FakeSessionRpcApi()),
sessions = sessions,
workspace = workspace,
app = app,
cs = scope,
@@ -193,6 +203,94 @@ class EmptySessionPanelTest : BasePlatformTestCase() {
assertEquals("Untitled", label?.text)
}
fun `test renderer uses title overlay`() {
val panel = panel(
recents = listOf(session("ses_1", title = "Stored")),
titles = { mapOf("ses_1" to "Live") },
)
panel.syncActivity()
val cell = panel.rendererComponent(session("ses_1", title = "Stored")) as BorderLayoutPanel
assertEquals("Live", titleText(cell))
}
fun `test sync activity removes title overlay`() {
var title = "Live"
val panel = panel(
recents = listOf(session("ses_1", title = "Stored")),
titles = { title.takeIf { it.isNotBlank() }?.let { mapOf("ses_1" to it) }.orEmpty() },
)
panel.syncActivity()
assertEquals("Live", titleText(panel.rendererComponent(session("ses_1", title = "Stored")) as BorderLayoutPanel))
title = ""
panel.syncActivity()
assertEquals("Stored", titleText(panel.rendererComponent(session("ses_1", title = "Stored")) as BorderLayoutPanel))
}
fun `test renderer shows running badge for busy recent session`() {
val panel = panel(listOf(session("ses_1")))
rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
flush()
panel.syncActivity()
val cell = panel.rendererComponent(session("ses_1")) as BorderLayoutPanel
assertEquals(KiloBundle.message("session.part.tool.running"), badgeText(cell))
}
fun `test renderer shows overlay badge for active recent session`() {
val panel = panel(
recents = listOf(session("ses_1")),
activity = { sessions.activity() + mapOf("ses_1" to SessionActivityKind.QUESTION) },
)
rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
flush()
panel.syncActivity()
val cell = panel.rendererComponent(session("ses_1")) as BorderLayoutPanel
assertEquals(KiloBundle.message("history.badge.question"), badgeText(cell))
}
fun `test sync activity updates recent badge kind change`() {
var kind: SessionActivityKind? = null
val panel = panel(
recents = listOf(session("ses_1")),
activity = { sessions.activity() + kind?.let { mapOf("ses_1" to it) }.orEmpty() },
)
rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("busy"))
flush()
panel.syncActivity()
assertEquals(
KiloBundle.message("session.part.tool.running"),
badgeText(panel.rendererComponent(session("ses_1")) as BorderLayoutPanel),
)
kind = SessionActivityKind.QUESTION
panel.syncActivity()
assertEquals(
KiloBundle.message("history.badge.question"),
badgeText(panel.rendererComponent(session("ses_1")) as BorderLayoutPanel),
)
}
fun `test renderer hides running badge for idle recent session`() {
val panel = panel(listOf(session("ses_1")))
rpc.statuses.value = mapOf("ses_1" to SessionStatusDto("idle"))
flush()
panel.syncActivity()
val cell = panel.rendererComponent(session("ses_1")) as BorderLayoutPanel
assertNull(badgeText(cell))
}
fun `test timestamp normalization handles seconds and milliseconds`() {
assertEquals(1_700_000_000_000L, HistoryTime.millis(LocalHistoryItem(session("ses_1", 1_700_000_000))))
assertEquals(1_700_000_000_000L, HistoryTime.millis(LocalHistoryItem(session("ses_1", 1_700_000_000_000))))
@@ -208,8 +306,28 @@ class EmptySessionPanelTest : BasePlatformTestCase() {
assertEquals("4d ago", panel.text(session("ses_1", now - 345_600_000), now))
}
private fun panel(recents: List<SessionDto> = emptyList(), history: () -> Unit = {}) =
EmptySessionPanel(testRootDisposable, controller, recents, history)
private fun panel(
recents: List<SessionDto> = emptyList(),
history: () -> Unit = {},
activity: () -> Map<String, SessionActivityKind> = { sessions.activity() },
titles: () -> Map<String, String> = { emptyMap() },
) = EmptySessionPanel(testRootDisposable, controller, recents, history, activity, titles)
private fun flush() = runBlocking {
delay(100)
UIUtil.dispatchAllInvocationEvents()
}
private fun badgeText(cell: BorderLayoutPanel): String? = UIUtil.uiTraverser(cell)
.filter(JBLabel::class.java)
.mapNotNull { (it.icon as? FilledBadgeIcon)?.takeIf { _ -> it.isVisible }?.text }
.firstOrNull()
private fun titleText(cell: BorderLayoutPanel): String? = UIUtil.uiTraverser(cell)
.filter(JBLabel::class.java)
.filter { it.icon == null }
.firstOrNull()
?.text
private fun session(id: String, updated: Long = 2_000L, title: String = "Title $id") = SessionDto(
id = id,
@@ -1,6 +1,7 @@
package ai.kilocode.client.session.ui
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.prompt.PromptDataKeys
import ai.kilocode.client.session.ui.prompt.PromptPanel
import com.intellij.icons.AllIcons
@@ -8,7 +9,10 @@ import com.intellij.openapi.actionSystem.DataSink
import com.intellij.openapi.actionSystem.UiDataProvider
import com.intellij.openapi.keymap.KeymapUtil
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import com.intellij.ui.EditorTextField
import com.intellij.util.ui.EmptyIcon
import java.awt.Container
import javax.swing.JButton
import javax.swing.SwingUtilities
@Suppress("UnstableApiUsage")
@@ -41,6 +45,42 @@ class PromptPanelTest : BasePlatformTestCase() {
assertTrue(panel.preferredSize.height >= 26)
}
fun `test prompt editor grows when lines are added`() {
val panel = PromptPanel(project, {}, {})
val editor = panel.defaultFocusedComponent as EditorTextField
val min = editor.preferredSize.height
editor.text = "one\ntwo\nthree\nfour\nfive"
assertTrue(editor.preferredSize.height > min)
}
fun `test prompt editor shrinks when lines are removed`() {
val panel = PromptPanel(project, {}, {})
val editor = panel.defaultFocusedComponent as EditorTextField
val min = editor.preferredSize.height
editor.text = "one\ntwo\nthree\nfour\nfive"
assertTrue(editor.preferredSize.height > min)
editor.text = "one"
assertEquals(min, editor.preferredSize.height)
}
fun `test prompt editor shrinks after clear`() {
val panel = PromptPanel(project, {}, {})
val editor = panel.defaultFocusedComponent as EditorTextField
val min = editor.preferredSize.height
editor.text = "one\ntwo\nthree\nfour\nfive"
assertTrue(editor.preferredSize.height > min)
panel.clear()
assertEquals(min, editor.preferredSize.height)
}
fun `test reasoning picker hides when variants are empty`() {
val panel = PromptPanel(project, {}, {})
@@ -110,6 +150,48 @@ class PromptPanelTest : BasePlatformTestCase() {
assertTrue(panel.isStopEnabled)
}
fun `test auto approve button toggles and updates tooltip`() {
val panel = PromptPanel(project, {}, {})
val button = autoApproveButton(panel)
var seen: Boolean? = null
panel.onAutoApproveToggle = { seen = it }
assertFalse(button.isSelected)
assertEquals(KiloBundle.message("prompt.action.autoApprove.enable"), button.accessibleContext.accessibleName)
assertEquals(KiloBundle.message("prompt.action.autoApprove.disabled.tooltip"), button.toolTipText)
val icon = button.icon
button.doClick()
assertEquals(true, seen)
panel.setAutoApprove(true)
assertTrue(button.isSelected)
assertNotSame(icon, button.icon)
assertEquals(KiloBundle.message("prompt.action.autoApprove.disable"), button.accessibleContext.accessibleName)
assertEquals(KiloBundle.message("prompt.action.autoApprove.enabled.tooltip"), button.toolTipText)
button.doClick()
assertEquals(false, seen)
panel.setAutoApprove(false)
assertSame(icon, button.icon)
}
fun `test auto approve button sits next to send button`() {
val panel = PromptPanel(project, {}, {})
val auto = autoApproveButton(panel)
val send = panel.buttonForTest()
val items = auto.parent.components.toList()
assertTrue(SwingUtilities.isDescendingFrom(auto, panel.shellForTest()))
assertSame(auto.parent, send.parent)
assertEquals(2, items.indexOf(send) - items.indexOf(auto))
}
fun `test pickers belong to rounded shell`() {
val panel = PromptPanel(project, {}, {})
val shell = panel.shellForTest()
@@ -120,6 +202,25 @@ class PromptPanelTest : BasePlatformTestCase() {
assertSame(shell, panel.mode.parent.parent)
}
private fun autoApproveButton(panel: PromptPanel): JButton {
val enable = KiloBundle.message("prompt.action.autoApprove.enable")
val disable = KiloBundle.message("prompt.action.autoApprove.disable")
return buttons(panel).first {
val name = it.accessibleContext.accessibleName
name == enable || name == disable
}
}
private fun buttons(root: java.awt.Component): List<JButton> {
val out = mutableListOf<JButton>()
fun visit(node: java.awt.Component) {
if (node is JButton) out.add(node)
if (node is Container) node.components.forEach(::visit)
}
visit(root)
return out
}
private class TestSink : DataSink {
var send: Any? = null
@@ -165,6 +165,18 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
assertTrue(mv.part("p1") is TextView)
}
fun `test text markdown link uses panel url opener`() {
val urls = mutableListOf<String>()
val item = SessionMessageListPanel(model, parent, openFile = openFile, openUrl = { urls.add(it) })
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", part("p1", "a1", "text", text = "[docs](https://kilocode.ai/docs)"))
val view = item.findMessage("a1")!!.part("p1") as TextView
view.md.simulateLink("https://kilocode.ai/docs")
assertEquals(listOf("https://kilocode.ai/docs"), urls)
}
fun `test ContentDelta appends text to TextView`() {
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", part("p1", "a1", "text", text = "hello "))
@@ -87,6 +87,14 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
assertFalse(tv.labelText().contains("Running"))
}
fun `test read tool renders as ReadToolView`() {
model.upsertMessage(msg("a1", "assistant"))
model.updateContent("a1", toolPart("t1", "a1", "read", "completed"))
val tv = panel.findMessage("a1")!!.part("t1")
assertTrue(tv is ai.kilocode.client.session.views.ReadToolView)
}
// ------ multiple turns update correctly ------
fun `test content goes to correct turn when multiple turns exist`() {
@@ -127,6 +135,7 @@ class SessionUiUpdateTest : BasePlatformTestCase() {
assertNotNull(gv)
assertTrue(gv is ai.kilocode.client.session.views.base.GenericView)
assertTrue((gv as ai.kilocode.client.session.views.base.GenericView).labelText().contains("snapshot"))
assertNull(gv.border)
}
// ------ silent part types ------
@@ -19,7 +19,7 @@ class PlanExitViewTest : BasePlatformTestCase() {
fun `test view factory replaces running tool with plan exit view when completed`() {
val running = tool(ToolExecState.RUNNING)
val existing = ViewFactory.create(running) {}
val existing = ViewFactory.create(running, {}) {}
assertTrue(existing is ToolView)
val done = tool(ToolExecState.COMPLETED).apply {
@@ -27,7 +27,7 @@ class PlanExitViewTest : BasePlatformTestCase() {
}
assertTrue(ViewFactory.shouldReplace(existing, done))
assertTrue(ViewFactory.create(done) {} is PlanExitView)
assertTrue(ViewFactory.create(done, {}) {} is PlanExitView)
}
fun `test clicking plan link opens href`() {
@@ -4,8 +4,16 @@ import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.ui.style.SessionUiStyle
import ai.kilocode.client.session.views.question.QuestionResultView
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import java.awt.Color
import java.awt.Component
import java.awt.Container
import java.awt.event.MouseEvent
import java.awt.image.BufferedImage
import javax.swing.JPanel
import javax.swing.border.Border
@Suppress("UnstableApiUsage")
class QuestionResultViewTest : BasePlatformTestCase() {
@@ -122,6 +130,22 @@ class QuestionResultViewTest : BasePlatformTestCase() {
assertFalse("Should be collapsed after second toggle", view.isExpanded())
}
fun `test hover border differs from header fill`() {
val view = QuestionResultView(completedTool(
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
))
val root = view.node(0)
val header = root.node(0)
enter(header)
assertEquals(SessionUiStyle.View.hoverLine().rgb, paint(root.border).rgb)
assertNotSameColor(SessionUiStyle.View.headerHover(), paint(root.border))
exit(header)
assertEquals(SessionUiStyle.View.line().rgb, paint(root.border).rgb)
}
// ------ view factory routing ------
fun `test view factory uses question result view for completed parsable question tool`() {
@@ -129,7 +153,7 @@ class QuestionResultViewTest : BasePlatformTestCase() {
input = mapOf("questions" to """[{"question":"Q1"}]"""),
metadata = mapOf("answers" to """[["A1"]]"""),
)
val view = ViewFactory.create(tool) {}
val view = ViewFactory.create(tool, {}) {}
assertTrue(view is QuestionResultView)
}
@@ -139,14 +163,14 @@ class QuestionResultViewTest : BasePlatformTestCase() {
input = emptyMap(),
metadata = emptyMap(),
)
val view = ViewFactory.create(tool) {}
val view = ViewFactory.create(tool, {}) {}
assertTrue(view is ToolView)
}
fun `test view factory falls back to tool view for running question`() {
val tool = runningTool("question")
val view = ViewFactory.create(tool) {}
val view = ViewFactory.create(tool, {}) {}
assertTrue(view is ToolView)
}
@@ -237,4 +261,36 @@ class QuestionResultViewTest : BasePlatformTestCase() {
private fun runningTool(name: String, id: String = "tp1"): Tool =
Tool(id, name, toolKind(name)).apply { state = ToolExecState.RUNNING }
private fun Container.node(index: Int) = components[index] as JPanel
private fun enter(component: Component) = event(component, MouseEvent.MOUSE_ENTERED)
private fun exit(component: Component) = event(component, MouseEvent.MOUSE_EXITED)
private fun event(component: Component, id: Int) {
component.dispatchEvent(MouseEvent(
component,
id,
System.currentTimeMillis(),
0,
1,
1,
0,
false,
))
}
private fun paint(border: Border): Color {
val image = BufferedImage(3, 3, BufferedImage.TYPE_INT_ARGB)
val panel = JPanel()
val graphics = image.createGraphics()
border.paintBorder(panel, graphics, 0, 0, image.width, image.height)
graphics.dispose()
return Color(image.getRGB(0, 0), true)
}
private fun assertNotSameColor(left: Color, right: Color) {
assertFalse("Expected distinct colors but both were ${left.rgb}", left.rgb == right.rgb)
}
}
@@ -515,6 +515,7 @@ class QuestionViewTest : BasePlatformTestCase() {
fun `test selection requests scroll to bottom`() {
view.show(singleSelectQuestion("q_scroll"))
scrolls = 0
option<JBRadioButton>(view, "Minimal").doClick()
@@ -523,6 +524,7 @@ class QuestionViewTest : BasePlatformTestCase() {
fun `test question navigation requests scroll to bottom`() {
view.show(twoItemQuestion("q_nav_scroll"))
scrolls = 0
option<JBRadioButton>(view, "Minimal").doClick()
button(view, "Next").doClick()
@@ -0,0 +1,110 @@
package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Tool
import ai.kilocode.client.session.model.ToolExecState
import ai.kilocode.client.session.model.toolKind
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import ai.kilocode.client.ui.UiStyle
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import javax.swing.ScrollPaneConstants
@Suppress("UnstableApiUsage")
class ReadToolViewTest : BasePlatformTestCase() {
fun `test read tool shows filename`() {
val t = tool().also { it.input = mapOf("filePath" to "README.MD") }
val view = ReadToolView(t)
val base: Any = view
assertTrue(base is SecondarySessionPartView)
assertTrue(view.labelText().contains("Read"))
assertTrue(view.labelText().contains("README.MD"))
}
fun `test read tool handles windows path`() {
val t = tool().also { it.input = mapOf("filePath" to "C:\\repo\\README.MD") }
val view = ReadToolView(t)
assertTrue(view.labelText().contains("README.MD"))
}
fun `test read file output renders filename hyperlink`() {
val opened = mutableListOf<String>()
val path = "/Users/kirillk/work/kilocode/.kilo/worktrees/agreeable-marlin/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiLayoutTest.kt"
val t = tool().also {
it.output = """
<path>$path</path>
<type>file</type>
<content>
content
</content>
""".trimIndent()
}
val view = ReadToolView(t, openFile = { opened.add(it) })
assertTrue(view.linkVisible())
assertEquals("SessionUiLayoutTest.kt", view.linkText())
assertEquals(path, view.linkHref())
assertTrue(view.linkMarkup().contains("<u>SessionUiLayoutTest.kt</u>"))
assertEquals(UiStyle.Colors.fg().rgb, view.linkForeground().rgb)
assertEquals(view.linkFont(), view.bodyFont())
assertTrue(view.labelText().contains("SessionUiLayoutTest.kt"))
view.openLink()
assertEquals(listOf(path), opened)
}
fun `test read directory output remains plain text`() {
val path = "/Users/kirillk/work/kilocode/packages/kilo-jetbrains"
val t = tool().also {
it.output = """
<path>$path</path>
<type>directory</type>
<content></content>
""".trimIndent()
}
val view = ReadToolView(t)
assertFalse(view.linkVisible())
assertNull(view.linkHref())
assertEquals(UiStyle.Colors.fg().rgb, view.subtitleForeground().rgb)
assertEquals(view.subtitleFont(), view.bodyFont())
assertTrue(view.labelText().contains(path))
}
fun `test read output is secondary non expandable summary`() {
val t = tool().also { it.output = "file contents" }
val view = ReadToolView(t)
assertFalse(view.hasToggle())
assertFalse(view.isExpanded())
assertFalse(view.bodyVisible())
assertEquals("file contents", view.bodyText())
assertEquals(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER, view.horizontalPolicy())
view.toggle()
assertFalse(view.isExpanded())
assertFalse(view.bodyVisible())
}
fun `test view factory routes read kind tools to read tool view`() {
assertTrue(ViewFactory.create(tool(), openFile = {}) is ReadToolView)
assertTrue(ViewFactory.create(Tool("p2", "grep", toolKind("grep")), openFile = {}) is ReadToolView)
assertTrue(ViewFactory.create(Tool("p3", "glob", toolKind("glob")), openFile = {}) is ReadToolView)
}
fun `test canRender matches read kind tools only`() {
assertTrue(ReadToolView.canRender(tool()))
assertTrue(ReadToolView.canRender(Tool("p2", "grep", toolKind("grep"))))
assertTrue(ReadToolView.canRender(Tool("p3", "glob", toolKind("glob"))))
assertFalse(ReadToolView.canRender(Tool("p4", "bash", toolKind("bash"))))
}
private fun tool() = Tool("p1", "read", toolKind("read")).also { it.state = ToolExecState.COMPLETED }
}
@@ -2,6 +2,7 @@ package ai.kilocode.client.session.views
import ai.kilocode.client.session.model.Reasoning
import ai.kilocode.client.session.ui.style.SessionEditorStyle
import ai.kilocode.client.session.views.base.SecondarySessionPartView
import com.intellij.testFramework.fixtures.BasePlatformTestCase
import javax.swing.ScrollPaneConstants
@@ -10,8 +11,10 @@ class ReasoningViewTest : BasePlatformTestCase() {
fun `test completed reasoning is collapsed by default`() {
val view = ReasoningView(reasoning("p1", done = true, text = "one\ntwo\nthree\nfour"))
val base: Any = view
assertFalse(view.isExpanded())
assertTrue(base is SecondarySessionPartView)
assertEquals("Reasoning", view.headerText())
assertEquals("one\ntwo\nthree\nfour", view.markdown())
assertTrue(view.hasToggle())
@@ -160,6 +163,17 @@ class ReasoningViewTest : BasePlatformTestCase() {
assertTrue(view.preferredSize.height > 0)
}
fun `test link opens url callback`() {
val urls = mutableListOf<String>()
val view = ReasoningView(reasoning("p1", done = true, text = "[docs](https://kilocode.ai/docs)"), openUrl = {
urls.add(it)
})
view.md.simulateLink("https://kilocode.ai/docs")
assertEquals(listOf("https://kilocode.ai/docs"), urls)
}
private fun assertEditorSheet(sheet: String, style: SessionEditorStyle) {
assertTrue(sheet.contains(style.editorFamily))
assertTrue(sheet.contains("${style.editorSize}pt"))
@@ -113,4 +113,13 @@ class TextViewTest : BasePlatformTestCase() {
view.appendDelta("**")
assertTrue(view.md.html().contains("<strong>"))
}
fun `test link opens url callback`() {
val urls = mutableListOf<String>()
val view = TextView(Text("p1"), openUrl = { urls.add(it) })
view.md.simulateLink("https://kilocode.ai/docs")
assertEquals(listOf("https://kilocode.ai/docs"), urls)
}
}

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