From d6ce487c08f5193ff04af96dfd49b44a22dc56c7 Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Mon, 29 Jun 2026 14:48:18 +0200 Subject: [PATCH 1/7] CAMEL-23855: Add F8 AI prompt panel to TUI Co-Authored-By: Claude Signed-off-by: Claus Ibsen --- .../camel/dsl/jbang/core/commands/Ask.java | 836 +---------------- .../dsl/jbang/core/commands/AskTools.java | 865 ++++++++++++++++++ .../dsl/jbang/core/commands/LlmClient.java | 64 +- .../dsl/jbang/core/commands/tui/AiPanel.java | 406 ++++++++ .../jbang/core/commands/tui/CamelMonitor.java | 39 +- 5 files changed, 1358 insertions(+), 852 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java index 9a7e5af167613..f6a3ad5d70baf 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java @@ -16,30 +16,13 @@ */ package org.apache.camel.dsl.jbang.core.commands; -import java.io.IOException; -import java.io.InputStream; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; -import java.util.stream.Stream; -import org.apache.camel.catalog.CamelCatalog; -import org.apache.camel.catalog.DefaultCamelCatalog; import org.apache.camel.dsl.jbang.core.common.EnvironmentHelper; -import org.apache.camel.dsl.jbang.core.common.ExampleHelper; -import org.apache.camel.dsl.jbang.core.common.Printer; import org.apache.camel.dsl.jbang.core.common.RuntimeHelper; -import org.apache.camel.tooling.model.ComponentModel; -import org.apache.camel.util.IOHelper; import org.apache.camel.util.json.JsonObject; -import org.apache.camel.util.json.Jsoner; import org.jline.reader.EndOfFileException; import org.jline.reader.LineReader; import org.jline.reader.LineReaderBuilder; @@ -113,8 +96,7 @@ public class Ask extends CamelCommand { boolean verbose; private long targetPid; - private CamelCatalog catalog; - private volatile List commandMetadataCache; + private AskTools askTools; public Ask(CamelJBangMain main) { super(main); @@ -149,8 +131,9 @@ public Integer doCall() throws Exception { targetPid = -1; } + askTools = new AskTools(targetPid); String systemPrompt = buildSystemPrompt(process); - List tools = buildToolDefinitions(); + List tools = askTools.buildToolDefinitions(); if (question == null || question.isEmpty()) { return runInteractiveChat(client, process, systemPrompt, tools); @@ -227,7 +210,7 @@ private int runAgentLoop( if (showTools) { printer().println("[tool] " + toolCall.name() + "(" + toolCall.arguments().toJson() + ")"); } - String result = executeTool(toolCall.name(), toolCall.arguments()); + String result = askTools.executeTool(toolCall.name(), toolCall.arguments()); if (showTools) { printer().println("[result] " + truncate(result, 200)); } @@ -283,570 +266,12 @@ private RuntimeHelper.ProcessInfo findProcess(String nameOrPid) { // ---- System prompt ---- private String buildSystemPrompt(RuntimeHelper.ProcessInfo process) { - StringBuilder sb = new StringBuilder(); - sb.append("You are an Apache Camel assistant. "); - sb.append("You help users build, understand, and troubleshoot Camel applications.\n\n"); - - if (process != null) { - sb.append("You are connected to a running Camel application: "); - sb.append(process.name()).append(" (PID ").append(process.pid()).append("). "); - sb.append("Use the runtime inspection tools to gather information about it.\n\n"); - } else { - List available = RuntimeHelper.discoverProcesses(); - if (!available.isEmpty()) { - sb.append("No Camel process is currently selected. "); - sb.append("Use list_processes to see available processes, then select_process to connect to one. "); - sb.append("Runtime inspection tools will not work until a process is selected.\n\n"); - } - } - - sb.append("You can search the Camel catalog (components, EIPs), browse examples, "); - sb.append("read/write files, and execute any Camel CLI command.\n\n"); - sb.append("For CLI commands beyond the built-in tools, use cli_list_commands to discover "); - sb.append("available commands, cli_command_help to see options, and cli_exec to run them.\n\n"); - sb.append("Guidelines:\n"); - sb.append("- When creating routes, use YAML DSL format (Camel's recommended format for JBang)\n"); - sb.append("- Look at existing files first with list_files/read_file before creating new ones\n"); - sb.append("- Use catalog tools to look up component syntax before writing routes\n"); - sb.append("- Use examples as reference when building new routes\n"); - sb.append("- Be concise and actionable in your answers\n"); - sb.append("- Format output as plain text for terminal display, do not use markdown\n"); - if (process != null) { - sb.append("- Start by gathering relevant information using the available runtime tools\n"); - sb.append("- If something looks wrong, explain what it means and suggest fixes\n"); - sb.append("- To stop routes or the application, always use the provided tools "); - sb.append("(stop_route, stop_application) for graceful shutdown. Never suggest kill or kill -9.\n"); - } - return sb.toString(); - } - - // ---- Tool definitions ---- - - private List buildToolDefinitions() { - List tools = new ArrayList<>(); - - // Process discovery and selection - tools.add(new LlmClient.ToolDef( - "list_processes", - "List all running Camel processes with their PID and name. Use this to discover available processes before selecting one.", - emptyParams())); - tools.add(new LlmClient.ToolDef( - "select_process", - "Select a running Camel process by name or PID to inspect. Required when multiple processes are running. After selection, all runtime tools (get_routes, get_context, etc.) will target this process.", - objectParams(Map.of( - "name", stringProp("Name or PID of the Camel process to connect to"))))); - - // Status-file tools (no parameters needed) - tools.add(new LlmClient.ToolDef( - "get_context", - "Get Camel context info: name, version, state, uptime, route count, exchange statistics.", - emptyParams())); - tools.add(new LlmClient.ToolDef( - "get_routes", - "List all routes with their state, uptime, messages processed, last error, and throughput.", - emptyParams())); - tools.add(new LlmClient.ToolDef( - "get_health", - "Get health check status for the Camel application.", - emptyParams())); - tools.add(new LlmClient.ToolDef( - "get_endpoints", - "List all endpoints registered in the Camel context with URIs and usage stats.", - emptyParams())); - tools.add(new LlmClient.ToolDef( - "get_inflight", - "Show currently in-flight exchanges (messages being processed).", - emptyParams())); - tools.add(new LlmClient.ToolDef( - "get_blocked", - "Show blocked exchanges that are stuck or waiting.", - emptyParams())); - tools.add(new LlmClient.ToolDef( - "get_consumers", - "Show consumer statistics (polling and event-driven consumers).", - emptyParams())); - tools.add(new LlmClient.ToolDef( - "get_properties", - "Show configuration properties of the running Camel application.", - emptyParams())); - - // IPC action tools (with parameters) - tools.add(new LlmClient.ToolDef( - "get_route_source", - "Get the source code of routes. Use filter to limit by filename (supports wildcards).", - objectParams(Map.of( - "filter", stringProp("Filter source files by name (supports wildcards). Use * for all."))))); - tools.add(new LlmClient.ToolDef( - "get_route_dump", - "Dump route definitions in XML or YAML format.", - objectParams(Map.of( - "routeId", stringProp("Route ID to dump (use * for all routes)"), - "format", stringProp("Output format: xml or yaml (default: yaml)"))))); - tools.add(new LlmClient.ToolDef( - "get_route_structure", - "Show the route structure as a tree of processors.", - objectParams(Map.of( - "routeId", stringProp("Route ID to inspect (use * for all routes)"))))); - tools.add(new LlmClient.ToolDef( - "get_top_processors", - "Show top processor statistics: which processors are slowest and most active.", - emptyParams())); - tools.add(new LlmClient.ToolDef( - "trace_control", - "Enable, disable, or dump message tracing.", - objectParams(Map.of( - "action", stringProp("Action: enable, disable, or dump"))))); - - // Route lifecycle tools - tools.add(new LlmClient.ToolDef( - "stop_route", - "Gracefully stop a route. The route will finish processing in-flight exchanges before stopping.", - objectParams(Map.of( - "routeId", stringProp("The ID of the route to stop"))))); - tools.add(new LlmClient.ToolDef( - "start_route", - "Start a stopped route.", - objectParams(Map.of( - "routeId", stringProp("The ID of the route to start"))))); - tools.add(new LlmClient.ToolDef( - "suspend_route", - "Suspend a route (pauses the consumer but keeps the route loaded).", - objectParams(Map.of( - "routeId", stringProp("The ID of the route to suspend"))))); - tools.add(new LlmClient.ToolDef( - "resume_route", - "Resume a suspended route.", - objectParams(Map.of( - "routeId", stringProp("The ID of the route to resume"))))); - - // Application lifecycle - tools.add(new LlmClient.ToolDef( - "stop_application", - "Gracefully stop the Camel application. The application will finish processing in-flight exchanges and shut down cleanly. Use this instead of kill.", - emptyParams())); - - // Catalog tools - tools.add(new LlmClient.ToolDef( - "catalog_components", - "Search the Camel component catalog by name or label. Returns component name, title, description, and labels.", - objectParams(Map.of( - "filter", stringProp("Filter by name, title, or description (case-insensitive substring)"), - "label", stringProp("Filter by category label (e.g., cloud, messaging, database, file)"))))); - tools.add(new LlmClient.ToolDef( - "catalog_component_doc", - "Get detailed documentation for a Camel component: URI syntax and endpoint options.", - objectParams(Map.of( - "component", stringProp("Component name (e.g., kafka, http, file, timer)"))))); - tools.add(new LlmClient.ToolDef( - "catalog_eips", - "Search EIPs (Enterprise Integration Patterns) like split, aggregate, filter, choice, multicast.", - objectParams(Map.of( - "filter", stringProp("Filter by name, title, or description (case-insensitive substring)"))))); - - // Example tools - tools.add(new LlmClient.ToolDef( - "list_examples", - "List available Camel CLI examples. Returns name, title, description, difficulty level, and tags.", - objectParams(Map.of( - "filter", stringProp("Filter by name, description, or tag (case-insensitive)"), - "level", stringProp("Filter by difficulty: beginner, intermediate, or advanced"))))); - tools.add(new LlmClient.ToolDef( - "get_example_file", - "Get the content of a file from a bundled Camel CLI example. Use list_examples first to find available examples.", - objectParams(Map.of( - "example", stringProp("Example name (e.g., timer-log, rest-api, circuit-breaker)"), - "file", stringProp("File name within the example (e.g., route.camel.yaml)"))))); - - // CLI tools (access to all camel CLI commands) - tools.add(new LlmClient.ToolDef( - "cli_list_commands", - "List available Camel CLI commands. Returns command names and descriptions. Use filter to narrow results.", - objectParams(Map.of( - "filter", stringProp("Filter by command name or description (case-insensitive substring)"))))); - tools.add(new LlmClient.ToolDef( - "cli_command_help", - "Get detailed help for a specific Camel CLI command, including all options with types and defaults.", - objectParams(Map.of( - "command", stringProp("Full command name (e.g., 'get error', 'catalog component', 'run')"))))); - tools.add(new LlmClient.ToolDef( - "cli_exec", - "Execute any Camel CLI command and return its output. Use cli_list_commands and cli_command_help first to discover commands and their options. CAUTION: some commands (stop, cmd stop-route, cmd stop-group) are destructive and will affect running integrations. Always confirm with the user before executing destructive commands.", - objectParams(Map.of( - "command", stringProp( - "The full command line to execute (e.g., 'get error --diagram', 'catalog component --filter=kafka')"))))); - - // File tools - tools.add(new LlmClient.ToolDef( - "list_files", - "List files in a directory (up to 2 levels deep). Defaults to current working directory.", - objectParams(Map.of( - "path", stringProp("Directory path relative to CWD (default: current directory)"))))); - tools.add(new LlmClient.ToolDef( - "read_file", - "Read the content of a file. Useful for inspecting route definitions, configuration, and properties files.", - objectParams(Map.of( - "file", stringProp("File path relative to CWD"))))); - tools.add(new LlmClient.ToolDef( - "write_file", - "Write content to a file. Creates parent directories if needed. Use for creating or editing route definitions and configuration files.", - objectParams(Map.of( - "file", stringProp("File path relative to CWD"), - "content", stringProp("The content to write to the file"))))); - - return tools; - } - - // ---- Tool execution ---- - - private String executeTool(String name, JsonObject args) { - try { - return switch (name) { - // Runtime tools (require a running process) - case "list_processes" -> executeListProcesses(); - case "select_process" -> executeSelectProcess(args); - case "get_context" -> - targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "context"); - case "get_routes" -> - targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "routes"); - case "get_health" -> - targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "healthChecks"); - case "get_endpoints" -> - targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "endpoints"); - case "get_inflight" -> - targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "inflight"); - case "get_blocked" -> - targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "blocked"); - case "get_consumers" -> - targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "consumers"); - case "get_properties" -> - targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "properties"); - case "get_route_source" -> targetPid < 0 ? NO_PROCESS : executeRouteSource(args); - case "get_route_dump" -> targetPid < 0 ? NO_PROCESS : executeRouteDump(args); - case "get_route_structure" -> targetPid < 0 ? NO_PROCESS : executeRouteStructure(args); - case "get_top_processors" -> - targetPid < 0 ? NO_PROCESS : RuntimeHelper.executeAction(targetPid, "top-processors", null); - case "trace_control" -> targetPid < 0 ? NO_PROCESS : executeTraceControl(args); - case "stop_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "stop"); - case "start_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "start"); - case "suspend_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "suspend"); - case "resume_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "resume"); - case "stop_application" -> targetPid < 0 ? NO_PROCESS : RuntimeHelper.stopApplication(targetPid); - // Catalog tools - case "catalog_components" -> executeCatalogComponents(args); - case "catalog_component_doc" -> executeCatalogComponentDoc(args); - case "catalog_eips" -> executeCatalogEips(args); - // Example tools - case "list_examples" -> executeListExamples(args); - case "get_example_file" -> executeGetExampleFile(args); - // CLI tools - case "cli_list_commands" -> executeCliListCommands(args); - case "cli_command_help" -> executeCliCommandHelp(args); - case "cli_exec" -> executeCliExec(args); - // File tools - case "list_files" -> executeListFiles(args); - case "read_file" -> executeReadFile(args); - case "write_file" -> executeWriteFile(args); - default -> "Unknown tool: " + name; - }; - } catch (Exception e) { - return "Error executing " + name + ": " + e.getMessage(); - } - } - - private String executeListProcesses() { - List processes = RuntimeHelper.discoverProcesses(); - if (processes.isEmpty()) { - return "No running Camel processes found. Start one with: camel run "; - } - JsonObject response = new JsonObject(); - response.put("count", processes.size()); - List list = new ArrayList<>(); - for (RuntimeHelper.ProcessInfo p : processes) { - JsonObject entry = new JsonObject(); - entry.put("pid", p.pid()); - entry.put("name", p.name()); - entry.put("selected", p.pid() == targetPid); - list.add(entry); - } - response.put("processes", list); - if (targetPid < 0) { - response.put("hint", "No process selected. Use select_process to connect to one."); - } - return response.toJson(); - } - - private String executeSelectProcess(JsonObject args) { - String name = args.getString("name"); - if (name == null || name.isBlank()) { - return "Error: name or PID is required"; - } - RuntimeHelper.ProcessInfo found = RuntimeHelper.findProcess(name); - if (found == null) { - List processes = RuntimeHelper.discoverProcesses(); - if (processes.isEmpty()) { - return "No running Camel processes found."; - } - StringBuilder sb = new StringBuilder("No process found matching: " + name + ". Available:\n"); - processes.forEach(p -> sb.append(" ").append(p.name()).append(" (PID ").append(p.pid()).append(")\n")); - return sb.toString(); - } - targetPid = found.pid(); - return "Connected to " + found.name() + " (PID " + found.pid() + "). Runtime tools are now active."; + return AskTools.buildSystemPrompt( + process != null ? process.pid() : -1, + process != null ? process.name() : null); } - private String executeRouteSource(JsonObject args) { - String filter = args.getString("filter"); - return RuntimeHelper.executeAction(targetPid, "source", - root -> root.put("filter", filter != null ? filter : "*")); - } - - private String executeRouteDump(JsonObject args) { - String routeId = args.getString("routeId"); - String format = args.getString("format"); - return RuntimeHelper.executeAction(targetPid, "route-dump", root -> { - root.put("id", routeId != null ? routeId : "*"); - root.put("format", format != null ? format : "yaml"); - }); - } - - private String executeRouteStructure(JsonObject args) { - String routeId = args.getString("routeId"); - return RuntimeHelper.executeAction(targetPid, "route-structure", - root -> root.put("id", routeId != null ? routeId : "*")); - } - - private String executeTraceControl(JsonObject args) { - String action = args.getString("action"); - if (action == null) { - return "Error: action is required (enable, disable, dump)"; - } - return RuntimeHelper.executeAction(targetPid, "trace", root -> { - switch (action.toLowerCase()) { - case "enable" -> root.put("enabled", "true"); - case "disable" -> root.put("enabled", "false"); - case "dump" -> root.put("dump", "true"); - default -> root.put("enabled", action); - } - }); - } - - private String executeRouteCommand(JsonObject args, String command) { - String routeId = args.getString("routeId"); - if (routeId == null || routeId.isBlank()) { - return "Error: routeId is required"; - } - return RuntimeHelper.executeAction(targetPid, "route", root -> { - root.put("id", routeId); - root.put("command", command); - }); - } - - // ---- Catalog tools ---- - - private String executeCatalogComponents(JsonObject args) { - String filter = args.getString("filter"); - String label = args.getString("label"); - CamelCatalog catalog = getCatalog(); - - List results = catalog.findComponentNames().stream() - .map(catalog::componentModel) - .filter(m -> m != null) - .filter(m -> matchesFilter(m.getScheme(), m.getTitle(), m.getDescription(), filter)) - .filter(m -> label == null || label.isBlank() - || (m.getLabel() != null && m.getLabel().toLowerCase().contains(label.toLowerCase()))) - .limit(20) - .map(m -> { - JsonObject jo = new JsonObject(); - jo.put("name", m.getScheme()); - jo.put("title", m.getTitle()); - jo.put("description", m.getDescription()); - jo.put("label", m.getLabel()); - return jo; - }) - .collect(Collectors.toList()); - - JsonObject response = new JsonObject(); - response.put("count", results.size()); - response.put("components", results); - return response.toJson(); - } - - private String executeCatalogComponentDoc(JsonObject args) { - String component = args.getString("component"); - if (component == null || component.isBlank()) { - return "Error: component name is required"; - } - CamelCatalog catalog = getCatalog(); - ComponentModel model = catalog.componentModel(component); - if (model == null) { - return "Component not found: " + component; - } - - JsonObject response = new JsonObject(); - response.put("name", model.getScheme()); - response.put("title", model.getTitle()); - response.put("description", model.getDescription()); - response.put("syntax", model.getSyntax()); - response.put("consumerOnly", model.isConsumerOnly()); - response.put("producerOnly", model.isProducerOnly()); - - List options = new ArrayList<>(); - if (model.getEndpointOptions() != null) { - model.getEndpointOptions().stream() - .filter(opt -> !opt.isDeprecated()) - .forEach(opt -> { - JsonObject jo = new JsonObject(); - jo.put("name", opt.getName()); - jo.put("description", opt.getDescription()); - jo.put("type", opt.getType()); - jo.put("required", opt.isRequired()); - if (opt.getDefaultValue() != null) { - jo.put("defaultValue", opt.getDefaultValue().toString()); - } - options.add(jo); - }); - } - response.put("options", options); - return response.toJson(); - } - - private String executeCatalogEips(JsonObject args) { - String filter = args.getString("filter"); - CamelCatalog catalog = getCatalog(); - - List results = catalog.findModelNames().stream() - .map(catalog::eipModel) - .filter(m -> m != null) - .filter(m -> matchesFilter(m.getName(), m.getTitle(), m.getDescription(), filter)) - .limit(20) - .map(m -> { - JsonObject jo = new JsonObject(); - jo.put("name", m.getName()); - jo.put("title", m.getTitle()); - jo.put("description", m.getDescription()); - jo.put("label", m.getLabel()); - return jo; - }) - .collect(Collectors.toList()); - - JsonObject response = new JsonObject(); - response.put("count", results.size()); - response.put("eips", results); - return response.toJson(); - } - - private static boolean matchesFilter(String name, String title, String description, String filter) { - if (filter == null || filter.isBlank()) { - return true; - } - String lf = filter.toLowerCase(); - return (name != null && name.toLowerCase().contains(lf)) - || (title != null && title.toLowerCase().contains(lf)) - || (description != null && description.toLowerCase().contains(lf)); - } - - // ---- Example tools ---- - - @SuppressWarnings("unchecked") - private String executeListExamples(JsonObject args) { - String filter = args.getString("filter"); - String level = args.getString("level"); - - List catalog = ExampleHelper.loadCatalog(); - List filtered = ExampleHelper.filterExamples(catalog, filter); - - List results = new ArrayList<>(); - for (JsonObject entry : filtered) { - if (level != null && !level.isBlank()) { - String entryLevel = entry.getString("level"); - if (entryLevel == null || !entryLevel.equalsIgnoreCase(level)) { - continue; - } - } - JsonObject jo = new JsonObject(); - jo.put("name", entry.getString("name")); - jo.put("title", entry.getString("title")); - jo.put("description", entry.getString("description")); - jo.put("level", entry.getString("level")); - jo.put("tags", entry.get("tags")); - jo.put("bundled", ExampleHelper.isBundled(entry)); - jo.put("files", ExampleHelper.getFiles(entry)); - results.add(jo); - - if (results.size() >= 20) { - break; - } - } - - JsonObject response = new JsonObject(); - response.put("count", results.size()); - response.put("examples", results); - return response.toJson(); - } - - private String executeGetExampleFile(JsonObject args) { - String example = args.getString("example"); - String file = args.getString("file"); - if (example == null || example.isBlank()) { - return "Error: example name is required"; - } - if (file == null || file.isBlank()) { - return "Error: file name is required"; - } - - List catalog = ExampleHelper.loadCatalog(); - JsonObject entry = ExampleHelper.findExample(catalog, example); - if (entry == null) { - return "Example not found: " + example; - } - - List files = ExampleHelper.getFiles(entry); - if (!files.contains(file)) { - return "File '" + file + "' not found in example '" + example + "'. Available files: " + files; - } - - if (ExampleHelper.isBundled(entry)) { - String resourcePath = "examples/" + example + "/" + file; - try (InputStream is = ExampleHelper.class.getClassLoader().getResourceAsStream(resourcePath)) { - if (is != null) { - return IOHelper.loadText(is); - } - } catch (Exception e) { - return "Error reading file: " + e.getMessage(); - } - return "Could not read bundled file: " + resourcePath; - } else { - return "This example is not bundled. View it on GitHub: " + ExampleHelper.getGithubUrl(entry) + "/" + file; - } - } - - // ---- CLI tools ---- - - @SuppressWarnings("unchecked") - private List loadCommandMetadata() { - if (commandMetadataCache != null) { - return commandMetadataCache; - } - try (InputStream is = getClass().getClassLoader() - .getResourceAsStream("META-INF/camel-jbang-commands-metadata.json")) { - if (is == null) { - return List.of(); - } - String json = IOHelper.loadText(is); - JsonObject root = (JsonObject) Jsoner.deserialize(json); - Object commands = root.get("commands"); - if (commands instanceof Collection) { - commandMetadataCache = ((Collection) commands).stream() - .filter(JsonObject.class::isInstance) - .map(JsonObject.class::cast) - .toList(); - return commandMetadataCache; - } - } catch (Exception e) { - printer().printErr("Failed to load CLI command metadata: " + e.getMessage()); - } - return List.of(); - } + // ---- CLI helper methods (used by AskTools) ---- @SuppressWarnings("unchecked") static void collectCommands(List commands, List result, String filter) { @@ -879,18 +304,6 @@ static void collectCommands(List commands, List result, } } - private String executeCliListCommands(JsonObject args) { - String filter = args.getString("filter"); - List commands = loadCommandMetadata(); - List result = new ArrayList<>(); - collectCommands(commands, result, filter); - - JsonObject response = new JsonObject(); - response.put("count", result.size()); - response.put("commands", result); - return response.toJson(); - } - @SuppressWarnings("unchecked") static JsonObject findCommand(List commands, String fullName) { for (JsonObject cmd : commands) { @@ -913,128 +326,6 @@ static JsonObject findCommand(List commands, String fullName) { return null; } - @SuppressWarnings("unchecked") - private String executeCliCommandHelp(JsonObject args) { - String command = args.getString("command"); - if (command == null || command.isBlank()) { - return "Error: command name is required"; - } - - List commands = loadCommandMetadata(); - JsonObject cmd = findCommand(commands, command); - if (cmd == null) { - return "Command not found: " + command + ". Use cli_list_commands to see available commands."; - } - - JsonObject response = new JsonObject(); - response.put("command", cmd.getString("fullName")); - response.put("description", cmd.getString("description")); - - Object options = cmd.get("options"); - if (options instanceof Collection) { - List opts = ((Collection) options).stream() - .filter(JsonObject.class::isInstance) - .map(JsonObject.class::cast) - .map(opt -> { - JsonObject o = new JsonObject(); - o.put("names", opt.getString("names")); - o.put("description", opt.getString("description")); - o.put("type", opt.getString("type")); - String dv = opt.getString("defaultValue"); - if (dv != null) { - o.put("defaultValue", dv); - } - return o; - }) - .toList(); - response.put("options", opts); - } - - Object subs = cmd.get("subcommands"); - if (subs instanceof Collection subList && !subList.isEmpty()) { - List subSummaries = ((Collection) subList).stream() - .filter(JsonObject.class::isInstance) - .map(JsonObject.class::cast) - .map(sub -> { - JsonObject s = new JsonObject(); - s.put("command", sub.getString("fullName")); - s.put("description", sub.getString("description")); - return s; - }) - .toList(); - response.put("subcommands", subSummaries); - } - - return response.toJson(); - } - - private String executeCliExec(JsonObject args) { - String command = args.getString("command"); - if (command == null || command.isBlank()) { - return "Error: command is required"; - } - - picocli.CommandLine commandLine = CamelJBangMain.getCommandLine(); - if (commandLine == null) { - return "Error: CLI not available"; - } - - String[] cmdArgs = tokenizeCommand(command.trim()); - - // capture output by temporarily swapping the Printer on main - StringBuilder captured = new StringBuilder(); - Printer capturingPrinter = new Printer() { - @Override - public void println() { - captured.append('\n'); - } - - @Override - public void println(String line) { - captured.append(line).append('\n'); - } - - @Override - public void print(String output) { - captured.append(output); - } - - @Override - public void printf(String format, Object... fmtArgs) { - captured.append(String.format(format, fmtArgs)); - } - }; - - // also capture PicoCLI's own output (usage/help text) - StringWriter sw = new StringWriter(); - PrintWriter pw = new PrintWriter(sw); - PrintWriter originalOut = commandLine.getOut(); - PrintWriter originalErr = commandLine.getErr(); - commandLine.setOut(pw); - commandLine.setErr(pw); - - Printer originalPrinter = getMain().getOut(); - getMain().setOut(capturingPrinter); - try { - int exitCode = commandLine.execute(cmdArgs); - pw.flush(); - String output = captured.toString() + sw.toString(); - if (output.isBlank() && exitCode != 0) { - return "Command exited with code " + exitCode; - } - if (output.length() > 32768) { - output = output.substring(0, 32768) + "\n... (truncated)"; - } - return output; - } catch (Exception e) { - return "Error executing command: " + e.getMessage(); - } finally { - getMain().setOut(originalPrinter); - commandLine.setOut(originalOut); - commandLine.setErr(originalErr); - } - } - static String[] tokenizeCommand(String command) { List tokens = new ArrayList<>(); StringBuilder current = new StringBuilder(); @@ -1062,116 +353,7 @@ static String[] tokenizeCommand(String command) { return tokens.toArray(String[]::new); } - // ---- File tools ---- - - private String executeListFiles(JsonObject args) throws IOException { - String pathStr = args.getString("path"); - Path cwd = Path.of("").toAbsolutePath().normalize(); - Path base = cwd.resolve(pathStr != null && !pathStr.isBlank() ? pathStr : ".").normalize(); - - if (!base.startsWith(cwd)) { - return "Error: path must be within the current working directory"; - } - if (!Files.isDirectory(base)) { - return "Error: not a directory: " + cwd.relativize(base); - } - - try (Stream stream = Files.walk(base, 2)) { - List files = stream - .filter(p -> !p.equals(base)) - .map(p -> cwd.relativize(p).toString() + (Files.isDirectory(p) ? "/" : "")) - .sorted() - .toList(); - - JsonObject response = new JsonObject(); - response.put("directory", base.equals(cwd) ? "." : cwd.relativize(base).toString()); - response.put("count", files.size()); - response.put("files", files); - return response.toJson(); - } - } - - private String executeReadFile(JsonObject args) throws IOException { - String fileStr = args.getString("file"); - if (fileStr == null || fileStr.isBlank()) { - return "Error: file path is required"; - } - - Path cwd = Path.of("").toAbsolutePath().normalize(); - Path filePath = cwd.resolve(fileStr).normalize(); - - if (!filePath.startsWith(cwd)) { - return "Error: file path must be within the current working directory"; - } - if (!Files.exists(filePath)) { - return "File not found: " + cwd.relativize(filePath); - } - - String content = Files.readString(filePath); - if (content.length() > 32768) { - content = content.substring(0, 32768) + "\n... (truncated, file is " + content.length() + " bytes)"; - } - return content; - } - - private String executeWriteFile(JsonObject args) throws IOException { - String fileStr = args.getString("file"); - String content = args.getString("content"); - if (fileStr == null || fileStr.isBlank()) { - return "Error: file path is required"; - } - if (content == null) { - return "Error: content is required"; - } - - Path cwd = Path.of("").toAbsolutePath().normalize(); - Path filePath = cwd.resolve(fileStr).normalize(); - - if (!filePath.startsWith(cwd)) { - return "Error: file path must be within the current working directory"; - } - - Files.createDirectories(filePath.getParent()); - Files.writeString(filePath, content); - return "File written: " + cwd.relativize(filePath); - } - - private CamelCatalog getCatalog() { - if (catalog == null) { - catalog = new DefaultCamelCatalog(); - } - return catalog; - } - - // ---- JSON schema helpers for tool parameters ---- - - private static JsonObject emptyParams() { - JsonObject schema = new JsonObject(); - schema.put("type", "object"); - schema.put("properties", new JsonObject()); - return schema; - } - - private static JsonObject objectParams(Map properties) { - JsonObject props = new JsonObject(); - Map ordered = new LinkedHashMap<>(properties); - for (Map.Entry entry : ordered.entrySet()) { - props.put(entry.getKey(), entry.getValue()); - } - JsonObject schema = new JsonObject(); - schema.put("type", "object"); - schema.put("properties", props); - return schema; - } - - private static JsonObject stringProp(String description) { - JsonObject prop = new JsonObject(); - prop.put("type", "string"); - prop.put("description", description); - return prop; - } - - private static String truncate(String text, int maxLen) { + static String truncate(String text, int maxLen) { if (text == null) { return "null"; } diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java new file mode 100644 index 0000000000000..50c196a83a924 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java @@ -0,0 +1,865 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.camel.catalog.CamelCatalog; +import org.apache.camel.catalog.DefaultCamelCatalog; +import org.apache.camel.dsl.jbang.core.common.ExampleHelper; +import org.apache.camel.dsl.jbang.core.common.Printer; +import org.apache.camel.dsl.jbang.core.common.RuntimeHelper; +import org.apache.camel.tooling.model.ComponentModel; +import org.apache.camel.util.IOHelper; +import org.apache.camel.util.json.JsonObject; +import org.apache.camel.util.json.Jsoner; + +/** + * Shared tool definitions and execution logic for the Camel AI assistant. Used by both the {@code camel ask} CLI + * command and the TUI AI panel. + */ +public class AskTools { + + private static final String NO_PROCESS + = "No running Camel process connected. Start one with: camel run "; + + private long targetPid; + private CamelCatalog catalog; + private volatile List commandMetadataCache; + + public AskTools(long targetPid) { + this.targetPid = targetPid; + } + + public long getTargetPid() { + return targetPid; + } + + public void setTargetPid(long targetPid) { + this.targetPid = targetPid; + } + + // ---- Tool definitions ---- + + public List buildToolDefinitions() { + List tools = new ArrayList<>(); + + tools.add(new LlmClient.ToolDef( + "list_processes", + "List all running Camel processes with their PID and name. Use this to discover available processes before selecting one.", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "select_process", + "Select a running Camel process by name or PID to inspect. Required when multiple processes are running. After selection, all runtime tools (get_routes, get_context, etc.) will target this process.", + objectParams(Map.of( + "name", stringProp("Name or PID of the Camel process to connect to"))))); + + tools.add(new LlmClient.ToolDef( + "get_context", + "Get Camel context info: name, version, state, uptime, route count, exchange statistics.", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_routes", + "List all routes with their state, uptime, messages processed, last error, and throughput.", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_health", + "Get health check status for the Camel application.", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_endpoints", + "List all endpoints registered in the Camel context with URIs and usage stats.", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_inflight", + "Show currently in-flight exchanges (messages being processed).", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_blocked", + "Show blocked exchanges that are stuck or waiting.", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_consumers", + "Show consumer statistics (polling and event-driven consumers).", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_properties", + "Show configuration properties of the running Camel application.", + emptyParams())); + + tools.add(new LlmClient.ToolDef( + "get_route_source", + "Get the source code of routes. Use filter to limit by filename (supports wildcards).", + objectParams(Map.of( + "filter", stringProp("Filter source files by name (supports wildcards). Use * for all."))))); + tools.add(new LlmClient.ToolDef( + "get_route_dump", + "Dump route definitions in XML or YAML format.", + objectParams(Map.of( + "routeId", stringProp("Route ID to dump (use * for all routes)"), + "format", stringProp("Output format: xml or yaml (default: yaml)"))))); + tools.add(new LlmClient.ToolDef( + "get_route_structure", + "Show the route structure as a tree of processors.", + objectParams(Map.of( + "routeId", stringProp("Route ID to inspect (use * for all routes)"))))); + tools.add(new LlmClient.ToolDef( + "get_top_processors", + "Show top processor statistics: which processors are slowest and most active.", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "trace_control", + "Enable, disable, or dump message tracing.", + objectParams(Map.of( + "action", stringProp("Action: enable, disable, or dump"))))); + + tools.add(new LlmClient.ToolDef( + "stop_route", + "Gracefully stop a route. The route will finish processing in-flight exchanges before stopping.", + objectParams(Map.of( + "routeId", stringProp("The ID of the route to stop"))))); + tools.add(new LlmClient.ToolDef( + "start_route", + "Start a stopped route.", + objectParams(Map.of( + "routeId", stringProp("The ID of the route to start"))))); + tools.add(new LlmClient.ToolDef( + "suspend_route", + "Suspend a route (pauses the consumer but keeps the route loaded).", + objectParams(Map.of( + "routeId", stringProp("The ID of the route to suspend"))))); + tools.add(new LlmClient.ToolDef( + "resume_route", + "Resume a suspended route.", + objectParams(Map.of( + "routeId", stringProp("The ID of the route to resume"))))); + + tools.add(new LlmClient.ToolDef( + "stop_application", + "Gracefully stop the Camel application. The application will finish processing in-flight exchanges and shut down cleanly. Use this instead of kill.", + emptyParams())); + + tools.add(new LlmClient.ToolDef( + "catalog_components", + "Search the Camel component catalog by name or label. Returns component name, title, description, and labels.", + objectParams(Map.of( + "filter", stringProp("Filter by name, title, or description (case-insensitive substring)"), + "label", stringProp("Filter by category label (e.g., cloud, messaging, database, file)"))))); + tools.add(new LlmClient.ToolDef( + "catalog_component_doc", + "Get detailed documentation for a Camel component: URI syntax and endpoint options.", + objectParams(Map.of( + "component", stringProp("Component name (e.g., kafka, http, file, timer)"))))); + tools.add(new LlmClient.ToolDef( + "catalog_eips", + "Search EIPs (Enterprise Integration Patterns) like split, aggregate, filter, choice, multicast.", + objectParams(Map.of( + "filter", stringProp("Filter by name, title, or description (case-insensitive substring)"))))); + + tools.add(new LlmClient.ToolDef( + "list_examples", + "List available Camel CLI examples. Returns name, title, description, difficulty level, and tags.", + objectParams(Map.of( + "filter", stringProp("Filter by name, description, or tag (case-insensitive)"), + "level", stringProp("Filter by difficulty: beginner, intermediate, or advanced"))))); + tools.add(new LlmClient.ToolDef( + "get_example_file", + "Get the content of a file from a bundled Camel CLI example. Use list_examples first to find available examples.", + objectParams(Map.of( + "example", stringProp("Example name (e.g., timer-log, rest-api, circuit-breaker)"), + "file", stringProp("File name within the example (e.g., route.camel.yaml)"))))); + + tools.add(new LlmClient.ToolDef( + "cli_list_commands", + "List available Camel CLI commands. Returns command names and descriptions. Use filter to narrow results.", + objectParams(Map.of( + "filter", stringProp("Filter by command name or description (case-insensitive substring)"))))); + tools.add(new LlmClient.ToolDef( + "cli_command_help", + "Get detailed help for a specific Camel CLI command, including all options with types and defaults.", + objectParams(Map.of( + "command", stringProp("Full command name (e.g., 'get error', 'catalog component', 'run')"))))); + tools.add(new LlmClient.ToolDef( + "cli_exec", + "Execute any Camel CLI command and return its output. Use cli_list_commands and cli_command_help first to discover commands and their options. CAUTION: some commands (stop, cmd stop-route, cmd stop-group) are destructive and will affect running integrations. Always confirm with the user before executing destructive commands.", + objectParams(Map.of( + "command", stringProp( + "The full command line to execute (e.g., 'get error --diagram', 'catalog component --filter=kafka')"))))); + + tools.add(new LlmClient.ToolDef( + "list_files", + "List files in a directory (up to 2 levels deep). Defaults to current working directory.", + objectParams(Map.of( + "path", stringProp("Directory path relative to CWD (default: current directory)"))))); + tools.add(new LlmClient.ToolDef( + "read_file", + "Read the content of a file. Useful for inspecting route definitions, configuration, and properties files.", + objectParams(Map.of( + "file", stringProp("File path relative to CWD"))))); + tools.add(new LlmClient.ToolDef( + "write_file", + "Write content to a file. Creates parent directories if needed. Use for creating or editing route definitions and configuration files.", + objectParams(Map.of( + "file", stringProp("File path relative to CWD"), + "content", stringProp("The content to write to the file"))))); + + return tools; + } + + // ---- Tool execution ---- + + public String executeTool(String name, JsonObject args) { + try { + return switch (name) { + case "list_processes" -> executeListProcesses(); + case "select_process" -> executeSelectProcess(args); + case "get_context" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "context"); + case "get_routes" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "routes"); + case "get_health" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "healthChecks"); + case "get_endpoints" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "endpoints"); + case "get_inflight" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "inflight"); + case "get_blocked" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "blocked"); + case "get_consumers" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "consumers"); + case "get_properties" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "properties"); + case "get_route_source" -> targetPid < 0 ? NO_PROCESS : executeRouteSource(args); + case "get_route_dump" -> targetPid < 0 ? NO_PROCESS : executeRouteDump(args); + case "get_route_structure" -> targetPid < 0 ? NO_PROCESS : executeRouteStructure(args); + case "get_top_processors" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.executeAction(targetPid, "top-processors", null); + case "trace_control" -> targetPid < 0 ? NO_PROCESS : executeTraceControl(args); + case "stop_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "stop"); + case "start_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "start"); + case "suspend_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "suspend"); + case "resume_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "resume"); + case "stop_application" -> targetPid < 0 ? NO_PROCESS : RuntimeHelper.stopApplication(targetPid); + case "catalog_components" -> executeCatalogComponents(args); + case "catalog_component_doc" -> executeCatalogComponentDoc(args); + case "catalog_eips" -> executeCatalogEips(args); + case "list_examples" -> executeListExamples(args); + case "get_example_file" -> executeGetExampleFile(args); + case "cli_list_commands" -> executeCliListCommands(args); + case "cli_command_help" -> executeCliCommandHelp(args); + case "cli_exec" -> executeCliExec(args); + case "list_files" -> executeListFiles(args); + case "read_file" -> executeReadFile(args); + case "write_file" -> executeWriteFile(args); + default -> "Unknown tool: " + name; + }; + } catch (Exception e) { + return "Error executing " + name + ": " + e.getMessage(); + } + } + + // ---- System prompt ---- + + public static String buildSystemPrompt(long targetPid, String processName) { + StringBuilder sb = new StringBuilder(); + sb.append("You are an Apache Camel assistant. "); + sb.append("You help users build, understand, and troubleshoot Camel applications.\n\n"); + + if (targetPid >= 0 && processName != null) { + sb.append("You are connected to a running Camel application: "); + sb.append(processName).append(" (PID ").append(targetPid).append("). "); + sb.append("Use the runtime inspection tools to gather information about it.\n\n"); + } else { + List available = RuntimeHelper.discoverProcesses(); + if (!available.isEmpty()) { + sb.append("No Camel process is currently selected. "); + sb.append("Use list_processes to see available processes, then select_process to connect to one. "); + sb.append("Runtime inspection tools will not work until a process is selected.\n\n"); + } + } + + sb.append("You can search the Camel catalog (components, EIPs), browse examples, "); + sb.append("read/write files, and execute any Camel CLI command.\n\n"); + sb.append("For CLI commands beyond the built-in tools, use cli_list_commands to discover "); + sb.append("available commands, cli_command_help to see options, and cli_exec to run them.\n\n"); + sb.append("Guidelines:\n"); + sb.append("- When creating routes, use YAML DSL format (Camel's recommended format for JBang)\n"); + sb.append("- Look at existing files first with list_files/read_file before creating new ones\n"); + sb.append("- Use catalog tools to look up component syntax before writing routes\n"); + sb.append("- Use examples as reference when building new routes\n"); + sb.append("- Be concise and actionable in your answers\n"); + sb.append("- Format output as plain text for terminal display, do not use markdown\n"); + if (targetPid >= 0) { + sb.append("- Start by gathering relevant information using the available runtime tools\n"); + sb.append("- If something looks wrong, explain what it means and suggest fixes\n"); + sb.append("- To stop routes or the application, always use the provided tools "); + sb.append("(stop_route, stop_application) for graceful shutdown. Never suggest kill or kill -9.\n"); + } + return sb.toString(); + } + + // ---- Runtime tool execution ---- + + private String executeListProcesses() { + List processes = RuntimeHelper.discoverProcesses(); + if (processes.isEmpty()) { + return "No running Camel processes found. Start one with: camel run "; + } + JsonObject response = new JsonObject(); + response.put("count", processes.size()); + List list = new ArrayList<>(); + for (RuntimeHelper.ProcessInfo p : processes) { + JsonObject entry = new JsonObject(); + entry.put("pid", p.pid()); + entry.put("name", p.name()); + entry.put("selected", p.pid() == targetPid); + list.add(entry); + } + response.put("processes", list); + if (targetPid < 0) { + response.put("hint", "No process selected. Use select_process to connect to one."); + } + return response.toJson(); + } + + private String executeSelectProcess(JsonObject args) { + String name = args.getString("name"); + if (name == null || name.isBlank()) { + return "Error: name or PID is required"; + } + RuntimeHelper.ProcessInfo found = RuntimeHelper.findProcess(name); + if (found == null) { + List processes = RuntimeHelper.discoverProcesses(); + if (processes.isEmpty()) { + return "No running Camel processes found."; + } + StringBuilder sb = new StringBuilder("No process found matching: " + name + ". Available:\n"); + processes.forEach(p -> sb.append(" ").append(p.name()).append(" (PID ").append(p.pid()).append(")\n")); + return sb.toString(); + } + targetPid = found.pid(); + return "Connected to " + found.name() + " (PID " + found.pid() + "). Runtime tools are now active."; + } + + private String executeRouteSource(JsonObject args) { + String filter = args.getString("filter"); + return RuntimeHelper.executeAction(targetPid, "source", + root -> root.put("filter", filter != null ? filter : "*")); + } + + private String executeRouteDump(JsonObject args) { + String routeId = args.getString("routeId"); + String format = args.getString("format"); + return RuntimeHelper.executeAction(targetPid, "route-dump", root -> { + root.put("id", routeId != null ? routeId : "*"); + root.put("format", format != null ? format : "yaml"); + }); + } + + private String executeRouteStructure(JsonObject args) { + String routeId = args.getString("routeId"); + return RuntimeHelper.executeAction(targetPid, "route-structure", + root -> root.put("id", routeId != null ? routeId : "*")); + } + + private String executeTraceControl(JsonObject args) { + String action = args.getString("action"); + if (action == null) { + return "Error: action is required (enable, disable, dump)"; + } + return RuntimeHelper.executeAction(targetPid, "trace", root -> { + switch (action.toLowerCase()) { + case "enable" -> root.put("enabled", "true"); + case "disable" -> root.put("enabled", "false"); + case "dump" -> root.put("dump", "true"); + default -> root.put("enabled", action); + } + }); + } + + private String executeRouteCommand(JsonObject args, String command) { + String routeId = args.getString("routeId"); + if (routeId == null || routeId.isBlank()) { + return "Error: routeId is required"; + } + return RuntimeHelper.executeAction(targetPid, "route", root -> { + root.put("id", routeId); + root.put("command", command); + }); + } + + // ---- Catalog tools ---- + + private String executeCatalogComponents(JsonObject args) { + String filter = args.getString("filter"); + String label = args.getString("label"); + CamelCatalog cat = getCatalog(); + + List results = cat.findComponentNames().stream() + .map(cat::componentModel) + .filter(m -> m != null) + .filter(m -> matchesFilter(m.getScheme(), m.getTitle(), m.getDescription(), filter)) + .filter(m -> label == null || label.isBlank() + || (m.getLabel() != null && m.getLabel().toLowerCase().contains(label.toLowerCase()))) + .limit(20) + .map(m -> { + JsonObject jo = new JsonObject(); + jo.put("name", m.getScheme()); + jo.put("title", m.getTitle()); + jo.put("description", m.getDescription()); + jo.put("label", m.getLabel()); + return jo; + }) + .collect(Collectors.toList()); + + JsonObject response = new JsonObject(); + response.put("count", results.size()); + response.put("components", results); + return response.toJson(); + } + + private String executeCatalogComponentDoc(JsonObject args) { + String component = args.getString("component"); + if (component == null || component.isBlank()) { + return "Error: component name is required"; + } + CamelCatalog cat = getCatalog(); + ComponentModel model = cat.componentModel(component); + if (model == null) { + return "Component not found: " + component; + } + + JsonObject response = new JsonObject(); + response.put("name", model.getScheme()); + response.put("title", model.getTitle()); + response.put("description", model.getDescription()); + response.put("syntax", model.getSyntax()); + response.put("consumerOnly", model.isConsumerOnly()); + response.put("producerOnly", model.isProducerOnly()); + + List options = new ArrayList<>(); + if (model.getEndpointOptions() != null) { + model.getEndpointOptions().stream() + .filter(opt -> !opt.isDeprecated()) + .forEach(opt -> { + JsonObject jo = new JsonObject(); + jo.put("name", opt.getName()); + jo.put("description", opt.getDescription()); + jo.put("type", opt.getType()); + jo.put("required", opt.isRequired()); + if (opt.getDefaultValue() != null) { + jo.put("defaultValue", opt.getDefaultValue().toString()); + } + options.add(jo); + }); + } + response.put("options", options); + return response.toJson(); + } + + private String executeCatalogEips(JsonObject args) { + String filter = args.getString("filter"); + CamelCatalog cat = getCatalog(); + + List results = cat.findModelNames().stream() + .map(cat::eipModel) + .filter(m -> m != null) + .filter(m -> matchesFilter(m.getName(), m.getTitle(), m.getDescription(), filter)) + .limit(20) + .map(m -> { + JsonObject jo = new JsonObject(); + jo.put("name", m.getName()); + jo.put("title", m.getTitle()); + jo.put("description", m.getDescription()); + jo.put("label", m.getLabel()); + return jo; + }) + .collect(Collectors.toList()); + + JsonObject response = new JsonObject(); + response.put("count", results.size()); + response.put("eips", results); + return response.toJson(); + } + + // ---- Example tools ---- + + @SuppressWarnings("unchecked") + private String executeListExamples(JsonObject args) { + String filter = args.getString("filter"); + String level = args.getString("level"); + + List catalog2 = ExampleHelper.loadCatalog(); + List filtered = ExampleHelper.filterExamples(catalog2, filter); + + List results = new ArrayList<>(); + for (JsonObject entry : filtered) { + if (level != null && !level.isBlank()) { + String entryLevel = entry.getString("level"); + if (entryLevel == null || !entryLevel.equalsIgnoreCase(level)) { + continue; + } + } + JsonObject jo = new JsonObject(); + jo.put("name", entry.getString("name")); + jo.put("title", entry.getString("title")); + jo.put("description", entry.getString("description")); + jo.put("level", entry.getString("level")); + jo.put("tags", entry.get("tags")); + jo.put("bundled", ExampleHelper.isBundled(entry)); + jo.put("files", ExampleHelper.getFiles(entry)); + results.add(jo); + + if (results.size() >= 20) { + break; + } + } + + JsonObject response = new JsonObject(); + response.put("count", results.size()); + response.put("examples", results); + return response.toJson(); + } + + private String executeGetExampleFile(JsonObject args) { + String example = args.getString("example"); + String file = args.getString("file"); + if (example == null || example.isBlank()) { + return "Error: example name is required"; + } + if (file == null || file.isBlank()) { + return "Error: file name is required"; + } + + List catalog2 = ExampleHelper.loadCatalog(); + JsonObject entry = ExampleHelper.findExample(catalog2, example); + if (entry == null) { + return "Example not found: " + example; + } + + List files = ExampleHelper.getFiles(entry); + if (!files.contains(file)) { + return "File '" + file + "' not found in example '" + example + "'. Available files: " + files; + } + + if (ExampleHelper.isBundled(entry)) { + String resourcePath = "examples/" + example + "/" + file; + try (InputStream is = ExampleHelper.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (is != null) { + return IOHelper.loadText(is); + } + } catch (Exception e) { + return "Error reading file: " + e.getMessage(); + } + return "Could not read bundled file: " + resourcePath; + } else { + return "This example is not bundled. View it on GitHub: " + ExampleHelper.getGithubUrl(entry) + "/" + file; + } + } + + // ---- CLI tools ---- + + @SuppressWarnings("unchecked") + private List loadCommandMetadata() { + if (commandMetadataCache != null) { + return commandMetadataCache; + } + try (InputStream is = getClass().getClassLoader() + .getResourceAsStream("META-INF/camel-jbang-commands-metadata.json")) { + if (is == null) { + return List.of(); + } + String json = IOHelper.loadText(is); + JsonObject root = (JsonObject) Jsoner.deserialize(json); + Object commands = root.get("commands"); + if (commands instanceof Collection) { + commandMetadataCache = ((Collection) commands).stream() + .filter(JsonObject.class::isInstance) + .map(JsonObject.class::cast) + .toList(); + return commandMetadataCache; + } + } catch (Exception e) { + // ignore + } + return List.of(); + } + + private String executeCliListCommands(JsonObject args) { + String filter = args.getString("filter"); + List commands = loadCommandMetadata(); + List result = new ArrayList<>(); + Ask.collectCommands(commands, result, filter); + + JsonObject response = new JsonObject(); + response.put("count", result.size()); + response.put("commands", result); + return response.toJson(); + } + + @SuppressWarnings("unchecked") + private String executeCliCommandHelp(JsonObject args) { + String command = args.getString("command"); + if (command == null || command.isBlank()) { + return "Error: command name is required"; + } + + List commands = loadCommandMetadata(); + JsonObject cmd = Ask.findCommand(commands, command); + if (cmd == null) { + return "Command not found: " + command + ". Use cli_list_commands to see available commands."; + } + + JsonObject response = new JsonObject(); + response.put("command", cmd.getString("fullName")); + response.put("description", cmd.getString("description")); + + Object options = cmd.get("options"); + if (options instanceof Collection) { + List opts = ((Collection) options).stream() + .filter(JsonObject.class::isInstance) + .map(JsonObject.class::cast) + .map(opt -> { + JsonObject o = new JsonObject(); + o.put("names", opt.getString("names")); + o.put("description", opt.getString("description")); + o.put("type", opt.getString("type")); + String dv = opt.getString("defaultValue"); + if (dv != null) { + o.put("defaultValue", dv); + } + return o; + }) + .toList(); + response.put("options", opts); + } + + Object subs = cmd.get("subcommands"); + if (subs instanceof Collection subList && !subList.isEmpty()) { + List subSummaries = ((Collection) subList).stream() + .filter(JsonObject.class::isInstance) + .map(JsonObject.class::cast) + .map(sub -> { + JsonObject s = new JsonObject(); + s.put("command", sub.getString("fullName")); + s.put("description", sub.getString("description")); + return s; + }) + .toList(); + response.put("subcommands", subSummaries); + } + + return response.toJson(); + } + + private String executeCliExec(JsonObject args) { + String command = args.getString("command"); + if (command == null || command.isBlank()) { + return "Error: command is required"; + } + + picocli.CommandLine commandLine = CamelJBangMain.getCommandLine(); + if (commandLine == null) { + return "Error: CLI not available"; + } + + String[] cmdArgs = Ask.tokenizeCommand(command.trim()); + + StringBuilder captured = new StringBuilder(); + Printer capturingPrinter = new Printer() { + @Override + public void println() { + captured.append('\n'); + } + + @Override + public void println(String line) { + captured.append(line).append('\n'); + } + + @Override + public void print(String output) { + captured.append(output); + } + + @Override + public void printf(String format, Object... fmtArgs) { + captured.append(String.format(format, fmtArgs)); + } + }; + + CamelJBangMain main = (CamelJBangMain) commandLine.getCommand(); + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + PrintWriter originalOut = commandLine.getOut(); + PrintWriter originalErr = commandLine.getErr(); + commandLine.setOut(pw); + commandLine.setErr(pw); + + Printer originalPrinter = main.getOut(); + main.setOut(capturingPrinter); + try { + int exitCode = commandLine.execute(cmdArgs); + pw.flush(); + String output = captured.toString() + sw.toString(); + if (output.isBlank() && exitCode != 0) { + return "Command exited with code " + exitCode; + } + if (output.length() > 32768) { + output = output.substring(0, 32768) + "\n... (truncated)"; + } + return output; + } catch (Exception e) { + return "Error executing command: " + e.getMessage(); + } finally { + main.setOut(originalPrinter); + commandLine.setOut(originalOut); + commandLine.setErr(originalErr); + } + } + + // ---- File tools ---- + + private String executeListFiles(JsonObject args) throws IOException { + String pathStr = args.getString("path"); + Path cwd = Path.of("").toAbsolutePath().normalize(); + Path base = cwd.resolve(pathStr != null && !pathStr.isBlank() ? pathStr : ".").normalize(); + + if (!base.startsWith(cwd)) { + return "Error: path must be within the current working directory"; + } + if (!Files.isDirectory(base)) { + return "Error: not a directory: " + cwd.relativize(base); + } + + try (Stream stream = Files.walk(base, 2)) { + List files = stream + .filter(p -> !p.equals(base)) + .map(p -> cwd.relativize(p).toString() + (Files.isDirectory(p) ? "/" : "")) + .sorted() + .toList(); + + JsonObject response = new JsonObject(); + response.put("directory", base.equals(cwd) ? "." : cwd.relativize(base).toString()); + response.put("count", files.size()); + response.put("files", files); + return response.toJson(); + } + } + + private String executeReadFile(JsonObject args) throws IOException { + String fileStr = args.getString("file"); + if (fileStr == null || fileStr.isBlank()) { + return "Error: file path is required"; + } + + Path cwd = Path.of("").toAbsolutePath().normalize(); + Path filePath = cwd.resolve(fileStr).normalize(); + + if (!filePath.startsWith(cwd)) { + return "Error: file path must be within the current working directory"; + } + if (!Files.exists(filePath)) { + return "File not found: " + cwd.relativize(filePath); + } + + String content = Files.readString(filePath); + if (content.length() > 32768) { + content = content.substring(0, 32768) + "\n... (truncated, file is " + content.length() + " bytes)"; + } + return content; + } + + private String executeWriteFile(JsonObject args) throws IOException { + String fileStr = args.getString("file"); + String content = args.getString("content"); + if (fileStr == null || fileStr.isBlank()) { + return "Error: file path is required"; + } + if (content == null) { + return "Error: content is required"; + } + + Path cwd = Path.of("").toAbsolutePath().normalize(); + Path filePath = cwd.resolve(fileStr).normalize(); + + if (!filePath.startsWith(cwd)) { + return "Error: file path must be within the current working directory"; + } + + Files.createDirectories(filePath.getParent()); + Files.writeString(filePath, content); + return "File written: " + cwd.relativize(filePath); + } + + private CamelCatalog getCatalog() { + if (catalog == null) { + catalog = new DefaultCamelCatalog(); + } + return catalog; + } + + // ---- JSON schema helpers ---- + + public static JsonObject emptyParams() { + JsonObject schema = new JsonObject(); + schema.put("type", "object"); + schema.put("properties", new JsonObject()); + return schema; + } + + public static JsonObject objectParams(Map properties) { + JsonObject props = new JsonObject(); + Map ordered = new LinkedHashMap<>(properties); + for (Map.Entry entry : ordered.entrySet()) { + props.put(entry.getKey(), entry.getValue()); + } + JsonObject schema = new JsonObject(); + schema.put("type", "object"); + schema.put("properties", props); + return schema; + } + + public static JsonObject stringProp(String description) { + JsonObject prop = new JsonObject(); + prop.put("type", "string"); + prop.put("description", description); + return prop; + } + + static boolean matchesFilter(String name, String title, String description, String filter) { + if (filter == null || filter.isBlank()) { + return true; + } + String lf = filter.toLowerCase(); + return (name != null && name.toLowerCase().contains(lf)) + || (title != null && title.toLowerCase().contains(lf)) + || (description != null && description.toLowerCase().contains(lf)); + } +} diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java index ac87f338677cf..d2abee4f8f449 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java @@ -42,7 +42,7 @@ /** * Shared LLM HTTP client supporting Ollama, OpenAI-compatible, and Anthropic (including Vertex AI) APIs. */ -class LlmClient { +public class LlmClient { private static final String DEFAULT_OLLAMA_URL = "http://localhost:11434"; private static final String DEFAULT_ANTHROPIC_URL = "https://api.anthropic.com"; @@ -58,7 +58,7 @@ class LlmClient { "claude-opus-4-5", "claude-opus-4-5@20251101", "claude-haiku-4-5", "claude-haiku-4-5@20251001"); - enum ApiType { + public enum ApiType { ollama, openai, anthropic @@ -66,31 +66,31 @@ enum ApiType { // -- Unified abstractions for tool-calling across API formats -- - record ToolDef(String name, String description, JsonObject parameters) { + public record ToolDef(String name, String description, JsonObject parameters) { } - record ToolCall(String id, String name, JsonObject arguments) { + public record ToolCall(String id, String name, JsonObject arguments) { } - record ToolResult(String toolCallId, String content) { + public record ToolResult(String toolCallId, String content) { } - record Message(String role, String content, List toolCalls, List toolResults) { + public record Message(String role, String content, List toolCalls, List toolResults) { - static Message user(String text) { + public static Message user(String text) { return new Message("user", text, null, null); } - static Message assistantWithToolCalls(String text, List calls) { + public static Message assistantWithToolCalls(String text, List calls) { return new Message("assistant", text, calls, null); } - static Message toolResults(List results) { + public static Message toolResults(List results) { return new Message("tool", null, null, results); } } - record ChatResponse(String text, List toolCalls, String stopReason, boolean streamed) { + public record ChatResponse(String text, List toolCalls, String stopReason, boolean streamed) { } // -- Configuration -- @@ -104,7 +104,23 @@ record ChatResponse(String text, List toolCalls, String stopReason, bo boolean stream; int maxTokens; boolean verbose; - Printer printer; + Printer printer = new Printer() { + @Override + public void println() { + } + + @Override + public void println(String line) { + } + + @Override + public void print(String output) { + } + + @Override + public void printf(String format, Object... args) { + } + }; private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS)) @@ -116,63 +132,63 @@ record ChatResponse(String text, List toolCalls, String stopReason, bo // -- Builder -- - static LlmClient create() { + public static LlmClient create() { return new LlmClient(); } - LlmClient withApiType(ApiType apiType) { + public LlmClient withApiType(ApiType apiType) { this.apiType = apiType; return this; } - LlmClient withUrl(String url) { + public LlmClient withUrl(String url) { this.url = url; return this; } - LlmClient withApiKey(String apiKey) { + public LlmClient withApiKey(String apiKey) { this.apiKey = apiKey; return this; } - LlmClient withModel(String model) { + public LlmClient withModel(String model) { this.model = model; return this; } - LlmClient withTimeout(int timeout) { + public LlmClient withTimeout(int timeout) { this.timeout = timeout; return this; } - LlmClient withTemperature(double temperature) { + public LlmClient withTemperature(double temperature) { this.temperature = temperature; return this; } - LlmClient withStream(boolean stream) { + public LlmClient withStream(boolean stream) { this.stream = stream; return this; } - LlmClient withMaxTokens(int maxTokens) { + public LlmClient withMaxTokens(int maxTokens) { this.maxTokens = maxTokens; return this; } - LlmClient withVerbose(boolean verbose) { + public LlmClient withVerbose(boolean verbose) { this.verbose = verbose; return this; } - LlmClient withPrinter(Printer printer) { + public LlmClient withPrinter(Printer printer) { this.printer = printer; return this; } // -- Auto-detection -- - boolean detectEndpoint() { + public boolean detectEndpoint() { boolean found; if (tryExplicitUrl()) { found = true; @@ -232,7 +248,7 @@ String generate(String systemPrompt, String userPrompt) { // -- Chat with tools (for ask) -- - ChatResponse chatWithTools(String systemPrompt, List messages, List tools) { + public ChatResponse chatWithTools(String systemPrompt, List messages, List tools) { return switch (apiType) { case ollama -> chatOllamaFormat(systemPrompt, messages, tools); case openai -> chatOpenAiFormat(systemPrompt, messages, tools); diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java new file mode 100644 index 0000000000000..52d2257bd6762 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java @@ -0,0 +1,406 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import dev.tamboui.layout.Constraint; +import dev.tamboui.layout.Layout; +import dev.tamboui.layout.Rect; +import dev.tamboui.markdown.MarkdownView; +import dev.tamboui.style.Color; +import dev.tamboui.style.Style; +import dev.tamboui.terminal.Frame; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.block.Borders; +import dev.tamboui.widgets.block.Title; +import dev.tamboui.widgets.paragraph.Paragraph; +import org.apache.camel.dsl.jbang.core.commands.AskTools; +import org.apache.camel.dsl.jbang.core.commands.LlmClient; + +/** + * AI prompt panel for the TUI. Communicates directly with an LLM via {@link LlmClient} and uses the same tool + * definitions as {@code camel ask}. Toggled with F8 when the TUI runs with {@code --mcp} mode. + */ +class AiPanel { + + private static final int[] SPLIT_PERCENTS = { 25, 50, 75, 100 }; + private static final int MAX_ITERATIONS = 10; + + private boolean visible; + private int splitIndex = 1; // default 50% + private MonitorContext ctx; + + // Input state + private final StringBuilder inputBuffer = new StringBuilder(); + private int cursorPos; + + // Conversation display + private final List conversation = new ArrayList<>(); + private int scrollOffset; + + // LLM state + private LlmClient client; + private List messages; + private List tools; + private AskTools askTools; + private final AtomicBoolean thinking = new AtomicBoolean(); + private volatile Thread agentThread; + private String initError; + + record ConversationEntry(String role, String text) { + } + + void setContext(MonitorContext ctx) { + this.ctx = ctx; + } + + boolean isOpen() { + return visible; + } + + int panelPercent() { + return SPLIT_PERCENTS[splitIndex]; + } + + void cycleHeight() { + splitIndex = (splitIndex + 1) % SPLIT_PERCENTS.length; + } + + void open() { + visible = true; + if (client == null) { + initClient(); + } + } + + void close() { + visible = false; + } + + void destroy() { + close(); + Thread t = agentThread; + if (t != null) { + t.interrupt(); + } + } + + private void initClient() { + try { + client = LlmClient.create() + .withTemperature(0.3) + .withTimeout(120) + .withMaxTokens(4096); + if (!client.detectEndpoint()) { + initError = "No LLM service reachable. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or start Ollama."; + client = null; + return; + } + initError = null; + messages = new ArrayList<>(); + long pid = ctx != null && ctx.selectedPid != null ? Long.parseLong(ctx.selectedPid) : -1; + String name = ctx != null ? ctx.selectedName() : null; + askTools = new AskTools(pid); + tools = askTools.buildToolDefinitions(); + } catch (Exception e) { + initError = "Failed to initialize AI: " + e.getMessage(); + client = null; + } + } + + boolean handleKeyEvent(KeyEvent ke) { + if (ke.isKey(KeyCode.F8)) { + close(); + return true; + } + if (ke.isKey(KeyCode.PAGE_UP)) { + scrollOffset += 5; + return true; + } + if (ke.isKey(KeyCode.PAGE_DOWN)) { + scrollOffset = Math.max(0, scrollOffset - 5); + return true; + } + if (thinking.get()) { + if (ke.isCtrlC()) { + Thread t = agentThread; + if (t != null) { + t.interrupt(); + } + thinking.set(false); + conversation.add(new ConversationEntry("system", "(cancelled)")); + return true; + } + return true; + } + if (ke.isKey(KeyCode.ENTER)) { + if (!inputBuffer.isEmpty()) { + submitQuestion(); + } + return true; + } + if (ke.isKey(KeyCode.BACKSPACE)) { + if (cursorPos > 0) { + inputBuffer.deleteCharAt(cursorPos - 1); + cursorPos--; + } + return true; + } + if (ke.isKey(KeyCode.DELETE)) { + if (cursorPos < inputBuffer.length()) { + inputBuffer.deleteCharAt(cursorPos); + } + return true; + } + if (ke.isKey(KeyCode.LEFT)) { + if (cursorPos > 0) { + cursorPos--; + } + return true; + } + if (ke.isKey(KeyCode.RIGHT)) { + if (cursorPos < inputBuffer.length()) { + cursorPos++; + } + return true; + } + if (ke.isKey(KeyCode.HOME)) { + cursorPos = 0; + return true; + } + if (ke.isKey(KeyCode.END)) { + cursorPos = inputBuffer.length(); + return true; + } + if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) { + inputBuffer.insert(cursorPos, ke.character()); + cursorPos++; + return true; + } + return true; + } + + private void submitQuestion() { + String question = inputBuffer.toString().trim(); + inputBuffer.setLength(0); + cursorPos = 0; + scrollOffset = 0; + + conversation.add(new ConversationEntry("user", question)); + thinking.set(true); + + // rebuild tools if target process changed + long pid = ctx != null && ctx.selectedPid != null ? Long.parseLong(ctx.selectedPid) : -1; + String name = ctx != null ? ctx.selectedName() : null; + askTools = new AskTools(pid); + tools = askTools.buildToolDefinitions(); + String systemPrompt = AskTools.buildSystemPrompt(pid, name); + + agentThread = new Thread(() -> { + try { + runAgentLoop(systemPrompt, question); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + conversation.add(new ConversationEntry("error", e.getMessage())); + } finally { + thinking.set(false); + agentThread = null; + } + }, "tui-ai-agent"); + agentThread.setDaemon(true); + agentThread.start(); + } + + private void runAgentLoop(String systemPrompt, String question) throws InterruptedException { + if (messages == null) { + messages = new ArrayList<>(); + } + messages.add(LlmClient.Message.user(question)); + + for (int i = 0; i < MAX_ITERATIONS; i++) { + if (Thread.interrupted()) { + throw new InterruptedException(); + } + + LlmClient.ChatResponse response = client.chatWithTools(systemPrompt, messages, tools); + if (response == null) { + conversation.add(new ConversationEntry("error", "No response from LLM")); + return; + } + + // check for error response (null text, no tool calls, error stop reason) + if ("error".equals(response.stopReason()) + && (response.toolCalls() == null || response.toolCalls().isEmpty()) + && response.text() == null) { + conversation.add(new ConversationEntry("error", "LLM request failed. Check API key and endpoint.")); + return; + } + + if (response.toolCalls() != null && !response.toolCalls().isEmpty()) { + messages.add(LlmClient.Message.assistantWithToolCalls(response.text(), response.toolCalls())); + + List results = new ArrayList<>(); + for (LlmClient.ToolCall toolCall : response.toolCalls()) { + if (Thread.interrupted()) { + throw new InterruptedException(); + } + String result = askTools.executeTool(toolCall.name(), toolCall.arguments()); + results.add(new LlmClient.ToolResult(toolCall.id(), result)); + } + messages.add(LlmClient.Message.toolResults(results)); + } else { + String text = response.text(); + if (text != null && !text.isBlank()) { + conversation.add(new ConversationEntry("assistant", text)); + } else { + conversation.add(new ConversationEntry("error", "Empty response from LLM.")); + } + messages.add(LlmClient.Message.assistantWithToolCalls(text, List.of())); + return; + } + } + conversation.add(new ConversationEntry( + "error", + "Reached maximum iterations (" + MAX_ITERATIONS + ") without a final answer.")); + } + + void render(Frame frame, Rect area) { + Block block = Block.builder() + .borders(Borders.ALL) + .borderType(BorderType.ROUNDED) + .title(Title.from(Line.from(Span.styled(" AI ", Style.EMPTY.bold())))) + .build(); + frame.renderWidget(block, area); + Rect inner = block.inner(area); + if (inner.height() < 2) { + return; + } + + // Split inner area: conversation (fill) + separator (1 row) + input (1 row) + padding (1 row) + List parts = Layout.vertical() + .constraints(Constraint.fill(), Constraint.length(1), Constraint.length(1), Constraint.length(1)) + .split(inner); + Rect conversationArea = parts.get(0); + Rect separatorArea = parts.get(1); + Rect inputArea = parts.get(2); + // parts.get(3) is empty padding row above the bottom border + + renderConversation(frame, conversationArea); + // horizontal line separator + String line = "─".repeat(separatorArea.width()); + frame.renderWidget(Paragraph.from(Line.from(Span.styled(line, Style.EMPTY.dim()))), + separatorArea); + renderInput(frame, inputArea); + } + + private void renderConversation(Frame frame, Rect area) { + if (area.height() < 1) { + return; + } + + StringBuilder md = new StringBuilder(); + + if (initError != null) { + md.append("**Error:** ").append(initError).append("\n\n"); + } else if (conversation.isEmpty() && !thinking.get()) { + md.append("*Ask a question about your Camel application...*\n"); + } + + for (ConversationEntry entry : conversation) { + switch (entry.role()) { + case "user" -> md.append("**You:** ").append(entry.text()).append("\n\n"); + case "assistant" -> md.append(entry.text()).append("\n\n"); + case "error" -> md.append("**Error:** ").append(entry.text()).append("\n\n"); + case "system" -> md.append("*").append(entry.text()).append("*\n\n"); + default -> { + } + } + } + + if (thinking.get()) { + long dots = (System.currentTimeMillis() / 500) % 4; + md.append("*🤔 thinking").append(".".repeat((int) dots + 1)).append("*\n"); + } + + MarkdownView view = MarkdownView.builder() + .source(md.toString()) + .scroll(scrollOffset) + .build(); + frame.renderWidget(view, area); + } + + private void renderInput(Frame frame, Rect area) { + String prompt = "> "; + String text = inputBuffer.toString(); + + List spans = new ArrayList<>(); + spans.add(Span.styled(prompt, Style.EMPTY.fg(Color.CYAN).bold())); + + if (thinking.get()) { + spans.add(Span.styled(text, Style.EMPTY.dim())); + } else { + // Render with cursor + int maxWidth = area.width() - prompt.length(); + if (maxWidth <= 0) { + return; + } + // Ensure cursor is visible by adjusting text window + int windowStart = 0; + if (cursorPos > maxWidth - 1) { + windowStart = cursorPos - maxWidth + 1; + } + String visible = text.substring(windowStart, + Math.min(text.length(), windowStart + maxWidth)); + int cursorInWindow = cursorPos - windowStart; + + if (cursorInWindow >= 0 && cursorInWindow < visible.length()) { + spans.add(Span.raw(visible.substring(0, cursorInWindow))); + spans.add(Span.styled(String.valueOf(visible.charAt(cursorInWindow)), + Style.EMPTY.reversed())); + spans.add(Span.raw(visible.substring(cursorInWindow + 1))); + } else { + spans.add(Span.raw(visible)); + if (cursorInWindow == visible.length()) { + spans.add(Span.styled(" ", Style.EMPTY.reversed())); + } + } + } + + frame.renderWidget(Paragraph.from(Line.from(spans)), area); + } + + void renderFooter(List spans) { + MonitorContext.hint(spans, "F8", "close"); + MonitorContext.hint(spans, "Shift+F8", panelPercent() + "%"); + MonitorContext.hint(spans, "PgUp/Dn", "scroll"); + if (!thinking.get()) { + MonitorContext.hint(spans, "Enter", "send"); + } else { + MonitorContext.hint(spans, "Ctrl+C", "cancel"); + } + } + +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index 2fac10ed617a8..1be0463682390 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -185,6 +185,7 @@ public class CamelMonitor extends CamelCommand { private final DrawOverlay drawOverlay = new DrawOverlay(); private final HelpOverlay helpOverlay = new HelpOverlay(); private final ShellPanel shellPanel = new ShellPanel(); + private final AiPanel aiPanel = new AiPanel(); private final ActionsPopup actionsPopup = new ActionsPopup( () -> data.get().stream() @@ -280,6 +281,7 @@ public Integer doCall() throws Exception { actionsPopup.setContext(ctx); actionsPopup.setResetStatsAction(this::resetStats); shellPanel.setContext(ctx); + aiPanel.setContext(ctx); actionsPopup.setOpenShellAction(shellPanel::open); actionsPopup.setBrowseFilesAction(this::openFilesPopup); logTab = new LogTab(ctx); @@ -347,6 +349,7 @@ public Integer doCall() throws Exception { this::render); } finally { shellPanel.destroy(); + aiPanel.destroy(); if (mcpServer != null) { mcpServer.stop(); } @@ -406,6 +409,13 @@ private boolean handleEvent(Event event, TuiRunner runner) { } return shellPanel.handleKeyEvent(ke); } + if (aiPanel.isOpen()) { + if (ke.isKey(KeyCode.F8) && ke.hasShift()) { + aiPanel.cycleHeight(); + return true; + } + return aiPanel.handleKeyEvent(ke); + } if (actionsPopup.isVisible()) { return actionsPopup.handleKeyEvent(ke); } @@ -641,10 +651,24 @@ private boolean handleGlobalKeys(KeyEvent ke, TuiRunner runner) { if (shellPanel.isOpen()) { shellPanel.close(); } else { + if (aiPanel.isOpen()) { + aiPanel.close(); + } shellPanel.open(); } return true; } + if (ke.isKey(KeyCode.F8) && mcp) { + if (aiPanel.isOpen()) { + aiPanel.close(); + } else { + if (shellPanel.isOpen()) { + shellPanel.close(); + } + aiPanel.open(); + } + return true; + } if (ke.isKey(KeyCode.F2)) { if (tabsState.selected() == TAB_ROUTES && routesTab != null) { actionsPopup.setPreSelectedRouteId(routesTab.selectedRouteId()); @@ -1002,7 +1026,8 @@ private void render(Frame frame) { renderHeader(frame, mainChunks.get(0)); renderTabs(frame, mainChunks.get(1)); Rect contentArea = mainChunks.get(2); - ctx.shellPercent = shellPanel.isOpen() ? shellPanel.panelPercent() : 0; + ctx.shellPercent = shellPanel.isOpen() ? shellPanel.panelPercent() + : aiPanel.isOpen() ? aiPanel.panelPercent() : 0; if (shellPanel.isOpen()) { List splitChunks = Layout.vertical() .constraints(Constraint.percentage(100 - shellPanel.panelPercent()), @@ -1010,6 +1035,13 @@ private void render(Frame frame) { .split(contentArea); renderContent(frame, splitChunks.get(0)); shellPanel.render(frame, splitChunks.get(1)); + } else if (aiPanel.isOpen()) { + List splitChunks = Layout.vertical() + .constraints(Constraint.percentage(100 - aiPanel.panelPercent()), + Constraint.percentage(aiPanel.panelPercent())) + .split(contentArea); + renderContent(frame, splitChunks.get(0)); + aiPanel.render(frame, splitChunks.get(1)); } else { renderContent(frame, contentArea); } @@ -1763,6 +1795,8 @@ private void renderFooter(Frame frame, Rect area) { hint(spans, "Esc", "close"); } else if (shellPanel.isOpen()) { shellPanel.renderFooter(spans); + } else if (aiPanel.isOpen()) { + aiPanel.renderFooter(spans); } else { MonitorTab tab = activeTab(); @@ -1857,6 +1891,9 @@ private int insertFKeyHints(List spans) { hint(fKeySpans, "F3", "switch"); } hint(fKeySpans, "F6", "shell"); + if (mcp) { + hint(fKeySpans, "F8", "AI"); + } spans.addAll(insertPos, fKeySpans); // Return total F-key span count. The footer drop loop uses this to remove pairs from // the tail (F6, then F3, F2), stopping before the first pair (F1 help when present). From 0afb7bdc8f4d88519c1056f5308f3f95a3da6bea Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Mon, 29 Jun 2026 15:43:30 +0200 Subject: [PATCH 2/7] CAMEL-23855: camel-jbang - AI panel improvements and AI Log popup - Add markdown rendering with hard breaks for LLM responses - Add auto-scroll to bottom on new question/response - Add scrollbar when conversation overflows - Add dimmed placeholder text - Add AI Log popup (Actions menu) showing tool calls, args, results Co-Authored-By: Claude Signed-off-by: Claus Ibsen --- .../jbang/core/commands/tui/ActionsPopup.java | 33 ++- .../jbang/core/commands/tui/AiLogPopup.java | 222 ++++++++++++++++++ .../dsl/jbang/core/commands/tui/AiPanel.java | 120 +++++++++- .../jbang/core/commands/tui/CamelMonitor.java | 1 + 4 files changed, 365 insertions(+), 11 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopup.java diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java index 6f57ad1ec3892..d90a0efa92eb8 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java @@ -87,11 +87,12 @@ enum Action { SETUP_AI, MCP_INFO, MCP_LOG, + AI_LOG, SHELL } private static final int[] GROUP_SIZES = { 5, 4, 5 }; - private static final int MCP_GROUP_SIZE = 3; + private static final int MCP_GROUP_SIZE = 4; private static final int SHELL_GROUP_SIZE = 1; private final Supplier> runningNames; @@ -148,6 +149,7 @@ enum Action { private String selectedFolder; private final McpLogPopup mcpLogPopup = new McpLogPopup(); + private final AiLogPopup aiLogPopup = new AiLogPopup(); private final DoctorPopup doctorPopup = new DoctorPopup(); private final SendMessagePopup sendMessagePopup = new SendMessagePopup(); @@ -218,6 +220,10 @@ void setMcpEnabled( mcpLogPopup.setActivityLog(activityLog); } + void setAiActivityLog(Supplier> activityLog) { + aiLogPopup.setActivityLog(activityLog); + } + private int visualActionCount() { int total = 0; for (int gs : GROUP_SIZES) { @@ -273,7 +279,7 @@ private List buildVisualActionList() { Action.SHOW_KEYSTROKES)); if (mcpEnabled) { flat.add(null); - flat.addAll(List.of(Action.SETUP_AI, Action.MCP_INFO, Action.MCP_LOG)); + flat.addAll(List.of(Action.SETUP_AI, Action.MCP_INFO, Action.MCP_LOG, Action.AI_LOG)); } flat.add(null); flat.add(Action.SHELL); @@ -298,7 +304,7 @@ boolean isVisible() { return showActionsMenu || showExampleBrowser || showFolderInput || runOptionsForm.isVisible() || showDocPicker || showDocViewer || showInfraBrowser || showInfraPortDialog - || mcpLogPopup.isVisible() || doctorPopup.isVisible() + || mcpLogPopup.isVisible() || aiLogPopup.isVisible() || doctorPopup.isVisible() || sendMessagePopup.isVisible() || stopAllPopup.isVisible() || captionOverlay.isInlineMode(); } @@ -366,6 +372,7 @@ List getActionLabels() { labels.add("Setup AI..."); labels.add("MCP Info"); labels.add("MCP Log"); + labels.add("AI Log"); } labels.add("───"); labels.add("Shell"); @@ -387,6 +394,7 @@ void close() { showInfraBrowser = false; showInfraPortDialog = false; mcpLogPopup.close(); + aiLogPopup.close(); doctorPopup.close(); sendMessagePopup.close(); stopAllPopup.close(); @@ -419,6 +427,9 @@ boolean handleKeyEvent(KeyEvent ke) { if (mcpLogPopup.handleKeyEvent(ke)) { return true; } + if (aiLogPopup.handleKeyEvent(ke)) { + return true; + } if (showDocViewer) { if (ke.isCancel()) { showDocViewer = false; @@ -608,6 +619,9 @@ boolean handleKeyEvent(KeyEvent ke) { } else if (action == Action.MCP_LOG) { showActionsMenu = false; openMcpLog(); + } else if (action == Action.AI_LOG) { + showActionsMenu = false; + openAiLog(); } else if (action == Action.SEND_MESSAGE) { showActionsMenu = false; openSendMessage(); @@ -664,6 +678,9 @@ void render(Frame frame, Rect area) { if (mcpLogPopup.isVisible()) { mcpLogPopup.render(frame, area); } + if (aiLogPopup.isVisible()) { + aiLogPopup.render(frame, area); + } if (doctorPopup.isVisible()) { doctorPopup.render(frame, area); } @@ -695,6 +712,10 @@ void renderFooter(List spans) { doctorPopup.renderFooter(spans); return; } + if (aiLogPopup.isVisible()) { + aiLogPopup.renderFooter(spans); + return; + } if (mcpLogPopup.isVisible()) { mcpLogPopup.renderFooter(spans); return; @@ -809,6 +830,7 @@ private void renderActionsMenu(Frame frame, Rect area) { items.add(ListItem.from(" 🧠 Setup AI...")); items.add(ListItem.from(" 🤖 MCP Info")); items.add(ListItem.from(" 📋 MCP Log")); + items.add(ListItem.from(" 💬 AI Log")); } // Group 5: Shell items.add(ListItem.from(divider).style(Style.EMPTY.dim())); @@ -1245,6 +1267,10 @@ private void openMcpLog() { mcpLogPopup.open(); } + private void openAiLog() { + aiLogPopup.open(); + } + // ---- Folder Input ---- private void openFolderInput() { @@ -2171,6 +2197,7 @@ boolean executeActionByName(String name) { case SETUP_AI -> openSetupAI(); case MCP_INFO -> openMcpInfo(); case MCP_LOG -> openMcpLog(); + case AI_LOG -> openAiLog(); default -> { return false; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopup.java new file mode 100644 index 0000000000000..147c75a51a699 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopup.java @@ -0,0 +1,222 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import dev.tamboui.layout.Rect; +import dev.tamboui.style.Color; +import dev.tamboui.style.Style; +import dev.tamboui.terminal.Frame; +import dev.tamboui.text.Line; +import dev.tamboui.text.Span; +import dev.tamboui.text.Text; +import dev.tamboui.tui.event.KeyCode; +import dev.tamboui.tui.event.KeyEvent; +import dev.tamboui.widgets.Clear; +import dev.tamboui.widgets.block.Block; +import dev.tamboui.widgets.block.BorderType; +import dev.tamboui.widgets.block.Borders; +import dev.tamboui.widgets.block.Title; +import dev.tamboui.widgets.list.ListItem; +import dev.tamboui.widgets.list.ListState; +import dev.tamboui.widgets.list.ListWidget; +import dev.tamboui.widgets.list.ScrollMode; +import dev.tamboui.widgets.paragraph.Paragraph; +import org.apache.camel.util.json.Jsoner; + +import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hint; +import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hintLast; + +class AiLogPopup { + + private boolean visible; + private Supplier> activityLog; + private List entries; + private int selected; + private int detailScroll; + + void setActivityLog(Supplier> activityLog) { + this.activityLog = activityLog; + } + + boolean isVisible() { + return visible; + } + + void open() { + entries = activityLog != null ? activityLog.get() : List.of(); + selected = entries.isEmpty() ? 0 : entries.size() - 1; + detailScroll = 0; + visible = true; + } + + void close() { + visible = false; + } + + boolean handleKeyEvent(KeyEvent ke) { + if (!visible) { + return false; + } + if (ke.isCancel()) { + visible = false; + } else if (ke.isUp() || ke.isChar('k')) { + if (entries != null && !entries.isEmpty()) { + selected = Math.max(0, selected - 1); + detailScroll = 0; + } + } else if (ke.isDown() || ke.isChar('j')) { + if (entries != null && !entries.isEmpty()) { + selected = Math.min(entries.size() - 1, selected + 1); + detailScroll = 0; + } + } else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) { + detailScroll = Math.max(0, detailScroll - 5); + } else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) { + detailScroll += 5; + } + return true; + } + + void render(Frame frame, Rect area) { + Rect popup = new Rect(area.left() + 2, area.top() + 1, area.width() - 4, area.height() - 2); + frame.renderWidget(Clear.INSTANCE, popup); + + if (entries == null || entries.isEmpty()) { + Block block = Block.builder() + .borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" AI Log ") + .titleBottom(Title.from(Line.from( + Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" back ")))) + .build(); + frame.renderWidget(block, popup); + Rect inner = block.inner(popup); + frame.renderWidget(Paragraph.from(Line.from( + Span.styled("No AI activity yet. Open the AI panel (F8) and ask a question.", Style.EMPTY.dim()))), + inner); + return; + } + + int splitY = popup.top() + Math.max(3, (popup.height() * 2) / 5); + Rect masterArea = new Rect(popup.left(), popup.top(), popup.width(), splitY - popup.top()); + Rect detailArea = new Rect(popup.left(), splitY, popup.width(), popup.bottom() - splitY); + + renderMaster(frame, masterArea); + renderDetail(frame, detailArea); + } + + void renderFooter(List spans) { + hint(spans, "↑↓", "select"); + hint(spans, "PgUp/Dn", "scroll detail"); + hintLast(spans, "Esc", "back"); + } + + private void renderMaster(Frame frame, Rect area) { + List items = new ArrayList<>(); + for (AiPanel.LogEntry entry : entries) { + Style levelStyle = switch (entry.level()) { + case QUESTION -> Style.EMPTY.fg(Color.CYAN); + case TOOL -> Style.EMPTY.fg(Color.YELLOW); + case RESULT -> Style.EMPTY.fg(Color.GREEN); + case RESPONSE -> Style.EMPTY.fg(Color.MAGENTA); + case ERROR -> Style.EMPTY.fg(Color.LIGHT_RED); + }; + String levelTag = switch (entry.level()) { + case QUESTION -> " ASK "; + case TOOL -> " TOOL "; + case RESULT -> " RESULT "; + case RESPONSE -> " RESPONSE "; + case ERROR -> " ERROR "; + }; + items.add(ListItem.from(Line.from( + Span.styled(entry.timestamp(), Style.EMPTY.dim()), + Span.styled(levelTag, levelStyle), + Span.raw(entry.message())))); + } + + ListState masterState = new ListState(); + masterState.select(selected); + ListWidget list = ListWidget.builder() + .items(items.toArray(ListItem[]::new)) + .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue()) + .highlightSymbol("▸ ") + .scrollMode(ScrollMode.AUTO_SCROLL) + .block(Block.builder() + .borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" AI Log ") + .build()) + .build(); + frame.renderStatefulWidget(list, area, masterState); + } + + private void renderDetail(Frame frame, Rect area) { + AiPanel.LogEntry entry = entries.get(selected); + List lines = new ArrayList<>(); + + String detail = entry.detail(); + if (detail != null && !detail.isBlank()) { + if (entry.level() == AiPanel.LogLevel.TOOL || entry.level() == AiPanel.LogLevel.RESULT) { + lines.add(Line.from(Span.styled( + entry.level() == AiPanel.LogLevel.TOOL ? "▶ Arguments" : "◀ Result", + Style.EMPTY.fg(entry.level() == AiPanel.LogLevel.TOOL ? Color.YELLOW : Color.GREEN).bold()))); + addJsonLines(lines, detail); + } else { + lines.add(Line.from(Span.styled("▶ Content", + Style.EMPTY.fg(Color.CYAN).bold()))); + for (String line : detail.split("\n", -1)) { + lines.add(Line.from(Span.styled(" " + line, Style.EMPTY.dim()))); + } + } + } else { + lines.add(Line.from(Span.styled("(no detail data)", Style.EMPTY.dim()))); + } + + Block detailBlock = Block.builder() + .borderType(BorderType.ROUNDED).borders(Borders.ALL) + .title(" Detail ") + .build(); + frame.renderWidget(detailBlock, area); + Rect inner = detailBlock.inner(area); + + int visibleLines = inner.height(); + int totalLines = lines.size(); + int clampedScroll = Math.min(detailScroll, Math.max(0, totalLines - visibleLines)); + int end = Math.min(clampedScroll + visibleLines, totalLines); + if (clampedScroll < end) { + List visible = lines.subList(clampedScroll, end); + frame.renderWidget( + Paragraph.builder().text(Text.from(visible.toArray(Line[]::new))).build(), + inner); + } + } + + private static void addJsonLines(List lines, String json) { + try { + String pretty = Jsoner.prettyPrint(json, 2); + for (String line : pretty.split("\n", -1)) { + lines.add(Line.from(Span.styled(" " + line, Style.EMPTY.dim()))); + } + } catch (Exception e) { + for (String line : json.split("\n", -1)) { + lines.add(Line.from(Span.styled(" " + line, Style.EMPTY.dim()))); + } + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java index 52d2257bd6762..cb38d8dd4cf30 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java @@ -16,6 +16,9 @@ */ package org.apache.camel.dsl.jbang.core.commands.tui; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; @@ -47,6 +50,20 @@ class AiPanel { private static final int[] SPLIT_PERCENTS = { 25, 50, 75, 100 }; private static final int MAX_ITERATIONS = 10; + private static final int MAX_LOG_ENTRIES = 200; + private static final DateTimeFormatter TIME_FMT + = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault()); + + enum LogLevel { + QUESTION, + TOOL, + RESULT, + RESPONSE, + ERROR + } + + record LogEntry(String timestamp, LogLevel level, String message, String detail) { + } private boolean visible; private int splitIndex = 1; // default 50% @@ -69,6 +86,9 @@ class AiPanel { private volatile Thread agentThread; private String initError; + // Activity log for AI Log popup + private final List activityLog = new ArrayList<>(); + record ConversationEntry(String role, String text) { } @@ -76,6 +96,17 @@ void setContext(MonitorContext ctx) { this.ctx = ctx; } + synchronized List getActivityLog() { + return new ArrayList<>(activityLog); + } + + private synchronized void log(LogLevel level, String message, String detail) { + activityLog.add(new LogEntry(TIME_FMT.format(Instant.now()), level, message, detail)); + if (activityLog.size() > MAX_LOG_ENTRIES) { + activityLog.remove(0); + } + } + boolean isOpen() { return visible; } @@ -209,6 +240,7 @@ private void submitQuestion() { scrollOffset = 0; conversation.add(new ConversationEntry("user", question)); + log(LogLevel.QUESTION, "Question", question); thinking.set(true); // rebuild tools if target process changed @@ -247,7 +279,9 @@ private void runAgentLoop(String systemPrompt, String question) throws Interrupt LlmClient.ChatResponse response = client.chatWithTools(systemPrompt, messages, tools); if (response == null) { - conversation.add(new ConversationEntry("error", "No response from LLM")); + String err = "No response from LLM"; + conversation.add(new ConversationEntry("error", err)); + log(LogLevel.ERROR, "Error", err); return; } @@ -255,7 +289,9 @@ private void runAgentLoop(String systemPrompt, String question) throws Interrupt if ("error".equals(response.stopReason()) && (response.toolCalls() == null || response.toolCalls().isEmpty()) && response.text() == null) { - conversation.add(new ConversationEntry("error", "LLM request failed. Check API key and endpoint.")); + String err = "LLM request failed. Check API key and endpoint."; + conversation.add(new ConversationEntry("error", err)); + log(LogLevel.ERROR, "Error", err); return; } @@ -267,7 +303,9 @@ private void runAgentLoop(String systemPrompt, String question) throws Interrupt if (Thread.interrupted()) { throw new InterruptedException(); } + log(LogLevel.TOOL, toolCall.name(), toolCall.arguments().toJson()); String result = askTools.executeTool(toolCall.name(), toolCall.arguments()); + log(LogLevel.RESULT, toolCall.name(), result); results.add(new LlmClient.ToolResult(toolCall.id(), result)); } messages.add(LlmClient.Message.toolResults(results)); @@ -275,9 +313,13 @@ private void runAgentLoop(String systemPrompt, String question) throws Interrupt String text = response.text(); if (text != null && !text.isBlank()) { conversation.add(new ConversationEntry("assistant", text)); + log(LogLevel.RESPONSE, "Response", text); } else { - conversation.add(new ConversationEntry("error", "Empty response from LLM.")); + String err = "Empty response from LLM."; + conversation.add(new ConversationEntry("error", err)); + log(LogLevel.ERROR, "Error", err); } + scrollOffset = 0; messages.add(LlmClient.Message.assistantWithToolCalls(text, List.of())); return; } @@ -326,13 +368,16 @@ private void renderConversation(Frame frame, Rect area) { if (initError != null) { md.append("**Error:** ").append(initError).append("\n\n"); } else if (conversation.isEmpty() && !thinking.get()) { - md.append("*Ask a question about your Camel application...*\n"); + frame.renderWidget( + Paragraph.from(Line.from(Span.styled("Ask a question about your Camel application...", Style.EMPTY.dim()))), + area); + return; } for (ConversationEntry entry : conversation) { switch (entry.role()) { case "user" -> md.append("**You:** ").append(entry.text()).append("\n\n"); - case "assistant" -> md.append(entry.text()).append("\n\n"); + case "assistant" -> md.append(toHardBreaks(entry.text())).append("\n\n"); case "error" -> md.append("**Error:** ").append(entry.text()).append("\n\n"); case "system" -> md.append("*").append(entry.text()).append("*\n\n"); default -> { @@ -345,11 +390,44 @@ private void renderConversation(Frame frame, Rect area) { md.append("*🤔 thinking").append(".".repeat((int) dots + 1)).append("*\n"); } + String source = md.toString(); + + // Estimate total rendered lines (accounting for word wrap) + int contentWidth = Math.max(1, area.width()); + int estimatedLines = 0; + for (String l : source.split("\n", -1)) { + estimatedLines += Math.max(1, (l.length() / contentWidth) + 1); + } + + boolean overflow = estimatedLines > area.height(); + Rect contentArea = area; + Rect scrollbarArea = null; + if (overflow) { + List hParts = Layout.horizontal() + .constraints(Constraint.fill(), Constraint.length(1)) + .split(area); + contentArea = hParts.get(0); + scrollbarArea = hParts.get(1); + } + + // scrollOffset=0 means auto-scroll to bottom (most recent content visible) + // scrollOffset>0 means user scrolled up by that many lines + int scroll; + if (scrollOffset == 0) { + scroll = estimatedLines; + } else { + scroll = Math.max(0, estimatedLines - contentArea.height() - scrollOffset); + } + MarkdownView view = MarkdownView.builder() - .source(md.toString()) - .scroll(scrollOffset) + .source(source) + .scroll(scroll) .build(); - frame.renderWidget(view, area); + frame.renderWidget(view, contentArea); + + if (overflow && scrollbarArea != null) { + renderScrollbar(frame, scrollbarArea, estimatedLines, contentArea.height(), scroll); + } } private void renderInput(Frame frame, Rect area) { @@ -403,4 +481,30 @@ void renderFooter(List spans) { } } + private void renderScrollbar(Frame frame, Rect area, int totalLines, int visibleHeight, int scroll) { + int thumbSize = Math.max(1, visibleHeight * visibleHeight / Math.max(1, totalLines)); + int maxScroll = Math.max(1, totalLines - visibleHeight); + int thumbPos = (int) ((long) Math.min(scroll, maxScroll) * (visibleHeight - thumbSize) / maxScroll); + + List lines = new ArrayList<>(); + for (int i = 0; i < area.height(); i++) { + if (i >= thumbPos && i < thumbPos + thumbSize) { + lines.add(Line.from(Span.styled("▐", Style.EMPTY.fg(Color.CYAN)))); + } else { + lines.add(Line.from(Span.styled("│", Style.EMPTY.dim()))); + } + } + frame.renderWidget(Paragraph.from(new dev.tamboui.text.Text(lines, dev.tamboui.layout.Alignment.LEFT)), area); + } + + private static String toHardBreaks(String text) { + if (text == null) { + return ""; + } + // Convert single newlines to markdown hard breaks (two trailing spaces + newline) + // so the LLM's line-by-line formatting is preserved in MarkdownView. + // Double newlines (paragraph breaks) are left as-is. + return text.replaceAll("(? Date: Mon, 29 Jun 2026 17:52:47 +0200 Subject: [PATCH 3/7] CAMEL-23855: camel-jbang - AI panel scroll fix and dimmed elapsed time Co-Authored-By: Claude Signed-off-by: Claus Ibsen --- .../dsl/jbang/core/commands/tui/AiPanel.java | 64 +++++++++++++++---- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java index cb38d8dd4cf30..e083564269f65 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java @@ -85,11 +85,15 @@ record LogEntry(String timestamp, LogLevel level, String message, String detail) private final AtomicBoolean thinking = new AtomicBoolean(); private volatile Thread agentThread; private String initError; + private long thinkingStartTime; // Activity log for AI Log popup private final List activityLog = new ArrayList<>(); - record ConversationEntry(String role, String text) { + record ConversationEntry(String role, String text, long elapsedSeconds) { + ConversationEntry(String role, String text) { + this(role, text, -1); + } } void setContext(MonitorContext ctx) { @@ -241,6 +245,7 @@ private void submitQuestion() { conversation.add(new ConversationEntry("user", question)); log(LogLevel.QUESTION, "Question", question); + thinkingStartTime = System.currentTimeMillis(); thinking.set(true); // rebuild tools if target process changed @@ -312,8 +317,9 @@ private void runAgentLoop(String systemPrompt, String question) throws Interrupt } else { String text = response.text(); if (text != null && !text.isBlank()) { - conversation.add(new ConversationEntry("assistant", text)); - log(LogLevel.RESPONSE, "Response", text); + long elapsed = (System.currentTimeMillis() - thinkingStartTime) / 1000; + conversation.add(new ConversationEntry("assistant", text, elapsed)); + log(LogLevel.RESPONSE, "Response (" + elapsed + "s)", text); } else { String err = "Empty response from LLM."; conversation.add(new ConversationEntry("error", err)); @@ -386,38 +392,62 @@ private void renderConversation(Frame frame, Rect area) { } if (thinking.get()) { + long elapsed = (System.currentTimeMillis() - thinkingStartTime) / 1000; long dots = (System.currentTimeMillis() / 500) % 4; - md.append("*🤔 thinking").append(".".repeat((int) dots + 1)).append("*\n"); + md.append("*🤔 thinking"); + if (elapsed > 0) { + md.append(" (").append(elapsed).append("s)"); + } + md.append(".".repeat((int) dots + 1)).append("*\n"); + } + + // Show elapsed time as a dimmed line below the markdown when at the bottom + long lastElapsed = -1; + if (!thinking.get() && !conversation.isEmpty()) { + ConversationEntry last = conversation.get(conversation.size() - 1); + if ("assistant".equals(last.role()) && last.elapsedSeconds() >= 0) { + lastElapsed = last.elapsedSeconds(); + } + } + + // Reserve 1 row for dimmed elapsed time when we have one to show + Rect mdArea = area; + Rect elapsedArea = null; + if (lastElapsed >= 0 && area.height() > 2) { + List vParts = Layout.vertical() + .constraints(Constraint.fill(), Constraint.length(1)) + .split(area); + mdArea = vParts.get(0); + elapsedArea = vParts.get(1); } String source = md.toString(); // Estimate total rendered lines (accounting for word wrap) - int contentWidth = Math.max(1, area.width()); + int contentWidth = Math.max(1, mdArea.width()); int estimatedLines = 0; for (String l : source.split("\n", -1)) { estimatedLines += Math.max(1, (l.length() / contentWidth) + 1); } - boolean overflow = estimatedLines > area.height(); - Rect contentArea = area; + boolean overflow = estimatedLines > mdArea.height(); + Rect contentArea = mdArea; Rect scrollbarArea = null; if (overflow) { List hParts = Layout.horizontal() .constraints(Constraint.fill(), Constraint.length(1)) - .split(area); + .split(mdArea); contentArea = hParts.get(0); scrollbarArea = hParts.get(1); } // scrollOffset=0 means auto-scroll to bottom (most recent content visible) // scrollOffset>0 means user scrolled up by that many lines - int scroll; - if (scrollOffset == 0) { - scroll = estimatedLines; - } else { - scroll = Math.max(0, estimatedLines - contentArea.height() - scrollOffset); - } + // Clamp so PgDn always has immediate effect after scrolling past the top + int maxScrollOffset = Math.max(0, estimatedLines - contentArea.height()); + scrollOffset = Math.min(scrollOffset, maxScrollOffset); + + int scroll = Math.max(0, maxScrollOffset - scrollOffset); MarkdownView view = MarkdownView.builder() .source(source) @@ -428,6 +458,12 @@ private void renderConversation(Frame frame, Rect area) { if (overflow && scrollbarArea != null) { renderScrollbar(frame, scrollbarArea, estimatedLines, contentArea.height(), scroll); } + + if (elapsedArea != null && lastElapsed >= 0) { + frame.renderWidget( + Paragraph.from(Line.from(Span.styled("(" + lastElapsed + "s)", Style.EMPTY.dim()))), + elapsedArea); + } } private void renderInput(Frame frame, Rect area) { From db0691c193983ab124178f6fc1df2c0d1f3fd58d Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Mon, 29 Jun 2026 18:36:09 +0200 Subject: [PATCH 4/7] CAMEL-23855: camel-jbang - AI panel space optimizations Co-Authored-By: Claude Signed-off-by: Claus Ibsen --- .../dsl/jbang/core/commands/tui/AiPanel.java | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java index e083564269f65..62cb481588075 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java @@ -119,6 +119,14 @@ int panelPercent() { return SPLIT_PERCENTS[splitIndex]; } + private long lastResponseElapsed() { + if (thinking.get() || conversation.isEmpty()) { + return -1; + } + ConversationEntry last = conversation.get(conversation.size() - 1); + return "assistant".equals(last.role()) ? last.elapsedSeconds() : -1; + } + void cycleHeight() { splitIndex = (splitIndex + 1) % SPLIT_PERCENTS.length; } @@ -336,10 +344,21 @@ private void runAgentLoop(String systemPrompt, String question) throws Interrupt } void render(Frame frame, Rect area) { + // At 25% show elapsed in the title bar to save space + long titleElapsed = lastResponseElapsed(); + Line titleLine; + if (splitIndex == 0 && titleElapsed >= 0) { + titleLine = Line.from( + Span.styled(" AI ", Style.EMPTY.bold()), + Span.styled("(" + titleElapsed + "s) ", Style.EMPTY.dim())); + } else { + titleLine = Line.from(Span.styled(" AI ", Style.EMPTY.bold())); + } + Block block = Block.builder() .borders(Borders.ALL) .borderType(BorderType.ROUNDED) - .title(Title.from(Line.from(Span.styled(" AI ", Style.EMPTY.bold())))) + .title(Title.from(titleLine)) .build(); frame.renderWidget(block, area); Rect inner = block.inner(area); @@ -347,14 +366,13 @@ void render(Frame frame, Rect area) { return; } - // Split inner area: conversation (fill) + separator (1 row) + input (1 row) + padding (1 row) + // Split inner area: conversation (fill) + separator (1 row) + input (1 row) List parts = Layout.vertical() - .constraints(Constraint.fill(), Constraint.length(1), Constraint.length(1), Constraint.length(1)) + .constraints(Constraint.fill(), Constraint.length(1), Constraint.length(1)) .split(inner); Rect conversationArea = parts.get(0); Rect separatorArea = parts.get(1); Rect inputArea = parts.get(2); - // parts.get(3) is empty padding row above the bottom border renderConversation(frame, conversationArea); // horizontal line separator @@ -410,10 +428,10 @@ private void renderConversation(Frame frame, Rect area) { } } - // Reserve 1 row for dimmed elapsed time when we have one to show + // Reserve 1 row for dimmed elapsed time (skip at 25% — shown in title bar instead) Rect mdArea = area; Rect elapsedArea = null; - if (lastElapsed >= 0 && area.height() > 2) { + if (lastElapsed >= 0 && splitIndex > 0 && area.height() > 2) { List vParts = Layout.vertical() .constraints(Constraint.fill(), Constraint.length(1)) .split(area); From 92b4f3c20c1ef332d369a0e4f48934a250724d05 Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Mon, 29 Jun 2026 18:45:44 +0200 Subject: [PATCH 5/7] CAMEL-23855: camel-jbang - Add missing MCP tools to AI assistant Co-Authored-By: Claude Signed-off-by: Claus Ibsen --- .../dsl/jbang/core/commands/AskTools.java | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java index 50c196a83a924..05a89ddddbe40 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java @@ -112,6 +112,26 @@ public List buildToolDefinitions() { "get_properties", "Show configuration properties of the running Camel application.", emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_memory", + "Show JVM memory usage (heap/non-heap), garbage collection stats, and thread counts.", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_errors", + "Get captured routing errors from the running Camel application. Returns error details including exception, exchange context, and route information.", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_history", + "Get the message history trace of the last completed exchange. Shows the route path, processors visited, headers, body, and timing.", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_variables", + "Show exchange variables in the Camel context.", + emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_services", + "Show services registered in the Camel service registry.", + emptyParams())); tools.add(new LlmClient.ToolDef( "get_route_source", @@ -133,12 +153,40 @@ public List buildToolDefinitions() { "get_top_processors", "Show top processor statistics: which processors are slowest and most active.", emptyParams())); + tools.add(new LlmClient.ToolDef( + "get_route_topology", + "Get the inter-route topology showing how routes connect to each other and to external endpoints.", + emptyParams())); tools.add(new LlmClient.ToolDef( "trace_control", "Enable, disable, or dump message tracing.", objectParams(Map.of( "action", stringProp("Action: enable, disable, or dump"))))); + tools.add(new LlmClient.ToolDef( + "send_message", + "Send a test message to a Camel endpoint in the running application.", + objectParams(Map.of( + "endpoint", stringProp("Endpoint URI to send to (e.g., direct:myRoute, seda:queue)"), + "body", stringProp("Message body to send"), + "headers", stringProp("Message headers as key=value pairs separated by newlines"))))); + tools.add(new LlmClient.ToolDef( + "eval_expression", + "Evaluate a Camel expression in the given language (e.g., simple, jsonpath, xpath) against the running context.", + objectParams(Map.of( + "language", stringProp("Expression language (e.g., simple, jsonpath, xpath, jq)"), + "expression", stringProp("Expression to evaluate"))))); + tools.add(new LlmClient.ToolDef( + "browse_endpoint", + "Browse messages in a Camel endpoint (e.g., browse messages queued in a SEDA endpoint).", + objectParams(Map.of( + "endpoint", stringProp("Endpoint URI to browse (e.g., seda:queue)"), + "limit", stringProp("Maximum number of messages to return (default: 50)"))))); + tools.add(new LlmClient.ToolDef( + "get_thread_dump", + "Get a JVM thread dump showing thread names, states, and stack traces.", + emptyParams())); + tools.add(new LlmClient.ToolDef( "stop_route", "Gracefully stop a route. The route will finish processing in-flight exchanges before stopping.", @@ -255,12 +303,30 @@ public String executeTool(String name, JsonObject args) { targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "consumers"); case "get_properties" -> targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "properties"); + case "get_memory" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "memory"); + case "get_errors" -> targetPid < 0 ? NO_PROCESS : executeGetErrors(); + case "get_history" -> targetPid < 0 ? NO_PROCESS : executeGetHistory(); + case "get_variables" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "variables"); + case "get_services" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "services"); case "get_route_source" -> targetPid < 0 ? NO_PROCESS : executeRouteSource(args); case "get_route_dump" -> targetPid < 0 ? NO_PROCESS : executeRouteDump(args); case "get_route_structure" -> targetPid < 0 ? NO_PROCESS : executeRouteStructure(args); case "get_top_processors" -> targetPid < 0 ? NO_PROCESS : RuntimeHelper.executeAction(targetPid, "top-processors", null); + case "get_route_topology" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.executeAction(targetPid, "route-topology", root -> { + root.put("metric", "true"); + root.put("external", "true"); + }); case "trace_control" -> targetPid < 0 ? NO_PROCESS : executeTraceControl(args); + case "send_message" -> targetPid < 0 ? NO_PROCESS : executeSendMessage(args); + case "eval_expression" -> targetPid < 0 ? NO_PROCESS : executeEvalExpression(args); + case "browse_endpoint" -> targetPid < 0 ? NO_PROCESS : executeBrowseEndpoint(args); + case "get_thread_dump" -> + targetPid < 0 ? NO_PROCESS : RuntimeHelper.executeAction(targetPid, "thread-dump", null); case "stop_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "stop"); case "start_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "start"); case "suspend_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "suspend"); @@ -414,6 +480,70 @@ private String executeRouteCommand(JsonObject args, String command) { }); } + private String executeGetErrors() { + JsonObject errors = RuntimeHelper.readErrorFile(targetPid); + if (errors == null) { + return "No errors captured."; + } + return errors.toJson(); + } + + private String executeGetHistory() { + JsonObject history = RuntimeHelper.readHistoryFile(targetPid); + if (history == null) { + return "No message history available."; + } + return history.toJson(); + } + + private String executeSendMessage(JsonObject args) { + String endpoint = args.getString("endpoint"); + if (endpoint == null || endpoint.isBlank()) { + return "Error: endpoint is required"; + } + String body = args.getString("body"); + String headers = args.getString("headers"); + JsonObject result = RuntimeHelper.sendMessage(targetPid, endpoint, body, headers); + return result.toJson(); + } + + private String executeEvalExpression(JsonObject args) { + String language = args.getString("language"); + String expression = args.getString("expression"); + if (language == null || language.isBlank()) { + return "Error: language is required"; + } + if (expression == null || expression.isBlank()) { + return "Error: expression is required"; + } + return RuntimeHelper.executeAction(targetPid, "eval", root -> { + root.put("language", language); + root.put("predicate", "false"); + root.put("template", Jsoner.escape(expression)); + }); + } + + private String executeBrowseEndpoint(JsonObject args) { + String endpoint = args.getString("endpoint"); + if (endpoint == null || endpoint.isBlank()) { + return "Error: endpoint is required"; + } + String limitStr = args.getString("limit"); + int limit = 50; + if (limitStr != null && !limitStr.isBlank()) { + try { + limit = Integer.parseInt(limitStr); + } catch (NumberFormatException e) { + // use default + } + } + int browseLimit = limit; + return RuntimeHelper.executeAction(targetPid, "browse", root -> { + root.put("filter", endpoint); + root.put("limit", browseLimit); + }); + } + // ---- Catalog tools ---- private String executeCatalogComponents(JsonObject args) { From fbf71a6136e65121ba580db9779f64b38aca0124 Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Mon, 29 Jun 2026 18:59:00 +0200 Subject: [PATCH 6/7] CAMEL-23855: camel-jbang - Show AI panel always and fix shell scrollback Co-Authored-By: Claude Signed-off-by: Claus Ibsen --- .../jbang/core/commands/tui/CamelMonitor.java | 8 +++---- .../jbang/core/commands/tui/ShellPanel.java | 23 ++++++++----------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java index 94b4472a1e7cf..dea72389ce848 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -319,12 +319,12 @@ public Integer doCall() throws Exception { eventLog = new TuiEventLog(500); Path mcpJsonFile = null; + actionsPopup.setAiActivityLog(aiPanel::getActivityLog); if (mcp) { mcpServer = new TuiMcpServer(mcpPort, this); try { mcpServer.start(); actionsPopup.setMcpEnabled(true, mcpPort, mcpServer::getConnectedClient, mcpServer::getActivityLog); - actionsPopup.setAiActivityLog(aiPanel::getActivityLog); mcpJsonFile = writeMcpJson(mcpPort); } catch (java.net.BindException e) { System.err.println("MCP server failed to start: port " + mcpPort + " is already in use."); @@ -659,7 +659,7 @@ private boolean handleGlobalKeys(KeyEvent ke, TuiRunner runner) { } return true; } - if (ke.isKey(KeyCode.F8) && mcp) { + if (ke.isKey(KeyCode.F8)) { if (aiPanel.isOpen()) { aiPanel.close(); } else { @@ -1892,9 +1892,7 @@ private int insertFKeyHints(List spans) { hint(fKeySpans, "F3", "switch"); } hint(fKeySpans, "F6", "shell"); - if (mcp) { - hint(fKeySpans, "F8", "AI"); - } + hint(fKeySpans, "F8", "AI"); spans.addAll(insertPos, fKeySpans); // Return total F-key span count. The footer drop loop uses this to remove pairs from // the tail (F6, then F3, F2), stopping before the first pair (F1 help when present). diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java index fbdee894f0e0c..d8a0f98d47bf7 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java @@ -18,7 +18,7 @@ import java.io.IOException; import java.io.OutputStream; -import java.lang.reflect.Method; +import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayList; @@ -138,13 +138,13 @@ boolean handleKeyEvent(KeyEvent ke) { return true; } - // Shift+PageUp/Down for scrollback through history - if (ke.isKey(KeyCode.PAGE_UP) && ke.hasShift()) { + // PageUp/Down for scrollback through history + if (ke.isKey(KeyCode.PAGE_UP)) { int histSize = screenTerminal != null ? getHistorySize(screenTerminal) : 0; scrollOffset = Math.min(scrollOffset + lastHeight, histSize); return true; } - if (ke.isKey(KeyCode.PAGE_DOWN) && ke.hasShift()) { + if (ke.isKey(KeyCode.PAGE_DOWN)) { scrollOffset = Math.max(0, scrollOffset - lastHeight); return true; } @@ -280,7 +280,7 @@ void render(Frame frame, Rect area) { void renderFooter(List spans) { MonitorContext.hint(spans, "F6", "close"); MonitorContext.hint(spans, "Shift+F6", SPLIT_PERCENTS[splitIndex] + "%"); - MonitorContext.hint(spans, "Shift+PgUp/Dn", "scroll"); + MonitorContext.hint(spans, "PgUp/Dn", "scroll"); } private List renderLiveView(long[] screen, int width, int height) { @@ -619,20 +619,17 @@ static byte[] encodeKeyEvent(KeyEvent ke) { @SuppressWarnings("unchecked") private static List getHistory(ScreenTerminal st) { try { - Method m = ScreenTerminal.class.getMethod("getHistory"); - return (List) m.invoke(st); + Field f = ScreenTerminal.class.getDeclaredField("history"); + f.setAccessible(true); + List history = (List) f.get(st); + return history != null ? history : Collections.emptyList(); } catch (Exception e) { return Collections.emptyList(); } } private static int getHistorySize(ScreenTerminal st) { - try { - Method m = ScreenTerminal.class.getMethod("getHistorySize"); - return (int) m.invoke(st); - } catch (Exception e) { - return 0; - } + return getHistory(st).size(); } private static class DelegateOutputStream extends OutputStream { From 63d51b5b35f71b7e819830e6a5801efd77433016 Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Mon, 29 Jun 2026 19:24:59 +0200 Subject: [PATCH 7/7] CAMEL-23855: camel-jbang - Remove spacer row between History panels Co-Authored-By: Claude Signed-off-by: Claus Ibsen --- .../dsl/jbang/core/commands/tui/HistoryTab.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java index 737449f5d37fb..1b4abc00bf929 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java @@ -1078,7 +1078,7 @@ private void renderTraceExchangeDetail(Frame frame, Rect area) { List steps = getTraceStepsDepthFirst(traceSelectedExchangeId); List chunks = Layout.vertical() - .constraints(Constraint.length(10), Constraint.length(1), Constraint.fill()) + .constraints(Constraint.length(10), Constraint.fill()) .split(area); Map descMap = showDescription ? getRouteDescriptions() : Collections.emptyMap(); @@ -1099,10 +1099,10 @@ private void renderTraceExchangeDetail(Frame frame, Rect area) { if (showWaterfall) { Integer sel = traceStepTableState.selected(); - renderWaterfall(frame, chunks.get(2), steps.stream().map(WaterfallStep::fromTrace).toList(), + renderWaterfall(frame, chunks.get(1), steps.stream().map(WaterfallStep::fromTrace).toList(), sel != null ? sel : -1); } else { - renderTraceStepDetail(frame, chunks.get(2), steps); + renderTraceStepDetail(frame, chunks.get(1), steps); } } @@ -1348,7 +1348,7 @@ private void renderHistory(Frame frame, Rect area) { List current = reorderHistoryDepthFirst(historyEntries); List chunks = Layout.vertical() - .constraints(Constraint.length(10), Constraint.length(1), Constraint.fill()) + .constraints(Constraint.length(10), Constraint.fill()) .split(area); Map descMap = showDescription ? getRouteDescriptions() : Collections.emptyMap(); @@ -1369,10 +1369,10 @@ private void renderHistory(Frame frame, Rect area) { if (showWaterfall) { Integer sel = historyTableState.selected(); - renderWaterfall(frame, chunks.get(2), current.stream().map(WaterfallStep::fromHistory).toList(), + renderWaterfall(frame, chunks.get(1), current.stream().map(WaterfallStep::fromHistory).toList(), sel != null ? sel : -1); } else { - renderHistoryDetail(frame, chunks.get(2), current); + renderHistoryDetail(frame, chunks.get(1), current); } }