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

davsclaus pushed a commit to branch feature/CAMEL-23861-ai-usage-stats
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 560d2d7f98c615f1fa4227e064649a25b551c263
Author: Claus Ibsen <[email protected]>
AuthorDate: Wed Jul 1 14:11:13 2026 +0200

    CAMEL-23861: Add AI usage stats view to TUI AI panel (Ctrl+U)
    
    Co-Authored-By: Claude <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../camel/dsl/jbang/core/commands/LlmClient.java   |   8 +
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 240 ++++++++++++++++++++-
 2 files changed, 241 insertions(+), 7 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
index 63488519de91..23643453c8e3 100644
--- 
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
+++ 
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
@@ -153,6 +153,14 @@ public class LlmClient {
     private String vertexRegion;
     private String vertexProjectId;
 
+    public String model() {
+        return model;
+    }
+
+    public ApiType apiType() {
+        return apiType;
+    }
+
     // -- Builder --
 
     public static LlmClient create() {
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 ee4bb5c59e59..df2c87e85438 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
@@ -20,7 +20,9 @@ import java.time.Instant;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 import dev.tamboui.layout.Constraint;
@@ -34,11 +36,18 @@ import dev.tamboui.text.Line;
 import dev.tamboui.text.Span;
 import dev.tamboui.tui.event.KeyCode;
 import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.barchart.Bar;
+import dev.tamboui.widgets.barchart.BarChart;
+import dev.tamboui.widgets.barchart.BarGroup;
 import dev.tamboui.widgets.block.Block;
 import dev.tamboui.widgets.block.BorderType;
 import dev.tamboui.widgets.block.Borders;
 import dev.tamboui.widgets.block.Title;
 import dev.tamboui.widgets.paragraph.Paragraph;
+import dev.tamboui.widgets.table.Cell;
+import dev.tamboui.widgets.table.Row;
+import dev.tamboui.widgets.table.Table;
+import dev.tamboui.widgets.table.TableState;
 import org.apache.camel.dsl.jbang.core.commands.AskTools;
 import org.apache.camel.dsl.jbang.core.commands.LlmClient;
 
@@ -91,12 +100,22 @@ class AiPanel {
     // Activity log for AI Log popup
     private final List<LogEntry> activityLog = new ArrayList<>();
 
+    // AI usage stats
+    private final List<AiUsageEntry> usageHistory = new ArrayList<>();
+    private final TableState statsTableState = new TableState();
+    private boolean statsView;
+    private int statsScrollOffset;
+
     record ConversationEntry(String role, String text, long elapsedSeconds, 
int totalTokens) {
         ConversationEntry(String role, String text) {
             this(role, text, -1, 0);
         }
     }
 
+    record AiUsageEntry(String model, String provider, int inputTokens, int 
outputTokens,
+            int totalTokens, long latencyMs, String stopReason, Instant 
timestamp) {
+    }
+
     void setContext(MonitorContext ctx) {
         this.ctx = ctx;
     }
@@ -187,12 +206,25 @@ class AiPanel {
             close();
             return true;
         }
+        if (ke.hasCtrl() && ke.isCharIgnoreCase('u')) {
+            statsView = !statsView;
+            statsScrollOffset = 0;
+            return true;
+        }
         if (ke.isKey(KeyCode.PAGE_UP)) {
-            scrollOffset += 5;
+            if (statsView) {
+                statsScrollOffset += 5;
+            } else {
+                scrollOffset += 5;
+            }
             return true;
         }
         if (ke.isKey(KeyCode.PAGE_DOWN)) {
-            scrollOffset = Math.max(0, scrollOffset - 5);
+            if (statsView) {
+                statsScrollOffset = Math.max(0, statsScrollOffset - 5);
+            } else {
+                scrollOffset = Math.max(0, scrollOffset - 5);
+            }
             return true;
         }
         if (thinking.get()) {
@@ -300,7 +332,9 @@ class AiPanel {
                 throw new InterruptedException();
             }
 
+            long callStart = System.currentTimeMillis();
             LlmClient.ChatResponse response = 
client.chatWithTools(systemPrompt, messages, tools);
+            long callLatency = System.currentTimeMillis() - callStart;
             if (response == null) {
                 String err = "No response from LLM";
                 conversation.add(new ConversationEntry("error", err));
@@ -308,6 +342,7 @@ class AiPanel {
                 return;
             }
             totalUsage = totalUsage.add(response.usage());
+            recordUsage(response, callLatency);
 
             // check for error response (null text, no tool calls, error stop 
reason)
             if ("error".equals(response.stopReason())
@@ -358,12 +393,27 @@ class AiPanel {
                 "Reached maximum iterations (" + MAX_ITERATIONS + ") without a 
final answer."));
     }
 
+    private void recordUsage(LlmClient.ChatResponse response, long latencyMs) {
+        if (client == null || response.usage().totalTokens() == 0) {
+            return;
+        }
+        String model = client.model() != null ? client.model() : "unknown";
+        String provider = client.apiType() != null ? client.apiType().name() : 
"unknown";
+        usageHistory.add(new AiUsageEntry(
+                model, provider,
+                response.usage().inputTokens(), 
response.usage().outputTokens(),
+                response.usage().totalTokens(), latencyMs,
+                response.stopReason(), Instant.now()));
+    }
+
     void render(Frame frame, Rect area) {
         // At 25% show elapsed and tokens in the title bar to save space
         long titleElapsed = lastResponseElapsed();
         int titleTokens = lastResponseTokens();
         Line titleLine;
-        if (splitIndex == 0 && titleElapsed >= 0) {
+        if (statsView) {
+            titleLine = Line.from(Span.styled(" AI Usage ", 
Style.EMPTY.bold()));
+        } else if (splitIndex == 0 && titleElapsed >= 0) {
             String tokenSuffix = titleTokens > 0 ? ", " + 
LlmClient.formatTokens(titleTokens) + " tokens" : "";
             titleLine = Line.from(
                     Span.styled(" AI ", Style.EMPTY.bold()),
@@ -387,6 +437,11 @@ class AiPanel {
             return;
         }
 
+        if (statsView) {
+            renderStats(frame, inner);
+            return;
+        }
+
         // Split inner area: conversation (fill) + separator (1 row) + input 
(1 row)
         List<Rect> parts = Layout.vertical()
                 .constraints(Constraint.fill(), Constraint.length(1), 
Constraint.length(1))
@@ -550,12 +605,183 @@ class AiPanel {
 
     void renderFooter(List<Span> spans) {
         MonitorContext.hint(spans, "F8", "close");
+        if (statsView) {
+            MonitorContext.hint(spans, "Ctrl+U", "chat");
+        } else {
+            MonitorContext.hint(spans, "Ctrl+U", "usage");
+        }
         MonitorContext.hint(spans, "Shift+F8", "resize (" + 
SPLIT_PERCENTS[splitIndex] + "%)");
         MonitorContext.hint(spans, "PgUp/Dn", "scroll");
-        if (!thinking.get()) {
-            MonitorContext.hint(spans, "Enter", "send");
-        } else {
-            MonitorContext.hint(spans, "Ctrl+C", "cancel");
+        if (!statsView) {
+            if (!thinking.get()) {
+                MonitorContext.hint(spans, "Enter", "send");
+            } else {
+                MonitorContext.hint(spans, "Ctrl+C", "cancel");
+            }
+        }
+    }
+
+    private void renderStats(Frame frame, Rect area) {
+        if (area.height() < 3) {
+            return;
+        }
+
+        if (usageHistory.isEmpty()) {
+            frame.renderWidget(
+                    Paragraph.from(Line.from(Span.styled("No AI usage data 
yet. Ask a question first.", Style.EMPTY.dim()))),
+                    area);
+            return;
+        }
+
+        // Compute aggregates
+        int totalInput = 0;
+        int totalOutput = 0;
+        int totalTokens = 0;
+        long totalLatency = 0;
+        for (AiUsageEntry e : usageHistory) {
+            totalInput += e.inputTokens();
+            totalOutput += e.outputTokens();
+            totalTokens += e.totalTokens();
+            totalLatency += e.latencyMs();
+        }
+        int requestCount = usageHistory.size();
+
+        // Per-model aggregation
+        Map<String, int[]> perModel = new LinkedHashMap<>();
+        for (AiUsageEntry e : usageHistory) {
+            String key = e.model() + " (" + e.provider() + ")";
+            int[] stats = perModel.computeIfAbsent(key, k -> new int[4]);
+            stats[0]++; // requests
+            stats[1] += e.inputTokens();
+            stats[2] += e.outputTokens();
+            stats[3] += e.totalTokens();
+        }
+
+        // Per-conversation token totals (group by consecutive runs between 
user questions)
+        List<Integer> turnTokens = new ArrayList<>();
+        int currentTurn = 0;
+        int turnIndex = 0;
+        for (AiUsageEntry e : usageHistory) {
+            if (turnIndex > 0) {
+                AiUsageEntry prev = usageHistory.get(turnIndex - 1);
+                long gap = e.timestamp().toEpochMilli() - 
prev.timestamp().toEpochMilli();
+                if (gap > 30_000) {
+                    turnTokens.add(currentTurn);
+                    currentTurn = 0;
+                }
+            }
+            currentTurn += e.totalTokens();
+            turnIndex++;
+        }
+        if (currentTurn > 0) {
+            turnTokens.add(currentTurn);
+        }
+
+        // Layout: summary (2 rows) + model table (header + models + 1 blank) 
+ chart (fill)
+        int tableRows = perModel.size() + 1;
+        int summaryRows = 2;
+        int chartMinRows = 4;
+        boolean hasChart = area.height() > summaryRows + tableRows + 
chartMinRows + 1;
+
+        List<Constraint> constraints = new ArrayList<>();
+        constraints.add(Constraint.length(summaryRows));
+        constraints.add(Constraint.length(tableRows + 1));
+        if (hasChart) {
+            constraints.add(Constraint.fill());
+        }
+        List<Rect> sections = Layout.vertical()
+                .constraints(constraints)
+                .split(area);
+
+        // --- Summary ---
+        Rect summaryArea = sections.get(0);
+        Style dimStyle = Style.EMPTY.dim();
+        Style cyanStyle = Style.EMPTY.fg(Color.CYAN);
+        List<Line> summaryLines = new ArrayList<>();
+        summaryLines.add(Line.from(
+                Span.styled("Requests: ", dimStyle),
+                Span.styled(String.valueOf(requestCount), cyanStyle),
+                Span.styled("   Total tokens: ", dimStyle),
+                Span.styled(LlmClient.formatTokens(totalTokens), cyanStyle),
+                Span.styled(" (in: ", dimStyle),
+                Span.styled(LlmClient.formatTokens(totalInput), 
Style.EMPTY.fg(Color.GREEN)),
+                Span.styled(" / out: ", dimStyle),
+                Span.styled(LlmClient.formatTokens(totalOutput), 
Style.EMPTY.fg(Color.YELLOW)),
+                Span.styled(")", dimStyle)));
+        summaryLines.add(Line.from(
+                Span.styled("Avg latency: ", dimStyle),
+                Span.styled((totalLatency / requestCount / 1000) + "s", 
cyanStyle),
+                Span.styled("   Total time: ", dimStyle),
+                Span.styled((totalLatency / 1000) + "s", cyanStyle)));
+        frame.renderWidget(
+                Paragraph.from(new dev.tamboui.text.Text(summaryLines, 
dev.tamboui.layout.Alignment.LEFT)),
+                summaryArea);
+
+        // --- Per-model table ---
+        Rect tableArea = sections.get(1);
+        List<Row> rows = new ArrayList<>();
+        for (Map.Entry<String, int[]> entry : perModel.entrySet()) {
+            int[] s = entry.getValue();
+            rows.add(Row.from(
+                    Cell.from(Span.styled(entry.getKey(), cyanStyle)),
+                    Cell.from(String.valueOf(s[0])),
+                    Cell.from(LlmClient.formatTokens(s[1])),
+                    Cell.from(LlmClient.formatTokens(s[2])),
+                    Cell.from(LlmClient.formatTokens(s[3]))));
+        }
+        Table table = Table.builder()
+                .rows(rows)
+                .header(Row.from(
+                        Cell.from(Span.styled("MODEL", Style.EMPTY.bold())),
+                        Cell.from(Span.styled("REQS", Style.EMPTY.bold())),
+                        Cell.from(Span.styled("INPUT", Style.EMPTY.bold())),
+                        Cell.from(Span.styled("OUTPUT", Style.EMPTY.bold())),
+                        Cell.from(Span.styled("TOTAL", Style.EMPTY.bold()))))
+                .widths(
+                        Constraint.fill(),
+                        Constraint.length(6),
+                        Constraint.length(8),
+                        Constraint.length(8),
+                        Constraint.length(8))
+                .build();
+        frame.renderStatefulWidget(table, tableArea, statsTableState);
+
+        // --- Token bar chart per turn ---
+        if (hasChart && turnTokens.size() > 1) {
+            Rect chartArea = sections.get(2);
+
+            // Title row + chart
+            List<Rect> chartParts = Layout.vertical()
+                    .constraints(Constraint.length(1), Constraint.fill())
+                    .split(chartArea);
+            frame.renderWidget(
+                    Paragraph.from(Line.from(Span.styled("Tokens per 
question:", Style.EMPTY.bold()))),
+                    chartParts.get(0));
+
+            Rect barArea = chartParts.get(1);
+            int maxTokensInTurn = 
turnTokens.stream().mapToInt(Integer::intValue).max().orElse(1);
+
+            // Limit bars to available width
+            int maxBars = Math.max(1, barArea.width() / 2);
+            int startIdx = Math.max(0, turnTokens.size() - maxBars);
+            List<BarGroup> groups = new ArrayList<>();
+            for (int i = startIdx; i < turnTokens.size(); i++) {
+                groups.add(BarGroup.of(
+                        Bar.builder()
+                                .value(turnTokens.get(i))
+                                .textValue("")
+                                .style(Style.EMPTY.fg(Color.CYAN))
+                                .build()));
+            }
+
+            BarChart barChart = BarChart.builder()
+                    .data(groups)
+                    .max(maxTokensInTurn + 2)
+                    .barWidth(1)
+                    .barGap(1)
+                    .groupGap(0)
+                    .build();
+            frame.renderWidget(barChart, barArea);
         }
     }
 

Reply via email to