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

davsclaus pushed a commit to branch feature/CAMEL-23672-tui-diagram
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 34cb0c5c61d36fea94e877b8a82dfd948c418f4a
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Jun 3 18:05:20 2026 +0200

    CAMEL-23672: camel-tui - Interactive topology navigation with info panel 
and route drill-down
    
    Co-Authored-By: Claude Opus 4.6 <[email protected]>
---
 .../camel/diagram/TopologyAsciiRenderer.java       |  11 +
 .../jbang/core/commands/tui/DiagramSupport.java    | 343 ++++++++++++++++++++-
 .../dsl/jbang/core/commands/tui/DiagramTab.java    | 205 +++++++++++-
 3 files changed, 550 insertions(+), 9 deletions(-)

diff --git 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
index 84bbbd065f2f..9c1b6b9b3400 100644
--- 
a/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
+++ 
b/components/camel-diagram/src/main/java/org/apache/camel/diagram/TopologyAsciiRenderer.java
@@ -54,6 +54,7 @@ public class TopologyAsciiRenderer {
     private final boolean metrics;
     private final boolean showDescription;
     private final List<CounterPos> counterPositions = new ArrayList<>();
+    private final List<NodeBox> nodeBoxes = new ArrayList<>();
 
     public enum CounterType {
         OK,
@@ -65,6 +66,9 @@ public class TopologyAsciiRenderer {
     public record CounterPos(int row, int col, int length, CounterType type) {
     }
 
+    public record NodeBox(String routeId, int startRow, int endRow, int 
startCol, int endCol, int layer) {
+    }
+
     public TopologyAsciiRenderer(int nodeWidth, boolean unicode) {
         this(nodeWidth, unicode, false, false);
     }
@@ -85,6 +89,10 @@ public class TopologyAsciiRenderer {
         return counterPositions;
     }
 
+    public List<NodeBox> getNodeBoxes() {
+        return nodeBoxes;
+    }
+
     public String renderDiagram(TopologyLayoutResult result) {
         String plain = renderDiagramPlain(result);
         return applyAnsiColors(plain);
@@ -92,6 +100,7 @@ public class TopologyAsciiRenderer {
 
     public String renderDiagramPlain(TopologyLayoutResult result) {
         counterPositions.clear();
+        nodeBoxes.clear();
 
         int gridWidth = toCol(result.totalWidth) + boxWidth + 4;
         int gridHeight = toRow(result.totalHeight) + 10;
@@ -235,6 +244,8 @@ public class TopologyAsciiRenderer {
                 }
             }
         }
+
+        nodeBoxes.add(new NodeBox(node.routeId, row, row + height - 1, col, 
col + boxWidth - 1, node.layer));
     }
 
     private void drawEdge(char[][] grid, TopologyLayoutEdge edge) {
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 193419ff8e52..861b6c91d477 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
@@ -55,6 +55,8 @@ import org.apache.camel.diagram.TopologyHelper;
 import org.apache.camel.diagram.TopologyImageRenderer;
 import org.apache.camel.diagram.TopologyLayoutEngine;
 import org.apache.camel.diagram.TopologyLayoutEngine.TopologyEdgeInfo;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutEdge;
+import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutNode;
 import org.apache.camel.diagram.TopologyLayoutEngine.TopologyLayoutResult;
 import org.apache.camel.diagram.TopologyLayoutEngine.TopologyNodeInfo;
 import org.apache.camel.dsl.jbang.core.common.PathUtils;
@@ -83,6 +85,13 @@ class DiagramSupport {
     private int cropW = -1;
     private int cropH = -1;
     private final AtomicBoolean loading = new AtomicBoolean(false);
+    private List<TopologyAsciiRenderer.NodeBox> nodeBoxes = 
Collections.emptyList();
+    private List<TopologyLayoutNode> topologyNodes = Collections.emptyList();
+    private List<TopologyLayoutEdge> topologyEdges = Collections.emptyList();
+    private int selectedNodeIndex = -1;
+    private String pendingSelectionRouteId;
+    private int lastVisibleHeight;
+    private int lastVisibleWidth;
 
     List<String> getLines() {
         return lines;
@@ -112,6 +121,54 @@ class DiagramSupport {
         this.showDescription = showDescription;
     }
 
+    List<TopologyAsciiRenderer.NodeBox> getNodeBoxes() {
+        return nodeBoxes;
+    }
+
+    int getSelectedNodeIndex() {
+        return selectedNodeIndex;
+    }
+
+    void setSelectedNodeIndex(int idx) {
+        this.selectedNodeIndex = idx;
+    }
+
+    void setPendingSelectionRouteId(String routeId) {
+        this.pendingSelectionRouteId = routeId;
+    }
+
+    String getSelectedRouteId() {
+        if (selectedNodeIndex >= 0 && selectedNodeIndex < nodeBoxes.size()) {
+            return nodeBoxes.get(selectedNodeIndex).routeId();
+        }
+        return null;
+    }
+
+    TopologyLayoutNode getSelectedTopologyNode() {
+        String routeId = getSelectedRouteId();
+        if (routeId == null) {
+            return null;
+        }
+        for (TopologyLayoutNode node : topologyNodes) {
+            if (routeId.equals(node.routeId)) {
+                return node;
+            }
+        }
+        return null;
+    }
+
+    String getConnectedRouteId(String externalNodeId) {
+        for (TopologyLayoutEdge edge : topologyEdges) {
+            if (externalNodeId.equals(edge.from.routeId)) {
+                return edge.to.routeId;
+            }
+            if (externalNodeId.equals(edge.to.routeId)) {
+                return edge.from.routeId;
+            }
+        }
+        return null;
+    }
+
     boolean hasDiagramData() {
         return diagramTextMode ? !lines.isEmpty() : fullImageData != null;
     }
@@ -205,6 +262,10 @@ class DiagramSupport {
     void reset() {
         close();
         lines = Collections.emptyList();
+        nodeBoxes = Collections.emptyList();
+        topologyNodes = Collections.emptyList();
+        topologyEdges = Collections.emptyList();
+        selectedNodeIndex = -1;
         scrollY = 0;
         scrollX = 0;
     }
@@ -217,6 +278,181 @@ class DiagramSupport {
         hint(spans, "Home/End", "top/end");
     }
 
+    // ---- Node selection ----
+
+    private static int findTopLeftNode(List<TopologyAsciiRenderer.NodeBox> 
boxes) {
+        int bestIdx = 0;
+        for (int i = 1; i < boxes.size(); i++) {
+            TopologyAsciiRenderer.NodeBox nb = boxes.get(i);
+            TopologyAsciiRenderer.NodeBox best = boxes.get(bestIdx);
+            if (nb.startRow() < best.startRow()
+                    || (nb.startRow() == best.startRow() && nb.startCol() < 
best.startCol())) {
+                bestIdx = i;
+            }
+        }
+        return bestIdx;
+    }
+
+    // ---- Node selection navigation ----
+
+    void selectNodeUp() {
+        if (nodeBoxes.isEmpty()) {
+            return;
+        }
+        if (selectedNodeIndex < 0) {
+            selectedNodeIndex = 0;
+            return;
+        }
+        TopologyAsciiRenderer.NodeBox current = 
nodeBoxes.get(selectedNodeIndex);
+        int currentMidCol = (current.startCol() + current.endCol()) / 2;
+        int bestIdx = -1;
+        int bestDist = Integer.MAX_VALUE;
+        for (int i = 0; i < nodeBoxes.size(); i++) {
+            TopologyAsciiRenderer.NodeBox nb = nodeBoxes.get(i);
+            if (nb.layer() < current.layer()) {
+                int midCol = (nb.startCol() + nb.endCol()) / 2;
+                int dist = Math.abs(midCol - currentMidCol);
+                if (nb.layer() > (bestIdx >= 0 ? 
nodeBoxes.get(bestIdx).layer() : -1) || dist < bestDist) {
+                    bestIdx = i;
+                    bestDist = dist;
+                }
+            }
+        }
+        if (bestIdx >= 0) {
+            // find the closest layer above, then pick closest column within 
that layer
+            int targetLayer = nodeBoxes.get(bestIdx).layer();
+            bestIdx = -1;
+            bestDist = Integer.MAX_VALUE;
+            for (int i = 0; i < nodeBoxes.size(); i++) {
+                TopologyAsciiRenderer.NodeBox nb = nodeBoxes.get(i);
+                if (nb.layer() == targetLayer) {
+                    int midCol = (nb.startCol() + nb.endCol()) / 2;
+                    int dist = Math.abs(midCol - currentMidCol);
+                    if (dist < bestDist) {
+                        bestIdx = i;
+                        bestDist = dist;
+                    }
+                }
+            }
+            if (bestIdx >= 0) {
+                selectedNodeIndex = bestIdx;
+            }
+        }
+    }
+
+    void selectNodeDown() {
+        if (nodeBoxes.isEmpty()) {
+            return;
+        }
+        if (selectedNodeIndex < 0) {
+            selectedNodeIndex = 0;
+            return;
+        }
+        TopologyAsciiRenderer.NodeBox current = 
nodeBoxes.get(selectedNodeIndex);
+        int currentMidCol = (current.startCol() + current.endCol()) / 2;
+        int bestIdx = -1;
+        for (int i = 0; i < nodeBoxes.size(); i++) {
+            TopologyAsciiRenderer.NodeBox nb = nodeBoxes.get(i);
+            if (nb.layer() > current.layer()) {
+                if (bestIdx < 0 || nb.layer() < 
nodeBoxes.get(bestIdx).layer()) {
+                    bestIdx = i;
+                }
+            }
+        }
+        if (bestIdx >= 0) {
+            int targetLayer = nodeBoxes.get(bestIdx).layer();
+            bestIdx = -1;
+            int bestDist = Integer.MAX_VALUE;
+            for (int i = 0; i < nodeBoxes.size(); i++) {
+                TopologyAsciiRenderer.NodeBox nb = nodeBoxes.get(i);
+                if (nb.layer() == targetLayer) {
+                    int midCol = (nb.startCol() + nb.endCol()) / 2;
+                    int dist = Math.abs(midCol - currentMidCol);
+                    if (dist < bestDist) {
+                        bestIdx = i;
+                        bestDist = dist;
+                    }
+                }
+            }
+            if (bestIdx >= 0) {
+                selectedNodeIndex = bestIdx;
+            }
+        }
+    }
+
+    void selectNodeLeft() {
+        if (nodeBoxes.isEmpty()) {
+            return;
+        }
+        if (selectedNodeIndex < 0) {
+            selectedNodeIndex = 0;
+            return;
+        }
+        TopologyAsciiRenderer.NodeBox current = 
nodeBoxes.get(selectedNodeIndex);
+        int bestIdx = -1;
+        int bestCol = -1;
+        for (int i = 0; i < nodeBoxes.size(); i++) {
+            TopologyAsciiRenderer.NodeBox nb = nodeBoxes.get(i);
+            if (nb.layer() == current.layer() && nb.startCol() < 
current.startCol()) {
+                if (nb.startCol() > bestCol) {
+                    bestIdx = i;
+                    bestCol = nb.startCol();
+                }
+            }
+        }
+        if (bestIdx >= 0) {
+            selectedNodeIndex = bestIdx;
+        }
+    }
+
+    void selectNodeRight() {
+        if (nodeBoxes.isEmpty()) {
+            return;
+        }
+        if (selectedNodeIndex < 0) {
+            selectedNodeIndex = 0;
+            return;
+        }
+        TopologyAsciiRenderer.NodeBox current = 
nodeBoxes.get(selectedNodeIndex);
+        int bestIdx = -1;
+        int bestCol = Integer.MAX_VALUE;
+        for (int i = 0; i < nodeBoxes.size(); i++) {
+            TopologyAsciiRenderer.NodeBox nb = nodeBoxes.get(i);
+            if (nb.layer() == current.layer() && nb.startCol() > 
current.startCol()) {
+                if (nb.startCol() < bestCol) {
+                    bestIdx = i;
+                    bestCol = nb.startCol();
+                }
+            }
+        }
+        if (bestIdx >= 0) {
+            selectedNodeIndex = bestIdx;
+        }
+    }
+
+    void scrollToSelectedNode() {
+        if (selectedNodeIndex < 0 || selectedNodeIndex >= nodeBoxes.size()) {
+            return;
+        }
+        TopologyAsciiRenderer.NodeBox box = nodeBoxes.get(selectedNodeIndex);
+        if (lastVisibleHeight > 0) {
+            if (box.startRow() < scrollY + 1) {
+                scrollY = Math.max(0, box.startRow() - 1);
+            }
+            if (box.endRow() >= scrollY + lastVisibleHeight - 1) {
+                scrollY = box.endRow() - lastVisibleHeight + 2;
+            }
+        }
+        if (lastVisibleWidth > 0) {
+            if (box.startCol() < scrollX + 1) {
+                scrollX = Math.max(0, box.startCol() - 1);
+            }
+            if (box.endCol() >= scrollX + lastVisibleWidth - 1) {
+                scrollX = box.endCol() - lastVisibleWidth + 2;
+            }
+        }
+    }
+
     // ---- Rendering ----
 
     void renderDiagram(Frame frame, Rect area, String title) {
@@ -238,6 +474,8 @@ class DiagramSupport {
         Rect inner = block.inner(area);
         int visibleLines = Math.max(1, inner.height() - 1);
         int visibleCols = Math.max(1, inner.width() - 1);
+        lastVisibleHeight = visibleLines;
+        lastVisibleWidth = visibleCols;
 
         int maxVScroll = Math.max(0, lines.size() - visibleLines);
         int maxHScroll = Math.max(0, maxWidth - visibleCols);
@@ -487,7 +725,20 @@ class DiagramSupport {
                 }
             }
 
-            applyResult(ctx, resultLines, null, null, null, positions, 
Collections.emptySet());
+            List<TopologyAsciiRenderer.NodeBox> boxes = new ArrayList<>();
+            for (TopologyAsciiRenderer.NodeBox nb : renderer.getNodeBoxes()) {
+                int mappedStart = (nb.startRow() >= 0 && nb.startRow() < 
rowMapping.length)
+                        ? rowMapping[nb.startRow()] : -1;
+                int mappedEnd = (nb.endRow() >= 0 && nb.endRow() < 
rowMapping.length)
+                        ? rowMapping[nb.endRow()] : -1;
+                if (mappedStart >= 0 && mappedEnd >= 0) {
+                    boxes.add(new TopologyAsciiRenderer.NodeBox(
+                            nb.routeId(), mappedStart, mappedEnd, 
nb.startCol(), nb.endCol(), nb.layer()));
+                }
+            }
+
+            applyResult(ctx, resultLines, null, null, null, positions, 
Collections.emptySet(), boxes,
+                    result.nodes, result.edges);
         } else {
             TerminalImageCapabilities caps = 
TerminalImageCapabilities.detect();
             if (caps.supportsNativeImages()) {
@@ -645,7 +896,8 @@ class DiagramSupport {
             List<String> resultLines, ImageData resultImageData, ImageData 
resultFullImageData,
             ImageProtocol resultProtocol) {
         applyResult(ctx, resultLines, resultImageData, resultFullImageData, 
resultProtocol,
-                Collections.emptyList(), Collections.emptySet());
+                Collections.emptyList(), Collections.emptySet(), 
Collections.emptyList(),
+                Collections.emptyList(), Collections.emptyList());
     }
 
     private void applyResult(
@@ -653,6 +905,19 @@ class DiagramSupport {
             List<String> resultLines, ImageData resultImageData, ImageData 
resultFullImageData,
             ImageProtocol resultProtocol,
             List<RouteDiagramAsciiRenderer.CounterPos> positions, Set<Integer> 
titleRows) {
+        applyResult(ctx, resultLines, resultImageData, resultFullImageData, 
resultProtocol,
+                positions, titleRows, Collections.emptyList(),
+                Collections.emptyList(), Collections.emptyList());
+    }
+
+    private void applyResult(
+            MonitorContext ctx,
+            List<String> resultLines, ImageData resultImageData, ImageData 
resultFullImageData,
+            ImageProtocol resultProtocol,
+            List<RouteDiagramAsciiRenderer.CounterPos> positions, Set<Integer> 
titleRows,
+            List<TopologyAsciiRenderer.NodeBox> resultNodeBoxes,
+            List<TopologyLayoutNode> resultTopologyNodes,
+            List<TopologyLayoutEdge> resultTopologyEdges) {
         if (ctx.runner == null) {
             return;
         }
@@ -663,6 +928,31 @@ class DiagramSupport {
             routeTitleRows = titleRows;
             fullImageData = resultFullImageData;
             protocol = resultProtocol;
+            topologyNodes = resultTopologyNodes;
+            topologyEdges = resultTopologyEdges;
+
+            // Preserve selection across refreshes by matching routeId
+            String prevSelectedRouteId = getSelectedRouteId();
+            if (prevSelectedRouteId == null && pendingSelectionRouteId != 
null) {
+                prevSelectedRouteId = pendingSelectionRouteId;
+            }
+            pendingSelectionRouteId = null;
+            nodeBoxes = resultNodeBoxes;
+            if (prevSelectedRouteId != null && !resultNodeBoxes.isEmpty()) {
+                int newIdx = -1;
+                for (int i = 0; i < resultNodeBoxes.size(); i++) {
+                    if 
(prevSelectedRouteId.equals(resultNodeBoxes.get(i).routeId())) {
+                        newIdx = i;
+                        break;
+                    }
+                }
+                selectedNodeIndex = newIdx >= 0 ? newIdx : 0;
+            } else if (!resultNodeBoxes.isEmpty() && selectedNodeIndex < 0) {
+                selectedNodeIndex = findTopLeftNode(resultNodeBoxes);
+            } else if (resultNodeBoxes.isEmpty()) {
+                selectedNodeIndex = -1;
+            }
+
             if (!wasShowing) {
                 imageData = resultImageData;
                 scrollY = 0;
@@ -673,7 +963,6 @@ class DiagramSupport {
                 cropH = -1;
             } else {
                 showDiagram = true;
-                // invalidate crop cache so next render re-crops at current 
scroll position
                 cropX = -1;
                 cropY = -1;
                 cropW = -1;
@@ -689,6 +978,17 @@ class DiagramSupport {
             return Line.from(Span.styled(text, 
Style.EMPTY.fg(Color.WHITE).bold()));
         }
 
+        // Check if this row is within the selected node
+        int selStartCol = -1;
+        int selEndCol = -1;
+        if (selectedNodeIndex >= 0 && selectedNodeIndex < nodeBoxes.size()) {
+            TopologyAsciiRenderer.NodeBox box = 
nodeBoxes.get(selectedNodeIndex);
+            if (row >= box.startRow() && row <= box.endRow()) {
+                selStartCol = box.startCol() - hScrollX;
+                selEndCol = box.endCol() - hScrollX;
+            }
+        }
+
         List<int[]> counterRanges = new ArrayList<>();
         for (RouteDiagramAsciiRenderer.CounterPos cp : counterPositions) {
             if (cp.row() == row) {
@@ -735,9 +1035,46 @@ class DiagramSupport {
             }
             idx = labelEnd;
         }
+
+        // Apply selection highlighting as background overlay
+        if (selStartCol >= 0 && selEndCol >= 0) {
+            spans = applySelectionHighlight(spans, selStartCol, selEndCol);
+        }
+
         return Line.from(spans);
     }
 
+    private static List<Span> applySelectionHighlight(List<Span> spans, int 
startCol, int endCol) {
+        List<Span> result = new ArrayList<>();
+        int pos = 0;
+        for (Span span : spans) {
+            String content = span.content();
+            int spanStart = pos;
+            int spanEnd = pos + content.length();
+            pos = spanEnd;
+
+            if (spanEnd <= startCol || spanStart >= endCol + 1) {
+                result.add(span);
+                continue;
+            }
+
+            // Split span at selection boundaries
+            if (spanStart < startCol) {
+                result.add(Span.styled(content.substring(0, startCol - 
spanStart), span.style()));
+            }
+            int hlStart = Math.max(0, startCol - spanStart);
+            int hlEnd = Math.min(content.length(), endCol + 1 - spanStart);
+            if (hlStart < hlEnd) {
+                Style hlStyle = span.style().bg(Color.DARK_GRAY);
+                result.add(Span.styled(content.substring(hlStart, hlEnd), 
hlStyle));
+            }
+            if (spanStart + content.length() > endCol + 1) {
+                result.add(Span.styled(content.substring(endCol + 1 - 
spanStart), span.style()));
+            }
+        }
+        return result;
+    }
+
     private static void addStyledSegment(
             List<Span> spans, String text, int from, int to, List<int[]> 
counterRanges, Color defaultColor) {
         int pos = from;
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 034485a3729f..9949b560ff43 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
@@ -16,9 +16,13 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
+import java.util.ArrayList;
 import java.util.List;
 
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
 import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
 import dev.tamboui.style.Style;
 import dev.tamboui.terminal.Frame;
 import dev.tamboui.text.Line;
@@ -51,6 +55,31 @@ class DiagramTab implements MonitorTab {
 
     @Override
     public boolean handleKeyEvent(KeyEvent ke) {
+        // Node selection navigation in topology mode
+        if (topologyMode && diagram.isShowDiagram() && diagram.hasDiagramData()
+                && !diagram.getNodeBoxes().isEmpty()) {
+            if (ke.isUp()) {
+                diagram.selectNodeUp();
+                diagram.scrollToSelectedNode();
+                return true;
+            }
+            if (ke.isDown()) {
+                diagram.selectNodeDown();
+                diagram.scrollToSelectedNode();
+                return true;
+            }
+            if (ke.isLeft()) {
+                diagram.selectNodeLeft();
+                diagram.scrollToSelectedNode();
+                return true;
+            }
+            if (ke.isRight()) {
+                diagram.selectNodeRight();
+                diagram.scrollToSelectedNode();
+                return true;
+            }
+        }
+
         if (diagram.handleScrollKeys(ke)) {
             return true;
         }
@@ -81,7 +110,17 @@ class DiagramTab implements MonitorTab {
 
         // Drill down into route diagram (Enter)
         if (topologyMode && ke.isConfirm()) {
-            // For now, drill-down is a future feature
+            String selectedRouteId = diagram.getSelectedRouteId();
+            if (selectedRouteId != null) {
+                IntegrationInfo info = ctx.findSelectedIntegration();
+                if (info != null && info.routes.stream().anyMatch(r -> 
selectedRouteId.equals(r.routeId))) {
+                    drillDownRouteId = selectedRouteId;
+                    topologyMode = false;
+                    diagram.setTopologyMode(false);
+                    diagram.endLoad();
+                    reloadDiagram();
+                }
+            }
             return true;
         }
 
@@ -91,6 +130,7 @@ class DiagramTab implements MonitorTab {
     @Override
     public boolean handleEscape() {
         if (!topologyMode) {
+            diagram.setPendingSelectionRouteId(drillDownRouteId);
             topologyMode = true;
             diagram.setTopologyMode(true);
             diagram.endLoad();
@@ -140,7 +180,18 @@ class DiagramTab implements MonitorTab {
             } else {
                 title = " Route [" + drillDownRouteId + "] ";
             }
-            diagram.renderDiagram(frame, area, title);
+
+            String selectedRouteId = topologyMode ? 
diagram.getSelectedRouteId() : drillDownRouteId;
+            if (selectedRouteId != null && area.width() > 60) {
+                int panelWidth = 30;
+                List<Rect> hChunks = Layout.horizontal()
+                        .constraints(Constraint.length(panelWidth), 
Constraint.fill())
+                        .split(area);
+                renderInfoPanel(frame, hChunks.get(0), info, selectedRouteId);
+                diagram.renderDiagram(frame, hChunks.get(1), title);
+            } else {
+                diagram.renderDiagram(frame, area, title);
+            }
             return;
         }
 
@@ -156,12 +207,139 @@ class DiagramTab implements MonitorTab {
                 area);
     }
 
+    private void renderInfoPanel(Frame frame, Rect area, IntegrationInfo info, 
String routeId) {
+        RouteInfo route = null;
+        for (RouteInfo r : info.routes) {
+            if (routeId.equals(r.routeId)) {
+                route = r;
+                break;
+            }
+        }
+
+        List<Line> lines = new ArrayList<>();
+        if (route != null) {
+            lines.add(Line.from(
+                    Span.styled(" Route: ", 
Style.EMPTY.fg(Color.YELLOW).bold()),
+                    Span.styled(route.routeId, 
Style.EMPTY.fg(Color.WHITE).bold())));
+            lines.add(Line.from(
+                    Span.styled(" From:  ", Style.EMPTY.dim()),
+                    Span.raw(route.from != null ? route.from : "")));
+            String stateLabel = route.state != null ? route.state : "";
+            Style stateStyle = "Started".equals(route.state) ? 
Style.EMPTY.fg(Color.GREEN) : Style.EMPTY.fg(Color.LIGHT_RED);
+            lines.add(Line.from(
+                    Span.styled(" State: ", Style.EMPTY.dim()),
+                    Span.styled(stateLabel, stateStyle)));
+
+            lines.add(Line.from(Span.raw("")));
+            lines.add(Line.from(
+                    Span.styled(" Uptime:     ", Style.EMPTY.dim()),
+                    Span.raw(route.uptime != null ? route.uptime : "")));
+            lines.add(Line.from(
+                    Span.styled(" Throughput: ", Style.EMPTY.dim()),
+                    Span.raw(route.throughput != null ? route.throughput : 
"")));
+
+            lines.add(Line.from(Span.raw("")));
+            lines.add(Line.from(
+                    Span.styled(" Total:    ", Style.EMPTY.dim()),
+                    Span.raw(String.valueOf(route.total))));
+            Style failStyle = route.failed > 0 ? 
Style.EMPTY.fg(Color.LIGHT_RED).bold() : Style.EMPTY;
+            lines.add(Line.from(
+                    Span.styled(" Failed:   ", Style.EMPTY.dim()),
+                    Span.styled(String.valueOf(route.failed), failStyle)));
+            lines.add(Line.from(
+                    Span.styled(" Inflight: ", Style.EMPTY.dim()),
+                    Span.raw(String.valueOf(route.inflight))));
+
+            lines.add(Line.from(Span.raw("")));
+            if (route.total > 0) {
+                lines.add(Line.from(
+                        Span.styled(" Mean: ", Style.EMPTY.dim()),
+                        Span.raw(route.meanTime + " ms")));
+                lines.add(Line.from(
+                        Span.styled(" Max:  ", Style.EMPTY.dim()),
+                        Span.raw(route.maxTime + " ms")));
+                lines.add(Line.from(
+                        Span.styled(" Min:  ", Style.EMPTY.dim()),
+                        Span.raw(route.minTime + " ms")));
+            }
+
+            if (route.coverage != null) {
+                lines.add(Line.from(Span.raw("")));
+                lines.add(Line.from(
+                        Span.styled(" Coverage: ", Style.EMPTY.dim()),
+                        Span.raw(route.coverage)));
+            }
+        } else {
+            // External endpoint — show topology node data
+            var topoNode = diagram.getSelectedTopologyNode();
+            if (topoNode != null) {
+                boolean isInbound = "external-in".equals(topoNode.nodeType);
+                lines.add(Line.from(
+                        Span.styled(isInbound ? " Inbound" : " Outbound",
+                                Style.EMPTY.fg(Color.CYAN).bold())));
+                lines.add(Line.from(Span.raw("")));
+                lines.add(Line.from(
+                        Span.styled(" URI: ", Style.EMPTY.dim()),
+                        Span.raw(topoNode.from != null ? topoNode.from : "")));
+                if (topoNode.description != null && 
!topoNode.description.isBlank()) {
+                    lines.add(Line.from(
+                            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 (topoNode.exchangesTotal > 0 || topoNode.exchangesFailed > 
0) {
+                    lines.add(Line.from(Span.raw("")));
+                    lines.add(Line.from(
+                            Span.styled(" Total:  ", Style.EMPTY.dim()),
+                            
Span.raw(String.valueOf(topoNode.exchangesTotal))));
+                    if (topoNode.exchangesFailed > 0) {
+                        lines.add(Line.from(
+                                Span.styled(" Failed: ", Style.EMPTY.dim()),
+                                
Span.styled(String.valueOf(topoNode.exchangesFailed),
+                                        
Style.EMPTY.fg(Color.LIGHT_RED).bold())));
+                    }
+                }
+            } else {
+                lines.add(Line.from(
+                        Span.styled(" " + routeId, 
Style.EMPTY.fg(Color.CYAN).bold())));
+                lines.add(Line.from(
+                        Span.styled(" (external endpoint)", 
Style.EMPTY.dim())));
+            }
+        }
+
+        Paragraph paragraph = Paragraph.builder()
+                .text(Text.from(lines))
+                .block(Block.builder().borderType(BorderType.ROUNDED)
+                        .title(" Info ").build())
+                .build();
+        frame.renderWidget(paragraph, area);
+    }
+
     @Override
     public void renderFooter(List<Span> spans) {
         if (diagram.isShowDiagram()) {
-            diagram.renderFooterHints(spans);
+            if (!topologyMode) {
+                hint(spans, "Esc", "back");
+                hint(spans, "↑↓←→", "scroll");
+                hint(spans, "PgUp/PgDn", "page");
+            } else if (!diagram.getNodeBoxes().isEmpty()) {
+                hint(spans, "Esc", "close");
+                hint(spans, "↑↓←→", "navigate");
+                hint(spans, "Enter", "drill-down");
+                hint(spans, "PgUp/PgDn", "page");
+            } else {
+                diagram.renderFooterHints(spans);
+            }
             hint(spans, "m", "metrics" + (diagramMetrics ? " [on]" : " 
[off]"));
-            hint(spans, "e", "external" + (showExternal ? " [on]" : " [off]"));
+            if (topologyMode) {
+                hint(spans, "e", "external" + (showExternal ? " [on]" : " 
[off]"));
+            }
             hint(spans, "n", "description" + (diagram.isShowDescription() ? " 
[on]" : " [off]"));
         }
     }
@@ -266,15 +444,30 @@ class DiagramTab implements MonitorTab {
                                 External system boxes are drawn with dashed 
borders to distinguish
                                 them from route boxes. Dashed edges connect 
routes to external systems.
 
+                                ## Navigation
+
+                                In the topology view, use arrow keys to select 
route boxes:
+                                - `↑↓` moves between layers 
(upstream/downstream routes)
+                                - `←→` moves between routes in the same layer
+
+                                When a route is selected, an **Info panel** 
appears on the left
+                                showing key metrics: state, uptime, 
throughput, exchange counts,
+                                and processing times.
+
+                                Press `Enter` on a selected route to **drill 
down** into its
+                                internal EIP structure (the route diagram). 
Press `Esc` to
+                                return to the topology view.
+
                                 ## Keys
 
+                                - `↑↓←→` — navigate between route boxes
+                                - `Enter` — drill down into selected route
+                                - `Esc` — return to topology / close diagram
                                 - `m` — toggle metrics on/off (default: on)
                                 - `e` — toggle external systems on/off 
(default: off)
                                 - `n` — toggle description labels on/off 
(default: off)
-                                - `↑↓←→` — scroll diagram
                                 - `PgUp/PgDn` — page scroll
                                 - `Home/End` — top/end
-                                - `Esc` — close diagram
                 """;
     }
 

Reply via email to