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

davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/main by this push:
     new dccac5e0a82c CAMEL-23861: Phase 2 - TUI AI Usage view integrates GenAI 
OTel spans
dccac5e0a82c is described below

commit dccac5e0a82cd0033de5859f44ca5fe40f44d40c
Author: Omar Atie <[email protected]>
AuthorDate: Fri Aug 14 04:49:09 2026 -0700

    CAMEL-23861: Phase 2 - TUI AI Usage view integrates GenAI OTel spans
    
    Extend the Camel JBang TUI AI Usage panel (Ctrl+U) to combine TUI ask
    token metrics with route-level GenAI usage extracted from OTel spans
    using gen_ai.* semantic convention attributes from camel-ai-observability.
    
    Adds GenAiSpanUsageExtractor, extends AiPanel with source labels
    ([tui]/[route:<routeId>]), wires OTel span data from CamelMonitor,
    and includes unit tests and documentation updates.
    
    Closes #25491
    
    Co-authored-by: Composer <[email protected]>
    Co-authored-by: Guillaume Nodet <[email protected]>
    Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
---
 .../camel/catalog/docs/ai-observability.adoc       |  10 ++
 .../src/main/docs/ai-observability.adoc            |  10 ++
 .../modules/ROOT/pages/camel-jbang-tui.adoc        |   4 +
 dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml     |   4 +
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java |  93 ++++++++++++--
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |   6 +
 .../core/commands/tui/GenAiSpanUsageExtractor.java | 122 +++++++++++++++++++
 .../core/commands/tui/AiPanelGenAiUsageTest.java   | 133 +++++++++++++++++++++
 .../commands/tui/GenAiSpanUsageExtractorTest.java  | 125 +++++++++++++++++++
 9 files changed, 498 insertions(+), 9 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
index ae8a89b194a0..04e3893b1427 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/ai-observability.adoc
@@ -36,3 +36,13 @@ Metrics recorded (when Micrometer is available):
 
 * `gen_ai.client.operation` — operation duration timer
 * `gen_ai.client.token.usage` — token usage counter (tags: 
`gen_ai.token.type=input|output`)
+
+== Camel TUI integration (Phase 2)
+
+When monitoring a running integration with `camel tui` and observability 
enabled, the AI panel usage view
+(*Ctrl+U* while the AI panel is open) combines:
+
+* *TUI ask* token usage from `camel ask` / the embedded AI prompt
+* *Route* GenAI usage extracted from exported OpenTelemetry spans with 
`gen_ai.*` attributes
+
+This gives a single dashboard for developer CLI usage and production route LLM 
calls.
diff --git 
a/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
 
b/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
index ae8a89b194a0..04e3893b1427 100644
--- 
a/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
+++ 
b/components/camel-ai/camel-ai-observability/src/main/docs/ai-observability.adoc
@@ -36,3 +36,13 @@ Metrics recorded (when Micrometer is available):
 
 * `gen_ai.client.operation` — operation duration timer
 * `gen_ai.client.token.usage` — token usage counter (tags: 
`gen_ai.token.type=input|output`)
+
+== Camel TUI integration (Phase 2)
+
+When monitoring a running integration with `camel tui` and observability 
enabled, the AI panel usage view
+(*Ctrl+U* while the AI panel is open) combines:
+
+* *TUI ask* token usage from `camel ask` / the embedded AI prompt
+* *Route* GenAI usage extracted from exported OpenTelemetry spans with 
`gen_ai.*` attributes
+
+This gives a single dashboard for developer CLI usage and production route LLM 
calls.
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
index 9df667818106..9c7192eab078 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -807,6 +807,10 @@ When the AI panel is open, input that starts with `/` runs 
a local panel command
 Submitted prompts (including slash commands) participate in AI prompt history 
