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

davsclaus pushed a commit to branch tui-ollama-tab
in repository https://gitbox.apache.org/repos/asf/camel.git

commit e5a0c17cb59979f5cd189b0fdf54baa9b0664de4
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Sep 17 12:57:16 2026 +0200

    CAMEL-24794: camel-jbang - TUI Ollama tab groups the request log per 
question
    
    A question asked in the AI panel costs one request per tool-call step, so a
    single question filled ten lines of the log while the model column repeated
    the one local model on every line. The log now shows one line per question:
    the question text (first line, cut to fit) where the model was, the number
    of requests it took, and the figures for the question as a whole: the
    largest prompt, generated tokens, cache hit across the steps, the highest
    context fill, prefill and decode rates across the steps, the time to the
    first token of the first step and the wall time from the first request to
    the last. Enter or Right unfolds the steps with their own figures and the
    model, Left folds them. Route calls stay one line each.
    
    The AI panel passes the question number and text to the monitor; the JSON
    of tui_get_ollama carries them per request and adds a questions array with
    the aggregates. F1 help and the user manual describe the grouped view.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../modules/ROOT/pages/camel-jbang-tui.adoc        |   8 +-
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java |   7 +-
 .../dsl/jbang/core/commands/tui/OllamaMonitor.java | 197 ++++++++++++++++++++-
 .../dsl/jbang/core/commands/tui/OllamaTab.java     | 183 +++++++++++++++----
 .../src/main/resources/tui/help/ollama.md          |  42 +++--
 .../jbang/core/commands/tui/OllamaMonitorTest.java |  54 ++++++
 .../core/commands/tui/OllamaTabRenderTest.java     |  42 ++++-
 7 files changed, 474 insertions(+), 59 deletions(-)

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 369e9e2de5ad..2d3f8a0373a6 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -432,9 +432,11 @@ the TUI checks every ten seconds, so it appears shortly 
after `ollama serve` sta
   of how much of the window each AI panel prompt filled, with the session peak 
and the compactions seen.
 * *Host* -- GPU utilization and memory (Apple silicon through `ioreg`, NVIDIA 
through `nvidia-smi`),
   and CPU and memory of the Ollama server and its model runner.
-* *Requests* -- one line per request with input, output and cached tokens, the 
share of the context
-  window the prompt filled, prefill and decode tokens per second, time to 
first token, total time and
-  the stop reason.
+* *Requests* -- one line per question asked in the AI panel (a question with 
tool calls costs one
+  request per step; *Enter* unfolds the steps) with the question text, prompt 
and generated tokens,
+  cache hit, the share of the context window reached, prefill and decode 
tokens per second, time to
+  first token, the time you waited and the stop reason. Calls made by Camel 
routes are listed as
+  their own lines.
 
 Two kinds of requests appear in the log. Questions asked in the AI panel with 
