You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by gn...@apache.org on 2026/04/30 07:33:12 UTC

(camel) branch main updated: CAMEL-21975: Add route-diagram command to display Camel route diagrams in terminal

This is an automated email from the ASF dual-hosted git repository.

gnodet 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 6b103ea78335 CAMEL-21975: Add route-diagram command to display Camel route diagrams in terminal
6b103ea78335 is described below

commit 6b103ea783357819372c960b6c7a3ab680c33bb1
Author: Guillaume Nodet <gn...@gmail.com>
AuthorDate: Thu Apr 30 09:33:05 2026 +0200

    CAMEL-21975: Add route-diagram command to display Camel route diagrams in terminal
    
    - New `camel cmd route-diagram` command that renders visual diagrams of Camel routes
    - Inline image rendering via Kitty, iTerm2, Sixel protocols with text-based tree fallback
    - Supports --output for PNG export, --filter for route selection, --theme for color customization
    - Layout engine handles branching EIPs (choice, multicast, doTry) with merge lines
    - Color palette based on GitHub Primer design system (dark/light/transparent presets)
    - Also adds diagram view (`d` key) in `camel tui monitor` Routes tab
---
 .../ROOT/images/jbang/route-diagram-dark.png       | Bin 0 -> 151329 bytes
 .../ROOT/images/jbang/route-diagram-light.png      | Bin 0 -> 144838 bytes
 .../camel-jbang-cmd-route-diagram.adoc             |  29 ++
 .../ROOT/pages/jbang-commands/camel-jbang-cmd.adoc |   1 +
 .../META-INF/camel-jbang-commands-metadata.json    |   2 +-
 .../dsl/jbang/core/commands/CamelJBangMain.java    |   1 +
 .../commands/action/CamelRouteDiagramAction.java   | 213 +++++++++
 .../commands/action/RouteDiagramLayoutEngine.java  | 262 +++++++++++
 .../core/commands/action/RouteDiagramRenderer.java | 419 ++++++++++++++++++
 .../action/CamelRouteDiagramActionTest.java        | 481 +++++++++++++++++++++
 10 files changed, 1407 insertions(+), 1 deletion(-)

