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 b3830a04924f CAMEL-23672: TUI - Diagram external toggle with three
modes (off/edges/all) (#23809)
b3830a04924f is described below
commit b3830a04924f1b16b94cc5dd2c63b6962dc90354
Author: Claus Ibsen <[email protected]>
AuthorDate: Sat Jun 6 22:59:51 2026 +0200
CAMEL-23672: TUI - Diagram external toggle with three modes (off/edges/all)
(#23809)
* CAMEL-23672: TUI - Diagram external toggle with three modes
(off/edges/all)
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-23672: TUI - Add tui_get_files MCP tool and tui_control +
improvements
Co-Authored-By: Claude <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-23672: TUI - MCP diagram navigation, state reporting, and footer
actions
Add route/node parameters to tui_navigate for programmatic diagram
navigation without arrow keys. Enrich tui_get_state with diagram context
(mode, selected node, info panel stats). Expose footer keyboard shortcuts
as structured JSON actions in tui_get_state, tui_navigate, and tui_send_keys
responses so AI agents discover available actions without screen scraping.
Co-Authored-By: Claude <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-23672: TUI - ErrorsTab compact BHPV toggles and error count in title
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-23672: TUI - Add tui_locate MCP tool for screen coordinate lookup
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
* CAMEL-23672: TUI - Add tui_draw_shape MCP tool for drawing shapes on
screen
Adds shape generators (box, highlight, underline, arrows, text) to
DrawOverlay
and registers tui_draw_shape MCP tool. Highlight mode preserves existing
text
and applies background color like a marker pen. Supports append mode for
composing multiple shapes.
Co-Authored-By: Claude <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---------
Signed-off-by: Claus Ibsen <[email protected]>
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../org/apache/camel/diagram/TopologyHelper.java | 69 ++++++
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 42 +++-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 245 +++++++++++++++++++++
.../jbang/core/commands/tui/DiagramSupport.java | 144 +++++++++++-
.../dsl/jbang/core/commands/tui/DiagramTab.java | 239 ++++++++++++++++++--
.../dsl/jbang/core/commands/tui/DrawOverlay.java | 97 +++++++-
.../dsl/jbang/core/commands/tui/ErrorsTab.java | 8 +-
.../dsl/jbang/core/commands/tui/FilesBrowser.java | 24 ++
.../camel/dsl/jbang/core/commands/tui/LogTab.java | 2 +-
.../dsl/jbang/core/commands/tui/RoutesTab.java | 11 +-
.../jbang/core/commands/tui/SearchHighlighter.java | 4 +
.../dsl/jbang/core/commands/tui/TuiCommand.java | 47 +++-
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 224 ++++++++++++++++++-
.../tui/diagram/TopologyDiagramWidget.java | 3 +-
.../tui/diagram/TopologyMinimapWidget.java | 13 +-
15 files changed, 1117 insertions(+), 55 deletions(-)
diff --git
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
index ce48c0d105a5..d59a2a3bb1ad 100644
---
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
+++
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyHelper.java
@@ -17,7 +17,11 @@
package org.apache.camel.diagram;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo;
import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo;
@@ -121,6 +125,71 @@ public final class TopologyHelper {
}
}
+ /**
+ * Expands external-type route-to-route edges into intermediary external
nodes. For each shared external endpoint
+ * (e.g., kafka:foo used by both a producer and consumer route), the
direct edge is replaced with a dashed external
+ * box and two edges passing through it.
+ */
+ public static void expandExternalEdges(List<TopologyNodeInfo> nodes,
List<TopologyEdgeInfo> edges) {
+ // Only consider route nodes — exclude external-in/external-out nodes
already added
+ Set<String> routeIds = nodes.stream()
+ .filter(n -> n.nodeType == null ||
!n.nodeType.startsWith("external"))
+ .map(n -> n.routeId)
+ .collect(Collectors.toSet());
+
+ // Group external edges by endpoint URI (only edges between two route
nodes)
+ Map<String, List<TopologyEdgeInfo>> byEndpoint = new LinkedHashMap<>();
+ for (TopologyEdgeInfo edge : edges) {
+ if ("external".equals(edge.connectionType)
+ && routeIds.contains(edge.fromRouteId) &&
routeIds.contains(edge.toRouteId)) {
+ byEndpoint.computeIfAbsent(edge.endpoint, k -> new
ArrayList<>()).add(edge);
+ }
+ }
+ if (byEndpoint.isEmpty()) {
+ return;
+ }
+
+ int idx = 0;
+ for (Map.Entry<String, List<TopologyEdgeInfo>> entry :
byEndpoint.entrySet()) {
+ String uri = entry.getKey();
+ List<TopologyEdgeInfo> group = entry.getValue();
+
+ // Create intermediary external node
+ TopologyNodeInfo extNode = new TopologyNodeInfo();
+ extNode.routeId = "ext-" + idx++;
+ extNode.from = uri;
+ extNode.nodeType = "external";
+ int colonIdx = uri.indexOf(':');
+ extNode.description = colonIdx > 0 ? uri.substring(colonIdx + 1) :
uri;
+
+ // Determine scheme from URI
+ if (colonIdx > 0) {
+ extNode.fromScheme = uri.substring(0, colonIdx);
+ }
+
+ nodes.add(extNode);
+
+ // Replace each original edge with two edges through the
intermediary node
+ for (TopologyEdgeInfo orig : group) {
+ edges.remove(orig);
+
+ TopologyEdgeInfo toExt = new TopologyEdgeInfo();
+ toExt.fromRouteId = orig.fromRouteId;
+ toExt.toRouteId = extNode.routeId;
+ toExt.endpoint = uri;
+ toExt.connectionType = "external";
+ edges.add(toExt);
+
+ TopologyEdgeInfo fromExt = new TopologyEdgeInfo();
+ fromExt.fromRouteId = extNode.routeId;
+ fromExt.toRouteId = orig.toRouteId;
+ fromExt.endpoint = uri;
+ fromExt.connectionType = "external";
+ edges.add(fromExt);
+ }
+ }
+ }
+
public static void enrichWithMetrics(List<TopologyNodeInfo> nodes,
JsonObject routeStructureJson) {
if (routeStructureJson == null) {
return;
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 afed843f228b..50b318011c04 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
@@ -83,12 +83,13 @@ class ActionsPopup {
TAPE_INSTRUCTIONS,
CAPTION,
SHOW_KEYSTROKES,
+ SETUP_AI,
MCP_INFO,
MCP_LOG
}
private static final int[] GROUP_SIZES = { 5, 4, 5 };
- private static final int MCP_GROUP_SIZE = 2;
+ private static final int MCP_GROUP_SIZE = 3;
private final Supplier<Set<String>> runningNames;
private final Supplier<List<IntegrationInfo>> integrations;
@@ -338,6 +339,7 @@ class ActionsPopup {
// Group 4: MCP
if (mcpEnabled) {
labels.add("───");
+ labels.add("Setup AI...");
labels.add("MCP Info");
labels.add("MCP Log");
}
@@ -564,6 +566,9 @@ class ActionsPopup {
doctorPopup.open();
} else if (action == Action.RUN_INFRA) {
openInfraBrowser();
+ } else if (action == Action.SETUP_AI) {
+ showActionsMenu = false;
+ openSetupAI();
} else if (action == Action.MCP_INFO) {
showActionsMenu = false;
openMcpInfo();
@@ -767,6 +772,7 @@ class ActionsPopup {
// Group 4: MCP
if (mcpEnabled) {
items.add(ListItem.from(divider).style(Style.EMPTY.dim()));
+ items.add(ListItem.from(" 🧠 Setup AI..."));
items.add(ListItem.from(" 🤖 MCP Info"));
items.add(ListItem.from(" 📋 MCP Log"));
}
@@ -1122,6 +1128,38 @@ class ActionsPopup {
docViewerFromExampleBrowser = false;
}
+ private void openSetupAI() {
+ docLines = null;
+ String url = "http://localhost:" + mcpPort + "/mcp";
+ String client = mcpConnectedClient != null ? mcpConnectedClient.get()
: null;
+ String status = client != null
+ ? "**Connected:** " + client + "\n\nYour AI agent is already
connected and ready to use."
+ : "**Status:** Waiting for connection";
+ docContent = "# Setup AI Agent\n\n"
+ + status + "\n\n"
+ + "## Connect Claude Code\n\n"
+ + "Run this command in your terminal:\n\n"
+ + " claude mcp add --transport http camel-tui " + url
+ "\n\n"
+ + "Then start a new Claude Code session. The TUI footer
will turn green\n"
+ + "when the AI agent connects.\n\n"
+ + "## Alternative: .mcp.json\n\n"
+ + "A `.mcp.json` file is auto-generated in the current
directory while the\n"
+ + "TUI runs with `--mcp`. AI agents that scan for
`.mcp.json` will discover\n"
+ + "the MCP server automatically.\n\n"
+ + "## What the AI Can Do\n\n"
+ + "Once connected, your AI agent can:\n\n"
+ + "- See the TUI screen and follow your key presses\n"
+ + "- Navigate tabs and select integrations\n"
+ + "- Read route diagrams and health status\n"
+ + "- Send test messages to endpoints\n"
+ + "- Record VHS tapes for documentation\n\n"
+ + "Try asking: *\"What's on my Camel TUI screen right
now?\"*\n";
+ docTitle = "Setup AI";
+ docScroll = 0;
+ showDocViewer = true;
+ docViewerFromExampleBrowser = false;
+ }
+
private void openMcpInfo() {
docLines = null;
String url = "http://localhost:" + mcpPort + "/mcp";
@@ -1146,6 +1184,7 @@ class ActionsPopup {
+ "| `tui_navigate` | Switch tabs and select integrations
|\n"
+ "| `tui_send_keys` | Send key presses to control the
TUI |\n"
+ "| `tui_wait_for_idle` | Waits for the screen to settle
after an action |\n"
+ + "| `tui_control` | Stop/start routes, restart, stop, or
kill integration |\n"
+ "| `tui_tape_start` | Start recording interactions as a
VHS .tape file |\n"
+ "| `tui_tape_stop` | Stop recording and return the tape
content |\n\n"
+ "## Setup for Claude Code\n\n"
@@ -2094,6 +2133,7 @@ class ActionsPopup {
case TAPE_RECORDING -> toggleTapeRecording.run();
case DOCTOR -> doctorPopup.open();
case CAPTION -> captionOverlay.openInline();
+ case SETUP_AI -> openSetupAI();
case MCP_INFO -> openMcpInfo();
case MCP_LOG -> openMcpLog();
default -> {
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 ec967232f378..1f935cbe9020 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
@@ -78,6 +78,7 @@ import
org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
import org.apache.camel.dsl.jbang.core.common.VersionHelper;
+import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
import picocli.CommandLine;
@@ -1653,6 +1654,9 @@ public class CamelMonitor extends CamelCommand {
}
rightSpans.add(Span.styled(mcpLabel, labelStyle));
rightSpans.add(Span.styled(suffix, suffixStyle));
+ if (client == null) {
+ rightSpans.add(Span.styled(" F2 → Setup AI",
Style.EMPTY.dim()));
+ }
}
if (!rightSpans.isEmpty()) {
@@ -2426,6 +2430,73 @@ public class CamelMonitor extends CamelCommand {
}
}
+ JsonObject getFiles(String name, String file) {
+ List<IntegrationInfo> integrations = data.get();
+ IntegrationInfo target = null;
+ if (name != null && !name.isEmpty()) {
+ for (IntegrationInfo info : integrations) {
+ if (!info.vanishing && name.equals(info.name)) {
+ target = info;
+ break;
+ }
+ }
+ } else {
+ target = ctx != null ? ctx.findSelectedIntegration() : null;
+ }
+ if (target == null) {
+ return null;
+ }
+ Path dir = FilesBrowser.resolveSourceDirectory(target);
+ if (dir == null || !Files.isDirectory(dir)) {
+ return null;
+ }
+ if (file != null && !file.isEmpty()) {
+ Path filePath = dir.resolve(file);
+ if (!Files.isRegularFile(filePath)) {
+ return null;
+ }
+ try {
+ String content = Files.readString(filePath,
StandardCharsets.UTF_8);
+ JsonObject result = new JsonObject();
+ result.put("file", file);
+ result.put("directory", dir.toString());
+ result.put("size",
FilesBrowser.formatFileSize(Files.size(filePath)));
+ result.put("type", FilesBrowser.fileType(filePath));
+ result.put("content", content);
+ return result;
+ } catch (IOException e) {
+ return null;
+ }
+ }
+ JsonArray files = new JsonArray();
+ try (var stream = Files.list(dir)) {
+ stream.filter(Files::isRegularFile)
+ .sorted((a, b) ->
a.getFileName().toString().compareToIgnoreCase(b.getFileName().toString()))
+ .limit(99)
+ .forEach(p -> {
+ JsonObject entry = new JsonObject();
+ entry.put("name", p.getFileName().toString());
+ try {
+ entry.put("size",
FilesBrowser.formatFileSize(Files.size(p)));
+ } catch (IOException e) {
+ entry.put("size", "0 B");
+ }
+ entry.put("type", FilesBrowser.fileType(p));
+ files.add(entry);
+ });
+ } catch (IOException e) {
+ return null;
+ }
+ if (files.isEmpty()) {
+ return null;
+ }
+ JsonObject result = new JsonObject();
+ result.put("directory", dir.toString());
+ result.put("files", files);
+ result.put("totalFiles", files.size());
+ return result;
+ }
+
int injectKeys(List<String> keys, int delayMs) {
long fireAt = System.currentTimeMillis();
int count = 0;
@@ -2539,6 +2610,137 @@ public class CamelMonitor extends CamelCommand {
return diagramTab.getTopologyDataAsJson();
}
+ String navigateDiagramToRoute(String routeId) {
+ navigateToTab("Diagram");
+ if (diagramTab.selectRoute(routeId)) {
+ return routeId;
+ }
+ return null;
+ }
+
+ String navigateDiagramToNode(String routeId, String nodeId) {
+ navigateToTab("Diagram");
+ if (diagramTab.selectNode(routeId, nodeId)) {
+ return nodeId;
+ }
+ return null;
+ }
+
+ JsonObject getDiagramState() {
+ return diagramTab.getDiagramStateAsJson();
+ }
+
+ JsonArray locateText(String search) {
+ Buffer buf = lastBuffer;
+ if (buf == null || search == null || search.isEmpty()) {
+ return new JsonArray();
+ }
+ String screen = ExportRequest.export(buf).text().toString();
+ String[] lines = screen.split("\n", -1);
+ int searchWidth = 0;
+ for (int i = 0; i < search.length();) {
+ int cp = search.codePointAt(i);
+ searchWidth += Math.max(1, CharWidth.of(cp));
+ i += Character.charCount(cp);
+ }
+ JsonArray matches = new JsonArray();
+ for (int y = 0; y < lines.length; y++) {
+ String line = lines[y];
+ int idx = line.indexOf(search);
+ while (idx >= 0) {
+ int visualCol = 0;
+ for (int i = 0; i < idx;) {
+ int cp = line.codePointAt(i);
+ visualCol += Math.max(1, CharWidth.of(cp));
+ i += Character.charCount(cp);
+ }
+ JsonObject match = new JsonObject();
+ match.put("x", visualCol);
+ match.put("y", y);
+ match.put("width", searchWidth);
+ match.put("height", 1);
+ match.put("text", search);
+ matches.add(match);
+ idx = line.indexOf(search, idx + 1);
+ }
+ if (matches.size() >= 20) {
+ break;
+ }
+ }
+ return matches;
+ }
+
+ JsonObject locateNodes(List<String> nodeIds) {
+ return diagramTab.locateNodes(nodeIds);
+ }
+
+ JsonArray getFooterActionsAsJson() {
+ List<Span> spans = new ArrayList<>();
+ if (helpOverlay.isVisible()) {
+ helpOverlay.renderFooter(spans);
+ } else if (captionOverlay.isCaptionVisible()) {
+ captionOverlay.renderFooter(spans);
+ } else if (filesBrowser.isVisible()) {
+ filesBrowser.renderFooter(spans);
+ } else if (showSwitchPopup || showMorePopup) {
+ if (showSwitchPopup) {
+ hint(spans, "Up/Down", "select");
+ hint(spans, "Enter", "switch");
+ hint(spans, "Esc", "close");
+ } else {
+ hint(spans, "Up/Down", "select");
+ hint(spans, "Enter", "open");
+ hint(spans, "Esc", "close");
+ }
+ } else {
+ MonitorTab tab = activeTab();
+ if (tabsState.selected() == TAB_OVERVIEW) {
+ renderOverviewFooter(spans);
+ } else if (tab != null) {
+ tab.renderFooter(spans);
+ insertFKeyHints(spans);
+ }
+ }
+ JsonArray actions = new JsonArray();
+ for (int i = 0; i + 1 < spans.size(); i += 2) {
+ String key = spans.get(i).content().trim();
+ String rawLabel = spans.get(i + 1).content().trim();
+ // compact "show BHPV" pattern: key="show", then space, then 4
single-letter spans, then trailing space
+ if ("show".equals(key) && i + 6 < spans.size()) {
+ for (int j = 0; j < 4; j++) {
+ Span letter = spans.get(i + 2 + j);
+ String ch = letter.content();
+ boolean on = ch.equals(ch.toUpperCase());
+ JsonObject toggle = new JsonObject();
+ toggle.put("key", ch.toLowerCase());
+ String label = switch (ch.toLowerCase()) {
+ case "b" -> "body";
+ case "h" -> "headers";
+ case "p" -> "properties";
+ case "v" -> "variables";
+ default -> ch;
+ };
+ toggle.put("label", label);
+ toggle.put("state", on ? "on" : "off");
+ actions.add(toggle);
+ }
+ i += 5; // skip the 7-span group (loop adds 2, we consumed 5
more)
+ continue;
+ }
+ JsonObject action = new JsonObject();
+ action.put("key", key);
+ int bracket = rawLabel.indexOf('[');
+ if (bracket > 0 && rawLabel.endsWith("]")) {
+ action.put("label", rawLabel.substring(0, bracket).trim());
+ action.put("state", rawLabel.substring(bracket + 1,
rawLabel.length() - 1));
+ } else {
+ action.put("label", rawLabel);
+ }
+ actions.add(action);
+ }
+ return actions;
+ }
+
void setLogLevel(String level) {
logTab.setLogLevel(level);
}
@@ -2568,6 +2770,49 @@ public class CamelMonitor extends CamelCommand {
return RuntimeHelper.sendMessage(pid, endpoint, body, headers);
}
+ String controlIntegration(String action) {
+ if (action == null || action.isBlank()) {
+ return "Error: action is required";
+ }
+ if (ctx.selectedPid == null) {
+ return "Error: no integration selected";
+ }
+ String name = selectedName();
+ return switch (action) {
+ case "stop-routes", "pause" -> {
+ if (isInfraSelected()) {
+ yield "Error: cannot stop routes on infra service";
+ }
+ sendRouteCommand(ctx.selectedPid, "*", "stop");
+ yield "Routes stopped for " + name;
+ }
+ case "start-routes", "resume" -> {
+ if (isInfraSelected()) {
+ yield "Error: cannot start routes on infra service";
+ }
+ sendRouteCommand(ctx.selectedPid, "*", "start");
+ yield "Routes started for " + name;
+ }
+ case "restart" -> {
+ if (isInfraSelected()) {
+ yield "Error: cannot restart infra service";
+ }
+ restartSelectedProcess();
+ yield "Restarting " + name;
+ }
+ case "stop" -> {
+ stopSelectedProcess(false);
+ yield "Stopping " + name;
+ }
+ case "kill" -> {
+ stopSelectedProcess(true);
+ yield "Killed " + name;
+ }
+ default -> "Unknown action: " + action
+ + ". Available: stop-routes, start-routes, restart,
stop, kill";
+ };
+ }
+
private record PendingKey(KeyEvent event, long fireAt) {
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
index c31681f97321..ef5ddfea2941 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramSupport.java
@@ -79,6 +79,8 @@ class DiagramSupport {
private String pendingSelectionRouteId;
private int lastVisibleHeight;
private int lastVisibleWidth;
+ private int lastAreaX;
+ private int lastAreaY;
// Native widget rendering data
private TopologyLayoutResult topologyLayout;
@@ -151,6 +153,15 @@ class DiagramSupport {
return null;
}
+ int findNodeIndexByRouteId(String routeId) {
+ for (int i = 0; i < nodeBoxes.size(); i++) {
+ if (routeId.equals(nodeBoxes.get(i).routeId())) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
TopologyLayoutNode getSelectedTopologyNode() {
String routeId = getSelectedRouteId();
if (routeId == null) {
@@ -244,6 +255,67 @@ class DiagramSupport {
return result;
}
+ JsonObject locateNodes(List<String> ids) {
+ if (ids == null || ids.isEmpty()) {
+ return null;
+ }
+ JsonArray matches = new JsonArray();
+ int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE;
+ int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE;
+
+ for (String id : ids) {
+ // search topology nodeBoxes by routeId
+ for (var nb : nodeBoxes) {
+ if (id.equals(nb.routeId())) {
+ JsonObject m = nodeBoxToScreen(nb.startRow(), nb.endRow(),
nb.startCol(), nb.endCol());
+ m.put("node", id);
+ matches.add(m);
+ minX = Math.min(minX, m.getInteger("x"));
+ minY = Math.min(minY, m.getInteger("y"));
+ maxX = Math.max(maxX, m.getInteger("x") +
m.getInteger("width"));
+ maxY = Math.max(maxY, m.getInteger("y") +
m.getInteger("height"));
+ break;
+ }
+ }
+ // search eip nodeBoxes by nodeId
+ for (var nb : eipNodeBoxes) {
+ if (id.equals(nb.nodeId())) {
+ JsonObject m = nodeBoxToScreen(nb.startRow(), nb.endRow(),
nb.startCol(), nb.endCol());
+ m.put("node", id);
+ matches.add(m);
+ minX = Math.min(minX, m.getInteger("x"));
+ minY = Math.min(minY, m.getInteger("y"));
+ maxX = Math.max(maxX, m.getInteger("x") +
m.getInteger("width"));
+ maxY = Math.max(maxY, m.getInteger("y") +
m.getInteger("height"));
+ break;
+ }
+ }
+ }
+ if (matches.isEmpty()) {
+ return null;
+ }
+ JsonObject result = new JsonObject();
+ result.put("matches", matches);
+ if (matches.size() > 1) {
+ JsonObject bounds = new JsonObject();
+ bounds.put("x", minX);
+ bounds.put("y", minY);
+ bounds.put("width", maxX - minX);
+ bounds.put("height", maxY - minY);
+ result.put("bounds", bounds);
+ }
+ return result;
+ }
+
+ private JsonObject nodeBoxToScreen(int startRow, int endRow, int startCol,
int endCol) {
+ JsonObject m = new JsonObject();
+ m.put("x", lastAreaX + startCol - scrollX);
+ m.put("y", lastAreaY + startRow - scrollY);
+ m.put("width", endCol - startCol + 1);
+ m.put("height", endRow - startRow + 1);
+ return m;
+ }
+
RouteDiagramLayoutEngine.LayoutRoute getRouteLayout(String routeId) {
return routeLayouts.get(routeId);
}
@@ -409,7 +481,7 @@ class DiagramSupport {
ctx.runner.scheduler().execute(() -> {
try {
setTopologyMode(true);
- loadAllDiagramsInBackground(ctx, pid, false, false);
+ loadAllDiagramsInBackground(ctx, pid, false, 0);
} finally {
endLoad();
}
@@ -640,7 +712,8 @@ class DiagramSupport {
for (TopologyLayoutNode node : result.nodes) {
int col = nodeW == 0 ? 0 : node.x * bw / nodeW;
int row = node.y / 20;
- boolean ext = "external-in".equals(node.nodeType) ||
"external-out".equals(node.nodeType);
+ boolean ext = "external-in".equals(node.nodeType) ||
"external-out".equals(node.nodeType)
+ || "external".equals(node.nodeType);
int contentLines;
if (ext) {
contentLines = 1;
@@ -697,7 +770,10 @@ class DiagramSupport {
.constraints(Constraint.fill(), Constraint.length(1))
.split(vChunks.get(0));
- frame.renderWidget(finalWidget, hChunks.get(0));
+ Rect widgetArea = hChunks.get(0);
+ frame.renderWidget(finalWidget, widgetArea);
+ lastAreaX = widgetArea.x();
+ lastAreaY = widgetArea.y();
// Update nodeBoxes from widget
List<TopologyAsciiRenderer.NodeBox> widgetBoxes = new ArrayList<>();
@@ -799,6 +875,15 @@ class DiagramSupport {
return null;
}
+ int findEipNodeIndexByNodeId(String nodeId) {
+ for (int i = 0; i < eipNodeBoxes.size(); i++) {
+ if (nodeId.equals(eipNodeBoxes.get(i).nodeId())) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
/**
* Finds the route ID that the selected EIP node links to, by matching the
node's endpoint URI against topology
* edges and route "from" endpoints.
@@ -828,6 +913,10 @@ class DiagramSupport {
if ("from".equals(type)) {
// "from" node: find route that sends TO this endpoint
if (currentRouteId.equals(edge.to.routeId) &&
!currentRouteId.equals(edge.from.routeId)) {
+ String resolved = resolveThrough(edge.from.routeId,
currentRouteId);
+ if (resolved != null) {
+ return resolved;
+ }
return edge.from.routeId;
}
} else {
@@ -835,6 +924,10 @@ class DiagramSupport {
if (currentRouteId.equals(edge.from.routeId) &&
!currentRouteId.equals(edge.to.routeId)) {
String targetFrom = stripQueryParams(edge.to.from);
if (baseUri.equals(targetFrom)) {
+ String resolved = resolveThrough(edge.to.routeId,
currentRouteId);
+ if (resolved != null) {
+ return resolved;
+ }
return edge.to.routeId;
}
}
@@ -856,6 +949,30 @@ class DiagramSupport {
return null;
}
+ private String resolveThrough(String nodeId, String excludeRouteId) {
+ if (!"external".equals(findNodeType(nodeId))) {
+ return null;
+ }
+ for (TopologyLayoutEdge e : topologyEdges) {
+ if (nodeId.equals(e.from.routeId) &&
!excludeRouteId.equals(e.to.routeId)) {
+ return e.to.routeId;
+ }
+ if (nodeId.equals(e.to.routeId) &&
!excludeRouteId.equals(e.from.routeId)) {
+ return e.from.routeId;
+ }
+ }
+ return null;
+ }
+
+ private String findNodeType(String nodeId) {
+ for (TopologyLayoutNode n : topologyNodes) {
+ if (nodeId.equals(n.routeId)) {
+ return n.nodeType;
+ }
+ }
+ return null;
+ }
+
static String getBaseUri(RouteDiagramLayoutEngine.NodeInfo info) {
String uri = info.uri;
if (uri == null) {
@@ -1130,7 +1247,10 @@ class DiagramSupport {
.constraints(Constraint.fill(), Constraint.length(1))
.split(vChunks.get(0));
- frame.renderWidget(finalWidget, hChunks.get(0));
+ Rect widgetArea = hChunks.get(0);
+ frame.renderWidget(finalWidget, widgetArea);
+ lastAreaX = widgetArea.x();
+ lastAreaY = widgetArea.y();
eipNodeBoxes = new ArrayList<>(finalWidget.getNodeBoxes());
if (selectedEipNodeIndex < 0 && !eipNodeBoxes.isEmpty()) {
@@ -1567,7 +1687,10 @@ class DiagramSupport {
.constraints(Constraint.fill(), Constraint.length(1))
.split(vChunks.get(0));
- frame.renderWidget(finalWidget, hChunks.get(0));
+ Rect widgetArea = hChunks.get(0);
+ frame.renderWidget(finalWidget, widgetArea);
+ lastAreaX = widgetArea.x();
+ lastAreaY = widgetArea.y();
List<TopologyAsciiRenderer.NodeBox> widgetBoxes = new ArrayList<>();
for (var nb : finalWidget.getNodeBoxes()) {
@@ -1692,7 +1815,10 @@ class DiagramSupport {
.constraints(Constraint.fill(), Constraint.length(1))
.split(vChunks.get(0));
- frame.renderWidget(finalWidget, hChunks.get(0));
+ Rect widgetArea = hChunks.get(0);
+ frame.renderWidget(finalWidget, widgetArea);
+ lastAreaX = widgetArea.x();
+ lastAreaY = widgetArea.y();
eipNodeBoxes = new ArrayList<>(finalWidget.getNodeBoxes());
selectedEipNodeIndex = selIdx;
@@ -1720,8 +1846,9 @@ class DiagramSupport {
}
void loadAllDiagramsInBackground(
- MonitorContext ctx, String pid, boolean metrics, boolean external)
{
+ MonitorContext ctx, String pid, boolean metrics, int externalMode)
{
// Single IPC call: topology + route structures
+ boolean external = externalMode > 0;
JsonObject topoJson = requestRouteTopology(ctx, pid, external, true);
TopologyLayoutResult topoResult = null;
@@ -1735,6 +1862,9 @@ class DiagramSupport {
if (external) {
TopologyHelper.addExternalEndpoints(nodes, edges, topoJson);
}
+ if (externalMode == 2) {
+ TopologyHelper.expandExternalEdges(nodes, edges);
+ }
if (!nodes.isEmpty()) {
TopologyLayoutEngine engine = new TopologyLayoutEngine();
topoResult = engine.layout(nodes, edges);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
index aeea11c9205d..00265881eea2 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
@@ -35,6 +35,7 @@ import dev.tamboui.widgets.block.Block;
import dev.tamboui.widgets.block.BorderType;
import dev.tamboui.widgets.paragraph.Paragraph;
import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.*;
@@ -45,7 +46,8 @@ class DiagramTab implements MonitorTab {
private final DiagramSupport diagram = new DiagramSupport();
private final SourceViewer sourceViewer = new SourceViewer();
private boolean diagramMetrics = true;
- private boolean showExternal;
+ private static final String[] EXTERNAL_LABELS = { " [off]", " [edges]", "
[all]" };
+ private int externalMode;
private boolean topologyMode = true;
private String drillDownRouteId;
private final Deque<String> routeNavigationStack = new ArrayDeque<>();
@@ -175,9 +177,9 @@ class DiagramTab implements MonitorTab {
return true;
}
- // Toggle external systems
+ // Cycle external systems: off → edges → all → off
if (diagram.isShowDiagram() && topologyMode &&
ke.isCharIgnoreCase('e')) {
- showExternal = !showExternal;
+ externalMode = (externalMode + 1) % 3;
diagram.endLoad();
reloadDiagram();
return true;
@@ -465,9 +467,10 @@ class DiagramTab implements MonitorTab {
var topoNode = diagram.getSelectedTopologyNode();
if (topoNode != null) {
boolean isInbound = "external-in".equals(topoNode.nodeType);
+ boolean isBridge = "external".equals(topoNode.nodeType);
+ String label = isBridge ? " External" : isInbound ? " Inbound"
: " Outbound";
lines.add(Line.from(
- Span.styled(isInbound ? " Inbound" : " Outbound",
- Style.EMPTY.fg(Color.CYAN).bold())));
+ Span.styled(label,
Style.EMPTY.fg(Color.CYAN).bold())));
lines.add(Line.from(Span.raw("")));
lines.add(Line.from(
Span.styled(" URI: ", Style.EMPTY.dim()),
@@ -477,12 +480,14 @@ class DiagramTab implements MonitorTab {
Span.styled(" Path: ", Style.EMPTY.dim()),
Span.raw(topoNode.description)));
}
- String connectedRoute = diagram.getConnectedRouteId(routeId);
- if (connectedRoute != null) {
- lines.add(Line.from(Span.raw("")));
- lines.add(Line.from(
- Span.styled(isInbound ? " To route: " : " From
route: ", Style.EMPTY.dim()),
- Span.styled(connectedRoute,
Style.EMPTY.fg(Color.WHITE))));
+ if (!isBridge) {
+ String connectedRoute =
diagram.getConnectedRouteId(routeId);
+ if (connectedRoute != null) {
+ lines.add(Line.from(Span.raw("")));
+ lines.add(Line.from(
+ Span.styled(isInbound ? " To route: " : " From
route: ", Style.EMPTY.dim()),
+ Span.styled(connectedRoute,
Style.EMPTY.fg(Color.WHITE))));
+ }
}
if (topoNode.exchangesTotal > 0 || topoNode.exchangesFailed >
0) {
lines.add(Line.from(Span.raw("")));
@@ -645,7 +650,7 @@ class DiagramTab implements MonitorTab {
}
hint(spans, "m", "metrics" + (diagramMetrics ? " [on]" : "
[off]"));
if (topologyMode) {
- hint(spans, "e", "external" + (showExternal ? " [on]" : "
[off]"));
+ hint(spans, "e", "external" + EXTERNAL_LABELS[externalMode]);
}
hint(spans, "n", "description" + (diagram.isShowDescription() ? "
[on]" : " [off]"));
}
@@ -675,7 +680,7 @@ class DiagramTab implements MonitorTab {
String pid = ctx.selectedPid;
boolean showMetrics = diagramMetrics;
- boolean external = showExternal;
+ int external = externalMode;
if (showPlaceholder) {
diagram.setLoadingPlaceholder();
@@ -738,10 +743,15 @@ class DiagramTab implements MonitorTab {
## External Systems
- When external systems are enabled, the diagram
shows a three-band layout:
- - **Top band** — external consumers sending
messages INTO Camel
- - **Middle band** — the Camel routes and their
internal connections
- - **Bottom band** — external producers where
Camel sends messages OUT
+ Press `e` to cycle through three external
modes:
+
+ - **off** — no external endpoints shown
+ - **edges** — external endpoints that are
truly outside Camel are shown
+ as dashed boxes in top/bottom bands. Routes
sharing an external
+ endpoint (e.g. kafka) are connected with a
direct arrow.
+ - **all** — same as edges, but routes sharing
an external endpoint
+ are connected through an intermediary dashed
box showing the
+ endpoint name, instead of a direct arrow.
External system boxes are drawn with dashed
borders to distinguish
them from route boxes. Dashed edges connect
routes to external systems.
@@ -939,4 +949,199 @@ class DiagramTab implements MonitorTab {
}
return Math.max(1, String.valueOf(max).length());
}
+
+ // ---- MCP programmatic navigation ----
+
+ boolean selectRoute(String routeId) {
+ int idx = diagram.findNodeIndexByRouteId(routeId);
+ if (idx < 0) {
+ return false;
+ }
+ diagram.setSelectedNodeIndex(idx);
+ diagram.scrollToSelectedNode();
+ return true;
+ }
+
+ boolean selectNode(String routeId, String nodeId) {
+ // Ensure we're on the Diagram tab in topology mode first
+ if (routeId != null) {
+ int routeIdx = diagram.findNodeIndexByRouteId(routeId);
+ if (routeIdx < 0) {
+ return false;
+ }
+ // Drill down into the route (mirrors Enter-key logic)
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ if (info == null || info.routes.stream().noneMatch(r ->
routeId.equals(r.routeId))) {
+ return false;
+ }
+ routeNavigationStack.clear();
+ drillDownRouteId = routeId;
+ topologyMode = false;
+ diagram.setTopologyMode(false);
+ diagram.selectFromNode(routeId);
+ diagram.resetScroll();
+ diagram.endLoad();
+ if (diagram.getRouteLayout(routeId) == null) {
+ reloadDiagram();
+ }
+ }
+ if (nodeId != null) {
+ int nodeIdx = diagram.findEipNodeIndexByNodeId(nodeId);
+ if (nodeIdx < 0) {
+ return false;
+ }
+ diagram.setSelectedEipNodeIndex(nodeIdx);
+ diagram.scrollToSelectedEipNode();
+ }
+ return true;
+ }
+
+ @Override
+ public SelectionContext getSelectionContext() {
+ if (!diagram.isShowDiagram()) {
+ return null;
+ }
+ if (topologyMode) {
+ var boxes = diagram.getNodeBoxes();
+ if (boxes.isEmpty()) {
+ return null;
+ }
+ List<String> items = new ArrayList<>();
+ for (var box : boxes) {
+ items.add(box.routeId());
+ }
+ return new SelectionContext(
+ "topology-routes", items,
+ diagram.getSelectedNodeIndex(), items.size(), "Topology
routes");
+ } else {
+ var boxes = diagram.getEipNodeBoxes();
+ if (boxes.isEmpty()) {
+ return null;
+ }
+ List<String> items = new ArrayList<>();
+ for (var box : boxes) {
+ items.add(box.nodeId());
+ }
+ return new SelectionContext(
+ "eip-nodes", items,
+ diagram.getSelectedEipNodeIndex(), items.size(),
+ "EIP nodes [" + drillDownRouteId + "]");
+ }
+ }
+
+ JsonObject getDiagramStateAsJson() {
+ if (!diagram.isShowDiagram()) {
+ return null;
+ }
+ JsonObject result = new JsonObject();
+ result.put("diagramMode", topologyMode ? "topology" : "route");
+
+ if (!topologyMode && drillDownRouteId != null) {
+ result.put("routeId", drillDownRouteId);
+ JsonArray stack = new JsonArray();
+ for (String s : routeNavigationStack) {
+ stack.add(s);
+ }
+ if (!stack.isEmpty()) {
+ result.put("navigationStack", stack);
+ }
+ }
+
+ // Selected node info
+ if (topologyMode) {
+ String routeId = diagram.getSelectedRouteId();
+ if (routeId != null) {
+ JsonObject node = new JsonObject();
+ node.put("routeId", routeId);
+ result.put("selectedRoute", node);
+ }
+ } else {
+ var eipBox = diagram.getSelectedEipNodeBox();
+ if (eipBox != null && eipBox.layoutNode() != null) {
+ JsonObject node = new JsonObject();
+ node.put("id", eipBox.nodeId());
+ node.put("type", eipBox.type());
+ String label = String.join("",
eipBox.layoutNode().wrappedLines);
+ if (!label.isBlank()) {
+ node.put("label", label);
+ }
+ if (eipBox.layoutNode().id != null) {
+ node.put("processorId", eipBox.layoutNode().id);
+ }
+ String linkedRoute =
diagram.findLinkedRouteId(drillDownRouteId);
+ if (linkedRoute != null) {
+ node.put("linkedRoute", linkedRoute);
+ }
+ result.put("selectedNode", node);
+ }
+ }
+
+ // Info panel stats
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ if (info != null) {
+ String routeId = topologyMode ? diagram.getSelectedRouteId() :
drillDownRouteId;
+ if (routeId != null) {
+ RouteInfo route = null;
+ for (RouteInfo r : info.routes) {
+ if (routeId.equals(r.routeId)) {
+ route = r;
+ break;
+ }
+ }
+ if (route != null) {
+ JsonObject ri = new JsonObject();
+ ri.put("routeId", route.routeId);
+ ri.put("from", route.from);
+ ri.put("state", route.state);
+ ri.put("uptime", route.uptime);
+ ri.put("throughput", route.throughput);
+ if (route.coverage != null) {
+ ri.put("coverage", route.coverage);
+ }
+ ri.put("total", route.total);
+ ri.put("failed", route.failed);
+ ri.put("inflight", route.inflight);
+ if (route.total > 0) {
+ ri.put("meanTime", route.meanTime);
+ ri.put("maxTime", route.maxTime);
+ ri.put("minTime", route.minTime);
+ }
+ if (route.sinceLastCompleted != null) {
+ ri.put("sinceLastSuccess", route.sinceLastCompleted);
+ }
+ if (route.sinceLastFailed != null) {
+ ri.put("sinceLastFail", route.sinceLastFailed);
+ }
+ result.put("info", ri);
+ }
+ }
+
+ // EIP node stats (when drilled down)
+ if (!topologyMode) {
+ var eipBox = diagram.getSelectedEipNodeBox();
+ if (eipBox != null && eipBox.layoutNode() != null
+ && eipBox.layoutNode().treeNode != null
+ && eipBox.layoutNode().treeNode.info.stat != null) {
+ var stat = eipBox.layoutNode().treeNode.info.stat;
+ JsonObject ni = new JsonObject();
+ ni.put("total", stat.exchangesTotal);
+ ni.put("failed", stat.exchangesFailed);
+ ni.put("inflight", stat.exchangesInflight);
+ if (stat.exchangesTotal > 0) {
+ ni.put("meanTime", stat.meanProcessingTime);
+ ni.put("maxTime", stat.maxProcessingTime);
+ ni.put("minTime", stat.minProcessingTime);
+ ni.put("lastTime", stat.lastProcessingTime);
+ }
+ result.put("nodeInfo", ni);
+ }
+ }
+ }
+
+ return result;
+ }
+
+ JsonObject locateNodes(List<String> nodeIds) {
+ return diagram.locateNodes(nodeIds);
+ }
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
index a8a45f7e1728..aeb651c92192 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
@@ -74,11 +74,106 @@ class DrawOverlay {
for (DrawCell cell : cells) {
if (cell.x >= 0 && cell.y >= 0
&& cell.x < screenArea.width() && cell.y <
screenArea.height()) {
- buffer.setString(cell.x, cell.y, cell.symbol, cell.style);
+ if (cell.symbol == null) {
+ // highlight mode: keep existing symbol, apply style as
background
+ var existing = buffer.get(cell.x, cell.y);
+ if (existing != null && existing !=
dev.tamboui.buffer.Cell.CONTINUATION) {
+ Style merged =
existing.style().bg(cell.style.bg().orElse(Color.YELLOW));
+ buffer.setString(cell.x, cell.y, existing.symbol(),
merged);
+ }
+ } else {
+ buffer.setString(cell.x, cell.y, cell.symbol, cell.style);
+ }
}
}
}
+ static List<DrawCell> generateShape(String shape, int x, int y, int width,
int height, int length, Color color) {
+ return switch (shape) {
+ case "box" -> generateBox(x, y, width, height, color);
+ case "highlight" -> generateHighlight(x, y, width, height, color);
+ case "underline" -> generateUnderline(x, y, width, color);
+ case "arrow-down" -> generateArrow(x, y, length, 0, 1, color);
+ case "arrow-up" -> generateArrow(x, y, length, 0, -1, color);
+ case "arrow-right" -> generateArrow(x, y, length, 1, 0, color);
+ case "arrow-left" -> generateArrow(x, y, length, -1, 0, color);
+ default -> List.of();
+ };
+ }
+
+ private static List<DrawCell> generateBox(int x, int y, int w, int h,
Color color) {
+ List<DrawCell> cells = new ArrayList<>();
+ Style s = Style.EMPTY.fg(color).bold();
+ cells.add(new DrawCell(x, y, "┌", s));
+ cells.add(new DrawCell(x + w - 1, y, "┐", s));
+ cells.add(new DrawCell(x, y + h - 1, "└", s));
+ cells.add(new DrawCell(x + w - 1, y + h - 1, "┘", s));
+ for (int i = 1; i < w - 1; i++) {
+ cells.add(new DrawCell(x + i, y, "─", s));
+ cells.add(new DrawCell(x + i, y + h - 1, "─", s));
+ }
+ for (int j = 1; j < h - 1; j++) {
+ cells.add(new DrawCell(x, y + j, "│", s));
+ cells.add(new DrawCell(x + w - 1, y + j, "│", s));
+ }
+ return cells;
+ }
+
+ private static List<DrawCell> generateHighlight(int x, int y, int w, int
h, Color color) {
+ List<DrawCell> cells = new ArrayList<>();
+ Style s = Style.EMPTY.bg(color);
+ for (int row = 0; row < h; row++) {
+ for (int col = 0; col < w; col++) {
+ cells.add(new DrawCell(x + col, y + row, null, s));
+ }
+ }
+ return cells;
+ }
+
+ private static List<DrawCell> generateUnderline(int x, int y, int w, Color
color) {
+ List<DrawCell> cells = new ArrayList<>();
+ Style s = Style.EMPTY.fg(color).bold();
+ for (int i = 0; i < w; i++) {
+ cells.add(new DrawCell(x + i, y, "─", s));
+ }
+ return cells;
+ }
+
+ private static List<DrawCell> generateArrow(int x, int y, int length, int
dx, int dy, Color color) {
+ List<DrawCell> cells = new ArrayList<>();
+ Style s = Style.EMPTY.fg(color).bold();
+ String shaft = dy != 0 ? "│" : "─";
+ String head;
+ if (dx > 0) {
+ head = "▶";
+ } else if (dx < 0) {
+ head = "◀";
+ } else if (dy > 0) {
+ head = "▼";
+ } else {
+ head = "▲";
+ }
+ for (int i = 0; i < length - 1; i++) {
+ cells.add(new DrawCell(x + i * dx, y + i * dy, shaft, s));
+ }
+ cells.add(new DrawCell(x + (length - 1) * dx, y + (length - 1) * dy,
head, s));
+ return cells;
+ }
+
+ static List<DrawCell> generateText(int x, int y, String text, Color color)
{
+ List<DrawCell> cells = new ArrayList<>();
+ Style s = Style.EMPTY.fg(color).bold();
+ int col = x;
+ for (int i = 0; i < text.length();) {
+ int cp = text.codePointAt(i);
+ String ch = new String(Character.toChars(cp));
+ cells.add(new DrawCell(col, y, ch, s));
+ col += Math.max(1, dev.tamboui.text.CharWidth.of(cp));
+ i += Character.charCount(cp);
+ }
+ return cells;
+ }
+
static Color parseColor(String name) {
if (name == null || name.isBlank()) {
return null;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
index c0adaf580826..806f071128ab 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ErrorsTab.java
@@ -363,7 +363,8 @@ class ErrorsTab implements MonitorTab {
Constraint.fill())
.highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
.highlightSpacing(Table.HighlightSpacing.ALWAYS)
- .block(Block.builder().borderType(BorderType.ROUNDED).title("
Errors ").build())
+ .block(Block.builder().borderType(BorderType.ROUNDED)
+ .title(" Errors (" + sorted.size() + ") sort:" + sort
+ " ").build())
.build();
frame.renderStatefulWidget(table, chunks.get(0), tableState);
@@ -416,10 +417,7 @@ class ErrorsTab implements MonitorTab {
hint(spans, "s", "sort");
hint(spans, "d", "diagram");
hint(spans, "f", "handled [" + handledFilter + "]");
- hint(spans, "p", "properties [" + (showProperties ? "on" : "off") +
"]");
- hint(spans, "v", "variables [" + (showVariables ? "on" : "off") + "]");
- hint(spans, "h", "headers [" + (showHeaders ? "on" : "off") + "]");
- hint(spans, "b", "body [" + (showBody ? "on" : "off") + "]");
+ hintShowBhpv(spans, showBody, showHeaders, showProperties,
showVariables);
hint(spans, "w", "wrap [" + (wordWrap ? "on" : "off") + "]");
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilesBrowser.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilesBrowser.java
index bcdd965ee0b9..57605a020d9f 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilesBrowser.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/FilesBrowser.java
@@ -251,6 +251,30 @@ class FilesBrowser {
return String.format("%.1f MB", bytes / (1024.0 * 1024));
}
+ static String fileType(Path path) {
+ String name = path.getFileName().toString();
+ String lower = name.toLowerCase(Locale.ROOT);
+ if (lower.endsWith(".kamelet.yaml") || lower.endsWith(".kamelet.yml"))
{
+ return "camel";
+ }
+ if (lower.endsWith(".yaml") || lower.endsWith(".yml")) {
+ return isCamelYaml(path) ? "camel" : "other";
+ }
+ if (lower.endsWith(".xml")) {
+ return isCamelXml(path) ? "camel" : "other";
+ }
+ if (lower.endsWith(".java")) {
+ return isCamelJava(path) ? "camel" : "java";
+ }
+ if (lower.endsWith(".properties") || lower.endsWith(".cfg")) {
+ return "config";
+ }
+ if (lower.startsWith("readme")) {
+ return "readme";
+ }
+ return "other";
+ }
+
private static String fileEmoji(Path path) {
String name = path.getFileName().toString();
String lower = name.toLowerCase(Locale.ROOT);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
index 28dec3649ef1..fd28125ec246 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
@@ -346,7 +346,7 @@ class LogTab implements MonitorTab {
List<Line> visibleLines = allLines.subList(start,
Math.min(allLines.size(), start + visibleHeight));
int currentMatchLine = search.currentMatchLine();
- if (currentMatchLine >= 0 || search.hasFindTerm()) {
+ if (currentMatchLine >= 0 || search.hasFindTerm() ||
search.hasHighlightTerm()) {
List<Line> highlighted = new ArrayList<>(visibleLines.size());
for (int i = 0; i < visibleLines.size(); i++) {
highlighted.add(search.applyHighlights(visibleLines.get(i),
start + i, currentMatchLine));
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
index 49c3453de8df..88ab80b9d2c7 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java
@@ -71,7 +71,8 @@ class RoutesTab implements MonitorTab {
private final SourceViewer sourceViewer = new SourceViewer();
private boolean diagramMetrics = true;
private boolean showDescription;
- private boolean showExternal;
+ private static final String[] EXTERNAL_LABELS = { " [off]", " [edges]", "
[all]" };
+ private int externalMode;
private boolean topologyMode = true;
private String drillDownRouteId;
private final Deque<String> routeNavigationStack = new ArrayDeque<>();
@@ -214,9 +215,9 @@ class RoutesTab implements MonitorTab {
return true;
}
- // Toggle external systems (topology mode only)
+ // Cycle external systems: off → edges → all → off (topology mode only)
if (diagram.isShowDiagram() && topologyMode &&
ke.isCharIgnoreCase('e')) {
- showExternal = !showExternal;
+ externalMode = (externalMode + 1) % 3;
diagram.endLoad();
reloadDiagram();
return true;
@@ -666,7 +667,7 @@ class RoutesTab implements MonitorTab {
}
hint(spans, "m", "metrics" + (diagramMetrics ? " [on]" : "
[off]"));
if (topologyMode) {
- hint(spans, "e", "external" + (showExternal ? " [on]" : "
[off]"));
+ hint(spans, "e", "external" + EXTERNAL_LABELS[externalMode]);
}
hint(spans, "n", "description" + (diagram.isShowDescription() ? "
[on]" : " [off]"));
} else {
@@ -1333,7 +1334,7 @@ class RoutesTab implements MonitorTab {
String pid = ctx.selectedPid;
boolean showMetrics = diagramMetrics;
- boolean external = showExternal;
+ int external = externalMode;
if (showPlaceholder) {
diagram.setLoadingPlaceholder();
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java
index 72a8d7379724..878d81b5f249 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SearchHighlighter.java
@@ -285,6 +285,10 @@ class SearchHighlighter {
return findTerm != null;
}
+ boolean hasHighlightTerm() {
+ return highlightTerm != null;
+ }
+
void reset() {
findInputActive = false;
highlightInputActive = false;
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
index 28d64578814d..623720abfcbd 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiCommand.java
@@ -16,6 +16,9 @@
*/
package org.apache.camel.dsl.jbang.core.commands.tui;
+import java.util.ArrayList;
+import java.util.List;
+
import org.apache.camel.dsl.jbang.core.commands.CamelCommand;
import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
import picocli.CommandLine;
@@ -25,6 +28,28 @@ public class TuiCommand extends CamelCommand {
private ClassLoader classLoader;
+ @CommandLine.Parameters(description = "Name or pid of running Camel
integration", arity = "0..1")
+ String name;
+
+ @CommandLine.Option(names = { "--mcp" },
+ description = "Enable embedded MCP server for AI agent
access to the TUI")
+ boolean mcp;
+
+ @CommandLine.Option(names = { "--mcp-port" },
+ description = "MCP server port (default:
${DEFAULT-VALUE})",
+ defaultValue = "8123")
+ int mcpPort = 8123;
+
+ @CommandLine.Option(names = { "--refresh" },
+ description = "Refresh interval in milliseconds
(default: ${DEFAULT-VALUE})",
+ defaultValue = "100")
+ long refreshInterval = 100;
+
+ @CommandLine.Option(names = { "--record" },
+ description = "Replay a .tape file inside the TUI and
record to an Asciinema .cast file",
+ arity = "0..1")
+ String record;
+
public TuiCommand(CamelJBangMain main, ClassLoader classLoader) {
super(main);
this.classLoader = classLoader;
@@ -32,8 +57,26 @@ public class TuiCommand extends CamelCommand {
@Override
public Integer doCall() throws Exception {
- // default to dashboard
+ List<String> args = new ArrayList<>();
+ if (name != null) {
+ args.add(name);
+ }
+ if (mcp) {
+ args.add("--mcp");
+ }
+ if (mcpPort != 8123) {
+ args.add("--mcp-port");
+ args.add(String.valueOf(mcpPort));
+ }
+ if (refreshInterval != 100) {
+ args.add("--refresh");
+ args.add(String.valueOf(refreshInterval));
+ }
+ if (record != null) {
+ args.add("--record");
+ args.add(record);
+ }
CamelMonitor cmd = new CamelMonitor(getMain(), classLoader);
- return new CommandLine(cmd).execute();
+ return new CommandLine(cmd).execute(args.toArray(String[]::new));
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index e0e90f8ed958..612be553db7e 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -32,6 +32,7 @@ import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import dev.tamboui.buffer.Buffer;
import dev.tamboui.export.ExportRequest;
+import dev.tamboui.style.Color;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
import org.apache.camel.util.json.Jsoner;
@@ -260,11 +261,18 @@ class TuiMcpServer {
toolList.add(toolDef(
"tui_navigate",
"Navigates the TUI: switch tabs and/or select an integration. "
- + "Both parameters are optional — set
whichever you want to change. "
- + "Tab names: Overview, Log, Routes,
Consumers, Endpoints, HTTP, Health, Inspect, Circuit Breaker. "
+ + "All parameters are optional — set whichever
you want to change. "
+ + "Tab names: Overview, Log, Diagram, Routes,
Consumers, Endpoints, HTTP, Health, Inspect, Circuit Breaker. "
+ + "Use 'route' to select a route in the
Diagram topology, "
+ + "and 'node' to drill down into a route and
select a specific processor/EIP node. "
+ "Returns screen content and selection
metadata after navigating.",
- Map.of("tab", propDef("string", "Tab to switch to (e.g.
'Routes', 'Health')"),
- "integration", propDef("string", "Integration name or
PID to select"))));
+ Map.of("tab", propDef("string", "Tab to switch to (e.g.
'Routes', 'Health', 'Diagram')"),
+ "integration", propDef("string", "Integration name or
PID to select"),
+ "route", propDef("string",
+ "Route ID to select in the Diagram tab
topology (e.g. 'order-dispatcher')"),
+ "node", propDef("string",
+ "Processor/EIP node ID to select within a
drilled-down route (e.g. 'multicast1'). "
+ + "If 'route' is also
provided, drills into that route first"))));
toolList.add(toolDef(
"tui_send_keys",
@@ -347,6 +355,29 @@ class TuiMcpServer {
+ "The underlying content is unchanged since
drawing is an overlay.",
Map.of()));
+ toolList.add(toolDef(
+ "tui_draw_shape",
+ "Draws a predefined shape on the TUI screen overlay. "
+ + "Much easier than constructing individual
cells with tui_draw. "
+ + "Combine with tui_locate for precise
positioning.",
+ Map.of("shape", propDef("string",
+ "Shape to draw: box (rectangle border), highlight
(background color on existing text like a marker pen), "
+ + "underline (horizontal
line), arrow-down, arrow-up, arrow-left, arrow-right, "
+ + "text (draw text string at
position)"),
+ "x", propDef("integer", "X coordinate (column) of the
shape origin"),
+ "y", propDef("integer", "Y coordinate (row) of the
shape origin"),
+ "width", propDef("integer", "Width of the shape (for
box, highlight, underline)"),
+ "height", propDef("integer", "Height of the shape (for
box, highlight). Defaults to 1."),
+ "length", propDef("integer", "Length of arrows"),
+ "text", propDef("string", "Text content to draw (for
text shape)"),
+ "color", propDef("string",
+ "Color: red, green, blue, yellow, cyan,
magenta, white, gray, black. Default: red for box/underline/arrow, yellow for
highlight."),
+ "duration",
+ propDef("integer", "Auto-dismiss after this many
seconds. If omitted, stays until cleared."),
+ "append", propDef("boolean",
+ "If true, add to existing drawing instead of
replacing it. Default false.")),
+ List.of("shape", "x", "y")));
+
// --- Structured data tools ---
toolList.add(toolDef(
@@ -446,6 +477,39 @@ class TuiMcpServer {
+ "If no name is provided, returns the
README for the currently selected integration.",
Map.of("name", propDef("string",
"Integration name. If omitted, uses the currently
selected integration."))));
+ toolList.add(toolDef(
+ "tui_control",
+ "Controls the selected integration: stop/start routes,
restart, stop, or kill the process. "
+ + "Actions: stop-routes (or pause) — suspend
all routes; "
+ + "start-routes (or resume) — resume all
routes; "
+ + "restart — gracefully restart the
integration; "
+ + "stop — gracefully stop the process; "
+ + "kill — forcefully terminate the process.",
+ Map.of("action", propDef("string",
+ "Control action: stop-routes, start-routes, pause,
resume, restart, stop, or kill")),
+ List.of("action")));
+ toolList.add(toolDef(
+ "tui_get_files",
+ "Returns source files from the selected integration's
directory. "
+ + "Without a file parameter, returns the list
of files (name, size, type). "
+ + "With a file parameter, returns the file's
content. "
+ + "Useful for reading route source code,
configuration, and other integration files.",
+ Map.of("name", propDef("string",
+ "Integration name. If omitted, uses the currently
selected integration."),
+ "file", propDef("string",
+ "Filename to read. If omitted, returns the
file list instead."))));
+ toolList.add(toolDef(
+ "tui_locate",
+ "Locates elements on the TUI screen and returns their exact
screen coordinates (x, y, width, height). "
+ + "Use 'text' to find text on screen with proper
wide-character handling (emoji, CJK). "
+ + "Use 'node' or 'nodes' to find diagram nodes
by ID. "
+ + "Returns coordinates suitable for tui_draw.",
+ Map.of("text", propDef("string",
+ "Text to search for on screen. Returns all matches
with screen coordinates."),
+ "node", propDef("string",
+ "Single diagram node ID to locate (routeId or
nodeId)."),
+ "nodes", propDef("array",
+ "Array of diagram node IDs to locate. Returns
individual rects plus combined bounds."))));
JsonObject result = new JsonObject();
result.put("tools", toolList);
@@ -492,6 +556,10 @@ class TuiMcpServer {
case "tui_filter" -> callFilter(args);
case "tui_toggle_trace_display" ->
callToggleTraceDisplay(args);
case "tui_get_readme" -> callGetReadme(args);
+ case "tui_control" -> callControl(args);
+ case "tui_get_files" -> callGetFiles(args);
+ case "tui_locate" -> callLocate(args);
+ case "tui_draw_shape" -> callDrawShape(args);
default -> {
isError = true;
yield "Unknown tool: " + toolName;
@@ -533,6 +601,13 @@ class TuiMcpServer {
}
}
+ private void addFooterActions(JsonObject result) {
+ JsonArray actions = monitor.getFooterActionsAsJson();
+ if (actions != null && !actions.isEmpty()) {
+ result.put("actions", actions);
+ }
+ }
+
private String callGetScreen(Map<String, Object> args) {
Buffer buf = monitor.getLastBuffer();
if (buf == null) {
@@ -593,6 +668,11 @@ class TuiMcpServer {
result.put("keystrokesVisible", monitor.isKeystrokesVisible());
result.put("captionVisible", monitor.isCaptionVisible());
addSelectionContext(result);
+ addFooterActions(result);
+ JsonObject diagramState = monitor.getDiagramState();
+ if (diagramState != null) {
+ result.put("diagram", diagramState);
+ }
return Jsoner.serialize(result);
}
@@ -625,9 +705,11 @@ class TuiMcpServer {
JsonObject result = new JsonObject();
String tab = (String) args.get("tab");
String integration = (String) args.get("integration");
+ String route = args.get("route") instanceof String s ? s : null;
+ String node = args.get("node") instanceof String s ? s : null;
- if (tab == null && integration == null) {
- result.put("error", "Provide at least one of: tab, integration");
+ if (tab == null && integration == null && route == null && node ==
null) {
+ result.put("error", "Provide at least one of: tab, integration,
route, node");
result.put("availableTabs", toJsonArray(monitor.getTabNames()));
result.put("availableIntegrations",
toJsonArray(monitor.getIntegrationNames()));
return Jsoner.serialize(result);
@@ -661,6 +743,25 @@ class TuiMcpServer {
}
}
+ // Diagram route/node navigation (route selection in topology doesn't
need render wait)
+ if (node == null && route != null) {
+ String selected = monitor.navigateDiagramToRoute(route);
+ if (selected != null) {
+ result.put("selectedRoute", route);
+ } else {
+ result.put("routeError", "Route not found in diagram: " +
route);
+ }
+ }
+
+ // When drilling down with a node, we first drill into the route, then
wait
+ // for render to populate the EIP node boxes, then select the node
+ if (node != null) {
+ // Drill into the route first (sets topologyMode=false)
+ if (route != null) {
+ monitor.navigateDiagramToNode(route, null);
+ }
+ }
+
long beforeGen = monitor.getRenderGeneration();
long deadline = System.currentTimeMillis() + 2000;
while (System.currentTimeMillis() < deadline) {
@@ -674,11 +775,26 @@ class TuiMcpServer {
break;
}
}
+
+ // Now that the render has populated EIP node boxes, select the node
+ if (node != null) {
+ String selected = monitor.navigateDiagramToNode(null, node);
+ if (selected != null) {
+ result.put("selectedNode", node);
+ if (route != null) {
+ result.put("drillDownRoute", route);
+ }
+ } else {
+ result.put("nodeError", "Node not found: " + node
+ + (route != null ? " in route " +
route : ""));
+ }
+ }
Buffer buf = monitor.getLastBuffer();
if (buf != null) {
result.put("screen", ExportRequest.export(buf).text().toString());
}
addSelectionContext(result);
+ addFooterActions(result);
return Jsoner.serialize(result);
}
@@ -742,6 +858,7 @@ class TuiMcpServer {
result.put("screen", ExportRequest.export(buf).text().toString());
}
addSelectionContext(result);
+ addFooterActions(result);
return Jsoner.serialize(result);
}
@@ -1098,6 +1215,101 @@ class TuiMcpServer {
return Jsoner.serialize(result);
}
+ private String callControl(Map<String, Object> args) {
+ String action = (String) args.get("action");
+ if (action == null || action.isBlank()) {
+ return "Error: action is required";
+ }
+ return monitor.controlIntegration(action);
+ }
+
+ private String callGetFiles(Map<String, Object> args) {
+ String name = args.get("name") instanceof String s ? s : null;
+ String file = args.get("file") instanceof String s ? s : null;
+ JsonObject response = monitor.getFiles(name, file);
+ if (response == null) {
+ return name != null
+ ? "No source files found for integration '" + name + "'"
+ : "No source files found for the selected integration";
+ }
+ return Jsoner.serialize(response);
+ }
+
+ @SuppressWarnings("unchecked")
+ private String callLocate(Map<String, Object> args) {
+ String text = args.get("text") instanceof String s ? s : null;
+ String node = args.get("node") instanceof String s ? s : null;
+ List<String> nodes = args.get("nodes") instanceof List<?> list
+ ? ((List<Object>) list).stream().map(Object::toString).toList()
+ : null;
+
+ JsonObject result = new JsonObject();
+
+ if (text != null) {
+ JsonArray matches = monitor.locateText(text);
+ result.put("matches", matches);
+ } else if (node != null || nodes != null) {
+ List<String> ids = nodes != null ? nodes : List.of(node);
+ JsonObject located = monitor.locateNodes(ids);
+ if (located != null) {
+ result.put("matches", located.get("matches"));
+ if (located.containsKey("bounds")) {
+ result.put("bounds", located.get("bounds"));
+ }
+ } else {
+ result.put("matches", new JsonArray());
+ }
+ } else {
+ result.put("error", "Provide 'text', 'node', or 'nodes'
parameter");
+ }
+
+ return Jsoner.serialize(result);
+ }
+
+ private String callDrawShape(Map<String, Object> args) {
+ String shape = args.get("shape") instanceof String s ? s : null;
+ if (shape == null) {
+ return "Error: 'shape' is required";
+ }
+ int x = args.get("x") instanceof Number n ? n.intValue() : 0;
+ int y = args.get("y") instanceof Number n ? n.intValue() : 0;
+ int width = args.get("width") instanceof Number n ? n.intValue() : 0;
+ int height = args.get("height") instanceof Number n ? n.intValue() :
Math.max(1, 0);
+ int length = args.get("length") instanceof Number n ? n.intValue() : 5;
+ String text = args.get("text") instanceof String s ? s : null;
+ String colorName = args.get("color") instanceof String s ? s : null;
+ int duration = args.get("duration") instanceof Number n ? n.intValue()
: 0;
+
+ Color color = DrawOverlay.parseColor(colorName);
+ if (color == null) {
+ color = "highlight".equals(shape) ? Color.YELLOW : Color.RED;
+ }
+
+ if (height < 1) {
+ height = 1;
+ }
+
+ List<DrawOverlay.DrawCell> cells;
+ if ("text".equals(shape)) {
+ cells = DrawOverlay.generateText(x, y, text != null ? text : "",
color);
+ } else {
+ cells = DrawOverlay.generateShape(shape, x, y, width, height,
length, color);
+ }
+
+ if (cells.isEmpty() && !"text".equals(shape)) {
+ return "Unknown shape: " + shape
+ + ". Use: box, highlight, underline, arrow-down, arrow-up,
arrow-left, arrow-right, text";
+ }
+
+ boolean append = args.get("append") instanceof Boolean b && b;
+ if (append) {
+ monitor.appendDrawing(cells);
+ } else {
+ monitor.setDrawing(cells, duration);
+ }
+ return "Drew " + shape + " at (" + x + "," + y + ")";
+ }
+
private static JsonArray toJsonArray(List<String> list) {
JsonArray arr = new JsonArray();
arr.addAll(list);
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
index d2187803f491..c53d2ec04205 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyDiagramWidget.java
@@ -388,7 +388,8 @@ public class TopologyDiagramWidget implements Widget {
}
private static boolean isExternal(TopologyLayoutNode node) {
- return "external-in".equals(node.nodeType) ||
"external-out".equals(node.nodeType);
+ return "external-in".equals(node.nodeType) ||
"external-out".equals(node.nodeType)
+ || "external".equals(node.nodeType);
}
static List<String> wrapText(String text, int maxWidth) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyMinimapWidget.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyMinimapWidget.java
index f18ae2803244..0ebeeb90afd5 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyMinimapWidget.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/diagram/TopologyMinimapWidget.java
@@ -54,8 +54,10 @@ public class TopologyMinimapWidget implements Widget {
}
for (TopologyLayoutNode node : layout.nodes) {
+ if (node.nodeType != null && node.nodeType.startsWith("external"))
{
+ continue;
+ }
boolean isCurrent = node.routeId != null &&
node.routeId.equals(currentRouteId);
- boolean isExternal = "external-in".equals(node.nodeType) ||
"external-out".equals(node.nodeType);
int col = node.x * mapW / totalW;
int row = node.y * mapH / totalH;
@@ -67,14 +69,7 @@ public class TopologyMinimapWidget implements Widget {
col = Math.max(0, col);
row = Math.max(0, row);
- Style style;
- if (isCurrent) {
- style = CURRENT_STYLE;
- } else if (isExternal) {
- style = EXTERNAL_STYLE;
- } else {
- style = OTHER_STYLE;
- }
+ Style style = isCurrent ? CURRENT_STYLE : OTHER_STYLE;
drawMiniBox(buffer, area, col, row, nodeW, nodeH, style,
isCurrent);
}