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 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user