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 c2717babf3b2 camel-jbang-plugin-tui: AI panel marks each question with 
an accent gutter bar
c2717babf3b2 is described below

commit c2717babf3b29f54447148bbd68237c36f66c3d7
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Sep 8 21:50:24 2026 +0200

    camel-jbang-plugin-tui: AI panel marks each question with an accent gutter 
bar
    
    The conversation was one markdown document with bold You:/TUI: labels, so
    consecutive turns blended together. The user's question is now rendered as
    a blockquote styled as an accent-coloured gutter bar with bold text, and the
    answer follows plain and unlabelled.
    
    camel-jbang-plugin-tui: expose infra services to MCP and the AI panel
    
    The MCP tools never mentioned infra services (brokers, databases started
    with camel infra run), so the AI panel could not list them, read their
    logs, or start/stop them even though the TUI itself can.
    
    - tui_get_options lists infraServices (alias, pid, version, connection
      properties); tui_get_state and tui_get_options report infraCount and
      the selected infra
    - tui_get_log accepts infra=<alias> to read the service log file
    - new tui_infra tool: list, log, start, stop, restart (core tool)
    - LaunchManager.startInfra() extracted from the example auto-start path
    - system prompt tells the model about tui_infra and extends the never-
      stop-unasked rule to infra services
    
    camel-jbang-plugin-tui: AI usage view groups tokens per question, /usage 
reset, integration label
    
    - Tokens per question chart: usage entries now carry the sequence number
      of the question they were recorded for, and the chart groups by that.
      The old heuristic split questions on a 30s pause between requests, so
      follow-ups typed within half a minute merged into one bar and the chart
      stayed hidden.
    - /usage reset clears the panel's usage and hides route usage from spans
      exported before the reset.
    - The request split reads 'TUI / integration' instead of 'TUI / routes',
      as 'routes' can be read as an LLM router.
    - Trim the tui_infra and tui_get_log descriptions and the system prompt so
      the core prompt prefix stays within its 3500 token budget.
    
    camel-jbang-plugin-tui: slimmer MCP log and options responses, tui_action 
by menu label
    
    - tui_get_log folds continuation lines (stack frames, Caused by, wrapped
      text) into the timestamped record they belong to as a 'detail' block,
      capped at 20 lines with a count of the rest. A 50-line stack trace was
      50 entries, each claiming to be an INFO line with no time, and cost the
      model more tokens than the rest of the log combined.
    - tui_get_options lists F2 menu entries by label only. The per-entry
      'F2,Down,...,Enter' key sequences and the hint were index-based, broke
      whenever the menu changed, and could not be used by the panel anyway
      (tui_send_keys is not a core tool).
    - tui_action accepts a menu label ('Run Doctor', 'Run an Example...') as
      well as its kebab-case names, and can now open the goto, settings,
      example, project, infra, send-message and shell entries headlessly.
    
    Co-Authored-By: Claude Fable 5.1 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../dsl/jbang/core/commands/tui/ActionsPopup.java  |  82 ++++++++++-
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java |  91 ++++++++----
 .../core/commands/tui/AiSlashCommandContext.java   |   3 +
 .../core/commands/tui/AiSlashCommandRegistry.java  |  13 +-
 .../dsl/jbang/core/commands/tui/InfraSupport.java  | 133 +++++++++++++++++
 .../dsl/jbang/core/commands/tui/LaunchManager.java |  32 ++--
 .../camel/dsl/jbang/core/commands/tui/LogTab.java  |  77 ++++++++--
 .../dsl/jbang/core/commands/tui/McpFacade.java     |  38 +++++
 .../camel/dsl/jbang/core/commands/tui/Theme.java   |  20 ++-
 .../jbang/core/commands/tui/TuiToolRegistry.java   | 164 +++++++++++++++++----
 .../jbang/core/commands/tui/ActionsPopupTest.java  |  22 +++
 .../core/commands/tui/AiPanelGenAiUsageTest.java   |   2 +-
 .../dsl/jbang/core/commands/tui/AiPanelTest.java   |  89 +++++++++++
 .../commands/tui/AiSlashCommandRegistryTest.java   |   4 +
 .../jbang/core/commands/tui/InfraSupportTest.java  | 124 ++++++++++++++++
 .../core/commands/tui/LogTabLogDataJsonTest.java   | 119 +++++++++++++++
 16 files changed, 926 insertions(+), 87 deletions(-)

diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
index a0b94aa5b9fd..07abfb14df70 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopup.java
@@ -19,6 +19,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Locale;
 import java.util.Set;
 import java.util.function.BiConsumer;
 import java.util.function.IntPredicate;
@@ -1356,15 +1357,62 @@ class ActionsPopup {
         docViewerPopup.showFailureLog(name, logFile);
     }
 
+    /**
+     * Maps an F2 menu label (as {@link #getActionLabels()} lists it) to its 
action. Trailing "..." and shortcut hints
+     * such as "(F3)" are ignored, as is case, so "run doctor", "Run Doctor" 
and "Screen..." all resolve. Submenu
+     * entries and the ".." back entry have no action of their own and resolve 
to null.
+     */
+    static Action actionForLabel(String label) {
+        if (label == null) {
+            return null;
+        }
+        String key = label.strip().toLowerCase(Locale.ROOT)
+                .replaceAll("\\s*\\(.*\\)$", "")
+                .replaceAll("\\.+$", "")
+                .strip();
+        return switch (key) {
+            case "go to" -> Action.GOTO_TAB;
+            case "switch integration" -> Action.SWITCH_INTEGRATION;
+            case "send message" -> Action.SEND_MESSAGE;
+            case "run an example", "run example" -> Action.RUN_EXAMPLE;
+            case "open project" -> Action.OPEN_PROJECT;
+            case "run dev/infra service", "run infra service", "run infra" -> 
Action.RUN_INFRA;
+            case "browse files" -> Action.BROWSE_FILES;
+            case "run doctor" -> Action.DOCTOR;
+            case "reset stats" -> Action.RESET_STATS;
+            case "settings" -> Action.SETTINGS;
+            case "shell" -> Action.SHELL;
+            case "take screenshot" -> Action.SCREENSHOT;
+            case "reset screen" -> Action.RESET_SCREEN;
+            case "start tape recording", "stop tape recording" -> 
Action.TAPE_RECORDING;
+            case "tape recording guide" -> Action.TAPE_INSTRUCTIONS;
+            case "caption" -> Action.CAPTION;
+            case "show keystrokes", "hide keystrokes" -> 
Action.SHOW_KEYSTROKES;
+            case "camel animation" -> Action.CAMEL_ANIMATION;
+            case "ai prompt" -> Action.AI_PROMPT;
+            case "setup ai" -> Action.SETUP_AI;
+            case "ai log" -> Action.AI_LOG;
+            case "mcp info" -> Action.MCP_INFO;
+            case "mcp log" -> Action.MCP_LOG;
+            default -> null;
+        };
+    }
+
+    /**
+     * Executes an action given as a kebab-case name ({@code reset-stats}) or 
as an F2 menu label ({@code Run Doctor}).
+     */
     boolean executeActionByName(String name) {
         if (name == null || name.isBlank()) {
             return false;
         }
-        String normalized = name.replace("-", "_").toUpperCase();
+        String normalized = name.strip().replace("-", 
"_").toUpperCase(Locale.ROOT);
         Action action;
         try {
             action = Action.valueOf(normalized);
         } catch (IllegalArgumentException e) {
+            action = actionForLabel(name);
+        }
+        if (action == null) {
             return false;
         }
         switch (action) {
@@ -1401,10 +1449,42 @@ class ActionsPopup {
                     camelAnimationAction.run();
                 }
             }
+            // menu entries that open a popup or view, mirroring what Enter 
does on them
+            case GOTO_TAB -> gotoTabPopup.open();
+            case SETTINGS -> settingsPopup.open();
+            case RUN_EXAMPLE -> exampleBrowserPopup.open();
+            case OPEN_PROJECT -> folderInputPopup.open();
+            case RUN_INFRA -> infraBrowserPopup.open();
+            case TAPE_INSTRUCTIONS -> openTapeInstructions();
+            case SEND_MESSAGE -> {
+                if (ctx == null || ctx.selectedPid == null || 
ctx.isInfraSelected()) {
+                    return false;
+                }
+                openSendMessage();
+            }
+            case BROWSE_FILES -> {
+                if (ctx == null || ctx.selectedPid == null || 
ctx.isInfraSelected() || browseFilesAction == null) {
+                    return false;
+                }
+                browseFilesAction.run();
+            }
+            case SWITCH_INTEGRATION -> {
+                if (!hasMultipleIntegrations() || switchIntegrationAction == 
null) {
+                    return false;
+                }
+                switchIntegrationAction.run();
+            }
+            case SHELL -> {
+                if (openShellAction == null) {
+                    return false;
+                }
+                openShellAction.run();
+            }
             default -> {
                 return false;
             }
         }