when
 `camel.tui.ai.promptHistory` is not `0`. Use *↑*/*↓* on the prompt line to 
recall them.
 
+Press *Ctrl+U* while the AI panel is open to toggle the AI Usage view. It 
shows token consumption from
+the embedded AI prompt (*TUI ask*) and from monitored route LLM calls exported 
as OpenTelemetry GenAI spans
+when observability is enabled on the integration.
+
 === Connecting an AI Agent
 
 To connect Claude Code to the TUI, add the MCP server to your project 
configuration
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml 
b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
index 260223b5cdc3..241adb18dbad 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/pom.xml
@@ -41,6 +41,10 @@
     </properties>
 
     <dependencies>
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-ai-observability-api</artifactId>
+        </dependency>
         <dependency>
             <groupId>org.apache.camel</groupId>
             <artifactId>camel-jbang-core</artifactId>
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
index 665c3d7f3df4..ba7dbb3a91e8 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -32,6 +32,7 @@ import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
 
 import dev.tamboui.layout.Constraint;
 import dev.tamboui.layout.Layout;
@@ -162,9 +163,16 @@ class AiPanel {
 
     // AI usage stats
     private final List<AiUsageEntry> usageHistory = new 
CopyOnWriteArrayList<>();
+    private AtomicReference<List<SpanEntry>> otelSpans = new 
AtomicReference<>(List.of());
     private final TableState statsTableState = new TableState();
     private boolean statsView;
     private int statsScrollOffset;
+    boolean spanRefreshRequested;
+
+    enum AiUsageSource {
+        TUI,
+        ROUTE
+    }
 
     record ConversationEntry(AiRole role, String text, long elapsedSeconds, 
int totalTokens) {
         ConversationEntry(AiRole role, String text) {
@@ -173,7 +181,14 @@ class AiPanel {
     }
 
     record AiUsageEntry(String model, String provider, int inputTokens, int 
outputTokens,
-            int totalTokens, long latencyMs, String stopReason, Instant 
timestamp) {
+            int totalTokens, long latencyMs, String stopReason, Instant 
timestamp,
+            AiUsageSource source, String routeId) {
+
+        AiUsageEntry(String model, String provider, int inputTokens, int 
outputTokens,
+                     int totalTokens, long latencyMs, String stopReason, 
Instant timestamp) {
+            this(model, provider, inputTokens, outputTokens, totalTokens, 
latencyMs, stopReason, timestamp,
+                 AiUsageSource.TUI, null);
+        }
     }
 
     void setContext(MonitorContext ctx) {
@@ -202,6 +217,10 @@ class AiPanel {
         this.mcpServerPort = port;
     }
 
+    void setOtelSpans(AtomicReference<List<SpanEntry>> otelSpans) {
+        this.otelSpans = otelSpans != null ? otelSpans : new 
AtomicReference<>(List.of());
+    }
+
     synchronized List<LogEntry> getActivityLog() {
         return new ArrayList<>(activityLog);
     }
@@ -403,6 +422,9 @@ class AiPanel {
         if (ke.hasCtrl() && ke.isCharIgnoreCase('u')) {
             statsView = !statsView;
             statsScrollOffset = 0;
+            if (statsView) {
+                spanRefreshRequested = true;
+            }
             return true;
         }
         if (ke.hasCtrl() && ke.isCharIgnoreCase('y')) {
@@ -1323,9 +1345,12 @@ class AiPanel {
             return;
         }
 
-        if (usageHistory.isEmpty()) {
+        List<AiUsageEntry> entries = combinedUsageEntries();
+        if (entries.isEmpty()) {
             frame.renderWidget(
-                    Paragraph.from(Line.from(Span.styled("No AI usage data 
yet. Ask a question first.", Style.EMPTY.dim()))),
+                    Paragraph.from(Line.from(Span.styled(
+                            "No AI usage data yet. Ask a question in the AI 
panel, or run routes with GenAI observability and OTel span export 
(--observe).",
+                            Style.EMPTY.dim()))),
                     area);
             return;
         }
@@ -1335,18 +1360,25 @@ class AiPanel {
         int totalOutput = 0;
         int totalTokens = 0;
         long totalLatency = 0;
-        for (AiUsageEntry e : usageHistory) {
+        int tuiRequests = 0;
+        int routeRequests = 0;
+        for (AiUsageEntry e : entries) {
             totalInput += e.inputTokens();
             totalOutput += e.outputTokens();
             totalTokens += e.totalTokens();
             totalLatency += e.latencyMs();
+            if (e.source() == AiUsageSource.ROUTE) {
+                routeRequests++;
+            } else {
+                tuiRequests++;
+            }
         }
-        int requestCount = usageHistory.size();
+        int requestCount = entries.size();
 
         // Per-model aggregation
         Map<String, int[]> perModel = new LinkedHashMap<>();
-        for (AiUsageEntry e : usageHistory) {
-            String key = e.model() + " (" + e.provider() + ")";
+        for (AiUsageEntry e : entries) {
+            String key = modelTableKey(e);
             int[] stats = perModel.computeIfAbsent(key, k -> new int[4]);
             stats[0]++; // requests
             stats[1] += e.inputTokens();
@@ -1354,7 +1386,7 @@ class AiPanel {
             stats[3] += e.totalTokens();
         }
 
-        // Per-conversation token totals (group by consecutive runs between 
user questions)
+        // Per-conversation token totals (TUI ask sessions only)
         List<Integer> turnTokens = new ArrayList<>();
         int currentTurn = 0;
         int turnIndex = 0;
@@ -1398,7 +1430,11 @@ class AiPanel {
         summaryLines.add(Line.from(
                 Span.styled("Requests: ", dimStyle),
                 Span.styled(String.valueOf(requestCount), cyanStyle),
-                Span.styled("   Total tokens: ", dimStyle),
+                Span.styled(" (TUI: ", dimStyle),
+                Span.styled(String.valueOf(tuiRequests), cyanStyle),
+                Span.styled(" / routes: ", dimStyle),
+                Span.styled(String.valueOf(routeRequests), cyanStyle),
+                Span.styled(")   Total tokens: ", dimStyle),
                 Span.styled(LlmClient.formatTokens(totalTokens), cyanStyle),
                 Span.styled(" (in: ", dimStyle),
                 Span.styled(LlmClient.formatTokens(totalInput), 
Theme.success()),
@@ -1482,6 +1518,45 @@ class AiPanel {
         }
     }
 
+    private List<AiUsageEntry> combinedUsageEntries() {
+        List<AiUsageEntry> combined = new ArrayList<>(usageHistory.size() + 8);
+        combined.addAll(usageHistory);
+        List<SpanEntry> spans = otelSpans.get();
+        if (spans != null && !spans.isEmpty()) {
+            combined.addAll(GenAiSpanUsageExtractor.extract(spans));
+        }
+        return combined;
+    }
+
+    private static String modelTableKey(AiUsageEntry entry) {
+        String modelProvider = entry.model() + " (" + entry.provider() + ")";
+        if (entry.source() == AiUsageSource.ROUTE) {
+            String route = entry.routeId() != null && 
!entry.routeId().isBlank() ? entry.routeId() : "route";
+            return "[route:" + route + "] " + modelProvider;
+        }
+        return "[tui] " + modelProvider;
+    }
+
+    boolean isStatsView() {
+        return statsView;
+    }
+
+    void toggleStatsViewForTesting() {
+        statsView = !statsView;
+        statsScrollOffset = 0;
+        if (statsView) {
+            spanRefreshRequested = true;
+        }
+    }
+
+    List<AiUsageEntry> combinedUsageEntriesForTesting() {
+        return combinedUsageEntries();
+    }
+
+    void recordUsageForTesting(AiUsageEntry entry) {
+        usageHistory.add(entry);
+    }
+
     private void renderScrollbar(Frame frame, Rect area, int totalLines, int 
visibleHeight, int scroll) {
         int thumbSize = Math.max(1, visibleHeight * visibleHeight / 
Math.max(1, totalLines));
         int maxScroll = Math.max(1, totalLines - visibleHeight);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 2aa880d1208b..92fdfaebe6c9 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -575,6 +575,7 @@ public class CamelMonitor extends CamelCommand {
                     }
                 });
         aiPanel.setMcpFacade(mcpFacade);
+        aiPanel.setOtelSpans(dataService.otelSpans());
         mcpFacade.setAiActivityLog(aiPanel::getActivityLog);
         Path mcpJsonFile = null;
         actionsPopup.setAiActivityLog(aiPanel::getActivityLog);
@@ -2391,6 +2392,11 @@ public class CamelMonitor extends CamelCommand {
             tabRegistry.spansTab().spanRefreshRequested = false;
             dataService.refreshSpanData();
         }
+        if (aiPanel.isOpen() && aiPanel.isStatsView()
+                && ctx.selectedPid != null && aiPanel.spanRefreshRequested) {
+            aiPanel.spanRefreshRequested = false;
+            dataService.refreshSpanData();
+        }
     }
 
     // ---- Helpers ----
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GenAiSpanUsageExtractor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GenAiSpanUsageExtractor.java
new file mode 100644
index 000000000000..ad78b4972c25
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GenAiSpanUsageExtractor.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.component.ai.observability.GenAiAttributes;
+
+/**
+ * Converts OTel spans exported from monitored Camel integrations into AI 
usage entries for the TUI stats view.
+ */
+final class GenAiSpanUsageExtractor {
+
+    private GenAiSpanUsageExtractor() {
+    }
+
+    static boolean isGenAiSpan(SpanEntry span) {
+        if (span == null || span.attributes() == null) {
+            return false;
+        }
+        return span.attributes().containsKey(GenAiAttributes.OPERATION_NAME);
+    }
+
+    static List<AiPanel.AiUsageEntry> extract(List<SpanEntry> spans) {
+        if (spans == null || spans.isEmpty()) {
+            return List.of();
+        }
+        List<AiPanel.AiUsageEntry> entries = new ArrayList<>();
+        for (SpanEntry span : spans) {
+            if (!isGenAiSpan(span)) {
+                continue;
+            }
+            AiPanel.AiUsageEntry entry = toUsageEntry(span);
+            if (entry != null) {
+                entries.add(entry);
+            }
+        }
+        return List.copyOf(entries);
+    }
+
+    private static AiPanel.AiUsageEntry toUsageEntry(SpanEntry span) {
+        Map<String, Object> attrs = span.attributes();
+        String requestModel = stringAttr(attrs, GenAiAttributes.REQUEST_MODEL);
+        String responseModel = stringAttr(attrs, 
GenAiAttributes.RESPONSE_MODEL);
+        String model = !isBlank(responseModel) ? responseModel : requestModel;
+        if (isBlank(model) || "unknown".equalsIgnoreCase(model)) {
+            model = span.name() != null ? span.name() : "unknown";
+        }
+
+        String provider = stringAttr(attrs, GenAiAttributes.SYSTEM);
+        if (isBlank(provider)) {
+            provider = "unknown";
+        }
+
+        int inputTokens = intAttr(attrs, GenAiAttributes.INPUT_TOKENS);
+        int outputTokens = intAttr(attrs, GenAiAttributes.OUTPUT_TOKENS);
+        int totalTokens = inputTokens + outputTokens;
+
+        long latencyMs = span.durationMs() > 0 ? span.durationMs() : 0;
+        String stopReason = stringAttr(attrs, GenAiAttributes.FINISH_REASONS);
+        Instant timestamp = span.startEpochNanos() > 0
+                ? Instant.ofEpochSecond(0, span.startEpochNanos())
+                : Instant.EPOCH;
+
+        return new AiPanel.AiUsageEntry(
+                model,
+                provider,
+                inputTokens,
+                outputTokens,
+                totalTokens,
+                latencyMs,
+                stopReason,
+                timestamp,
+                AiPanel.AiUsageSource.ROUTE,
+                span.routeId());
+    }
+
+    private static String stringAttr(Map<String, Object> attrs, String key) {
+        Object value = attrs.get(key);
+        if (value == null) {
+            return null;
+        }
+        String text = value.toString();
+        return text.isBlank() ? null : text;
+    }
+
+    private static int intAttr(Map<String, Object> attrs, String key) {
+        Object value = attrs.get(key);
+        if (value == null) {
+            return 0;
+        }
+        if (value instanceof Number number) {
+            return number.intValue();
+        }
+        try {
+            return Integer.parseInt(value.toString());
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
+
+    private static boolean isBlank(String value) {
+        return value == null || value.isBlank();
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelGenAiUsageTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelGenAiUsageTest.java
new file mode 100644
index 000000000000..4fa6581b419b
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelGenAiUsageTest.java
@@ -0,0 +1,133 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
+import org.apache.camel.component.ai.observability.GenAiAttributes;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AiPanelGenAiUsageTest {
+
+    private AtomicReference<List<SpanEntry>> spans;
+
+    @BeforeEach
+    void setUp() {
+        Theme.resetForTesting();
+        spans = new AtomicReference<>(List.of());
+    }
+
+    @Test
+    void combinedUsageIncludesRouteGenAiSpans() {
+        AiPanel panel = new AiPanel();
+        panel.setOtelSpans(spans);
+        panel.recordUsageForTesting(new AiPanel.AiUsageEntry(
+                "gpt-4o-mini", "openai", 10, 5, 15, 1000, "stop", 
Instant.now()));
+
+        spans.set(List.of(routeSpan("route-chat", "gpt-4o", 100, 20)));
+
+        assertThat(panel.combinedUsageEntriesForTesting()).hasSize(2);
+        assertThat(panel.combinedUsageEntriesForTesting())
+                .anyMatch(entry -> entry.source() == 
AiPanel.AiUsageSource.ROUTE
+                        && "route-chat".equals(entry.routeId()));
+    }
+
+    @Test
+    void statsViewRendersRouteAndTuiBreakdown() {
+        AiPanel panel = new AiPanel();
+        panel.setOtelSpans(spans);
+        panel.open();
+        panel.recordUsageForTesting(new AiPanel.AiUsageEntry(
+                "gpt-4o-mini", "openai", 10, 5, 15, 1000, "stop", 
Instant.now()));
+        spans.set(List.of(routeSpan("orders", "gpt-4o", 50, 10)));
+        panel.toggleStatsViewForTesting();
+
+        Rect area = new Rect(0, 0, 120, 24);
+        Buffer buffer = Buffer.empty(area);
+        panel.render(Frame.forTesting(buffer), area);
+
+        String rendered = TuiTestHelper.bufferToString(buffer);
+        assertThat(rendered).contains("AI Usage");
+        assertThat(rendered).contains("TUI:");
+        assertThat(rendered).contains("routes:");
+        assertThat(rendered).contains("[tui]");
+        assertThat(rendered).contains("[route:orders]");
+    }
+
+    @Test
+    void togglingStatsViewRequestsSpanRefresh() {
+        AiPanel panel = new AiPanel();
+        assertThat(panel.spanRefreshRequested).isFalse();
+
+        panel.toggleStatsViewForTesting();
+        assertThat(panel.isStatsView()).isTrue();
+        assertThat(panel.spanRefreshRequested).isTrue();
+
+        panel.toggleStatsViewForTesting();
+        assertThat(panel.isStatsView()).isFalse();
+        assertThat(panel.spanRefreshRequested).isTrue();
+    }
+
+    @Test
+    void statsViewShowsGuidanceWhenNoDataAvailable() {
+        AiPanel panel = new AiPanel();
+        panel.setOtelSpans(spans);
+        panel.open();
+        panel.toggleStatsViewForTesting();
+
+        Rect area = new Rect(0, 0, 100, 12);
+        Buffer buffer = Buffer.empty(area);
+        panel.render(Frame.forTesting(buffer), area);
+
+        assertThat(TuiTestHelper.bufferToString(buffer))
+                .contains("No AI usage data yet")
+                .contains("GenAI observability");
+    }
+
+    private static SpanEntry routeSpan(String routeId, String model, int 
input, int output) {
+        return new SpanEntry(
+                "trace-1",
+                "span-1",
+                "",
+                "chat " + model,
+                "CLIENT",
+                "OK",
+                Instant.now().getEpochSecond() * 1_000_000_000L,
+                Instant.now().getEpochSecond() * 1_000_000_000L + 500_000_000L,
+                500,
+                routeId,
+                null,
+                "camel",
+                Map.of(
+                        GenAiAttributes.OPERATION_NAME, "chat",
+                        GenAiAttributes.SYSTEM, "openai",
+                        GenAiAttributes.REQUEST_MODEL, model,
+                        GenAiAttributes.RESPONSE_MODEL, model,
+                        GenAiAttributes.INPUT_TOKENS, input,
+                        GenAiAttributes.OUTPUT_TOKENS, output,
+                        GenAiAttributes.FINISH_REASONS, "stop"));
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GenAiSpanUsageExtractorTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GenAiSpanUsageExtractorTest.java
new file mode 100644
index 000000000000..67cb3204ec7c
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/GenAiSpanUsageExtractorTest.java
@@ -0,0 +1,125 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.component.ai.observability.GenAiAttributes;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class GenAiSpanUsageExtractorTest {
+
+    @Test
+    void isGenAiSpanRequiresOperationNameAttribute() {
+        SpanEntry genAi = span(Map.of(GenAiAttributes.OPERATION_NAME, "chat"));
+        SpanEntry other = span(Map.of("http.method", "GET"));
+
+        assertThat(GenAiSpanUsageExtractor.isGenAiSpan(genAi)).isTrue();
+        assertThat(GenAiSpanUsageExtractor.isGenAiSpan(other)).isFalse();
+        assertThat(GenAiSpanUsageExtractor.isGenAiSpan(null)).isFalse();
+    }
+
+    @Test
+    void extractMapsGenAiSemanticConventionAttributes() {
+        SpanEntry span = span(Map.of(
+                GenAiAttributes.OPERATION_NAME, "chat",
+                GenAiAttributes.SYSTEM, "openai",
+                GenAiAttributes.REQUEST_MODEL, "gpt-4o-mini",
+                GenAiAttributes.RESPONSE_MODEL, "gpt-4o-mini",
+                GenAiAttributes.INPUT_TOKENS, 120,
+                GenAiAttributes.OUTPUT_TOKENS, 45,
+                GenAiAttributes.FINISH_REASONS, "stop"));
+
+        List<AiPanel.AiUsageEntry> entries = 
GenAiSpanUsageExtractor.extract(List.of(span));
+
+        assertThat(entries).hasSize(1);
+        AiPanel.AiUsageEntry entry = entries.get(0);
+        assertThat(entry.source()).isEqualTo(AiPanel.AiUsageSource.ROUTE);
+        assertThat(entry.model()).isEqualTo("gpt-4o-mini");
+        assertThat(entry.provider()).isEqualTo("openai");
+        assertThat(entry.inputTokens()).isEqualTo(120);
+        assertThat(entry.outputTokens()).isEqualTo(45);
+        assertThat(entry.totalTokens()).isEqualTo(165);
+        assertThat(entry.latencyMs()).isEqualTo(250);
+        assertThat(entry.stopReason()).isEqualTo("stop");
+        assertThat(entry.routeId()).isEqualTo("route-a");
+    }
+
+    @Test
+    void extractIgnoresNonGenAiSpans() {
+        SpanEntry camelSpan = span(Map.of("camel.routeId", "r1"));
+        SpanEntry genAi = span(Map.of(
+                GenAiAttributes.OPERATION_NAME, "embeddings",
+                GenAiAttributes.SYSTEM, "openai",
+                GenAiAttributes.REQUEST_MODEL, "text-embedding-3-small",
+                GenAiAttributes.INPUT_TOKENS, "10",
+                GenAiAttributes.OUTPUT_TOKENS, "0"));
+
+        List<AiPanel.AiUsageEntry> entries = 
GenAiSpanUsageExtractor.extract(List.of(camelSpan, genAi));
+
+        assertThat(entries).hasSize(1);
+        assertThat(entries.get(0).model()).isEqualTo("text-embedding-3-small");
+    }
+
+    @Test
+    void extractFallsBackToRequestModelAndSpanName() {
+        SpanEntry requestOnly = span("chat gpt-4", Map.of(
+                GenAiAttributes.OPERATION_NAME, "chat",
+                GenAiAttributes.REQUEST_MODEL, "gpt-4",
+                GenAiAttributes.SYSTEM, "anthropic"));
+
+        SpanEntry nameFallback = span("embeddings unknown-model", Map.of(
+                GenAiAttributes.OPERATION_NAME, "embeddings",
+                GenAiAttributes.REQUEST_MODEL, "unknown",
+                GenAiAttributes.SYSTEM, "openai"));
+
+        
assertThat(GenAiSpanUsageExtractor.extract(List.of(requestOnly)).get(0).model()).isEqualTo("gpt-4");
+        
assertThat(GenAiSpanUsageExtractor.extract(List.of(nameFallback)).get(0).model())
+                .isEqualTo("embeddings unknown-model");
+    }
+
+    @Test
+    void extractReturnsEmptyListForNullOrEmptyInput() {
+        assertThat(GenAiSpanUsageExtractor.extract(null)).isEmpty();
+        assertThat(GenAiSpanUsageExtractor.extract(List.of())).isEmpty();
+    }
+
+    private static SpanEntry span(Map<String, Object> attributes) {
+        return span("chat gpt-4o-mini", attributes);
+    }
+
+    private static SpanEntry span(String name, Map<String, Object> attributes) 
{
+        return new SpanEntry(
+                "trace-1",
+                "span-1",
+                "",
+                name,
+                "CLIENT",
+                "OK",
+                Instant.parse("2026-08-13T12:00:00Z").getEpochSecond() * 
1_000_000_000L,
+                Instant.parse("2026-08-13T12:00:00.250Z").getEpochSecond() * 
1_000_000_000L,
+                250,
+                "route-a",
+                null,
+                "camel",
+                attributes);
+    }
+}

Reply via email to