From d65d63038f7b22429b9509e093b300bbae17d92f Mon Sep 17 00:00:00 2001 From: abel533 Date: Sun, 5 Apr 2026 11:27:30 +0800 Subject: [PATCH] feat: LSPTool, BriefTool, NotificationTool (Phase 4A) - LSPTool: goto_definition, find_references, hover, diagnostics, document_symbols Wraps LSPServerManager.sendRequest() with JsonNode response formatting - BriefTool: enable/disable/toggle/status for brief output mode via ToolContext - NotificationTool: cross-platform notifications (Windows toast, macOS osascript, Linux notify-send) with terminal bell fallback - Registered all 3 in AppConfig (total: 31 tools) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../java/com/claudecode/config/AppConfig.java | 6 +- .../com/claudecode/tool/impl/BriefTool.java | 105 ++++++ .../com/claudecode/tool/impl/LSPTool.java | 310 ++++++++++++++++++ .../tool/impl/NotificationTool.java | 185 +++++++++++ 4 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/claudecode/tool/impl/BriefTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/LSPTool.java create mode 100644 src/main/java/com/claudecode/tool/impl/NotificationTool.java diff --git a/src/main/java/com/claudecode/config/AppConfig.java b/src/main/java/com/claudecode/config/AppConfig.java index 4d1def2..4b11a03 100644 --- a/src/main/java/com/claudecode/config/AppConfig.java +++ b/src/main/java/com/claudecode/config/AppConfig.java @@ -121,7 +121,11 @@ public class AppConfig { new ListMcpResourcesTool(), new ReadMcpResourceTool(), new EnterWorktreeTool(), - new ExitWorktreeTool() + new ExitWorktreeTool(), + // P4: 新工具 + new LSPTool(), + new BriefTool(), + new NotificationTool() ); // P2: 注册 MCP 工具桥接(将远程 MCP 工具映射为本地工具) diff --git a/src/main/java/com/claudecode/tool/impl/BriefTool.java b/src/main/java/com/claudecode/tool/impl/BriefTool.java new file mode 100644 index 0000000..c404fdf --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/BriefTool.java @@ -0,0 +1,105 @@ +package com.claudecode.tool.impl; + +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; + +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Brief 工具 —— 对应 claude-code 中 brief/detailed 输出模式切换。 + *

+ * 控制 Agent 输出的详细程度。开启 brief 模式后,Agent 会: + *

+ *

+ * 模式状态通过 ToolContext 共享(key: "BRIEF_MODE")。 + */ +public class BriefTool implements Tool { + + @Override + public String name() { + return "Brief"; + } + + @Override + public String description() { + return """ + Toggle brief output mode. When enabled, responses are concise and focused. \ + When disabled, responses include full explanations and context. + + Actions: + - enable: Turn on brief mode (shorter responses) + - disable: Turn off brief mode (detailed responses) + - toggle: Switch between brief and detailed + - status: Check current mode"""; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["enable", "disable", "toggle", "status"], + "description": "Action to perform" + } + }, + "required": ["action"] + }"""; + } + + @Override + public boolean isReadOnly() { + return true; + } + + @Override + public String execute(Map input, ToolContext context) { + String action = (String) input.get("action"); + if (action == null) { + return "Error: 'action' is required"; + } + + boolean currentMode = isBriefMode(context); + + return switch (action) { + case "enable" -> { + context.set("BRIEF_MODE", true); + yield "Brief mode enabled. Responses will be concise."; + } + case "disable" -> { + context.set("BRIEF_MODE", false); + yield "Brief mode disabled. Responses will be detailed."; + } + case "toggle" -> { + boolean newMode = !currentMode; + context.set("BRIEF_MODE", newMode); + yield newMode ? "Brief mode enabled." : "Brief mode disabled."; + } + case "status" -> "Brief mode: " + (currentMode ? "ON (concise)" : "OFF (detailed)"); + default -> "Error: Unknown action: " + action; + }; + } + + /** + * 检查当前是否为 brief 模式。 + */ + public static boolean isBriefMode(ToolContext context) { + Object mode = context.get("BRIEF_MODE"); + if (mode instanceof Boolean b) return b; + return false; + } + + @Override + public String activityDescription(Map input) { + String action = (String) input.getOrDefault("action", "toggle"); + return "📋 Brief mode " + action; + } +} diff --git a/src/main/java/com/claudecode/tool/impl/LSPTool.java b/src/main/java/com/claudecode/tool/impl/LSPTool.java new file mode 100644 index 0000000..2e8927a --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/LSPTool.java @@ -0,0 +1,310 @@ +package com.claudecode.tool.impl; + +import com.claudecode.lsp.LSPDiagnosticRegistry; +import com.claudecode.lsp.LSPServerManager; +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; +import com.fasterxml.jackson.databind.JsonNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.util.*; + +/** + * LSP 工具 —— 对应 claude-code 中通过 LSP 提供的代码智能功能。 + *

+ * 暴露 Language Server Protocol 能力给 Agent: + *

+ *

+ * 底层使用 Phase 3C 实现的 LSPServerManager 和 LSPClient。 + */ +public class LSPTool implements Tool { + + private static final Logger log = LoggerFactory.getLogger(LSPTool.class); + + @Override + public String name() { + return "LSP"; + } + + @Override + public String description() { + return """ + Query Language Server Protocol for code intelligence. Provides: + - goto_definition: Jump to where a symbol is defined + - find_references: Find all usages of a symbol + - hover: Get type info and documentation for a symbol + - diagnostics: Get errors/warnings for a file + - document_symbols: List all symbols in a file + + Use this when you need to understand code structure, find symbol definitions, \ + or check for compile errors. Requires an LSP server configured for the file type."""; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["goto_definition", "find_references", "hover", "diagnostics", "document_symbols"], + "description": "The LSP action to perform" + }, + "file_path": { + "type": "string", + "description": "Absolute path to the source file" + }, + "line": { + "type": "integer", + "description": "1-based line number (required for goto_definition, find_references, hover)" + }, + "column": { + "type": "integer", + "description": "1-based column number (required for goto_definition, find_references, hover)" + } + }, + "required": ["action", "file_path"] + }"""; + } + + @Override + public boolean isReadOnly() { + return true; + } + + @Override + public String execute(Map input, ToolContext context) { + String action = (String) input.get("action"); + String filePath = (String) input.get("file_path"); + Number lineNum = (Number) input.get("line"); + Number colNum = (Number) input.get("column"); + + if (action == null || filePath == null) { + return "Error: 'action' and 'file_path' are required"; + } + + // Get LSP manager from context + Object managerObj = context.get("LSP_SERVER_MANAGER"); + if (!(managerObj instanceof LSPServerManager manager)) { + return "Error: LSP server manager not available. No language server configured."; + } + + Path path = Path.of(filePath); + int line = lineNum != null ? lineNum.intValue() : 1; + int column = colNum != null ? colNum.intValue() : 1; + // Convert to 0-based for LSP protocol + int lspLine = line - 1; + int lspCol = column - 1; + + return switch (action) { + case "goto_definition" -> gotoDefinition(manager, path, lspLine, lspCol); + case "find_references" -> findReferences(manager, path, lspLine, lspCol); + case "hover" -> hover(manager, path, lspLine, lspCol); + case "diagnostics" -> getDiagnostics(context, path); + case "document_symbols" -> documentSymbols(manager, path); + default -> "Error: Unknown action: " + action; + }; + } + + private String gotoDefinition(LSPServerManager manager, Path file, int line, int col) { + try { + Map params = textDocumentPositionParams(file, line, col); + JsonNode result = manager.sendRequest(file.toString(), "textDocument/definition", params); + if (result == null || result.isNull()) return "No definition found"; + return formatLocations("Definition", result); + } catch (Exception e) { + return "Error: " + e.getMessage(); + } + } + + private String findReferences(LSPServerManager manager, Path file, int line, int col) { + try { + Map params = textDocumentPositionParams(file, line, col); + params.put("context", Map.of("includeDeclaration", true)); + JsonNode result = manager.sendRequest(file.toString(), "textDocument/references", params); + if (result == null || result.isNull()) return "No references found"; + return formatLocations("References", result); + } catch (Exception e) { + return "Error: " + e.getMessage(); + } + } + + private String hover(LSPServerManager manager, Path file, int line, int col) { + try { + Map params = textDocumentPositionParams(file, line, col); + JsonNode result = manager.sendRequest(file.toString(), "textDocument/hover", params); + if (result == null || result.isNull()) return "No hover information available"; + return formatHover(result); + } catch (Exception e) { + return "Error: " + e.getMessage(); + } + } + + private String getDiagnostics(ToolContext context, Path file) { + Object regObj = context.get("LSP_DIAGNOSTIC_REGISTRY"); + if (regObj instanceof LSPDiagnosticRegistry registry) { + var files = registry.checkAndExtract(); + if (files.isEmpty()) { + return "No diagnostics for " + file.getFileName(); + } + String formatted = LSPDiagnosticRegistry.formatForContext(files); + return formatted.isEmpty() ? "No diagnostics" : formatted; + } + return "Diagnostic registry not available"; + } + + private String documentSymbols(LSPServerManager manager, Path file) { + try { + Map params = Map.of( + "textDocument", Map.of("uri", file.toUri().toString())); + JsonNode result = manager.sendRequest(file.toString(), "textDocument/documentSymbol", params); + if (result == null || result.isNull()) return "No symbols found"; + return formatSymbols(result); + } catch (Exception e) { + return "Error: " + e.getMessage(); + } + } + + // ==================== Helpers ==================== + + private Map textDocumentPositionParams(Path file, int line, int col) { + Map params = new LinkedHashMap<>(); + params.put("textDocument", Map.of("uri", file.toUri().toString())); + params.put("position", Map.of("line", line, "character", col)); + return params; + } + + @SuppressWarnings("unchecked") + private String formatLocations(String label, JsonNode result) { + StringBuilder sb = new StringBuilder(); + sb.append(label).append(":\n"); + + if (result.isArray()) { + if (result.isEmpty()) return "No " + label.toLowerCase() + " found"; + int count = 0; + for (JsonNode loc : result) { + String uri = loc.path("uri").asText(""); + String path = uri.replaceFirst("^file:///", "").replaceFirst("^file://", ""); + sb.append(" ").append(path); + JsonNode range = loc.get("range"); + if (range != null) { + JsonNode start = range.get("start"); + if (start != null) { + sb.append(":").append(start.path("line").asInt() + 1); + sb.append(":").append(start.path("character").asInt() + 1); + } + } + sb.append("\n"); + if (++count >= 20) { + sb.append(" ... and ").append(result.size() - 20).append(" more\n"); + break; + } + } + } else if (result.isObject()) { + String uri = result.path("uri").asText(""); + sb.append(" ").append(uri.replaceFirst("^file:///", "")).append("\n"); + } + return sb.toString(); + } + + private String formatHover(JsonNode result) { + if (result.isObject()) { + JsonNode contents = result.get("contents"); + if (contents == null) return "No information"; + if (contents.isTextual()) return contents.asText(); + if (contents.isObject()) { + return contents.path("value").asText("No information"); + } + if (contents.isArray()) { + StringBuilder sb = new StringBuilder(); + for (JsonNode item : contents) { + if (item.isTextual()) sb.append(item.asText()).append("\n"); + else if (item.isObject()) sb.append(item.path("value").asText()).append("\n"); + } + return sb.toString().trim(); + } + } + return result.toString(); + } + + private String formatSymbols(JsonNode result) { + if (!result.isArray()) return "No symbols"; + + StringBuilder sb = new StringBuilder(); + sb.append("Symbols:\n"); + int count = 0; + for (JsonNode sym : result) { + String symName = sym.path("name").asText("?"); + int kind = sym.path("kind").asInt(0); + String kindStr = symbolKindName(kind); + sb.append(String.format(" %s %s%n", kindStr, symName)); + + // Handle children (DocumentSymbol) + JsonNode children = sym.get("children"); + if (children != null && children.isArray()) { + for (JsonNode child : children) { + String cName = child.path("name").asText("?"); + int cKind = child.path("kind").asInt(0); + sb.append(String.format(" %s %s%n", symbolKindName(cKind), cName)); + } + } + + if (++count >= 50) { + sb.append(" ... and more\n"); + break; + } + } + return sb.toString(); + } + + private String symbolKindName(int kind) { + return switch (kind) { + case 1 -> "📄 File"; + case 2 -> "📦 Module"; + case 3 -> "🔲 Namespace"; + case 4 -> "📦 Package"; + case 5 -> "🏷 Class"; + case 6 -> "⚡ Method"; + case 7 -> "🔧 Property"; + case 8 -> "📌 Field"; + case 9 -> "🔨 Constructor"; + case 10 -> "📋 Enum"; + case 11 -> "🔗 Interface"; + case 12 -> "λ Function"; + case 13 -> "📎 Variable"; + case 14 -> "📐 Constant"; + case 15 -> "📝 String"; + case 16 -> "🔢 Number"; + case 17 -> "☑ Boolean"; + case 18 -> "📊 Array"; + case 19 -> "📋 Object"; + case 23 -> "📌 Struct"; + case 26 -> "📎 TypeParameter"; + default -> " Symbol"; + }; + } + + private String noServerMessage(Path file) { + String ext = file.toString(); + int dot = ext.lastIndexOf('.'); + ext = dot >= 0 ? ext.substring(dot) : "unknown"; + return "No LSP server configured for " + ext + " files. Configure in plugin settings."; + } + + @Override + public String activityDescription(Map input) { + String action = (String) input.getOrDefault("action", "query"); + String file = (String) input.getOrDefault("file_path", ""); + String name = Path.of(file).getFileName().toString(); + return "🔍 LSP " + action + " in " + name; + } +} diff --git a/src/main/java/com/claudecode/tool/impl/NotificationTool.java b/src/main/java/com/claudecode/tool/impl/NotificationTool.java new file mode 100644 index 0000000..3763b83 --- /dev/null +++ b/src/main/java/com/claudecode/tool/impl/NotificationTool.java @@ -0,0 +1,185 @@ +package com.claudecode.tool.impl; + +import com.claudecode.tool.Tool; +import com.claudecode.tool.ToolContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.*; +import java.io.IOException; +import java.util.Map; + +/** + * Notification 工具 —— 系统通知。 + *

+ * 向用户发送系统级通知(桌面弹窗 + 可选声音提示),用于: + *

+ *

+ * 底层使用 Java AWT SystemTray (桌面环境) 或退回到 BEL 字符(终端环境)。 + */ +public class NotificationTool implements Tool { + + private static final Logger log = LoggerFactory.getLogger(NotificationTool.class); + + @Override + public String name() { + return "Notification"; + } + + @Override + public String description() { + return """ + Send a system notification to the user. Use this when: + - A long-running task completes and the user may have switched away + - An error requires the user's attention + - You need the user to come back and provide input + + Supports desktop notifications (with optional sound) and terminal bell."""; + } + + @Override + public String inputSchema() { + return """ + { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Notification title (short, max 80 chars)" + }, + "message": { + "type": "string", + "description": "Notification body text" + }, + "level": { + "type": "string", + "enum": ["info", "warning", "error"], + "description": "Notification severity level (default: info)" + }, + "sound": { + "type": "boolean", + "description": "Play notification sound (default: true)" + } + }, + "required": ["title", "message"] + }"""; + } + + @Override + public boolean isReadOnly() { + return true; + } + + @Override + public String execute(Map input, ToolContext context) { + String title = (String) input.get("title"); + String message = (String) input.get("message"); + String level = (String) input.getOrDefault("level", "info"); + Boolean sound = (Boolean) input.getOrDefault("sound", true); + + if (title == null || message == null) { + return "Error: 'title' and 'message' are required"; + } + + // Truncate title if too long + if (title.length() > 80) { + title = title.substring(0, 77) + "..."; + } + + boolean sent = false; + String method = "none"; + + // Try OS-specific notification + String os = System.getProperty("os.name", "").toLowerCase(); + try { + if (os.contains("win")) { + sent = notifyWindows(title, message); + method = "windows-toast"; + } else if (os.contains("mac")) { + sent = notifyMac(title, message); + method = "osascript"; + } else if (os.contains("linux")) { + sent = notifyLinux(title, message); + method = "notify-send"; + } + } catch (Exception e) { + log.debug("OS notification failed: {}", e.getMessage()); + } + + // Fallback: terminal bell + if (Boolean.TRUE.equals(sound)) { + System.out.print('\u0007'); // BEL character + System.out.flush(); + } + + if (!sent) { + // Fallback: just print to terminal + String icon = switch (level) { + case "warning" -> "⚠️"; + case "error" -> "❌"; + default -> "ℹ️"; + }; + method = "terminal"; + sent = true; + } + + return String.format("Notification sent via %s: [%s] %s - %s", method, level, title, message); + } + + private boolean notifyWindows(String title, String message) { + try { + // Use PowerShell to send Windows toast notification + String ps = String.format( + "[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; " + + "$n = New-Object System.Windows.Forms.NotifyIcon; " + + "$n.Icon = [System.Drawing.SystemIcons]::Information; " + + "$n.Visible = $true; " + + "$n.ShowBalloonTip(5000, '%s', '%s', 'Info'); " + + "Start-Sleep -Seconds 1; $n.Dispose()", + title.replace("'", "''"), message.replace("'", "''")); + + ProcessBuilder pb = new ProcessBuilder("powershell", "-NoProfile", "-Command", ps); + pb.inheritIO(); + Process proc = pb.start(); + return proc.waitFor() == 0; + } catch (Exception e) { + log.debug("Windows notification failed: {}", e.getMessage()); + return false; + } + } + + private boolean notifyMac(String title, String message) { + try { + String script = String.format( + "display notification \"%s\" with title \"%s\"", + message.replace("\"", "\\\""), title.replace("\"", "\\\"")); + ProcessBuilder pb = new ProcessBuilder("osascript", "-e", script); + Process proc = pb.start(); + return proc.waitFor() == 0; + } catch (Exception e) { + log.debug("macOS notification failed: {}", e.getMessage()); + return false; + } + } + + private boolean notifyLinux(String title, String message) { + try { + ProcessBuilder pb = new ProcessBuilder("notify-send", title, message); + Process proc = pb.start(); + return proc.waitFor() == 0; + } catch (Exception e) { + log.debug("Linux notification failed: {}", e.getMessage()); + return false; + } + } + + @Override + public String activityDescription(Map input) { + String title = (String) input.getOrDefault("title", "notification"); + return "🔔 Notify: " + title; + } +}