Merge remote-tracking branch 'origin/main' into bottled-barometer
# Conflicts: # packages/kilo-vscode/webview-ui/src/components/history/SessionList.tsx
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Center local session history delete buttons within their rows.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Improve the size and readability of the local History session context menu.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Support toggling auto-approve for permission prompts from the JetBrains chat input.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Resize the JetBrains prompt editor as prompt lines are added or removed.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Support opening links in JetBrains session markdown transcripts.
|
||||
@@ -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.
|
||||
@@ -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)!
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": minor
|
||||
---
|
||||
|
||||
Make the Agent Manager tool available by default in VS Code.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Restore readable diff highlighting and collapsed unchanged sections in VS Code themes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Prevent saved global indexing provider changes from temporarily reverting in active workspaces.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.artifacts
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||