` 纯文本而非语法高亮 — **不完成**
+- 未配置 Provider 时点击发送没有任何提示 — **不完成**
+- 输入框在 AI 回答完成前未锁定(可重复发送) — **不完成**
+
+---
+
+## 2. ⚡ SSE 流式引擎
+
+**这是 Chat 体验的命脉。** 如果 SSE 没有真正流式,Module 1 的全部精细化都是空的。
+
+### 2.1 依赖
+
+```gradle
+// build.gradle
+dependencies {
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.squareup.okhttp3:okhttp-sse:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
+}
+```
+
+### 2.2 OkHttp 客户端配置
+
+```kotlin
+object HttpClientFactory {
+ val client: OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(5, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS) // 流式读取,不能太短
+ .writeTimeout(10, TimeUnit.SECONDS)
+ .build()
+}
+```
+
+**读超时设为 60s,不是 30s** — 国内 AI 服务首 token 延迟可能超过 10s。
+
+### 2.3 Chat 请求构建
+
+```kotlin
+fun buildRequest(config: ProviderConfig, apiKey: String, messages: List): Request {
+ val endpoint = config.endpoint.trimEnd('/') + "/chat/completions"
+ val body = Json.encodeToString(ChatRequest(
+ model = config.model,
+ messages = messages.map { ApiMessage(it.role.name.lowercase(), it.content) },
+ stream = true,
+ temperature = settings.chatTemperature,
+ max_tokens = settings.chatMaxTokens
+ ))
+ return Request.Builder()
+ .url(endpoint)
+ .post(body.toRequestBody("application/json".toMediaType()))
+ .header("Authorization", "Bearer $apiKey")
+ .header("Accept", "text/event-stream")
+ .build()
+}
+```
+
+### 2.4 SSE 解析与 token 推送
+
+**使用 `okhttp-sse` 的 `EventSources.createFactory`**,不要自己解析原始 HTTP 响应行:
+
+```kotlin
+fun streamChat(
+ request: Request,
+ msgId: String,
+ bridgeHandler: BridgeHandler,
+ onComplete: () -> Unit
+) {
+ val listener = object : EventSourceListener() {
+ override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
+ if (data == "[DONE]") {
+ bridgeHandler.notifyEnd(msgId)
+ onComplete()
+ return
+ }
+ try {
+ val delta = Json { ignoreUnknownKeys = true }
+ .decodeFromString(data)
+ val token = delta.choices.firstOrNull()?.delta?.content ?: return
+ if (token.isNotEmpty()) bridgeHandler.notifyToken(token)
+ } catch (_: Exception) {
+ // 跳过无法解析的 chunk(如空行、心跳)
+ }
+ }
+
+ override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
+ val msg = when {
+ response != null -> "HTTP ${response.code}: ${response.body?.string()?.take(200)}"
+ t != null -> t.message ?: "未知错误"
+ else -> "连接失败"
+ }
+ bridgeHandler.notifyError(msg)
+ onComplete()
+ }
+ }
+
+ bridgeHandler.notifyStart(msgId)
+ EventSources.createFactory(HttpClientFactory.client)
+ .newEventSource(request, listener)
+}
+```
+
+所有 `streamChat` 调用必须在协程或后台线程中发起(使用 `CoroutineScope(Dispatchers.IO)`),不得在 EDT 上发起 IO。
+
+### 2.5 Session 消息历史
+
+```kotlin
+data class Message(
+ val role: MessageRole,
+ val content: String
+)
+
+enum class MessageRole { SYSTEM, USER, ASSISTANT }
+
+class ChatSession {
+ private val messages = mutableListOf()
+
+ fun add(msg: Message) { messages.add(msg) }
+
+ fun getApiMessages(): List {
+ // 保留 system + 最近 20 轮对话,防止超 context window
+ val system = messages.filter { it.role == MessageRole.SYSTEM }
+ val history = messages.filter { it.role != MessageRole.SYSTEM }.takeLast(40)
+ return system + history
+ }
+
+ fun clear() { messages.clear() }
+}
+```
+
+每个 Tool Window 实例持有一个 `ChatSession`。New Chat 按钮调用 `session.clear()` 并通知前端清空消息列表。
+
+### 2.6 Not Complete If...(SSE 引擎)
+
+- 使用 `response.body!!.string()` 一次性读取响应再解析 — **不完成**(这是缓冲响应,不是流式)
+- token 先累积在 Kotlin 侧,等 `[DONE]` 之后才一次性调用 `notifyToken` — **不完成**
+- SSE 解析依赖正则匹配原始 `data:` 前缀而非 `okhttp-sse` 的 `EventSourceListener` — **不完成**
+- 网络错误时前端没有任何反馈,输入框永久锁定 — **不完成**
+- `connectTimeout` 和 `readTimeout` 使用同一个值(如默认 10s)— **不完成**(读超时必须至少 60s)
+
+---
+
+## 3. API Provider 配置
+
+### 3.1 数据模型
+
+```kotlin
+@Serializable
+data class ProviderConfig(
+ val id: String = UUID.randomUUID().toString(),
+ var name: String,
+ var endpoint: String, // 例: "https://api.openai.com/v1"
+ var model: String,
+ // API Key 不存在此处,存在 PasswordSafe
+)
+
+@State(name = "CodePlanGUISettings", storages = [Storage("codePlanGUI.xml")])
+@Service(Service.Level.APP)
+class PluginSettings : PersistentStateComponent {
+ var providers: MutableList = mutableListOf()
+ var activeProviderId: String? = null
+ var chatTemperature: Double = 0.7
+ var chatMaxTokens: Int = 4096
+ var commitLanguage: String = "zh" // "zh" | "en"
+ var commitFormat: String = "conventional" // "conventional" | "free"
+ var contextInjectionEnabled: Boolean = true
+ var contextMaxLines: Int = 300
+ // ...PersistentStateComponent 模板方法
+}
+```
+
+### 3.2 API Key 安全存储
+
+API Key **绝对不能**写入 `codePlanGUI.xml`(会同步到 VCS)。使用 IDEA PasswordSafe:
+
+```kotlin
+object ApiKeyStore {
+ private fun attrs(providerId: String) =
+ CredentialAttributes(generateServiceName("CodePlanGUI", providerId))
+
+ fun save(providerId: String, key: String) =
+ PasswordSafe.instance.setPassword(attrs(providerId), key)
+
+ fun load(providerId: String): String? =
+ PasswordSafe.instance.getPassword(attrs(providerId))
+
+ fun delete(providerId: String) =
+ PasswordSafe.instance.setPassword(attrs(providerId), null)
+}
+```
+
+Provider 被删除时必须调用 `ApiKeyStore.delete(provider.id)` 清理 PasswordSafe。
+
+### 3.3 Settings 面板
+
+实现 `Configurable` 注册在 `plugin.xml`:
+
+```xml
+
+
+
+```
+
+面板包含两个 Tab:
+
+**Providers Tab:**
+- JTable 显示已配置的 providers(列:Name, Endpoint, Model)
+- 工具栏按钮:Add / Edit / Remove
+- Edit 弹出对话框,字段:Name、Endpoint、API Key(JPasswordField,默认显示 `••••••`)、Model
+- Endpoint 保存时自动 `trimEnd('/')`
+- "Test Connection" 按钮:发出最小请求验证,≤5s 内显示结果
+
+**Chat / Commit Tab:**
+- Temperature 滑块(0.0 ~ 2.0,步长 0.1,默认 0.7)
+- Max Tokens 数字输入(100 ~ 8192,默认 4096)
+- Commit Language 单选(中文 / English)
+- Commit Format 单选(Conventional Commits / 自由格式)
+
+### 3.4 Test Connection 实现
+
+```kotlin
+fun testConnection(config: ProviderConfig, apiKey: String): TestResult {
+ val request = Request.Builder()
+ .url(config.endpoint.trimEnd('/') + "/chat/completions")
+ .post("""{"model":"${config.model}","messages":[{"role":"user","content":"hi"}],"max_tokens":1,"stream":false}"""
+ .toRequestBody("application/json".toMediaType()))
+ .header("Authorization", "Bearer $apiKey")
+ .build()
+
+ return try {
+ val response = HttpClientFactory.client.newCall(request)
+ .execute() // 同步调用,已在后台线程
+ if (response.isSuccessful) TestResult.Success
+ else TestResult.Failure("HTTP ${response.code}: ${response.body?.string()?.take(200)}")
+ } catch (e: SocketTimeoutException) {
+ TestResult.Failure("连接超时(5s):请检查 endpoint 是否正确")
+ } catch (e: Exception) {
+ TestResult.Failure(e.message ?: "未知错误")
+ }
+}
+```
+
+Test Connection 必须在后台线程(`ProgressManager.getInstance().runProcessWithProgressSynchronously`)执行,不得在 EDT 上发出网络请求。
+
+---
+
+## 4. Commit Message 生成
+
+### 4.1 Action 注册
+
+注册 `AnAction` 到 VCS Commit 对话框工具栏:
+
+```xml
+
+
+
+
+
+```
+
+`Vcs.MessageActionGroup` 会将 action 注入到 IDEA 的 Commit 对话框工具栏(2022.2+ 有效)。
+
+### 4.2 读取 Staged Diff
+
+```kotlin
+override fun actionPerformed(e: AnActionEvent) {
+ val project = e.project ?: return
+
+ // 获取 staged diff
+ val diff = readStagedDiff(project)
+ if (diff.isBlank()) {
+ Messages.showInfoMessage(project, "没有 staged 的改动", "CodePlanGUI")
+ return
+ }
+
+ // 截断:最多 5000 字符
+ val truncatedDiff = if (diff.length > 5000)
+ diff.take(5000) + "\n... [diff truncated]"
+ else diff
+
+ // 异步调用 API,完成后写入 commit message
+ generateAndApply(project, e, truncatedDiff)
+}
+
+private fun readStagedDiff(project: Project): String {
+ val projectDir = project.basePath ?: return ""
+ val result = ProcessBuilder("git", "diff", "--staged", "--no-color")
+ .directory(File(projectDir))
+ .redirectErrorStream(true)
+ .start()
+ .inputStream.bufferedReader().readText()
+ return result.trim()
+}
+```
+
+### 4.3 Commit Message Prompt
+
+System prompt(硬编码,不暴露给用户自定义——MVP 阶段):
+
+```
+你是一个 git commit message 生成助手。
+根据以下 git diff,生成一条 commit message。
+
+要求:
+- 使用 Conventional Commits 格式:():
+- type 从以下选择:feat / fix / refactor / docs / test / chore / style / perf
+- subject 用${if (language == "zh") "中文" else "English"}
+- subject 不超过 72 字符
+- 如果改动复杂,在空行后加 body 说明
+- 只输出 commit message 本身,不要任何解释或额外文字
+
+git diff:
+```
+
+用户 message 内容 = 上述 system prompt + `\n${truncatedDiff}`(作为单轮对话发出,temperature=0.3,max_tokens=500,**非流式**,`stream=false`)。
+
+### 4.4 写入 Commit 对话框
+
+```kotlin
+private fun generateAndApply(project: Project, e: AnActionEvent, diff: String) {
+ ProgressManager.getInstance().run(object : Task.Backgroundable(project, "生成 Commit Message...") {
+ override fun run(indicator: ProgressIndicator) {
+ val result = callApiSync(diff) // 非流式,阻塞调用
+
+ ApplicationManager.getApplication().invokeLater {
+ // 优先写入 commit dialog
+ val panel = e.getData(VcsDataKeys.CHECKIN_PROJECT_PANEL)
+ if (panel != null) {
+ panel.commitMessage = result
+ } else {
+ // fallback:写入剪贴板
+ CopyPasteManager.getInstance().setContents(StringSelection(result))
+ Messages.showInfoMessage(project,
+ "已复制到剪贴板(未找到提交对话框)", "CodePlanGUI")
+ }
+ }
+ }
+ })
+}
+```
+
+---
+
+## 5. 工程基础设施
+
+### 5.1 项目结构
+
+```
+CodePlanGUI/
+├── build.gradle — Gradle 构建脚本(Groovy DSL,对齐 jetbrains-cc-gui)
+├── gradle.properties — pluginVersion, ideaVersion 等
+├── settings.gradle
+├── gradle/wrapper/
+├── webview/ — 独立 React 前端项目
+│ ├── package.json
+│ ├── vite.config.ts
+│ ├── src/
+│ └── dist/ — 构建输出(.gitignore 中)
+└── src/main/
+ ├── java/com/yourpkg/codeplangui/
+ │ ├── ChatToolWindowFactory.java (或 .kt)
+ │ ├── ChatPanel.kt
+ │ ├── BridgeHandler.kt
+ │ ├── SseClient.kt
+ │ ├── settings/
+ │ │ ├── PluginSettings.kt
+ │ │ ├── PluginSettingsConfigurable.kt
+ │ │ └── ApiKeyStore.kt
+ │ └── action/
+ │ └── GenerateCommitMessageAction.kt
+ └── resources/
+ ├── META-INF/plugin.xml
+ ├── icons/
+ │ ├── toolwindow.svg (13x13 @ 2x = 16x16)
+ │ └── generate.svg
+ └── webview/
+ └── index.html — 由 webview/scripts/copy-dist.mjs 复制过来
+```
+
+### 5.2 build.gradle
+
+```gradle
+plugins {
+ id 'java'
+ id 'org.jetbrains.kotlin.jvm' version '1.9.25'
+ id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.25'
+ id 'org.jetbrains.intellij.platform' version '2.1.0'
+}
+
+group = 'com.yourpkg'
+version = '0.1.0'
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ toolchain { languageVersion = JavaLanguageVersion.of(17) }
+}
+
+repositories {
+ mavenCentral()
+ intellijPlatform { defaultRepositories() }
+}
+
+intellijPlatform {
+ pluginConfiguration {
+ ideaVersion {
+ sinceBuild = '231' // IDEA 2023.1
+ untilBuild = provider { null } // 不限制上限
+ }
+ }
+}
+
+dependencies {
+ intellijPlatform {
+ intellijIdeaCommunity('2023.1')
+ instrumentationTools()
+ pluginVerifier()
+ }
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.squareup.okhttp3:okhttp-sse:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
+}
+
+// 前端构建集成
+tasks.register('buildWebview', Exec) {
+ workingDir 'webview'
+ commandLine 'npm', 'run', 'build'
+}
+
+tasks.named('processResources').configure {
+ dependsOn 'buildWebview'
+}
+```
+
+### 5.3 Vite 配置(webview/vite.config.ts)
+
+```typescript
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react-swc'
+import { viteSingleFile } from 'vite-plugin-singlefile'
+
+export default defineConfig({
+ plugins: [react(), viteSingleFile()],
+ build: {
+ outDir: 'dist',
+ target: 'es2020',
+ // 禁用 chunk splitting — singlefile 要求所有内容内联
+ rollupOptions: {
+ output: { inlineDynamicImports: true }
+ }
+ }
+})
+```
+
+`postbuild` 脚本(`webview/scripts/copy-dist.mjs`):
+
+```javascript
+import { copyFileSync, mkdirSync } from 'fs'
+mkdirSync('../src/main/resources/webview', { recursive: true })
+copyFileSync('dist/index.html', '../src/main/resources/webview/index.html')
+console.log('✓ Copied webview/dist/index.html → src/main/resources/webview/index.html')
+```
+
+### 5.4 开发工作流
+
+**前端开发(热重载):**
+
+```bash
+cd webview && npm run dev
+# 在 IDEA Run Configuration 中,用 loadURL("http://localhost:5173") 替换 loadHTML
+# 通过 gradle.properties 中的 isDev=true 判断
+```
+
+**插件调试:**
+
+```bash
+./gradlew runIde
+# 启动沙箱 IDEA,插件已加载
+```
+
+**打包:**
+
+```bash
+./gradlew buildWebview buildPlugin
+# 输出: build/distributions/CodePlanGUI-0.1.0.zip
+```
+
+### 5.5 plugin.xml 关键依赖声明
+
+```xml
+com.intellij.modules.platform
+Git4Idea
+```
+
+---
+
+## 6. 完整用户流 + 失败态
+
+### Happy Path
+
+```
+1. 用户安装插件,首次打开 IDEA
+ → 右侧 Tool Window "CodePlanGUI" 出现
+ → 未配置 Provider,Chat 面板显示"请先配置 API"提示链接
+
+2. 用户点击链接 → Settings > Tools > CodePlanGUI
+ → 点 Add,填入 endpoint / key / model
+ → 点 "Test Connection" → 2s 内显示 "✓ 连接成功"
+ → 点 OK 保存
+
+3. 用户切回 Chat 面板
+ → 顶栏显示 provider 名 + model 名
+ → 输入框可用
+
+4. 用户输入问题,按 Enter
+ → 用户气泡立即出现(不等 API 响应)
+ → AI 气泡出现,显示闪烁光标
+ → 首 token 出现(≤ 网络 RTT + TTFT)
+ → 逐 token 追加,Markdown 实时渲染
+ → [DONE] 收到,光标消失,输入框解锁
+
+5. 用户在 Git Commit 对话框点 ✨ 按钮
+ → 进度条:"生成 Commit Message..."
+ → 完成后 commit message 输入框自动填入
+```
+
+### 失败态
+
+| 触发点 | 用户看到 | 恢复路径 |
+|---|---|---|
+| 发送时未配置 Provider | ErrorBanner auth 类型:"请先配置 API Provider" + 打开设置按钮 | 点按钮打开 Settings |
+| API Key 未设置或错误 | ErrorBanner auth 类型:"API Key 未设置或未保存,请在 Settings 中重新配置" + 打开设置按钮 | 点按钮打开 Settings |
+| 网络超时(readTimeout 60s) | ErrorBanner temp 类型:"请求超时,请检查网络或 endpoint" + 重试按钮 | 点按钮重发 |
+| endpoint 路径错误(404) | ErrorBanner auth 类型:"HTTP 404: endpoint 路径可能有误(应包含 /v1)" | 输入框解锁,可重发 |
+| 配额不足 | ErrorBanner quota 类型:"配额不足" + 打开设置按钮 | 点按钮打开 Settings |
+| 未知错误 | ErrorBanner generic 类型:"未知错误" + 无按钮 | 输入框解锁,可重发 |
+| JBR 不支持 JCEF | 静态 JLabel 说明,不崩溃 | 用户切换 JBR |
+| git diff --staged 失败 | Messages.showError:"无法读取 staged 改动,请确认在 git 仓库中" | 无需恢复 |
+| 未找到 Commit 对话框 | 消息写入剪贴板 + 通知"已复制" | 手动粘贴 |
+| JCEF 加载 HTML 失败 | ErrorBanner(初始化时):"前端加载失败,请重启 IDE" | 重启 |
+
+**ErrorBanner 分层类型:**
+
+| 类型 | 标签 | 按钮 |
+|---|---|---|
+| auth | 配置错误 | 打开设置 |
+| quota | 配额不足 | 打开设置 |
+| temp | 临时错误 | 重试 |
+| generic | 未知错误 | 无 |
+
+---
+
+## 7. Open Design Questions(在实现前解答)
+
+1. **包名/group**:确定最终包名(`com.yourpkg` → 改为实际值),group ID 用于 JetBrains Marketplace 上传。
+
+2. **IDEA 最低版本**:`sinceBuild = '231'`(2023.1)是否覆盖目标用户?如果需要支持 2022.x,`Vcs.MessageActionGroup` 需要回退方案。
+
+3. **`runIde` 用哪个 IDEA 版本**:`intellijIdeaCommunity('2023.1')` 仅用于本地调试 sandbox,可换成 Ultimate 或更新版本。
+
+4. **前端主题**:Ant Design 默认是 light theme。需要监听 `UIManager` 的 LAF 变化,在 dark theme(Darcula)时切换 Ant Design 的 `ConfigProvider theme={darkAlgorithm}`。可以在 Kotlin 侧通过 `LafManagerListener` 检测,调用 `window.__bridge.onThemeChange('dark'|'light')` 通知前端。
+
+5. **Commit Action 兼容性**:`VcsDataKeys.CHECKIN_PROJECT_PANEL` 在 IDEA 2022.3 之前已弃用。如目标覆盖旧版本,需要额外实现 `CheckinHandlerFactory` 方式。MVP 阶段先只支持 2023.1+,fallback 到剪贴板。
diff --git a/index.html b/index.html
index 8fa7c79..d1ae82e 100644
--- a/index.html
+++ b/index.html
@@ -1,429 +1,395 @@
-
-
-
-
-
- CodePlanGUI - 你的 IDEA AI 助手
-
-
-
-
-
-
-
-
-
-
- 内测开放中
- 不被厂商定义的
IDE AI 工作站
- 用你已有的 Coding Plan,在 IDEA 里获得完整的 AI 编码体验 — 流式对话、代码补全、Commit 生成、多步 Agent 工作流,不绑定任何模型厂商。
-
- 无需注册账号 · 你的 Key 你做主 · MIT 开源协议
-
-
-
-
-
-
- 已实现功能
- 为 IDEA 而生的 AI 体验
- 以下所有功能均已实现,开箱即用。不画饼,只交付。
-
-
-
-
- 流式 Chat
- SSE 逐 token 输出,Markdown 渲染 + 代码高亮。
-
-
-
-
- Commit Message
- 一键读取暂存区 diff,生成 Conventional Commits。
-
-
-
-
- 多 Provider
- 任意 OpenAI 兼容接口,支持连接测试。
-
-
-
-
- Ask AI
- 右键选中代码直接提问,告别复制粘贴。
-
-
-
-
- 命令权限控制
- 白名单 + 用户审批 + 工作区路径保护。
-
-
-
-
- 安全存储
- API Key 存入 PasswordSafe,明文不落盘。
-
-
-
-
-
- 开发中
- 内联补全
- 光标停留触发 AI 建议,Tab 接受。
-
-
-
-
- 开发中
- 异构 Agent
- 每个节点绑定不同 Provider,按需分配模型。
-
-
-
-
- 开发中
- MCP Server
- 管理 MCP Servers,AI 自动发现外部工具。
-
-
-
-
- 开发中
- 用量统计
- Token 用量与费用估算,辅助 Provider 选型。
-
-
-
-
-
-
-
-
-
- 为什么选择 CodePlanGUI
- 不被厂商绑架的 IDE AI 助手
- 通义灵码有 IDE 集成但锁定自家模型;Claude Code GUI 能力强但绑定 Anthropic 协议。CodePlanGUI 兼得两者之长。
-
-
-
-
-
- CodePlanGUI
- 通义灵码 / Copilot
- Claude Code GUI
-
-
-
-
- 接入任意 OpenAI 兼容模型
- ✓ DeepSeek / 千问 / 豆包 / Ollama…
- ✗ 绑定自家模型
- ✗ 仅 Anthropic 协议
-
-
- Chat 侧边栏 + 代码高亮
- ✓
- ✓
- ✓
-
-
- Commit Message 生成
- ✓
- ✓
- ✗
-
-
- 命令执行 + 权限审批
- ✓
- ✗
- ✓
-
-
- 本地 Ollama / 私有部署
- ✓
- ✗
- ✗
-
-
- 零服务器 / Key 不上传
- ✓
- ✗
- ✗
-
-
- 异构多节点 Agent(开发中)
- ✓
- ✗
- ✗
-
-
-
-
-
-
-
-
-
-
- 广泛兼容
- 你的 API,你做主
- 支持所有 OpenAI 兼容接口,已有 Key 即可使用,无需重新注册。
-
- OpenAI
- DeepSeek
- 阿里百炼(千问)
- 字节豆包
- 本地 Ollama
- 任何 OpenAI 兼容接口...
-
-
- ┌─────────────────────────────────────────────┐
- │ IntelliJ IDEA │
- │ ├── CodePlanGUI Tool Window (Chat) │
- │ ├── Settings → Providers 管理 │
- │ ├── Git Commit → ✨ 生成提交信息 │
- │ └── JCEF Browser (React 19 + Ant Design 5) │
- │ ↕ Kotlin ↔ JS Bridge │
- │ ↕ OkHttp SSE │
- └─────────────────────────────────────────────┘
- ↓
- 你自己的 AI API (OpenAI 兼容)
- 阿里百炼 / 豆包 / DeepSeek / OpenAI / Ollama
-
-
-
-
-
-
- 快速开始
- 三步上手
- 无需注册,无需绑定账号,几分钟即可开始使用。
-
-
- 1
- 安装插件
- 从 GitHub 下载 zip,Settings → Plugins → Install from Disk
-
-
- 2
- 配置 Provider
- Settings → Tools → CodePlanGUI,填入你的 API Endpoint 和 Key
-
-
- 3
- 开始对话
- 打开右侧 Tool Window,开始和 AI 畅聊你的代码
-
-
-
-
-
-
-
-
- 欢迎加入内测
- CodePlanGUI 正在内测中,我们期待你的反馈与建议。在 GitHub 上 Star、提 Issue 或提交 PR,一起打造更好的 IDEA AI
- 插件。
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+ CodePlanGUI - 你的 IDEA AI 助手
+
+
+
+
+
+
+
+
+
+
+ 内测开放中
+ 不被厂商定义的
IDE AI 工作站
+ 用你已有的 Coding Plan,在 IDEA 里获得完整的 AI 编码体验 — 流式对话、代码补全、Commit 生成、多步 Agent 工作流,不绑定任何模型厂商。
+
+ 无需注册账号 · 你的 Key 你做主 · MIT 开源协议
+
+
+
+
+
+
+ 已实现功能
+ 为 IDEA 而生的 AI 体验
+ 以下所有功能均已实现,开箱即用。不画饼,只交付。
+
+
+
+
+ 流式 Chat
+ SSE 逐 token 输出,Markdown 渲染 + 代码高亮。
+
+
+
+
+ Commit Message
+ 一键读取暂存区 diff,生成 Conventional Commits。
+
+
+
+
+ 多 Provider
+ 任意 OpenAI 兼容接口,支持连接测试。
+
+
+
+
+ Ask AI
+ 右键选中代码直接提问,告别复制粘贴。
+
+
+
+
+ 命令权限控制
+ 白名单 + 用户审批 + 工作区路径保护。
+
+
+
+
+ 安全存储
+ API Key 存入 PasswordSafe,明文不落盘。
+
+
+
+
+
+ 开发中
+ 内联补全
+ 光标停留触发 AI 建议,Tab 接受。
+
+
+
+
+ 开发中
+ 异构 Agent
+ 每个节点绑定不同 Provider,按需分配模型。
+
+
+
+
+ 开发中
+ MCP Server
+ 管理 MCP Servers,AI 自动发现外部工具。
+
+
+
+
+ 开发中
+ 用量统计
+ Token 用量与费用估算,辅助 Provider 选型。
+
+
+
+
+
+
+
+
+
+ 为什么选择 CodePlanGUI
+ 不被厂商绑架的 IDE AI 助手
+ 通义灵码有 IDE 集成但锁定自家模型;Claude Code GUI 能力强但绑定 Anthropic 协议。CodePlanGUI 兼得两者之长。
+
+
+
+
+
+ CodePlanGUI
+ 通义灵码 / Copilot
+ Claude Code GUI
+
+
+
+
+ 接入任意 OpenAI 兼容模型
+ ✓ DeepSeek / 千问 / 豆包 / Ollama…
+ ✗ 绑定自家模型
+ ✗ 仅 Anthropic 协议
+
+
+ Chat 侧边栏 + 代码高亮
+ ✓
+ ✓
+ ✓
+
+
+ Commit Message 生成
+ ✓
+ ✓
+ ✗
+
+
+ 命令执行 + 权限审批
+ ✓
+ ✗
+ ✓
+
+
+ 本地 Ollama / 私有部署
+ ✓
+ ✗
+ ✗
+
+
+ 零服务器 / Key 不上传
+ ✓
+ ✗
+ ✗
+
+
+ 异构多节点 Agent(开发中)
+ ✓
+ ✗
+ ✗
+
+
+
+
+
+
+
+
+
+
+ 广泛兼容
+ 你的 API,你做主
+ 支持所有 OpenAI 兼容接口,已有 Key 即可使用,无需重新注册。
+
+ OpenAI
+ DeepSeek
+ 阿里百炼(千问)
+ 字节豆包
+ 本地 Ollama
+ 任何 OpenAI 兼容接口...
+
+
+┌─────────────────────────────────────────────┐
+│ IntelliJ IDEA │
+│ ├── CodePlanGUI Tool Window (Chat) │
+│ ├── Settings → Providers 管理 │
+│ ├── Git Commit → ✨ 生成提交信息 │
+│ └── JCEF Browser (React 19 + Ant Design 5) │
+│ ↕ Kotlin ↔ JS Bridge │
+│ ↕ OkHttp SSE │
+└─────────────────────────────────────────────┘
+ ↓
+ 你自己的 AI API (OpenAI 兼容)
+ 阿里百炼 / 豆包 / DeepSeek / OpenAI / Ollama
+
+
+
+
+
+
+ 快速开始
+ 三步上手
+ 无需注册,无需绑定账号,几分钟即可开始使用。
+
+
+ 1
+ 安装插件
+ 从 GitHub 下载 zip,Settings → Plugins → Install from Disk
+
+
+ 2
+ 配置 Provider
+ Settings → Tools → CodePlanGUI,填入你的 API Endpoint 和 Key
+
+
+ 3
+ 开始对话
+ 打开右侧 Tool Window,开始和 AI 畅聊你的代码
+
+
+
+
+
+
+
+
+ 欢迎加入内测
+ CodePlanGUI 正在内测中,我们期待你的反馈与建议。在 GitHub 上 Star、提 Issue 或提交 PR,一起打造更好的 IDEA AI 插件。
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/kotlin/com/github/codeplangui/BridgeHandler.kt b/src/main/kotlin/com/github/codeplangui/BridgeHandler.kt
index 4c2422c..b74f9dd 100644
--- a/src/main/kotlin/com/github/codeplangui/BridgeHandler.kt
+++ b/src/main/kotlin/com/github/codeplangui/BridgeHandler.kt
@@ -5,6 +5,7 @@ import com.intellij.ui.jcef.JBCefBrowser
import com.intellij.ui.jcef.JBCefBrowserBase
import com.intellij.ui.jcef.JBCefJSQuery
import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.cef.browser.CefBrowser
@@ -155,7 +156,7 @@ class BridgeHandler(
}
is BridgePayloadHandlingResult.CommandError -> {
logger.warn(result.message, result.cause)
- notifyError(result.message)
+ notifyError("generic", result.message)
null
}
}
@@ -235,11 +236,14 @@ class BridgeHandler(
fun notifyStart(msgId: String) = flushAndPush("window.__bridge.onStart(${msgId.quoted()})")
- fun notifyToken(token: String) = enqueueJS("window.__bridge.onToken(${json.encodeToString(token)})")
+ fun notifyToken(token: String) = enqueueJS("window.__bridge.onToken(${token.quoted()})")
fun notifyEnd(msgId: String) = flushAndPush("window.__bridge.onEnd(${msgId.quoted()})")
- fun notifyError(message: String) = flushAndPush("window.__bridge.onError(${json.encodeToString(message)})")
+ fun notifyError(errorType: String, message: String) {
+ logger.warn("[CodePlanGUI Bridge] notifyError: type=$errorType, message=${message.take(80)}")
+ flushAndPush("window.__bridge.onError(${errorType.quoted()}, ${message.quoted()})")
+ }
fun notifyStructuredError(error: BridgeErrorPayload) =
flushAndPush("window.__bridge.onStructuredError(${json.encodeToString(error)})")
@@ -248,10 +252,10 @@ class BridgeHandler(
flushAndPush("window.__bridge.onStatus(${json.encodeToString(status)})")
fun notifyContextFile(fileName: String) =
- pushJS("window.__bridge.onContextFile(${json.encodeToString(fileName)})")
+ pushJS("window.__bridge.onContextFile(${fileName.quoted()})")
fun notifyTheme(theme: String) =
- pushJS("window.__bridge.onTheme(${json.encodeToString(theme)})")
+ pushJS("window.__bridge.onTheme(${theme.quoted()})")
fun notifyLog(msgId: String, logLine: String, type: String) =
enqueueJS(
diff --git a/src/main/kotlin/com/github/codeplangui/ChatService.kt b/src/main/kotlin/com/github/codeplangui/ChatService.kt
index 1e5ee28..8475a87 100644
--- a/src/main/kotlin/com/github/codeplangui/ChatService.kt
+++ b/src/main/kotlin/com/github/codeplangui/ChatService.kt
@@ -1,6 +1,7 @@
package com.github.codeplangui
import com.github.codeplangui.api.OkHttpSseClient
+import com.github.codeplangui.api.ClassifiedError
import com.github.codeplangui.api.ToolCallAccumulator
import com.github.codeplangui.api.ToolCallDelta
import com.github.codeplangui.api.ToolDefinition
@@ -117,22 +118,14 @@ class ChatService(private val project: Project) : Disposable {
val provider = settings.getActiveProvider()
if (provider == null) {
publishStatus()
- bridgeHandler?.notifyStructuredError(BridgeErrorPayload(
- type = "config",
- message = "请先在 Settings > Tools > CodePlanGUI 中配置 API Provider",
- action = "openSettings"
- ))
+ bridgeHandler?.notifyError("auth", "请先在 Settings > Tools > CodePlanGUI 中配置 API Provider")
return
}
val apiKey = ApiKeyStore.load(provider.id) ?: ""
if (apiKey.isBlank()) {
publishStatus()
- bridgeHandler?.notifyStructuredError(BridgeErrorPayload(
- type = "config",
- message = "API Key 未设置或未保存,请在 Settings 中重新配置并点 Apply/OK",
- action = "openSettings"
- ))
+ bridgeHandler?.notifyError("auth", "API Key 未设置或未保存,请在 Settings 中重新配置并点 Apply/OK")
return
}
@@ -393,19 +386,16 @@ $selection
}
/** Terminates an in-progress stream with an error and resets all state, preventing a permanent stuck spinner. */
- private fun abortStream(msgId: String, errorMessage: String) {
+ private fun abortStream(msgId: String, errorType: String, errorMessage: String) {
if (activeMessageId != msgId) return
- logger.warn("[CodePlanGUI Approval] aborting stream msgId=$msgId error=${errorMessage.summarizeForLog(240)}")
+ logger.warn("[CodePlanGUI Approval] aborting stream msgId=$msgId type=$errorType error=${errorMessage.summarizeForLog(240)}")
activeStream?.cancel()
activeStream = null
activeMessageId = null
bridgeNotifiedStart.remove(msgId)
resetToolCallState()
publishStatus()
- bridgeHandler?.notifyStructuredError(BridgeErrorPayload(
- type = "runtime",
- message = errorMessage
- ))
+ bridgeHandler?.notifyError(errorType, errorMessage)
}
private fun startStreamingRound(msgId: String, request: okhttp3.Request, toolsEnabled: Boolean) {
@@ -452,14 +442,14 @@ $selection
bridgeHandler?.notifyEnd(msgId)
}
},
- onError = { message ->
+ onError = { classifiedError ->
if (activeMessageId == msgId) {
- logger.warn("[CodePlanGUI Approval] model round failed msgId=$msgId error=$message")
+ logger.warn("[CodePlanGUI Approval] model round failed msgId=$msgId type=${classifiedError.type} error=${classifiedError.message}")
activeStream = null
activeMessageId = null
bridgeNotifiedStart.remove(msgId)
publishStatus()
- bridgeHandler?.notifyStructuredError(classifyStreamError(message))
+ bridgeHandler?.notifyError(classifiedError.type, classifiedError.message)
}
},
onToolCallChunk = { delta ->
@@ -555,7 +545,7 @@ $selection
private fun prepareToolCallsForExecution(msgId: String): List? {
val accumulatedToolCalls = toolCallAccumulator.snapshot()
if (accumulatedToolCalls.isEmpty()) {
- abortStream(msgId, "AI sent a tool_calls finish_reason but no tool call deltas were captured")
+ abortStream(msgId, "generic", "AI sent a tool_calls finish_reason but no tool call deltas were captured")
return null
}
@@ -563,6 +553,7 @@ $selection
val toolCallId = accumulated.id ?: run {
abortStream(
msgId,
+ "generic",
"AI sent a tool_calls finish_reason but tool call index ${accumulated.index} had no id"
)
return null
@@ -571,11 +562,11 @@ $selection
val argsObj = try {
kotlinx.serialization.json.Json.parseToJsonElement(argsJson).jsonObject
} catch (_: Exception) {
- abortStream(msgId, "AI returned malformed tool call arguments for index ${accumulated.index}: '$argsJson'")
+ abortStream(msgId, "generic", "AI returned malformed tool call arguments for index ${accumulated.index}: '$argsJson'")
return null
}
val command = argsObj["command"]?.jsonPrimitive?.contentOrNull ?: run {
- abortStream(msgId, "AI tool call index ${accumulated.index} is missing required 'command' field")
+ abortStream(msgId, "generic", "AI tool call index ${accumulated.index} is missing required 'command' field")
return null
}
val description = argsObj["description"]?.jsonPrimitive?.contentOrNull ?: ""
diff --git a/src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt b/src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt
index 49f7a48..7c1e801 100644
--- a/src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt
+++ b/src/main/kotlin/com/github/codeplangui/api/OkHttpSseClient.kt
@@ -86,6 +86,15 @@ sealed class TestResult {
data class Failure(val message: String) : TestResult()
}
+/**
+ * Classified API error with type tag for frontend differentiation.
+ * type: "auth" | "quota" | "temp" | "generic"
+ */
+data class ClassifiedError(
+ val type: String,
+ val message: String
+)
+
private const val STREAM_DEBUG_MAX_LENGTH = 1200
internal fun summarizeInterestingSseFrame(id: String?, type: String?, data: String): String? {
@@ -176,7 +185,7 @@ class OkHttpSseClient(
request: Request,
onToken: (String) -> Unit,
onEnd: () -> Unit,
- onError: (String) -> Unit,
+ onError: (ClassifiedError) -> Unit,
onToolCallChunk: (ToolCallDelta) -> Unit = {},
onFinishReason: (String) -> Unit = {}
): EventSource {
@@ -210,7 +219,8 @@ class OkHttpSseClient(
} else {
buildErrorMessage(response, t, responseBody = peeked)
}
- onError(msg)
+ val errorType = classifyErrorType(msg)
+ onError(ClassifiedError(type = errorType, message = msg))
}
}
return eventSourceFactory.newEventSource(request, listener)
@@ -234,7 +244,7 @@ class OkHttpSseClient(
if (cont.isActive) cont.resume(Result.success(accumulated.toString()))
},
onError = { error ->
- if (cont.isActive) cont.resume(Result.failure(Exception(error)))
+ if (cont.isActive) cont.resume(Result.failure(Exception(error.message)))
}
)
cont.invokeOnCancellation { source.cancel() }
@@ -322,6 +332,18 @@ class OkHttpSseClient(
}
}
+ /**
+ * Classifies a raw error message into a typed error for frontend display.
+ */
+ fun classifyErrorType(rawMessage: String): String {
+ return when {
+ QUOTA_PATTERNS.any { it in rawMessage.lowercase() } -> "quota"
+ AUTH_PATTERNS.any { it in rawMessage.lowercase() } -> "auth"
+ BUSY_PATTERNS.any { it in rawMessage.lowercase() } -> "temp"
+ else -> "generic"
+ }
+ }
+
/**
* Checks if a response body indicates an error despite HTTP 200.
* Handles providers like GLM/Doubao/Qianwen that wrap errors as:
diff --git a/src/main/resources/webview/index.html b/src/main/resources/webview/index.html
index 544c6aa..aeb1fe7 100644
--- a/src/main/resources/webview/index.html
+++ b/src/main/resources/webview/index.html
@@ -1,10 +1,10 @@
-
-
-
-
-
- CodePlanGUI
-
-
-
-
-
-
-
+*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-variable,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id{color:#79c0ff}.hljs-regexp,.hljs-string,.hljs-meta .hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-comment,.hljs-code,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-tag,.hljs-selector-pseudo{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}:root,.theme-dark{color-scheme:dark;--bg: #0f1115;--bg-elevated: rgba(22, 24, 30, .86);--panel: rgba(29, 31, 38, .84);--panel-strong: rgba(40, 30, 20, .72);--border: rgba(232, 214, 190, .12);--border-strong: rgba(214, 164, 95, .28);--text: #f2eadf;--muted: #bcae9a;--accent: #d2a15e;--accent-strong: #f0c488;--danger: #ff8a75;--shadow: 0 18px 48px rgba(0, 0, 0, .35);--font-body: "Avenir Next", "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;--font-mono: "SFMono-Regular", "JetBrains Mono", "Menlo", monospace;--input-bg: rgba(22, 24, 30, .92);--input-border: rgba(232, 214, 190, .14);--input-placeholder: rgba(188, 174, 154, .68);--input-focus-border: rgba(240, 196, 136, .38);--input-focus-ring: rgba(210, 161, 94, .09);--gradient-bg: linear-gradient(180deg, #14171c 0%, #0f1115 100%);--bar-bg: linear-gradient(180deg, rgba(23, 25, 31, .92), rgba(16, 18, 24, .88));--input-area-bg: linear-gradient(180deg, rgba(16, 18, 24, .82), rgba(12, 14, 19, .94));--assistant-bubble-bg: linear-gradient(180deg, rgba(29, 31, 38, .94), rgba(17, 19, 24, .92));--pre-bg: rgba(7, 8, 11, .88);--pre-border: rgba(240, 196, 136, .12);--card-bg: linear-gradient(180deg, rgba(35, 28, 23, .54), rgba(16, 18, 24, .88));--send-button-bg: linear-gradient(135deg, #b98346, #87552b);--send-button-shadow: 0 10px 24px rgba(89, 47, 17, .28);--send-button-disabled-bg: rgba(70, 72, 80, .65);--send-button-disabled-border: rgba(255, 255, 255, .08)}.theme-light{color-scheme:light;--bg: #f5f5f5;--bg-elevated: rgba(255, 255, 255, .9);--panel: rgba(255, 255, 255, .85);--panel-strong: rgba(245, 240, 230, .8);--border: rgba(0, 0, 0, .08);--border-strong: rgba(210, 161, 94, .35);--text: #1a1a1a;--muted: #666666;--accent: #b98346;--accent-strong: #87552b;--danger: #d94e41;--shadow: 0 18px 48px rgba(0, 0, 0, .12);--input-bg: rgba(255, 255, 255, .95);--input-border: rgba(135, 85, 43, .18);--input-placeholder: rgba(102, 102, 102, .6);--input-focus-border: rgba(135, 85, 43, .35);--input-focus-ring: rgba(185, 131, 70, .14);--gradient-bg: linear-gradient(180deg, #fafafa 0%, #f5f5f5 100%);--bar-bg: linear-gradient(180deg, rgba(250, 250, 252, .97), rgba(242, 242, 246, .95));--input-area-bg: linear-gradient(180deg, rgba(248, 248, 250, .95), rgba(240, 240, 244, .98));--assistant-bubble-bg: linear-gradient(180deg, rgba(255, 255, 255, .95), rgba(248, 248, 250, .92));--pre-bg: rgba(236, 236, 240, .9);--pre-border: rgba(180, 130, 70, .15);--card-bg: linear-gradient(180deg, rgba(255, 255, 255, .85), rgba(242, 242, 246, .9));--send-button-bg: linear-gradient(135deg, #d5ae77, #b98346);--send-button-shadow: 0 10px 20px rgba(185, 131, 70, .18);--send-button-disabled-bg: rgba(205, 205, 210, .88);--send-button-disabled-border: rgba(120, 120, 128, .12)}*{box-sizing:border-box}html,body,#root{margin:0;min-height:100%;height:100%}body{overflow:hidden;font-family:var(--font-body);font-size:13px;color:var(--text);background:radial-gradient(circle at top left,rgba(210,161,94,.12),transparent 32%),radial-gradient(circle at bottom right,rgba(125,72,43,.14),transparent 30%),var(--gradient-bg)}body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.1;background-image:linear-gradient(rgba(255,255,255,.04) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.04) 1px,transparent 1px);background-size:36px 36px;mask-image:linear-gradient(180deg,rgba(255,255,255,.75),transparent)}.app-shell{display:flex;flex-direction:column;height:100vh;position:relative}.provider-bar{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:18px 18px 14px;border-bottom:1px solid var(--border);backdrop-filter:blur(16px);background:var(--bar-bg)}.provider-eyebrow,.assistant-bubble-label,.context-caption,.empty-kicker{text-transform:uppercase;letter-spacing:.18em;font-size:10px;color:var(--muted)}.provider-title{margin:4px 0 0!important;color:var(--text)!important;font-size:18px!important;font-weight:600!important}.provider-meta{display:block;margin-top:6px;color:var(--muted)!important}.provider-context{display:block;margin-top:4px;color:var(--accent)!important;font-size:11px}.provider-actions{display:flex;gap:8px}.provider-action.ant-btn{color:var(--accent)!important;border:1px solid var(--border-strong);border-radius:999px;background:#d2a15e14}.provider-action.ant-btn:hover{color:var(--accent-strong)!important;border-color:#f0c48873!important;background:#d2a15e24!important}.messages-area{flex:1;overflow-y:auto;padding:16px 18px 12px;scroll-behavior:smooth}.empty-state{min-height:100%;display:flex;align-items:center;justify-content:center;text-align:center;padding:24px}.empty-card{width:min(100%,420px);padding:28px 24px;border-radius:28px;border:1px solid var(--border);background:var(--card-bg),var(--bg-elevated);box-shadow:var(--shadow)}.empty-icon{width:74px;height:74px;margin:0 auto 16px;display:grid;place-items:center;border-radius:50%;border:1px solid rgba(210,161,94,.28);background:radial-gradient(circle,#d2a15e33,#d2a15e08);color:var(--accent-strong);font-size:28px}.empty-title{margin:8px 0!important;color:var(--text)!important}.empty-copy{color:var(--muted);line-height:1.8}.message-row{display:flex;margin-bottom:16px}.message-row-user{justify-content:flex-end}.message-row-assistant{justify-content:flex-start}.message-bubble{max-width:min(88%,720px);border-radius:22px;border:1px solid var(--border);box-shadow:var(--shadow)}.message-bubble-user{padding:13px 15px;background:linear-gradient(135deg,#d2a15e3d,#7345266b),var(--panel-strong);border-radius:22px 22px 8px}.message-bubble-user .ant-typography{color:var(--text);white-space:pre-wrap;word-break:break-word}.message-bubble-assistant{width:100%;padding:14px 16px 16px;background:var(--assistant-bubble-bg),var(--panel)}.assistant-bubble-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.bubble-copy-button.ant-btn{color:var(--muted)!important}.bubble-copy-button.ant-btn:hover{color:var(--accent-strong)!important;background:#d2a15e14!important}.assistant-markdown{line-height:1.75;color:var(--text);word-break:break-word}.assistant-markdown h1,.assistant-markdown h2,.assistant-markdown h3{font-weight:600;letter-spacing:.02em}.assistant-markdown p,.assistant-markdown ul,.assistant-markdown ol{margin:0 0 .9em}.assistant-markdown a{color:var(--accent-strong)}.assistant-markdown pre{margin:1.1em 0;padding:14px 14px 16px;overflow-x:auto;border-radius:18px;border:1px solid var(--pre-border);background:var(--pre-bg);position:relative}.assistant-markdown code{font-family:var(--font-mono)}.assistant-markdown code:not(pre code){padding:.15em .45em;border-radius:999px;background:#d2a15e1f;color:var(--accent-strong)}.bubble-copy-anchor{position:absolute;top:10px;right:10px}.bubble-copy-fallback{border:1px solid rgba(240,196,136,.18);border-radius:999px;background:#22242deb;color:var(--muted);font-family:var(--font-body);font-size:11px;padding:4px 9px;cursor:pointer}.bubble-copy-fallback:hover{color:var(--accent-strong)}.stream-cursor{display:inline-block;width:8px;height:14px;margin-left:4px;border-radius:3px;background:var(--accent);animation:blink .7s infinite;vertical-align:text-bottom}.continuation-indicator{display:flex;align-items:center;gap:8px;padding:8px 16px;color:var(--muted);font-size:12px}.continuation-text{color:var(--muted);font-size:12px}.continuation-spinner{display:inline-block;width:12px;height:12px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.input-area{display:flex;flex-direction:column;gap:10px;padding:14px 16px 18px;border-top:1px solid var(--border);background:var(--input-area-bg)}.input-meta{display:flex;align-items:center;justify-content:flex-start;gap:12px}.context-toggle{display:inline-flex;align-items:center;gap:10px}.context-caption{font-size:10px}.context-file-label{max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--accent)}.composer-row{display:flex;align-items:flex-end;gap:10px}.composer-input{flex:1;resize:none;min-height:44px;max-height:120px;padding:11px 13px;border-radius:18px;border:1px solid var(--input-border);background:var(--input-bg);color:var(--text);font:inherit;line-height:1.6;outline:none;box-shadow:inset 0 1px #ffffff0a;caret-color:var(--accent-strong);cursor:text}.composer-input:focus{border-color:var(--input-focus-border);box-shadow:0 0 0 4px var(--input-focus-ring)}.composer-input::placeholder{color:var(--input-placeholder)}.send-button.ant-btn{height:44px;border-radius:16px;border:1px solid rgba(240,196,136,.28);background:var(--send-button-bg)!important;box-shadow:var(--send-button-shadow)}.send-button.ant-btn:disabled{background:var(--send-button-disabled-bg)!important;border-color:var(--send-button-disabled-border)!important;box-shadow:none}@keyframes blink{0%,to{opacity:1}50%{opacity:0}}.exec-card{max-width:min(88%,720px);border-radius:14px;border:1px solid var(--border);background:var(--assistant-bubble-bg),var(--panel);padding:10px 14px;font-size:13px}.exec-card-header{margin-bottom:6px}.exec-card-command{font-size:12px!important}.exec-log-panel{margin-top:8px;border-radius:10px;border:1px solid var(--border);background:var(--pre-bg);overflow:hidden}.exec-log-header{display:flex;align-items:center;gap:8px;padding:5px 12px;cursor:pointer;user-select:none;font-size:11px;color:var(--muted);border-bottom:1px solid var(--border)}.exec-log-header:hover{color:var(--accent-strong)}.exec-log-title{font-family:var(--font-mono);letter-spacing:.02em}.exec-log-body{max-height:220px;overflow-y:auto;padding:6px 12px;font-family:var(--font-mono);font-size:11px;line-height:1.6}.exec-log-line{white-space:pre-wrap;word-break:break-all;padding:1px 0}.exec-log-stdout{color:var(--text);opacity:.85}.exec-log-stderr{color:var(--danger)}.exec-log-info{color:var(--accent);opacity:.9}
+
+
+
+
+