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 a3ed5ea24f16f8d6d2d2fe0bbbd047022ff03351
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Sep 17 12:20:03 2026 +0200

    CAMEL-24794: camel-jbang - TUI Ollama tab shows context fill per turn and 
compactions
    
    Each request now carries the context window of the runner that served it
    (the slot size, else the loaded model's allocated context, fetched on the
    spot when the tab has not polled yet). The request log gains a CTX column
    with the share of the window the prompt filled (evaluated plus cached
    tokens), yellow from 50% and red from 80%.
    
    The Context panel gains a "turns" line: one bar per AI panel turn scaled to
    the whole window, so a growing conversation and the drop of a compaction
    are visible at a glance, followed by the latest fill, the session peak and
    the number of compactions seen. A prompt a fifth smaller than the previous
    turn counts as a compaction. The same figures are in tui_get_ollama
    (promptTokens, contextSize, contextPercent per request; peakContextPercent
    and compactions in the session block), the F1 help and the user manual.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../modules/ROOT/pages/camel-jbang-tui.adoc        |   8 +-
 .../dsl/jbang/core/commands/tui/OllamaMonitor.java | 100 +++++++++++++++++++--
 .../dsl/jbang/core/commands/tui/OllamaTab.java     |  60 ++++++++++++-
 .../src/main/resources/tui/help/ollama.md          |   8 ++
 .../jbang/core/commands/tui/OllamaMonitorTest.java |  31 +++++++
 .../core/commands/tui/OllamaTabRenderTest.java     |  26 +++++-
 6 files changed, 218 insertions(+), 15 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 f8c97d5ced6f..5851b3104896 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-tui.adoc
@@ -427,11 +427,13 @@ or at the endpoint the AI panel (*F8*) is using.
   otherwise from the last request; time to first token and load time (a load 
of a second or more
   is a cold start); session averages and a sparkline of the decode rate.
 * *Context* -- how full the context window is, the share of the prompt served 
from Ollama's cache,
-  whether the model is working or idle, and the speculative decoding method in 
use.
+  whether the model is working or idle, the speculative decoding method in 
use, and a per-turn trend
+  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, 
prefill and decode
-  tokens per second, time to first token, total time and the stop reason.
+* *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.
 
 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/OllamaMonitor.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/OllamaMonitor.java
index 0b6cca44a239..ceaf4c63dd42 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
@@ -140,10 +140,26 @@ final class OllamaMonitor {
         ROUTE
     }
 