diff --git a/docs/user-manual/modules/ROOT/images/jbang/route-diagram-dark.png b/docs/user-manual/modules/ROOT/images/jbang/route-diagram-dark.png
new file mode 100644
index 000000000000..70699e1b9348
Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/route-diagram-dark.png differ
diff --git a/docs/user-manual/modules/ROOT/images/jbang/route-diagram-light.png b/docs/user-manual/modules/ROOT/images/jbang/route-diagram-light.png
new file mode 100644
index 000000000000..8ec2e522c132
Binary files /dev/null and b/docs/user-manual/modules/ROOT/images/jbang/route-diagram-light.png differ
diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
new file mode 100644
index 000000000000..29848d309f16
--- /dev/null
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd-route-diagram.adoc
@@ -0,0 +1,29 @@
+
+// AUTO-GENERATED by camel-package-maven-plugin - DO NOT EDIT THIS FILE
+= camel cmd route-diagram
+
+Display Camel route diagram in the terminal
+
+
+== Usage
+
+[source,bash]
+----
+camel cmd route-diagram [options]
+----
+
+
+
+== Options
+
+[cols="2,5,1,2",options="header"]
+|===
+| Option | Description | Default | Type
+| `--filter` | Filter route by filename or route id |  | String
+| `--output` | Save diagram to a PNG file instead of displaying in terminal |  | String
+| `--theme,--colors` | Color theme preset (dark, light, transparent) or custom colors (e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or ANSI color names (e.g. from=seagreen:to=steelblue). Use bg= for transparent. Can also be set via DIAGRAM_COLORS env var. | dark | String
+| `--width` | Image width in pixels (0 = auto) | 0 | int
+| `-h,--help` | Display the help and sub-commands |  | boolean
+|===
+
+
diff --git a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
index 0d0c1ec7544b..5ce6c69d3117 100644
--- a/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
+++ b/docs/user-manual/modules/ROOT/pages/jbang-commands/camel-jbang-cmd.adoc
@@ -28,6 +28,7 @@ camel cmd [options]
 | xref:jbang-commands/camel-jbang-cmd-reload.adoc[reload] | Trigger reloading Camel
 | xref:jbang-commands/camel-jbang-cmd-reset-stats.adoc[reset-stats] | Reset performance statistics
 | xref:jbang-commands/camel-jbang-cmd-resume-route.adoc[resume-route] | Resume Camel routes
+| xref:jbang-commands/camel-jbang-cmd-route-diagram.adoc[route-diagram] | Display Camel route diagram in the terminal
 | xref:jbang-commands/camel-jbang-cmd-route-structure.adoc[route-structure] | Dump Camel route structure
 | xref:jbang-commands/camel-jbang-cmd-send.adoc[send] | Send messages to endpoints
 | xref:jbang-commands/camel-jbang-cmd-start-group.adoc[start-group] | Start Camel route groups
diff --git a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
index fe71d31564b3..5c2c7c9f22ae 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
+++ b/dsl/camel-jbang/camel-jbang-core/src/generated/resources/META-INF/camel-jbang-commands-metadata.json
@@ -2,7 +2,7 @@
   "commands": [
     { "name": "bind", "fullName": "bind", "description": "DEPRECATED: Bind source and sink Kamelets as a new Camel integration", "deprecated": true, "sourceClass": "org.apache.camel.dsl.jbang.core.commands.bind.Bind", "options": [ { "names": "--error-handler", "description": "Add error handler (none|log|sink:<endpoint>). Sink endpoints are expected in the format [[apigroup\/]version:]kind:[namespace\/]name, plain Camel URIs or Kamelet name.", "javaType": "java.lang.String", "type": "stri [...]
     { "name": "catalog", "fullName": "catalog", "description": "List artifacts from Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.catalog.CatalogCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "component", "fullName": "catalog component", "description": "List components from the Camel Catalog", "sourceClass": "org.apache.camel.dsl.jbang.co [...]
-    { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...]
+    { "name": "cmd", "fullName": "cmd", "description": "Performs commands in the running Camel integrations, such as start\/stop route, or change logging levels.", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.action.CamelAction", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "browse", "fullName": "cmd browse", "description": "Browse pending messages on endpoints [...]
     { "name": "completion", "fullName": "completion", "description": "Generate completion script for bash\/zsh", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Complete", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ] },
     { "name": "config", "fullName": "config", "description": "Get and set user configuration values", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config.ConfigCommand", "options": [ { "names": "-h,--help", "description": "Display the help and sub-commands", "javaType": "boolean", "type": "boolean" } ], "subcommands": [ { "name": "get", "fullName": "config get", "description": "Display user configuration value", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.config. [...]
     { "name": "debug", "fullName": "debug", "description": "Debug local Camel integration", "sourceClass": "org.apache.camel.dsl.jbang.core.commands.Debug", "options": [ { "names": "--ago", "description": "Use ago instead of yyyy-MM-dd HH:mm:ss in timestamp.", "javaType": "boolean", "type": "boolean" }, { "names": "--background", "description": "Run in the background", "defaultValue": "false", "javaType": "boolean", "type": "boolean" }, { "names": "--background-wait", "description": "To  [...]
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
index bd03af9ee39c..477769c86125 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/CamelJBangMain.java
@@ -108,6 +108,7 @@ public class CamelJBangMain implements Callable<Integer> {
                         .addSubcommand("reload", new CommandLine(new CamelReloadAction(this)))
                         .addSubcommand("reset-stats", new CommandLine(new CamelResetStatsAction(this)))
                         .addSubcommand("resume-route", new CommandLine(new CamelRouteResumeAction(this)))
+                        .addSubcommand("route-diagram", new CommandLine(new CamelRouteDiagramAction(this)))
                         .addSubcommand("route-structure", new CommandLine(new CamelRouteStructureAction(this)))
                         .addSubcommand("send", new CommandLine(new CamelSendAction(this)))
                         .addSubcommand("start-group", new CommandLine(new CamelRouteGroupStartAction(this)))
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
new file mode 100644
index 000000000000..e78f65cf5843
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramAction.java
@@ -0,0 +1,213 @@
+/*
+ * 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.action;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import javax.imageio.ImageIO;
+
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.RouteInfo;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramRenderer.DiagramColors;
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.support.PatternHelper;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.jline.terminal.Terminal;
+import org.jline.terminal.TerminalBuilder;
+import org.jline.terminal.impl.TerminalGraphics;
+import org.jline.terminal.impl.TerminalGraphicsManager;
+import picocli.CommandLine;
+import picocli.CommandLine.Command;
+
+@Command(name = "route-diagram", description = "Display Camel route diagram in the terminal", sortOptions = false,
+         showDefaultValues = true)
+public class CamelRouteDiagramAction extends ActionBaseCommand {
+
+    @CommandLine.Parameters(description = "Name or pid of running Camel integration", arity = "0..1")
+    String name = "*";
+
+    @CommandLine.Option(names = { "--filter" },
+                        description = "Filter route by filename or route id")
+    String filter;
+
+    @CommandLine.Option(names = { "--width" },
+                        description = "Image width in pixels (0 = auto)", defaultValue = "0")
+    int width;
+
+    @CommandLine.Option(names = { "--output" },
+                        description = "Save diagram to a PNG file instead of displaying in terminal")
+    String output;
+
+    @CommandLine.Option(names = { "--theme", "--colors" },
+                        description = "Color theme preset (dark, light, transparent) or custom colors "
+                                      + "(e.g. bg=#1e1e1e:from=#2e7d32:to=#1565c0). Values can be #hex or "
+                                      + "ANSI color names (e.g. from=seagreen:to=steelblue). "
+                                      + "Use bg= for transparent. Can also be set via DIAGRAM_COLORS env var.",
+                        defaultValue = "dark")
+    String theme;
+
+    public CamelRouteDiagramAction(CamelJBangMain main) {
+        super(main);
+    }
+
+    @Override
+    public Integer doCall() throws Exception {
+        System.setProperty("java.awt.headless", "true");
+
+        String colorSpec = System.getenv("DIAGRAM_COLORS");
+        DiagramColors colors = DiagramColors.parse(colorSpec != null ? colorSpec : theme);
+
+        List<Long> pids = findPids(name);
+        if (pids.isEmpty()) {
+            return 1;
+        } else if (pids.size() > 1) {
+            printer().println("Name or pid " + name + " matches " + pids.size()
+                              + " running Camel integrations. Specify a name or PID that matches exactly one.");
+            return 1;
+        }
+
+        long pid = pids.get(0);
+
+        Path outputFile = prepareAction(Long.toString(pid), "route-structure", root -> {
+            root.put("filter", "*");
+            root.put("brief", false);
+        });
+
+        try {
+            JsonObject jo = getJsonObject(outputFile);
+            if (jo == null) {
+                printer().println("Response from running Camel with PID " + pid + " not received within 5 seconds");
+                return 1;
+            }
+
+            List<RouteInfo> routes = parseRoutes(jo);
+            if (routes.isEmpty()) {
+                printer().println("No routes found");
+                return 0;
+            }
+
+            if (filter != null) {
+                routes = routes.stream()
+                        .filter(r -> (r.routeId != null && PatternHelper.matchPattern(r.routeId, filter))
+                                || (r.source != null && PatternHelper.matchPattern(r.source, filter)))
+                        .toList();
+            }
+
+            if (routes.isEmpty()) {
+                printer().println("No routes match filter: " + filter);
+                return 0;
+            }
+
+            RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+            RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+
+            List<LayoutRoute> layoutRoutes = new ArrayList<>();
+            int currentY = RouteDiagramLayoutEngine.PADDING;
+            for (RouteInfo route : routes) {
+                LayoutRoute lr = engine.layoutRoute(route, currentY);
+                layoutRoutes.add(lr);
+                currentY = lr.maxY + RouteDiagramLayoutEngine.V_GAP;
+            }
+
+            java.awt.image.BufferedImage image;
+            try {
+                image = renderer.renderDiagram(layoutRoutes, currentY, colors);
+            } catch (IllegalStateException e) {
+                printer().println(e.getMessage());
+                return 1;
+            }
+
+            if (output != null) {
+                File file = new File(output);
+                File parentDir = file.getParentFile();
+                if (parentDir != null) {
+                    parentDir.mkdirs();
+                }
+                ImageIO.write(image, "PNG", file);
+                printer().println("Diagram saved to: " + file.getAbsolutePath());
+            } else {
+                try (Terminal terminal = TerminalBuilder.builder().system(true).build()) {
+                    try {
+                        Optional<TerminalGraphics> protocol = TerminalGraphicsManager.getBestProtocol(terminal);
+                        if (protocol.isPresent()) {
+                            TerminalGraphics.ImageOptions opts = new TerminalGraphics.ImageOptions()
+                                    .preserveAspectRatio(true);
+                            if (width > 0) {
+                                opts.width(width);
+                            }
+                            protocol.get().displayImage(terminal, image, opts);
+                            terminal.writer().println();
+                            terminal.flush();
+                        } else {
+                            printer().println(
+                                    "Terminal does not support graphics protocols (Kitty, iTerm2, or Sixel).");
+                            printer().println(
+                                    "Try running in a supported terminal: Kitty, iTerm2, WezTerm, Ghostty, or VS Code.");
+                            renderer.printTextDiagram(routes, printer());
+                        }
+                    } catch (IOException | UnsupportedOperationException e) {
+                        printer().println("Failed to display diagram in terminal: " + e.getMessage());
+                        printer().println("Falling back to text diagram.");
+                        renderer.printTextDiagram(routes, printer());
+                    }
+                }
+            }
+
+            return 0;
+        } finally {
+            PathUtils.deleteFile(outputFile);
+        }
+    }
+
+    List<RouteInfo> parseRoutes(JsonObject jo) {
+        List<RouteInfo> routes = new ArrayList<>();
+        JsonArray arr = (JsonArray) jo.get("routes");
+        if (arr == null) {
+            return routes;
+        }
+
+        for (int i = 0; i < arr.size(); i++) {
+            JsonObject o = (JsonObject) arr.get(i);
+            RouteInfo route = new RouteInfo();
+            route.routeId = o.getString("routeId");
+            route.source = CamelRouteStructureAction.extractSourceName(o.getString("source"));
+
+            List<JsonObject> lines = o.getCollection("code");
+            if (lines != null) {
+                for (JsonObject line : lines) {
+                    NodeInfo node = new NodeInfo();
+                    node.type = line.getString("type");
+                    node.code = Jsoner.unescape(line.getString("code"));
+                    Integer level = line.getInteger("level");
+                    node.level = level != null ? level : 0;
+                    route.nodes.add(node);
+                }
+            }
+            routes.add(route);
+        }
+        return routes;
+    }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramLayoutEngine.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramLayoutEngine.java
new file mode 100644
index 000000000000..6b701acaf9d2
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramLayoutEngine.java
@@ -0,0 +1,262 @@
+/*
+ * 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.action;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+class RouteDiagramLayoutEngine {
+
+    static final int SCALE = 2;
+    static final int NODE_WIDTH = 180 * SCALE;
+    static final int NODE_HEIGHT = 32 * SCALE;
+    static final int H_GAP = 30 * SCALE;
+    static final int V_GAP = 40 * SCALE;
+    static final int PADDING = 30 * SCALE;
+    static final int LABEL_OFFSET = 24 * SCALE;
+
+    private static final Set<String> BRANCHING_EIPS = Set.of(
+            "choice", "multicast", "doTry", "loadBalance", "recipientList");
+
+    static class NodeInfo {
+        String type;
+        String code;
+        int level;
+    }
+
+    static class RouteInfo {
+        String routeId;
+        String source;
+        List<NodeInfo> nodes = new ArrayList<>();
+    }
+
+    static class TreeNode {
+        final NodeInfo info;
+        TreeNode parent;
+        List<TreeNode> children = new ArrayList<>();
+        int subtreeWidth;
+        LayoutNode layoutNode;
+
+        TreeNode(NodeInfo info) {
+            this.info = info;
+        }
+    }
+
+    static class LayoutNode {
+        String label;
+        String type;
+        int x;
+        int y;
+        LayoutNode parentNode;
+        TreeNode treeNode;
+        boolean connectFromMerge;
+        int mergeY;
+        int mergeCx;
+    }
+
+    static class LayoutRoute {
+        String routeId;
+        String source;
+        int labelY;
+        int maxX;
+        int maxY;
+        List<LayoutNode> nodes = new ArrayList<>();
+    }
+
+    static TreeNode buildTree(List<NodeInfo> nodes) {
+        if (nodes.isEmpty()) {
+            return null;
+        }
+        TreeNode root = new TreeNode(nodes.get(0));
+        TreeNode current = root;
+
+        for (int i = 1; i < nodes.size(); i++) {
+            NodeInfo ni = nodes.get(i);
+            TreeNode tn = new TreeNode(ni);
+
+            if (ni.level > current.info.level) {
+                current.children.add(tn);
+                tn.parent = current;
+            } else if (ni.level == current.info.level) {
+                TreeNode parent = current.parent;
+                if (parent != null) {
+                    parent.children.add(tn);
+                    tn.parent = parent;
+                } else {
+                    root.children.add(tn);
+                    tn.parent = root;
+                }
+            } else {
+                TreeNode ancestor = current.parent;
+                while (ancestor != null && ancestor.info.level >= ni.level) {
+                    ancestor = ancestor.parent;
+                }
+                if (ancestor != null) {
+                    ancestor.children.add(tn);
+                    tn.parent = ancestor;
+                } else {
+                    root.children.add(tn);
+                    tn.parent = root;
+                }
+            }
+            current = tn;
+        }
+        return root;
+    }
+
+    static String cleanLabel(String code) {
+        if (code == null) {
+            return "";
+        }
+        return code.replaceFirst("^\\.", "");
+    }
+
+    LayoutRoute layoutRoute(RouteInfo route, int startY) {
+        LayoutRoute lr = new LayoutRoute();
+        lr.routeId = route.routeId;
+        lr.source = route.source;
+        lr.labelY = startY;
+
+        TreeNode tree = buildTree(route.nodes);
+        if (tree == null) {
+            lr.maxX = PADDING + NODE_WIDTH;
+            lr.maxY = startY + LABEL_OFFSET;
+            return lr;
+        }
+
+        computeSubtreeWidth(tree);
+        assignPositions(tree, PADDING, startY + LABEL_OFFSET, tree.subtreeWidth, lr);
+
+        int maxX = 0;
+        for (LayoutNode ln : lr.nodes) {
+            maxX = Math.max(maxX, ln.x + NODE_WIDTH);
+        }
+        lr.maxX = maxX + PADDING;
+
+        return lr;
+    }
+
+    private int computeSubtreeWidth(TreeNode node) {
+        if (node.children.isEmpty()) {
+            node.subtreeWidth = NODE_WIDTH;
+            return node.subtreeWidth;
+        }
+
+        if (isBranchingEip(node.info.type)) {
+            int totalWidth = 0;
+            for (int i = 0; i < node.children.size(); i++) {
+                if (i > 0) {
+                    totalWidth += H_GAP;
+                }
+                totalWidth += computeSubtreeWidth(node.children.get(i));
+            }
+            node.subtreeWidth = Math.max(NODE_WIDTH, totalWidth);
+        } else {
+            int maxChildWidth = NODE_WIDTH;
+            for (TreeNode child : node.children) {
+                maxChildWidth = Math.max(maxChildWidth, computeSubtreeWidth(child));
+            }
+            node.subtreeWidth = maxChildWidth;
+        }
+        return node.subtreeWidth;
+    }
+
+    private void assignPositions(TreeNode node, int x, int y, int parentWidth, LayoutRoute lr) {
+        int availableWidth = Math.max(node.subtreeWidth, parentWidth);
+        int nodeX = x + (availableWidth - NODE_WIDTH) / 2;
+
+        LayoutNode ln = new LayoutNode();
+        ln.label = cleanLabel(node.info.code);
+        ln.type = node.info.type;
+        ln.x = nodeX;
+        ln.y = y;
+        ln.treeNode = node;
+        node.layoutNode = ln;
+        lr.nodes.add(ln);
+
+        if (node.parent != null && node.parent.layoutNode != null) {
+            TreeNode parentNode = node.parent;
+            if (!isBranchingEip(parentNode.info.type)) {
+                int myIndex = parentNode.children.indexOf(node);
+                if (myIndex > 0) {
+                    TreeNode prevSibling = parentNode.children.get(myIndex - 1);
+                    if (isBranchingEip(prevSibling.info.type)) {
+                        ln.connectFromMerge = true;
+                        ln.mergeY = findMaxY(prevSibling) + V_GAP / 2;
+                        ln.mergeCx = prevSibling.layoutNode.x + NODE_WIDTH / 2;
+                        ln.parentNode = prevSibling.layoutNode;
+                    } else {
+                        ln.parentNode = findLastLayoutNode(prevSibling);
+                    }
+                } else {
+                    ln.parentNode = parentNode.layoutNode;
+                }
+            } else {
+                ln.parentNode = parentNode.layoutNode;
+            }
+        }
+
+        lr.maxY = Math.max(lr.maxY, y + NODE_HEIGHT + V_GAP);
+
+        if (node.children.isEmpty()) {
+            return;
+        }
+
+        int childY = y + NODE_HEIGHT + V_GAP;
+
+        if (isBranchingEip(node.info.type)) {
+            int childX = x + (availableWidth - node.subtreeWidth) / 2;
+            for (TreeNode child : node.children) {
+                assignPositions(child, childX, childY, child.subtreeWidth, lr);
+                childX += child.subtreeWidth + H_GAP;
+            }
+        } else {
+            int curY = childY;
+            for (int i = 0; i < node.children.size(); i++) {
+                TreeNode child = node.children.get(i);
+                assignPositions(child, x, curY, availableWidth, lr);
+                curY = findMaxY(child) + V_GAP;
+                if (isBranchingEip(child.info.type) && i < node.children.size() - 1) {
+                    curY += V_GAP;
+                }
+            }
+        }
+    }
+
+    static LayoutNode findLastLayoutNode(TreeNode node) {
+        if (node.children.isEmpty()) {
+            return node.layoutNode;
+        }
+        if (isBranchingEip(node.info.type)) {
+            return node.layoutNode;
+        }
+        return findLastLayoutNode(node.children.get(node.children.size() - 1));
+    }
+
+    static int findMaxY(TreeNode node) {
+        int maxY = node.layoutNode != null ? node.layoutNode.y + NODE_HEIGHT : 0;
+        for (TreeNode child : node.children) {
+            maxY = Math.max(maxY, findMaxY(child));
+        }
+        return maxY;
+    }
+
+    static boolean isBranchingEip(String type) {
+        return type != null && BRANCHING_EIPS.contains(type);
+    }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramRenderer.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramRenderer.java
new file mode 100644
index 000000000000..a49d024ed4d2
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/action/RouteDiagramRenderer.java
@@ -0,0 +1,419 @@
+/*
+ * 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.action;
+
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.FontMetrics;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.LayoutNode;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.RouteInfo;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.TreeNode;
+import org.apache.camel.dsl.jbang.core.common.Printer;
+
+import static org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.NODE_HEIGHT;
+import static org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.NODE_WIDTH;
+import static org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.PADDING;
+import static org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.SCALE;
+import static org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.V_GAP;
+
+class RouteDiagramRenderer {
+
+    private static final int ARC = 14 * SCALE;
+    private static final int FONT_SIZE_LABEL = 13 * SCALE;
+    private static final int FONT_SIZE_NODE = 12 * SCALE;
+    private static final int ARROW_SIZE = 6 * SCALE;
+    private static final int MERGE_DOT = 5 * SCALE;
+    private static final float STROKE_WIDTH = 1.5f * SCALE;
+    private static final float BORDER_STROKE_WIDTH = 1.0f * SCALE;
+    private static final int NODE_TEXT_PADDING = 16 * SCALE;
+    private static final int LABEL_TEXT_BASELINE = 14 * SCALE;
+    static final int MAX_IMAGE_DIMENSION = 16384;
+
+    private static final String DARK_COLORS
+            = "bg=#0d1117:text=#f0f6fc:arrow=#656c76:label=#d1d7e0:from=#238636:to=#1f6feb:eip=#8957e5"
+              + ":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a";
+    private static final String LIGHT_COLORS
+            = "bg=#f6f8fa:text=#f0f6fc:arrow=#59636e:label=#1f2328:from=#238636:to=#1f6feb:eip=#8957e5"
+              + ":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a";
+    private static final String TRANSPARENT_COLORS
+            = "bg=:text=#f0f6fc:arrow=#656c76:label=#d1d7e0:from=#238636:to=#1f6feb:eip=#8957e5"
+              + ":choice=#d29922:default=#3d444d:transform=#1b7c83:processor=#bf4b8a";
+
+    private static final Map<String, String> COLOR_PRESETS = Map.of(
+            "dark", DARK_COLORS,
+            "light", LIGHT_COLORS,
+            "transparent", TRANSPARENT_COLORS);
+
+    static class DiagramColors {
+        private Color bg;
+        private Color text;
+        private Color arrow;
+        private Color routeLabel;
+        private Color nodeFrom;
+        private Color nodeTo;
+        private Color nodeEip;
+        private Color nodeChoice;
+        private Color nodeDefault;
+        private Color nodeTransform;
+        private Color nodeProcessor;
+
+        static DiagramColors parse(String spec) {
+            String resolved = COLOR_PRESETS.getOrDefault(spec, spec);
+            Map<String, String> map = new HashMap<>();
+            for (String entry : DARK_COLORS.split(":")) {
+                int eq = entry.indexOf('=');
+                if (eq > 0) {
+                    map.put(entry.substring(0, eq), entry.substring(eq + 1));
+                }
+            }
+            for (String entry : resolved.split(":")) {
+                int eq = entry.indexOf('=');
+                if (eq > 0) {
+                    map.put(entry.substring(0, eq), entry.substring(eq + 1));
+                }
+            }
+            DiagramColors c = new DiagramColors();
+            c.bg = parseColor(map.get("bg"));
+            c.text = parseColor(map.getOrDefault("text", "#ffffff"));
+            c.arrow = parseColor(map.getOrDefault("arrow", "#b4b4b4"));
+            c.routeLabel = parseColor(map.getOrDefault("label", "#c8c8c8"));
+            c.nodeFrom = parseColor(map.getOrDefault("from", "#2e7d32"));
+            c.nodeTo = parseColor(map.getOrDefault("to", "#1565c0"));
+            c.nodeEip = parseColor(map.getOrDefault("eip", "#9c27b0"));
+            c.nodeChoice = parseColor(map.getOrDefault("choice", "#e65100"));
+            c.nodeDefault = parseColor(map.getOrDefault("default", "#455a64"));
+            c.nodeTransform = parseColor(map.getOrDefault("transform", "#00838f"));
+            c.nodeProcessor = parseColor(map.getOrDefault("processor", "#d84315"));
+            return c;
+        }
+
+        private static Color parseColor(String value) {
+            if (value == null || value.isEmpty()) {
+                return null;
+            }
+            if (value.startsWith("#")) {
+                try {
+                    return new Color(Integer.parseInt(value.substring(1), 16));
+                } catch (NumberFormatException e) {
+                    return null;
+                }
+            }
+            Integer idx = org.jline.utils.Colors.rgbColor(value);
+            if (idx != null) {
+                return new Color(org.jline.utils.Colors.rgbColor(idx.intValue()));
+            }
+            return null;
+        }
+
+        Color getBg() {
+            return bg;
+        }
+
+        Color getText() {
+            return text;
+        }
+
+        Color getArrow() {
+            return arrow;
+        }
+
+        Color getRouteLabel() {
+            return routeLabel;
+        }
+
+        Color getNodeFrom() {
+            return nodeFrom;
+        }
+
+        Color getNodeTo() {
+            return nodeTo;
+        }
+
+        Color getNodeEip() {
+            return nodeEip;
+        }
+
+        Color getNodeChoice() {
+            return nodeChoice;
+        }
+
+        Color getNodeDefault() {
+            return nodeDefault;
+        }
+
+        Color getNodeTransform() {
+            return nodeTransform;
+        }
+
+        Color getNodeProcessor() {
+            return nodeProcessor;
+        }
+    }
+
+    BufferedImage renderDiagram(List<LayoutRoute> layoutRoutes, int totalHeight, DiagramColors colors) {
+        int imgWidth = layoutRoutes.stream().mapToInt(lr -> lr.maxX).max().orElse(400) + PADDING;
+        int imgHeight = totalHeight + PADDING;
+
+        if (imgWidth > MAX_IMAGE_DIMENSION || imgHeight > MAX_IMAGE_DIMENSION) {
+            throw new IllegalStateException(
+                    "Diagram too large (" + imgWidth / SCALE + "x" + imgHeight / SCALE
+                                            + " logical pixels). Use --filter to narrow the routes displayed.");
+        }
+
+        int imageType = colors.getBg() == null ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB;
+        BufferedImage image = new BufferedImage(imgWidth, imgHeight, imageType);
+        Graphics2D g = image.createGraphics();
+        try {
+            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+            g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+            g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
+            g.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
+
+            if (colors.getBg() != null) {
+                g.setColor(colors.getBg());
+                g.fillRect(0, 0, imgWidth, imgHeight);
+            }
+
+            for (LayoutRoute lr : layoutRoutes) {
+                drawRoute(g, lr, colors);
+            }
+        } finally {
+            g.dispose();
+        }
+        return image;
+    }
+
+    private void drawRoute(Graphics2D g, LayoutRoute lr, DiagramColors colors) {
+        g.setColor(colors.getRouteLabel());
+        g.setFont(new Font("SansSerif", Font.BOLD, FONT_SIZE_LABEL));
+        String label = lr.routeId;
+        if (lr.source != null && !lr.source.isEmpty()) {
+            label += " (" + lr.source + ")";
+        }
+        g.drawString(label, PADDING, lr.labelY + LABEL_TEXT_BASELINE);
+
+        g.setStroke(new BasicStroke(STROKE_WIDTH));
+
+        for (LayoutNode ln : lr.nodes) {
+            if (RouteDiagramLayoutEngine.isBranchingEip(ln.type) && ln.treeNode != null
+                    && !ln.treeNode.children.isEmpty()) {
+                drawMergeLines(g, ln, colors);
+            }
+        }
+
+        for (LayoutNode ln : lr.nodes) {
+            if (ln.parentNode != null) {
+                if (ln.connectFromMerge) {
+                    drawArrowFromMerge(g, ln, colors);
+                } else {
+                    drawArrow(g, ln.parentNode, ln, colors);
+                }
+            }
+        }
+
+        for (LayoutNode ln : lr.nodes) {
+            drawNode(g, ln, colors);
+        }
+    }
+
+    private void drawMergeLines(Graphics2D g, LayoutNode branchingNode, DiagramColors colors) {
+        TreeNode tn = branchingNode.treeNode;
+        if (tn.children.isEmpty()) {
+            return;
+        }
+
+        TreeNode parentNode = tn.parent;
+        if (parentNode == null) {
+            return;
+        }
+        int myIndex = parentNode.children.indexOf(tn);
+        if (myIndex < 0 || myIndex >= parentNode.children.size() - 1) {
+            return;
+        }
+
+        int branchesMaxY = RouteDiagramLayoutEngine.findMaxY(tn);
+        int mergeY = branchesMaxY + V_GAP / 2;
+
+        g.setColor(colors.getArrow());
+        g.setStroke(new BasicStroke(STROKE_WIDTH));
+
+        int minCx = Integer.MAX_VALUE;
+        int maxCx = Integer.MIN_VALUE;
+        for (TreeNode child : tn.children) {
+            LayoutNode lastNode = RouteDiagramLayoutEngine.findLastLayoutNode(child);
+            if (lastNode != null) {
+                int cx = lastNode.x + NODE_WIDTH / 2;
+                int by = lastNode.y + NODE_HEIGHT;
+                g.drawLine(cx, by, cx, mergeY);
+                minCx = Math.min(minCx, cx);
+                maxCx = Math.max(maxCx, cx);
+            }
+        }
+
+        if (minCx < maxCx) {
+            g.drawLine(minCx, mergeY, maxCx, mergeY);
+        }
+
+        int mergeCx = branchingNode.x + NODE_WIDTH / 2;
+        g.fillOval(mergeCx - MERGE_DOT, mergeY - MERGE_DOT, MERGE_DOT * 2, MERGE_DOT * 2);
+    }
+
+    private void drawArrowFromMerge(Graphics2D g, LayoutNode to, DiagramColors colors) {
+        g.setColor(colors.getArrow());
+        g.setStroke(new BasicStroke(STROKE_WIDTH));
+
+        int toCx = to.x + NODE_WIDTH / 2;
+        int toTy = to.y;
+        int mergeCx = to.mergeCx;
+        int mergeY = to.mergeY;
+
+        if (mergeCx == toCx) {
+            g.drawLine(mergeCx, mergeY, toCx, toTy);
+        } else {
+            int midY = mergeY + (toTy - mergeY) / 2;
+            g.drawLine(mergeCx, mergeY, mergeCx, midY);
+            g.drawLine(mergeCx, midY, toCx, midY);
+            g.drawLine(toCx, midY, toCx, toTy);
+        }
+        drawArrowHead(g, toCx, toTy);
+    }
+
+    private void drawNode(Graphics2D g, LayoutNode node, DiagramColors colors) {
+        Color color = getNodeColor(node.type, colors);
+
+        g.setColor(color);
+        g.fillRoundRect(node.x, node.y, NODE_WIDTH, NODE_HEIGHT, ARC, ARC);
+
+        g.setColor(color.brighter());
+        g.setStroke(new BasicStroke(BORDER_STROKE_WIDTH));
+        g.drawRoundRect(node.x, node.y, NODE_WIDTH, NODE_HEIGHT, ARC, ARC);
+
+        g.setColor(colors.getText());
+        g.setFont(new Font("SansSerif", Font.PLAIN, FONT_SIZE_NODE));
+        FontMetrics fm = g.getFontMetrics();
+        String text = node.label;
+        while (fm.stringWidth(text) > NODE_WIDTH - NODE_TEXT_PADDING && text.length() > 4) {
+            text = text.substring(0, text.length() - 4) + "...";
+        }
+        int textX = node.x + (NODE_WIDTH - fm.stringWidth(text)) / 2;
+        int textY = node.y + (NODE_HEIGHT + fm.getAscent() - fm.getDescent()) / 2;
+        g.drawString(text, textX, textY);
+    }
+
+    private void drawArrow(Graphics2D g, LayoutNode from, LayoutNode to, DiagramColors colors) {
+        g.setColor(colors.getArrow());
+        g.setStroke(new BasicStroke(STROKE_WIDTH));
+
+        int fromCx = from.x + NODE_WIDTH / 2;
+        int fromBy = from.y + NODE_HEIGHT;
+        int toCx = to.x + NODE_WIDTH / 2;
+        int toTy = to.y;
+
+        if (fromCx == toCx) {
+            g.drawLine(fromCx, fromBy, toCx, toTy);
+        } else {
+            int midY = fromBy + V_GAP / 2;
+            g.drawLine(fromCx, fromBy, fromCx, midY);
+            g.drawLine(fromCx, midY, toCx, midY);
+            g.drawLine(toCx, midY, toCx, toTy);
+        }
+        drawArrowHead(g, toCx, toTy);
+    }
+
+    private void drawArrowHead(Graphics2D g, int x, int y) {
+        int[] xPoints = { x - ARROW_SIZE, x, x + ARROW_SIZE };
+        int[] yPoints = { y - ARROW_SIZE, y, y - ARROW_SIZE };
+        g.fillPolygon(xPoints, yPoints, 3);
+    }
+
+    private Color getNodeColor(String type, DiagramColors colors) {
+        if (type == null) {
+            return colors.getNodeDefault();
+        }
+        return switch (type) {
+            case "from" -> colors.getNodeFrom();
+            case "to", "toD", "wireTap", "enrich", "pollEnrich" -> colors.getNodeTo();
+            case "choice", "when", "otherwise" -> colors.getNodeChoice();
+            case "marshal", "unmarshal", "transform", "setBody", "setHeader", "setProperty",
+                    "convertBodyTo", "removeHeader", "removeHeaders", "removeProperty", "removeProperties" ->
+                colors.getNodeTransform();
+            case "bean", "process", "log", "script", "delay" -> colors.getNodeProcessor();
+            case "filter", "split", "aggregate", "multicast", "recipientList",
+                    "routingSlip", "dynamicRouter", "loadBalance",
+                    "circuitBreaker", "saga", "doTry", "doCatch", "doFinally",
+                    "onException", "onCompletion", "intercept",
+                    "loop", "resequence", "throttle", "kamelet", "pipeline", "threads" ->
+                colors.getNodeEip();
+            default -> colors.getNodeDefault();
+        };
+    }
+
+    void printTextDiagram(List<RouteInfo> routes, Printer printer) {
+        for (RouteInfo route : routes) {
+            printer.println();
+            String header = "Route: " + route.routeId;
+            if (route.source != null && !route.source.isEmpty()) {
+                header += " (" + route.source + ")";
+            }
+            printer.println(header);
+
+            TreeNode tree = RouteDiagramLayoutEngine.buildTree(route.nodes);
+            if (tree != null) {
+                printTreeNode(tree, "", true, true, printer);
+            }
+            printer.println();
+        }
+    }
+
+    private void printTreeNode(TreeNode node, String prefix, boolean isLast, boolean isRoot, Printer printer) {
+        String connector;
+        if (isRoot) {
+            connector = "  ";
+        } else if (isLast) {
+            connector = "└── ";
+        } else {
+            connector = "├── ";
+        }
+
+        String typeTag = node.info.type != null ? "[" + node.info.type + "] " : "";
+        String code = RouteDiagramLayoutEngine.cleanLabel(node.info.code);
+
+        printer.println(prefix + connector + typeTag + code);
+
+        String childPrefix;
+        if (isRoot) {
+            childPrefix = prefix + "  ";
+        } else if (isLast) {
+            childPrefix = prefix + "    ";
+        } else {
+            childPrefix = prefix + "│   ";
+        }
+
+        for (int i = 0; i < node.children.size(); i++) {
+            printTreeNode(node.children.get(i), childPrefix, i == node.children.size() - 1, false, printer);
+        }
+    }
+}
diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionTest.java
new file mode 100644
index 000000000000..9e6aceea6ee7
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/action/CamelRouteDiagramActionTest.java
@@ -0,0 +1,481 @@
+/*
+ * 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.action;
+
+import java.awt.Color;
+import java.awt.image.BufferedImage;
+import java.util.List;
+
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.LayoutNode;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.LayoutRoute;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.NodeInfo;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.RouteInfo;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramLayoutEngine.TreeNode;
+import org.apache.camel.dsl.jbang.core.commands.action.RouteDiagramRenderer.DiagramColors;
+import org.apache.camel.dsl.jbang.core.common.Printer;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CamelRouteDiagramActionTest {
+
+    @Test
+    void testBuildTreeEmpty() {
+        assertNull(RouteDiagramLayoutEngine.buildTree(List.of()));
+    }
+
+    @Test
+    void testBuildTreeSingleNode() {
+        List<NodeInfo> nodes = List.of(node("from", "timer:tick", 0));
+        TreeNode root = RouteDiagramLayoutEngine.buildTree(nodes);
+
+        assertNotNull(root);
+        assertEquals("from", root.info.type);
+        assertTrue(root.children.isEmpty());
+    }
+
+    @Test
+    void testBuildTreeSequential() {
+        List<NodeInfo> nodes = List.of(
+                node("from", "timer:tick", 0),
+                node("to", "log:a", 1),
+                node("to", "log:b", 1));
+        TreeNode root = RouteDiagramLayoutEngine.buildTree(nodes);
+
+        assertNotNull(root);
+        assertEquals(2, root.children.size());
+        assertEquals("log:a", root.children.get(0).info.code);
+        assertEquals("log:b", root.children.get(1).info.code);
+    }
+
+    @Test
+    void testBuildTreeBranching() {
+        List<NodeInfo> nodes = List.of(
+                node("from", "timer:tick", 0),
+                node("choice", "choice()", 1),
+                node("when", "when(simple(...))", 2),
+                node("to", "log:a", 3),
+                node("otherwise", "otherwise()", 2),
+                node("to", "log:b", 3));
+        TreeNode root = RouteDiagramLayoutEngine.buildTree(nodes);
+
+        assertNotNull(root);
+        assertEquals(1, root.children.size());
+        TreeNode choice = root.children.get(0);
+        assertEquals("choice", choice.info.type);
+        assertEquals(2, choice.children.size());
+        assertEquals("when", choice.children.get(0).info.type);
+        assertEquals("otherwise", choice.children.get(1).info.type);
+        assertEquals(1, choice.children.get(0).children.size());
+        assertEquals(1, choice.children.get(1).children.size());
+    }
+
+    @Test
+    void testBuildTreeWalkUpMultipleLevels() {
+        List<NodeInfo> nodes = List.of(
+                node("from", "timer:tick", 0),
+                node("choice", "choice()", 1),
+                node("when", "when(...)", 2),
+                node("to", "log:deep", 3),
+                node("to", "log:after-choice", 1));
+        TreeNode root = RouteDiagramLayoutEngine.buildTree(nodes);
+
+        assertNotNull(root);
+        assertEquals(2, root.children.size());
+        assertEquals("choice", root.children.get(0).info.type);
+        assertEquals("log:after-choice", root.children.get(1).info.code);
+    }
+
+    @Test
+    void testBuildTreeDeeplyNested() {
+        List<NodeInfo> nodes = List.of(
+                node("from", "timer:tick", 0),
+                node("choice", "choice()", 1),
+                node("when", "when(a)", 2),
+                node("choice", "choice()", 3),
+                node("when", "when(b)", 4),
+                node("to", "log:deep", 5),
+                node("to", "log:end", 1));
+        TreeNode root = RouteDiagramLayoutEngine.buildTree(nodes);
+
+        assertNotNull(root);
+        assertEquals(2, root.children.size());
+        TreeNode outerChoice = root.children.get(0);
+        assertEquals("choice", outerChoice.info.type);
+        TreeNode when = outerChoice.children.get(0);
+        assertEquals(1, when.children.size());
+        TreeNode innerChoice = when.children.get(0);
+        assertEquals("choice", innerChoice.info.type);
+        assertEquals("log:end", root.children.get(1).info.code);
+    }
+
+    @Test
+    void testBuildTreeFromOnly() {
+        List<NodeInfo> nodes = List.of(node("from", "timer:tick", 0));
+        TreeNode root = RouteDiagramLayoutEngine.buildTree(nodes);
+
+        assertNotNull(root);
+        assertEquals("from", root.info.type);
+        assertTrue(root.children.isEmpty());
+    }
+
+    @Test
+    void testColorPresetDark() {
+        DiagramColors colors = DiagramColors.parse("dark");
+        assertNotNull(colors.getBg());
+        assertNotNull(colors.getText());
+        assertNotNull(colors.getNodeFrom());
+        assertEquals(new Color(0x0d1117), colors.getBg());
+        assertEquals(new Color(0xf0f6fc), colors.getText());
+    }
+
+    @Test
+    void testColorPresetLight() {
+        DiagramColors colors = DiagramColors.parse("light");
+        assertEquals(new Color(0xf6f8fa), colors.getBg());
+        assertEquals(new Color(0xf0f6fc), colors.getText());
+    }
+
+    @Test
+    void testColorPresetTransparent() {
+        DiagramColors colors = DiagramColors.parse("transparent");
+        assertNull(colors.getBg());
+        assertNotNull(colors.getText());
+    }
+
+    @Test
+    void testColorCustomOverride() {
+        DiagramColors colors = DiagramColors.parse("bg=#ff0000:from=#00ff00");
+        assertEquals(new Color(0xff0000), colors.getBg());
+        assertEquals(new Color(0x00ff00), colors.getNodeFrom());
+        assertNotNull(colors.getText());
+        assertNotNull(colors.getNodeTo());
+    }
+
+    @Test
+    void testColorInvalidHexFallsBack() {
+        DiagramColors colors = DiagramColors.parse("bg=notacolor");
+        assertNull(colors.getBg());
+    }
+
+    @Test
+    void testColorNewCategories() {
+        DiagramColors colors = DiagramColors.parse("dark");
+        assertNotNull(colors.getNodeTransform());
+        assertNotNull(colors.getNodeProcessor());
+        assertEquals(new Color(0x1b7c83), colors.getNodeTransform());
+        assertEquals(new Color(0xbf4b8a), colors.getNodeProcessor());
+    }
+
+    @Test
+    void testColorCustomTransformProcessor() {
+        DiagramColors colors = DiagramColors.parse("transform=#aabbcc:processor=#ddeeff");
+        assertEquals(new Color(0xaabbcc), colors.getNodeTransform());
+        assertEquals(new Color(0xddeeff), colors.getNodeProcessor());
+    }
+
+    @Test
+    void testColorByName() {
+        DiagramColors colors = DiagramColors.parse("from=green:to=blue");
+        assertNotNull(colors.getNodeFrom());
+        assertNotNull(colors.getNodeTo());
+    }
+
+    @Test
+    void testColorInvalidNameFallsBack() {
+        DiagramColors colors = DiagramColors.parse("bg=nosuchcolor");
+        assertNull(colors.getBg());
+    }
+
+    @Test
+    void testCleanLabelNull() {
+        assertEquals("", RouteDiagramLayoutEngine.cleanLabel(null));
+    }
+
+    @Test
+    void testCleanLabelShort() {
+        assertEquals("log:hello", RouteDiagramLayoutEngine.cleanLabel("log:hello"));
+    }
+
+    @Test
+    void testCleanLabelLeadingDot() {
+        assertEquals("to(\"log:a\")", RouteDiagramLayoutEngine.cleanLabel(".to(\"log:a\")"));
+    }
+
+    @Test
+    void testCleanLabelPreservesLong() {
+        String longLabel = "to(\"http://very-long-endpoint-url-that-exceeds-forty-characters\")";
+        String result = RouteDiagramLayoutEngine.cleanLabel(longLabel);
+        assertEquals(longLabel, result);
+    }
+
+    @Test
+    void testLayoutSingleRoute() {
+        RouteInfo route = new RouteInfo();
+        route.routeId = "route1";
+        route.nodes.add(node("from", "timer:tick", 0));
+        route.nodes.add(node("to", "log:a", 1));
+
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING);
+
+        assertEquals(2, lr.nodes.size());
+        assertTrue(lr.maxX > 0);
+        assertTrue(lr.maxY > 0);
+
+        LayoutNode fromNode = lr.nodes.get(0);
+        LayoutNode toNode = lr.nodes.get(1);
+        assertEquals("from", fromNode.type);
+        assertEquals("to", toNode.type);
+        assertEquals(fromNode.x, toNode.x);
+        assertTrue(toNode.y > fromNode.y);
+    }
+
+    @Test
+    void testLayoutBranchingRoute() {
+        RouteInfo route = new RouteInfo();
+        route.routeId = "route1";
+        route.nodes.add(node("from", "timer:tick", 0));
+        route.nodes.add(node("choice", "choice()", 1));
+        route.nodes.add(node("when", "when(...)", 2));
+        route.nodes.add(node("to", "log:a", 3));
+        route.nodes.add(node("otherwise", "otherwise()", 2));
+        route.nodes.add(node("to", "log:b", 3));
+
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING);
+
+        assertEquals(6, lr.nodes.size());
+
+        LayoutNode whenNode = lr.nodes.get(2);
+        LayoutNode otherwiseNode = lr.nodes.get(4);
+        assertTrue(whenNode.x != otherwiseNode.x, "Branches should be at different x positions");
+    }
+
+    @Test
+    void testLayoutEmptyRoute() {
+        RouteInfo route = new RouteInfo();
+        route.routeId = "empty";
+
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        LayoutRoute lr = engine.layoutRoute(route, 0);
+
+        assertTrue(lr.nodes.isEmpty());
+        assertTrue(lr.maxX > 0);
+    }
+
+    @Test
+    void testRenderDiagramProducesImage() {
+        System.setProperty("java.awt.headless", "true");
+
+        RouteInfo route = new RouteInfo();
+        route.routeId = "route1";
+        route.nodes.add(node("from", "timer:tick", 0));
+        route.nodes.add(node("to", "log:a", 1));
+
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING);
+
+        RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+        DiagramColors colors = DiagramColors.parse("dark");
+        BufferedImage image = renderer.renderDiagram(List.of(lr), lr.maxY + RouteDiagramLayoutEngine.V_GAP, colors);
+
+        assertNotNull(image);
+        assertTrue(image.getWidth() > 0);
+        assertTrue(image.getHeight() > 0);
+        assertEquals(BufferedImage.TYPE_INT_RGB, image.getType());
+    }
+
+    @Test
+    void testRenderDiagramTransparentBackground() {
+        System.setProperty("java.awt.headless", "true");
+
+        RouteInfo route = new RouteInfo();
+        route.routeId = "route1";
+        route.nodes.add(node("from", "timer:tick", 0));
+
+        RouteDiagramLayoutEngine engine = new RouteDiagramLayoutEngine();
+        LayoutRoute lr = engine.layoutRoute(route, RouteDiagramLayoutEngine.PADDING);
+
+        RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+        DiagramColors colors = DiagramColors.parse("transparent");
+        BufferedImage image = renderer.renderDiagram(List.of(lr), lr.maxY + RouteDiagramLayoutEngine.V_GAP, colors);
+
+        assertEquals(BufferedImage.TYPE_INT_ARGB, image.getType());
+    }
+
+    @Test
+    void testRenderDiagramExceedingMaxDimensionThrows() {
+        System.setProperty("java.awt.headless", "true");
+
+        LayoutRoute lr = new LayoutRoute();
+        lr.routeId = "huge";
+        lr.maxX = RouteDiagramRenderer.MAX_IMAGE_DIMENSION + 100;
+        lr.maxY = 100;
+
+        RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+        DiagramColors colors = DiagramColors.parse("dark");
+        assertThrows(IllegalStateException.class,
+                () -> renderer.renderDiagram(List.of(lr), 100, colors));
+    }
+
+    @Test
+    void testTextDiagramSequential() {
+        RouteInfo route = new RouteInfo();
+        route.routeId = "route1";
+        route.source = "test.yaml";
+        route.nodes.add(node("from", "timer:tick", 0));
+        route.nodes.add(node("to", "log:a", 1));
+        route.nodes.add(node("to", "log:b", 1));
+
+        StringBuilder sb = new StringBuilder();
+        Printer printer = new CapturingPrinter(sb);
+
+        RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+        renderer.printTextDiagram(List.of(route), printer);
+
+        String output = sb.toString();
+        assertTrue(output.contains("Route: route1 (test.yaml)"));
+        assertTrue(output.contains("[from] timer:tick"));
+        assertTrue(output.contains("[to] log:a"));
+        assertTrue(output.contains("[to] log:b"));
+        assertTrue(output.contains("├──") || output.contains("└──"));
+    }
+
+    @Test
+    void testTextDiagramBranching() {
+        RouteInfo route = new RouteInfo();
+        route.routeId = "route1";
+        route.nodes.add(node("from", "timer:tick", 0));
+        route.nodes.add(node("choice", "choice()", 1));
+        route.nodes.add(node("when", "when(...)", 2));
+        route.nodes.add(node("to", "log:a", 3));
+        route.nodes.add(node("otherwise", "otherwise()", 2));
+        route.nodes.add(node("to", "log:b", 3));
+
+        StringBuilder sb = new StringBuilder();
+        Printer printer = new CapturingPrinter(sb);
+
+        RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+        renderer.printTextDiagram(List.of(route), printer);
+
+        String output = sb.toString();
+        assertTrue(output.contains("[choice]"));
+        assertTrue(output.contains("[when]"));
+        assertTrue(output.contains("[otherwise]"));
+        assertTrue(output.contains("│"), "Should contain tree continuation characters");
+    }
+
+    @Test
+    void testTextDiagramEmptyRoute() {
+        RouteInfo route = new RouteInfo();
+        route.routeId = "empty";
+
+        StringBuilder sb = new StringBuilder();
+        Printer printer = new CapturingPrinter(sb);
+
+        RouteDiagramRenderer renderer = new RouteDiagramRenderer();
+        renderer.printTextDiagram(List.of(route), printer);
+
+        String output = sb.toString();
+        assertTrue(output.contains("Route: empty"));
+    }
+
+    @Test
+    void testParseRoutesEmpty() {
+        org.apache.camel.util.json.JsonObject jo = new org.apache.camel.util.json.JsonObject();
+        CamelRouteDiagramAction action = new CamelRouteDiagramAction(null);
+        List<RouteInfo> routes = action.parseRoutes(jo);
+        assertTrue(routes.isEmpty());
+    }
+
+    @Test
+    void testParseRoutesWithData() {
+        org.apache.camel.util.json.JsonObject line1 = new org.apache.camel.util.json.JsonObject();
+        line1.put("type", "from");
+        line1.put("code", "timer:tick");
+        line1.put("level", 0);
+
+        org.apache.camel.util.json.JsonObject line2 = new org.apache.camel.util.json.JsonObject();
+        line2.put("type", "to");
+        line2.put("code", "log:a");
+        line2.put("level", 1);
+
+        org.apache.camel.util.json.JsonArray code = new org.apache.camel.util.json.JsonArray();
+        code.add(line1);
+        code.add(line2);
+
+        org.apache.camel.util.json.JsonObject routeObj = new org.apache.camel.util.json.JsonObject();
+        routeObj.put("routeId", "route1");
+        routeObj.put("code", code);
+
+        org.apache.camel.util.json.JsonArray routesArr = new org.apache.camel.util.json.JsonArray();
+        routesArr.add(routeObj);
+
+        org.apache.camel.util.json.JsonObject jo = new org.apache.camel.util.json.JsonObject();
+        jo.put("routes", routesArr);
+
+        CamelRouteDiagramAction action = new CamelRouteDiagramAction(null);
+        List<RouteInfo> routes = action.parseRoutes(jo);
+
+        assertEquals(1, routes.size());
+        assertEquals("route1", routes.get(0).routeId);
+        assertEquals(2, routes.get(0).nodes.size());
+        assertEquals("from", routes.get(0).nodes.get(0).type);
+        assertEquals("timer:tick", routes.get(0).nodes.get(0).code);
+    }
+
+    private static NodeInfo node(String type, String code, int level) {
+        NodeInfo n = new NodeInfo();
+        n.type = type;
+        n.code = code;
+        n.level = level;
+        return n;
+    }
+
+    private static class CapturingPrinter implements Printer {
+        private final StringBuilder sb;
+
+        CapturingPrinter(StringBuilder sb) {
+            this.sb = sb;
+        }
+
+        @Override
+        public void println() {
+            sb.append('\n');
+        }
+
+        @Override
+        public void println(String line) {
+            sb.append(line).append('\n');
+        }
+
+        @Override
+        public void print(String output) {
+            sb.append(output);
+        }
+
+        @Override
+        public void printf(String format, Object... args) {
+            sb.append(String.format(format, args));
+        }
+    }
+}