gnodet-bot commented on code in PR #29385:
URL: https://github.com/apache/camel/pull/29385#discussion_r4503063270


##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java:
##########
@@ -0,0 +1,1014 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.dsl.jbang.core.commands.ai.About;
+import org.apache.camel.dsl.jbang.core.commands.ai.AboutReport;
+import org.apache.camel.dsl.jbang.core.commands.ai.ProjectOverview;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * About an integration (Overview ▸ About, CAMEL-26700): what it does at a 
glance and without an AI, and what has
+ * happened. A header with what it is and its purpose (the comment at the top 
of its main file, else the descriptions of
+ * its routes), then what comes in, its routes (the routes called with direct 
or seda under the route that calls them)
+ * and what goes out, from the analysis of its sources ({@link 
ProjectOverview}). Under it, the last minutes from the
+ * timeline the integration records, which the Timeline tab (4) shows second 
by second, and what to watch.
+ */
+final class AboutView {
+
+    // how often the sources are read again, to see a change
+    private static final long RELOAD_MILLIS = 10_000;
+    private static final int HAPPENED_SECONDS = 300;
+    private static final String BLOCKS = " ▁▂▃▄▅▆▇█";
+    // the strips have this many columns, whatever the zoom
+    private static final int STRIP_COLUMNS = 60;
+    // the sparklines of In and Out
+    private static final int SPARK_WIDTH = 14;
+    private static final DateTimeFormatter TIME = 
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
+
+    private final MonitorContext ctx;
+    private volatile ProjectOverview.Overview overview;
+    private volatile About.Model model;
+    private volatile String purpose;
+    private volatile String purposeFrom;
+    private volatile boolean loading;
+    private volatile String error;
+    private Path dir;
+    private long loadedAt;
+    // the zoom of What happened, as the windows of the Timeline tab (+ and 
-), and its last second when moved (← →);
+    // -1 follows the last second recorded
+    private int zoom = TimelineTab.WINDOWS.length - 1;
+    private long stripsEnd = -1;
+    // the line of In, Routes or Out the cursor is on (↑ ↓), in the order of 
the columns; -1 when none is
+    private int cursor = -1;
+    private volatile List<Target> targets = List.of();
+    private volatile List<String> targetLabels = List.of();
+
+    /**
+     * What a line of In, Routes or Out is about, for Enter: a route (opened 
on the Diagram) or an endpoint a route
+     * sends to (opened on the Endpoint tab).
+     *
+     * @param routeId  the route
+     * @param endpoint the URI of the endpoint as the sources have it, or null 
for a route
+     */
+    record Target(String routeId, String endpoint) {
+    }
+
+    AboutView(MonitorContext ctx) {
+        this.ctx = ctx;
+    }
+
+    /** Reads the sources of the integration in the background, again when 
they may have changed. */
+    private void load(IntegrationInfo info, Consumer<Runnable> onRenderThread) 
{
+        Path d = info.directory != null ? Path.of(info.directory) : null;
+        long now = System.currentTimeMillis();
+        boolean same = d != null && d.equals(dir);
+        if (d == null || loading || same && now - loadedAt < RELOAD_MILLIS) {
+            if (d == null) {
+                error = "No source directory for this integration";
+            }
+            return;
+        }
+        if (!same) {
+            overview = null;
+            purpose = null;
+            error = null;
+        }
+        dir = d;
+        loadedAt = now;
+        loading = true;
+        Thread t = new Thread(() -> {
+            try {
+                ProjectOverview.Overview o = ProjectOverview.analyze(d, 
ArchitectureView.catalog());
+                About.Model m = About.of(d, o);
+                onRenderThread.accept(() -> {
+                    overview = o;
+                    model = m;
+                    purpose = m.purpose();
+                    purposeFrom = m.purposeFrom();
+                    error = o.flows().isEmpty() ? "No routes found in " + d : 
null;
+                    loading = false;
+                });
+            } catch (Exception e) {
+                onRenderThread.accept(() -> {
+                    error = "Cannot read the sources: " + e.getMessage();
+                    loading = false;
+                });
+            }
+        }, "CamelTuiAbout");
+        t.setDaemon(true);
+        t.start();
+    }
+
+    /**
+     * The keys of What happened: + zooms in on the last trouble in the window 
(a failure, a handled error, a
+     * redelivery, a route down, a circuit breaker open), - zooms out, and ← → 
move a zoomed window.
+     */
+    boolean handleKeyEvent(KeyEvent ke, IntegrationInfo info) {
+        if (info == null) {
+            return false;
+        }
+        if (ke.isConfirm()) {
+            return open();
+        }
+        long end = ctx.timeline.endSecond(info.pid);
+        if (ke.isChar('+') || ke.isChar('=')) {
+            if (zoom > 0 && end >= 0) {
+                long trouble = lastTrouble(info, windowEnd(end), seconds());
+                zoom--;
+                if (trouble >= 0) {
+                    // the trouble in the middle of the window, when there is 
time after it
+                    moveTo(trouble + seconds() / 2, end);
+                }
+            }
+            return true;
+        }
+        if (ke.isChar('-')) {
+            if (zoom < TimelineTab.WINDOWS.length - 1) {
+                zoom++;
+                if (end >= 0) {
+                    moveTo(windowEnd(end) + seconds() / 4, end);
+                }
+            }
+            return true;
+        }
+        if ((ke.isLeft() || ke.isRight()) && end >= 0) {
+            int by = Math.max(1, seconds() / 4);
+            moveTo(windowEnd(end) + (ke.isLeft() ? -by : by), end);
+            return true;
+        }
+        return false;
+    }
+
+    /** The lines the cursor can be on, for an agent (tui_get_state): the 
route, or the endpoint and its route. */
+    SelectionContext selectionContext() {
+        List<String> items = targetLabels;
+        return new SelectionContext("list", items, cursor < items.size() ? 
cursor : -1, items.size(), "About");
+    }
+
+    /**
+     * About the integration as data, for an agent (tui_get_table): what the 
screen shows, with what happened over the
+     * window of What happened.
+     */
+    JsonObject tableJson(IntegrationInfo info) {
+        JsonObject result = new JsonObject();
+        result.put("tab", "Overview");
+        result.put("view", "About");
+        result.put("integration", info.name != null ? info.name : info.pid);
+        result.put("pid", info.pid);
+        About.Model m = model;
+        if (m == null) {
+            result.put("loading", error != null ? error : "reading the 
sources");
+            return result;
+        }
+        if (m.purpose() != null) {
+            result.put("purpose", m.purpose());
+            result.put("purposeFrom", m.purposeFrom());
+        }
+        Map<String, Long> totals = new LinkedHashMap<>();
+        info.routes.forEach(r -> {
+            if (r.routeId != null) {
+                totals.put(r.routeId, r.total);
+            }
+        });
+        JsonArray in = new JsonArray();
+        for (About.In e : m.in()) {
+            JsonObject jo = new JsonObject();
+            jo.put("label", e.label());
+            jo.put("kind", e.kind());
+            jo.put("routeId", e.route());
+            putTotalAndRate(jo, info, e.route(), totals);
+            in.add(jo);
+        }
+        result.put("in", in);
+        JsonArray routes = new JsonArray();
+        for (About.RouteLine r : m.routes()) {
+            JsonObject jo = new JsonObject();
+            jo.put("routeId", r.routeId());
+            jo.put("depth", r.depth());
+            if (r.how() != null) {
+                jo.put("calledHow", r.how());
+            }
+            jo.put("from", r.from());
+            jo.put("steps", r.steps());
+            if (totals.get(r.routeId()) != null) {
+                jo.put("exchangesTotal", totals.get(r.routeId()));
+            }
+            routes.add(jo);
+        }
+        result.put("routes", routes);
+        JsonArray out = new JsonArray();
+        for (About.Out o : m.out()) {
+            JsonObject jo = new JsonObject();
+            jo.put("uri", o.uri());
+            jo.put("category", o.category());
+            jo.put("routeId", o.route());
+            Long hits = outHits(info, o.uri(), o.route());
+            if (hits != null) {
+                jo.put("sends", hits);
+            }
+            out.add(jo);
+        }
+        result.put("out", out);
+        long last = ctx.timeline.endSecond(info.pid);
+        if (last >= 0) {
+            long end = windowEnd(last);
+            int seconds = seconds();
+            JsonObject happened = new JsonObject();
+            happened.put("seconds", seconds);
+            happened.put("endSecond", end);
+            happened.put("following", stripsEnd < 0);
+            JsonArray lines = new JsonArray();
+            for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, 
routeOrder(info), end, seconds)) {
+                JsonObject jo = new JsonObject();
+                jo.put("routeId", r.route());
+                jo.put("exchanges", r.total(r.completed()) + 
r.total(r.recovered()) + r.total(r.handled())
+                                    + r.total(r.failed()));
+                long[] worst = r.total(r.failed()) > 0 ? r.failed() : 
r.total(r.handled()) > 0 ? r.handled()
+                        : r.total(r.recovered()) > 0 ? r.recovered() : null;
+                if (worst != null) {
+                    jo.put("worst", worst == r.failed() ? "failed" : worst == 
r.handled() ? "handled" : "recovered");
+                    jo.put("worstCount", r.total(worst));
+                    jo.put("worstSecondsAgo", last - lastAt(worst, end, 
seconds));
+                    if (worst == r.recovered()) {
+                        String from = recoveredFrom(info, r.route(), end - 
seconds + 1, end);
+                        if (from != null) {
+                            jo.put("recoveredFrom", from);
+                        }
+                    }
+                }
+                if (r.openSeconds() > 0) {
+                    jo.put("circuitBreakerOpenSeconds", r.openSeconds());
+                }
+                if (r.total(r.down()) > 0) {
+                    jo.put("downSeconds", r.total(r.down()));
+                }
+                lines.add(jo);
+            }
+            happened.put("routes", lines);
+            JsonArray causes = new JsonArray();
+            for (Timeline.CauseSeries c : ctx.timeline.causes(info.pid, end, 
seconds)) {
+                long total = Arrays.stream(c.values()).sum();
+                if (total > 0) {
+                    JsonObject jo = new JsonObject();
+                    jo.put("label", c.label());
+                    if (c.rule() != null) {
+                        jo.put("chaosRule", c.rule());
+                    }
+                    jo.put("count", total);
+                    causes.add(jo);
+                }
+            }
+            happened.put("causes", causes);
+            result.put("whatHappened", happened);
+        }
+        Target t = selected();
+        if (t != null) {
+            JsonObject sel = new JsonObject();
+            sel.put("routeId", t.routeId());
+            if (t.endpoint() != null) {
+                sel.put("endpoint", t.endpoint());
+            }
+            result.put("selected", sel);
+        }
+        result.put("keys", "v all integrations, Up/Down select a line, Enter 
opens it, +/- zoom, Left/Right move, 4 Timeline");
+        return result;
+    }
+
+    private void putTotalAndRate(JsonObject jo, IntegrationInfo info, String 
routeId, Map<String, Long> totals) {
+        if (routeId == null) {
+            return;
+        }
+        if (totals.get(routeId) != null) {
+            jo.put("exchangesTotal", totals.get(routeId));
+        }
+        long[] perSecond = routePerSecond(info, routeId);
+        if (perSecond != null) {
+            jo.put("rate", rate(perSecond));
+        }
+    }
+
+    /** ↑ ↓: the cursor moves over the lines of In, Routes and Out that are 
about a route or an endpoint. */
+    void moveCursor(int delta) {
+        int n = targets.size();
+        if (n == 0) {
+            cursor = -1;
+        } else if (cursor < 0) {
+            cursor = delta > 0 ? 0 : n - 1;
+        } else {
+            cursor = Math.max(0, Math.min(n - 1, cursor + delta));
+        }
+    }
+
+    /** The line the cursor is on, or null. */
+    Target selected() {
+        List<Target> t = targets;
+        return cursor >= 0 && cursor < t.size() ? t.get(cursor) : null;
+    }
+
+    /** Enter: opens what the cursor is on; false when it is on nothing. */
+    boolean open() {
+        Target t = selected();
+        if (t == null || ctx.openFromAbout == null) {
+            return false;
+        }
+        ctx.openFromAbout.accept(t);
+        return true;
+    }
+
+    private int seconds() {
+        return TimelineTab.WINDOWS[zoom];
+    }
+
+    private long windowEnd(long end) {
+        return stripsEnd >= 0 ? Math.min(stripsEnd, end) : end;
+    }
+
+    /** Moves the window to end at the given second, as far as there are 
seconds recorded; at the end it follows. */
+    private void moveTo(long second, long end) {
+        long first = Math.max(0, end - Timeline.SECONDS + 1);
+        long to = Math.max(first + seconds() - 1, second);
+        stripsEnd = to >= end ? -1 : to;
+    }
+
+    /** The last second in the window that a route had trouble in, or -1. */
+    private long lastTrouble(IntegrationInfo info, long windowEnd, int 
seconds) {
+        long last = -1;
+        for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, 
routeOrder(info), windowEnd, seconds)) {
+            for (int i = seconds - 1; i >= 0; i--) {
+                if (r.failed()[i] > 0 || r.handled()[i] > 0 || 
r.recovered()[i] > 0 || r.attempts()[i] > 0
+                        || r.down()[i] > 0 || r.open()[i] > 0) {
+                    last = Math.max(last, windowEnd - seconds + 1 + i);
+                    break;
+                }
+            }
+        }
+        return last;
+    }
+
+    private static List<String> routeOrder(IntegrationInfo info) {
+        return info.routes.stream().map(r -> r.routeId).filter(id -> id != 
null).toList();
+    }
+
+    void render(Frame frame, Rect area, IntegrationInfo info) {
+        load(info, Runnable::run);
+        ProjectOverview.Overview o = overview;

Review Comment:
   💡 **Root cause of concurrency issues: `Runnable::run` runs the update on the 
background thread**
   
   `render()` passes `Runnable::run` as the `onRenderThread` consumer, so when 
`load()`'s background thread calls `onRenderThread.accept(...)`, the lambda 
that sets `model`, `purpose`, `purposeFrom`, etc. executes on the **background 
thread**, not the render thread.
   
   This means all those volatile writes happen concurrently with render reads — 
and since they can't be made atomic with individual volatile fields, the TOCTOU 
races in B1/B2 above are unavoidable unless locals are snapshotted. If other 
views use `ctx.runner.runOnRenderThread()` to push updates to the render 
thread, consider doing the same here for consistency (it also keeps the render 
thread's field-read perspective coherent).



##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java:
##########
@@ -0,0 +1,1014 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.dsl.jbang.core.commands.ai.About;
+import org.apache.camel.dsl.jbang.core.commands.ai.AboutReport;
+import org.apache.camel.dsl.jbang.core.commands.ai.ProjectOverview;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * About an integration (Overview ▸ About, CAMEL-26700): what it does at a 
glance and without an AI, and what has
+ * happened. A header with what it is and its purpose (the comment at the top 
of its main file, else the descriptions of
+ * its routes), then what comes in, its routes (the routes called with direct 
or seda under the route that calls them)
+ * and what goes out, from the analysis of its sources ({@link 
ProjectOverview}). Under it, the last minutes from the
+ * timeline the integration records, which the Timeline tab (4) shows second 
by second, and what to watch.
+ */
+final class AboutView {
+
+    // how often the sources are read again, to see a change
+    private static final long RELOAD_MILLIS = 10_000;
+    private static final int HAPPENED_SECONDS = 300;
+    private static final String BLOCKS = " ▁▂▃▄▅▆▇█";
+    // the strips have this many columns, whatever the zoom
+    private static final int STRIP_COLUMNS = 60;
+    // the sparklines of In and Out
+    private static final int SPARK_WIDTH = 14;
+    private static final DateTimeFormatter TIME = 
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
+
+    private final MonitorContext ctx;
+    private volatile ProjectOverview.Overview overview;
+    private volatile About.Model model;
+    private volatile String purpose;
+    private volatile String purposeFrom;
+    private volatile boolean loading;
+    private volatile String error;
+    private Path dir;
+    private long loadedAt;
+    // the zoom of What happened, as the windows of the Timeline tab (+ and 
-), and its last second when moved (← →);
+    // -1 follows the last second recorded
+    private int zoom = TimelineTab.WINDOWS.length - 1;
+    private long stripsEnd = -1;
+    // the line of In, Routes or Out the cursor is on (↑ ↓), in the order of 
the columns; -1 when none is
+    private int cursor = -1;
+    private volatile List<Target> targets = List.of();
+    private volatile List<String> targetLabels = List.of();
+
+    /**
+     * What a line of In, Routes or Out is about, for Enter: a route (opened 
on the Diagram) or an endpoint a route
+     * sends to (opened on the Endpoint tab).
+     *
+     * @param routeId  the route
+     * @param endpoint the URI of the endpoint as the sources have it, or null 
for a route
+     */
+    record Target(String routeId, String endpoint) {
+    }
+
+    AboutView(MonitorContext ctx) {
+        this.ctx = ctx;
+    }
+
+    /** Reads the sources of the integration in the background, again when 
they may have changed. */
+    private void load(IntegrationInfo info, Consumer<Runnable> onRenderThread) 
{
+        Path d = info.directory != null ? Path.of(info.directory) : null;
+        long now = System.currentTimeMillis();
+        boolean same = d != null && d.equals(dir);
+        if (d == null || loading || same && now - loadedAt < RELOAD_MILLIS) {
+            if (d == null) {
+                error = "No source directory for this integration";
+            }
+            return;
+        }
+        if (!same) {
+            overview = null;
+            purpose = null;
+            error = null;
+        }
+        dir = d;
+        loadedAt = now;
+        loading = true;
+        Thread t = new Thread(() -> {
+            try {
+                ProjectOverview.Overview o = ProjectOverview.analyze(d, 
ArchitectureView.catalog());
+                About.Model m = About.of(d, o);
+                onRenderThread.accept(() -> {
+                    overview = o;
+                    model = m;
+                    purpose = m.purpose();
+                    purposeFrom = m.purposeFrom();
+                    error = o.flows().isEmpty() ? "No routes found in " + d : 
null;
+                    loading = false;
+                });
+            } catch (Exception e) {
+                onRenderThread.accept(() -> {
+                    error = "Cannot read the sources: " + e.getMessage();
+                    loading = false;
+                });
+            }
+        }, "CamelTuiAbout");
+        t.setDaemon(true);
+        t.start();
+    }
+
+    /**
+     * The keys of What happened: + zooms in on the last trouble in the window 
(a failure, a handled error, a
+     * redelivery, a route down, a circuit breaker open), - zooms out, and ← → 
move a zoomed window.
+     */
+    boolean handleKeyEvent(KeyEvent ke, IntegrationInfo info) {
+        if (info == null) {
+            return false;
+        }
+        if (ke.isConfirm()) {
+            return open();
+        }
+        long end = ctx.timeline.endSecond(info.pid);
+        if (ke.isChar('+') || ke.isChar('=')) {
+            if (zoom > 0 && end >= 0) {
+                long trouble = lastTrouble(info, windowEnd(end), seconds());
+                zoom--;
+                if (trouble >= 0) {
+                    // the trouble in the middle of the window, when there is 
time after it
+                    moveTo(trouble + seconds() / 2, end);
+                }
+            }
+            return true;
+        }
+        if (ke.isChar('-')) {
+            if (zoom < TimelineTab.WINDOWS.length - 1) {
+                zoom++;
+                if (end >= 0) {
+                    moveTo(windowEnd(end) + seconds() / 4, end);
+                }
+            }
+            return true;
+        }
+        if ((ke.isLeft() || ke.isRight()) && end >= 0) {
+            int by = Math.max(1, seconds() / 4);
+            moveTo(windowEnd(end) + (ke.isLeft() ? -by : by), end);
+            return true;
+        }
+        return false;
+    }
+
+    /** The lines the cursor can be on, for an agent (tui_get_state): the 
route, or the endpoint and its route. */
+    SelectionContext selectionContext() {
+        List<String> items = targetLabels;
+        return new SelectionContext("list", items, cursor < items.size() ? 
cursor : -1, items.size(), "About");
+    }
+
+    /**
+     * About the integration as data, for an agent (tui_get_table): what the 
screen shows, with what happened over the
+     * window of What happened.
+     */
+    JsonObject tableJson(IntegrationInfo info) {
+        JsonObject result = new JsonObject();
+        result.put("tab", "Overview");
+        result.put("view", "About");
+        result.put("integration", info.name != null ? info.name : info.pid);
+        result.put("pid", info.pid);
+        About.Model m = model;
+        if (m == null) {
+            result.put("loading", error != null ? error : "reading the 
sources");
+            return result;
+        }
+        if (m.purpose() != null) {
+            result.put("purpose", m.purpose());
+            result.put("purposeFrom", m.purposeFrom());
+        }
+        Map<String, Long> totals = new LinkedHashMap<>();
+        info.routes.forEach(r -> {
+            if (r.routeId != null) {
+                totals.put(r.routeId, r.total);
+            }
+        });
+        JsonArray in = new JsonArray();
+        for (About.In e : m.in()) {
+            JsonObject jo = new JsonObject();
+            jo.put("label", e.label());
+            jo.put("kind", e.kind());
+            jo.put("routeId", e.route());
+            putTotalAndRate(jo, info, e.route(), totals);
+            in.add(jo);
+        }
+        result.put("in", in);
+        JsonArray routes = new JsonArray();
+        for (About.RouteLine r : m.routes()) {
+            JsonObject jo = new JsonObject();
+            jo.put("routeId", r.routeId());
+            jo.put("depth", r.depth());
+            if (r.how() != null) {
+                jo.put("calledHow", r.how());
+            }
+            jo.put("from", r.from());
+            jo.put("steps", r.steps());
+            if (totals.get(r.routeId()) != null) {
+                jo.put("exchangesTotal", totals.get(r.routeId()));
+            }
+            routes.add(jo);
+        }
+        result.put("routes", routes);
+        JsonArray out = new JsonArray();
+        for (About.Out o : m.out()) {
+            JsonObject jo = new JsonObject();
+            jo.put("uri", o.uri());
+            jo.put("category", o.category());
+            jo.put("routeId", o.route());
+            Long hits = outHits(info, o.uri(), o.route());
+            if (hits != null) {
+                jo.put("sends", hits);
+            }
+            out.add(jo);
+        }
+        result.put("out", out);
+        long last = ctx.timeline.endSecond(info.pid);
+        if (last >= 0) {
+            long end = windowEnd(last);
+            int seconds = seconds();
+            JsonObject happened = new JsonObject();
+            happened.put("seconds", seconds);
+            happened.put("endSecond", end);
+            happened.put("following", stripsEnd < 0);
+            JsonArray lines = new JsonArray();
+            for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, 
routeOrder(info), end, seconds)) {
+                JsonObject jo = new JsonObject();
+                jo.put("routeId", r.route());
+                jo.put("exchanges", r.total(r.completed()) + 
r.total(r.recovered()) + r.total(r.handled())
+                                    + r.total(r.failed()));
+                long[] worst = r.total(r.failed()) > 0 ? r.failed() : 
r.total(r.handled()) > 0 ? r.handled()
+                        : r.total(r.recovered()) > 0 ? r.recovered() : null;
+                if (worst != null) {
+                    jo.put("worst", worst == r.failed() ? "failed" : worst == 
r.handled() ? "handled" : "recovered");
+                    jo.put("worstCount", r.total(worst));
+                    jo.put("worstSecondsAgo", last - lastAt(worst, end, 
seconds));
+                    if (worst == r.recovered()) {
+                        String from = recoveredFrom(info, r.route(), end - 
seconds + 1, end);
+                        if (from != null) {
+                            jo.put("recoveredFrom", from);
+                        }
+                    }
+                }
+                if (r.openSeconds() > 0) {
+                    jo.put("circuitBreakerOpenSeconds", r.openSeconds());
+                }
+                if (r.total(r.down()) > 0) {
+                    jo.put("downSeconds", r.total(r.down()));
+                }
+                lines.add(jo);
+            }
+            happened.put("routes", lines);
+            JsonArray causes = new JsonArray();
+            for (Timeline.CauseSeries c : ctx.timeline.causes(info.pid, end, 
seconds)) {
+                long total = Arrays.stream(c.values()).sum();
+                if (total > 0) {
+                    JsonObject jo = new JsonObject();
+                    jo.put("label", c.label());
+                    if (c.rule() != null) {
+                        jo.put("chaosRule", c.rule());
+                    }
+                    jo.put("count", total);
+                    causes.add(jo);
+                }
+            }
+            happened.put("causes", causes);
+            result.put("whatHappened", happened);
+        }
+        Target t = selected();
+        if (t != null) {
+            JsonObject sel = new JsonObject();
+            sel.put("routeId", t.routeId());
+            if (t.endpoint() != null) {
+                sel.put("endpoint", t.endpoint());
+            }
+            result.put("selected", sel);
+        }
+        result.put("keys", "v all integrations, Up/Down select a line, Enter 
opens it, +/- zoom, Left/Right move, 4 Timeline");
+        return result;
+    }
+
+    private void putTotalAndRate(JsonObject jo, IntegrationInfo info, String 
routeId, Map<String, Long> totals) {
+        if (routeId == null) {
+            return;
+        }
+        if (totals.get(routeId) != null) {
+            jo.put("exchangesTotal", totals.get(routeId));
+        }
+        long[] perSecond = routePerSecond(info, routeId);
+        if (perSecond != null) {
+            jo.put("rate", rate(perSecond));
+        }
+    }
+
+    /** ↑ ↓: the cursor moves over the lines of In, Routes and Out that are 
about a route or an endpoint. */
+    void moveCursor(int delta) {
+        int n = targets.size();
+        if (n == 0) {
+            cursor = -1;
+        } else if (cursor < 0) {
+            cursor = delta > 0 ? 0 : n - 1;
+        } else {
+            cursor = Math.max(0, Math.min(n - 1, cursor + delta));
+        }
+    }
+
+    /** The line the cursor is on, or null. */
+    Target selected() {
+        List<Target> t = targets;
+        return cursor >= 0 && cursor < t.size() ? t.get(cursor) : null;
+    }
+
+    /** Enter: opens what the cursor is on; false when it is on nothing. */
+    boolean open() {
+        Target t = selected();
+        if (t == null || ctx.openFromAbout == null) {
+            return false;
+        }
+        ctx.openFromAbout.accept(t);
+        return true;
+    }
+
+    private int seconds() {
+        return TimelineTab.WINDOWS[zoom];
+    }
+
+    private long windowEnd(long end) {
+        return stripsEnd >= 0 ? Math.min(stripsEnd, end) : end;
+    }
+
+    /** Moves the window to end at the given second, as far as there are 
seconds recorded; at the end it follows. */
+    private void moveTo(long second, long end) {
+        long first = Math.max(0, end - Timeline.SECONDS + 1);
+        long to = Math.max(first + seconds() - 1, second);
+        stripsEnd = to >= end ? -1 : to;
+    }
+
+    /** The last second in the window that a route had trouble in, or -1. */
+    private long lastTrouble(IntegrationInfo info, long windowEnd, int 
seconds) {
+        long last = -1;
+        for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, 
routeOrder(info), windowEnd, seconds)) {
+            for (int i = seconds - 1; i >= 0; i--) {
+                if (r.failed()[i] > 0 || r.handled()[i] > 0 || 
r.recovered()[i] > 0 || r.attempts()[i] > 0
+                        || r.down()[i] > 0 || r.open()[i] > 0) {
+                    last = Math.max(last, windowEnd - seconds + 1 + i);
+                    break;
+                }
+            }
+        }
+        return last;
+    }
+
+    private static List<String> routeOrder(IntegrationInfo info) {
+        return info.routes.stream().map(r -> r.routeId).filter(id -> id != 
null).toList();
+    }
+
+    void render(Frame frame, Rect area, IntegrationInfo info) {
+        load(info, Runnable::run);
+        ProjectOverview.Overview o = overview;
+        int width = area.width();
+        List<Line> head = new ArrayList<>();
+        head.add(Line.from(Span.raw(" "), Span.raw(meta(info, o)),
+                Span.styled(info.vanishing ? "  ○ stopped" : "  ● running",
+                        info.vanishing ? Theme.muted() : Theme.success())));
+        if (purpose != null) {
+            for (String l : wrap(purpose, Math.max(20, width - 6), 2)) {

Review Comment:
   ⚠️ **Concurrency bug: volatile `purpose` and `purposeFrom` read without 
snapshot**
   
   The null check at line 404 (`if (purpose != null)`) and the actual read at 
line 405 (`wrap(purpose, ...)`) are two separate volatile reads. Between them, 
the background thread can set `purpose = null` (when switching integrations) → 
`wrap()` receives null → NPE on `text.split(" ")` or similar. `purposeFrom` at 
line 408 is also an unguarded re-read that can be null even after the check 
passed.
   
   ```suggestion
           String p = purpose;
           String pFrom = purposeFrom;
           if (p != null) {
               for (String l : wrap(p, Math.max(20, width - 6), 2)) {
                   head.add(Line.from(Span.styled(" " + l, 
Style.EMPTY.bold())));
               }
               head.add(Line.from(Span.styled(" " + pFrom, Theme.muted())));
   ```



##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AboutReport.java:
##########
@@ -0,0 +1,409 @@
+/*
+ * 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.ai;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * About an integration as text and JSON (CAMEL-26700), for {@code camel get 
about} and the AI tools: {@link About}
+ * (what it does, from its sources) and, when it runs, what has happened: the 
exchanges of its routes since it started
+ * (its status), and the last minutes from the timeline it records (the 
timeline dev console).
+ */
+public final class AboutReport {
+
+    private AboutReport() {
+    }
+
+    /**
+     * About the integration as JSON.
+     *
+     * @param model    about it, from its sources
+     * @param status   its status (the status file of the CLI connector), or 
null when it does not run
+     * @param timeline its timeline (the timeline dev console), or null
+     */
+    public static JsonObject json(About.Model model, JsonObject status, 
JsonObject timeline) {
+        Map<String, JsonObject> routeStatus = routeStatus(status);
+        JsonObject answer = new JsonObject();
+        if (model.purpose() != null) {
+            answer.put("purpose", model.purpose());
+            answer.put("purposeFrom", model.purposeFrom());
+        }
+        JsonArray in = new JsonArray();
+        for (About.In e : model.in()) {
+            JsonObject jo = new JsonObject();
+            jo.put("kind", e.kind());
+            jo.put("label", e.label());
+            jo.put("routeId", e.route());
+            putTotal(jo, routeStatus.get(e.route()));
+            in.add(jo);
+        }
+        answer.put("in", in);
+        JsonArray routes = new JsonArray();
+        for (About.RouteLine r : model.routes()) {
+            JsonObject jo = new JsonObject();
+            jo.put("routeId", r.routeId());
+            jo.put("depth", r.depth());
+            if (r.how() != null) {
+                jo.put("calledHow", r.how());
+            }
+            jo.put("from", r.from());
+            jo.put("steps", r.steps());
+            putTotal(jo, routeStatus.get(r.routeId()));
+            routes.add(jo);
+        }
+        answer.put("routes", routes);
+        JsonArray out = new JsonArray();
+        for (About.Out o : model.out()) {
+            JsonObject jo = new JsonObject();
+            jo.put("category", o.category());
+            jo.put("uri", o.uri());
+            jo.put("direction", o.direction());
+            jo.put("routeId", o.route());
+            Long hits = sends(status, o.uri(), o.route());
+            if (hits != null) {
+                jo.put("sends", hits);
+            }
+            out.add(jo);
+        }
+        answer.put("out", out);
+        if (timeline != null) {
+            answer.put("lastMinutes", lastMinutes(timeline));
+        }
+        return answer;
+    }
+
+    /**
+     * About the integration that runs from the directory, if one does: its 
pid and name, and {@link #json} with its
+     * status and timeline; null when none runs from there.
+     */
+    public static JsonObject running(Path dir, About.Model model) {
+        Path wanted = dir.toAbsolutePath().normalize();
+        for (RuntimeHelper.ProcessInfo p : RuntimeHelper.discoverProcesses()) {
+            JsonObject status = RuntimeHelper.readStatus(p.pid());
+            JsonObject runtime = status != null ? (JsonObject) 
status.get("runtime") : null;
+            String directory = runtime != null ? 
runtime.getString("directory") : null;
+            if (directory == null || 
!Path.of(directory).toAbsolutePath().normalize().equals(wanted)) {
+                continue;
+            }
+            JsonObject timeline = null;
+            try {
+                JsonObject request = new JsonObject();
+                request.put("action", "timeline");
+                request.put("exchanges", "true");
+                timeline = RuntimeHelper.executeAction(p.pid(), request, 5000);
+            } catch (IOException e) {
+                // no timeline: the routes since the start only
+            }
+            JsonObject answer = json(model, status, timeline != null && 
timeline.containsKey("routes") ? timeline : null);
+            answer.put("pid", Long.toString(p.pid()));
+            answer.put("name", p.name());
+            return answer;
+        }
+        return null;
+    }
+
+    /** About the integration as text, from its JSON ({@link #json}). */
+    public static String text(String title, JsonObject about) {
+        StringBuilder sb = new StringBuilder();
+        sb.append(title).append('\n');
+        if (about.getString("purpose") != null) {
+            sb.append(about.getString("purpose")).append('\n');
+            sb.append("  
(").append(about.getString("purposeFrom")).append(")\n");
+        }
+        sb.append("\nIn\n");
+        List<JsonObject> in = list(about, "in");
+        if (in.isEmpty()) {
+            sb.append("  nothing from outside\n");
+        }
+        for (JsonObject e : in) {
+            sb.append(String.format("  %-44s %s%n", e.getString("label"), 
total(e)));
+        }
+        sb.append("\nRoutes\n");
+        for (JsonObject r : list(about, "routes")) {
+            String how = "call".equals(r.getString("calledHow")) ? "↳ " : 
"async".equals(r.getString("calledHow")) ? "⇢ " : "";
+            int steps = r.getIntegerOrDefault("steps", 0);
+            String line = "  ".repeat(r.getIntegerOrDefault("depth", 0)) + how 
+ r.getString("routeId") + "  "
+                          + r.getString("from") + " → " + steps + (steps == 1 
? " step" : " steps");
+            sb.append(String.format("  %-44s %s%n", line, total(r)));
+        }
+        sb.append("\nOut\n");
+        List<JsonObject> out = list(about, "out");
+        if (out.isEmpty()) {
+            sb.append("  nothing outside the integration\n");
+        }
+        for (JsonObject o : out) {
+            sb.append(String.format("  %-44s %s%n", o.getString("uri") + "  
from " + o.getString("routeId"),
+                    o.get("sends") != null ? o.getLong("sends") + "" : ""));
+        }
+        JsonObject last = (JsonObject) about.get("lastMinutes");
+        if (last != null) {
+            sb.append("\nLast 
").append(minutes(last.getIntegerOrDefault("seconds", 0))).append('\n');
+            sb.append("  ").append(last.getLongOrDefault("exchanges", 
0)).append(" exchanges");
+            for (String k : List.of("recovered", "handled", "failed")) {
+                if (last.getLongOrDefault(k, 0) > 0) {
+                    sb.append(" · ").append(last.getLong(k)).append(' 
').append(k);
+                }
+            }
+            JsonObject cause = (JsonObject) last.get("cause");
+            if (cause != null) {
+                sb.append(" · ").append(cause.getString("label")).append(" 
×").append(cause.getLong("count"))
+                        .append(" (last 
").append(ago(cause.getLongOrDefault("lastSecondsAgo", 0))).append(')');
+            }
+            sb.append('\n');
+            for (JsonObject r : list(last, "routes")) {
+                StringBuilder line = new StringBuilder();
+                line.append(String.format("  %-20s %6d", 
r.getString("routeId"), r.getLongOrDefault("exchanges", 0)));
+                if (r.get("worst") != null) {
+                    line.append("  ").append(r.getLong("worstCount")).append(' 
').append(r.getString("worst"))
+                            .append(' 
').append(ago(r.getLongOrDefault("worstSecondsAgo", 0)));
+                    if (r.getString("recoveredFrom") != null) {
+                        line.append(" · 
").append(r.getString("recoveredFrom"));
+                    }
+                }
+                if (r.getLongOrDefault("circuitBreakerOpenSeconds", 0) > 0) {
+                    line.append("  circuit breaker open 
").append(r.getLong("circuitBreakerOpenSeconds")).append('s');
+                }
+                if (r.getLongOrDefault("downSeconds", 0) > 0) {
+                    line.append("  down 
").append(r.getLong("downSeconds")).append('s');
+                }
+                sb.append(line).append('\n');
+            }
+        }
+        return sb.toString();
+    }
+
+    /**
+     * What happened in the seconds of the timeline: the exchanges and how 
they ended, the cause that happened most with
+     * when it last did, and per route the worst that happened to it with 
when, the failure a recovery recovered from,
+     * and the seconds a circuit breaker was open or a chaos rule had it down.
+     */
+    static JsonObject lastMinutes(JsonObject timeline) {
+        int n = timeline.getIntegerOrDefault("seconds", 0);
+        JsonObject answer = new JsonObject();
+        answer.put("seconds", n);
+        long exchanges = 0;
+        Map<String, Long> totals = new LinkedHashMap<>();
+        Map<String, long[]> open = new LinkedHashMap<>();
+        for (JsonObject cb : list(timeline, "circuitBreakers")) {
+            long[] states = values(cb, "states", n);
+            long[] v = open.computeIfAbsent(cb.getString("routeId"), k -> new 
long[n]);
+            for (int i = 0; i < n; i++) {
+                v[i] = Math.max(v[i], states[i]);
+            }
+        }
+        // the failure a recovered exchange of a route was redelivered for, 
the last one
+        Map<String, String> recoveredFrom = new LinkedHashMap<>();
+        String anyRecoveredFrom = null;
+        for (JsonObject e : list(timeline, "exchanges")) {
+            if (e.getBooleanOrDefault("redelivered", false) && 
!e.getBooleanOrDefault("failed", false)
+                    && !e.getBooleanOrDefault("handled", false) && 
e.getString("exception") != null) {
+                recoveredFrom.put(e.getString("routeId"), 
e.getString("exception"));
+                anyRecoveredFrom = e.getString("exception");
+            }
+        }
+        JsonArray routes = new JsonArray();
+        for (JsonObject r : list(timeline, "routes")) {
+            String routeId = r.getString("routeId");
+            long[] completed = values(r, "completed", n);
+            long[] recovered = values(r, "recovered", n);
+            long[] handled = values(r, "handled", n);
+            long[] failed = values(r, "failed", n);
+            long done = sum(completed) + sum(recovered) + sum(handled) + 
sum(failed);
+            totals.merge("recovered", sum(recovered), Long::sum);
+            totals.merge("handled", sum(handled), Long::sum);
+            totals.merge("failed", sum(failed), Long::sum);
+            JsonObject jo = new JsonObject();
+            jo.put("routeId", routeId);
+            jo.put("exchanges", done);
+            long[] worst = sum(failed) > 0 ? failed : sum(handled) > 0 ? 
handled : sum(recovered) > 0 ? recovered : null;
+            if (worst != null) {
+                jo.put("worst", worst == failed ? "failed" : worst == handled 
? "handled" : "recovered");
+                jo.put("worstCount", sum(worst));
+                jo.put("worstSecondsAgo", n - 1 - last(worst));
+                if (worst == recovered) {
+                    String from = recoveredFrom.getOrDefault(routeId, 
anyRecoveredFrom);
+                    if (from != null) {
+                        jo.put("recoveredFrom", from);
+                    }
+                }
+            }
+            long[] states = open.get(routeId);
+            if (states != null) {
+                jo.put("circuitBreakerOpenSeconds", 
Arrays.stream(states).filter(v -> v >= 2).count());
+            }
+            if (r.get("down") != null && sum(values(r, "down", n)) > 0) {
+                jo.put("downSeconds", sum(values(r, "down", n)));
+            }
+            routes.add(jo);
+        }
+        for (Object v : timeline.getCollectionOrDefault("exchangesDone", 
List.of())) {
+            exchanges += v instanceof Number num ? num.longValue() : 0;
+        }
+        answer.put("exchanges", exchanges);
+        totals.forEach(answer::put);
+        // the cause that happened most
+        JsonObject cause = null;
+        long most = 0;
+        for (JsonObject e : list(timeline, "errors")) {
+            long[] counts = values(e, "counts", n);
+            if (sum(counts) > most) {
+                most = sum(counts);
+                String exception = e.getString("exception");
+                String label = exception != null ? 
exception.substring(exception.lastIndexOf('.') + 1) : "error";
+                if (e.get("statusCode") != null) {
+                    label += " " + e.getInteger("statusCode");
+                }
+                if (e.getString("chaosRule") != null) {
+                    label = "chaos rule " + e.getString("chaosRule") + ": " + 
label;
+                }
+                cause = new JsonObject();
+                cause.put("label", label);
+                cause.put("routeId", e.getString("routeId"));
+                cause.put("count", most);
+                cause.put("lastSecondsAgo", n - 1 - last(counts));
+            }
+        }
+        if (cause != null) {
+            answer.put("cause", cause);
+        }
+        answer.put("routes", routes);
+        return answer;
+    }
+
+    private static Map<String, JsonObject> routeStatus(JsonObject status) {
+        Map<String, JsonObject> answer = new LinkedHashMap<>();
+        if (status != null && status.get("routes") instanceof List<?> routes) {
+            for (Object o : routes) {
+                if (o instanceof JsonObject r && r.getString("routeId") != 
null) {
+                    answer.put(r.getString("routeId"), r);
+                }
+            }
+        }
+        return answer;
+    }
+
+    private static void putTotal(JsonObject jo, JsonObject route) {
+        if (route != null && route.get("statistics") instanceof Map<?, ?> 
stats) {
+            if (stats.get("exchangesTotal") instanceof Number total) {
+                jo.put("exchangesTotal", total.longValue());
+            }
+            if (stats.get("exchangesFailed") instanceof Number failed && 
failed.longValue() > 0) {
+                jo.put("exchangesFailed", failed.longValue());
+            }
+        }
+    }
+
+    /**
+     * How many times the route sent to the endpoint, from the endpoint 
statistics in the status; null when there is no
+     * such endpoint (a URI with a placeholder, or no statistics).
+     */
+    static Long sends(JsonObject status, String uri, String route) {
+        if (status == null || !(status.get("endpoints") instanceof JsonObject 
endpoints)
+                || !(endpoints.get("endpoints") instanceof List<?> list)) {
+            return null;
+        }
+        String base = baseUri(uri);
+        Long answer = null;
+        for (Object o : list) {
+            if (o instanceof JsonObject ep && 
"out".equals(ep.getString("direction"))
+                    && (route == null || 
route.equals(ep.getString("routeId"))) && 
base.equals(baseUri(ep.getString("uri")))
+                    && ep.get("hits") instanceof Number hits) {
+                answer = (answer != null ? answer : 0) + hits.longValue();
+            }
+        }
+        return answer;
+    }
+
+    /** The URI without its options, and the same with or without the // after 
the scheme. */
+    public static String baseUri(String uri) {
+        if (uri == null) {
+            return "";
+        }
+        int q = uri.indexOf('?');
+        String answer = q >= 0 ? uri.substring(0, q) : uri;
+        answer = answer.replaceFirst("^([a-zA-Z0-9+.-]+):/{0,2}", "$1:");

Review Comment:
   ⚠️ **`baseUri()` regex `/{0,2}` normalizes `file:` URIs inconsistently**
   
   `/{0,2}` matches 0, 1, or 2 leading slashes after the scheme. Result:
   - `file:/tmp/foo` → strips 1 slash → `file:tmp/foo` ❌
   - `file:///tmp/foo` → strips 2 of 3 slashes → `file:/tmp/foo` ✅ (correct)
   
   Same logical endpoint, different normalized form. This breaks send-count 
matching in `sends()` when the source records `file:/path` and the runtime 
reports `file:///path`.
   
   Change to `/{1,3}` to require at least one slash (so scheme-only URIs like 
`direct:foo` with no slash are unchanged) and strip up to 3 (normalizing all 
`file:/`, `file://`, `file:///` to `file:`):
   ```suggestion
           answer = answer.replaceFirst("^([a-zA-Z0-9+.-]+):/{1,3}", "$1:");
   ```



##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AboutReport.java:
##########
@@ -0,0 +1,409 @@
+/*
+ * 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.ai;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * About an integration as text and JSON (CAMEL-26700), for {@code camel get 
about} and the AI tools: {@link About}
+ * (what it does, from its sources) and, when it runs, what has happened: the 
exchanges of its routes since it started
+ * (its status), and the last minutes from the timeline it records (the 
timeline dev console).
+ */
+public final class AboutReport {
+
+    private AboutReport() {
+    }
+
+    /**
+     * About the integration as JSON.
+     *
+     * @param model    about it, from its sources
+     * @param status   its status (the status file of the CLI connector), or 
null when it does not run
+     * @param timeline its timeline (the timeline dev console), or null
+     */
+    public static JsonObject json(About.Model model, JsonObject status, 
JsonObject timeline) {
+        Map<String, JsonObject> routeStatus = routeStatus(status);
+        JsonObject answer = new JsonObject();
+        if (model.purpose() != null) {
+            answer.put("purpose", model.purpose());
+            answer.put("purposeFrom", model.purposeFrom());
+        }
+        JsonArray in = new JsonArray();
+        for (About.In e : model.in()) {
+            JsonObject jo = new JsonObject();
+            jo.put("kind", e.kind());
+            jo.put("label", e.label());
+            jo.put("routeId", e.route());
+            putTotal(jo, routeStatus.get(e.route()));
+            in.add(jo);
+        }
+        answer.put("in", in);
+        JsonArray routes = new JsonArray();
+        for (About.RouteLine r : model.routes()) {
+            JsonObject jo = new JsonObject();
+            jo.put("routeId", r.routeId());
+            jo.put("depth", r.depth());
+            if (r.how() != null) {
+                jo.put("calledHow", r.how());
+            }
+            jo.put("from", r.from());
+            jo.put("steps", r.steps());
+            putTotal(jo, routeStatus.get(r.routeId()));
+            routes.add(jo);
+        }
+        answer.put("routes", routes);
+        JsonArray out = new JsonArray();
+        for (About.Out o : model.out()) {
+            JsonObject jo = new JsonObject();
+            jo.put("category", o.category());
+            jo.put("uri", o.uri());
+            jo.put("direction", o.direction());
+            jo.put("routeId", o.route());
+            Long hits = sends(status, o.uri(), o.route());
+            if (hits != null) {
+                jo.put("sends", hits);
+            }
+            out.add(jo);
+        }
+        answer.put("out", out);
+        if (timeline != null) {
+            answer.put("lastMinutes", lastMinutes(timeline));
+        }
+        return answer;
+    }
+
+    /**
+     * About the integration that runs from the directory, if one does: its 
pid and name, and {@link #json} with its
+     * status and timeline; null when none runs from there.
+     */
+    public static JsonObject running(Path dir, About.Model model) {
+        Path wanted = dir.toAbsolutePath().normalize();
+        for (RuntimeHelper.ProcessInfo p : RuntimeHelper.discoverProcesses()) {
+            JsonObject status = RuntimeHelper.readStatus(p.pid());
+            JsonObject runtime = status != null ? (JsonObject) 
status.get("runtime") : null;
+            String directory = runtime != null ? 
runtime.getString("directory") : null;
+            if (directory == null || 
!Path.of(directory).toAbsolutePath().normalize().equals(wanted)) {
+                continue;
+            }
+            JsonObject timeline = null;
+            try {
+                JsonObject request = new JsonObject();
+                request.put("action", "timeline");
+                request.put("exchanges", "true");
+                timeline = RuntimeHelper.executeAction(p.pid(), request, 5000);
+            } catch (IOException e) {
+                // no timeline: the routes since the start only
+            }
+            JsonObject answer = json(model, status, timeline != null && 
timeline.containsKey("routes") ? timeline : null);
+            answer.put("pid", Long.toString(p.pid()));
+            answer.put("name", p.name());
+            return answer;
+        }
+        return null;
+    }
+
+    /** About the integration as text, from its JSON ({@link #json}). */
+    public static String text(String title, JsonObject about) {
+        StringBuilder sb = new StringBuilder();
+        sb.append(title).append('\n');
+        if (about.getString("purpose") != null) {
+            sb.append(about.getString("purpose")).append('\n');
+            sb.append("  
(").append(about.getString("purposeFrom")).append(")\n");
+        }
+        sb.append("\nIn\n");
+        List<JsonObject> in = list(about, "in");
+        if (in.isEmpty()) {
+            sb.append("  nothing from outside\n");
+        }
+        for (JsonObject e : in) {
+            sb.append(String.format("  %-44s %s%n", e.getString("label"), 
total(e)));
+        }
+        sb.append("\nRoutes\n");
+        for (JsonObject r : list(about, "routes")) {
+            String how = "call".equals(r.getString("calledHow")) ? "↳ " : 
"async".equals(r.getString("calledHow")) ? "⇢ " : "";
+            int steps = r.getIntegerOrDefault("steps", 0);
+            String line = "  ".repeat(r.getIntegerOrDefault("depth", 0)) + how 
+ r.getString("routeId") + "  "
+                          + r.getString("from") + " → " + steps + (steps == 1 
? " step" : " steps");
+            sb.append(String.format("  %-44s %s%n", line, total(r)));
+        }
+        sb.append("\nOut\n");
+        List<JsonObject> out = list(about, "out");
+        if (out.isEmpty()) {
+            sb.append("  nothing outside the integration\n");
+        }
+        for (JsonObject o : out) {
+            sb.append(String.format("  %-44s %s%n", o.getString("uri") + "  
from " + o.getString("routeId"),
+                    o.get("sends") != null ? o.getLong("sends") + "" : ""));
+        }
+        JsonObject last = (JsonObject) about.get("lastMinutes");
+        if (last != null) {
+            sb.append("\nLast 
").append(minutes(last.getIntegerOrDefault("seconds", 0))).append('\n');
+            sb.append("  ").append(last.getLongOrDefault("exchanges", 
0)).append(" exchanges");
+            for (String k : List.of("recovered", "handled", "failed")) {
+                if (last.getLongOrDefault(k, 0) > 0) {
+                    sb.append(" · ").append(last.getLong(k)).append(' 
').append(k);
+                }
+            }
+            JsonObject cause = (JsonObject) last.get("cause");
+            if (cause != null) {
+                sb.append(" · ").append(cause.getString("label")).append(" 
×").append(cause.getLong("count"))
+                        .append(" (last 
").append(ago(cause.getLongOrDefault("lastSecondsAgo", 0))).append(')');
+            }
+            sb.append('\n');
+            for (JsonObject r : list(last, "routes")) {
+                StringBuilder line = new StringBuilder();
+                line.append(String.format("  %-20s %6d", 
r.getString("routeId"), r.getLongOrDefault("exchanges", 0)));
+                if (r.get("worst") != null) {
+                    line.append("  ").append(r.getLong("worstCount")).append(' 
').append(r.getString("worst"))
+                            .append(' 
').append(ago(r.getLongOrDefault("worstSecondsAgo", 0)));
+                    if (r.getString("recoveredFrom") != null) {
+                        line.append(" · 
").append(r.getString("recoveredFrom"));
+                    }
+                }
+                if (r.getLongOrDefault("circuitBreakerOpenSeconds", 0) > 0) {
+                    line.append("  circuit breaker open 
").append(r.getLong("circuitBreakerOpenSeconds")).append('s');
+                }
+                if (r.getLongOrDefault("downSeconds", 0) > 0) {
+                    line.append("  down 
").append(r.getLong("downSeconds")).append('s');
+                }
+                sb.append(line).append('\n');
+            }
+        }
+        return sb.toString();
+    }
+
+    /**
+     * What happened in the seconds of the timeline: the exchanges and how 
they ended, the cause that happened most with
+     * when it last did, and per route the worst that happened to it with 
when, the failure a recovery recovered from,
+     * and the seconds a circuit breaker was open or a chaos rule had it down.
+     */
+    static JsonObject lastMinutes(JsonObject timeline) {
+        int n = timeline.getIntegerOrDefault("seconds", 0);
+        JsonObject answer = new JsonObject();
+        answer.put("seconds", n);
+        long exchanges = 0;
+        Map<String, Long> totals = new LinkedHashMap<>();
+        Map<String, long[]> open = new LinkedHashMap<>();
+        for (JsonObject cb : list(timeline, "circuitBreakers")) {
+            long[] states = values(cb, "states", n);
+            long[] v = open.computeIfAbsent(cb.getString("routeId"), k -> new 
long[n]);
+            for (int i = 0; i < n; i++) {
+                v[i] = Math.max(v[i], states[i]);
+            }
+        }
+        // the failure a recovered exchange of a route was redelivered for, 
the last one
+        Map<String, String> recoveredFrom = new LinkedHashMap<>();
+        String anyRecoveredFrom = null;
+        for (JsonObject e : list(timeline, "exchanges")) {
+            if (e.getBooleanOrDefault("redelivered", false) && 
!e.getBooleanOrDefault("failed", false)
+                    && !e.getBooleanOrDefault("handled", false) && 
e.getString("exception") != null) {
+                recoveredFrom.put(e.getString("routeId"), 
e.getString("exception"));
+                anyRecoveredFrom = e.getString("exception");
+            }
+        }
+        JsonArray routes = new JsonArray();
+        for (JsonObject r : list(timeline, "routes")) {
+            String routeId = r.getString("routeId");
+            long[] completed = values(r, "completed", n);
+            long[] recovered = values(r, "recovered", n);
+            long[] handled = values(r, "handled", n);
+            long[] failed = values(r, "failed", n);
+            long done = sum(completed) + sum(recovered) + sum(handled) + 
sum(failed);
+            totals.merge("recovered", sum(recovered), Long::sum);
+            totals.merge("handled", sum(handled), Long::sum);
+            totals.merge("failed", sum(failed), Long::sum);
+            JsonObject jo = new JsonObject();
+            jo.put("routeId", routeId);
+            jo.put("exchanges", done);
+            long[] worst = sum(failed) > 0 ? failed : sum(handled) > 0 ? 
handled : sum(recovered) > 0 ? recovered : null;
+            if (worst != null) {
+                jo.put("worst", worst == failed ? "failed" : worst == handled 
? "handled" : "recovered");
+                jo.put("worstCount", sum(worst));
+                jo.put("worstSecondsAgo", n - 1 - last(worst));
+                if (worst == recovered) {
+                    String from = recoveredFrom.getOrDefault(routeId, 
anyRecoveredFrom);
+                    if (from != null) {
+                        jo.put("recoveredFrom", from);
+                    }
+                }
+            }
+            long[] states = open.get(routeId);
+            if (states != null) {
+                jo.put("circuitBreakerOpenSeconds", 
Arrays.stream(states).filter(v -> v >= 2).count());
+            }
+            if (r.get("down") != null && sum(values(r, "down", n)) > 0) {
+                jo.put("downSeconds", sum(values(r, "down", n)));

Review Comment:
   💡 **`values(r, "down", n)` called twice — extract to local**
   
   Both the null guard and the `put` value call `values(r, "down", n)` with 
identical arguments. Each call allocates a `long[n]` and iterates the JSON list.
   
   ```suggestion
               long[] down = values(r, "down", n);
               if (r.get("down") != null && sum(down) > 0) {
                   jo.put("downSeconds", sum(down));
   ```



##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/About.java:
##########
@@ -0,0 +1,235 @@
+/*
+ * 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.ai;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * About an integration (CAMEL-26700): what it does at a glance and without an 
AI, from the analysis of its sources
+ * ({@link ProjectOverview}). Its purpose in the words of its author, what 
comes in, its routes (a route called with
+ * direct or seda under the route that calls it) and what goes out. The About 
screen of the TUI and {@code camel get
+ * about} are built from it, so they say the same.
+ */
+public final class About {
+
+    /**
+     * What starts the integration.
+     *
+     * @param kind  the kind of entry: http, timer, rest, or for a consumer 
the scheme of what it consumes from
+     * @param label the entry as the sources have it, such as timer:orders 
(period=500)
+     * @param route the route it starts
+     */
+    public record In(String kind, String label, String route) {
+    }
+
+    /**
+     * A route, in the order to show it: a route that something else in the 
integration calls comes after its caller,
+     * one deeper.
+     *
+     * @param routeId the route ID (else its key in the analysis)
+     * @param depth   how deep under the route that calls it (0 for a route 
that nothing in the integration calls)
+     * @param how     call (direct: and the like, the caller waits), async 
(seda: and the like), or null at depth 0
+     * @param from    the scheme the route consumes from
+     * @param steps   the number of steps of the route
+     */
+    public record RouteLine(String routeId, int depth, String how, String 
from, int steps) {
+    }
+
+    /**
+     * A system the integration talks to.
+     *
+     * @param category  the kind of system, as the catalog groups the component
+     * @param uri       the endpoint as the sources have it
+     * @param direction out, or in/out for a request-reply
+     * @param route     the route that talks to it
+     */
+    public record Out(String category, String uri, String direction, String 
route) {
+    }
+
+    /**
+     * About an integration.
+     *
+     * @param purpose     what it does, in the words of its author, or null
+     * @param purposeFrom where the purpose comes from, or null
+     */
+    public record Model(String purpose, String purposeFrom, List<In> in, 
List<RouteLine> routes, List<Out> out) {
+    }
+
+    private About() {
+    }
+
+    /** About the integration in the directory, from the analysis of its 
sources. */
+    public static Model of(Path dir, ProjectOverview.Overview o) {
+        String[] p = purpose(dir, o);
+        List<In> in = new ArrayList<>();
+        for (ProjectOverview.EntryPoint e : o.entryPoints()) {
+            // a consumer by the kind of system it consumes from (its scheme)
+            String kind = "consumer".equals(e.kind()) && 
e.label().indexOf(':') > 0
+                    ? e.label().substring(0, e.label().indexOf(':')) : 
e.kind();
+            in.add(new In(kind, e.label(), e.route()));
+        }
+
+        // the routes that something else in the integration calls go under 
their caller
+        Map<String, List<ProjectOverview.Link>> calledBy = new 
LinkedHashMap<>();
+        Set<String> called = new LinkedHashSet<>();
+        for (ProjectOverview.Link l : o.links()) {
+            if (("call".equals(l.kind()) || "async".equals(l.kind())) && 
!l.from().equals(l.to())) {
+                calledBy.computeIfAbsent(l.from(), k -> new 
ArrayList<>()).add(l);
+                called.add(l.to());
+            }
+        }
+        List<RouteLine> routes = new ArrayList<>();
+        Set<String> shown = new LinkedHashSet<>();
+        for (ProjectRoutes.Route r : o.flows()) {
+            if (!called.contains(r.key())) {
+                routeLines(o, r, 0, null, calledBy, shown, routes);
+            }
+        }
+        // what is left: routes called only by routes in a cycle
+        for (ProjectRoutes.Route r : o.flows()) {
+            if (!shown.contains(r.key())) {
+                routeLines(o, r, 0, null, calledBy, shown, routes);
+            }
+        }
+
+        List<Out> out = new ArrayList<>();
+        Set<String> systems = new LinkedHashSet<>();
+        for (ProjectOverview.SystemUse s : o.systems()) {
+            if (!"in".equals(s.direction()) && systems.add(s.uri() + "|" + 
s.direction())) {
+                out.add(new Out(s.category(), s.uri(), s.direction(), 
s.route()));
+            }
+        }
+        return new Model(p != null ? p[0] : null, p != null ? p[1] : null, in, 
routes, out);
+    }
+
+    private static void routeLines(
+            ProjectOverview.Overview o, ProjectRoutes.Route r, int depth, 
String how,
+            Map<String, List<ProjectOverview.Link>> calledBy, Set<String> 
shown, List<RouteLine> lines) {
+        if (!shown.add(r.key()) || depth > 6) {
+            return;
+        }
+        String from = r.from() != null ? r.from().scheme() : r.kind();
+        lines.add(new RouteLine(r.id() != null ? r.id() : r.key(), depth, how, 
from, r.steps()));
+        for (ProjectOverview.Link l : calledBy.getOrDefault(r.key(), 
List.of())) {
+            ProjectRoutes.Route target = o.route(l.to());
+            if (target != null) {
+                routeLines(o, target, depth + 1, l.kind(), calledBy, shown, 
lines);
+            }
+        }
+    }
+
+    /**
+     * What the integration does, in the words of its author: the comment at 
the top of a route file (the files in the
+     * order of the analysis), else the descriptions of the routes that 
something outside starts. Returns the text and
+     * where it comes from, or null.
+     */
+    public static String[] purpose(Path dir, ProjectOverview.Overview o) {
+        for (String file : o.files()) {
+            try {
+                String text = leadingComment(file, 
Files.readString(dir.resolve(file), StandardCharsets.UTF_8));
+                if (text != null) {
+                    return new String[] { text, "from the comment at the top 
of " + file };
+                }
+            } catch (IOException e) {
+                // an unreadable file has no comment
+            }
+        }
+        List<String> descriptions = new ArrayList<>();
+        Set<String> entryRoutes = new LinkedHashSet<>();
+        o.entryPoints().forEach(e -> entryRoutes.add(e.route()));
+        for (ProjectRoutes.Route r : o.flows()) {
+            if (r.hasDescription() && entryRoutes.contains(r.key())) {
+                descriptions.add(r.description());
+            }
+        }
+        for (ProjectRoutes.Route r : o.flows()) {
+            if (r.hasDescription() && !entryRoutes.contains(r.key()) && 
descriptions.size() < 2) {
+                descriptions.add(r.description());
+            }
+        }
+        if (!descriptions.isEmpty()) {
+            return new String[] {
+                    String.join(" ", descriptions.subList(0, Math.min(2, 
descriptions.size()))),
+                    "from the descriptions of the routes" };
+        }
+        return null;
+    }
+
+    /**
+     * The comment a file starts with: the {@code #} lines of YAML, the 
Javadoc or {@code //} lines before the class of
+     * Java, the first {@code <!-- -->} of XML. A license header is no 
description.
+     */
+    public static String leadingComment(String file, String content) {

Review Comment:
   💡 **`leadingComment` is `public` but has no null guard on `content`**
   
   A null `content` will NPE at `content.lines()` (YAML branch) or 
`content.indexOf(...)` (XML/Java). All current call sites pass non-null content 
(from `Files.readString`) but a public method that NPEs on null is a trap for 
future callers.
   
   Consider adding an early return: `if (content == null) return null;`



##########
dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AboutView.java:
##########
@@ -0,0 +1,1014 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.dsl.jbang.core.commands.ai.About;
+import org.apache.camel.dsl.jbang.core.commands.ai.AboutReport;
+import org.apache.camel.dsl.jbang.core.commands.ai.ProjectOverview;
+import org.apache.camel.util.TimeUtils;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * About an integration (Overview ▸ About, CAMEL-26700): what it does at a 
glance and without an AI, and what has
+ * happened. A header with what it is and its purpose (the comment at the top 
of its main file, else the descriptions of
+ * its routes), then what comes in, its routes (the routes called with direct 
or seda under the route that calls them)
+ * and what goes out, from the analysis of its sources ({@link 
ProjectOverview}). Under it, the last minutes from the
+ * timeline the integration records, which the Timeline tab (4) shows second 
by second, and what to watch.
+ */
+final class AboutView {
+
+    // how often the sources are read again, to see a change
+    private static final long RELOAD_MILLIS = 10_000;
+    private static final int HAPPENED_SECONDS = 300;
+    private static final String BLOCKS = " ▁▂▃▄▅▆▇█";
+    // the strips have this many columns, whatever the zoom
+    private static final int STRIP_COLUMNS = 60;
+    // the sparklines of In and Out
+    private static final int SPARK_WIDTH = 14;
+    private static final DateTimeFormatter TIME = 
DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
+
+    private final MonitorContext ctx;
+    private volatile ProjectOverview.Overview overview;
+    private volatile About.Model model;
+    private volatile String purpose;
+    private volatile String purposeFrom;
+    private volatile boolean loading;
+    private volatile String error;
+    private Path dir;
+    private long loadedAt;
+    // the zoom of What happened, as the windows of the Timeline tab (+ and 
-), and its last second when moved (← →);
+    // -1 follows the last second recorded
+    private int zoom = TimelineTab.WINDOWS.length - 1;
+    private long stripsEnd = -1;
+    // the line of In, Routes or Out the cursor is on (↑ ↓), in the order of 
the columns; -1 when none is
+    private int cursor = -1;
+    private volatile List<Target> targets = List.of();
+    private volatile List<String> targetLabels = List.of();
+
+    /**
+     * What a line of In, Routes or Out is about, for Enter: a route (opened 
on the Diagram) or an endpoint a route
+     * sends to (opened on the Endpoint tab).
+     *
+     * @param routeId  the route
+     * @param endpoint the URI of the endpoint as the sources have it, or null 
for a route
+     */
+    record Target(String routeId, String endpoint) {
+    }
+
+    AboutView(MonitorContext ctx) {
+        this.ctx = ctx;
+    }
+
+    /** Reads the sources of the integration in the background, again when 
they may have changed. */
+    private void load(IntegrationInfo info, Consumer<Runnable> onRenderThread) 
{
+        Path d = info.directory != null ? Path.of(info.directory) : null;
+        long now = System.currentTimeMillis();
+        boolean same = d != null && d.equals(dir);
+        if (d == null || loading || same && now - loadedAt < RELOAD_MILLIS) {
+            if (d == null) {
+                error = "No source directory for this integration";
+            }
+            return;
+        }
+        if (!same) {
+            overview = null;
+            purpose = null;
+            error = null;
+        }
+        dir = d;
+        loadedAt = now;
+        loading = true;
+        Thread t = new Thread(() -> {
+            try {
+                ProjectOverview.Overview o = ProjectOverview.analyze(d, 
ArchitectureView.catalog());
+                About.Model m = About.of(d, o);
+                onRenderThread.accept(() -> {
+                    overview = o;
+                    model = m;
+                    purpose = m.purpose();
+                    purposeFrom = m.purposeFrom();
+                    error = o.flows().isEmpty() ? "No routes found in " + d : 
null;
+                    loading = false;
+                });
+            } catch (Exception e) {
+                onRenderThread.accept(() -> {
+                    error = "Cannot read the sources: " + e.getMessage();
+                    loading = false;
+                });
+            }
+        }, "CamelTuiAbout");
+        t.setDaemon(true);
+        t.start();
+    }
+
+    /**
+     * The keys of What happened: + zooms in on the last trouble in the window 
(a failure, a handled error, a
+     * redelivery, a route down, a circuit breaker open), - zooms out, and ← → 
move a zoomed window.
+     */
+    boolean handleKeyEvent(KeyEvent ke, IntegrationInfo info) {
+        if (info == null) {
+            return false;
+        }
+        if (ke.isConfirm()) {
+            return open();
+        }
+        long end = ctx.timeline.endSecond(info.pid);
+        if (ke.isChar('+') || ke.isChar('=')) {
+            if (zoom > 0 && end >= 0) {
+                long trouble = lastTrouble(info, windowEnd(end), seconds());
+                zoom--;
+                if (trouble >= 0) {
+                    // the trouble in the middle of the window, when there is 
time after it
+                    moveTo(trouble + seconds() / 2, end);
+                }
+            }
+            return true;
+        }
+        if (ke.isChar('-')) {
+            if (zoom < TimelineTab.WINDOWS.length - 1) {
+                zoom++;
+                if (end >= 0) {
+                    moveTo(windowEnd(end) + seconds() / 4, end);
+                }
+            }
+            return true;
+        }
+        if ((ke.isLeft() || ke.isRight()) && end >= 0) {
+            int by = Math.max(1, seconds() / 4);
+            moveTo(windowEnd(end) + (ke.isLeft() ? -by : by), end);
+            return true;
+        }
+        return false;
+    }
+
+    /** The lines the cursor can be on, for an agent (tui_get_state): the 
route, or the endpoint and its route. */
+    SelectionContext selectionContext() {
+        List<String> items = targetLabels;
+        return new SelectionContext("list", items, cursor < items.size() ? 
cursor : -1, items.size(), "About");
+    }
+
+    /**
+     * About the integration as data, for an agent (tui_get_table): what the 
screen shows, with what happened over the
+     * window of What happened.
+     */
+    JsonObject tableJson(IntegrationInfo info) {
+        JsonObject result = new JsonObject();
+        result.put("tab", "Overview");
+        result.put("view", "About");
+        result.put("integration", info.name != null ? info.name : info.pid);
+        result.put("pid", info.pid);
+        About.Model m = model;
+        if (m == null) {
+            result.put("loading", error != null ? error : "reading the 
sources");
+            return result;
+        }
+        if (m.purpose() != null) {
+            result.put("purpose", m.purpose());
+            result.put("purposeFrom", m.purposeFrom());
+        }
+        Map<String, Long> totals = new LinkedHashMap<>();
+        info.routes.forEach(r -> {
+            if (r.routeId != null) {
+                totals.put(r.routeId, r.total);
+            }
+        });
+        JsonArray in = new JsonArray();
+        for (About.In e : m.in()) {
+            JsonObject jo = new JsonObject();
+            jo.put("label", e.label());
+            jo.put("kind", e.kind());
+            jo.put("routeId", e.route());
+            putTotalAndRate(jo, info, e.route(), totals);
+            in.add(jo);
+        }
+        result.put("in", in);
+        JsonArray routes = new JsonArray();
+        for (About.RouteLine r : m.routes()) {
+            JsonObject jo = new JsonObject();
+            jo.put("routeId", r.routeId());
+            jo.put("depth", r.depth());
+            if (r.how() != null) {
+                jo.put("calledHow", r.how());
+            }
+            jo.put("from", r.from());
+            jo.put("steps", r.steps());
+            if (totals.get(r.routeId()) != null) {
+                jo.put("exchangesTotal", totals.get(r.routeId()));
+            }
+            routes.add(jo);
+        }
+        result.put("routes", routes);
+        JsonArray out = new JsonArray();
+        for (About.Out o : m.out()) {
+            JsonObject jo = new JsonObject();
+            jo.put("uri", o.uri());
+            jo.put("category", o.category());
+            jo.put("routeId", o.route());
+            Long hits = outHits(info, o.uri(), o.route());
+            if (hits != null) {
+                jo.put("sends", hits);
+            }
+            out.add(jo);
+        }
+        result.put("out", out);
+        long last = ctx.timeline.endSecond(info.pid);
+        if (last >= 0) {
+            long end = windowEnd(last);
+            int seconds = seconds();
+            JsonObject happened = new JsonObject();
+            happened.put("seconds", seconds);
+            happened.put("endSecond", end);
+            happened.put("following", stripsEnd < 0);
+            JsonArray lines = new JsonArray();
+            for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, 
routeOrder(info), end, seconds)) {
+                JsonObject jo = new JsonObject();
+                jo.put("routeId", r.route());
+                jo.put("exchanges", r.total(r.completed()) + 
r.total(r.recovered()) + r.total(r.handled())
+                                    + r.total(r.failed()));
+                long[] worst = r.total(r.failed()) > 0 ? r.failed() : 
r.total(r.handled()) > 0 ? r.handled()
+                        : r.total(r.recovered()) > 0 ? r.recovered() : null;
+                if (worst != null) {
+                    jo.put("worst", worst == r.failed() ? "failed" : worst == 
r.handled() ? "handled" : "recovered");
+                    jo.put("worstCount", r.total(worst));
+                    jo.put("worstSecondsAgo", last - lastAt(worst, end, 
seconds));
+                    if (worst == r.recovered()) {
+                        String from = recoveredFrom(info, r.route(), end - 
seconds + 1, end);
+                        if (from != null) {
+                            jo.put("recoveredFrom", from);
+                        }
+                    }
+                }
+                if (r.openSeconds() > 0) {
+                    jo.put("circuitBreakerOpenSeconds", r.openSeconds());
+                }
+                if (r.total(r.down()) > 0) {
+                    jo.put("downSeconds", r.total(r.down()));
+                }
+                lines.add(jo);
+            }
+            happened.put("routes", lines);
+            JsonArray causes = new JsonArray();
+            for (Timeline.CauseSeries c : ctx.timeline.causes(info.pid, end, 
seconds)) {
+                long total = Arrays.stream(c.values()).sum();
+                if (total > 0) {
+                    JsonObject jo = new JsonObject();
+                    jo.put("label", c.label());
+                    if (c.rule() != null) {
+                        jo.put("chaosRule", c.rule());
+                    }
+                    jo.put("count", total);
+                    causes.add(jo);
+                }
+            }
+            happened.put("causes", causes);
+            result.put("whatHappened", happened);
+        }
+        Target t = selected();
+        if (t != null) {
+            JsonObject sel = new JsonObject();
+            sel.put("routeId", t.routeId());
+            if (t.endpoint() != null) {
+                sel.put("endpoint", t.endpoint());
+            }
+            result.put("selected", sel);
+        }
+        result.put("keys", "v all integrations, Up/Down select a line, Enter 
opens it, +/- zoom, Left/Right move, 4 Timeline");
+        return result;
+    }
+
+    private void putTotalAndRate(JsonObject jo, IntegrationInfo info, String 
routeId, Map<String, Long> totals) {
+        if (routeId == null) {
+            return;
+        }
+        if (totals.get(routeId) != null) {
+            jo.put("exchangesTotal", totals.get(routeId));
+        }
+        long[] perSecond = routePerSecond(info, routeId);
+        if (perSecond != null) {
+            jo.put("rate", rate(perSecond));
+        }
+    }
+
+    /** ↑ ↓: the cursor moves over the lines of In, Routes and Out that are 
about a route or an endpoint. */
+    void moveCursor(int delta) {
+        int n = targets.size();
+        if (n == 0) {
+            cursor = -1;
+        } else if (cursor < 0) {
+            cursor = delta > 0 ? 0 : n - 1;
+        } else {
+            cursor = Math.max(0, Math.min(n - 1, cursor + delta));
+        }
+    }
+
+    /** The line the cursor is on, or null. */
+    Target selected() {
+        List<Target> t = targets;
+        return cursor >= 0 && cursor < t.size() ? t.get(cursor) : null;
+    }
+
+    /** Enter: opens what the cursor is on; false when it is on nothing. */
+    boolean open() {
+        Target t = selected();
+        if (t == null || ctx.openFromAbout == null) {
+            return false;
+        }
+        ctx.openFromAbout.accept(t);
+        return true;
+    }
+
+    private int seconds() {
+        return TimelineTab.WINDOWS[zoom];
+    }
+
+    private long windowEnd(long end) {
+        return stripsEnd >= 0 ? Math.min(stripsEnd, end) : end;
+    }
+
+    /** Moves the window to end at the given second, as far as there are 
seconds recorded; at the end it follows. */
+    private void moveTo(long second, long end) {
+        long first = Math.max(0, end - Timeline.SECONDS + 1);
+        long to = Math.max(first + seconds() - 1, second);
+        stripsEnd = to >= end ? -1 : to;
+    }
+
+    /** The last second in the window that a route had trouble in, or -1. */
+    private long lastTrouble(IntegrationInfo info, long windowEnd, int 
seconds) {
+        long last = -1;
+        for (Timeline.RouteSeries r : ctx.timeline.routes(info.pid, 
routeOrder(info), windowEnd, seconds)) {
+            for (int i = seconds - 1; i >= 0; i--) {
+                if (r.failed()[i] > 0 || r.handled()[i] > 0 || 
r.recovered()[i] > 0 || r.attempts()[i] > 0
+                        || r.down()[i] > 0 || r.open()[i] > 0) {
+                    last = Math.max(last, windowEnd - seconds + 1 + i);
+                    break;
+                }
+            }
+        }
+        return last;
+    }
+
+    private static List<String> routeOrder(IntegrationInfo info) {
+        return info.routes.stream().map(r -> r.routeId).filter(id -> id != 
null).toList();
+    }
+
+    void render(Frame frame, Rect area, IntegrationInfo info) {
+        load(info, Runnable::run);
+        ProjectOverview.Overview o = overview;
+        int width = area.width();
+        List<Line> head = new ArrayList<>();
+        head.add(Line.from(Span.raw(" "), Span.raw(meta(info, o)),
+                Span.styled(info.vanishing ? "  ○ stopped" : "  ● running",
+                        info.vanishing ? Theme.muted() : Theme.success())));
+        if (purpose != null) {
+            for (String l : wrap(purpose, Math.max(20, width - 6), 2)) {
+                head.add(Line.from(Span.styled(" " + l, Style.EMPTY.bold())));
+            }
+            head.add(Line.from(Span.styled(" " + purposeFrom, Theme.muted())));
+        } else if (o != null) {
+            head.add(Line.from(Span.styled(" No description yet: a comment at 
the top of " + mainFile(o)
+                                           + " says what it does, or ask the 
AI (Shift+F8 on a route)",
+                    Theme.muted())));
+        } else {
+            head.add(Line.from(Span.styled(error != null ? " " + error : " 
Reading the sources…", Theme.muted())));
+        }
+
+        List<Line> in = new ArrayList<>();
+        List<Line> routes = new ArrayList<>();
+        List<Line> out = new ArrayList<>();
+        List<Target> inTargets = new ArrayList<>();
+        List<Target> routeTargets = new ArrayList<>();
+        List<Target> outTargets = new ArrayList<>();
+        if (o != null) {
+            About.Model m = model;
+            if (m != null) {
+                columns(m, info, in, routes, out, inTargets, routeTargets, 
outTargets);
+            }
+        }
+        // the cursor goes down In, then Routes, then Out
+        List<Target> all = new ArrayList<>();
+        List<String> labels = new ArrayList<>();
+        List<String> inLabels = new ArrayList<>();
+        if (model != null) {
+            model.in().stream().filter(e -> e.route() != null).forEach(e -> 
inLabels.add(e.label()));

Review Comment:
   ⚠️ **Concurrency bug: volatile `model` read twice**
   
   `model` is snapshot to local `m` at line 429, correctly used in `columns(m, 
...)`. But 4 lines later (here), `model` is re-read directly from the volatile 
field — bypassing the snapshot.
   
   **Race scenario:** background thread nulls `model` between the snapshot at 
line 429 and this re-read → `m` is non-null (columns populated) but `model` is 
null → no NPE here. Reverse race: `m` is null at line 429 (columns skipped), 
but `model` is non-null here → `inLabels` is populated while `inTargets` is 
empty → cursor/label mismatch: pressing Enter navigates to nothing.
   
   ```suggestion
           if (m != null) {
               m.in().stream().filter(e -> e.route() != null).forEach(e -> 
inLabels.add(e.label()));
           }
   ```



##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/About.java:
##########
@@ -0,0 +1,235 @@
+/*
+ * 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.ai;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * About an integration (CAMEL-26700): what it does at a glance and without an 
AI, from the analysis of its sources
+ * ({@link ProjectOverview}). Its purpose in the words of its author, what 
comes in, its routes (a route called with
+ * direct or seda under the route that calls it) and what goes out. The About 
screen of the TUI and {@code camel get
+ * about} are built from it, so they say the same.
+ */
+public final class About {
+
+    /**
+     * What starts the integration.
+     *
+     * @param kind  the kind of entry: http, timer, rest, or for a consumer 
the scheme of what it consumes from
+     * @param label the entry as the sources have it, such as timer:orders 
(period=500)
+     * @param route the route it starts
+     */
+    public record In(String kind, String label, String route) {
+    }
+
+    /**
+     * A route, in the order to show it: a route that something else in the 
integration calls comes after its caller,
+     * one deeper.
+     *
+     * @param routeId the route ID (else its key in the analysis)
+     * @param depth   how deep under the route that calls it (0 for a route 
that nothing in the integration calls)
+     * @param how     call (direct: and the like, the caller waits), async 
(seda: and the like), or null at depth 0
+     * @param from    the scheme the route consumes from
+     * @param steps   the number of steps of the route
+     */
+    public record RouteLine(String routeId, int depth, String how, String 
from, int steps) {
+    }
+
+    /**
+     * A system the integration talks to.
+     *
+     * @param category  the kind of system, as the catalog groups the component
+     * @param uri       the endpoint as the sources have it
+     * @param direction out, or in/out for a request-reply
+     * @param route     the route that talks to it
+     */
+    public record Out(String category, String uri, String direction, String 
route) {
+    }
+
+    /**
+     * About an integration.
+     *
+     * @param purpose     what it does, in the words of its author, or null
+     * @param purposeFrom where the purpose comes from, or null
+     */
+    public record Model(String purpose, String purposeFrom, List<In> in, 
List<RouteLine> routes, List<Out> out) {
+    }
+
+    private About() {
+    }
+
+    /** About the integration in the directory, from the analysis of its 
sources. */
+    public static Model of(Path dir, ProjectOverview.Overview o) {
+        String[] p = purpose(dir, o);
+        List<In> in = new ArrayList<>();
+        for (ProjectOverview.EntryPoint e : o.entryPoints()) {
+            // a consumer by the kind of system it consumes from (its scheme)
+            String kind = "consumer".equals(e.kind()) && 
e.label().indexOf(':') > 0
+                    ? e.label().substring(0, e.label().indexOf(':')) : 
e.kind();
+            in.add(new In(kind, e.label(), e.route()));
+        }
+
+        // the routes that something else in the integration calls go under 
their caller
+        Map<String, List<ProjectOverview.Link>> calledBy = new 
LinkedHashMap<>();
+        Set<String> called = new LinkedHashSet<>();
+        for (ProjectOverview.Link l : o.links()) {
+            if (("call".equals(l.kind()) || "async".equals(l.kind())) && 
!l.from().equals(l.to())) {
+                calledBy.computeIfAbsent(l.from(), k -> new 
ArrayList<>()).add(l);
+                called.add(l.to());
+            }
+        }
+        List<RouteLine> routes = new ArrayList<>();
+        Set<String> shown = new LinkedHashSet<>();
+        for (ProjectRoutes.Route r : o.flows()) {
+            if (!called.contains(r.key())) {
+                routeLines(o, r, 0, null, calledBy, shown, routes);
+            }
+        }
+        // what is left: routes called only by routes in a cycle
+        for (ProjectRoutes.Route r : o.flows()) {
+            if (!shown.contains(r.key())) {
+                routeLines(o, r, 0, null, calledBy, shown, routes);
+            }
+        }
+
+        List<Out> out = new ArrayList<>();
+        Set<String> systems = new LinkedHashSet<>();
+        for (ProjectOverview.SystemUse s : o.systems()) {
+            if (!"in".equals(s.direction()) && systems.add(s.uri() + "|" + 
s.direction())) {
+                out.add(new Out(s.category(), s.uri(), s.direction(), 
s.route()));
+            }
+        }
+        return new Model(p != null ? p[0] : null, p != null ? p[1] : null, in, 
routes, out);
+    }
+
+    private static void routeLines(
+            ProjectOverview.Overview o, ProjectRoutes.Route r, int depth, 
String how,
+            Map<String, List<ProjectOverview.Link>> calledBy, Set<String> 
shown, List<RouteLine> lines) {
+        if (!shown.add(r.key()) || depth > 6) {
+            return;
+        }
+        String from = r.from() != null ? r.from().scheme() : r.kind();
+        lines.add(new RouteLine(r.id() != null ? r.id() : r.key(), depth, how, 
from, r.steps()));
+        for (ProjectOverview.Link l : calledBy.getOrDefault(r.key(), 
List.of())) {
+            ProjectRoutes.Route target = o.route(l.to());
+            if (target != null) {
+                routeLines(o, target, depth + 1, l.kind(), calledBy, shown, 
lines);
+            }
+        }
+    }
+
+    /**
+     * What the integration does, in the words of its author: the comment at 
the top of a route file (the files in the
+     * order of the analysis), else the descriptions of the routes that 
something outside starts. Returns the text and
+     * where it comes from, or null.
+     */
+    public static String[] purpose(Path dir, ProjectOverview.Overview o) {
+        for (String file : o.files()) {
+            try {
+                String text = leadingComment(file, 
Files.readString(dir.resolve(file), StandardCharsets.UTF_8));
+                if (text != null) {
+                    return new String[] { text, "from the comment at the top 
of " + file };
+                }
+            } catch (IOException e) {
+                // an unreadable file has no comment
+            }
+        }
+        List<String> descriptions = new ArrayList<>();
+        Set<String> entryRoutes = new LinkedHashSet<>();
+        o.entryPoints().forEach(e -> entryRoutes.add(e.route()));
+        for (ProjectRoutes.Route r : o.flows()) {
+            if (r.hasDescription() && entryRoutes.contains(r.key())) {
+                descriptions.add(r.description());
+            }
+        }
+        for (ProjectRoutes.Route r : o.flows()) {
+            if (r.hasDescription() && !entryRoutes.contains(r.key()) && 
descriptions.size() < 2) {
+                descriptions.add(r.description());
+            }
+        }
+        if (!descriptions.isEmpty()) {
+            return new String[] {
+                    String.join(" ", descriptions.subList(0, Math.min(2, 
descriptions.size()))),
+                    "from the descriptions of the routes" };
+        }
+        return null;
+    }
+
+    /**
+     * The comment a file starts with: the {@code #} lines of YAML, the 
Javadoc or {@code //} lines before the class of
+     * Java, the first {@code <!-- -->} of XML. A license header is no 
description.
+     */
+    public static String leadingComment(String file, String content) {
+        String text = null;
+        if (file.endsWith(".yaml") || file.endsWith(".yml")) {
+            StringBuilder sb = new StringBuilder();
+            for (String line : content.lines().toList()) {
+                String l = line.strip();
+                if (l.startsWith("#")) {
+                    sb.append(l.replaceFirst("^#+\\s?", "")).append(' ');
+                } else if (!l.isEmpty() || !sb.isEmpty()) {
+                    break;
+                }
+            }
+            text = sb.toString();
+        } else if (file.endsWith(".java")) {
+            int cls = content.indexOf(" class ");
+            String before = cls > 0 ? content.substring(0, cls) : content;
+            int doc = before.lastIndexOf("/**");
+            if (doc >= 0) {
+                int end = before.indexOf("*/", doc);
+                text = end > 0 ? before.substring(doc + 3, 
end).replaceAll("(?m)^\\s*\\*\\s?", " ") : null;
+            } else {
+                StringBuilder sb = new StringBuilder();
+                for (String line : before.lines().toList()) {
+                    String l = line.strip();
+                    if (l.startsWith("//") && !l.startsWith("///") && 
!l.startsWith("//DEPS")
+                            && !l.startsWith("//JAVA")) {
+                        sb.append(l.substring(2).strip()).append(' ');
+                    }
+                }
+                text = sb.toString();
+            }
+        } else if (file.endsWith(".xml")) {
+            int start = content.indexOf("<!--");

Review Comment:
   ⚠️ **XML `leadingComment` only examines the first `<!--` comment**
   
   `content.indexOf("<!--")` finds only the first XML comment. In Apache XML 
route files, this is typically the license header. The license filter at the 
end of the method correctly rejects it and returns `null` — but then the actual 
description comment (a second `<!-- -->`) is never examined.
   
   By contrast: the Java branch uses `before.lastIndexOf("/**")` to skip to the 
class-level Javadoc, correctly ignoring the license header.
   
   Consider looping through XML comments until a non-license one is found:
   ```java
   int start = 0;
   String text = null;
   while (true) {
       int s = content.indexOf("<!--", start);
       if (s < 0) break;
       int e = content.indexOf("-->", s);
       if (e < 0) break;
       String candidate = content.substring(s + 4, e).replaceAll("\\s+", " 
").strip();
       if (!candidate.contains("Licensed to the Apache") && 
!candidate.contains("SPDX-License")) {
           text = candidate;
           break;
       }
       start = e + 3;
   }
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to