diff --git a/.codegraph/codegraph.db b/.codegraph/codegraph.db new file mode 100644 index 00000000..e041f99d Binary files /dev/null and b/.codegraph/codegraph.db differ diff --git a/.codegraph/codegraph.db-shm b/.codegraph/codegraph.db-shm new file mode 100644 index 00000000..a315cf73 Binary files /dev/null and b/.codegraph/codegraph.db-shm differ diff --git a/.codegraph/codegraph.db-wal b/.codegraph/codegraph.db-wal new file mode 100644 index 00000000..6e0eda9d Binary files /dev/null and b/.codegraph/codegraph.db-wal differ diff --git a/.task-session-resume.md b/.task-session-resume.md new file mode 100644 index 00000000..8c89b372 --- /dev/null +++ b/.task-session-resume.md @@ -0,0 +1,65 @@ +# Task: Session Resume QuickPick on Agent Connect + +## Goal +When the user connects to an agent, check if there are previous sessions for that agent + workspace. If yes, show a QuickPick to let the user choose: resume the last session or create a new one. + +## Files to Modify +- `src/core/SessionManager.ts` — modify `connectToAgent()` method + +## Current Behavior +`connectToAgent()` always calls `createAcpSession()` which always calls `newSession()`. + +## New Behavior +1. After connecting to the agent, check if it supports `session/resume` (already available via `this.connectionManager.getConnection()`) +2. Query `this.historyStore` for recent sessions matching this agent + workspace cwd +3. If sessions exist, show a QuickPick: + - Option 1: "上次会话: {title} ({updatedAt})" — calls `resumeSession(sessionId)` + - Option 2: "新建会话" — calls `newSession()` (current behavior) +4. If no sessions, proceed with `newSession()` as before + +## Key Context + +### SessionHistoryStore API +```typescript +// Already available as this.historyStore +list(agentName: string, cwd?: string): PersistedSessionEntry[] +``` +Returns entries with: `{ agentName, sessionId, title?, preview?, lastActive?, ... }` + +### SessionManager existing methods +- `connectToAgent(agentName, workspaceCwd?)` — the method to modify (line ~200) +- `createAcpSession(agentName, agentId, connInfo, workspaceCwd)` — creates new session +- `loadSession(agentName, sessionId)` — loads a session (replays history) +- `resumeSession(agentName, sessionId)` — resumes a session (no history replay) +- `getCachedCapabilities(agentName)` — returns `{ load, resume, list }` capabilities + +### The modify point +In `connectToAgent()`, after the connection is established (line ~220): +```typescript +const sessionInfo = await this.createAcpSession(agentName, agentId, connInfo, workspaceCwd); +``` + +Replace this with the QuickPick logic. + +## Implementation Steps + +1. After connection is established, get capabilities: `const caps = this.getCachedCapabilities(agentName)` +2. If `caps?.resume` or `caps?.load`: + - Get history: `const sessions = this.historyStore?.list(agentName, workspaceCwd) || []` + - Filter to sessions with history (messageCount > 0) + - Sort by lastActive (most recent first) + - Take top 5 +3. If sessions.length > 0: + - Show QuickPick with session titles and "新建会话" option + - If user picks a session: + - If `caps.load`: call `this.loadSession(agentName, sessionId)` (replays history) + - Else if `caps.resume`: call `this.resumeSession(agentName, sessionId)` (no replay) + - If user picks "新建会话": call `this.createAcpSession(...)` as before +4. If no sessions: proceed with `createAcpSession()` as before + +## Constraints +- Don't break existing behavior — if no history or user cancels, default to newSession +- Use VS Code's `vscode.window.showQuickPick` API +- Show the QuickPick with `placeHolder` text +- Don't add npm dependencies +- Run `npm run compile` to verify diff --git a/.task.md b/.task.md new file mode 100644 index 00000000..d88993b1 --- /dev/null +++ b/.task.md @@ -0,0 +1,32 @@ +你需要在 vscode-acp 项目中实现 Improved Diff Preview 功能。 + +## 任务 + +创建两个新文件,修改一个现有文件。工作目录是当前目录。 + +## 参考 + +阅读 AGENTS.md 了解完整设计。核心要点: +- 自定义 URI Scheme (acp-diff:) 代替临时文件 +- 同步快照(fs.readFileSync)避免竞态 +- 通过 SessionUpdateHandler.addListener 注册 +- dispose 时用 removeListener 清理 + +## 步骤 + +1. 读 AGENTS.md 了解完整设计 +2. 读 src/handlers/SessionUpdateHandler.ts 了解 listener 接口 +3. 读 src/extension.ts 了解注册和 dispose 位置 +4. 创建 src/handlers/DiffContentProvider.ts — 自定义 URI Scheme 的 TextDocumentContentProvider +5. 创建 src/handlers/DiffPreviewHandler.ts — 核心 diff 预览处理器 +6. 修改 src/extension.ts — 注册 DiffPreviewHandler +7. 运行 npm run compile 验证编译通过 + +## 关键约束 +- 不要添加 npm 依赖 +- TypeScript strict mode +- 用 log() 不用 console.log +- snapshotOldContent 必须是同步的(fs.readFileSync),不要用 async +- dispose 时必须 removeListener +- isEditKind 只匹配 edit/delete/move,不匹配 read/search/think/execute +- 完成后 npm run compile 必须通过 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..90926971 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,282 @@ +# AGENTS.md — vscode-acp 项目指南 + +## 项目概述 + +**vscode-acp** 是一个 VS Code 扩展,实现了 [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) 客户端。 +用户可以在 VS Code 内连接任何兼容 ACP 的 AI 编程 Agent(Claude Code、Hermes、Reasonix、Copilot 等)。 + +- **仓库**: `formulahendry/vscode-acp`(上游) +- **Fork**: `Henry-916/vscode-acp`(我们的 fork) +- **语言**: TypeScript +- **构建**: Webpack + tsconfig +- **测试**: `npm test`(VS Code Extension Host 测试) +- **Lint**: ESLint (`npm run lint`) + +## 当前任务 + +### feat: Improved Diff Preview for ACP Agents + +**目标**: 实现高质量的文件编辑 diff 预览,让 Agent 编辑文件时用户能实时看到变化。 + +**背景**: +- PR #37 (yes999zc) 提出了基础实现,但存在竞态条件、资源泄漏等问题 +- 我们的方案借鉴 Cline 的架构(自定义 URI Scheme),适配 ACP 协议 +- 不使用临时文件,不依赖 setTimeout + +**分支**: `feat/improved-diff-preview` + +## 项目结构 + +``` +src/ +├── extension.ts # 入口,注册所有 handler/provider +├── core/ +│ ├── AcpClientImpl.ts # ACP 协议客户端实现 +│ ├── AgentManager.ts # Agent 配置管理 +│ ├── ConnectionManager.ts # 连接生命周期 +│ ├── SessionHistoryStore.ts # 会话历史存储 +│ └── SessionManager.ts # 会话创建/恢复 +├── handlers/ +│ ├── FileSystemHandler.ts # ACP 文件读写(readTextFile/writeTextFile) +│ ├── PermissionHandler.ts # 权限请求处理 +│ ├── SessionUpdateHandler.ts # session/update 通知路由(关键!) +│ └── TerminalHandler.ts # 终端命令执行 +├── ui/ +│ ├── ChatWebviewProvider.ts # 聊天 WebView +│ ├── SessionTreeProvider.ts # 侧边栏会话树 +│ └── StatusBarManager.ts # 状态栏 +├── config/ +│ ├── AgentConfig.ts # Agent 配置 +│ └── RegistryClient.ts # Agent 注册表 +└── utils/ + ├── Logger.ts # 日志 + ├── StreamAdapter.ts # 流处理 + └── TelemetryManager.ts # 遥测 +``` + +## 关键架构:SessionUpdateHandler + +所有 ACP `session/update` 通知都通过 `SessionUpdateHandler` 路由: + +```typescript +// SessionUpdateHandler.ts +export type SessionUpdateListener = (update: SessionNotification) => void; + +export class SessionUpdateHandler { + private listeners: Set = new Set(); + + addListener(listener: SessionUpdateListener): void { + this.listeners.add(listener); + } + + removeListener(listener: SessionUpdateListener): void { + this.listeners.delete(listener); + } + + handleUpdate(update: SessionNotification): void { + for (const listener of this.listeners) { + try { listener(update); } catch (e) { log(`Error: ${e}`); } + } + } + + dispose(): void { this.listeners.clear(); } +} +``` + +**我们的 DiffPreviewHandler 通过 `addListener` 注册,接收所有 tool_call 通知。** + +## ACP 协议关键点 + +### tool_call 通知格式 + +```json +{ + "method": "session/update", + "params": { + "update": { + "sessionUpdate": "tool_call", // 或 "tool_call_update" + "toolCallId": "call_001", + "title": "Editing configuration file", + "kind": "edit", // read|edit|delete|move|search|execute|think|fetch|other + "status": "pending", // pending|in_progress|completed|failed + "locations": [{ "path": "/abs/path", "line": 42 }], + "content": [{ "type": "diff", "path": "...", ... }], + "rawInput": { ... } + } + } +} +``` + +### Status 枚举 +- `pending` — 工具调用已创建,未开始执行 +- `in_progress` — 正在执行 +- `completed` — 执行完成 +- `failed` — 执行失败 + +### Kind 枚举(与 diff 相关的) +- `edit` — 修改文件内容 +- `delete` — 删除文件 +- `move` — 移动/重命名文件 +- `create` — 创建新文件(某些 agent 用 edit + 新文件路径) + +### 文件路径提取优先级 +1. `locations[].path` — 最可靠,ACP 规范推荐 +2. `content[].path`(type=diff 时)— 有 diff 内容时 +3. `rawInput` 递归扫描(key: path/file/filePath/filepath/filename/targetPath/sourcePath) +4. `title` 正则回退 — 最不可靠 + +## Diff 预览设计方案 + +### 核心架构(借鉴 Cline) + +``` + ┌─────────────────────┐ + │ SessionUpdateHandler │ + └──────────┬──────────┘ + │ session/update + ┌──────────▼──────────┐ + │ DiffPreviewHandler │ + │ │ + │ 1. tool_call │ + │ pending/in_prog │ + │ → 同步快照旧内容 │ + │ → 存入 URI Map │ + │ → 高亮编辑行 │ + │ │ + │ 2. tool_call_update │ + │ → 更新高亮位置 │ + │ │ + │ 3. tool_call │ + │ completed │ + │ → vscode.diff │ + │ → 清理状态 │ + └─────────────────────┘ +``` + +### 关键设计决策 + +**1. 自定义 URI Scheme(不用临时文件)** +```typescript +const DIFF_URI_SCHEME = 'acp-diff'; + +// 旧内容 base64 编码到 URI query 参数 +function createOldContentUri(filePath: string, oldContent: string): vscode.Uri { + const fileName = path.basename(filePath); + return vscode.Uri.parse(`${DIFF_URI_SCHEME}:${fileName}`) + .with({ query: Buffer.from(oldContent).toString('base64') }); +} + +// 注册 TextDocumentContentProvider 提供旧内容 +class AcpDiffContentProvider implements vscode.TextDocumentContentProvider { + provideTextDocumentContent(uri: vscode.Uri): string { + return Buffer.from(uri.query, 'base64').toString('utf-8'); + } +} +``` + +**2. 同步快照(无竞态)** +```typescript +// tool_call pending 时立即同步读取 +// 优先从已打开的编辑器获取(可能有未保存内容) +// 回退到磁盘读取 +function snapshotOldContent(filePath: string): string | undefined { + const openDoc = vscode.workspace.textDocuments.find( + doc => doc.uri.fsPath === filePath + ); + if (openDoc) return openDoc.getText(); + + try { + const raw = fs.readFileSync(filePath); + const encoding = detectEncoding(raw); + return iconv.decode(raw, encoding); + } catch { + return undefined; // 新文件 + } +} +``` + +**3. 高亮编辑位置** +```typescript +// 编辑中的高亮装饰 +const editDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + backgroundColor: new vscode.ThemeColor('editor.findMatchHighlightBackground'), + overviewRulerColor: new vscode.ThemeColor('editorOverviewRuler.findMatchForeground'), + overviewRulerLane: vscode.OverviewRulerLane.Right, +}); +``` + +**4. Diff 触发** +```typescript +// completed 时打开 diff 视图 +const oldUri = createOldContentUri(filePath, oldContent); +const newUri = vscode.Uri.file(filePath); +await vscode.commands.executeCommand( + 'vscode.diff', + oldUri, + newUri, + `${path.basename(filePath)}: Original ↔ Agent's Changes` +); +``` + +## 开发规范 + +### 代码风格 +- TypeScript strict mode +- ESLint + Prettier +- 函数和变量用 camelCase +- 类和接口用 PascalCase +- 注释用英文(与上游保持一致) + +### Git 提交规范 +``` +feat: add DiffPreviewHandler with custom URI scheme +fix: handle encoding detection for non-UTF-8 files +refactor: extract snapshot logic to separate method +test: add unit tests for AcpDiffContentProvider +docs: update AGENTS.md with ACP protocol details +``` + +### 构建和测试 +```bash +npm install # 安装依赖 +npm run compile # 编译 +npm run lint # Lint 检查 +npm test # 运行测试 +npm run watch # 监听模式(开发用) +npx vsce package # 打包 VSIX +``` + +### 测试方法 +1. `npm run compile` 确保无编译错误 +2. 按 F5 启动 Extension Development Host +3. 连接一个 ACP Agent(如 Hermes: `hermes acp`) +4. 让 Agent 编辑文件,观察 diff 视图是否正确弹出 +5. 检查:新建文件、修改文件、多文件编辑、编码特殊文件 + +## 已知限制 + +- `content` 中的 `diff` 类型依赖 Agent 实现,不是所有 Agent 都提供 +- `rawInput` 结构因 Agent 而异,路径提取是启发式的 +- 暂不支持流式 diff(只在 completed 后显示)— 后续可增强 +- 暂不支持用户在 diff 视图中编辑后接受/拒绝 — 后续可增强 + +## 参考实现 + +### Cline (最佳实践) +- `apps/vscode/src/integrations/editor/DiffViewProvider.ts` — 抽象基类 +- `apps/vscode/src/hosts/vscode/VscodeDiffViewProvider.ts` — VS Code 实现 +- `apps/vscode/src/hosts/vscode/hostbridge/diff/openDiff.ts` — Diff 打开逻辑 +- 关键:自定义 URI Scheme (`cline-diff:`)、流式更新、Decoration 控制器 + +### PR #37 (参考但不照搬) +- `src/handlers/DiffPreviewHandler.ts` — 基础实现(有竞态 bug) +- 可参考其 `isEditToolCall` 判断逻辑和 `extractFilePaths` 路径提取 + +## 参考链接 + +- [ACP 协议规范 - Tool Calls](https://agentclientprotocol.com/protocol/v1/tool-calls) +- [ACP SDK (npm)](https://www.npmjs.com/package/@agentclientprotocol/sdk) +- [VS Code Diff API](https://code.visualstudio.com/api/references/vscode-api#commands.executeCommand) +- [Cline 源码](https://github.com/cline/cline) +- [PR #37](https://github.com/formulahendry/vscode-acp/pull/37) diff --git a/src/core/SessionManager.ts b/src/core/SessionManager.ts index 207ee9fa..dd4783fd 100644 --- a/src/core/SessionManager.ts +++ b/src/core/SessionManager.ts @@ -217,6 +217,49 @@ export class SessionManager extends EventEmitter { throw e; } + // Cache capabilities and check if agent supports session resume/load + const caps = this.summarizeCapabilities(connInfo.initResponse.agentCapabilities); + this.capabilities.set(agentName, caps); + + // If agent supports session resume/load, try to resume the most recent session + if ((caps.resume || caps.load) && this.historyStore) { + const recent = this.historyStore.list(agentName, workspaceCwd).slice(0, 5); + if (recent.length > 0) { + const items: (vscode.QuickPickItem & { sessionId?: string })[] = recent.map(s => ({ + label: s.title || s.firstPrompt || s.sessionId.slice(0, 8), + description: `上次会话 · ${new Date(s.lastActiveAt).toLocaleString()}`, + sessionId: s.sessionId, + })); + items.push({ label: '➕ 新建会话', description: '开始新的会话' }); + + // Show QuickPick with most recent session pre-selected + const picker = vscode.window.createQuickPick(); + picker.items = items; + picker.activeItems = [items[0]]; // Default to most recent + picker.placeholder = `恢复上次会话或新建(Enter 直接恢复最近一次)`; + + const picked = await new Promise(resolve => { + picker.onDidAccept(() => { resolve(picker.selectedItems[0]); picker.dispose(); }); + picker.onDidHide(() => { resolve(undefined); picker.dispose(); }); + picker.show(); + }); + + if (picked?.sessionId) { + try { + const sessionInfo = caps.load + ? await this.loadSession(agentName, picked.sessionId) + : await this.resumeSession(agentName, picked.sessionId); + + log(`Connected to agent ${agentName}, resumed session ${sessionInfo.sessionId}`); + sendEvent('agent/connect.end', { agentName, result: 'success' }, { duration: Date.now() - connectStartTime }); + return sessionInfo; + } catch (e) { + logError(`Failed to resume session for ${agentName}, creating new`, e); + } + } + } + } + // Create ACP session (with auth handling). The session is already // registered in `this.sessions` by createAcpSession so that any // notifications arriving during/after newSession can be persisted. diff --git a/src/extension.ts b/src/extension.ts index 82564dfe..73912a2f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,8 @@ import { ConnectionManager } from './core/ConnectionManager'; import { SessionManager } from './core/SessionManager'; import { SessionHistoryStore } from './core/SessionHistoryStore'; import { SessionUpdateHandler } from './handlers/SessionUpdateHandler'; +import { AcpDiffContentProvider, DIFF_URI_SCHEME } from './handlers/DiffContentProvider'; +import { DiffPreviewHandler } from './handlers/DiffPreviewHandler'; import { SessionTreeProvider } from './ui/SessionTreeProvider'; import { StatusBarManager } from './ui/StatusBarManager'; import { ChatWebviewProvider } from './ui/ChatWebviewProvider'; @@ -22,6 +24,17 @@ export function activate(context: vscode.ExtensionContext): void { // --- Core services --- const sessionUpdateHandler = new SessionUpdateHandler(); + + // Diff preview: custom URI scheme + listener on session/update + const diffContentProvider = new AcpDiffContentProvider(); + context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider(DIFF_URI_SCHEME, diffContentProvider), + ); + const diffPreviewHandler = new DiffPreviewHandler(sessionUpdateHandler); + context.subscriptions.push({ + dispose: () => diffPreviewHandler.dispose(), + }); + const agentManager = new AgentManager(); const connectionManager = new ConnectionManager(sessionUpdateHandler); const sessionManager = new SessionManager( diff --git a/src/handlers/DiffContentProvider.ts b/src/handlers/DiffContentProvider.ts new file mode 100644 index 00000000..e39bd00f --- /dev/null +++ b/src/handlers/DiffContentProvider.ts @@ -0,0 +1,20 @@ +import * as vscode from 'vscode'; + +export const DIFF_URI_SCHEME = 'acp-diff'; + +/** + * Provides old (pre-edit) file content for diff preview via custom URI scheme. + * + * The old content is base64-encoded in the URI query parameter, making the + * provider stateless — the URI itself carries everything needed to render the + * original content in VS Code's diff editor. + * + * See AGENTS.md § Diff 预览设计方案 for the full rationale. + */ +export class AcpDiffContentProvider implements vscode.TextDocumentContentProvider { + provideTextDocumentContent(uri: vscode.Uri): string { + const encoded = uri.query; + if (!encoded) { return ''; } + return Buffer.from(encoded, 'base64').toString('utf-8'); + } +} diff --git a/src/handlers/DiffPreviewHandler.ts b/src/handlers/DiffPreviewHandler.ts new file mode 100644 index 00000000..357f1bb5 --- /dev/null +++ b/src/handlers/DiffPreviewHandler.ts @@ -0,0 +1,463 @@ +import * as crypto from 'crypto'; +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { log } from '../utils/Logger'; +import { SessionUpdateHandler } from './SessionUpdateHandler'; +import { DIFF_URI_SCHEME } from './DiffContentProvider'; + +import type { SessionNotification, ToolCall, ToolCallUpdate, ToolCallContent } from '@agentclientprotocol/sdk'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Kinds that modify files on disk — only these trigger diff preview. */ +function isEditKind(kind: string | undefined | null, title?: string | null): boolean { + if (kind === 'edit' || kind === 'delete' || kind === 'move') { return true; } + // Some agents (e.g. Hermes) don't send `kind` — fall back to title heuristics + // Require both an edit verb AND a file reference to reduce false positives + if (!kind && title) { + const hasEditVerb = /\b(write|edit|patch|create|delete|move|rename|replace)\b/i.test(title); + const hasFileRef = /(?:\/[\w.\-]+)+|\b[\w.\-]+\.[a-z]{1,5}\b/i.test(title); + return hasEditVerb && hasFileRef; + } + return false; +} + +/** + * Build an `acp-diff:` URI with the old content base64-encoded in the query. + * The registered AcpDiffContentProvider decodes it when VS Code renders the + * diff editor. + */ +function createOldContentUri(filePath: string, oldContent: string): vscode.Uri { + const fileName = path.basename(filePath); + // Use a short hash of the full path to avoid collisions between + // same-named files in different directories (e.g. src/a/config.ts vs src/b/config.ts) + const hash = crypto.createHash('md5').update(filePath).digest('hex').slice(0, 8); + const uniqueName = `${hash}_${fileName}`; + return vscode.Uri.parse(`${DIFF_URI_SCHEME}:${uniqueName}`) + .with({ query: Buffer.from(oldContent).toString('base64') }); +} + +/** + * Synchronously snapshot the current file content. + * + * 1. Prefer the open editor (may hold unsaved changes). + * 2. Fall back to `fs.readFileSync`. + * 3. Return `undefined` for new files. + * + * MUST stay synchronous — called from the session/update listener path. + */ +function snapshotOldContent(filePath: string): string | undefined { + const openDoc = vscode.workspace.textDocuments.find( + (doc) => doc.uri.fsPath === filePath, + ); + if (openDoc) { return openDoc.getText(); } + + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch { + return undefined; + } +} + +/** + * Extract the most relevant file path from a tool call / update. + * + * Priority (ACP spec): + * 1. `locations[].path` + * 2. `content[].path` (diff content) + * 3. `rawInput` recursive key scan + * 4. `title` regex fallback + */ +function extractFilePaths(toolCall: ToolCall | ToolCallUpdate): string[] { + // Priority 1 + if (toolCall.locations && toolCall.locations.length > 0) { + const paths = toolCall.locations + .map((l) => l.path) + .filter((p): p is string => !!p); + if (paths.length > 0) { return paths; } + } + + // Priority 2 + if (toolCall.content && toolCall.content.length > 0) { + for (const c of toolCall.content) { + if (c.type === 'diff' && 'path' in c) { + const diff = c as { path?: string }; + if (diff.path) { return [diff.path]; } + } + } + } + + // Priority 3 + if (toolCall.rawInput !== undefined && toolCall.rawInput !== null) { + const paths = scanRawInputForPaths(toolCall.rawInput); + if (paths.length > 0) { return paths; } + } + + // Priority 4 — title regex (handles both absolute and relative paths) + if (toolCall.title) { + // Try absolute path first (Unix-style) + const absMatches = toolCall.title.match(/(?:\/[\w.\-]+)+/g); + if (absMatches && absMatches.length > 0) { return absMatches; } + // Try relative path with file extension (e.g. "patch (replace): index.html") + const relMatch = toolCall.title.match(/(?:^|:\s*)([\w.\-\/\\]+\.[a-z]{1,5})\b/i); + if (relMatch && relMatch[1]) { return [relMatch[1]]; } + } + + return []; +} + +const RAW_INPUT_PATH_KEYS = new Set([ + 'path', + 'file', + 'filePath', + 'filepath', + 'filename', + 'targetPath', + 'sourcePath', +]); + +/** Convert a possibly-relative file path to absolute using workspace root. */ +function normalizePath(filePath: string): string { + if (path.isAbsolute(filePath)) { return filePath; } + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + return path.resolve(workspacePath || process.cwd(), filePath); +} + +/** Recursively scan an unknown value for file-path-shaped values. */ +function scanRawInputForPaths(obj: unknown): string[] { + const results: string[] = []; + + const walk = (value: unknown): void => { + if (!value || typeof value !== 'object') { return; } + + if (Array.isArray(value)) { + for (const item of value) { walk(item); } + return; + } + + const record = value as Record; + for (const key of Object.keys(record)) { + const val = record[key]; + if (RAW_INPUT_PATH_KEYS.has(key) && typeof val === 'string' && val.includes('/')) { + results.push(val); + } else if (typeof val === 'object') { + walk(val); + } + } + }; + + walk(obj); + return results; +} + +// --------------------------------------------------------------------------- +// Diff line decoration +// --------------------------------------------------------------------------- + +/** + * Decoration style used to highlight the line being edited. + * + * A module-level singleton is fine because each editor tracks ranges per + * decoration type independently. We clear ranges on cleanup. + */ +const editDecoration = vscode.window.createTextEditorDecorationType({ + isWholeLine: true, + backgroundColor: new vscode.ThemeColor('editor.findMatchHighlightBackground'), + overviewRulerColor: new vscode.ThemeColor('editorOverviewRuler.findMatchForeground'), + overviewRulerLane: vscode.OverviewRulerLane.Right, +}); + +// --------------------------------------------------------------------------- +// Active-edit bookkeeping +// --------------------------------------------------------------------------- + +interface ActiveEdit { + filePath: string; + oldContent: string; + oldUri: vscode.Uri; + /** Line number (1-based) extracted from locations, or undefined. */ + line: number | undefined; +} + +// --------------------------------------------------------------------------- +// DiffPreviewHandler +// --------------------------------------------------------------------------- + +/** + * Listens to `session/update` notifications and provides inline diff preview + * for file-editing tool calls. + * + * Lifecycle + * --------- + * 1. **pending / in_progress** – snapshot old content, highlight the target + * line in the open editor. + * 2. **tool_call_update** – optionally update the highlighted line. + * 3. **completed** – open VS Code diff editor (old ↔ current), clean up. + * 4. **failed** – clean up without showing a diff. + * 5. **dispose()** – remove the session-update listener, clear decorations. + */ +export class DiffPreviewHandler { + private activeEdits = new Map(); + private boundOnUpdate: (update: SessionNotification) => void; + + constructor(private sessionUpdateHandler: SessionUpdateHandler) { + this.boundOnUpdate = this.onUpdate.bind(this); + this.sessionUpdateHandler.addListener(this.boundOnUpdate); + log('DiffPreviewHandler initialized'); + } + + // ----------------------------------------------------------------------- + // Public interface + // ----------------------------------------------------------------------- + + dispose(): void { + this.sessionUpdateHandler.removeListener(this.boundOnUpdate); + this.cleanupAll(); + editDecoration.dispose(); + log('DiffPreviewHandler disposed'); + } + + // ----------------------------------------------------------------------- + // Session update listener + // ----------------------------------------------------------------------- + + private onUpdate = (notification: SessionNotification): void => { + const update = notification.update; + + if (update.sessionUpdate === 'tool_call') { + this.handleToolCall(update as ToolCall & { sessionUpdate: 'tool_call' }); + } else if (update.sessionUpdate === 'tool_call_update') { + this.handleToolCallUpdate(update as ToolCallUpdate & { sessionUpdate: 'tool_call_update' }); + } + }; + + // ----------------------------------------------------------------------- + // Tool-call handling + // ----------------------------------------------------------------------- + + private handleToolCall(toolCall: ToolCall & { sessionUpdate: 'tool_call' }): void { + if (!isEditKind(toolCall.kind, toolCall.title)) { return; } + + const status = toolCall.status ?? 'pending'; + + if (status === 'pending' || status === 'in_progress') { + this.startEdit(toolCall); + } else if (status === 'completed') { + this.finishEdit(toolCall); + } else if (status === 'failed') { + this.cleanupEdit(toolCall.toolCallId); + } + } + + private handleToolCallUpdate(update: ToolCallUpdate & { sessionUpdate: 'tool_call_update' }): void { + // Status transition inside an update (some agents complete via update). + if (update.status === 'completed') { + this.finishEditFromUpdate(update); + return; + } + if (update.status === 'failed') { + this.cleanupEdit(update.toolCallId); + return; + } + + // Update decoration line if locations changed. + if (update.locations && update.locations.length > 0) { + this.updateDecorationLine(update.toolCallId, update.locations[0].line ?? undefined); + } + } + + // ----------------------------------------------------------------------- + // Edit lifecycle + // ----------------------------------------------------------------------- + + private fileWatchers = new Map(); + private debounceTimers = new Map>(); + + private startEdit(toolCall: ToolCall): void { + const paths = extractFilePaths(toolCall); + if (paths.length === 0) { return; } + + const filePath = normalizePath(paths[0]); + // If we already track this tool call for this file, skip re-snapshotting. + if (this.activeEdits.has(toolCall.toolCallId)) { return; } + + const oldContent = snapshotOldContent(filePath) ?? ''; + const oldUri = createOldContentUri(filePath, oldContent); + const line = toolCall.locations?.[0]?.line ?? undefined; + + this.activeEdits.set(toolCall.toolCallId, { + filePath, + oldContent, + oldUri, + line, + }); + + this.applyDecoration(filePath, line); + + // Set up a file watcher as fallback for agents that don't send + // tool_call_update with completed status (e.g. Hermes ACP adapter). + // When the file changes on disk, show the diff after a debounce + // to avoid showing partial writes. + const watcher = vscode.workspace.createFileSystemWatcher(filePath); + let debounceTimer: ReturnType | undefined; + const onChangeHandler = () => { + if (debounceTimer) { clearTimeout(debounceTimer); } + debounceTimer = setTimeout(() => { + this.debounceTimers.delete(toolCall.toolCallId); + const edit = this.activeEdits.get(toolCall.toolCallId); + if (edit) { + this.openDiff(edit.oldUri, vscode.Uri.file(edit.filePath), toolCall.title); + this.cleanupEdit(toolCall.toolCallId); + } + }, 800); // 800ms debounce — wait for file to stabilize + this.debounceTimers.set(toolCall.toolCallId, debounceTimer); + }; + watcher.onDidChange(onChangeHandler); + watcher.onDidCreate(onChangeHandler); // also handle new file creation + this.fileWatchers.set(toolCall.toolCallId, watcher); + log(`DiffPreview: tracking edit on ${filePath} (id=${toolCall.toolCallId})`); + } + + private finishEdit(toolCall: ToolCall): void { + const edit = this.activeEdits.get(toolCall.toolCallId); + + if (edit) { + this.openDiff(edit.oldUri, vscode.Uri.file(edit.filePath), toolCall.title); + this.cleanupEdit(toolCall.toolCallId); + return; + } + + // No pending snapshot — try Diff content's oldText. + this.tryFinishWithDiffContent(toolCall.content, toolCall.title); + } + + private finishEditFromUpdate(update: ToolCallUpdate): void { + const edit = this.activeEdits.get(update.toolCallId); + if (!edit) { return; } + + this.openDiff(edit.oldUri, vscode.Uri.file(edit.filePath), update.title ?? undefined); + this.cleanupEdit(update.toolCallId); + } + + /** + * Best-effort: if we never saw a `pending` notification but the completed + * notification includes Diff content with oldText, build the URI from it. + */ + private tryFinishWithDiffContent( + content: ToolCallContent[] | undefined, + title: string | undefined, + ): void { + if (!content) { return; } + + for (const c of content) { + if (c.type === 'diff' && 'oldText' in c) { + const diff = c as { path?: string; oldText?: string | null }; + if (diff.path && diff.oldText !== undefined && diff.oldText !== null) { + const oldUri = createOldContentUri(diff.path, diff.oldText); + this.openDiff(oldUri, vscode.Uri.file(diff.path), title); + return; + } + } + } + } + + // ----------------------------------------------------------------------- + // Decoration helpers + // ----------------------------------------------------------------------- + + private applyDecoration(filePath: string, line: number | undefined): void { + if (line === undefined || line < 1) { return; } + + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.fsPath === filePath) { + const range = new vscode.Range(line - 1, 0, line - 1, 0); + editor.setDecorations(editDecoration, [range]); + return; + } + } + } + + private updateDecorationLine(toolCallId: string, newLine: number | undefined): void { + const edit = this.activeEdits.get(toolCallId); + if (!edit) { return; } + + // Clear old decoration by removing all ranges first, then applying the new one. + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.fsPath === edit.filePath) { + editor.setDecorations(editDecoration, []); + break; + } + } + + edit.line = newLine; + this.applyDecoration(edit.filePath, newLine); + } + + // ----------------------------------------------------------------------- + // Diff view + // ----------------------------------------------------------------------- + + private openDiff(oldUri: vscode.Uri, newUri: vscode.Uri, title: string | undefined): void { + const diffTitle = title + ? `${path.basename(newUri.fsPath)}: ${title}` + : `${path.basename(newUri.fsPath)}: Original ↔ Agent's Changes`; + + vscode.commands.executeCommand('vscode.diff', oldUri, newUri, diffTitle); + log(`DiffPreview: opened diff for ${newUri.fsPath}`); + } + + // ----------------------------------------------------------------------- + // Cleanup + // ----------------------------------------------------------------------- + + private cleanupEdit(toolCallId: string): void { + const edit = this.activeEdits.get(toolCallId); + if (!edit) { return; } + + // Clear decoration ranges for this file. + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.fsPath === edit.filePath) { + editor.setDecorations(editDecoration, []); + break; + } + } + + // Clean up file watcher if any + const watcher = this.fileWatchers.get(toolCallId); + if (watcher) { + watcher.dispose(); + this.fileWatchers.delete(toolCallId); + } + + // Clean up debounce timer if any + const timer = this.debounceTimers.get(toolCallId); + if (timer) { + clearTimeout(timer); + this.debounceTimers.delete(toolCallId); + } + + this.activeEdits.delete(toolCallId); + log(`DiffPreview: cleaned up edit ${toolCallId}`); + } + + private cleanupAll(): void { + for (const toolCallId of this.activeEdits.keys()) { + this.cleanupEdit(toolCallId); + } + this.activeEdits.clear(); + // Clean up any remaining file watchers + for (const watcher of this.fileWatchers.values()) { + watcher.dispose(); + } + this.fileWatchers.clear(); + // Clean up any remaining debounce timers + for (const timer of this.debounceTimers.values()) { + clearTimeout(timer); + } + this.debounceTimers.clear(); + } +}