+        showActionsMenu = false;
         return true;
     }
 
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 e1df0d622eee..7bad44131449 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
@@ -230,6 +230,10 @@ class AiPanel {
 
     // AI usage stats
     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;
+    /** 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());
     private final TableState statsTableState = new TableState();
     private boolean statsView;
@@ -247,14 +251,26 @@ class AiPanel {
         }
     }
 
+    /**
+     * One model round trip. {@code question} is the sequence number of the 
panel question the request was made for (a
+     * question takes several round trips when the model calls tools), so 
usage can be grouped per question; it is 0 for
+     * route requests, which belong to no question.
+     */
     record AiUsageEntry(String model, String provider, int inputTokens, int 
outputTokens,
             int totalTokens, long latencyMs, String stopReason, Instant 
timestamp,
-            AiUsageSource source, String routeId) {
+            AiUsageSource source, String routeId, int question) {
 
         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);
+                 AiUsageSource.TUI, null, 0);
+        }
+
+        AiUsageEntry(String model, String provider, int inputTokens, int 
outputTokens,
+                     int totalTokens, long latencyMs, String stopReason, 
Instant timestamp,
+                     AiUsageSource source, String routeId) {
+            this(model, provider, inputTokens, outputTokens, totalTokens, 
latencyMs, stopReason, timestamp,
+                 source, routeId, 0);
         }
     }
 
@@ -1012,6 +1028,7 @@ class AiPanel {
             return;
         }
         conversation.add(new ConversationEntry(AiRole.USER, question));
+        questionCounter++;
         log(LogLevel.QUESTION, "Question", question);
         thinkingVerb = 
THINKING_VERBS.get(ThreadLocalRandom.current().nextInt(THINKING_VERBS.size()));
         thinkingStartTime = System.currentTimeMillis();
