This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 2cc56ca89262 CAMEL-23855: Add F8 AI prompt panel to TUI
2cc56ca89262 is described below
commit 2cc56ca892621343acda2c5f18ed90ed088b1995
Author: Claus Ibsen <[email protected]>
AuthorDate: Mon Jun 29 19:56:37 2026 +0200
CAMEL-23855: Add F8 AI prompt panel to TUI
Add F8 AI prompt panel to TUI for interactive AI assistant that works
with or without --mcp. The panel supports split view (25%/50%/75%)
with Shift+F8 to cycle, auto-scroll, dimmed elapsed time display,
and an AI Log popup showing tool calls and results.
Also adds 10 missing MCP tools to AskTools (get_memory, get_errors,
get_history, send_message, eval_expression, browse_endpoint, etc.),
fixes shell scrollback by accessing JLine's private history field,
simplifies scroll to plain PgUp/PgDn, removes spacer rows between
History tab panels, and adds comprehensive TUI test coverage.
Closes #24321
Co-Authored-By: Claude <[email protected]>
---
.../apache/camel/dsl/jbang/core/commands/Ask.java | 836 +--------------------
.../core/commands/{Ask.java => AskTools.java} | 615 ++++++---------
.../camel/dsl/jbang/core/commands/LlmClient.java | 64 +-
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 33 +-
.../dsl/jbang/core/commands/tui/AiLogPopup.java | 222 ++++++
.../camel/dsl/jbang/core/commands/tui/AiPanel.java | 564 ++++++++++++++
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 38 +-
.../dsl/jbang/core/commands/tui/HistoryTab.java | 12 +-
.../dsl/jbang/core/commands/tui/ShellPanel.java | 23 +-
9 files changed, 1133 insertions(+), 1274 deletions(-)
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 9a7e5af16761..f6a3ad5d70ba 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<JsonObject> commandMetadataCache;
+ private AskTools askTools;
public Ask(CamelJBangMain main) {
super(main);
@@ -149,8 +131,9 @@ public class Ask extends CamelCommand {
targetPid = -1;
}
+ askTools = new AskTools(targetPid);
String systemPrompt = buildSystemPrompt(process);
- List<LlmClient.ToolDef> tools = buildToolDefinitions();
+ List<LlmClient.ToolDef> tools = askTools.buildToolDefinitions();
if (question == null || question.isEmpty()) {
return runInteractiveChat(client, process, systemPrompt, tools);
@@ -227,7 +210,7 @@ public class Ask extends CamelCommand {
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 @@ public class Ask extends CamelCommand {
// ---- 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<RuntimeHelper.ProcessInfo> 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<LlmClient.ToolDef> buildToolDefinitions() {
- List<LlmClient.ToolDef> 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<RuntimeHelper.ProcessInfo> processes =
RuntimeHelper.discoverProcesses();
- if (processes.isEmpty()) {
- return "No running Camel processes found. Start one with: camel
run <file>";
- }
- JsonObject response = new JsonObject();
- response.put("count", processes.size());
- List<JsonObject> 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<RuntimeHelper.ProcessInfo> 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<JsonObject> 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<JsonObject> 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<JsonObject> 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<JsonObject> catalog = ExampleHelper.loadCatalog();
- List<JsonObject> filtered = ExampleHelper.filterExamples(catalog,
filter);
-
- List<JsonObject> 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<JsonObject> catalog = ExampleHelper.loadCatalog();
- JsonObject entry = ExampleHelper.findExample(catalog, example);
- if (entry == null) {
- return "Example not found: " + example;
- }
-
- List<String> 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<JsonObject> 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<Object>) 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<JsonObject> commands, List<JsonObject>
result, String filter) {
@@ -879,18 +304,6 @@ public class Ask extends CamelCommand {
}
}
- private String executeCliListCommands(JsonObject args) {
- String filter = args.getString("filter");
- List<JsonObject> commands = loadCommandMetadata();
- List<JsonObject> 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<JsonObject> commands, String fullName) {
for (JsonObject cmd : commands) {
@@ -913,128 +326,6 @@ public class Ask extends CamelCommand {
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<JsonObject> 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<JsonObject> opts = ((Collection<Object>) 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<JsonObject> subSummaries = ((Collection<Object>)
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<String> tokens = new ArrayList<>();
StringBuilder current = new StringBuilder();
@@ -1062,116 +353,7 @@ public class Ask extends CamelCommand {
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<Path> stream = Files.walk(base, 2)) {
- List<String> 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<String, JsonObject> properties)
{
- JsonObject props = new JsonObject();
- Map<String, JsonObject> ordered = new LinkedHashMap<>(properties);
- for (Map.Entry<String, JsonObject> 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/Ask.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
similarity index 70%
copy from
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
copy to
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
index 9a7e5af16761..05a89ddddbe4 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/AskTools.java
@@ -32,7 +32,6 @@ 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;
@@ -40,292 +39,37 @@ 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;
-import org.jline.reader.UserInterruptException;
-import org.jline.terminal.Terminal;
-import org.jline.terminal.TerminalBuilder;
-import picocli.CommandLine.Command;
-import picocli.CommandLine.Option;
-import picocli.CommandLine.Parameters;
/**
- * Ask a question about a running Camel application using AI with tool
calling. The LLM can inspect the live runtime
- * (routes, health, traces, etc.) to provide informed answers.
+ * 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.
*/
-@Command(name = "ask",
- description = "Ask a question about a running Camel application using
AI",
- sortOptions = false, showDefaultValues = true,
- footer = {
- "%nExamples:",
- " camel ask \"what routes are running?\"",
- " camel ask \"why is my route failing?\" --name=myApp",
- " camel ask \"show me the route structure\"
--api-type=anthropic",
- " camel ask \"are there any blocked exchanges?\"
--model=gpt-4",
- " camel ask (interactive
chat)" })
-public class Ask extends CamelCommand {
-
- private static final String DEFAULT_MODEL = "llama3.2";
+public class AskTools {
+
private static final String NO_PROCESS
= "No running Camel process connected. Start one with: camel run
<file>";
- @Parameters(description = "Question to ask (omit for interactive chat
mode)", arity = "0..*")
- List<String> question;
-
- @Option(names = { "--url" },
- description = "LLM API endpoint URL. Auto-detected if not
specified.")
- String url;
-
- @Option(names = { "--api-type" },
- description = "API type: 'ollama', 'openai', or 'anthropic'")
- LlmClient.ApiType apiType;
-
- @Option(names = { "--api-key" },
- description = "API key. Also reads ANTHROPIC_API_KEY,
OPENAI_API_KEY, or LLM_API_KEY env vars")
- String apiKey;
-
- @Option(names = { "--model" },
- description = "Model to use",
- defaultValue = DEFAULT_MODEL)
- String model = DEFAULT_MODEL;
-
- @Option(names = { "--timeout" },
- description = "Timeout in seconds for LLM response",
- defaultValue = "120")
- int timeout = 120;
-
- @Option(names = { "--name" },
- description = "Name or PID of the Camel process. Auto-detected
when exactly one process is running")
- String nameOrPid;
-
- @Option(names = { "--max-iterations" },
- description = "Maximum number of tool-calling rounds",
- defaultValue = "10")
- int maxIterations = 10;
-
- @Option(names = { "--show-tools" },
- description = "Show tool calls and results as they happen")
- boolean showTools;
-
- @Option(names = { "--verbose" },
- description = "Print debug information: HTTP requests, responses,
and parsed results")
- boolean verbose;
-
private long targetPid;
private CamelCatalog catalog;
private volatile List<JsonObject> commandMetadataCache;
- public Ask(CamelJBangMain main) {
- super(main);
- }
-
- @Override
- public Integer doCall() throws Exception {
- LlmClient client = LlmClient.create()
- .withUrl(url)
- .withApiType(apiType)
- .withApiKey(apiKey)
- .withModel(model)
- .withTimeout(timeout)
- .withTemperature(0.3)
- .withStream(true)
- .withMaxTokens(4096)
- .withVerbose(verbose)
- .withPrinter(printer());
-
- if (!client.detectEndpoint()) {
- printer().printErr("LLM service is not reachable.");
- printer().printErr("Options: --url=<endpoint>,
--api-type=anthropic, or start Ollama with: camel infra run ollama");
- return 1;
- }
-
- RuntimeHelper.ProcessInfo process = findProcess(nameOrPid);
- if (process != null) {
- targetPid = process.pid();
- } else if (nameOrPid != null && !nameOrPid.isBlank()) {
- return 1;
- } else {
- targetPid = -1;
- }
-
- String systemPrompt = buildSystemPrompt(process);
- List<LlmClient.ToolDef> tools = buildToolDefinitions();
-
- if (question == null || question.isEmpty()) {
- return runInteractiveChat(client, process, systemPrompt, tools);
- }
-
- String userQuestion = String.join(" ", question);
- printer().println("Using " + client.model + " (" + client.apiType + ")
to answer your question...");
- if (process != null) {
- printer().println("Target: " + process.name() + " (PID " +
process.pid() + ")");
- }
- printer().println();
-
- List<LlmClient.Message> messages = new ArrayList<>();
- return runAgentLoop(client, systemPrompt, tools, messages,
userQuestion);
+ public AskTools(long targetPid) {
+ this.targetPid = targetPid;
}
- private int runInteractiveChat(
- LlmClient client, RuntimeHelper.ProcessInfo process,
- String systemPrompt, List<LlmClient.ToolDef> tools)
- throws Exception {
- Terminal terminal = EnvironmentHelper.getActiveTerminal();
- if (terminal == null) {
- terminal = TerminalBuilder.builder().system(true).build();
- }
- LineReader reader =
LineReaderBuilder.builder().terminal(terminal).build();
-
- printer().println("Camel AI Assistant (" + client.model + ", " +
client.apiType + ")");
- if (process != null) {
- printer().println("Target: " + process.name() + " (PID " +
process.pid() + ")");
- }
- printer().println("Type your question, or 'exit' to quit.");
- printer().println();
-
- List<LlmClient.Message> messages = new ArrayList<>();
-
- while (true) {
- String line;
- try {
- line = reader.readLine("ask> ");
- } catch (UserInterruptException | EndOfFileException e) {
- break;
- }
- if (line == null || line.isBlank() ||
"exit".equalsIgnoreCase(line.strip())) {
- break;
- }
-
- int result = runAgentLoop(client, systemPrompt, tools, messages,
line.strip());
- if (result != 0) {
- printer().printErr("(error processing question,
continuing...)");
- }
- printer().println();
- }
- return 0;
+ public long getTargetPid() {
+ return targetPid;
}
- private int runAgentLoop(
- LlmClient client, String systemPrompt,
- List<LlmClient.ToolDef> tools, List<LlmClient.Message> messages,
- String userQuestion) {
- messages.add(LlmClient.Message.user(userQuestion));
-
- for (int i = 0; i < maxIterations; i++) {
- LlmClient.ChatResponse response =
client.chatWithTools(systemPrompt, messages, tools);
- if (response == null) {
- printer().printErr("Failed to get response from LLM");
- return 1;
- }
-
- if (response.toolCalls() != null &&
!response.toolCalls().isEmpty()) {
-
messages.add(LlmClient.Message.assistantWithToolCalls(response.text(),
response.toolCalls()));
-
- List<LlmClient.ToolResult> results = new ArrayList<>();
- for (LlmClient.ToolCall toolCall : response.toolCalls()) {
- if (showTools) {
- printer().println("[tool] " + toolCall.name() + "(" +
toolCall.arguments().toJson() + ")");
- }
- String result = executeTool(toolCall.name(),
toolCall.arguments());
- if (showTools) {
- printer().println("[result] " + truncate(result, 200));
- }
- results.add(new LlmClient.ToolResult(toolCall.id(),
result));
- }
- messages.add(LlmClient.Message.toolResults(results));
- } else {
- if (!response.streamed() && response.text() != null) {
- printer().println(response.text());
- }
-
messages.add(LlmClient.Message.assistantWithToolCalls(response.text(),
List.of()));
- return 0;
- }
- }
-
- printer().printErr("Reached maximum iterations (" + maxIterations + ")
without a final answer.");
- return 1;
- }
-
- // ---- Process discovery (delegates to RuntimeHelper) ----
-
- private RuntimeHelper.ProcessInfo findProcess(String nameOrPid) {
- // Fall back to TUI-selected process if no explicit name given
- if ((nameOrPid == null || nameOrPid.isBlank()) &&
EnvironmentHelper.getSelectedProcess() != null) {
- nameOrPid = EnvironmentHelper.getSelectedProcess();
- }
-
- RuntimeHelper.ProcessInfo found = RuntimeHelper.findProcess(nameOrPid);
- if (found != null) {
- return found;
- }
-
- List<RuntimeHelper.ProcessInfo> processes =
RuntimeHelper.discoverProcesses();
- if (nameOrPid != null && !nameOrPid.isBlank()) {
- if (processes.isEmpty()) {
- printer().printErr("No running Camel processes found.");
- printer().printErr("Start a Camel application first: camel run
myRoute.yaml");
- } else if (processes.size() > 1) {
- printer().printErr("No unique Camel process found matching: "
+ nameOrPid);
- processes.forEach(p -> printer().printErr(" " + p.name() + "
(PID " + p.pid() + ")"));
- printer().printErr("Specify a more specific name or PID with
--name");
- } else {
- printer().printErr("No Camel process found matching: " +
nameOrPid);
- }
- } else if (processes.size() > 1) {
- printer().println("Multiple Camel processes found. Use --name to
specify one:");
- processes.forEach(p -> printer().println(" " + p.name() + " (PID
" + p.pid() + ")"));
- printer().println();
- }
- return null;
- }
-
- // ---- 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<RuntimeHelper.ProcessInfo> 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();
+ public void setTargetPid(long targetPid) {
+ this.targetPid = targetPid;
}
// ---- Tool definitions ----
- private List<LlmClient.ToolDef> buildToolDefinitions() {
+ public List<LlmClient.ToolDef> buildToolDefinitions() {
List<LlmClient.ToolDef> 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.",
@@ -336,7 +80,6 @@ public class Ask extends CamelCommand {
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.",
@@ -369,8 +112,27 @@ public class Ask extends CamelCommand {
"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()));
- // 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).",
@@ -391,13 +153,40 @@ public class Ask extends CamelCommand {
"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")))));
- // Route lifecycle tools
+ 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.",
@@ -419,13 +208,11 @@ public class Ask extends CamelCommand {
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.",
@@ -443,7 +230,6 @@ public class Ask extends CamelCommand {
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.",
@@ -457,7 +243,6 @@ public class Ask extends CamelCommand {
"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.",
@@ -475,7 +260,6 @@ public class Ask extends CamelCommand {
"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.",
@@ -498,10 +282,9 @@ public class Ask extends CamelCommand {
// ---- Tool execution ----
- private String executeTool(String name, JsonObject args) {
+ public 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" ->
@@ -520,29 +303,43 @@ public class Ask extends CamelCommand {
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");
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);
@@ -553,6 +350,48 @@ public class Ask extends CamelCommand {
}
}
+ // ---- 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<RuntimeHelper.ProcessInfo> 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<RuntimeHelper.ProcessInfo> processes =
RuntimeHelper.discoverProcesses();
if (processes.isEmpty()) {
@@ -641,15 +480,79 @@ public class Ask extends CamelCommand {
});
}
+ 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) {
String filter = args.getString("filter");
String label = args.getString("label");
- CamelCatalog catalog = getCatalog();
+ CamelCatalog cat = getCatalog();
- List<JsonObject> results = catalog.findComponentNames().stream()
- .map(catalog::componentModel)
+ List<JsonObject> 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()
@@ -676,8 +579,8 @@ public class Ask extends CamelCommand {
if (component == null || component.isBlank()) {
return "Error: component name is required";
}
- CamelCatalog catalog = getCatalog();
- ComponentModel model = catalog.componentModel(component);
+ CamelCatalog cat = getCatalog();
+ ComponentModel model = cat.componentModel(component);
if (model == null) {
return "Component not found: " + component;
}
@@ -712,10 +615,10 @@ public class Ask extends CamelCommand {
private String executeCatalogEips(JsonObject args) {
String filter = args.getString("filter");
- CamelCatalog catalog = getCatalog();
+ CamelCatalog cat = getCatalog();
- List<JsonObject> results = catalog.findModelNames().stream()
- .map(catalog::eipModel)
+ List<JsonObject> results = cat.findModelNames().stream()
+ .map(cat::eipModel)
.filter(m -> m != null)
.filter(m -> matchesFilter(m.getName(), m.getTitle(),
m.getDescription(), filter))
.limit(20)
@@ -735,16 +638,6 @@ public class Ask extends CamelCommand {
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")
@@ -752,8 +645,8 @@ public class Ask extends CamelCommand {
String filter = args.getString("filter");
String level = args.getString("level");
- List<JsonObject> catalog = ExampleHelper.loadCatalog();
- List<JsonObject> filtered = ExampleHelper.filterExamples(catalog,
filter);
+ List<JsonObject> catalog2 = ExampleHelper.loadCatalog();
+ List<JsonObject> filtered = ExampleHelper.filterExamples(catalog2,
filter);
List<JsonObject> results = new ArrayList<>();
for (JsonObject entry : filtered) {
@@ -794,8 +687,8 @@ public class Ask extends CamelCommand {
return "Error: file name is required";
}
- List<JsonObject> catalog = ExampleHelper.loadCatalog();
- JsonObject entry = ExampleHelper.findExample(catalog, example);
+ List<JsonObject> catalog2 = ExampleHelper.loadCatalog();
+ JsonObject entry = ExampleHelper.findExample(catalog2, example);
if (entry == null) {
return "Example not found: " + example;
}
@@ -843,47 +736,16 @@ public class Ask extends CamelCommand {
return commandMetadataCache;
}
} catch (Exception e) {
- printer().printErr("Failed to load CLI command metadata: " +
e.getMessage());
+ // ignore
}
return List.of();
}
- @SuppressWarnings("unchecked")
- static void collectCommands(List<JsonObject> commands, List<JsonObject>
result, String filter) {
- for (JsonObject cmd : commands) {
- String fullName = cmd.getString("fullName");
- String description = cmd.getString("description");
- boolean matches = filter == null || filter.isBlank()
- || (fullName != null &&
fullName.toLowerCase().contains(filter.toLowerCase()))
- || (description != null &&
description.toLowerCase().contains(filter.toLowerCase()));
- if (matches) {
- JsonObject entry = new JsonObject();
- entry.put("command", fullName);
- entry.put("description", description);
- Object subs = cmd.get("subcommands");
- if (subs instanceof Collection<?> subList &&
!subList.isEmpty()) {
- entry.put("hasSubcommands", true);
- entry.put("subcommandCount", subList.size());
- }
- result.add(entry);
- }
- Object subs = cmd.get("subcommands");
- if (subs instanceof Collection<?>) {
- collectCommands(
- ((Collection<Object>) subs).stream()
- .filter(JsonObject.class::isInstance)
- .map(JsonObject.class::cast)
- .toList(),
- result, filter);
- }
- }
- }
-
private String executeCliListCommands(JsonObject args) {
String filter = args.getString("filter");
List<JsonObject> commands = loadCommandMetadata();
List<JsonObject> result = new ArrayList<>();
- collectCommands(commands, result, filter);
+ Ask.collectCommands(commands, result, filter);
JsonObject response = new JsonObject();
response.put("count", result.size());
@@ -891,28 +753,6 @@ public class Ask extends CamelCommand {
return response.toJson();
}
- @SuppressWarnings("unchecked")
- static JsonObject findCommand(List<JsonObject> commands, String fullName) {
- for (JsonObject cmd : commands) {
- if (fullName.equals(cmd.getString("fullName"))) {
- return cmd;
- }
- Object subs = cmd.get("subcommands");
- if (subs instanceof Collection<?>) {
- JsonObject found = findCommand(
- ((Collection<Object>) subs).stream()
- .filter(JsonObject.class::isInstance)
- .map(JsonObject.class::cast)
- .toList(),
- fullName);
- if (found != null) {
- return found;
- }
- }
- }
- return null;
- }
-
@SuppressWarnings("unchecked")
private String executeCliCommandHelp(JsonObject args) {
String command = args.getString("command");
@@ -921,7 +761,7 @@ public class Ask extends CamelCommand {
}
List<JsonObject> commands = loadCommandMetadata();
- JsonObject cmd = findCommand(commands, command);
+ JsonObject cmd = Ask.findCommand(commands, command);
if (cmd == null) {
return "Command not found: " + command + ". Use cli_list_commands
to see available commands.";
}
@@ -979,9 +819,8 @@ public class Ask extends CamelCommand {
return "Error: CLI not available";
}
- String[] cmdArgs = tokenizeCommand(command.trim());
+ String[] cmdArgs = Ask.tokenizeCommand(command.trim());
- // capture output by temporarily swapping the Printer on main
StringBuilder captured = new StringBuilder();
Printer capturingPrinter = new Printer() {
@Override
@@ -1005,7 +844,7 @@ public class Ask extends CamelCommand {
}
};
- // also capture PicoCLI's own output (usage/help text)
+ CamelJBangMain main = (CamelJBangMain) commandLine.getCommand();
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
PrintWriter originalOut = commandLine.getOut();
@@ -1013,8 +852,8 @@ public class Ask extends CamelCommand {
commandLine.setOut(pw);
commandLine.setErr(pw);
- Printer originalPrinter = getMain().getOut();
- getMain().setOut(capturingPrinter);
+ Printer originalPrinter = main.getOut();
+ main.setOut(capturingPrinter);
try {
int exitCode = commandLine.execute(cmdArgs);
pw.flush();
@@ -1029,39 +868,12 @@ public class Ask extends CamelCommand {
} catch (Exception e) {
return "Error executing command: " + e.getMessage();
} finally {
- getMain().setOut(originalPrinter);
+ main.setOut(originalPrinter);
commandLine.setOut(originalOut);
commandLine.setErr(originalErr);
}
}
- static String[] tokenizeCommand(String command) {
- List<String> tokens = new ArrayList<>();
- StringBuilder current = new StringBuilder();
- boolean inSingleQuote = false;
- boolean inDoubleQuote = false;
-
- for (int i = 0; i < command.length(); i++) {
- char c = command.charAt(i);
- if (c == '\'' && !inDoubleQuote) {
- inSingleQuote = !inSingleQuote;
- } else if (c == '"' && !inSingleQuote) {
- inDoubleQuote = !inDoubleQuote;
- } else if (Character.isWhitespace(c) && !inSingleQuote &&
!inDoubleQuote) {
- if (!current.isEmpty()) {
- tokens.add(current.toString());
- current.setLength(0);
- }
- } else {
- current.append(c);
- }
- }
- if (!current.isEmpty()) {
- tokens.add(current.toString());
- }
- return tokens.toArray(String[]::new);
- }
-
// ---- File tools ----
private String executeListFiles(JsonObject args) throws IOException {
@@ -1143,16 +955,16 @@ public class Ask extends CamelCommand {
return catalog;
}
- // ---- JSON schema helpers for tool parameters ----
+ // ---- JSON schema helpers ----
- private static JsonObject emptyParams() {
+ public static JsonObject emptyParams() {
JsonObject schema = new JsonObject();
schema.put("type", "object");
schema.put("properties", new JsonObject());
return schema;
}
- private static JsonObject objectParams(Map<String, JsonObject> properties)
{
+ public static JsonObject objectParams(Map<String, JsonObject> properties) {
JsonObject props = new JsonObject();
Map<String, JsonObject> ordered = new LinkedHashMap<>(properties);
for (Map.Entry<String, JsonObject> entry : ordered.entrySet()) {
@@ -1164,17 +976,20 @@ public class Ask extends CamelCommand {
return schema;
}
- private static JsonObject stringProp(String description) {
+ public 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) {
- if (text == null) {
- return "null";
+ static boolean matchesFilter(String name, String title, String
description, String filter) {
+ if (filter == null || filter.isBlank()) {
+ return true;
}
- return text.length() <= maxLen ? text : text.substring(0, maxLen) +
"...";
+ 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 ac87f338677c..d2abee4f8f44 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 @@ import org.apache.camel.util.json.Jsoner;
/**
* 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 @@ class LlmClient {
// -- 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<ToolCall> toolCalls,
List<ToolResult> toolResults) {
+ public record Message(String role, String content, List<ToolCall>
toolCalls, List<ToolResult> 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<ToolCall>
calls) {
+ public static Message assistantWithToolCalls(String text,
List<ToolCall> calls) {
return new Message("assistant", text, calls, null);
}
- static Message toolResults(List<ToolResult> results) {
+ public static Message toolResults(List<ToolResult> results) {
return new Message("tool", null, null, results);
}
}
- record ChatResponse(String text, List<ToolCall> toolCalls, String
stopReason, boolean streamed) {
+ public record ChatResponse(String text, List<ToolCall> toolCalls, String
stopReason, boolean streamed) {
}
// -- Configuration --
@@ -104,7 +104,23 @@ class LlmClient {
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 @@ class LlmClient {
// -- 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 @@ class LlmClient {
// -- Chat with tools (for ask) --
- ChatResponse chatWithTools(String systemPrompt, List<Message> messages,
List<ToolDef> tools) {
+ public ChatResponse chatWithTools(String systemPrompt, List<Message>
messages, List<ToolDef> 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/ActionsPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
index 6f57ad1ec389..d90a0efa92eb 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 @@ class ActionsPopup {
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<Set<String>> runningNames;
@@ -148,6 +149,7 @@ class ActionsPopup {
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 @@ class ActionsPopup {
mcpLogPopup.setActivityLog(activityLog);
}
+ void setAiActivityLog(Supplier<List<AiPanel.LogEntry>> activityLog) {
+ aiLogPopup.setActivityLog(activityLog);
+ }
+
private int visualActionCount() {
int total = 0;
for (int gs : GROUP_SIZES) {
@@ -273,7 +279,7 @@ class ActionsPopup {
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 @@ class ActionsPopup {
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 @@ class ActionsPopup {
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 @@ class ActionsPopup {
showInfraBrowser = false;
showInfraPortDialog = false;
mcpLogPopup.close();
+ aiLogPopup.close();
doctorPopup.close();
sendMessagePopup.close();
stopAllPopup.close();
@@ -419,6 +427,9 @@ class ActionsPopup {
if (mcpLogPopup.handleKeyEvent(ke)) {
return true;
}
+ if (aiLogPopup.handleKeyEvent(ke)) {
+ return true;
+ }
if (showDocViewer) {
if (ke.isCancel()) {
showDocViewer = false;
@@ -608,6 +619,9 @@ class ActionsPopup {
} 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 @@ class ActionsPopup {
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 @@ class ActionsPopup {
doctorPopup.renderFooter(spans);
return;
}
+ if (aiLogPopup.isVisible()) {
+ aiLogPopup.renderFooter(spans);
+ return;
+ }
if (mcpLogPopup.isVisible()) {
mcpLogPopup.renderFooter(spans);
return;
@@ -809,6 +830,7 @@ class ActionsPopup {
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 @@ class ActionsPopup {
mcpLogPopup.open();
}
+ private void openAiLog() {
+ aiLogPopup.open();
+ }
+
// ---- Folder Input ----
private void openFolderInput() {
@@ -2171,6 +2197,7 @@ class ActionsPopup {
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 000000000000..147c75a51a69
--- /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<List<AiPanel.LogEntry>> activityLog;
+ private List<AiPanel.LogEntry> entries;
+ private int selected;
+ private int detailScroll;
+
+ void setActivityLog(Supplier<List<AiPanel.LogEntry>> 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<Span> spans) {
+ hint(spans, "↑↓", "select");
+ hint(spans, "PgUp/Dn", "scroll detail");
+ hintLast(spans, "Esc", "back");
+ }
+
+ private void renderMaster(Frame frame, Rect area) {
+ List<ListItem> 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<Line> 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<Line> visible = lines.subList(clampedScroll, end);
+ frame.renderWidget(
+
Paragraph.builder().text(Text.from(visible.toArray(Line[]::new))).build(),
+ inner);
+ }
+ }
+
+ private static void addJsonLines(List<Line> 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
new file mode 100644
index 000000000000..62cb48158807
--- /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,564 @@
+/*
+ * 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.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;
+
+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 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%
+ private MonitorContext ctx;
+
+ // Input state
+ private final StringBuilder inputBuffer = new StringBuilder();
+ private int cursorPos;
+
+ // Conversation display
+ private final List<ConversationEntry> conversation = new ArrayList<>();
+ private int scrollOffset;
+
+ // LLM state
+ private LlmClient client;
+ private List<LlmClient.Message> messages;
+ private List<LlmClient.ToolDef> tools;
+ private AskTools askTools;
+ 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<LogEntry> activityLog = new ArrayList<>();
+
+ record ConversationEntry(String role, String text, long elapsedSeconds) {
+ ConversationEntry(String role, String text) {
+ this(role, text, -1);
+ }
+ }
+
+ void setContext(MonitorContext ctx) {
+ this.ctx = ctx;
+ }
+
+ synchronized List<LogEntry> 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;
+ }
+
+ 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;
+ }
+
+ 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));
+ log(LogLevel.QUESTION, "Question", question);
+ thinkingStartTime = System.currentTimeMillis();
+ 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) {
+ String err = "No response from LLM";
+ conversation.add(new ConversationEntry("error", err));
+ log(LogLevel.ERROR, "Error", err);
+ 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) {
+ String err = "LLM request failed. Check API key and endpoint.";
+ conversation.add(new ConversationEntry("error", err));
+ log(LogLevel.ERROR, "Error", err);
+ return;
+ }
+
+ if (response.toolCalls() != null &&
!response.toolCalls().isEmpty()) {
+
messages.add(LlmClient.Message.assistantWithToolCalls(response.text(),
response.toolCalls()));
+
+ List<LlmClient.ToolResult> results = new ArrayList<>();
+ for (LlmClient.ToolCall toolCall : response.toolCalls()) {
+ 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));
+ } else {
+ String text = response.text();
+ if (text != null && !text.isBlank()) {
+ 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));
+ log(LogLevel.ERROR, "Error", err);
+ }
+ scrollOffset = 0;
+ 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) {
+ // 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(titleLine))
+ .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)
+ List<Rect> parts = Layout.vertical()
+ .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);
+
+ 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()) {
+ 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(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 -> {
+ }
+ }
+ }
+
+ if (thinking.get()) {
+ long elapsed = (System.currentTimeMillis() - thinkingStartTime) /
1000;
+ long dots = (System.currentTimeMillis() / 500) % 4;
+ 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 (skip at 25% — shown in title
bar instead)
+ Rect mdArea = area;
+ Rect elapsedArea = null;
+ if (lastElapsed >= 0 && splitIndex > 0 && area.height() > 2) {
+ List<Rect> 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, mdArea.width());
+ int estimatedLines = 0;
+ for (String l : source.split("\n", -1)) {
+ estimatedLines += Math.max(1, (l.length() / contentWidth) + 1);
+ }
+
+ boolean overflow = estimatedLines > mdArea.height();
+ Rect contentArea = mdArea;
+ Rect scrollbarArea = null;
+ if (overflow) {
+ List<Rect> hParts = Layout.horizontal()
+ .constraints(Constraint.fill(), Constraint.length(1))
+ .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
+ // 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)
+ .scroll(scroll)
+ .build();
+ frame.renderWidget(view, contentArea);
+
+ 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) {
+ String prompt = "> ";
+ String text = inputBuffer.toString();
+
+ List<Span> 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<Span> 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");
+ }
+ }
+
+ 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<Line> 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("(?<!\n)\n(?!\n)", " \n");
+ }
+
+}
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 2fac10ed617a..dea72389ce84 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 class CamelMonitor extends CamelCommand {
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);
@@ -317,6 +319,7 @@ public class CamelMonitor extends CamelCommand {
eventLog = new TuiEventLog(500);
Path mcpJsonFile = null;
+ actionsPopup.setAiActivityLog(aiPanel::getActivityLog);
if (mcp) {
mcpServer = new TuiMcpServer(mcpPort, this);
try {
@@ -347,6 +350,7 @@ public class CamelMonitor extends CamelCommand {
this::render);
} finally {
shellPanel.destroy();
+ aiPanel.destroy();
if (mcpServer != null) {
mcpServer.stop();
}
@@ -406,6 +410,13 @@ public class CamelMonitor extends CamelCommand {
}
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 +652,24 @@ public class CamelMonitor extends CamelCommand {
if (shellPanel.isOpen()) {
shellPanel.close();
} else {
+ if (aiPanel.isOpen()) {
+ aiPanel.close();
+ }
shellPanel.open();
}
return true;
}
+ if (ke.isKey(KeyCode.F8)) {
+ 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 +1027,8 @@ public class CamelMonitor extends CamelCommand {
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<Rect> splitChunks = Layout.vertical()
.constraints(Constraint.percentage(100 -
shellPanel.panelPercent()),
@@ -1010,6 +1036,13 @@ public class CamelMonitor extends CamelCommand {
.split(contentArea);
renderContent(frame, splitChunks.get(0));
shellPanel.render(frame, splitChunks.get(1));
+ } else if (aiPanel.isOpen()) {
+ List<Rect> 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 +1796,8 @@ public class CamelMonitor extends CamelCommand {
hint(spans, "Esc", "close");
} else if (shellPanel.isOpen()) {
shellPanel.renderFooter(spans);
+ } else if (aiPanel.isOpen()) {
+ aiPanel.renderFooter(spans);
} else {
MonitorTab tab = activeTab();
@@ -1857,6 +1892,7 @@ public class CamelMonitor extends CamelCommand {
hint(fKeySpans, "F3", "switch");
}
hint(fKeySpans, "F6", "shell");
+ 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/HistoryTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java
index 737449f5d37f..1b4abc00bf92 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 @@ class HistoryTab implements MonitorTab {
List<TraceEntry> steps =
getTraceStepsDepthFirst(traceSelectedExchangeId);
List<Rect> chunks = Layout.vertical()
- .constraints(Constraint.length(10), Constraint.length(1),
Constraint.fill())
+ .constraints(Constraint.length(10), Constraint.fill())
.split(area);
Map<String, String> descMap = showDescription ? getRouteDescriptions()
: Collections.emptyMap();
@@ -1099,10 +1099,10 @@ class HistoryTab implements MonitorTab {
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 @@ class HistoryTab implements MonitorTab {
List<HistoryEntry> current = reorderHistoryDepthFirst(historyEntries);
List<Rect> chunks = Layout.vertical()
- .constraints(Constraint.length(10), Constraint.length(1),
Constraint.fill())
+ .constraints(Constraint.length(10), Constraint.fill())
.split(area);
Map<String, String> descMap = showDescription ? getRouteDescriptions()
: Collections.emptyMap();
@@ -1369,10 +1369,10 @@ class HistoryTab implements MonitorTab {
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);
}
}
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 fbdee894f0e0..d8a0f98d47bf 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 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
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 @@ class ShellPanel {
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 @@ class ShellPanel {
void renderFooter(List<Span> 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<Line> renderLiveView(long[] screen, int width, int height) {
@@ -619,20 +619,17 @@ class ShellPanel {
@SuppressWarnings("unchecked")
private static List<long[]> getHistory(ScreenTerminal st) {
try {
- Method m = ScreenTerminal.class.getMethod("getHistory");
- return (List<long[]>) m.invoke(st);
+ Field f = ScreenTerminal.class.getDeclaredField("history");
+ f.setAccessible(true);
+ List<long[]> history = (List<long[]>) 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 {