-    /** One request as Ollama reported it (TUI) or as the GenAI span of a 
route recorded it (no phase timings). */
+    /**
+     * One request as Ollama reported it (TUI) or as the GenAI span of a route 
recorded it (no phase timings).
+     * {@code contextSize} is the context window of the runner that served it, 
0 when unknown.
+     */
     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) {
+            String doneReason, long contextSize) {
+
+        /** Everything the model had in front of it: prompt tokens evaluated 
plus those served from cache. */
+        long promptTokens() {
+            return (long) inputTokens + cachedTokens;
+        }
+
+        /** Share of the context window the prompt filled, or -1 when the 
window is unknown. */
+        int contextPercent() {
+            if (contextSize <= 0) {
+                return -1;
+            }
+            return (int) Math.min(100, promptTokens() * 100 / contextSize);
+        }
 
         double prefillTokensPerSecond() {
             return prefillMs > 0 ? inputTokens * 1000.0 / prefillMs : 0;
@@ -169,15 +185,16 @@ final class OllamaMonitor {
     }
 
     record SessionTotals(int requests, long inputTokens, long outputTokens, 
long cachedTokens, long prefillMs,
-            long decodeMs, long loadMs, int coldStarts) {
+            long decodeMs, long loadMs, int coldStarts, int 
peakContextPercent, int compactions) {
 
-        static final SessionTotals EMPTY = new SessionTotals(0, 0, 0, 0, 0, 0, 
0, 0);
+        static final SessionTotals EMPTY = new SessionTotals(0, 0, 0, 0, 0, 0, 
0, 0, 0, 0);
 
-        SessionTotals plus(RequestEntry e) {
+        SessionTotals plus(RequestEntry e, boolean compaction) {
             return new SessionTotals(
                     requests + 1, inputTokens + e.inputTokens(), outputTokens 
+ e.outputTokens(),
                     cachedTokens + e.cachedTokens(), prefillMs + 
e.prefillMs(), decodeMs + e.decodeMs(),
-                    loadMs + e.loadMs(), coldStarts + (e.coldStart() ? 1 : 0));
+                    loadMs + e.loadMs(), coldStarts + (e.coldStart() ? 1 : 0),
+                    Math.max(peakContextPercent, e.contextPercent()), 
compactions + (compaction ? 1 : 0));
         }
 
         double avgDecodeTokensPerSecond() {
@@ -227,6 +244,7 @@ final class OllamaMonitor {
     private final Deque<RequestEntry> requests = new ArrayDeque<>();
     private final LinkedHashSet<String> seenSpanIds = new LinkedHashSet<>();
     private SessionTotals totals = SessionTotals.EMPTY;
+    private long lastTuiPromptTokens;
     private final TokenRateWindow decodeWindow = new 
TokenRateWindow(RATE_WINDOW_MS);
     private final TokenRateWindow prefillWindow = new 
TokenRateWindow(RATE_WINDOW_MS);
     private final long[] decodeHistory = new long[HISTORY_POINTS];
@@ -272,12 +290,53 @@ final class OllamaMonitor {
         RequestEntry entry = new RequestEntry(
                 Instant.now(), RequestSource.TUI, null, model != null ? model 
: "unknown",
                 usage.inputTokens(), usage.outputTokens(), 
usage.cachedTokens(),
-                usage.prefillMillis(), usage.generationMillis(), 
usage.loadMillis(), total, doneReason);
+                usage.prefillMillis(), usage.generationMillis(), 
usage.loadMillis(), total, doneReason,
+                contextSizeForNewRequest());
         synchronized (lock) {
             addRequest(entry);
         }
     }
 
+    /**
+     * The context window that served a request that just finished: the 
runner's slot size when known, else the
+     * allocated context of the loaded model, fetched on the spot when the tab 
has not polled yet. A request with a
+     * different {@code num_ctx} makes Ollama reload before answering, so what 
is loaded after the reply is what served
+     * it.
+     */
+    private long contextSizeForNewRequest() {
+        long known = knownContextSize();
+        if (known > 0) {
+            return known;
+        }
+        String base = baseUrl;
+        if (base != null) {
+            JsonObject ps = getJsonObject(base + "/api/ps");
+            if (ps != null) {
+                List<LoadedModel> loaded = OllamaParsers.parsePs(ps);
+                if (!loaded.isEmpty()) {
+                    synchronized (lock) {
+                        List<LoadedModel> withShapes = new ArrayList<>();
+                        for (LoadedModel m : loaded) {
+                            withShapes.add(m.withShape(shapes.get(m.name())));
+                        }
+                        models = List.copyOf(withShapes);
+                    }
+                    return loaded.get(0).contextLength();
+                }
+            }
+        }
+        return 0;
+    }
+
+    private long knownContextSize() {
+        synchronized (lock) {
+            if (slot != null && slot.contextSize() > 0) {
+                return slot.contextSize();
+            }
+            return models.isEmpty() ? 0 : models.get(0).contextLength();
+        }
+    }
+
     /**
      * Adds the Ollama calls Camel routes made, from the GenAI observability 
spans of the selected integration. Spans
      * are deduplicated by id, so the same list can be handed over on every 
refresh.
@@ -313,7 +372,7 @@ final class OllamaMonitor {
                         (int) OllamaParsers.num(attrs, 
GenAiAttributes.INPUT_TOKENS),
                         (int) OllamaParsers.num(attrs, 
GenAiAttributes.OUTPUT_TOKENS),
                         0, 0, 0, 0, Math.max(0, span.durationMs()),
-                        OllamaParsers.str(attrs, 
GenAiAttributes.FINISH_REASONS)));
+                        OllamaParsers.str(attrs, 
GenAiAttributes.FINISH_REASONS), knownContextSizeLocked()));
             }
             // spans arrive oldest first; keep the log newest first
             fresh.sort((a, b) -> a.timestamp().compareTo(b.timestamp()));
@@ -341,18 +400,36 @@ final class OllamaMonitor {
         synchronized (lock) {
             requests.clear();
             totals = SessionTotals.EMPTY;
+            lastTuiPromptTokens = 0;
             Arrays.fill(decodeHistory, 0);
             decodeWindow.clear();
             prefillWindow.clear();
         }
     }
 
+    /** As {@link #knownContextSize()} for callers already holding the lock. */
+    private long knownContextSizeLocked() {
+        if (slot != null && slot.contextSize() > 0) {
+            return slot.contextSize();
+        }
+        return models.isEmpty() ? 0 : models.get(0).contextLength();
+    }
+
     private void addRequest(RequestEntry entry) {
+        // an AI panel prompt that shrinks by a fifth or more against the 
previous turn means the history was
+        // compacted (or a new conversation started); either way the context 
was freed
+        boolean compaction = false;
+        if (entry.source() == RequestSource.TUI) {
+            if (lastTuiPromptTokens > 0 && entry.promptTokens() < 
lastTuiPromptTokens * 0.8) {
+                compaction = true;
+            }
+            lastTuiPromptTokens = entry.promptTokens();
+        }
         requests.addFirst(entry);
         while (requests.size() > MAX_REQUESTS) {
             requests.removeLast();
         }
-        totals = totals.plus(entry);
+        totals = totals.plus(entry, compaction);
     }
 
     // ---- state updates (also the test seam) ----
@@ -870,6 +947,8 @@ final class OllamaMonitor {
         session.put("avgDecodeTokensPerSecond", 
round1(s.totals().avgDecodeTokensPerSecond()));
         session.put("avgPrefillTokensPerSecond", 
round1(s.totals().avgPrefillTokensPerSecond()));
         session.put("coldStarts", s.totals().coldStarts());
+        session.put("peakContextPercent", s.totals().peakContextPercent());
+        session.put("compactions", s.totals().compactions());
         root.put("session", session);
 
         JsonArray reqs = new JsonArray();
@@ -906,6 +985,9 @@ final class OllamaMonitor {
         r.put("decodeTokensPerSecond", round1(e.decodeTokensPerSecond()));
         r.put("ttftMs", e.ttftMs());
         r.put("coldStart", e.coldStart());
+        r.put("promptTokens", e.promptTokens());
+        r.put("contextSize", e.contextSize());
+        r.put("contextPercent", e.contextPercent());
         if (e.doneReason() != null) {
             r.put("doneReason", e.doneReason());
         }
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 df6e7d259e61..e532e60b0e79 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
@@ -337,6 +337,10 @@ class OllamaTab extends AbstractTab {
                 state.add(Span.styled(" · speculative " + slot.speculative(), 
Theme.muted()));
             }
             lines.add(Line.from(state));
+            Line trend = contextTrendLine(s, block.inner(area).width());
+            if (trend != null) {
+                lines.add(trend);
+            }
         } else {
             RequestEntry last = s.lastRequest();
             long ctx = model != null ? model.contextLength() : 0;
@@ -357,10 +361,47 @@ class OllamaTab extends AbstractTab {
             lines.add(Line.from(Span.styled(s.local()
                     ? " live state appears once the runner is found"
                     : " live state needs the runner on this machine", 
Theme.muted().dim())));
+            Line trend = contextTrendLine(s, block.inner(area).width());
+            if (trend != null) {
+                lines.add(trend);
+            }
         }
         
frame.renderWidget(Paragraph.builder().text(Text.from(lines)).block(block).build(),
 area);
     }
 
+    /**
+     * How full the context was on each of the last AI panel turns, oldest 
first, scaled to the whole window so the bars
+     * are comparable across turns; a drop between bars is a compaction. Null 
when no turn has a known window.
+     */
+    static Line contextTrendLine(Snapshot s, int width) {
+        List<RequestEntry> turns = new ArrayList<>();
+        for (RequestEntry e : s.requests()) {
+            if (e.source() == RequestSource.TUI && e.contextPercent() >= 0) {
+                turns.add(e);
+            }
+        }
+        if (turns.isEmpty()) {
+            return null;
+        }
+        int last = turns.get(0).contextPercent();
+        SessionTotals t = s.totals();
+        String suffix = " " + last + "%" + (t.peakContextPercent() > last ? " 
(peak " + t.peakContextPercent() + "%)" : "")
+                        + (t.compactions() > 0
+                                ? " · " + t.compactions() + (t.compactions() 
== 1 ? " compaction" : " compactions")
+                                : "");
+        int barWidth = Math.max(4, Math.min(24, width - 8 - suffix.length()));
+        int n = Math.min(barWidth, turns.size());
+        long[] data = new long[n];
+        for (int i = 0; i < n; i++) {
+            data[n - 1 - i] = turns.get(i).contextPercent();
+        }
+        Style level = last >= 80 ? Theme.error() : last >= 50 ? 
Theme.warning() : Theme.info();
+        return Line.from(
+                Span.styled(" turns ", Theme.muted()),
+                Span.styled(sparkline(data, barWidth, 100), level),
+                Span.styled(suffix, level));
+    }
+
     private void renderHost(Frame frame, Rect area, Snapshot s) {
         List<Line> lines = new ArrayList<>();
         HostStats host = s.host();
@@ -419,6 +460,9 @@ class OllamaTab extends AbstractTab {
                     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,
@@ -448,6 +492,7 @@ class OllamaTab extends AbstractTab {
                         rightCell("IN", 6, Style.EMPTY.bold()),
                         rightCell("OUT", 6, Style.EMPTY.bold()),
                         rightCell("CACHED", 6, Style.EMPTY.bold()),
+                        rightCell("CTX", 5, Style.EMPTY.bold()),
                         rightCell("PREFILL", 8, Style.EMPTY.bold()),
                         rightCell("DECODE", 8, Style.EMPTY.bold()),
                         rightCell("TTFT", 7, Style.EMPTY.bold()),
@@ -460,6 +505,7 @@ class OllamaTab extends AbstractTab {
                         Constraint.length(6),
                         Constraint.length(6),
                         Constraint.length(6),
+                        Constraint.length(5),
                         Constraint.length(8),
                         Constraint.length(8),
                         Constraint.length(7),
@@ -468,7 +514,9 @@ class OllamaTab extends AbstractTab {
                 .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 ").build())
+                        .title(" Requests (" + requests.size()
+                               + ")  tok/s for prefill and decode · CTX = 
prompt share of the context window ")
+                        .build())
                 .build();
         lastTableArea = area;
         frame.renderStatefulWidget(table, area, tableState);
@@ -635,13 +683,21 @@ class OllamaTab extends AbstractTab {
 
     /** Right-aligned one-row sparkline scaled to the largest value; an 
all-zero series renders as spaces. */
     static String sparkline(long[] data, int width) {
-        if (data == null || width <= 0) {
+        if (data == null) {
             return "";
         }
         long max = 0;
         for (long v : data) {
             max = Math.max(max, v);
         }
+        return sparkline(data, width, max);
+    }
+
+    /** As {@link #sparkline(long[], int)} with a fixed scale, so bars stay 
comparable across renders. */
+    static String sparkline(long[] data, int width, long max) {
+        if (data == null || width <= 0) {
+            return "";
+        }
         StringBuilder sb = new StringBuilder();
         int start = Math.max(0, data.length - width);
         for (int i = 0; i < width - Math.min(width, data.length); i++) {
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 8e75cc50ca53..63af33fc2a0f 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
@@ -79,6 +79,13 @@ loads on the first request.
 - **speculative** — the speculative decoding method the runner uses
   (`draft-mtp` is multi-token prediction), which is why decode can
   exceed one token per step.
+- **turns** — one bar per AI panel turn, oldest first, scaled to the
+  whole context window: how much of the window each turn's prompt
+  filled (system prompt, tool definitions, history, question). The
+  bars grow as a conversation continues and drop when the history is
+  compacted; the line ends with the latest fill, the session peak and
+  the number of compactions seen. Yellow from 50%, red from 80%: that is
+  when `/compact` in the AI panel, or a smaller toolset, pays off.
 
 Without the local runner the panel falls back to the last request's
 tokens against the model's context length.
@@ -106,6 +113,7 @@ One line per request, newest first:
 | IN | Prompt tokens evaluated |
 | OUT | Tokens generated |
 | CACHED | Prompt tokens served from Ollama's cache (`-` when none) |
+| CTX | Share of the context window the prompt filled (IN plus CACHED against 
the window that served it) |
 | PREFILL | Prompt tokens per second |
 | DECODE | Generated tokens per second |
 | TTFT | Time to first token (load plus prefill), shown in yellow after a cold 
start |
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 cb2b325e32d9..bd8d7c1ecf3e 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
@@ -136,6 +136,37 @@ class OllamaMonitorTest {
         assertEquals(4100, e.totalMs());
         assertFalse(e.hasTimings());
         assertEquals(0, e.ttftMs());
+        // no runner slot and no model polled yet: the window is unknown
+        assertEquals(0, e.contextSize());
+        assertEquals(-1, e.contextPercent());
+    }
+
+    @Test
+    void contextFillComesFromTheSlotOrTheLoadedModelAndCompactionsAreCounted() 
{
+        OllamaMonitor monitor = new OllamaMonitor();
+        monitor.updateServer(new ServerInfo("http://localhost:11434";, 
"0.33.3", true));
+        monitor.updateModels(List.of(new OllamaMonitor.LoadedModel(
+                "m", "f", "35.5B", "Q4_K_M", 1, 1, 32_768, null, null)));
+        monitor.recordRequest("m", new LlmClient.TokenUsage(4_000, 60, 4_060, 
500, 900, 800, 0, 1800), 0, "stop");
+        RequestEntry first = monitor.snapshot().lastRequest();
+        assertEquals(32_768, first.contextSize());
+        assertEquals(4_500, first.promptTokens());
+        assertEquals(13, first.contextPercent());
+
+        // the runner's slot wins over the model list when present
+        monitor.updateSlot(slot(false, 0, System.currentTimeMillis()));
+        monitor.recordRequest("m", new LlmClient.TokenUsage(20_000, 60, 
20_060, 0, 900, 800, 0, 1800), 0, "stop");
+        assertEquals(262144, monitor.snapshot().lastRequest().contextSize());
+        assertEquals(7, monitor.snapshot().lastRequest().contextPercent());
+        assertEquals(13, monitor.snapshot().totals().peakContextPercent());
+        assertEquals(0, monitor.snapshot().totals().compactions());
+
+        // a prompt a fifth smaller than the previous turn counts as a 
compaction
+        monitor.recordRequest("m", new LlmClient.TokenUsage(9_000, 60, 9_060, 
0, 900, 800, 0, 1800), 0, "stop");
+        assertEquals(1, monitor.snapshot().totals().compactions());
+        JsonObject last = (JsonObject) ((JsonArray) 
monitor.toJson(1).get("requests")).get(0);
+        assertEquals(9_000L, last.get("promptTokens"));
+        assertEquals(3, last.get("contextPercent"));
     }
 
     @Test
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 46d7d515b5d0..668c73bae5ed 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
@@ -110,6 +110,7 @@ class OllamaTabRenderTest {
         assertTrue(rendered.contains("ollama serve"), rendered);
         // request log
         assertTrue(rendered.contains("Requests (1)"), rendered);
+        assertTrue(rendered.contains("CTX"), rendered);
         assertTrue(rendered.contains("PREFILL"), rendered);
         assertTrue(rendered.contains("DECODE"), rendered);
         assertTrue(rendered.contains("tui"), rendered);
@@ -169,6 +170,24 @@ class OllamaTabRenderTest {
         assertEquals(1, ((JsonArray) summary.get("loadedModels")).size());
     }
 
+    @Test
+    void contextTrendShowsTurnsPeakAndCompactions() {
+        localServerWithModel(); // ctx 262,144
+        // four turns: the prompt grows, then a compaction frees most of it
+        monitor.recordRequest("m", usage(26_000), 0, "stop");
+        monitor.recordRequest("m", usage(52_000), 0, "stop");
+        monitor.recordRequest("m", usage(131_000), 0, "stop");
+        monitor.recordRequest("m", usage(39_000), 0, "stop");
+
+        String rendered = TuiTestHelper.renderToString(new OllamaTab(ctx, 
monitor), 200, 40);
+        assertTrue(rendered.contains("turns"), rendered);
+        assertTrue(rendered.contains("14% (peak 49%) · 1 compaction"), 
rendered);
+        // the CTX column of the biggest turn
+        assertTrue(rendered.contains("49%"), rendered);
+        assertEquals(49, monitor.snapshot().totals().peakContextPercent());
+        assertEquals(1, monitor.snapshot().totals().compactions());
+    }
+
     @Test
     void helpTextExplainsThePhasesAndColumns() {
         String help = new OllamaTab(ctx, monitor).getHelpText();
@@ -197,6 +216,7 @@ class OllamaTabRenderTest {
         assertEquals("  ▁▄█", OllamaTab.sparkline(new long[] { 10, 40, 80 }, 
5));
         assertEquals("    ", OllamaTab.sparkline(new long[] { 0, 0 }, 4));
         assertEquals("▁█", OllamaTab.sparkline(new long[] { 1, 2, 3, 20 }, 2));
+        assertEquals("▁▄█", OllamaTab.sparkline(new long[] { 10, 50, 100 }, 3, 
100));
         Instant now = Instant.parse("2026-09-17T09:18:12Z");
         assertEquals("unloads in 4m32s", 
OllamaTab.formatCountdown(Instant.parse("2026-09-17T09:22:44Z"), now));
         assertEquals("unloads in 2h5m", 
OllamaTab.formatCountdown(now.plusSeconds(2 * 3600 + 300), now));
@@ -219,6 +239,10 @@ class OllamaTabRenderTest {
         monitor.updateModels(List.of(model()));
     }
 
+    private static LlmClient.TokenUsage usage(int promptTokens) {
+        return new LlmClient.TokenUsage(promptTokens, 50, promptTokens + 50, 
0, 900, 800, 10, 1800);
+    }
+
     private static LoadedModel model() {
         return new LoadedModel(
                 "qwen3.6:35b-a3b", "qwen35moe", "35.5B", "Q4_K_M", 
23567972432L, 23567972432L, 262144,
@@ -231,7 +255,7 @@ class OllamaTabRenderTest {
     private static RequestEntry request(int decodeTokensPerSecond) {
         return new RequestEntry(
                 Instant.now(), RequestSource.TUI, null, "m", 10, 
decodeTokensPerSecond, 0, 100, 1000,
-                0, 1100, "stop");
+                0, 1100, "stop", 0);
     }
 
     private static SpanEntry routeSpan() {

Reply via email to