@@ -1166,7 +1183,8 @@ class AiPanel {
                 model, provider,
                 response.usage().inputTokens(), 
response.usage().outputTokens(),
                 response.usage().totalTokens(), latencyMs,
-                response.stopReason(), Instant.now()));
+                response.stopReason(), Instant.now(),
+                AiUsageSource.TUI, null, questionCounter));
     }
 
     void render(Frame frame, Rect area) {
@@ -1289,10 +1307,13 @@ class AiPanel {
             return;
         }
 
+        // The user's question is a blockquote: with the chat styles that 
becomes an accent-coloured gutter bar, which
+        // is what the eye picks up when scanning for where the next turn 
starts. The answer is plain, without a label,
+        // as it always directly follows its question.
         for (ConversationEntry entry : conversation) {
             switch (entry.role()) {
-                case USER -> md.append("**You:** 
").append(entry.text()).append("\n\n");
-                case ASSISTANT -> md.append("**TUI:** 
").append(toHardBreaks(entry.text())).append("\n\n");
+                case USER -> md.append("> ").append(entry.text().replace("\n", 
"\n> ")).append("\n\n");
+                case ASSISTANT -> 
md.append(toHardBreaks(entry.text())).append("\n\n");
                 case ERROR -> md.append("**Error:** 
").append(entry.text()).append("\n\n");
                 case SYSTEM -> 
md.append(toHardBreaks(entry.text())).append("\n\n");
             }
@@ -1334,7 +1355,7 @@ class AiPanel {
         // couple of lines hidden below the visible area.
         MarkdownView.Builder viewBuilder = MarkdownView.builder()
                 .source(source)
-                .styles(Theme.markdownStyles());
+                .styles(Theme.chatMarkdownStyles());
         MarkdownView measure = viewBuilder.build();
         int totalLines = measure.computeHeight(mdArea.width());
 
@@ -1563,7 +1584,7 @@ class AiPanel {
         if (entries.isEmpty()) {
             frame.renderWidget(
                     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).",
+                            "No AI usage data yet. Ask a question, or run an 
integration with GenAI observability and --observe.",
                             Style.EMPTY.dim()))),
                     area);
             return;
@@ -1600,24 +1621,18 @@ class AiPanel {
             stats[3] += e.totalTokens();
         }
 
-        // Per-conversation token totals (TUI ask sessions only)
+        // Tokens per question (panel requests only): the round trips made for 
one question share its sequence
+        // number. Grouping by that, rather than by a pause between requests, 
keeps a follow-up typed right after the
+        // previous answer as its own bar.
         List<Integer> turnTokens = new ArrayList<>();
-        int currentTurn = 0;
-        int turnIndex = 0;
+        int lastQuestion = -1;
         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;
-                }
+            if (e.question() != lastQuestion) {
+                turnTokens.add(0);
+                lastQuestion = e.question();
             }
-            currentTurn += e.totalTokens();
-            turnIndex++;
-        }
-        if (currentTurn > 0) {
-            turnTokens.add(currentTurn);
+            int last = turnTokens.size() - 1;
+            turnTokens.set(last, turnTokens.get(last) + e.totalTokens());
         }
 
         // Layout: summary (2 rows) + model table (header + models + 1 blank) 
+ chart (fill)
@@ -1646,7 +1661,7 @@ class AiPanel {
                 Span.styled(String.valueOf(requestCount), cyanStyle),
                 Span.styled(" (TUI: ", dimStyle),
                 Span.styled(String.valueOf(tuiRequests), cyanStyle),
-                Span.styled(" / routes: ", dimStyle),
+                Span.styled(" / integration: ", dimStyle),
                 Span.styled(String.valueOf(routeRequests), cyanStyle),
                 Span.styled(")   Total tokens: ", dimStyle),
                 Span.styled(LlmClient.formatTokens(totalTokens), cyanStyle),
@@ -1737,11 +1752,26 @@ class AiPanel {
         combined.addAll(usageHistory);
         List<SpanEntry> spans = otelSpans.get();
         if (spans != null && !spans.isEmpty()) {
-            combined.addAll(GenAiSpanUsageExtractor.extract(spans));
+            Instant since = usageResetAt;
+            for (AiUsageEntry entry : GenAiSpanUsageExtractor.extract(spans)) {
+                if (!entry.timestamp().isBefore(since)) {
+                    combined.add(entry);
+                }
+            }
         }
         return combined;
     }
 
+    /**
+     * Forgets the usage recorded so far: the panel's own requests are 
dropped, and route usage from spans that were
+     * exported before now is hidden (the spans themselves stay, as the Spans 
tab owns them).
+     */
+    void resetUsage() {
+        usageHistory.clear();
+        usageResetAt = Instant.now();
+        statsScrollOffset = 0;
+    }
+
     private static String modelTableKey(AiUsageEntry entry) {
         String modelProvider = entry.model() + " (" + entry.provider() + ")";
         if (entry.source() == AiUsageSource.ROUTE) {
@@ -1884,7 +1914,9 @@ class AiPanel {
         sb.append("- Be concise and actionable; when something looks wrong, 
explain what it means and suggest fixes\n");
         sb.append("- tui_control stops/starts routes and integrations 
gracefully; its reset-stats action clears ");
         sb.append("statistics without touching the routes\n");
-        sb.append("- Never restart, stop or kill an integration unless the 
user explicitly asked for that\n");
+        sb.append("- tui_infra lists infra services (brokers, databases) and 
reads their logs\n");
+        sb.append("- Never restart, stop or kill an integration or infra 
service unless the user explicitly ");
+        sb.append("asked for that\n");
         sb.append("- If a tool call returns an error, do not repeat it with 
the same arguments; ");
         sb.append("tell the user what failed and what to try\n");
         sb.append("- To feed a route that consumes from a broker (MQTT, Kafka, 
JMS), tui_send_message can publish ");
@@ -2184,7 +2216,7 @@ class AiPanel {
     String usageSummary() {
         List<AiUsageEntry> entries = combinedUsageEntries();
         if (entries.isEmpty()) {
-            return "No AI usage yet. Ask a question, or run routes with GenAI 
observability and --observe to see "
+            return "No AI usage yet. Ask a question, or run an integration 
with GenAI observability and --observe to see "
                    + "their usage here.";
         }
         int totalInput = 0;
@@ -2215,7 +2247,7 @@ class AiPanel {
         sb.append("Requests: ").append(entries.size());
         if (routeRequests > 0) {
             sb.append(" (").append(tuiRequests).append(" from this panel, 
").append(routeRequests)
-                    .append(" from routes)");
+                    .append(" from the integration)");
         }
         sb.append(", tokens: ").append(LlmClient.formatTokens(totalTokens))
                 .append(" (in ").append(LlmClient.formatTokens(totalInput))
@@ -2497,6 +2529,11 @@ class AiPanel {
             return AiPanel.this.usageSummary();
         }
 
+        @Override
+        public void resetUsage() {
+            AiPanel.this.resetUsage();
+        }
+
         @Override
         public void copyLastResponse() {
             copyLastResponseToClipboard();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandContext.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandContext.java
index 81891992f4b6..80bcb09f2bbe 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandContext.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandContext.java
@@ -70,6 +70,9 @@ interface AiSlashCommandContext {
      */
     String usageSummary();
 
+    /** Clears the usage statistics so the summary and the Ctrl+U view start 
from zero. */
+    void resetUsage();
+
     void copyLastResponse();
 
     void exportConversation();
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java
index c3535e64433a..5df3e7763f6c 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistry.java
@@ -84,9 +84,16 @@ final class AiSlashCommandRegistry {
                         ? CommandResult.system("")
                         : CommandResult.error("No question to retry. Ask 
something first.")));
         commands.add(new Descriptor(
-                "usage", List.of("u"), "Show AI usage so far: requests, 
tokens, latency (Ctrl+U opens the full view)",
-                null,
-                (context, arguments) -> 
CommandResult.system(context.usageSummary())));
+                "usage", List.of("u"),
+                "Show AI usage so far: requests, tokens, latency (Ctrl+U opens 
the full view); 'reset' clears it",
+                "[reset]",
+                (context, arguments) -> {
+                    if ("reset".equalsIgnoreCase(arguments == null ? "" : 
arguments.strip())) {
+                        context.resetUsage();
+                        return CommandResult.system("AI usage statistics 
reset");
+                    }
+                    return CommandResult.system(context.usageSummary());
+                }));
         commands.add(new Descriptor(
                 "copy", List.of("y"), "Copy the last AI response to the 
clipboard (Ctrl+Y)", null,
                 (context, arguments) -> {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraSupport.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraSupport.java
new file mode 100644
index 000000000000..67561a01167a
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraSupport.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.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.apache.camel.dsl.jbang.core.common.PathUtils;
+import org.apache.camel.util.json.JsonObject;
+
+/**
+ * Infra service (broker, database, ...) operations shared by the MCP tools 
and the AI panel. The TUI discovers infra
+ * services from the {@code infra-{alias}-{pid}.json} files that {@code camel 
infra run} writes in the Camel home
+ * directory; the matching {@code .log} file next to it holds the service 
output.
+ */
+final class InfraSupport {
+
+    private InfraSupport() {
+    }
+
+    /**
+     * Finds a live infra service by alias (case-insensitive) or pid.
+     */
+    static InfraInfo find(List<InfraInfo> services, String aliasOrPid) {
+        if (services == null || aliasOrPid == null || aliasOrPid.isBlank()) {
+            return null;
+        }
+        String key = aliasOrPid.strip();
+        for (InfraInfo info : services) {
+            if (info.vanishing) {
+                continue;
+            }
+            if (key.equalsIgnoreCase(info.alias) || key.equals(info.pid)) {
+                return info;
+            }
+        }
+        return null;
+    }
+
+    static JsonObject toJson(InfraInfo info) {
+        JsonObject json = new JsonObject();
+        json.put("alias", info.alias);
+        json.put("pid", info.pid);
+        json.put("alive", info.alive);
+        if (info.description != null) {
+            json.put("description", info.description);
+        }
+        if (info.serviceVersion != null) {
+            json.put("version", info.serviceVersion);
+        }
+        // connection details (brokerUrl, host, port, ...) are what a caller 
needs to reach the service
+        JsonObject props = new JsonObject();
+        for (Map.Entry<String, Object> e : info.properties.entrySet()) {
+            String k = e.getKey();
+            if ("description".equals(k) || "serviceVersion".equals(k) || 
k.startsWith("get")) {
+                continue;
+            }
+            props.put(k, e.getValue());
+        }
+        if (!props.isEmpty()) {
+            json.put("properties", props);
+        }
+        return json;
+    }
+
+    static Path logFile(Path camelDir, InfraInfo info) {
+        return camelDir.resolve("infra-" + info.alias + "-" + info.pid + 
".log");
+    }
+
+    /**
+     * Returns the newest {@code limit} lines of the service log, newest 
first, optionally filtered by a
+     * case-insensitive substring. Returns an empty list when the service has 
written no log yet.
+     */
+    static List<String> readLogTail(Path camelDir, InfraInfo info, int limit, 
String filter) throws IOException {
+        Path logFile = logFile(camelDir, info);
+        if (!Files.isRegularFile(logFile)) {
+            return List.of();
+        }
+        List<String> lines;
+        try {
+            lines = Files.readAllLines(logFile, StandardCharsets.UTF_8);
+        } catch (IOException e) {
+            // service output is not guaranteed to be UTF-8
+            lines = Files.readAllLines(logFile, StandardCharsets.ISO_8859_1);
+        }
+        String needle = filter == null || filter.isBlank() ? null : 
filter.toLowerCase(Locale.ROOT);
+        List<String> result = new ArrayList<>();
+        for (int i = lines.size() - 1; i >= 0 && result.size() < limit; i--) {
+            String line = lines.get(i);
+            if (line.isBlank()) {
+                continue;
+            }
+            if (needle == null || 
line.toLowerCase(Locale.ROOT).contains(needle)) {
+                result.add(line);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Stops a service the same way the Overview stop-all does: remove its pid 
file so the TUI stops listing it, then
+     * ask the process to terminate. Returns false when no live process was 
found for it.
+     */
+    static boolean stop(Path camelDir, InfraInfo info) {
+        PathUtils.deleteFile(camelDir.resolve("infra-" + info.alias + "-" + 
info.pid + ".json"));
+        try {
+            long pid = Long.parseLong(info.pid);
+            return 
ProcessHandle.of(pid).map(ProcessHandle::destroy).orElse(false);
+        } catch (NumberFormatException e) {
+            return false;
+        }
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
index cd3c0e1315fe..96566fa3f099 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LaunchManager.java
@@ -126,21 +126,29 @@ class LaunchManager {
                 .anyMatch(i -> i.alive && "jaeger".equals(i.alias));
     }
 
+    /**
+     * Starts an infra service in the background via {@code camel infra run 
<alias> --background}. The launch is
+     * monitored like any other, so a failure surfaces through the failure log 
callback.
+     */
+    void startInfra(String alias) throws IOException {
+        List<String> cmd = new ArrayList<>(LauncherHelper.getCamelCommand());
+        cmd.add("infra");
+        cmd.add("run");
+        cmd.add(alias);
+        cmd.add("--background");
+        Path outputFile = createSecureTempFile("camel-infra-", ".log");
+        outputFile.toFile().deleteOnExit();
+        ProcessBuilder pb = new ProcessBuilder(cmd);
+        pb.redirectErrorStream(true);
+        pb.redirectOutput(outputFile.toFile());
+        Process process = pb.start();
+        pendingLaunches.add(new PendingLaunch(alias, process, outputFile, 
System.currentTimeMillis()));
+    }
+
     void startMissingInfraAndDefer(List<String> missingInfra, String 
displayName, Runnable launchAction) {
         for (String alias : missingInfra) {
             try {
-                List<String> cmd = new 
ArrayList<>(LauncherHelper.getCamelCommand());
-                cmd.add("infra");
-                cmd.add("run");
-                cmd.add(alias);
-                cmd.add("--background");
-                Path outputFile = createSecureTempFile("camel-infra-", ".log");
-                outputFile.toFile().deleteOnExit();
-                ProcessBuilder pb = new ProcessBuilder(cmd);
-                pb.redirectErrorStream(true);
-                pb.redirectOutput(outputFile.toFile());
-                Process process = pb.start();
-                pendingLaunches.add(new PendingLaunch(alias, process, 
outputFile, System.currentTimeMillis()));
+                startInfra(alias);
             } catch (Exception e) {
                 notify("Failed to start infra: " + alias + " - " + 
e.getMessage(), true);
                 return;
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
index 98777f9319c8..bf0713268c39 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTab.java
@@ -812,31 +812,71 @@ class LogTab extends AbstractTab {
     }
 
     JsonObject getLogDataAsJson(int limit, String filter, String level) {
-        List<LogEntry> entries = filteredLogEntries;
+        return buildLogDataJson(filteredLogEntries, limit, filter, level);
+    }
+
+    /** Continuation lines kept per log record before the rest is summarised 
as a count. */
+    static final int MAX_DETAIL_LINES = 20;
+
+    /** A timestamped log line together with the continuation lines (stack 
trace, wrapped text) that followed it. */
+    record LogRecord(LogEntry head, List<String> detail, int 
hiddenDetailLines) {
+    }
+
+    /**
+     * Groups raw log lines into records: a line without a timestamp (a stack 
frame, a "Caused by:", wrapped text)
+     * belongs to the timestamped line before it. The MCP log tool returns 
records, so a stack trace is one entry with a
+     * {@code detail} block rather than fifty entries that all claim to be 
INFO lines with no time.
+     */
+    static List<LogRecord> toRecords(List<LogEntry> entries) {
+        List<LogRecord> records = new ArrayList<>();
+        LogEntry head = null;
+        List<String> detail = null;
+        int hidden = 0;
+        for (LogEntry e : entries) {
+            boolean continuation = (e.time == null || e.time.isEmpty()) && 
head != null;
+            if (continuation) {
+                if (detail.size() < MAX_DETAIL_LINES) {
+                    detail.add(e.message);
+                } else {
+                    hidden++;
+                }
+                continue;
+            }
+            if (head != null) {
+                records.add(new LogRecord(head, detail, hidden));
+            }
+            head = e;
+            detail = new ArrayList<>();
+            hidden = 0;
+        }
+        if (head != null) {
+            records.add(new LogRecord(head, detail, hidden));
+        }
+        return records;
+    }
+
+    static JsonObject buildLogDataJson(List<LogEntry> entries, int limit, 
String filter, String level) {
+        JsonObject result = new JsonObject();
+        JsonArray rows = new JsonArray();
         if (entries == null || entries.isEmpty()) {
-            JsonObject result = new JsonObject();
-            result.put("lines", new JsonArray());
+            result.put("lines", rows);
             result.put("totalLines", 0);
             result.put("returnedLines", 0);
             return result;
         }
 
-        List<LogEntry> filtered = new ArrayList<>();
-        for (int i = entries.size() - 1; i >= 0 && filtered.size() < limit; 
i--) {
-            LogEntry e = entries.get(i);
+        List<LogRecord> records = toRecords(entries);
+        String needle = filter == null || filter.isBlank() ? null : 
filter.toLowerCase();
+        for (int i = records.size() - 1; i >= 0 && rows.size() < limit; i--) {
+            LogRecord r = records.get(i);
+            LogEntry e = r.head();
             if (level != null && !level.isBlank() && 
!level.equalsIgnoreCase(e.level)) {
                 continue;
             }
-            if (filter != null && !filter.isBlank()
-                    && 
!e.message.toLowerCase().contains(filter.toLowerCase())) {
+            if (needle != null && !e.message.toLowerCase().contains(needle)
+                    && r.detail().stream().noneMatch(d -> 
d.toLowerCase().contains(needle))) {
                 continue;
             }
-            filtered.add(e);
-        }
-
-        JsonObject result = new JsonObject();
-        JsonArray rows = new JsonArray();
-        for (LogEntry e : filtered) {
             JsonObject row = new JsonObject();
             row.put("time", e.time);
             row.put("level", e.level);
@@ -844,11 +884,18 @@ class LogTab extends AbstractTab {
                 row.put("logger", e.logger);
             }
             row.put("message", e.message);
+            if (!r.detail().isEmpty()) {
+                String detail = String.join("\n", r.detail());
+                if (r.hiddenDetailLines() > 0) {
+                    detail += "\n... " + r.hiddenDetailLines() + " more lines";
+                }
+                row.put("detail", detail);
+            }
             rows.add(row);
         }
         result.put("lines", rows);
         result.put("totalLines", entries.size());
-        result.put("returnedLines", filtered.size());
+        result.put("returnedLines", rows.size());
         return result;
     }
 }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index efe2b592a1fb..b84be58a65e3 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -35,6 +35,7 @@ import dev.tamboui.tui.event.KeyCode;
 import dev.tamboui.tui.event.KeyEvent;
 import dev.tamboui.tui.event.KeyModifiers;
 import dev.tamboui.widgets.tabs.TabsState;
+import org.apache.camel.dsl.jbang.core.common.CommandLineHelper;
 import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
 import org.apache.camel.util.json.JsonArray;
 import org.apache.camel.util.json.JsonObject;
@@ -367,6 +368,43 @@ class McpFacade {
                 .toList();
     }
 
+    /**
+     * Infra services (brokers, databases, ...) started with {@code camel 
infra run} that are still alive.
+     */
+    List<InfraInfo> liveInfraServices() {
+        if (ctx == null || ctx.infraData == null) {
+            return List.of();
+        }
+        List<InfraInfo> all = ctx.infraData.get();
+        return all == null ? List.of() : all.stream().filter(i -> 
!i.vanishing).toList();
+    }
+
+    InfraInfo findInfra(String aliasOrPid) {
+        return InfraSupport.find(liveInfraServices(), aliasOrPid);
+    }
+
+    /** Alias of the infra service selected in Overview, or null when an 
integration (or nothing) is selected. */
+    String getSelectedInfraAlias() {
+        InfraInfo infra = ctx != null ? ctx.findSelectedInfra() : null;
+        return infra != null ? infra.alias : null;
+    }
+
+    JsonObject getInfraLogData(InfraInfo info, int limit, String filter) 
throws IOException {
+        List<String> lines = 
InfraSupport.readLogTail(CommandLineHelper.getCamelDir(), info, limit, filter);
+        JsonObject result = new JsonObject();
+        result.put("infra", info.alias);
+        result.put("pid", info.pid);
+        JsonArray arr = new JsonArray();
+        arr.addAll(lines);
+        result.put("lines", arr);
+        result.put("returnedLines", lines.size());
+        return result;
+    }
+
+    boolean stopInfra(InfraInfo info) {
+        return InfraSupport.stop(CommandLineHelper.getCamelDir(), info);
+    }
+
     // ---- Key injection ----
 
     int injectKeys(List<String> keys, int delayMs) {
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Theme.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Theme.java
index 6f5a2beea43f..5ff339e2c612 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Theme.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Theme.java
@@ -266,6 +266,23 @@ public final class Theme {
 
     /** Theme-aware markdown styles for MarkdownView headings and other 
elements. */
     public static dev.tamboui.markdown.MarkdownStyles markdownStyles() {
+        return markdownStylesBuilder().build();
+    }
+
+    /**
+     * Markdown styles for the AI chat conversation. The user's question is 
rendered as a blockquote, so the blockquote
+     * becomes an accent-coloured gutter bar with bold text that marks where 
each new turn starts, while the answer that
+     * follows is plain markdown.
+     */
+    public static dev.tamboui.markdown.MarkdownStyles chatMarkdownStyles() {
+        return markdownStylesBuilder()
+                .blockquotePrefix("\u258e")
+                .blockquote(Style.EMPTY.fg(accent()).bold())
+                .horizontalRule(Style.EMPTY.dim())
+                .build();
+    }
+
+    private static dev.tamboui.markdown.MarkdownStyles.Builder 
markdownStylesBuilder() {
         return dev.tamboui.markdown.MarkdownStyles.builder()
                 .heading(1, label().bold())
                 .heading(2, label().bold())
@@ -273,8 +290,7 @@ public final class Theme {
                 .inlineCode(Style.EMPTY.fg(accent()))
                 .codeBlock(muted())
                 .listMarker(Style.EMPTY.fg(accent()))
-                .link(Style.EMPTY.fg(accent()).underlined())
-                .build();
+                .link(Style.EMPTY.fg(accent()).underlined());
     }
 
     /** Diagram box-drawing border color. */
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
index 147a52da3dce..cdd87230ae94 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiToolRegistry.java
@@ -18,6 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -89,7 +90,8 @@ class TuiToolRegistry {
             "tui_get_state", "tui_get_options", "tui_get_table", 
"tui_get_log", "tui_get_errors",
             "tui_get_diagram", "tui_get_topology", "tui_get_processor_detail", 
"tui_catalog_doc",
             "tui_get_history", "tui_get_spans", "tui_control", 
"tui_send_message", "tui_get_files",
-            "tui_get_readme", "tui_navigate", "tui_set_log_level", 
"tui_filter", "tui_get_status");
+            "tui_get_readme", "tui_navigate", "tui_set_log_level", 
"tui_filter", "tui_get_status",
+            "tui_infra");
 
     /**
      * Returns all tool definitions. The result is cached since it is 
immutable.
@@ -148,6 +150,7 @@ class TuiToolRegistry {
             case "tui_toggle_trace_display" -> callToggleTraceDisplay(args);
             case "tui_get_readme" -> callGetReadme(args);
             case "tui_control" -> callControl(args);
+            case "tui_infra" -> callInfra(args);
             case "tui_open_project" -> callOpenProject(args);
             case "tui_get_files" -> callGetFiles(args);
             case "tui_get_spans" -> callGetSpans(args);
@@ -371,10 +374,12 @@ class TuiToolRegistry {
                 List.of("section"))));
         tools.add(toToolDef(toolDef(
                 "tui_action",
-                "Invokes a TUI action by name, bypassing fragile key 
sequences. "
-                              + "Actions: reset-stats, reset-screen, 
screenshot, show-keystrokes, "
+                "Invokes a TUI action by name or by its F2 menu label (as 
listed in tui_get_options actions), "
+                              + "bypassing fragile key sequences. "
+                              + "Names: reset-stats, reset-screen, screenshot, 
show-keystrokes, "
                               + "tape-recording, doctor, caption, mcp-info, 
mcp-log, toggle-theme.",
-                Map.of("action", propDef("string", "Action name in kebab-case 
(e.g. 'reset-stats', 'screenshot')")),
+                Map.of("action", propDef("string",
+                        "Action name in kebab-case (e.g. 'reset-stats') or 
menu label (e.g. 'Run Doctor')")),
                 List.of("action"))));
         tools.add(toToolDef(toolDef(
                 "tui_get_themes",
@@ -389,10 +394,12 @@ class TuiToolRegistry {
         tools.add(toToolDef(toolDef(
                 "tui_get_log",
                 "Returns recent log lines as structured data with optional 
filtering. "
-                               + "Returns newest entries first.",
+                               + "Returns newest entries first. Reads the 
selected integration's log, or an infra "
+                               + "service's log when infra=<alias> is given.",
                 Map.of("limit", propDef("integer", "Maximum lines to return 
(default 50)"),
                         "filter", propDef("string", "Case-insensitive 
substring filter on log message"),
-                        "level", propDef("string", "Filter by log level (INFO, 
WARN, ERROR, DEBUG, TRACE)")))));
+                        "level", propDef("string", "Filter by log level (INFO, 
WARN, ERROR, DEBUG, TRACE)"),
+                        "infra", propDef("string", "Infra service alias whose 
log to read instead")))));
         tools.add(toToolDef(toolDef(
                 "tui_get_errors",
                 "Returns structured error data from the Errors tab. "
@@ -529,6 +536,17 @@ class TuiToolRegistry {
                         "Control action: reset-stats, stop-routes, 
start-routes, pause, resume, restart, stop, kill, "
                                                    + "stop-all, or close")),
                 List.of("action"))));
+        tools.add(toToolDef(toolDef(
+                "tui_infra",
+                "Lists and controls infra services (brokers, databases started 
with camel infra run, e.g. "
+                             + "mosquitto, kafka, postgres). Actions: list — 
running services with alias, pid, "
+                             + "version and connection properties; log — 
newest lines of a service's log; "
+                             + "start, stop, restart — change state, only when 
the user asked.",
+                Map.of("action", propDef("string", "list, log, start, stop or 
restart"),
+                        "alias", propDef("string", "Service alias (required 
except for list)"),
+                        "limit", propDef("integer", "log: maximum lines to 
return (default 50)"),
+                        "filter", propDef("string", "log: case-insensitive 
substring filter")),
+                List.of("action"))));
         tools.add(toToolDef(toolDef(
                 "tui_open_project",
                 "Opens a project directory as a phantom integration (shown as 
Stopped in Overview). "
@@ -709,6 +727,7 @@ class TuiToolRegistry {
             result.put("selectedIntegration", name);
         }
         result.put("integrationCount", facade.getIntegrationCount());
+        addInfraContext(result);
         result.put("keystrokesVisible", facade.isKeystrokesVisible());
         result.put("captionVisible", facade.isCaptionVisible());
         addSelectionContext(result);
@@ -926,19 +945,22 @@ class TuiToolRegistry {
             result.put("selectedIntegration", selected);
         }
         result.put("integrationCount", facade.getIntegrationCount());
+        JsonArray infraArray = new JsonArray();
+        for (InfraInfo info : facade.liveInfraServices()) {
+            infraArray.add(InfraSupport.toJson(info));
+        }
+        result.put("infraServices", infraArray);
+        addInfraContext(result);
 
-        List<String> actions = facade.getActionLabels();
+        // Menu entries by label only: tui_action takes a label, which is 
sturdier (and far smaller on the wire) than
+        // the F2 + n x Down + Enter key sequences this used to spell out per 
entry.
         JsonArray actionsArray = new JsonArray();
-        for (int i = 0; i < actions.size(); i++) {
-            JsonObject action = new JsonObject();
-            action.put("index", i);
-            action.put("label", actions.get(i));
-            action.put("keys", actionKeys(i, actions.size()));
-            actionsArray.add(action);
+        for (String label : facade.getActionLabels()) {
+            if (!label.startsWith("─")) {
+                actionsArray.add(label);
+            }
         }
         result.put("actions", actionsArray);
-        result.put("actionsHint",
-                "Press F2 to open the Actions menu, then use Down arrow to 
reach the item by index, then Enter to select.");
 
         return Jsoner.serialize(result);
     }
@@ -1351,8 +1373,9 @@ class TuiToolRegistry {
             return "Action '" + action + "' executed";
         }
         return "Unknown or unsupported action: " + action
-               + ". Available: reset-stats, reset-screen, screenshot, 
show-keystrokes, "
-               + "tape-recording, doctor, caption, mcp-info, mcp-log, 
toggle-theme";
+               + ". Use a name (reset-stats, reset-screen, screenshot, 
show-keystrokes, "
+               + "tape-recording, doctor, caption, mcp-info, mcp-log, 
toggle-theme) "
+               + "or a menu label from tui_get_options actions";
     }
 
     private String callGetThemes() {
@@ -1399,10 +1422,108 @@ class TuiToolRegistry {
         }
         String filter = args.get("filter") instanceof String s ? s : null;
         String level = args.get("level") instanceof String s ? s : null;
+        if (args.get("infra") instanceof String alias && !alias.isBlank()) {
+            return infraLog(alias, limit, filter);
+        }
         JsonObject data = facade.getLogData(limit, filter, level);
         return Jsoner.serialize(data);
     }
 
+    private void addInfraContext(JsonObject result) {
+        result.put("infraCount", facade.liveInfraServices().size());
+        String selectedInfra = facade.getSelectedInfraAlias();
+        if (selectedInfra != null) {
+            result.put("selectedInfra", selectedInfra);
+        }
+    }
+
+    private String infraLog(String alias, int limit, String filter) {
+        InfraInfo info = facade.findInfra(alias);
+        if (info == null) {
+            return unknownInfra(alias);
+        }
+        try {
+            return Jsoner.serialize(facade.getInfraLogData(info, limit, 
filter));
+        } catch (Exception e) {
+            return "Error: cannot read log of infra service " + info.alias + 
": " + e.getMessage();
+        }
+    }
+
+    private String unknownInfra(String alias) {
+        List<String> running = facade.liveInfraServices().stream().map(i -> 
i.alias).toList();
+        return "Error: no running infra service named '" + alias + "'. 
Running: "
+               + (running.isEmpty() ? "none" : String.join(", ", running));
+    }
+
+    private String callInfra(Map<String, Object> args) {
+        String action = args.get("action") instanceof String s ? 
s.strip().toLowerCase(Locale.ROOT) : "";
+        if (action.isEmpty()) {
+            return "Error: action is required (list, log, start, stop, 
restart)";
+        }
+        if ("list".equals(action) || "ps".equals(action)) {
+            JsonArray arr = new JsonArray();
+            for (InfraInfo info : facade.liveInfraServices()) {
+                arr.add(InfraSupport.toJson(info));
+            }
+            JsonObject result = new JsonObject();
+            result.put("infraServices", arr);
+            result.put("count", arr.size());
+            return Jsoner.serialize(result);
+        }
+        String alias = args.get("alias") instanceof String s ? s.strip() : "";
+        if (alias.isEmpty()) {
+            return "Error: alias is required for " + action;
+        }
+        return switch (action) {
+            case "log" -> {
+                int limit = 50;
+                if (args.get("limit") instanceof Number n) {
+                    limit = Math.max(1, Math.min(1000, n.intValue()));
+                }
+                String filter = args.get("filter") instanceof String s ? s : 
null;
+                yield infraLog(alias, limit, filter);
+            }
+            case "start", "run" -> {
+                if (facade.findInfra(alias) != null) {
+                    yield "Infra service " + alias + " is already running";
+                }
+                yield startInfra(alias);
+            }
+            case "stop" -> {
+                InfraInfo info = facade.findInfra(alias);
+                if (info == null) {
+                    yield unknownInfra(alias);
+                }
+                yield facade.stopInfra(info)
+                        ? "Stopping infra service " + info.alias + " (pid " + 
info.pid + ")"
+                        : "Error: infra service " + info.alias + " (pid " + 
info.pid + ") is not running";
+            }
+            case "restart" -> {
+                InfraInfo info = facade.findInfra(alias);
+                if (info == null) {
+                    yield unknownInfra(alias);
+                }
+                facade.stopInfra(info);
+                yield startInfra(info.alias).replace("Starting", "Restarting");
+            }
+            default -> "Unknown action: " + action + ". Available: list, log, 
start, stop, restart";
+        };
+    }
+
+    private String startInfra(String alias) {
+        LaunchManager lm = launchManager;
+        if (lm == null) {
+            return "Error: launching is not available";
+        }
+        try {
+            lm.startInfra(alias);
+            return "Starting infra service " + alias + " (Docker/Podman 
required); "
+                   + "it appears in tui_infra list once ready, typically 
within a few seconds";
+        } catch (Exception e) {
+            return "Error: failed to start infra service " + alias + ": " + 
e.getMessage();
+        }
+    }
+
     private String callGetErrors() {
         JsonObject data = facade.getTableData("Errors");
         if (data == null) {
@@ -2352,15 +2473,6 @@ class TuiToolRegistry {
         return camelArgs;
     }
 
-    private static String actionKeys(int index, int totalActions) {
-        StringBuilder sb = new StringBuilder("F2");
-        for (int i = 0; i < index; i++) {
-            sb.append(",Down");
-        }
-        sb.append(",Enter");
-        return sb.toString();
-    }
-
     private static JsonArray toJsonArray(List<String> list) {
         JsonArray arr = new JsonArray();
         arr.addAll(list);
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java
index a3d7ff6cd254..cccffe7b1c35 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/ActionsPopupTest.java
@@ -20,6 +20,7 @@ import dev.tamboui.layout.Rect;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 
 /**
  * Tests for {@link ActionsPopup#listItemAt}, which maps a click to an entry 
in a single-line, bordered list popup. The
@@ -29,6 +30,27 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
  */
 class ActionsPopupTest {
 
+    @Test
+    void menuLabelsResolveToActionsIgnoringCaseEllipsisAndShortcutHints() {
+        assertEquals(ActionsPopup.Action.DOCTOR, 
ActionsPopup.actionForLabel("Run Doctor"));
+        assertEquals(ActionsPopup.Action.DOCTOR, 
ActionsPopup.actionForLabel("run doctor"));
+        assertEquals(ActionsPopup.Action.RUN_EXAMPLE, 
ActionsPopup.actionForLabel("Run an Example..."));
+        assertEquals(ActionsPopup.Action.GOTO_TAB, 
ActionsPopup.actionForLabel("Go to... (Shift+F2)"));
+        assertEquals(ActionsPopup.Action.SWITCH_INTEGRATION, 
ActionsPopup.actionForLabel("Switch Integration (F3)"));
+        assertEquals(ActionsPopup.Action.RUN_INFRA, 
ActionsPopup.actionForLabel("Run Dev/Infra Service..."));
+        assertEquals(ActionsPopup.Action.TAPE_RECORDING, 
ActionsPopup.actionForLabel("Stop Tape Recording"));
+        assertEquals(ActionsPopup.Action.SHOW_KEYSTROKES, 
ActionsPopup.actionForLabel("Hide Keystrokes"));
+    }
+
+    @Test
+    void submenuAndDividerLabelsHaveNoAction() {
+        assertNull(ActionsPopup.actionForLabel("Screen..."));
+        assertNull(ActionsPopup.actionForLabel("AI & MCP..."));
+        assertNull(ActionsPopup.actionForLabel(".."));
+        assertNull(ActionsPopup.actionForLabel("───"));
+        assertNull(ActionsPopup.actionForLabel(null));
+    }
+
     @Test
     void resolvesClicksToEntriesWhenNotScrolled() {
         Rect popup = new Rect(0, 0, 40, 12);
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
index 4fa6581b419b..5c146ccc7a03 100644
--- 
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
@@ -72,7 +72,7 @@ class AiPanelGenAiUsageTest {
         String rendered = TuiTestHelper.bufferToString(buffer);
         assertThat(rendered).contains("AI Usage");
         assertThat(rendered).contains("TUI:");
-        assertThat(rendered).contains("routes:");
+        assertThat(rendered).contains("integration:");
         assertThat(rendered).contains("[tui]");
         assertThat(rendered).contains("[route:orders]");
     }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
index c7d98e0950ee..648eb1f72c0e 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanelTest.java
@@ -647,6 +647,32 @@ class AiPanelTest {
                 "auto-scroll must keep the most recent response line visible, 
not clip it below the input bar");
     }
 
+    @Test
+    void renderMarksUserTurnWithGutterBarAndLeavesAnswerPlain() throws 
Exception {
+        AiPanel panel = new AiPanel();
+        RecordingLlmClient client = new RecordingLlmClient("There are three 
routes running.");
+        panel.setClientForTesting(client);
+        panel.open();
+
+        type(panel, "how many routes");
+        panel.handleKeyEvent(KeyEvent.ofKey(KeyCode.ENTER, KeyModifiers.NONE));
+        assertTrue(client.awaitAnswer(5, TimeUnit.SECONDS));
+        await().atMost(5, TimeUnit.SECONDS).untilAsserted(
+                () -> assertFalse(panel.isAgentThreadRunningForTesting(), 
"agent thread should finish within 5 seconds"));
+
+        Rect area = new Rect(0, 0, 80, 12);
+        Buffer buffer = Buffer.empty(area);
+        panel.render(Frame.forTesting(buffer), area);
+        String rendered = TuiTestHelper.bufferToString(buffer);
+
+        // the question is rendered as a blockquote whose prefix is the accent 
gutter bar
+        assertTrue(rendered.contains("\u258e how many routes"), rendered);
+        assertTrue(rendered.contains("There are three routes running."), 
rendered);
+        // the old bold role labels are gone: the bar marks the turn, the 
answer needs no label
+        assertFalse(rendered.contains("You:"), rendered);
+        assertFalse(rendered.contains("TUI:"), rendered);
+    }
+
     @Test
     void tabCompletesSingleMatchAndAppendsSpace() {
         AiPanel panel = new AiPanel();
@@ -922,6 +948,65 @@ class AiPanelTest {
         assertTrue(summary.contains("Last request: 3.4k tokens in 2000 ms"), 
summary);
     }
 
+    @Test
+    void tokensPerQuestionChartGroupsRoundTripsByQuestionNotByPause() {
+        AiPanel panel = new AiPanel();
+        panel.setClientForTesting(new RecordingLlmClient("ok"));
+        panel.open();
+        Instant now = Instant.now();
+        // two round trips for question 1 (tool call, then answer) and one for 
a follow-up typed seconds later
+        panel.recordUsageForTesting(usage(3000, now, 1));
+        panel.recordUsageForTesting(usage(3500, now.plusSeconds(4), 1));
+        panel.recordUsageForTesting(usage(4000, now.plusSeconds(10), 2));
+        panel.toggleStatsViewForTesting();
+
+        Rect area = new Rect(0, 0, 100, 24);
+        Buffer buffer = Buffer.empty(area);
+        panel.render(Frame.forTesting(buffer), area);
+
+        assertTrue(TuiTestHelper.bufferToString(buffer).contains("Tokens per 
question:"),
+                "two questions asked within seconds of each other must still 
give two bars");
+    }
+
+    @Test
+    void tokensPerQuestionChartIsHiddenForASingleQuestion() {
+        AiPanel panel = new AiPanel();
+        panel.setClientForTesting(new RecordingLlmClient("ok"));
+        panel.open();
+        Instant now = Instant.now();
+        // one question whose round trips were spread over more than the old 
30s pause threshold
+        panel.recordUsageForTesting(usage(3000, now, 1));
+        panel.recordUsageForTesting(usage(3500, now.plusSeconds(45), 1));
+        panel.toggleStatsViewForTesting();
+
+        Rect area = new Rect(0, 0, 100, 24);
+        Buffer buffer = Buffer.empty(area);
+        panel.render(Frame.forTesting(buffer), area);
+
+        assertFalse(TuiTestHelper.bufferToString(buffer).contains("Tokens per 
question:"));
+    }
+
+    private static AiPanel.AiUsageEntry usage(int tokens, Instant at, int 
question) {
+        return new AiPanel.AiUsageEntry(
+                "qwen3.6:35b-a3b", "ollama", tokens - 100, 100, tokens, 1000, 
"end_turn", at,
+                AiPanel.AiUsageSource.TUI, null, question);
+    }
+
+    @Test
+    void usageResetSlashCommandClearsTheStatistics() {
+        AiPanel panel = new AiPanel();
+        panel.setClientForTesting(new RecordingLlmClient("ok"));
+        panel.open();
+        panel.recordUsageForTesting(usage(3000, Instant.now(), 1));
+        assertTrue(panel.usageSummary().startsWith("Requests: 1"), 
panel.usageSummary());
+
+        panel.executeSlashCommandForTesting("/usage reset");
+
+        assertTrue(panel.usageSummary().startsWith("No AI usage yet"), 
panel.usageSummary());
+        assertTrue(panel.conversationForTesting().stream()
+                .anyMatch(e -> e.role() == AiRole.SYSTEM && 
e.text().contains("AI usage statistics reset")));
+    }
+
     @Test
     void retryWithoutAQuestionIsRefused() {
         AiPanel panel = new AiPanel();
@@ -1147,6 +1232,10 @@ class AiPanelTest {
             return "";
         }
 
+        @Override
+        public void resetUsage() {
+        }
+
         @Override
         public void copyLastResponse() {
         }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java
index c0759fc1bb69..def7e6d5cef1 100644
--- 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/AiSlashCommandRegistryTest.java
@@ -394,6 +394,10 @@ class AiSlashCommandRegistryTest {
             return "";
         }
 
+        @Override
+        public void resetUsage() {
+        }
+
         @Override
         public void copyLastResponse() {
         }
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraSupportTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraSupportTest.java
new file mode 100644
index 000000000000..825a0d30a6c1
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/InfraSupportTest.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class InfraSupportTest {
+
+    @TempDir
+    Path camelDir;
+
+    private static InfraInfo mosquitto() {
+        InfraInfo info = new InfraInfo();
+        info.alias = "mosquitto";
+        info.pid = "8903";
+        info.alive = true;
+        info.description = "Mosquitto is a message broker that implements MQTT 
protocol";
+        info.serviceVersion = "2.0.22";
+        info.properties.put("brokerUrl", "tcp://localhost:1883");
+        info.properties.put("description", info.description);
+        info.properties.put("serviceVersion", info.serviceVersion);
+        info.properties.put("getPort", 1883);
+        info.properties.put("port", 1883);
+        return info;
+    }
+
+    @Test
+    void findMatchesAliasCaseInsensitivelyOrPidAndSkipsVanished() {
+        InfraInfo mosquitto = mosquitto();
+        InfraInfo gone = new InfraInfo();
+        gone.alias = "kafka";
+        gone.pid = "1";
+        gone.vanishing = true;
+        List<InfraInfo> services = List.of(mosquitto, gone);
+
+        assertSame(mosquitto, InfraSupport.find(services, "Mosquitto"));
+        assertSame(mosquitto, InfraSupport.find(services, "8903"));
+        assertNull(InfraSupport.find(services, "kafka"));
+        assertNull(InfraSupport.find(services, ""));
+        assertNull(InfraSupport.find(null, "mosquitto"));
+    }
+
+    @Test
+    void toJsonExposesConnectionPropertiesWithoutDuplicatingTopLevelFields() {
+        JsonObject json = InfraSupport.toJson(mosquitto());
+
+        assertEquals("mosquitto", json.get("alias"));
+        assertEquals("8903", json.get("pid"));
+        assertEquals("2.0.22", json.get("version"));
+        assertEquals(true, json.get("alive"));
+        JsonObject props = (JsonObject) json.get("properties");
+        assertEquals("tcp://localhost:1883", props.get("brokerUrl"));
+        assertEquals(1883, props.get("port"));
+        assertFalse(props.containsKey("description"));
+        assertFalse(props.containsKey("serviceVersion"));
+        assertFalse(props.containsKey("getPort"));
+    }
+
+    @Test
+    void readLogTailReturnsNewestFirstHonoursLimitAndFilter() throws Exception 
{
+        InfraInfo info = mosquitto();
+        Files.writeString(InfraSupport.logFile(camelDir, info), """
+                1: mosquitto version 2.0.22 starting
+                2: Opening ipv4 listen socket on port 1883.
+
+                3: New connection from 192.168.64.1:32148 on port 1883.
+                4: New client connected as mqtt-source
+                """);
+
+        List<String> all = InfraSupport.readLogTail(camelDir, info, 50, null);
+        assertEquals(4, all.size(), "blank lines are skipped");
+        assertTrue(all.get(0).startsWith("4:"), "newest first");
+        assertTrue(all.get(3).startsWith("1:"));
+
+        assertEquals(2, InfraSupport.readLogTail(camelDir, info, 2, 
null).size());
+
+        List<String> filtered = InfraSupport.readLogTail(camelDir, info, 50, 
"PORT 1883");
+        assertEquals(2, filtered.size());
+        assertTrue(filtered.get(0).startsWith("3:"));
+    }
+
+    @Test
+    void readLogTailIsEmptyWhenServiceHasNoLogYet() throws Exception {
+        assertTrue(InfraSupport.readLogTail(camelDir, mosquitto(), 50, 
null).isEmpty());
+    }
+
+    @Test
+    void stopRemovesPidFileAndReportsWhenNoProcessIsRunning() throws Exception 
{
+        InfraInfo info = mosquitto();
+        // a pid no live process can have, so the TUI never terminates a real 
process from this test
+        info.pid = String.valueOf(Long.MAX_VALUE);
+        Path pidFile = camelDir.resolve("infra-mosquitto-" + info.pid + 
".json");
+        Files.writeString(pidFile, "{}");
+
+        assertFalse(InfraSupport.stop(camelDir, info));
+        assertFalse(Files.exists(pidFile), "pid file is removed so the TUI 
stops listing the service");
+    }
+}
diff --git 
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabLogDataJsonTest.java
 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabLogDataJsonTest.java
new file mode 100644
index 000000000000..57baaf50ac2b
--- /dev/null
+++ 
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/test/java/org/apache/camel/dsl/jbang/core/commands/tui/LogTabLogDataJsonTest.java
@@ -0,0 +1,119 @@
+/*
+ * 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.util.ArrayList;
+import java.util.List;
+
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class LogTabLogDataJsonTest {
+
+    private static List<LogEntry> sampleLog() {
+        List<LogEntry> entries = new ArrayList<>();
+        entries.add(LogTab.parseLogLine(
+                "2026-09-08 18:34:00.100  INFO 8919 --- [main] 
org.apache.camel.main.MainSupport : Camel started"));
+        entries.add(LogTab.parseLogLine(
+                "2026-09-08 18:34:10.429  WARN 8919 --- [mqtt-source] 
o.a.c.c.paho.mqtt5.PahoMqtt5Consumer : Error processing exchange"));
+        entries.add(LogTab.parseLogLine(
+                "org.apache.camel.RuntimeCamelException: 
org.apache.camel.TypeConversionException: byte[] to JsonNode"));
+        entries.add(
+                LogTab.parseLogLine("        at 
org.apache.camel.language.jq.JqExpression.evaluate(JqExpression.java:182)"));
+        entries.add(
+                LogTab.parseLogLine("Caused by: 
com.fasterxml.jackson.core.JsonParseException: Unrecognized token 'hello'"));
+        entries.add(LogTab.parseLogLine("        ... 24 more"));
+        entries.add(LogTab.parseLogLine(
+                "2026-09-08 18:35:08.214  INFO 8919 --- [mqtt-source] 
mqtt.camel.yaml:19 : Warm temperature at 23.5"));
+        return entries;
+    }
+
+    private static JsonArray lines(JsonObject data) {
+        return (JsonArray) data.get("lines");
+    }
+
+    @Test
+    void stackTraceLinesFoldIntoTheRecordTheyBelongTo() {
+        JsonObject data = LogTab.buildLogDataJson(sampleLog(), 50, null, null);
+        JsonArray rows = lines(data);
+
+        assertEquals(3, rows.size(), "seven raw lines are three log records");
+        assertEquals(7, data.get("totalLines"));
+        assertEquals(3, data.get("returnedLines"));
+
+        JsonObject newest = (JsonObject) rows.get(0);
+        assertEquals("Warm temperature at 23.5", newest.get("message"));
+        assertFalse(newest.containsKey("detail"));
+
+        JsonObject warn = (JsonObject) rows.get(1);
+        assertEquals("WARN", warn.get("level"));
+        String detail = (String) warn.get("detail");
+        
assertTrue(detail.startsWith("org.apache.camel.RuntimeCamelException"), detail);
+        assertTrue(detail.contains("Caused by: 
com.fasterxml.jackson.core.JsonParseException"), detail);
+        assertTrue(detail.endsWith("... 24 more"), detail);
+    }
+
+    @Test
+    void filterMatchesInsideTheFoldedDetail() {
+        JsonArray rows = lines(LogTab.buildLogDataJson(sampleLog(), 50, 
"unrecognized token", null));
+
+        assertEquals(1, rows.size());
+        assertEquals("WARN", ((JsonObject) rows.get(0)).get("level"));
+    }
+
+    @Test
+    void levelFilterAppliesToTheRecordNotToItsContinuationLines() {
+        JsonArray rows = lines(LogTab.buildLogDataJson(sampleLog(), 50, null, 
"INFO"));
+
+        assertEquals(2, rows.size(), "the trace lines no longer count as INFO 
records");
+    }
+
+    @Test
+    void longTracesAreCappedWithACount() {
+        List<LogEntry> entries = new ArrayList<>();
+        entries.add(LogTab.parseLogLine(
+                "2026-09-08 18:34:10.429  WARN 8919 --- [main] o.a.c.Foo : 
boom"));
+        for (int i = 0; i < LogTab.MAX_DETAIL_LINES + 5; i++) {
+            entries.add(LogTab.parseLogLine("        at frame" + i + 
"(Foo.java:" + i + ")"));
+        }
+
+        JsonObject row = (JsonObject) lines(LogTab.buildLogDataJson(entries, 
50, null, null)).get(0);
+        String detail = (String) row.get("detail");
+
+        assertTrue(detail.contains("frame" + (LogTab.MAX_DETAIL_LINES - 1)), 
detail);
+        assertFalse(detail.contains("frame" + LogTab.MAX_DETAIL_LINES + "("), 
detail);
+        assertTrue(detail.endsWith("... 5 more lines"), detail);
+    }
+
+    @Test
+    void continuationLinesWithoutAHeadStayStandalone() {
+        List<LogEntry> entries = new ArrayList<>();
+        entries.add(LogTab.parseLogLine("plain line without a timestamp"));
+        entries.add(LogTab.parseLogLine(
+                "2026-09-08 18:34:00.100  INFO 8919 --- [main] o.a.c.Foo : 
started"));
+
+        JsonArray rows = lines(LogTab.buildLogDataJson(entries, 50, null, 
null));
+
+        assertEquals(2, rows.size());
+        assertEquals("plain line without a timestamp", ((JsonObject) 
rows.get(1)).get("message"));
+    }
+}

Reply via email to