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>
This commit is contained in:
@@ -121,7 +121,11 @@ public class AppConfig {
|
|||||||
new ListMcpResourcesTool(),
|
new ListMcpResourcesTool(),
|
||||||
new ReadMcpResourceTool(),
|
new ReadMcpResourceTool(),
|
||||||
new EnterWorktreeTool(),
|
new EnterWorktreeTool(),
|
||||||
new ExitWorktreeTool()
|
new ExitWorktreeTool(),
|
||||||
|
// P4: 新工具
|
||||||
|
new LSPTool(),
|
||||||
|
new BriefTool(),
|
||||||
|
new NotificationTool()
|
||||||
);
|
);
|
||||||
|
|
||||||
// P2: 注册 MCP 工具桥接(将远程 MCP 工具映射为本地工具)
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user