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>
pull/1/head
abel533 1 month ago
parent 12557f23f0
commit d65d63038f
  1. 6
      src/main/java/com/claudecode/config/AppConfig.java
  2. 105
      src/main/java/com/claudecode/tool/impl/BriefTool.java
  3. 310
      src/main/java/com/claudecode/tool/impl/LSPTool.java
  4. 185
      src/main/java/com/claudecode/tool/impl/NotificationTool.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 工具映射为本地工具)

@ -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 输出模式切换
* <p>
* 控制 Agent 输出的详细程度开启 brief 模式后Agent
* <ul>
* <li>使用更简洁的回复</li>
* <li>省略冗余的解释</li>
* <li>只显示关键信息</li>
* </ul>
* <p>
* 模式状态通过 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<String, Object> 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<String, Object> input) {
String action = (String) input.getOrDefault("action", "toggle");
return "📋 Brief mode " + action;
}
}

@ -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 提供的代码智能功能
* <p>
* 暴露 Language Server Protocol 能力给 Agent
* <ul>
* <li>goto_definition 跳转到定义</li>
* <li>find_references 查找引用</li>
* <li>hover 获取悬停信息类型文档</li>
* <li>diagnostics 获取文件诊断信息</li>
* <li>document_symbols 获取文件符号列表</li>
* </ul>
* <p>
* 底层使用 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> textDocumentPositionParams(Path file, int line, int col) {
Map<String, Object> 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<String, Object> 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;
}
}

@ -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 工具 系统通知
* <p>
* 向用户发送系统级通知桌面弹窗 + 可选声音提示用于
* <ul>
* <li>长时间任务完成时通知用户</li>
* <li>需要用户注意的错误或警告</li>
* <li>Agent 需要用户输入时的提醒</li>
* </ul>
* <p>
* 底层使用 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<String, Object> 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<String, Object> input) {
String title = (String) input.getOrDefault("title", "notification");
return "🔔 Notify: " + title;
}
}
Loading…
Cancel
Save