Ollama as the provider
 come with the timings Ollama reports for each request (prompt evaluation, 
generation, model load,
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 1e2608825dfa..bb046a7e18b0 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
@@ -298,6 +298,8 @@ class AiPanel {
     private final List<AiUsageEntry> usageHistory = new 
CopyOnWriteArrayList<>();
     /** Sequence number of the current question; tags the usage entries 
recorded while answering it. */
     private volatile int questionCounter;
+    /** The question the current turn answers, for grouping its requests on 
the Ollama tab. */
+    private volatile String currentQuestion;
     /** Route usage (GenAI spans) recorded before this instant is left out 
after a {@code /usage reset}. */
     private volatile Instant usageResetAt = Instant.EPOCH;
     private AtomicReference<List<SpanEntry>> otelSpans = new 
AtomicReference<>(List.of());
@@ -1170,6 +1172,7 @@ class AiPanel {
                 // the waiting camel_write_file call returns with the question 
and the model answers in this turn
                 conversation.add(new ConversationEntry(AiRole.USER, input));
                 questionCounter++;
+                currentQuestion = input;
                 log(LogLevel.QUESTION, "Question about the edit", input);
                 return;
             }
@@ -1354,6 +1357,7 @@ class AiPanel {
         }
         conversation.add(new ConversationEntry(AiRole.USER, question));
         questionCounter++;
+        currentQuestion = question;
         log(LogLevel.QUESTION, "Question", question);
         thinkingVerb = 
THINKING_VERBS.get(ThreadLocalRandom.current().nextInt(THINKING_VERBS.size()));
         thinkingStartTime = System.currentTimeMillis();
@@ -1995,7 +1999,8 @@ class AiPanel {
         if (ctx != null && ctx.ollamaMonitor != null && client.apiType() == 
LlmClient.ApiType.ollama) {
             // the Ollama tab shows this request with the timings Ollama 
returned
             ctx.ollamaMonitor.adoptEndpoint(client.endpointUrl());
-            ctx.ollamaMonitor.recordRequest(model, response.usage(), 
latencyMs, response.stopReason());
+            ctx.ollamaMonitor.recordRequest(model, response.usage(), 
latencyMs, response.stopReason(),
+                    questionCounter, currentQuestion);
         }
     }
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
index 553e11d8ce71..bc56758e4c57 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
@@ -146,7 +146,22 @@ final class OllamaMonitor {
      */
     record RequestEntry(Instant timestamp, RequestSource source, String 
routeId, String model, int inputTokens,
             int outputTokens, int cachedTokens, long prefillMs, long decodeMs, 
long loadMs, long totalMs,
-            String doneReason, long contextSize) {
+            String doneReason, long contextSize, int question, String 
questionText) {
+
+        RequestEntry(Instant timestamp, RequestSource source, String routeId, 
String model, int inputTokens,
+                     int outputTokens, int cachedTokens, long prefillMs, long 
decodeMs, long loadMs, long totalMs,
+                     String doneReason, long contextSize) {
+            this(timestamp, source, routeId, model, inputTokens, outputTokens, 
cachedTokens, prefillMs, decodeMs,
+                 loadMs, totalMs, doneReason, contextSize, 0, null);
+        }
+
+        /** Requests of the same AI panel question (its tool-call steps) share 
this key; a route call stands alone. */
+        String groupKey() {
+            if (source == RequestSource.ROUTE) {
+                return "route:" + timestamp.toEpochMilli() + ":" + routeId;
+            }
+            return "q" + question + ":" + (questionText != null ? questionText 
: "");
+        }
 
         /**
          * Everything the model had in front of it. Ollama's {@code 
prompt_eval_count} is the whole prompt; the cached
@@ -201,6 +216,131 @@ final class OllamaMonitor {
         }
     }
 
+    /**
+     * One AI panel question with all the requests it took (the model's tool 
calls each cost a request), oldest step
+     * first, or a single route call. Aggregates are what the user experienced 
for the whole question.
+     */
+    record QuestionGroup(String key, RequestSource source, int question, 
String questionText, String routeId,
+            List<RequestEntry> steps) {
+
+        RequestEntry first() {
+            return steps.get(0);
+        }
+
+        RequestEntry last() {
+            return steps.get(steps.size() - 1);
+        }
+
+        /** The largest prompt of the question: how far the context was 
pushed. */
+        long promptTokens() {
+            long max = 0;
+            for (RequestEntry e : steps) {
+                max = Math.max(max, e.promptTokens());
+            }
+            return max;
+        }
+
+        long outputTokens() {
+            long sum = 0;
+            for (RequestEntry e : steps) {
+                sum += e.outputTokens();
+            }
+            return sum;
+        }
+
+        int cacheHitPercent() {
+            long prompt = 0;
+            long cached = 0;
+            for (RequestEntry e : steps) {
+                prompt += e.inputTokens();
+                cached += e.cachedTokens();
+            }
+            return prompt > 0 ? (int) Math.min(100, cached * 100 / prompt) : 0;
+        }
+
+        int contextPercent() {
+            int max = -1;
+            for (RequestEntry e : steps) {
+                max = Math.max(max, e.contextPercent());
+            }
+            return max;
+        }
+
+        double prefillTokensPerSecond() {
+            long evaluated = 0;
+            long ms = 0;
+            for (RequestEntry e : steps) {
+                evaluated += e.evaluatedTokens();
+                ms += e.prefillMs();
+            }
+            return ms > 0 && evaluated > 0 ? evaluated * 1000.0 / ms : 0;
+        }
+
+        double decodeTokensPerSecond() {
+            long out = 0;
+            long ms = 0;
+            for (RequestEntry e : steps) {
+                out += e.outputTokens();
+                ms += e.decodeMs();
+            }
+            return ms > 0 && out > 0 ? out * 1000.0 / ms : 0;
+        }
+
+        /** Time to the first token of the first step: the wait before 
anything happened. */
+        long ttftMs() {
+            return first().ttftMs();
+        }
+
+        boolean hasTimings() {
+            return first().hasTimings();
+        }
+
+        boolean coldStart() {
+            for (RequestEntry e : steps) {
+                if (e.coldStart()) {
+                    return true;
+                }
+            }
+            return false;
+        }
+
+        /** From the first request starting to the last one finishing. */
+        long wallMs() {
+            long start = first().timestamp().toEpochMilli();
+            long end = last().timestamp().toEpochMilli() + last().totalMs();
+            return Math.max(end - start, last().totalMs());
+        }
+
+        String doneReason() {
+            return last().doneReason();
+        }
+    }
+
+    /**
+     * Groups requests (newest first) into questions: consecutive AI panel 
requests with the same question form one
+     * group with their steps oldest first; every route call is its own group. 
Groups come back newest first.
+     */
+    static List<QuestionGroup> groupByQuestion(List<RequestEntry> newestFirst) 
{
+        List<QuestionGroup> groups = new ArrayList<>();
+        int i = 0;
+        while (i < newestFirst.size()) {
+            RequestEntry head = newestFirst.get(i);
+            String key = head.groupKey();
+            List<RequestEntry> steps = new ArrayList<>();
+            int j = i;
+            while (j < newestFirst.size() && 
newestFirst.get(j).groupKey().equals(key)
+                    && (head.source() == RequestSource.TUI || j == i)) {
+                steps.add(0, newestFirst.get(j));
+                j++;
+            }
+            groups.add(new QuestionGroup(
+                    key, head.source(), head.question(), head.questionText(), 
head.routeId(),
+                    List.copyOf(steps)));
+            i = j;
+        }
+        return groups;
+    }
+
     record SessionTotals(int requests, long inputTokens, long outputTokens, 
long cachedTokens, long prefillMs,
             long decodeMs, long loadMs, int coldStarts, int 
peakContextPercent, int compactions) {
 
@@ -329,8 +469,18 @@ final class OllamaMonitor {
         }
     }
 
-    /** Records a request the TUI itself made to Ollama, with the usage and 
timings Ollama returned. */
+    /** As {@link #recordRequest(String, LlmClient.TokenUsage, long, String, 
int, String)} without a question. */
     void recordRequest(String model, LlmClient.TokenUsage usage, long 
latencyMs, String doneReason) {
+        recordRequest(model, usage, latencyMs, doneReason, 0, null);
+    }
+
+    /**
+     * Records a request the TUI itself made to Ollama, with the usage and 
timings Ollama returned. {@code question} and
+     * {@code questionText} identify the AI panel turn so the tool-call steps 
of one question group together.
+     */
+    void recordRequest(
+            String model, LlmClient.TokenUsage usage, long latencyMs, String 
doneReason, int question,
+            String questionText) {
         if (usage == null) {
             return;
         }
@@ -339,7 +489,7 @@ final class OllamaMonitor {
                 Instant.now(), RequestSource.TUI, null, model != null ? model 
: "unknown",
                 usage.inputTokens(), usage.outputTokens(), 
usage.cachedTokens(),
                 usage.prefillMillis(), usage.generationMillis(), 
usage.loadMillis(), total, doneReason,
-                contextSizeForNewRequest());
+                contextSizeForNewRequest(), question, questionText != null ? 
questionText.strip() : null);
         synchronized (lock) {
             addRequest(entry);
         }
@@ -1007,6 +1157,41 @@ final class OllamaMonitor {
             reqs.add(requestJson(e));
         }
         root.put("requests", reqs);
+        JsonArray questions = new JsonArray();
+        int q = 0;
+        for (QuestionGroup g : groupByQuestion(s.requests())) {
+            if (q++ >= requestLimit) {
+                break;
+            }
+            JsonObject jg = new JsonObject();
+            jg.put("time", g.first().timestamp().toString());
+            jg.put("source", g.source().name().toLowerCase(Locale.ROOT));
+            if (g.routeId() != null) {
+                jg.put("routeId", g.routeId());
+            }
+            if (g.question() > 0) {
+                jg.put("question", g.question());
+            }
+            if (g.questionText() != null) {
+                jg.put("questionText", g.questionText());
+            }
+            jg.put("model", g.last().model());
+            jg.put("steps", g.steps().size());
+            jg.put("promptTokens", g.promptTokens());
+            jg.put("outputTokens", g.outputTokens());
+            jg.put("cacheHitPercent", g.cacheHitPercent());
+            jg.put("contextPercent", g.contextPercent());
+            jg.put("prefillTokensPerSecond", 
round1(g.prefillTokensPerSecond()));
+            jg.put("decodeTokensPerSecond", round1(g.decodeTokensPerSecond()));
+            jg.put("ttftMs", g.ttftMs());
+            jg.put("wallMs", g.wallMs());
+            jg.put("coldStart", g.coldStart());
+            if (g.doneReason() != null) {
+                jg.put("doneReason", g.doneReason());
+            }
+            questions.add(jg);
+        }
+        root.put("questions", questions);
         if (s.lastPoll() != null) {
             root.put("lastPoll", s.lastPoll().toString());
         }
@@ -1034,6 +1219,12 @@ final class OllamaMonitor {
         r.put("coldStart", e.coldStart());
         r.put("promptTokens", e.promptTokens());
         r.put("evaluatedTokens", e.evaluatedTokens());
+        if (e.question() > 0) {
+            r.put("question", e.question());
+        }
+        if (e.questionText() != null) {
+            r.put("questionText", e.questionText());
+        }
         r.put("contextSize", e.contextSize());
         r.put("contextPercent", e.contextPercent());
         if (e.doneReason() != null) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java
index 7769b54d46f9..c7d9320d87f2 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTab.java
@@ -21,8 +21,10 @@ import java.time.Instant;
 import java.time.ZoneId;
 import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 
 import dev.tamboui.layout.Constraint;
 import dev.tamboui.layout.Layout;
@@ -47,6 +49,7 @@ import dev.tamboui.widgets.table.TableState;
 import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.HostStats;
 import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.LoadedModel;
 import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.ModelShape;
+import 
org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.QuestionGroup;
 import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.RequestEntry;
 import 
org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.RequestSource;
 import 
org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.SessionTotals;
@@ -76,6 +79,10 @@ class OllamaTab extends AbstractTab {
     private final TableState tableState = new TableState();
     private final ScrollbarState scrollState = new ScrollbarState();
     private Rect lastTableArea;
+    /** Questions whose steps are unfolded in the request log. */
+    private final Set<String> expandedGroups = new HashSet<>();
+    /** What each table row is: a {@link QuestionGroup} header or a {@link 
RequestEntry} step, as last rendered. */
+    private List<Object> rowRefs = List.of();
 
     OllamaTab(MonitorContext ctx, OllamaMonitor monitor) {
         super(ctx);
@@ -107,8 +114,25 @@ class OllamaTab extends AbstractTab {
             navigateDown();
             return true;
         }
+        if (ke.isConfirm() || ke.isRight() || ke.isLeft()) {
+            QuestionGroup group = selectedGroup();
+            if (group != null && group.steps().size() > 1) {
+                if (ke.isLeft()) {
+                    expandedGroups.remove(group.key());
+                    selectGroupHeader(group);
+                } else if (ke.isRight()) {
+                    expandedGroups.add(group.key());
+                } else if (!expandedGroups.remove(group.key())) {
+                    expandedGroups.add(group.key());
+                } else {
+                    selectGroupHeader(group);
+                }
+            }
+            return true;
+        }
         if (ke.isChar('r') && monitor != null) {
             monitor.reset();
+            expandedGroups.clear();
             tableState.select(0);
             return true;
         }
@@ -119,6 +143,35 @@ class OllamaTab extends AbstractTab {
         return false;
     }
 
+    /** The question the selected row belongs to (a header row or one of its 
steps). */
+    private QuestionGroup selectedGroup() {
+        Integer sel = tableState.selected();
+        if (sel == null || sel < 0 || sel >= rowRefs.size()) {
+            return null;
+        }
+        Object ref = rowRefs.get(sel);
+        if (ref instanceof QuestionGroup g) {
+            return g;
+        }
+        if (ref instanceof RequestEntry e) {
+            for (int i = sel; i >= 0; i--) {
+                if (rowRefs.get(i) instanceof QuestionGroup g && 
g.steps().contains(e)) {
+                    return g;
+                }
+            }
+        }
+        return null;
+    }
+
+    private void selectGroupHeader(QuestionGroup group) {
+        for (int i = 0; i < rowRefs.size(); i++) {
+            if (rowRefs.get(i) == group) {
+                tableState.select(i);
+                return;
+            }
+        }
+    }
+
     @Override
     public void navigateUp() {
         tableState.selectPrevious();
@@ -126,7 +179,7 @@ class OllamaTab extends AbstractTab {
 
     @Override
     public void navigateDown() {
-        int rows = monitor != null ? monitor.snapshot().requests().size() : 0;
+        int rows = rowRefs.size();
         if (rows > 0) {
             tableState.selectNext(rows);
         }
@@ -134,13 +187,13 @@ class OllamaTab extends AbstractTab {
 
     @Override
     public boolean handleMouseEvent(MouseEvent me, Rect area) {
-        int rows = monitor != null ? monitor.snapshot().requests().size() : 0;
-        return handleTableClick(me, lastTableArea, tableState, rows);
+        return handleTableClick(me, lastTableArea, tableState, rowRefs.size());
     }
 
     @Override
     public void renderFooter(List<Span> spans) {
         hint(spans, TuiIcons.ARROW_UP + TuiIcons.ARROW_DOWN, "select");
+        hint(spans, "Enter", "steps");
         hint(spans, "r", "reset");
         hintLast(spans, "F5", "refresh");
     }
@@ -457,28 +510,8 @@ class OllamaTab extends AbstractTab {
 
     private void renderRequests(Frame frame, Rect area, Snapshot s) {
         List<RequestEntry> requests = s.requests();
-        List<Row> rows = new ArrayList<>();
-        for (RequestEntry e : requests) {
-            rows.add(Row.from(
-                    Cell.from(Span.styled(" " + TIME.format(e.timestamp()), 
Theme.muted())),
-                    Cell.from(Span.styled(sourceLabel(e), e.source() == 
RequestSource.ROUTE
-                            ? Theme.notice()
-                            : Theme.info())),
-                    Cell.from(Span.raw(e.model())),
-                    rightCell(formatTokens(e.inputTokens()), 6),
-                    rightCell(formatTokens(e.outputTokens()), 6),
-                    rightCell(e.cachedTokens() > 0 ? 
formatTokens(e.cachedTokens()) : "-", 6, Theme.muted()),
-                    rightCell(e.contextPercent() >= 0 ? e.contextPercent() + 
"%" : "-", 5,
-                            e.contextPercent() >= 80 ? Theme.error() : 
e.contextPercent() >= 50 ? Theme.warning()
-                                    : Style.EMPTY),
-                    rightCell(e.prefillMs() > 0 ? 
formatRate(e.prefillTokensPerSecond()) : "-", 8, Theme.info()),
-                    rightCell(e.decodeMs() > 0 ? 
formatRate(e.decodeTokensPerSecond()) : "-", 8, Theme.success()),
-                    rightCell(e.hasTimings() ? formatSeconds(e.ttftMs()) : 
"-", 7,
-                            e.coldStart() ? Theme.warning() : Style.EMPTY),
-                    rightCell(e.totalMs() > 0 ? formatSeconds(e.totalMs()) : 
"-", 7),
-                    Cell.from(Span.styled(" " + (e.doneReason() != null ? 
e.doneReason() : ""), Theme.muted()))));
-        }
-        if (rows.isEmpty()) {
+        if (requests.isEmpty()) {
+            rowRefs = List.of();
             lastTableArea = area;
             frame.renderWidget(Paragraph.builder()
                     .text(Text.from(List.of(
@@ -491,15 +524,39 @@ class OllamaTab extends AbstractTab {
                     .build(), area);
             return;
         }
+
+        List<QuestionGroup> groups = OllamaMonitor.groupByQuestion(requests);
+        // fixed columns plus borders and the highlight gutter; the rest is 
the question column
+        int questionWidth = Math.max(12, area.width() - FIXED_COLUMNS_WIDTH);
+        List<Row> rows = new ArrayList<>();
+        List<Object> refs = new ArrayList<>();
+        for (QuestionGroup g : groups) {
+            boolean multi = g.steps().size() > 1;
+            boolean expanded = multi && expandedGroups.contains(g.key());
+            rows.add(questionRow(g, multi, expanded, questionWidth));
+            refs.add(g);
+            if (expanded) {
+                int n = 1;
+                for (RequestEntry e : g.steps()) {
+                    rows.add(stepRow(e, n++, g.steps().size()));
+                    refs.add(e);
+                }
+            }
+        }
+        rowRefs = refs;
+
+        String title = " Requests (" + groups.size() + (groups.size() == 1 ? " 
question, " : " questions, ")
+                       + requests.size() + (requests.size() == 1 ? " request)" 
: " requests)")
+                       + "  tok/s for prefill and decode · CTX = prompt share 
of the context window ";
         Table table = Table.builder()
                 .rows(rows)
                 .header(Row.from(
                         Cell.from(Span.styled(" TIME", Style.EMPTY.bold())),
                         Cell.from(Span.styled("SOURCE", Style.EMPTY.bold())),
-                        Cell.from(Span.styled("MODEL", Style.EMPTY.bold())),
+                        Cell.from(Span.styled("QUESTION", Style.EMPTY.bold())),
                         rightCell("IN", 6, Style.EMPTY.bold()),
                         rightCell("OUT", 6, Style.EMPTY.bold()),
-                        rightCell("CACHED", 6, Style.EMPTY.bold()),
+                        rightCell("CACHE", 6, Style.EMPTY.bold()),
                         rightCell("CTX", 5, Style.EMPTY.bold()),
                         rightCell("PREFILL", 8, Style.EMPTY.bold()),
                         rightCell("DECODE", 8, Style.EMPTY.bold()),
@@ -507,7 +564,7 @@ class OllamaTab extends AbstractTab {
                         rightCell("TOTAL", 7, Style.EMPTY.bold()),
                         Cell.from(Span.styled(" REASON", Style.EMPTY.bold()))))
                 .widths(
-                        Constraint.length(9),
+                        Constraint.length(10),
                         Constraint.length(18),
                         Constraint.fill(),
                         Constraint.length(6),
@@ -521,16 +578,78 @@ class OllamaTab extends AbstractTab {
                         Constraint.length(12))
                 .highlightStyle(Theme.selectionBg())
                 .highlightSpacing(Table.HighlightSpacing.ALWAYS)
-                
.block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL)
-                        .title(" Requests (" + requests.size()
-                               + ")  tok/s for prefill and decode · CTX = 
prompt share of the context window ")
-                        .build())
+                
.block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build())
                 .build();
         lastTableArea = area;
         frame.renderStatefulWidget(table, area, tableState);
         renderTableScrollbar(frame, area, tableState, scrollState, 
rows.size());
     }
 
+    private static final int FIXED_COLUMNS_WIDTH = 10 + 18 + 6 + 6 + 6 + 5 + 8 
+ 8 + 7 + 7 + 12 + 6;
+
+    /** One question (or route call) on one line: the question text cut to 
fit, then the whole-question figures. */
+    private static Row questionRow(QuestionGroup g, boolean multi, boolean 
expanded, int questionWidth) {
+        String marker = multi ? (expanded ? TuiIcons.MORE_CHEVRON : 
TuiIcons.ARROW_RIGHT) : " ";
+        String source;
+        Style sourceStyle;
+        if (g.source() == RequestSource.ROUTE) {
+            source = g.routeId() != null ? "route:" + g.routeId() : "route";
+            sourceStyle = Theme.notice();
+        } else {
+            source = (g.question() > 0 ? "#" + g.question() : "tui") + (multi 
? " ×" + g.steps().size() : "");
+            sourceStyle = Theme.info();
+        }
+        String text = g.questionText() != null && !g.questionText().isBlank()
+                ? firstLine(g.questionText())
+                : g.last().model();
+        int ctx = g.contextPercent();
+        return Row.from(
+                Cell.from(Span.styled(marker + 
TIME.format(g.first().timestamp()), Theme.muted())),
+                Cell.from(Span.styled(source, sourceStyle)),
+                Cell.from(Span.styled(TuiHelper.truncate(text, questionWidth), 
Theme.label())),
+                rightCell(formatTokens(g.promptTokens()), 6),
+                rightCell(formatTokens(g.outputTokens()), 6),
+                rightCell(g.cacheHitPercent() + "%", 6, Theme.muted()),
+                rightCell(ctx >= 0 ? ctx + "%" : "-", 5,
+                        ctx >= 80 ? Theme.error() : ctx >= 50 ? 
Theme.warning() : Style.EMPTY),
+                rightCell(g.prefillTokensPerSecond() > 0 ? 
formatRate(g.prefillTokensPerSecond()) : "-", 8,
+                        Theme.info()),
+                rightCell(g.decodeTokensPerSecond() > 0 ? 
formatRate(g.decodeTokensPerSecond()) : "-", 8,
+                        Theme.success()),
+                rightCell(g.hasTimings() ? formatSeconds(g.ttftMs()) : "-", 7,
+                        g.coldStart() ? Theme.warning() : Style.EMPTY),
+                rightCell(g.wallMs() > 0 ? formatSeconds(g.wallMs()) : "-", 7),
+                Cell.from(Span.styled(" " + (g.doneReason() != null ? 
g.doneReason() : ""), Theme.muted())));
+    }
+
+    /** One request of an unfolded question: step number, the model, and that 
request's own figures. */
+    private static Row stepRow(RequestEntry e, int step, int of) {
+        int ctx = e.contextPercent();
+        return Row.from(
+                Cell.from(Span.styled("  " + TIME.format(e.timestamp()), 
Theme.muted().dim())),
+                Cell.from(Span.styled("  step " + step + "/" + of, 
Theme.muted())),
+                Cell.from(Span.styled(e.model(), Theme.muted().dim())),
+                rightCell(formatTokens(e.inputTokens()), 6, Theme.muted()),
+                rightCell(formatTokens(e.outputTokens()), 6, Theme.muted()),
+                rightCell(e.cachedTokens() > 0 ? e.cacheHitPercent() + "%" : 
"-", 6, Theme.muted()),
+                rightCell(ctx >= 0 ? ctx + "%" : "-", 5, Theme.muted()),
+                rightCell(e.prefillMs() > 0 ? 
formatRate(e.prefillTokensPerSecond()) : "-", 8, Theme.muted()),
+                rightCell(e.decodeMs() > 0 ? 
formatRate(e.decodeTokensPerSecond()) : "-", 8, Theme.muted()),
+                rightCell(e.hasTimings() ? formatSeconds(e.ttftMs()) : "-", 7,
+                        e.coldStart() ? Theme.warning() : Theme.muted()),
+                rightCell(e.totalMs() > 0 ? formatSeconds(e.totalMs()) : "-", 
7, Theme.muted()),
+                Cell.from(Span.styled(" " + (e.doneReason() != null ? 
e.doneReason() : ""), Theme.muted().dim())));
+    }
+
+    static String firstLine(String text) {
+        String t = text.strip();
+        int nl = t.indexOf('\n');
+        if (nl >= 0) {
+            t = t.substring(0, nl).strip() + " …";
+        }
+        return t.replace('\t', ' ');
+    }
+
     // ---- MCP / table data ----
 
     @Override
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md
index e63b78c069fb..bc3bc9ae11f0 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/resources/tui/help/ollama.md
@@ -108,22 +108,28 @@ Shown when Ollama runs on this machine:
 
 ## Requests
 
-One line per request, newest first:
-
-| Column | Meaning |
-|--------|---------|
-| TIME | When the request started |
-| SOURCE | `tui` for the AI panel, `route:<id>` for a Camel route |
-| MODEL | The model that answered |
-| IN | Prompt tokens, the whole prompt the model saw (CACHED is the part of it 
served from cache) |
-| OUT | Tokens generated |
-| CACHED | Prompt tokens served from Ollama's cache (`-` when none) |
-| CTX | Share of the context window the prompt filled (IN against the window 
that served it) |
-| PREFILL | Evaluated prompt tokens per second (IN minus CACHED over the 
prefill time) |
-| DECODE | Generated tokens per second |
-| TTFT | Time to first token (load plus prefill), shown in yellow after a cold 
start |
-| TOTAL | Whole request as Ollama measured it |
-| REASON | Why generation stopped: `stop`, `length` (hit the token limit), 
`tool_calls` |
+One line per question, newest first. A question asked in the AI panel
+usually costs several requests, because every tool call the model makes
+is answered in a new request with the whole prompt again; the line shows
+the question text (cut to fit) and the figures for the question as a
+whole. Press **Enter** (or **→**) on a question with `×N` in the SOURCE
+column to unfold its steps, **←** to fold them again. A call made by a
+Camel route is one line of its own.
+
+| Column | Question line | Step line |
+|--------|---------------|-----------|
+| TIME | When the first request started | When the request started |
+| SOURCE | `#7 ×10`: question number and request count, or `route:<id>` | 
`step 3/10` |
+| QUESTION | The question, first line cut to fit (the model for a route) | The 
model that answered |
+| IN | The largest prompt of the question: how far the context was pushed | 
Prompt tokens, the whole prompt the model saw |
+| OUT | Tokens generated across all steps | Tokens generated |
+| CACHE | Share of all prompt tokens served from Ollama's cache | Share of 
this prompt served from cache (`-` when none) |
+| CTX | Highest share of the context window reached | Share of the context 
window this prompt filled |
+| PREFILL | Evaluated prompt tokens per second across the steps (prompt minus 
cached over the prefill time) | Same for this request |
+| DECODE | Generated tokens per second across the steps | Same for this 
request |
+| TTFT | Time to the first token of the first step (load plus prefill), yellow 
after a cold start | Time to first token of this request |
+| TOTAL | From the first request starting to the last finishing: what you 
waited | This request as Ollama measured it |
+| REASON | Why the last step stopped: `stop`, `length` (hit the token limit) | 
`tool_calls` for every step but the last |
 
 Route requests show `-` for prefill, decode and TTFT because the GenAI
 span carries tokens and duration only.
@@ -139,7 +145,9 @@ tab says so and keeps the rest.
 
 | Key | Action |
 |-----|--------|
-| ↑ / ↓ | Select a request |
+| ↑ / ↓ | Select a question or step |
+| Enter / → | Unfold the steps of a question |
+| ← | Fold them again |
 | r | Reset the request log and session totals |
 | F5 | Refresh now |
 
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java
index 60589f1933e6..04aeec6de046 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitorTest.java
@@ -204,6 +204,60 @@ class OllamaMonitorTest {
         assertEquals(0, history[0]);
     }
 
+    @Test
+    void requestsGroupIntoQuestionsWithStepsOldestFirst() {
+        OllamaMonitor monitor = new OllamaMonitor();
+        monitor.updateModels(List.of(new OllamaMonitor.LoadedModel(
+                "m", "f", "35.5B", "Q4_K_M", 1, 1, 32_768, null, null)));
+        // question 7: three steps (two tool calls, then the answer); the 
prompt grows and the cache warms
+        // the first step loaded the model: 1.1 s load plus 5.1 s prefill
+        monitor.recordRequest("m", new LlmClient.TokenUsage(4_500, 33, 4_533, 
0, 5_100, 700, 1_100, 6_900), 0,
+                "tool_calls", 7, "how many messages have camel done");
+        monitor.recordRequest("m", new LlmClient.TokenUsage(4_700, 78, 4_778, 
4_500, 500, 1_600, 0, 1_900), 0,
+                "tool_calls", 7, "how many messages have camel done");
+        monitor.recordRequest("m", new LlmClient.TokenUsage(7_000, 106, 7_106, 
4_700, 3_600, 1_900, 0, 5_500), 0,
+                "stop", 7, "how many messages have camel done");
+        // question 8: a single request
+        monitor.recordRequest("m", new LlmClient.TokenUsage(7_200, 40, 7_240, 
7_000, 300, 700, 0, 1_000), 0,
+                "stop", 8, "thanks");
+        // and a route call
+        monitor.ingestSpans(List.of(genAiSpan("s9", "ollama", "m", 
"chat-route", 412, 180, 4100)));
+
+        List<OllamaMonitor.QuestionGroup> groups = 
OllamaMonitor.groupByQuestion(monitor.snapshot().requests());
+        assertEquals(3, groups.size());
+        OllamaMonitor.QuestionGroup route = groups.get(0);
+        assertEquals(RequestSource.ROUTE, route.source());
+        assertEquals(1, route.steps().size());
+        assertEquals("chat-route", route.routeId());
+
+        OllamaMonitor.QuestionGroup q8 = groups.get(1);
+        assertEquals(8, q8.question());
+        assertEquals("thanks", q8.questionText());
+        assertEquals(1, q8.steps().size());
+
+        OllamaMonitor.QuestionGroup q7 = groups.get(2);
+        assertEquals(7, q7.question());
+        assertEquals(3, q7.steps().size());
+        assertEquals(33, q7.steps().get(0).outputTokens(), "steps are oldest 
first");
+        assertEquals(7_000, q7.promptTokens());
+        assertEquals(33 + 78 + 106, q7.outputTokens());
+        // 9,200 of 16,200 prompt tokens came from the cache
+        assertEquals(56, q7.cacheHitPercent());
+        assertEquals(21, q7.contextPercent());
+        assertEquals(6_200, q7.ttftMs());
+        assertTrue(q7.coldStart());
+        assertEquals("stop", q7.doneReason());
+        // evaluated 4,500 + 200 + 2,300 tokens over 9.2 s of prefill
+        assertEquals(7_000 * 1000.0 / 9_200, q7.prefillTokensPerSecond(), 0.5);
+        assertTrue(q7.wallMs() >= 5_500);
+
+        JsonArray questions = (JsonArray) monitor.toJson(10).get("questions");
+        assertEquals(3, questions.size());
+        JsonObject jq7 = (JsonObject) questions.get(2);
+        assertEquals(3, jq7.get("steps"));
+        assertEquals("how many messages have camel done", 
jq7.get("questionText"));
+    }
+
     @Test
     void availabilityFollowsTheServer() {
         OllamaMonitor monitor = new OllamaMonitor();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java
index d90b43d5dabf..8a77262cbae3 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaTabRenderTest.java
@@ -22,6 +22,9 @@ import java.util.List;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicReference;
 
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
 import org.apache.camel.dsl.jbang.core.commands.LlmClient;
 import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.GpuStats;
 import org.apache.camel.dsl.jbang.core.commands.tui.OllamaMonitor.HostStats;
@@ -110,7 +113,7 @@ class OllamaTabRenderTest {
         assertTrue(rendered.contains("llama-server"), rendered);
         assertTrue(rendered.contains("ollama serve"), rendered);
         // request log
-        assertTrue(rendered.contains("Requests (1)"), rendered);
+        assertTrue(rendered.contains("Requests (1 question, 1 request)"), 
rendered);
         assertTrue(rendered.contains("CTX"), rendered);
         assertTrue(rendered.contains("PREFILL"), rendered);
         assertTrue(rendered.contains("DECODE"), rendered);
@@ -155,6 +158,38 @@ class OllamaTabRenderTest {
         assertTrue(rendered.contains("2 installed: qwen3.6:35b-a3b, 
llama3.2:latest"), rendered);
     }
 
+    @Test
+    void requestsAreGroupedPerQuestionAndUnfoldOnEnter() {
+        localServerWithModel();
+        String question = "how many messages have camel done\nand are any 
failing?";
+        monitor.recordRequest("qwen3.6:35b-a3b", new 
LlmClient.TokenUsage(4_500, 33, 4_533, 0, 620, 700, 0, 1_320), 0,
+                "tool_calls", 7, question);
+        monitor.recordRequest("qwen3.6:35b-a3b", new 
LlmClient.TokenUsage(4_700, 78, 4_778, 4_500, 50, 1_600, 0, 1_700), 0,
+                "tool_calls", 7, question);
+        monitor.recordRequest("qwen3.6:35b-a3b", new 
LlmClient.TokenUsage(7_000, 106, 7_106, 4_700, 360, 1_900, 0, 2_300), 0,
+                "stop", 7, question);
+
+        OllamaTab tab = new OllamaTab(ctx, monitor);
+        String rendered = TuiTestHelper.renderToString(tab, 200, 40);
+        assertTrue(rendered.contains("Requests (1 question, 3 requests)"), 
rendered);
+        assertTrue(rendered.contains("#7 ×3"), rendered);
+        assertTrue(rendered.contains("how many messages have camel done …"), 
rendered);
+        assertTrue(rendered.contains("QUESTION"), rendered);
+        assertFalse(rendered.contains("step 1/3"), rendered);
+
+        // Enter on the question unfolds its steps
+        tab.navigateDown();
+        tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+        rendered = TuiTestHelper.renderToString(tab, 200, 40);
+        assertTrue(rendered.contains("step 1/3"), rendered);
+        assertTrue(rendered.contains("step 3/3"), rendered);
+
+        // and folds them again
+        tab.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+        rendered = TuiTestHelper.renderToString(tab, 200, 40);
+        assertFalse(rendered.contains("step 1/3"), rendered);
+    }
+
     @Test
     void routeRequestsShowTheirRouteId() {
         localServerWithModel();
@@ -164,7 +199,7 @@ class OllamaTabRenderTest {
 
         String rendered = TuiTestHelper.renderToString(new OllamaTab(ctx, 
monitor), 180, 40);
         assertTrue(rendered.contains("route:chat-route"), rendered);
-        assertTrue(rendered.contains("Requests (2)"), rendered);
+        assertTrue(rendered.contains("Requests (2 questions, 2 requests)"), 
rendered);
     }
 
     @Test
@@ -205,7 +240,8 @@ class OllamaTabRenderTest {
     void helpTextExplainsThePhasesAndColumns() {
         String help = new OllamaTab(ctx, monitor).getHelpText();
         assertTrue(help != null && help.contains("TTFT"), help);
-        for (String term : List.of("Prefill", "Decode", "cold", "cache hit", 
"speculative", "CACHED", "REASON")) {
+        for (String term : List.of("Prefill", "Decode", "cold", "cache hit", 
"speculative", "CACHE", "QUESTION",
+                "REASON")) {
             assertTrue(help.contains(term), "help should explain " + term);
         }
     }

Reply via email to