You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/06/29 16:59:40 UTC

(camel) branch fix/CAMEL-23855 created (now fbf71a6136e6)

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

davsclaus pushed a change to branch fix/CAMEL-23855
in repository https://gitbox.apache.org/repos/asf/camel.git


      at fbf71a6136e6 CAMEL-23855: camel-jbang - Show AI panel always and fix shell scrollback

This branch includes the following new commits:

     new d6ce487c08f5 CAMEL-23855: Add F8 AI prompt panel to TUI
     new 0afb7bdc8f4d CAMEL-23855: camel-jbang - AI panel improvements and AI Log popup
     new 43641866803c CAMEL-23855: camel-jbang - AI panel scroll fix and dimmed elapsed time
     new db0691c19398 CAMEL-23855: camel-jbang - AI panel space optimizations
     new 92b4f3c20c1e CAMEL-23855: camel-jbang - Add missing MCP tools to AI assistant
     new fbf71a6136e6 CAMEL-23855: camel-jbang - Show AI panel always and fix shell scrollback

The 6 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.



(camel) 02/06: CAMEL-23855: camel-jbang - AI panel improvements and AI Log popup

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-23855
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 0afb7bdc8f4d88519c1056f5308f3f95a3da6bea
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Jun 29 15:43:30 2026 +0200

    CAMEL-23855: camel-jbang - AI panel improvements and AI Log popup
    
    - Add markdown rendering with hard breaks for LLM responses
    - Add auto-scroll to bottom on new question/response
    - Add scrollbar when conversation overflows
    - Add dimmed placeholder text
    - Add AI Log popup (Actions menu) showing tool calls, args, results
    
    Co-Authored-By: Claude <no...@anthropic.com>
    Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
 .../dsl/jbang/core/commands/tui/ActionsPopup.java  |  33 ++-
 .../dsl/jbang/core/commands/tui/AiLogPopup.java    | 222 +++++++++++++++++++++
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 120 ++++++++++-
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |   1 +
 4 files changed, 365 insertions(+), 11 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 6f57ad1ec389..d90a0efa92eb 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
@@ -87,11 +87,12 @@ class ActionsPopup {
         SETUP_AI,
         MCP_INFO,
         MCP_LOG,
+        AI_LOG,
         SHELL
     }
 
     private static final int[] GROUP_SIZES = { 5, 4, 5 };
-    private static final int MCP_GROUP_SIZE = 3;
+    private static final int MCP_GROUP_SIZE = 4;
     private static final int SHELL_GROUP_SIZE = 1;
 
     private final Supplier<Set<String>> runningNames;
@@ -148,6 +149,7 @@ class ActionsPopup {
     private String selectedFolder;
 
     private final McpLogPopup mcpLogPopup = new McpLogPopup();
+    private final AiLogPopup aiLogPopup = new AiLogPopup();
 
     private final DoctorPopup doctorPopup = new DoctorPopup();
     private final SendMessagePopup sendMessagePopup = new SendMessagePopup();
@@ -218,6 +220,10 @@ class ActionsPopup {
         mcpLogPopup.setActivityLog(activityLog);
     }
 
+    void setAiActivityLog(Supplier<List<AiPanel.LogEntry>> activityLog) {
+        aiLogPopup.setActivityLog(activityLog);
+    }
+
     private int visualActionCount() {
         int total = 0;
         for (int gs : GROUP_SIZES) {
@@ -273,7 +279,7 @@ class ActionsPopup {
                 Action.SHOW_KEYSTROKES));
         if (mcpEnabled) {
             flat.add(null);
-            flat.addAll(List.of(Action.SETUP_AI, Action.MCP_INFO, Action.MCP_LOG));
+            flat.addAll(List.of(Action.SETUP_AI, Action.MCP_INFO, Action.MCP_LOG, Action.AI_LOG));
         }
         flat.add(null);
         flat.add(Action.SHELL);
@@ -298,7 +304,7 @@ class ActionsPopup {
         return showActionsMenu || showExampleBrowser || showFolderInput || runOptionsForm.isVisible()
                 || showDocPicker || showDocViewer
                 || showInfraBrowser || showInfraPortDialog
-                || mcpLogPopup.isVisible() || doctorPopup.isVisible()
+                || mcpLogPopup.isVisible() || aiLogPopup.isVisible() || doctorPopup.isVisible()
                 || sendMessagePopup.isVisible() || stopAllPopup.isVisible() || captionOverlay.isInlineMode();
     }
 
@@ -366,6 +372,7 @@ class ActionsPopup {
             labels.add("Setup AI...");
             labels.add("MCP Info");
             labels.add("MCP Log");
+            labels.add("AI Log");
         }
         labels.add("───");
         labels.add("Shell");
@@ -387,6 +394,7 @@ class ActionsPopup {
         showInfraBrowser = false;
         showInfraPortDialog = false;
         mcpLogPopup.close();
+        aiLogPopup.close();
         doctorPopup.close();
         sendMessagePopup.close();
         stopAllPopup.close();
@@ -419,6 +427,9 @@ class ActionsPopup {
         if (mcpLogPopup.handleKeyEvent(ke)) {
             return true;
         }
+        if (aiLogPopup.handleKeyEvent(ke)) {
+            return true;
+        }
         if (showDocViewer) {
             if (ke.isCancel()) {
                 showDocViewer = false;
@@ -608,6 +619,9 @@ class ActionsPopup {
                     } else if (action == Action.MCP_LOG) {
                         showActionsMenu = false;
                         openMcpLog();
+                    } else if (action == Action.AI_LOG) {
+                        showActionsMenu = false;
+                        openAiLog();
                     } else if (action == Action.SEND_MESSAGE) {
                         showActionsMenu = false;
                         openSendMessage();
@@ -664,6 +678,9 @@ class ActionsPopup {
         if (mcpLogPopup.isVisible()) {
             mcpLogPopup.render(frame, area);
         }
+        if (aiLogPopup.isVisible()) {
+            aiLogPopup.render(frame, area);
+        }
         if (doctorPopup.isVisible()) {
             doctorPopup.render(frame, area);
         }
@@ -695,6 +712,10 @@ class ActionsPopup {
             doctorPopup.renderFooter(spans);
             return;
         }
+        if (aiLogPopup.isVisible()) {
+            aiLogPopup.renderFooter(spans);
+            return;
+        }
         if (mcpLogPopup.isVisible()) {
             mcpLogPopup.renderFooter(spans);
             return;
@@ -809,6 +830,7 @@ class ActionsPopup {
             items.add(ListItem.from("  🧠 Setup AI..."));
             items.add(ListItem.from("  🤖 MCP Info"));
             items.add(ListItem.from("  📋 MCP Log"));
+            items.add(ListItem.from("  💬 AI Log"));
         }
         // Group 5: Shell
         items.add(ListItem.from(divider).style(Style.EMPTY.dim()));
@@ -1245,6 +1267,10 @@ class ActionsPopup {
         mcpLogPopup.open();
     }
 
+    private void openAiLog() {
+        aiLogPopup.open();
+    }
+
     // ---- Folder Input ----
 
     private void openFolderInput() {
@@ -2171,6 +2197,7 @@ class ActionsPopup {
             case SETUP_AI -> openSetupAI();
             case MCP_INFO -> openMcpInfo();
             case MCP_LOG -> openMcpLog();
+            case AI_LOG -> openAiLog();
             default -> {
                 return false;
             }
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopup.java
new file mode 100644
index 000000000000..147c75a51a69
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiLogPopup.java
@@ -0,0 +1,222 @@
+/*
+ * 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 java.util.function.Supplier;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.Clear;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.list.ListItem;
+import dev.tamboui.widgets.list.ListState;
+import dev.tamboui.widgets.list.ListWidget;
+import dev.tamboui.widgets.list.ScrollMode;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.util.json.Jsoner;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hint;
+import static org.apache.camel.dsl.jbang.core.commands.tui.MonitorContext.hintLast;
+
+class AiLogPopup {
+
+    private boolean visible;
+    private Supplier<List<AiPanel.LogEntry>> activityLog;
+    private List<AiPanel.LogEntry> entries;
+    private int selected;
+    private int detailScroll;
+
+    void setActivityLog(Supplier<List<AiPanel.LogEntry>> activityLog) {
+        this.activityLog = activityLog;
+    }
+
+    boolean isVisible() {
+        return visible;
+    }
+
+    void open() {
+        entries = activityLog != null ? activityLog.get() : List.of();
+        selected = entries.isEmpty() ? 0 : entries.size() - 1;
+        detailScroll = 0;
+        visible = true;
+    }
+
+    void close() {
+        visible = false;
+    }
+
+    boolean handleKeyEvent(KeyEvent ke) {
+        if (!visible) {
+            return false;
+        }
+        if (ke.isCancel()) {
+            visible = false;
+        } else if (ke.isUp() || ke.isChar('k')) {
+            if (entries != null && !entries.isEmpty()) {
+                selected = Math.max(0, selected - 1);
+                detailScroll = 0;
+            }
+        } else if (ke.isDown() || ke.isChar('j')) {
+            if (entries != null && !entries.isEmpty()) {
+                selected = Math.min(entries.size() - 1, selected + 1);
+                detailScroll = 0;
+            }
+        } else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
+            detailScroll = Math.max(0, detailScroll - 5);
+        } else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+            detailScroll += 5;
+        }
+        return true;
+    }
+
+    void render(Frame frame, Rect area) {
+        Rect popup = new Rect(area.left() + 2, area.top() + 1, area.width() - 4, area.height() - 2);
+        frame.renderWidget(Clear.INSTANCE, popup);
+
+        if (entries == null || entries.isEmpty()) {
+            Block block = Block.builder()
+                    .borderType(BorderType.ROUNDED).borders(Borders.ALL)
+                    .title(" AI Log ")
+                    .titleBottom(Title.from(Line.from(
+                            Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" back "))))
+                    .build();
+            frame.renderWidget(block, popup);
+            Rect inner = block.inner(popup);
+            frame.renderWidget(Paragraph.from(Line.from(
+                    Span.styled("No AI activity yet. Open the AI panel (F8) and ask a question.", Style.EMPTY.dim()))),
+                    inner);
+            return;
+        }
+
+        int splitY = popup.top() + Math.max(3, (popup.height() * 2) / 5);
+        Rect masterArea = new Rect(popup.left(), popup.top(), popup.width(), splitY - popup.top());
+        Rect detailArea = new Rect(popup.left(), splitY, popup.width(), popup.bottom() - splitY);
+
+        renderMaster(frame, masterArea);
+        renderDetail(frame, detailArea);
+    }
+
+    void renderFooter(List<Span> spans) {
+        hint(spans, "↑↓", "select");
+        hint(spans, "PgUp/Dn", "scroll detail");
+        hintLast(spans, "Esc", "back");
+    }
+
+    private void renderMaster(Frame frame, Rect area) {
+        List<ListItem> items = new ArrayList<>();
+        for (AiPanel.LogEntry entry : entries) {
+            Style levelStyle = switch (entry.level()) {
+                case QUESTION -> Style.EMPTY.fg(Color.CYAN);
+                case TOOL -> Style.EMPTY.fg(Color.YELLOW);
+                case RESULT -> Style.EMPTY.fg(Color.GREEN);
+                case RESPONSE -> Style.EMPTY.fg(Color.MAGENTA);
+                case ERROR -> Style.EMPTY.fg(Color.LIGHT_RED);
+            };
+            String levelTag = switch (entry.level()) {
+                case QUESTION -> " ASK      ";
+                case TOOL -> " TOOL     ";
+                case RESULT -> " RESULT   ";
+                case RESPONSE -> " RESPONSE ";
+                case ERROR -> " ERROR    ";
+            };
+            items.add(ListItem.from(Line.from(
+                    Span.styled(entry.timestamp(), Style.EMPTY.dim()),
+                    Span.styled(levelTag, levelStyle),
+                    Span.raw(entry.message()))));
+        }
+
+        ListState masterState = new ListState();
+        masterState.select(selected);
+        ListWidget list = ListWidget.builder()
+                .items(items.toArray(ListItem[]::new))
+                .highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
+                .highlightSymbol("▸ ")
+                .scrollMode(ScrollMode.AUTO_SCROLL)
+                .block(Block.builder()
+                        .borderType(BorderType.ROUNDED).borders(Borders.ALL)
+                        .title(" AI Log ")
+                        .build())
+                .build();
+        frame.renderStatefulWidget(list, area, masterState);
+    }
+
+    private void renderDetail(Frame frame, Rect area) {
+        AiPanel.LogEntry entry = entries.get(selected);
+        List<Line> lines = new ArrayList<>();
+
+        String detail = entry.detail();
+        if (detail != null && !detail.isBlank()) {
+            if (entry.level() == AiPanel.LogLevel.TOOL || entry.level() == AiPanel.LogLevel.RESULT) {
+                lines.add(Line.from(Span.styled(
+                        entry.level() == AiPanel.LogLevel.TOOL ? "▶ Arguments" : "◀ Result",
+                        Style.EMPTY.fg(entry.level() == AiPanel.LogLevel.TOOL ? Color.YELLOW : Color.GREEN).bold())));
+                addJsonLines(lines, detail);
+            } else {
+                lines.add(Line.from(Span.styled("▶ Content",
+                        Style.EMPTY.fg(Color.CYAN).bold())));
+                for (String line : detail.split("\n", -1)) {
+                    lines.add(Line.from(Span.styled("  " + line, Style.EMPTY.dim())));
+                }
+            }
+        } else {
+            lines.add(Line.from(Span.styled("(no detail data)", Style.EMPTY.dim())));
+        }
+
+        Block detailBlock = Block.builder()
+                .borderType(BorderType.ROUNDED).borders(Borders.ALL)
+                .title(" Detail ")
+                .build();
+        frame.renderWidget(detailBlock, area);
+        Rect inner = detailBlock.inner(area);
+
+        int visibleLines = inner.height();
+        int totalLines = lines.size();
+        int clampedScroll = Math.min(detailScroll, Math.max(0, totalLines - visibleLines));
+        int end = Math.min(clampedScroll + visibleLines, totalLines);
+        if (clampedScroll < end) {
+            List<Line> visible = lines.subList(clampedScroll, end);
+            frame.renderWidget(
+                    Paragraph.builder().text(Text.from(visible.toArray(Line[]::new))).build(),
+                    inner);
+        }
+    }
+
+    private static void addJsonLines(List<Line> lines, String json) {
+        try {
+            String pretty = Jsoner.prettyPrint(json, 2);
+            for (String line : pretty.split("\n", -1)) {
+                lines.add(Line.from(Span.styled("  " + line, Style.EMPTY.dim())));
+            }
+        } catch (Exception e) {
+            for (String line : json.split("\n", -1)) {
+                lines.add(Line.from(Span.styled("  " + line, Style.EMPTY.dim())));
+            }
+        }
+    }
+}
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 52d2257bd676..cb38d8dd4cf3 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
@@ -16,6 +16,9 @@
  */
 package org.apache.camel.dsl.jbang.core.commands.tui;
 
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -47,6 +50,20 @@ class AiPanel {
 
     private static final int[] SPLIT_PERCENTS = { 25, 50, 75, 100 };
     private static final int MAX_ITERATIONS = 10;
+    private static final int MAX_LOG_ENTRIES = 200;
+    private static final DateTimeFormatter TIME_FMT
+            = DateTimeFormatter.ofPattern("HH:mm:ss").withZone(ZoneId.systemDefault());
+
+    enum LogLevel {
+        QUESTION,
+        TOOL,
+        RESULT,
+        RESPONSE,
+        ERROR
+    }
+
+    record LogEntry(String timestamp, LogLevel level, String message, String detail) {
+    }
 
     private boolean visible;
     private int splitIndex = 1; // default 50%
@@ -69,6 +86,9 @@ class AiPanel {
     private volatile Thread agentThread;
     private String initError;
 
+    // Activity log for AI Log popup
+    private final List<LogEntry> activityLog = new ArrayList<>();
+
     record ConversationEntry(String role, String text) {
     }
 
@@ -76,6 +96,17 @@ class AiPanel {
         this.ctx = ctx;
     }
 
+    synchronized List<LogEntry> getActivityLog() {
+        return new ArrayList<>(activityLog);
+    }
+
+    private synchronized void log(LogLevel level, String message, String detail) {
+        activityLog.add(new LogEntry(TIME_FMT.format(Instant.now()), level, message, detail));
+        if (activityLog.size() > MAX_LOG_ENTRIES) {
+            activityLog.remove(0);
+        }
+    }
+
     boolean isOpen() {
         return visible;
     }
@@ -209,6 +240,7 @@ class AiPanel {
         scrollOffset = 0;
 
         conversation.add(new ConversationEntry("user", question));
+        log(LogLevel.QUESTION, "Question", question);
         thinking.set(true);
 
         // rebuild tools if target process changed
@@ -247,7 +279,9 @@ class AiPanel {
 
             LlmClient.ChatResponse response = client.chatWithTools(systemPrompt, messages, tools);
             if (response == null) {
-                conversation.add(new ConversationEntry("error", "No response from LLM"));
+                String err = "No response from LLM";
+                conversation.add(new ConversationEntry("error", err));
+                log(LogLevel.ERROR, "Error", err);
                 return;
             }
 
@@ -255,7 +289,9 @@ class AiPanel {
             if ("error".equals(response.stopReason())
                     && (response.toolCalls() == null || response.toolCalls().isEmpty())
                     && response.text() == null) {
-                conversation.add(new ConversationEntry("error", "LLM request failed. Check API key and endpoint."));
+                String err = "LLM request failed. Check API key and endpoint.";
+                conversation.add(new ConversationEntry("error", err));
+                log(LogLevel.ERROR, "Error", err);
                 return;
             }
 
@@ -267,7 +303,9 @@ class AiPanel {
                     if (Thread.interrupted()) {
                         throw new InterruptedException();
                     }
+                    log(LogLevel.TOOL, toolCall.name(), toolCall.arguments().toJson());
                     String result = askTools.executeTool(toolCall.name(), toolCall.arguments());
+                    log(LogLevel.RESULT, toolCall.name(), result);
                     results.add(new LlmClient.ToolResult(toolCall.id(), result));
                 }
                 messages.add(LlmClient.Message.toolResults(results));
@@ -275,9 +313,13 @@ class AiPanel {
                 String text = response.text();
                 if (text != null && !text.isBlank()) {
                     conversation.add(new ConversationEntry("assistant", text));
+                    log(LogLevel.RESPONSE, "Response", text);
                 } else {
-                    conversation.add(new ConversationEntry("error", "Empty response from LLM."));
+                    String err = "Empty response from LLM.";
+                    conversation.add(new ConversationEntry("error", err));
+                    log(LogLevel.ERROR, "Error", err);
                 }
+                scrollOffset = 0;
                 messages.add(LlmClient.Message.assistantWithToolCalls(text, List.of()));
                 return;
             }
@@ -326,13 +368,16 @@ class AiPanel {
         if (initError != null) {
             md.append("**Error:** ").append(initError).append("\n\n");
         } else if (conversation.isEmpty() && !thinking.get()) {
-            md.append("*Ask a question about your Camel application...*\n");
+            frame.renderWidget(
+                    Paragraph.from(Line.from(Span.styled("Ask a question about your Camel application...", Style.EMPTY.dim()))),
+                    area);
+            return;
         }
 
         for (ConversationEntry entry : conversation) {
             switch (entry.role()) {
                 case "user" -> md.append("**You:** ").append(entry.text()).append("\n\n");
-                case "assistant" -> md.append(entry.text()).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("*").append(entry.text()).append("*\n\n");
                 default -> {
@@ -345,11 +390,44 @@ class AiPanel {
             md.append("*🤔 thinking").append(".".repeat((int) dots + 1)).append("*\n");
         }
 
+        String source = md.toString();
+
+        // Estimate total rendered lines (accounting for word wrap)
+        int contentWidth = Math.max(1, area.width());
+        int estimatedLines = 0;
+        for (String l : source.split("\n", -1)) {
+            estimatedLines += Math.max(1, (l.length() / contentWidth) + 1);
+        }
+
+        boolean overflow = estimatedLines > area.height();
+        Rect contentArea = area;
+        Rect scrollbarArea = null;
+        if (overflow) {
+            List<Rect> hParts = Layout.horizontal()
+                    .constraints(Constraint.fill(), Constraint.length(1))
+                    .split(area);
+            contentArea = hParts.get(0);
+            scrollbarArea = hParts.get(1);
+        }
+
+        // scrollOffset=0 means auto-scroll to bottom (most recent content visible)
+        // scrollOffset>0 means user scrolled up by that many lines
+        int scroll;
+        if (scrollOffset == 0) {
+            scroll = estimatedLines;
+        } else {
+            scroll = Math.max(0, estimatedLines - contentArea.height() - scrollOffset);
+        }
+
         MarkdownView view = MarkdownView.builder()
-                .source(md.toString())
-                .scroll(scrollOffset)
+                .source(source)
+                .scroll(scroll)
                 .build();
-        frame.renderWidget(view, area);
+        frame.renderWidget(view, contentArea);
+
+        if (overflow && scrollbarArea != null) {
+            renderScrollbar(frame, scrollbarArea, estimatedLines, contentArea.height(), scroll);
+        }
     }
 
     private void renderInput(Frame frame, Rect area) {
@@ -403,4 +481,30 @@ class AiPanel {
         }
     }
 
+    private void renderScrollbar(Frame frame, Rect area, int totalLines, int visibleHeight, int scroll) {
+        int thumbSize = Math.max(1, visibleHeight * visibleHeight / Math.max(1, totalLines));
+        int maxScroll = Math.max(1, totalLines - visibleHeight);
+        int thumbPos = (int) ((long) Math.min(scroll, maxScroll) * (visibleHeight - thumbSize) / maxScroll);
+
+        List<Line> lines = new ArrayList<>();
+        for (int i = 0; i < area.height(); i++) {
+            if (i >= thumbPos && i < thumbPos + thumbSize) {
+                lines.add(Line.from(Span.styled("▐", Style.EMPTY.fg(Color.CYAN))));
+            } else {
+                lines.add(Line.from(Span.styled("│", Style.EMPTY.dim())));
+            }
+        }
+        frame.renderWidget(Paragraph.from(new dev.tamboui.text.Text(lines, dev.tamboui.layout.Alignment.LEFT)), area);
+    }
+
+    private static String toHardBreaks(String text) {
+        if (text == null) {
+            return "";
+        }
+        // Convert single newlines to markdown hard breaks (two trailing spaces + newline)
+        // so the LLM's line-by-line formatting is preserved in MarkdownView.
+        // Double newlines (paragraph breaks) are left as-is.
+        return text.replaceAll("(?<!\n)\n(?!\n)", "  \n");
+    }
+
 }
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 1be046368239..94b4472a1e7c 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -324,6 +324,7 @@ public class CamelMonitor extends CamelCommand {
             try {
                 mcpServer.start();
                 actionsPopup.setMcpEnabled(true, mcpPort, mcpServer::getConnectedClient, mcpServer::getActivityLog);
+                actionsPopup.setAiActivityLog(aiPanel::getActivityLog);
                 mcpJsonFile = writeMcpJson(mcpPort);
             } catch (java.net.BindException e) {
                 System.err.println("MCP server failed to start: port " + mcpPort + " is already in use.");


(camel) 03/06: CAMEL-23855: camel-jbang - AI panel scroll fix and dimmed elapsed time

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-23855
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 43641866803c5d753a6499c681c8a627d1b865fa
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Jun 29 17:52:47 2026 +0200

    CAMEL-23855: camel-jbang - AI panel scroll fix and dimmed elapsed time
    
    Co-Authored-By: Claude <no...@anthropic.com>
    Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 64 +++++++++++++++++-----
 1 file changed, 50 insertions(+), 14 deletions(-)

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 cb38d8dd4cf3..e083564269f6 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
@@ -85,11 +85,15 @@ class AiPanel {
     private final AtomicBoolean thinking = new AtomicBoolean();
     private volatile Thread agentThread;
     private String initError;
+    private long thinkingStartTime;
 
     // Activity log for AI Log popup
     private final List<LogEntry> activityLog = new ArrayList<>();
 
-    record ConversationEntry(String role, String text) {
+    record ConversationEntry(String role, String text, long elapsedSeconds) {
+        ConversationEntry(String role, String text) {
+            this(role, text, -1);
+        }
     }
 
     void setContext(MonitorContext ctx) {
@@ -241,6 +245,7 @@ class AiPanel {
 
         conversation.add(new ConversationEntry("user", question));
         log(LogLevel.QUESTION, "Question", question);
+        thinkingStartTime = System.currentTimeMillis();
         thinking.set(true);
 
         // rebuild tools if target process changed
@@ -312,8 +317,9 @@ class AiPanel {
             } else {
                 String text = response.text();
                 if (text != null && !text.isBlank()) {
-                    conversation.add(new ConversationEntry("assistant", text));
-                    log(LogLevel.RESPONSE, "Response", text);
+                    long elapsed = (System.currentTimeMillis() - thinkingStartTime) / 1000;
+                    conversation.add(new ConversationEntry("assistant", text, elapsed));
+                    log(LogLevel.RESPONSE, "Response (" + elapsed + "s)", text);
                 } else {
                     String err = "Empty response from LLM.";
                     conversation.add(new ConversationEntry("error", err));
@@ -386,38 +392,62 @@ class AiPanel {
         }
 
         if (thinking.get()) {
+            long elapsed = (System.currentTimeMillis() - thinkingStartTime) / 1000;
             long dots = (System.currentTimeMillis() / 500) % 4;
-            md.append("*🤔 thinking").append(".".repeat((int) dots + 1)).append("*\n");
+            md.append("*🤔 thinking");
+            if (elapsed > 0) {
+                md.append(" (").append(elapsed).append("s)");
+            }
+            md.append(".".repeat((int) dots + 1)).append("*\n");
+        }
+
+        // Show elapsed time as a dimmed line below the markdown when at the bottom
+        long lastElapsed = -1;
+        if (!thinking.get() && !conversation.isEmpty()) {
+            ConversationEntry last = conversation.get(conversation.size() - 1);
+            if ("assistant".equals(last.role()) && last.elapsedSeconds() >= 0) {
+                lastElapsed = last.elapsedSeconds();
+            }
+        }
+
+        // Reserve 1 row for dimmed elapsed time when we have one to show
+        Rect mdArea = area;
+        Rect elapsedArea = null;
+        if (lastElapsed >= 0 && area.height() > 2) {
+            List<Rect> vParts = Layout.vertical()
+                    .constraints(Constraint.fill(), Constraint.length(1))
+                    .split(area);
+            mdArea = vParts.get(0);
+            elapsedArea = vParts.get(1);
         }
 
         String source = md.toString();
 
         // Estimate total rendered lines (accounting for word wrap)
-        int contentWidth = Math.max(1, area.width());
+        int contentWidth = Math.max(1, mdArea.width());
         int estimatedLines = 0;
         for (String l : source.split("\n", -1)) {
             estimatedLines += Math.max(1, (l.length() / contentWidth) + 1);
         }
 
-        boolean overflow = estimatedLines > area.height();
-        Rect contentArea = area;
+        boolean overflow = estimatedLines > mdArea.height();
+        Rect contentArea = mdArea;
         Rect scrollbarArea = null;
         if (overflow) {
             List<Rect> hParts = Layout.horizontal()
                     .constraints(Constraint.fill(), Constraint.length(1))
-                    .split(area);
+                    .split(mdArea);
             contentArea = hParts.get(0);
             scrollbarArea = hParts.get(1);
         }
 
         // scrollOffset=0 means auto-scroll to bottom (most recent content visible)
         // scrollOffset>0 means user scrolled up by that many lines
-        int scroll;
-        if (scrollOffset == 0) {
-            scroll = estimatedLines;
-        } else {
-            scroll = Math.max(0, estimatedLines - contentArea.height() - scrollOffset);
-        }
+        // Clamp so PgDn always has immediate effect after scrolling past the top
+        int maxScrollOffset = Math.max(0, estimatedLines - contentArea.height());
+        scrollOffset = Math.min(scrollOffset, maxScrollOffset);
+
+        int scroll = Math.max(0, maxScrollOffset - scrollOffset);
 
         MarkdownView view = MarkdownView.builder()
                 .source(source)
@@ -428,6 +458,12 @@ class AiPanel {
         if (overflow && scrollbarArea != null) {
             renderScrollbar(frame, scrollbarArea, estimatedLines, contentArea.height(), scroll);
         }
+
+        if (elapsedArea != null && lastElapsed >= 0) {
+            frame.renderWidget(
+                    Paragraph.from(Line.from(Span.styled("(" + lastElapsed + "s)", Style.EMPTY.dim()))),
+                    elapsedArea);
+        }
     }
 
     private void renderInput(Frame frame, Rect area) {


(camel) 04/06: CAMEL-23855: camel-jbang - AI panel space optimizations

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-23855
in repository https://gitbox.apache.org/repos/asf/camel.git

commit db0691c193983ab124178f6fc1df2c0d1f3fd58d
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Jun 29 18:36:09 2026 +0200

    CAMEL-23855: camel-jbang - AI panel space optimizations
    
    Co-Authored-By: Claude <no...@anthropic.com>
    Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 30 +++++++++++++++++-----
 1 file changed, 24 insertions(+), 6 deletions(-)

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 e083564269f6..62cb48158807 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
@@ -119,6 +119,14 @@ class AiPanel {
         return SPLIT_PERCENTS[splitIndex];
     }
 
+    private long lastResponseElapsed() {
+        if (thinking.get() || conversation.isEmpty()) {
+            return -1;
+        }
+        ConversationEntry last = conversation.get(conversation.size() - 1);
+        return "assistant".equals(last.role()) ? last.elapsedSeconds() : -1;
+    }
+
     void cycleHeight() {
         splitIndex = (splitIndex + 1) % SPLIT_PERCENTS.length;
     }
@@ -336,10 +344,21 @@ class AiPanel {
     }
 
     void render(Frame frame, Rect area) {
+        // At 25% show elapsed in the title bar to save space
+        long titleElapsed = lastResponseElapsed();
+        Line titleLine;
+        if (splitIndex == 0 && titleElapsed >= 0) {
+            titleLine = Line.from(
+                    Span.styled(" AI ", Style.EMPTY.bold()),
+                    Span.styled("(" + titleElapsed + "s) ", Style.EMPTY.dim()));
+        } else {
+            titleLine = Line.from(Span.styled(" AI ", Style.EMPTY.bold()));
+        }
+
         Block block = Block.builder()
                 .borders(Borders.ALL)
                 .borderType(BorderType.ROUNDED)
-                .title(Title.from(Line.from(Span.styled(" AI ", Style.EMPTY.bold()))))
+                .title(Title.from(titleLine))
                 .build();
         frame.renderWidget(block, area);
         Rect inner = block.inner(area);
@@ -347,14 +366,13 @@ class AiPanel {
             return;
         }
 
-        // Split inner area: conversation (fill) + separator (1 row) + input (1 row) + padding (1 row)
+        // Split inner area: conversation (fill) + separator (1 row) + input (1 row)
         List<Rect> parts = Layout.vertical()
-                .constraints(Constraint.fill(), Constraint.length(1), Constraint.length(1), Constraint.length(1))
+                .constraints(Constraint.fill(), Constraint.length(1), Constraint.length(1))
                 .split(inner);
         Rect conversationArea = parts.get(0);
         Rect separatorArea = parts.get(1);
         Rect inputArea = parts.get(2);
-        // parts.get(3) is empty padding row above the bottom border
 
         renderConversation(frame, conversationArea);
         // horizontal line separator
@@ -410,10 +428,10 @@ class AiPanel {
             }
         }
 
-        // Reserve 1 row for dimmed elapsed time when we have one to show
+        // Reserve 1 row for dimmed elapsed time (skip at 25% — shown in title bar instead)
         Rect mdArea = area;
         Rect elapsedArea = null;
-        if (lastElapsed >= 0 && area.height() > 2) {
+        if (lastElapsed >= 0 && splitIndex > 0 && area.height() > 2) {
             List<Rect> vParts = Layout.vertical()
                     .constraints(Constraint.fill(), Constraint.length(1))
                     .split(area);


(camel) 01/06: CAMEL-23855: Add F8 AI prompt panel to TUI

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-23855
in repository https://gitbox.apache.org/repos/asf/camel.git

commit d6ce487c08f5193ff04af96dfd49b44a22dc56c7
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Jun 29 14:48:18 2026 +0200

    CAMEL-23855: Add F8 AI prompt panel to TUI
    
    Co-Authored-By: Claude <no...@anthropic.com>
    Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
 .../apache/camel/dsl/jbang/core/commands/Ask.java  | 836 +--------------------
 .../core/commands/{Ask.java => AskTools.java}      | 485 +++---------
 .../camel/dsl/jbang/core/commands/LlmClient.java   |  64 +-
 .../camel/dsl/jbang/core/commands/tui/AiPanel.java | 406 ++++++++++
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |  39 +-
 5 files changed, 578 insertions(+), 1252 deletions(-)

diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
index 9a7e5af16761..f6a3ad5d70ba 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
@@ -16,30 +16,13 @@
  */
 package org.apache.camel.dsl.jbang.core.commands;
 
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.PrintWriter;
-import java.io.StringWriter;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.LinkedHashMap;
 import java.util.List;
-import java.util.Map;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
 
-import org.apache.camel.catalog.CamelCatalog;
-import org.apache.camel.catalog.DefaultCamelCatalog;
 import org.apache.camel.dsl.jbang.core.common.EnvironmentHelper;
-import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
-import org.apache.camel.dsl.jbang.core.common.Printer;
 import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
-import org.apache.camel.tooling.model.ComponentModel;
-import org.apache.camel.util.IOHelper;
 import org.apache.camel.util.json.JsonObject;
-import org.apache.camel.util.json.Jsoner;
 import org.jline.reader.EndOfFileException;
 import org.jline.reader.LineReader;
 import org.jline.reader.LineReaderBuilder;
@@ -113,8 +96,7 @@ public class Ask extends CamelCommand {
     boolean verbose;
 
     private long targetPid;
-    private CamelCatalog catalog;
-    private volatile List<JsonObject> commandMetadataCache;
+    private AskTools askTools;
 
     public Ask(CamelJBangMain main) {
         super(main);
@@ -149,8 +131,9 @@ public class Ask extends CamelCommand {
             targetPid = -1;
         }
 
+        askTools = new AskTools(targetPid);
         String systemPrompt = buildSystemPrompt(process);
-        List<LlmClient.ToolDef> tools = buildToolDefinitions();
+        List<LlmClient.ToolDef> tools = askTools.buildToolDefinitions();
 
         if (question == null || question.isEmpty()) {
             return runInteractiveChat(client, process, systemPrompt, tools);
@@ -227,7 +210,7 @@ public class Ask extends CamelCommand {
                     if (showTools) {
                         printer().println("[tool] " + toolCall.name() + "(" + toolCall.arguments().toJson() + ")");
                     }
-                    String result = executeTool(toolCall.name(), toolCall.arguments());
+                    String result = askTools.executeTool(toolCall.name(), toolCall.arguments());
                     if (showTools) {
                         printer().println("[result] " + truncate(result, 200));
                     }
@@ -283,570 +266,12 @@ public class Ask extends CamelCommand {
     // ---- System prompt ----
 
     private String buildSystemPrompt(RuntimeHelper.ProcessInfo process) {
-        StringBuilder sb = new StringBuilder();
-        sb.append("You are an Apache Camel assistant. ");
-        sb.append("You help users build, understand, and troubleshoot Camel applications.\n\n");
-
-        if (process != null) {
-            sb.append("You are connected to a running Camel application: ");
-            sb.append(process.name()).append(" (PID ").append(process.pid()).append("). ");
-            sb.append("Use the runtime inspection tools to gather information about it.\n\n");
-        } else {
-            List<RuntimeHelper.ProcessInfo> available = RuntimeHelper.discoverProcesses();
-            if (!available.isEmpty()) {
-                sb.append("No Camel process is currently selected. ");
-                sb.append("Use list_processes to see available processes, then select_process to connect to one. ");
-                sb.append("Runtime inspection tools will not work until a process is selected.\n\n");
-            }
-        }
-
-        sb.append("You can search the Camel catalog (components, EIPs), browse examples, ");
-        sb.append("read/write files, and execute any Camel CLI command.\n\n");
-        sb.append("For CLI commands beyond the built-in tools, use cli_list_commands to discover ");
-        sb.append("available commands, cli_command_help to see options, and cli_exec to run them.\n\n");
-        sb.append("Guidelines:\n");
-        sb.append("- When creating routes, use YAML DSL format (Camel's recommended format for JBang)\n");
-        sb.append("- Look at existing files first with list_files/read_file before creating new ones\n");
-        sb.append("- Use catalog tools to look up component syntax before writing routes\n");
-        sb.append("- Use examples as reference when building new routes\n");
-        sb.append("- Be concise and actionable in your answers\n");
-        sb.append("- Format output as plain text for terminal display, do not use markdown\n");
-        if (process != null) {
-            sb.append("- Start by gathering relevant information using the available runtime tools\n");
-            sb.append("- If something looks wrong, explain what it means and suggest fixes\n");
-            sb.append("- To stop routes or the application, always use the provided tools ");
-            sb.append("(stop_route, stop_application) for graceful shutdown. Never suggest kill or kill -9.\n");
-        }
-        return sb.toString();
-    }
-
-    // ---- Tool definitions ----
-
-    private List<LlmClient.ToolDef> buildToolDefinitions() {
-        List<LlmClient.ToolDef> tools = new ArrayList<>();
-
-        // Process discovery and selection
-        tools.add(new LlmClient.ToolDef(
-                "list_processes",
-                "List all running Camel processes with their PID and name. Use this to discover available processes before selecting one.",
-                emptyParams()));
-        tools.add(new LlmClient.ToolDef(
-                "select_process",
-                "Select a running Camel process by name or PID to inspect. Required when multiple processes are running. After selection, all runtime tools (get_routes, get_context, etc.) will target this process.",
-                objectParams(Map.of(
-                        "name", stringProp("Name or PID of the Camel process to connect to")))));
-
-        // Status-file tools (no parameters needed)
-        tools.add(new LlmClient.ToolDef(
-                "get_context",
-                "Get Camel context info: name, version, state, uptime, route count, exchange statistics.",
-                emptyParams()));
-        tools.add(new LlmClient.ToolDef(
-                "get_routes",
-                "List all routes with their state, uptime, messages processed, last error, and throughput.",
-                emptyParams()));
-        tools.add(new LlmClient.ToolDef(
-                "get_health",
-                "Get health check status for the Camel application.",
-                emptyParams()));
-        tools.add(new LlmClient.ToolDef(
-                "get_endpoints",
-                "List all endpoints registered in the Camel context with URIs and usage stats.",
-                emptyParams()));
-        tools.add(new LlmClient.ToolDef(
-                "get_inflight",
-                "Show currently in-flight exchanges (messages being processed).",
-                emptyParams()));
-        tools.add(new LlmClient.ToolDef(
-                "get_blocked",
-                "Show blocked exchanges that are stuck or waiting.",
-                emptyParams()));
-        tools.add(new LlmClient.ToolDef(
-                "get_consumers",
-                "Show consumer statistics (polling and event-driven consumers).",
-                emptyParams()));
-        tools.add(new LlmClient.ToolDef(
-                "get_properties",
-                "Show configuration properties of the running Camel application.",
-                emptyParams()));
-
-        // IPC action tools (with parameters)
-        tools.add(new LlmClient.ToolDef(
-                "get_route_source",
-                "Get the source code of routes. Use filter to limit by filename (supports wildcards).",
-                objectParams(Map.of(
-                        "filter", stringProp("Filter source files by name (supports wildcards). Use * for all.")))));
-        tools.add(new LlmClient.ToolDef(
-                "get_route_dump",
-                "Dump route definitions in XML or YAML format.",
-                objectParams(Map.of(
-                        "routeId", stringProp("Route ID to dump (use * for all routes)"),
-                        "format", stringProp("Output format: xml or yaml (default: yaml)")))));
-        tools.add(new LlmClient.ToolDef(
-                "get_route_structure",
-                "Show the route structure as a tree of processors.",
-                objectParams(Map.of(
-                        "routeId", stringProp("Route ID to inspect (use * for all routes)")))));
-        tools.add(new LlmClient.ToolDef(
-                "get_top_processors",
-                "Show top processor statistics: which processors are slowest and most active.",
-                emptyParams()));
-        tools.add(new LlmClient.ToolDef(
-                "trace_control",
-                "Enable, disable, or dump message tracing.",
-                objectParams(Map.of(
-                        "action", stringProp("Action: enable, disable, or dump")))));
-
-        // Route lifecycle tools
-        tools.add(new LlmClient.ToolDef(
-                "stop_route",
-                "Gracefully stop a route. The route will finish processing in-flight exchanges before stopping.",
-                objectParams(Map.of(
-                        "routeId", stringProp("The ID of the route to stop")))));
-        tools.add(new LlmClient.ToolDef(
-                "start_route",
-                "Start a stopped route.",
-                objectParams(Map.of(
-                        "routeId", stringProp("The ID of the route to start")))));
-        tools.add(new LlmClient.ToolDef(
-                "suspend_route",
-                "Suspend a route (pauses the consumer but keeps the route loaded).",
-                objectParams(Map.of(
-                        "routeId", stringProp("The ID of the route to suspend")))));
-        tools.add(new LlmClient.ToolDef(
-                "resume_route",
-                "Resume a suspended route.",
-                objectParams(Map.of(
-                        "routeId", stringProp("The ID of the route to resume")))));
-
-        // Application lifecycle
-        tools.add(new LlmClient.ToolDef(
-                "stop_application",
-                "Gracefully stop the Camel application. The application will finish processing in-flight exchanges and shut down cleanly. Use this instead of kill.",
-                emptyParams()));
-
-        // Catalog tools
-        tools.add(new LlmClient.ToolDef(
-                "catalog_components",
-                "Search the Camel component catalog by name or label. Returns component name, title, description, and labels.",
-                objectParams(Map.of(
-                        "filter", stringProp("Filter by name, title, or description (case-insensitive substring)"),
-                        "label", stringProp("Filter by category label (e.g., cloud, messaging, database, file)")))));
-        tools.add(new LlmClient.ToolDef(
-                "catalog_component_doc",
-                "Get detailed documentation for a Camel component: URI syntax and endpoint options.",
-                objectParams(Map.of(
-                        "component", stringProp("Component name (e.g., kafka, http, file, timer)")))));
-        tools.add(new LlmClient.ToolDef(
-                "catalog_eips",
-                "Search EIPs (Enterprise Integration Patterns) like split, aggregate, filter, choice, multicast.",
-                objectParams(Map.of(
-                        "filter", stringProp("Filter by name, title, or description (case-insensitive substring)")))));
-
-        // Example tools
-        tools.add(new LlmClient.ToolDef(
-                "list_examples",
-                "List available Camel CLI examples. Returns name, title, description, difficulty level, and tags.",
-                objectParams(Map.of(
-                        "filter", stringProp("Filter by name, description, or tag (case-insensitive)"),
-                        "level", stringProp("Filter by difficulty: beginner, intermediate, or advanced")))));
-        tools.add(new LlmClient.ToolDef(
-                "get_example_file",
-                "Get the content of a file from a bundled Camel CLI example. Use list_examples first to find available examples.",
-                objectParams(Map.of(
-                        "example", stringProp("Example name (e.g., timer-log, rest-api, circuit-breaker)"),
-                        "file", stringProp("File name within the example (e.g., route.camel.yaml)")))));
-
-        // CLI tools (access to all camel CLI commands)
-        tools.add(new LlmClient.ToolDef(
-                "cli_list_commands",
-                "List available Camel CLI commands. Returns command names and descriptions. Use filter to narrow results.",
-                objectParams(Map.of(
-                        "filter", stringProp("Filter by command name or description (case-insensitive substring)")))));
-        tools.add(new LlmClient.ToolDef(
-                "cli_command_help",
-                "Get detailed help for a specific Camel CLI command, including all options with types and defaults.",
-                objectParams(Map.of(
-                        "command", stringProp("Full command name (e.g., 'get error', 'catalog component', 'run')")))));
-        tools.add(new LlmClient.ToolDef(
-                "cli_exec",
-                "Execute any Camel CLI command and return its output. Use cli_list_commands and cli_command_help first to discover commands and their options. CAUTION: some commands (stop, cmd stop-route, cmd stop-group) are destructive and will affect running integrations. Always confirm with the user before executing destructive commands.",
-                objectParams(Map.of(
-                        "command", stringProp(
-                                "The full command line to execute (e.g., 'get error --diagram', 'catalog component --filter=kafka')")))));
-
-        // File tools
-        tools.add(new LlmClient.ToolDef(
-                "list_files",
-                "List files in a directory (up to 2 levels deep). Defaults to current working directory.",
-                objectParams(Map.of(
-                        "path", stringProp("Directory path relative to CWD (default: current directory)")))));
-        tools.add(new LlmClient.ToolDef(
-                "read_file",
-                "Read the content of a file. Useful for inspecting route definitions, configuration, and properties files.",
-                objectParams(Map.of(
-                        "file", stringProp("File path relative to CWD")))));
-        tools.add(new LlmClient.ToolDef(
-                "write_file",
-                "Write content to a file. Creates parent directories if needed. Use for creating or editing route definitions and configuration files.",
-                objectParams(Map.of(
-                        "file", stringProp("File path relative to CWD"),
-                        "content", stringProp("The content to write to the file")))));
-
-        return tools;
-    }
-
-    // ---- Tool execution ----
-
-    private String executeTool(String name, JsonObject args) {
-        try {
-            return switch (name) {
-                // Runtime tools (require a running process)
-                case "list_processes" -> executeListProcesses();
-                case "select_process" -> executeSelectProcess(args);
-                case "get_context" ->
-                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "context");
-                case "get_routes" ->
-                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "routes");
-                case "get_health" ->
-                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "healthChecks");
-                case "get_endpoints" ->
-                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "endpoints");
-                case "get_inflight" ->
-                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "inflight");
-                case "get_blocked" ->
-                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "blocked");
-                case "get_consumers" ->
-                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "consumers");
-                case "get_properties" ->
-                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "properties");
-                case "get_route_source" -> targetPid < 0 ? NO_PROCESS : executeRouteSource(args);
-                case "get_route_dump" -> targetPid < 0 ? NO_PROCESS : executeRouteDump(args);
-                case "get_route_structure" -> targetPid < 0 ? NO_PROCESS : executeRouteStructure(args);
-                case "get_top_processors" ->
-                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.executeAction(targetPid, "top-processors", null);
-                case "trace_control" -> targetPid < 0 ? NO_PROCESS : executeTraceControl(args);
-                case "stop_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "stop");
-                case "start_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "start");
-                case "suspend_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "suspend");
-                case "resume_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "resume");
-                case "stop_application" -> targetPid < 0 ? NO_PROCESS : RuntimeHelper.stopApplication(targetPid);
-                // Catalog tools
-                case "catalog_components" -> executeCatalogComponents(args);
-                case "catalog_component_doc" -> executeCatalogComponentDoc(args);
-                case "catalog_eips" -> executeCatalogEips(args);
-                // Example tools
-                case "list_examples" -> executeListExamples(args);
-                case "get_example_file" -> executeGetExampleFile(args);
-                // CLI tools
-                case "cli_list_commands" -> executeCliListCommands(args);
-                case "cli_command_help" -> executeCliCommandHelp(args);
-                case "cli_exec" -> executeCliExec(args);
-                // File tools
-                case "list_files" -> executeListFiles(args);
-                case "read_file" -> executeReadFile(args);
-                case "write_file" -> executeWriteFile(args);
-                default -> "Unknown tool: " + name;
-            };
-        } catch (Exception e) {
-            return "Error executing " + name + ": " + e.getMessage();
-        }
-    }
-
-    private String executeListProcesses() {
-        List<RuntimeHelper.ProcessInfo> processes = RuntimeHelper.discoverProcesses();
-        if (processes.isEmpty()) {
-            return "No running Camel processes found. Start one with: camel run <file>";
-        }
-        JsonObject response = new JsonObject();
-        response.put("count", processes.size());
-        List<JsonObject> list = new ArrayList<>();
-        for (RuntimeHelper.ProcessInfo p : processes) {
-            JsonObject entry = new JsonObject();
-            entry.put("pid", p.pid());
-            entry.put("name", p.name());
-            entry.put("selected", p.pid() == targetPid);
-            list.add(entry);
-        }
-        response.put("processes", list);
-        if (targetPid < 0) {
-            response.put("hint", "No process selected. Use select_process to connect to one.");
-        }
-        return response.toJson();
-    }
-
-    private String executeSelectProcess(JsonObject args) {
-        String name = args.getString("name");
-        if (name == null || name.isBlank()) {
-            return "Error: name or PID is required";
-        }
-        RuntimeHelper.ProcessInfo found = RuntimeHelper.findProcess(name);
-        if (found == null) {
-            List<RuntimeHelper.ProcessInfo> processes = RuntimeHelper.discoverProcesses();
-            if (processes.isEmpty()) {
-                return "No running Camel processes found.";
-            }
-            StringBuilder sb = new StringBuilder("No process found matching: " + name + ". Available:\n");
-            processes.forEach(p -> sb.append("  ").append(p.name()).append(" (PID ").append(p.pid()).append(")\n"));
-            return sb.toString();
-        }
-        targetPid = found.pid();
-        return "Connected to " + found.name() + " (PID " + found.pid() + "). Runtime tools are now active.";
+        return AskTools.buildSystemPrompt(
+                process != null ? process.pid() : -1,
+                process != null ? process.name() : null);
     }
 
-    private String executeRouteSource(JsonObject args) {
-        String filter = args.getString("filter");
-        return RuntimeHelper.executeAction(targetPid, "source",
-                root -> root.put("filter", filter != null ? filter : "*"));
-    }
-
-    private String executeRouteDump(JsonObject args) {
-        String routeId = args.getString("routeId");
-        String format = args.getString("format");
-        return RuntimeHelper.executeAction(targetPid, "route-dump", root -> {
-            root.put("id", routeId != null ? routeId : "*");
-            root.put("format", format != null ? format : "yaml");
-        });
-    }
-
-    private String executeRouteStructure(JsonObject args) {
-        String routeId = args.getString("routeId");
-        return RuntimeHelper.executeAction(targetPid, "route-structure",
-                root -> root.put("id", routeId != null ? routeId : "*"));
-    }
-
-    private String executeTraceControl(JsonObject args) {
-        String action = args.getString("action");
-        if (action == null) {
-            return "Error: action is required (enable, disable, dump)";
-        }
-        return RuntimeHelper.executeAction(targetPid, "trace", root -> {
-            switch (action.toLowerCase()) {
-                case "enable" -> root.put("enabled", "true");
-                case "disable" -> root.put("enabled", "false");
-                case "dump" -> root.put("dump", "true");
-                default -> root.put("enabled", action);
-            }
-        });
-    }
-
-    private String executeRouteCommand(JsonObject args, String command) {
-        String routeId = args.getString("routeId");
-        if (routeId == null || routeId.isBlank()) {
-            return "Error: routeId is required";
-        }
-        return RuntimeHelper.executeAction(targetPid, "route", root -> {
-            root.put("id", routeId);
-            root.put("command", command);
-        });
-    }
-
-    // ---- Catalog tools ----
-
-    private String executeCatalogComponents(JsonObject args) {
-        String filter = args.getString("filter");
-        String label = args.getString("label");
-        CamelCatalog catalog = getCatalog();
-
-        List<JsonObject> results = catalog.findComponentNames().stream()
-                .map(catalog::componentModel)
-                .filter(m -> m != null)
-                .filter(m -> matchesFilter(m.getScheme(), m.getTitle(), m.getDescription(), filter))
-                .filter(m -> label == null || label.isBlank()
-                        || (m.getLabel() != null && m.getLabel().toLowerCase().contains(label.toLowerCase())))
-                .limit(20)
-                .map(m -> {
-                    JsonObject jo = new JsonObject();
-                    jo.put("name", m.getScheme());
-                    jo.put("title", m.getTitle());
-                    jo.put("description", m.getDescription());
-                    jo.put("label", m.getLabel());
-                    return jo;
-                })
-                .collect(Collectors.toList());
-
-        JsonObject response = new JsonObject();
-        response.put("count", results.size());
-        response.put("components", results);
-        return response.toJson();
-    }
-
-    private String executeCatalogComponentDoc(JsonObject args) {
-        String component = args.getString("component");
-        if (component == null || component.isBlank()) {
-            return "Error: component name is required";
-        }
-        CamelCatalog catalog = getCatalog();
-        ComponentModel model = catalog.componentModel(component);
-        if (model == null) {
-            return "Component not found: " + component;
-        }
-
-        JsonObject response = new JsonObject();
-        response.put("name", model.getScheme());
-        response.put("title", model.getTitle());
-        response.put("description", model.getDescription());
-        response.put("syntax", model.getSyntax());
-        response.put("consumerOnly", model.isConsumerOnly());
-        response.put("producerOnly", model.isProducerOnly());
-
-        List<JsonObject> options = new ArrayList<>();
-        if (model.getEndpointOptions() != null) {
-            model.getEndpointOptions().stream()
-                    .filter(opt -> !opt.isDeprecated())
-                    .forEach(opt -> {
-                        JsonObject jo = new JsonObject();
-                        jo.put("name", opt.getName());
-                        jo.put("description", opt.getDescription());
-                        jo.put("type", opt.getType());
-                        jo.put("required", opt.isRequired());
-                        if (opt.getDefaultValue() != null) {
-                            jo.put("defaultValue", opt.getDefaultValue().toString());
-                        }
-                        options.add(jo);
-                    });
-        }
-        response.put("options", options);
-        return response.toJson();
-    }
-
-    private String executeCatalogEips(JsonObject args) {
-        String filter = args.getString("filter");
-        CamelCatalog catalog = getCatalog();
-
-        List<JsonObject> results = catalog.findModelNames().stream()
-                .map(catalog::eipModel)
-                .filter(m -> m != null)
-                .filter(m -> matchesFilter(m.getName(), m.getTitle(), m.getDescription(), filter))
-                .limit(20)
-                .map(m -> {
-                    JsonObject jo = new JsonObject();
-                    jo.put("name", m.getName());
-                    jo.put("title", m.getTitle());
-                    jo.put("description", m.getDescription());
-                    jo.put("label", m.getLabel());
-                    return jo;
-                })
-                .collect(Collectors.toList());
-
-        JsonObject response = new JsonObject();
-        response.put("count", results.size());
-        response.put("eips", results);
-        return response.toJson();
-    }
-
-    private static boolean matchesFilter(String name, String title, String description, String filter) {
-        if (filter == null || filter.isBlank()) {
-            return true;
-        }
-        String lf = filter.toLowerCase();
-        return (name != null && name.toLowerCase().contains(lf))
-                || (title != null && title.toLowerCase().contains(lf))
-                || (description != null && description.toLowerCase().contains(lf));
-    }
-
-    // ---- Example tools ----
-
-    @SuppressWarnings("unchecked")
-    private String executeListExamples(JsonObject args) {
-        String filter = args.getString("filter");
-        String level = args.getString("level");
-
-        List<JsonObject> catalog = ExampleHelper.loadCatalog();
-        List<JsonObject> filtered = ExampleHelper.filterExamples(catalog, filter);
-
-        List<JsonObject> results = new ArrayList<>();
-        for (JsonObject entry : filtered) {
-            if (level != null && !level.isBlank()) {
-                String entryLevel = entry.getString("level");
-                if (entryLevel == null || !entryLevel.equalsIgnoreCase(level)) {
-                    continue;
-                }
-            }
-            JsonObject jo = new JsonObject();
-            jo.put("name", entry.getString("name"));
-            jo.put("title", entry.getString("title"));
-            jo.put("description", entry.getString("description"));
-            jo.put("level", entry.getString("level"));
-            jo.put("tags", entry.get("tags"));
-            jo.put("bundled", ExampleHelper.isBundled(entry));
-            jo.put("files", ExampleHelper.getFiles(entry));
-            results.add(jo);
-
-            if (results.size() >= 20) {
-                break;
-            }
-        }
-
-        JsonObject response = new JsonObject();
-        response.put("count", results.size());
-        response.put("examples", results);
-        return response.toJson();
-    }
-
-    private String executeGetExampleFile(JsonObject args) {
-        String example = args.getString("example");
-        String file = args.getString("file");
-        if (example == null || example.isBlank()) {
-            return "Error: example name is required";
-        }
-        if (file == null || file.isBlank()) {
-            return "Error: file name is required";
-        }
-
-        List<JsonObject> catalog = ExampleHelper.loadCatalog();
-        JsonObject entry = ExampleHelper.findExample(catalog, example);
-        if (entry == null) {
-            return "Example not found: " + example;
-        }
-
-        List<String> files = ExampleHelper.getFiles(entry);
-        if (!files.contains(file)) {
-            return "File '" + file + "' not found in example '" + example + "'. Available files: " + files;
-        }
-
-        if (ExampleHelper.isBundled(entry)) {
-            String resourcePath = "examples/" + example + "/" + file;
-            try (InputStream is = ExampleHelper.class.getClassLoader().getResourceAsStream(resourcePath)) {
-                if (is != null) {
-                    return IOHelper.loadText(is);
-                }
-            } catch (Exception e) {
-                return "Error reading file: " + e.getMessage();
-            }
-            return "Could not read bundled file: " + resourcePath;
-        } else {
-            return "This example is not bundled. View it on GitHub: " + ExampleHelper.getGithubUrl(entry) + "/" + file;
-        }
-    }
-
-    // ---- CLI tools ----
-
-    @SuppressWarnings("unchecked")
-    private List<JsonObject> loadCommandMetadata() {
-        if (commandMetadataCache != null) {
-            return commandMetadataCache;
-        }
-        try (InputStream is = getClass().getClassLoader()
-                .getResourceAsStream("META-INF/camel-jbang-commands-metadata.json")) {
-            if (is == null) {
-                return List.of();
-            }
-            String json = IOHelper.loadText(is);
-            JsonObject root = (JsonObject) Jsoner.deserialize(json);
-            Object commands = root.get("commands");
-            if (commands instanceof Collection<?>) {
-                commandMetadataCache = ((Collection<Object>) commands).stream()
-                        .filter(JsonObject.class::isInstance)
-                        .map(JsonObject.class::cast)
-                        .toList();
-                return commandMetadataCache;
-            }
-        } catch (Exception e) {
-            printer().printErr("Failed to load CLI command metadata: " + e.getMessage());
-        }
-        return List.of();
-    }
+    // ---- CLI helper methods (used by AskTools) ----
 
     @SuppressWarnings("unchecked")
     static void collectCommands(List<JsonObject> commands, List<JsonObject> result, String filter) {
@@ -879,18 +304,6 @@ public class Ask extends CamelCommand {
         }
     }
 
-    private String executeCliListCommands(JsonObject args) {
-        String filter = args.getString("filter");
-        List<JsonObject> commands = loadCommandMetadata();
-        List<JsonObject> result = new ArrayList<>();
-        collectCommands(commands, result, filter);
-
-        JsonObject response = new JsonObject();
-        response.put("count", result.size());
-        response.put("commands", result);
-        return response.toJson();
-    }
-
     @SuppressWarnings("unchecked")
     static JsonObject findCommand(List<JsonObject> commands, String fullName) {
         for (JsonObject cmd : commands) {
@@ -913,128 +326,6 @@ public class Ask extends CamelCommand {
         return null;
     }
 
-    @SuppressWarnings("unchecked")
-    private String executeCliCommandHelp(JsonObject args) {
-        String command = args.getString("command");
-        if (command == null || command.isBlank()) {
-            return "Error: command name is required";
-        }
-
-        List<JsonObject> commands = loadCommandMetadata();
-        JsonObject cmd = findCommand(commands, command);
-        if (cmd == null) {
-            return "Command not found: " + command + ". Use cli_list_commands to see available commands.";
-        }
-
-        JsonObject response = new JsonObject();
-        response.put("command", cmd.getString("fullName"));
-        response.put("description", cmd.getString("description"));
-
-        Object options = cmd.get("options");
-        if (options instanceof Collection<?>) {
-            List<JsonObject> opts = ((Collection<Object>) options).stream()
-                    .filter(JsonObject.class::isInstance)
-                    .map(JsonObject.class::cast)
-                    .map(opt -> {
-                        JsonObject o = new JsonObject();
-                        o.put("names", opt.getString("names"));
-                        o.put("description", opt.getString("description"));
-                        o.put("type", opt.getString("type"));
-                        String dv = opt.getString("defaultValue");
-                        if (dv != null) {
-                            o.put("defaultValue", dv);
-                        }
-                        return o;
-                    })
-                    .toList();
-            response.put("options", opts);
-        }
-
-        Object subs = cmd.get("subcommands");
-        if (subs instanceof Collection<?> subList && !subList.isEmpty()) {
-            List<JsonObject> subSummaries = ((Collection<Object>) subList).stream()
-                    .filter(JsonObject.class::isInstance)
-                    .map(JsonObject.class::cast)
-                    .map(sub -> {
-                        JsonObject s = new JsonObject();
-                        s.put("command", sub.getString("fullName"));
-                        s.put("description", sub.getString("description"));
-                        return s;
-                    })
-                    .toList();
-            response.put("subcommands", subSummaries);
-        }
-
-        return response.toJson();
-    }
-
-    private String executeCliExec(JsonObject args) {
-        String command = args.getString("command");
-        if (command == null || command.isBlank()) {
-            return "Error: command is required";
-        }
-
-        picocli.CommandLine commandLine = CamelJBangMain.getCommandLine();
-        if (commandLine == null) {
-            return "Error: CLI not available";
-        }
-
-        String[] cmdArgs = tokenizeCommand(command.trim());
-
-        // capture output by temporarily swapping the Printer on main
-        StringBuilder captured = new StringBuilder();
-        Printer capturingPrinter = new Printer() {
-            @Override
-            public void println() {
-                captured.append('\n');
-            }
-
-            @Override
-            public void println(String line) {
-                captured.append(line).append('\n');
-            }
-
-            @Override
-            public void print(String output) {
-                captured.append(output);
-            }
-
-            @Override
-            public void printf(String format, Object... fmtArgs) {
-                captured.append(String.format(format, fmtArgs));
-            }
-        };
-
-        // also capture PicoCLI's own output (usage/help text)
-        StringWriter sw = new StringWriter();
-        PrintWriter pw = new PrintWriter(sw);
-        PrintWriter originalOut = commandLine.getOut();
-        PrintWriter originalErr = commandLine.getErr();
-        commandLine.setOut(pw);
-        commandLine.setErr(pw);
-
-        Printer originalPrinter = getMain().getOut();
-        getMain().setOut(capturingPrinter);
-        try {
-            int exitCode = commandLine.execute(cmdArgs);
-            pw.flush();
-            String output = captured.toString() + sw.toString();
-            if (output.isBlank() && exitCode != 0) {
-                return "Command exited with code " + exitCode;
-            }
-            if (output.length() > 32768) {
-                output = output.substring(0, 32768) + "\n... (truncated)";
-            }
-            return output;
-        } catch (Exception e) {
-            return "Error executing command: " + e.getMessage();
-        } finally {
-            getMain().setOut(originalPrinter);
-            commandLine.setOut(originalOut);
-            commandLine.setErr(originalErr);
-        }
-    }
-
     static String[] tokenizeCommand(String command) {
         List<String> tokens = new ArrayList<>();
         StringBuilder current = new StringBuilder();
@@ -1062,116 +353,7 @@ public class Ask extends CamelCommand {
         return tokens.toArray(String[]::new);
     }
 
-    // ---- File tools ----
-
-    private String executeListFiles(JsonObject args) throws IOException {
-        String pathStr = args.getString("path");
-        Path cwd = Path.of("").toAbsolutePath().normalize();
-        Path base = cwd.resolve(pathStr != null && !pathStr.isBlank() ? pathStr : ".").normalize();
-
-        if (!base.startsWith(cwd)) {
-            return "Error: path must be within the current working directory";
-        }
-        if (!Files.isDirectory(base)) {
-            return "Error: not a directory: " + cwd.relativize(base);
-        }
-
-        try (Stream<Path> stream = Files.walk(base, 2)) {
-            List<String> files = stream
-                    .filter(p -> !p.equals(base))
-                    .map(p -> cwd.relativize(p).toString() + (Files.isDirectory(p) ? "/" : ""))
-                    .sorted()
-                    .toList();
-
-            JsonObject response = new JsonObject();
-            response.put("directory", base.equals(cwd) ? "." : cwd.relativize(base).toString());
-            response.put("count", files.size());
-            response.put("files", files);
-            return response.toJson();
-        }
-    }
-
-    private String executeReadFile(JsonObject args) throws IOException {
-        String fileStr = args.getString("file");
-        if (fileStr == null || fileStr.isBlank()) {
-            return "Error: file path is required";
-        }
-
-        Path cwd = Path.of("").toAbsolutePath().normalize();
-        Path filePath = cwd.resolve(fileStr).normalize();
-
-        if (!filePath.startsWith(cwd)) {
-            return "Error: file path must be within the current working directory";
-        }
-        if (!Files.exists(filePath)) {
-            return "File not found: " + cwd.relativize(filePath);
-        }
-
-        String content = Files.readString(filePath);
-        if (content.length() > 32768) {
-            content = content.substring(0, 32768) + "\n... (truncated, file is " + content.length() + " bytes)";
-        }
-        return content;
-    }
-
-    private String executeWriteFile(JsonObject args) throws IOException {
-        String fileStr = args.getString("file");
-        String content = args.getString("content");
-        if (fileStr == null || fileStr.isBlank()) {
-            return "Error: file path is required";
-        }
-        if (content == null) {
-            return "Error: content is required";
-        }
-
-        Path cwd = Path.of("").toAbsolutePath().normalize();
-        Path filePath = cwd.resolve(fileStr).normalize();
-
-        if (!filePath.startsWith(cwd)) {
-            return "Error: file path must be within the current working directory";
-        }
-
-        Files.createDirectories(filePath.getParent());
-        Files.writeString(filePath, content);
-        return "File written: " + cwd.relativize(filePath);
-    }
-
-    private CamelCatalog getCatalog() {
-        if (catalog == null) {
-            catalog = new DefaultCamelCatalog();
-        }
-        return catalog;
-    }
-
-    // ---- JSON schema helpers for tool parameters ----
-
-    private static JsonObject emptyParams() {
-        JsonObject schema = new JsonObject();
-        schema.put("type", "object");
-        schema.put("properties", new JsonObject());
-        return schema;
-    }
-
-    private static JsonObject objectParams(Map<String, JsonObject> properties) {
-        JsonObject props = new JsonObject();
-        Map<String, JsonObject> ordered = new LinkedHashMap<>(properties);
-        for (Map.Entry<String, JsonObject> entry : ordered.entrySet()) {
-            props.put(entry.getKey(), entry.getValue());
-        }
-        JsonObject schema = new JsonObject();
-        schema.put("type", "object");
-        schema.put("properties", props);
-        return schema;
-    }
-
-    private static JsonObject stringProp(String description) {
-        JsonObject prop = new JsonObject();
-        prop.put("type", "string");
-        prop.put("description", description);
-        return prop;
-    }
-
-    private static String truncate(String text, int maxLen) {
+    static String truncate(String text, int maxLen) {
         if (text == null) {
             return "null";
         }
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
similarity index 70%
copy from dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
copy to dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
index 9a7e5af16761..50c196a83a92 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
@@ -32,7 +32,6 @@ import java.util.stream.Stream;
 
 import org.apache.camel.catalog.CamelCatalog;
 import org.apache.camel.catalog.DefaultCamelCatalog;
-import org.apache.camel.dsl.jbang.core.common.EnvironmentHelper;
 import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
 import org.apache.camel.dsl.jbang.core.common.Printer;
 import org.apache.camel.dsl.jbang.core.common.RuntimeHelper;
@@ -40,292 +39,37 @@ import org.apache.camel.tooling.model.ComponentModel;
 import org.apache.camel.util.IOHelper;
 import org.apache.camel.util.json.JsonObject;
 import org.apache.camel.util.json.Jsoner;
-import org.jline.reader.EndOfFileException;
-import org.jline.reader.LineReader;
-import org.jline.reader.LineReaderBuilder;
-import org.jline.reader.UserInterruptException;
-import org.jline.terminal.Terminal;
-import org.jline.terminal.TerminalBuilder;
-import picocli.CommandLine.Command;
-import picocli.CommandLine.Option;
-import picocli.CommandLine.Parameters;
 
 /**
- * Ask a question about a running Camel application using AI with tool calling. The LLM can inspect the live runtime
- * (routes, health, traces, etc.) to provide informed answers.
+ * Shared tool definitions and execution logic for the Camel AI assistant. Used by both the {@code camel ask} CLI
+ * command and the TUI AI panel.
  */
-@Command(name = "ask",
-         description = "Ask a question about a running Camel application using AI",
-         sortOptions = false, showDefaultValues = true,
-         footer = {
-                 "%nExamples:",
-                 "  camel ask \"what routes are running?\"",
-                 "  camel ask \"why is my route failing?\" --name=myApp",
-                 "  camel ask \"show me the route structure\" --api-type=anthropic",
-                 "  camel ask \"are there any blocked exchanges?\" --model=gpt-4",
-                 "  camel ask                                   (interactive chat)" })
-public class Ask extends CamelCommand {
-
-    private static final String DEFAULT_MODEL = "llama3.2";
+public class AskTools {
+
     private static final String NO_PROCESS
             = "No running Camel process connected. Start one with: camel run <file>";
 
-    @Parameters(description = "Question to ask (omit for interactive chat mode)", arity = "0..*")
-    List<String> question;
-
-    @Option(names = { "--url" },
-            description = "LLM API endpoint URL. Auto-detected if not specified.")
-    String url;
-
-    @Option(names = { "--api-type" },
-            description = "API type: 'ollama', 'openai', or 'anthropic'")
-    LlmClient.ApiType apiType;
-
-    @Option(names = { "--api-key" },
-            description = "API key. Also reads ANTHROPIC_API_KEY, OPENAI_API_KEY, or LLM_API_KEY env vars")
-    String apiKey;
-
-    @Option(names = { "--model" },
-            description = "Model to use",
-            defaultValue = DEFAULT_MODEL)
-    String model = DEFAULT_MODEL;
-
-    @Option(names = { "--timeout" },
-            description = "Timeout in seconds for LLM response",
-            defaultValue = "120")
-    int timeout = 120;
-
-    @Option(names = { "--name" },
-            description = "Name or PID of the Camel process. Auto-detected when exactly one process is running")
-    String nameOrPid;
-
-    @Option(names = { "--max-iterations" },
-            description = "Maximum number of tool-calling rounds",
-            defaultValue = "10")
-    int maxIterations = 10;
-
-    @Option(names = { "--show-tools" },
-            description = "Show tool calls and results as they happen")
-    boolean showTools;
-
-    @Option(names = { "--verbose" },
-            description = "Print debug information: HTTP requests, responses, and parsed results")
-    boolean verbose;
-
     private long targetPid;
     private CamelCatalog catalog;
     private volatile List<JsonObject> commandMetadataCache;
 
-    public Ask(CamelJBangMain main) {
-        super(main);
+    public AskTools(long targetPid) {
+        this.targetPid = targetPid;
     }
 
-    @Override
-    public Integer doCall() throws Exception {
-        LlmClient client = LlmClient.create()
-                .withUrl(url)
-                .withApiType(apiType)
-                .withApiKey(apiKey)
-                .withModel(model)
-                .withTimeout(timeout)
-                .withTemperature(0.3)
-                .withStream(true)
-                .withMaxTokens(4096)
-                .withVerbose(verbose)
-                .withPrinter(printer());
-
-        if (!client.detectEndpoint()) {
-            printer().printErr("LLM service is not reachable.");
-            printer().printErr("Options: --url=<endpoint>, --api-type=anthropic, or start Ollama with: camel infra run ollama");
-            return 1;
-        }
-
-        RuntimeHelper.ProcessInfo process = findProcess(nameOrPid);
-        if (process != null) {
-            targetPid = process.pid();
-        } else if (nameOrPid != null && !nameOrPid.isBlank()) {
-            return 1;
-        } else {
-            targetPid = -1;
-        }
-
-        String systemPrompt = buildSystemPrompt(process);
-        List<LlmClient.ToolDef> tools = buildToolDefinitions();
-
-        if (question == null || question.isEmpty()) {
-            return runInteractiveChat(client, process, systemPrompt, tools);
-        }
-
-        String userQuestion = String.join(" ", question);
-        printer().println("Using " + client.model + " (" + client.apiType + ") to answer your question...");
-        if (process != null) {
-            printer().println("Target: " + process.name() + " (PID " + process.pid() + ")");
-        }
-        printer().println();
-
-        List<LlmClient.Message> messages = new ArrayList<>();
-        return runAgentLoop(client, systemPrompt, tools, messages, userQuestion);
+    public long getTargetPid() {
+        return targetPid;
     }
 
-    private int runInteractiveChat(
-            LlmClient client, RuntimeHelper.ProcessInfo process,
-            String systemPrompt, List<LlmClient.ToolDef> tools)
-            throws Exception {
-        Terminal terminal = EnvironmentHelper.getActiveTerminal();
-        if (terminal == null) {
-            terminal = TerminalBuilder.builder().system(true).build();
-        }
-        LineReader reader = LineReaderBuilder.builder().terminal(terminal).build();
-
-        printer().println("Camel AI Assistant (" + client.model + ", " + client.apiType + ")");
-        if (process != null) {
-            printer().println("Target: " + process.name() + " (PID " + process.pid() + ")");
-        }
-        printer().println("Type your question, or 'exit' to quit.");
-        printer().println();
-
-        List<LlmClient.Message> messages = new ArrayList<>();
-
-        while (true) {
-            String line;
-            try {
-                line = reader.readLine("ask> ");
-            } catch (UserInterruptException | EndOfFileException e) {
-                break;
-            }
-            if (line == null || line.isBlank() || "exit".equalsIgnoreCase(line.strip())) {
-                break;
-            }
-
-            int result = runAgentLoop(client, systemPrompt, tools, messages, line.strip());
-            if (result != 0) {
-                printer().printErr("(error processing question, continuing...)");
-            }
-            printer().println();
-        }
-        return 0;
-    }
-
-    private int runAgentLoop(
-            LlmClient client, String systemPrompt,
-            List<LlmClient.ToolDef> tools, List<LlmClient.Message> messages,
-            String userQuestion) {
-        messages.add(LlmClient.Message.user(userQuestion));
-
-        for (int i = 0; i < maxIterations; i++) {
-            LlmClient.ChatResponse response = client.chatWithTools(systemPrompt, messages, tools);
-            if (response == null) {
-                printer().printErr("Failed to get response from LLM");
-                return 1;
-            }
-
-            if (response.toolCalls() != null && !response.toolCalls().isEmpty()) {
-                messages.add(LlmClient.Message.assistantWithToolCalls(response.text(), response.toolCalls()));
-
-                List<LlmClient.ToolResult> results = new ArrayList<>();
-                for (LlmClient.ToolCall toolCall : response.toolCalls()) {
-                    if (showTools) {
-                        printer().println("[tool] " + toolCall.name() + "(" + toolCall.arguments().toJson() + ")");
-                    }
-                    String result = executeTool(toolCall.name(), toolCall.arguments());
-                    if (showTools) {
-                        printer().println("[result] " + truncate(result, 200));
-                    }
-                    results.add(new LlmClient.ToolResult(toolCall.id(), result));
-                }
-                messages.add(LlmClient.Message.toolResults(results));
-            } else {
-                if (!response.streamed() && response.text() != null) {
-                    printer().println(response.text());
-                }
-                messages.add(LlmClient.Message.assistantWithToolCalls(response.text(), List.of()));
-                return 0;
-            }
-        }
-
-        printer().printErr("Reached maximum iterations (" + maxIterations + ") without a final answer.");
-        return 1;
-    }
-
-    // ---- Process discovery (delegates to RuntimeHelper) ----
-
-    private RuntimeHelper.ProcessInfo findProcess(String nameOrPid) {
-        // Fall back to TUI-selected process if no explicit name given
-        if ((nameOrPid == null || nameOrPid.isBlank()) && EnvironmentHelper.getSelectedProcess() != null) {
-            nameOrPid = EnvironmentHelper.getSelectedProcess();
-        }
-
-        RuntimeHelper.ProcessInfo found = RuntimeHelper.findProcess(nameOrPid);
-        if (found != null) {
-            return found;
-        }
-
-        List<RuntimeHelper.ProcessInfo> processes = RuntimeHelper.discoverProcesses();
-        if (nameOrPid != null && !nameOrPid.isBlank()) {
-            if (processes.isEmpty()) {
-                printer().printErr("No running Camel processes found.");
-                printer().printErr("Start a Camel application first: camel run myRoute.yaml");
-            } else if (processes.size() > 1) {
-                printer().printErr("No unique Camel process found matching: " + nameOrPid);
-                processes.forEach(p -> printer().printErr("  " + p.name() + " (PID " + p.pid() + ")"));
-                printer().printErr("Specify a more specific name or PID with --name");
-            } else {
-                printer().printErr("No Camel process found matching: " + nameOrPid);
-            }
-        } else if (processes.size() > 1) {
-            printer().println("Multiple Camel processes found. Use --name to specify one:");
-            processes.forEach(p -> printer().println("  " + p.name() + " (PID " + p.pid() + ")"));
-            printer().println();
-        }
-        return null;
-    }
-
-    // ---- System prompt ----
-
-    private String buildSystemPrompt(RuntimeHelper.ProcessInfo process) {
-        StringBuilder sb = new StringBuilder();
-        sb.append("You are an Apache Camel assistant. ");
-        sb.append("You help users build, understand, and troubleshoot Camel applications.\n\n");
-
-        if (process != null) {
-            sb.append("You are connected to a running Camel application: ");
-            sb.append(process.name()).append(" (PID ").append(process.pid()).append("). ");
-            sb.append("Use the runtime inspection tools to gather information about it.\n\n");
-        } else {
-            List<RuntimeHelper.ProcessInfo> available = RuntimeHelper.discoverProcesses();
-            if (!available.isEmpty()) {
-                sb.append("No Camel process is currently selected. ");
-                sb.append("Use list_processes to see available processes, then select_process to connect to one. ");
-                sb.append("Runtime inspection tools will not work until a process is selected.\n\n");
-            }
-        }
-
-        sb.append("You can search the Camel catalog (components, EIPs), browse examples, ");
-        sb.append("read/write files, and execute any Camel CLI command.\n\n");
-        sb.append("For CLI commands beyond the built-in tools, use cli_list_commands to discover ");
-        sb.append("available commands, cli_command_help to see options, and cli_exec to run them.\n\n");
-        sb.append("Guidelines:\n");
-        sb.append("- When creating routes, use YAML DSL format (Camel's recommended format for JBang)\n");
-        sb.append("- Look at existing files first with list_files/read_file before creating new ones\n");
-        sb.append("- Use catalog tools to look up component syntax before writing routes\n");
-        sb.append("- Use examples as reference when building new routes\n");
-        sb.append("- Be concise and actionable in your answers\n");
-        sb.append("- Format output as plain text for terminal display, do not use markdown\n");
-        if (process != null) {
-            sb.append("- Start by gathering relevant information using the available runtime tools\n");
-            sb.append("- If something looks wrong, explain what it means and suggest fixes\n");
-            sb.append("- To stop routes or the application, always use the provided tools ");
-            sb.append("(stop_route, stop_application) for graceful shutdown. Never suggest kill or kill -9.\n");
-        }
-        return sb.toString();
+    public void setTargetPid(long targetPid) {
+        this.targetPid = targetPid;
     }
 
     // ---- Tool definitions ----
 
-    private List<LlmClient.ToolDef> buildToolDefinitions() {
+    public List<LlmClient.ToolDef> buildToolDefinitions() {
         List<LlmClient.ToolDef> tools = new ArrayList<>();
 
-        // Process discovery and selection
         tools.add(new LlmClient.ToolDef(
                 "list_processes",
                 "List all running Camel processes with their PID and name. Use this to discover available processes before selecting one.",
@@ -336,7 +80,6 @@ public class Ask extends CamelCommand {
                 objectParams(Map.of(
                         "name", stringProp("Name or PID of the Camel process to connect to")))));
 
-        // Status-file tools (no parameters needed)
         tools.add(new LlmClient.ToolDef(
                 "get_context",
                 "Get Camel context info: name, version, state, uptime, route count, exchange statistics.",
@@ -370,7 +113,6 @@ public class Ask extends CamelCommand {
                 "Show configuration properties of the running Camel application.",
                 emptyParams()));
 
-        // IPC action tools (with parameters)
         tools.add(new LlmClient.ToolDef(
                 "get_route_source",
                 "Get the source code of routes. Use filter to limit by filename (supports wildcards).",
@@ -397,7 +139,6 @@ public class Ask extends CamelCommand {
                 objectParams(Map.of(
                         "action", stringProp("Action: enable, disable, or dump")))));
 
-        // Route lifecycle tools
         tools.add(new LlmClient.ToolDef(
                 "stop_route",
                 "Gracefully stop a route. The route will finish processing in-flight exchanges before stopping.",
@@ -419,13 +160,11 @@ public class Ask extends CamelCommand {
                 objectParams(Map.of(
                         "routeId", stringProp("The ID of the route to resume")))));
 
-        // Application lifecycle
         tools.add(new LlmClient.ToolDef(
                 "stop_application",
                 "Gracefully stop the Camel application. The application will finish processing in-flight exchanges and shut down cleanly. Use this instead of kill.",
                 emptyParams()));
 
-        // Catalog tools
         tools.add(new LlmClient.ToolDef(
                 "catalog_components",
                 "Search the Camel component catalog by name or label. Returns component name, title, description, and labels.",
@@ -443,7 +182,6 @@ public class Ask extends CamelCommand {
                 objectParams(Map.of(
                         "filter", stringProp("Filter by name, title, or description (case-insensitive substring)")))));
 
-        // Example tools
         tools.add(new LlmClient.ToolDef(
                 "list_examples",
                 "List available Camel CLI examples. Returns name, title, description, difficulty level, and tags.",
@@ -457,7 +195,6 @@ public class Ask extends CamelCommand {
                         "example", stringProp("Example name (e.g., timer-log, rest-api, circuit-breaker)"),
                         "file", stringProp("File name within the example (e.g., route.camel.yaml)")))));
 
-        // CLI tools (access to all camel CLI commands)
         tools.add(new LlmClient.ToolDef(
                 "cli_list_commands",
                 "List available Camel CLI commands. Returns command names and descriptions. Use filter to narrow results.",
@@ -475,7 +212,6 @@ public class Ask extends CamelCommand {
                         "command", stringProp(
                                 "The full command line to execute (e.g., 'get error --diagram', 'catalog component --filter=kafka')")))));
 
-        // File tools
         tools.add(new LlmClient.ToolDef(
                 "list_files",
                 "List files in a directory (up to 2 levels deep). Defaults to current working directory.",
@@ -498,10 +234,9 @@ public class Ask extends CamelCommand {
 
     // ---- Tool execution ----
 
-    private String executeTool(String name, JsonObject args) {
+    public String executeTool(String name, JsonObject args) {
         try {
             return switch (name) {
-                // Runtime tools (require a running process)
                 case "list_processes" -> executeListProcesses();
                 case "select_process" -> executeSelectProcess(args);
                 case "get_context" ->
@@ -531,18 +266,14 @@ public class Ask extends CamelCommand {
                 case "suspend_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "suspend");
                 case "resume_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "resume");
                 case "stop_application" -> targetPid < 0 ? NO_PROCESS : RuntimeHelper.stopApplication(targetPid);
-                // Catalog tools
                 case "catalog_components" -> executeCatalogComponents(args);
                 case "catalog_component_doc" -> executeCatalogComponentDoc(args);
                 case "catalog_eips" -> executeCatalogEips(args);
-                // Example tools
                 case "list_examples" -> executeListExamples(args);
                 case "get_example_file" -> executeGetExampleFile(args);
-                // CLI tools
                 case "cli_list_commands" -> executeCliListCommands(args);
                 case "cli_command_help" -> executeCliCommandHelp(args);
                 case "cli_exec" -> executeCliExec(args);
-                // File tools
                 case "list_files" -> executeListFiles(args);
                 case "read_file" -> executeReadFile(args);
                 case "write_file" -> executeWriteFile(args);
@@ -553,6 +284,48 @@ public class Ask extends CamelCommand {
         }
     }
 
+    // ---- System prompt ----
+
+    public static String buildSystemPrompt(long targetPid, String processName) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("You are an Apache Camel assistant. ");
+        sb.append("You help users build, understand, and troubleshoot Camel applications.\n\n");
+
+        if (targetPid >= 0 && processName != null) {
+            sb.append("You are connected to a running Camel application: ");
+            sb.append(processName).append(" (PID ").append(targetPid).append("). ");
+            sb.append("Use the runtime inspection tools to gather information about it.\n\n");
+        } else {
+            List<RuntimeHelper.ProcessInfo> available = RuntimeHelper.discoverProcesses();
+            if (!available.isEmpty()) {
+                sb.append("No Camel process is currently selected. ");
+                sb.append("Use list_processes to see available processes, then select_process to connect to one. ");
+                sb.append("Runtime inspection tools will not work until a process is selected.\n\n");
+            }
+        }
+
+        sb.append("You can search the Camel catalog (components, EIPs), browse examples, ");
+        sb.append("read/write files, and execute any Camel CLI command.\n\n");
+        sb.append("For CLI commands beyond the built-in tools, use cli_list_commands to discover ");
+        sb.append("available commands, cli_command_help to see options, and cli_exec to run them.\n\n");
+        sb.append("Guidelines:\n");
+        sb.append("- When creating routes, use YAML DSL format (Camel's recommended format for JBang)\n");
+        sb.append("- Look at existing files first with list_files/read_file before creating new ones\n");
+        sb.append("- Use catalog tools to look up component syntax before writing routes\n");
+        sb.append("- Use examples as reference when building new routes\n");
+        sb.append("- Be concise and actionable in your answers\n");
+        sb.append("- Format output as plain text for terminal display, do not use markdown\n");
+        if (targetPid >= 0) {
+            sb.append("- Start by gathering relevant information using the available runtime tools\n");
+            sb.append("- If something looks wrong, explain what it means and suggest fixes\n");
+            sb.append("- To stop routes or the application, always use the provided tools ");
+            sb.append("(stop_route, stop_application) for graceful shutdown. Never suggest kill or kill -9.\n");
+        }
+        return sb.toString();
+    }
+
+    // ---- Runtime tool execution ----
+
     private String executeListProcesses() {
         List<RuntimeHelper.ProcessInfo> processes = RuntimeHelper.discoverProcesses();
         if (processes.isEmpty()) {
@@ -646,10 +419,10 @@ public class Ask extends CamelCommand {
     private String executeCatalogComponents(JsonObject args) {
         String filter = args.getString("filter");
         String label = args.getString("label");
-        CamelCatalog catalog = getCatalog();
+        CamelCatalog cat = getCatalog();
 
-        List<JsonObject> results = catalog.findComponentNames().stream()
-                .map(catalog::componentModel)
+        List<JsonObject> results = cat.findComponentNames().stream()
+                .map(cat::componentModel)
                 .filter(m -> m != null)
                 .filter(m -> matchesFilter(m.getScheme(), m.getTitle(), m.getDescription(), filter))
                 .filter(m -> label == null || label.isBlank()
@@ -676,8 +449,8 @@ public class Ask extends CamelCommand {
         if (component == null || component.isBlank()) {
             return "Error: component name is required";
         }
-        CamelCatalog catalog = getCatalog();
-        ComponentModel model = catalog.componentModel(component);
+        CamelCatalog cat = getCatalog();
+        ComponentModel model = cat.componentModel(component);
         if (model == null) {
             return "Component not found: " + component;
         }
@@ -712,10 +485,10 @@ public class Ask extends CamelCommand {
 
     private String executeCatalogEips(JsonObject args) {
         String filter = args.getString("filter");
-        CamelCatalog catalog = getCatalog();
+        CamelCatalog cat = getCatalog();
 
-        List<JsonObject> results = catalog.findModelNames().stream()
-                .map(catalog::eipModel)
+        List<JsonObject> results = cat.findModelNames().stream()
+                .map(cat::eipModel)
                 .filter(m -> m != null)
                 .filter(m -> matchesFilter(m.getName(), m.getTitle(), m.getDescription(), filter))
                 .limit(20)
@@ -735,16 +508,6 @@ public class Ask extends CamelCommand {
         return response.toJson();
     }
 
-    private static boolean matchesFilter(String name, String title, String description, String filter) {
-        if (filter == null || filter.isBlank()) {
-            return true;
-        }
-        String lf = filter.toLowerCase();
-        return (name != null && name.toLowerCase().contains(lf))
-                || (title != null && title.toLowerCase().contains(lf))
-                || (description != null && description.toLowerCase().contains(lf));
-    }
-
     // ---- Example tools ----
 
     @SuppressWarnings("unchecked")
@@ -752,8 +515,8 @@ public class Ask extends CamelCommand {
         String filter = args.getString("filter");
         String level = args.getString("level");
 
-        List<JsonObject> catalog = ExampleHelper.loadCatalog();
-        List<JsonObject> filtered = ExampleHelper.filterExamples(catalog, filter);
+        List<JsonObject> catalog2 = ExampleHelper.loadCatalog();
+        List<JsonObject> filtered = ExampleHelper.filterExamples(catalog2, filter);
 
         List<JsonObject> results = new ArrayList<>();
         for (JsonObject entry : filtered) {
@@ -794,8 +557,8 @@ public class Ask extends CamelCommand {
             return "Error: file name is required";
         }
 
-        List<JsonObject> catalog = ExampleHelper.loadCatalog();
-        JsonObject entry = ExampleHelper.findExample(catalog, example);
+        List<JsonObject> catalog2 = ExampleHelper.loadCatalog();
+        JsonObject entry = ExampleHelper.findExample(catalog2, example);
         if (entry == null) {
             return "Example not found: " + example;
         }
@@ -843,47 +606,16 @@ public class Ask extends CamelCommand {
                 return commandMetadataCache;
             }
         } catch (Exception e) {
-            printer().printErr("Failed to load CLI command metadata: " + e.getMessage());
+            // ignore
         }
         return List.of();
     }
 
-    @SuppressWarnings("unchecked")
-    static void collectCommands(List<JsonObject> commands, List<JsonObject> result, String filter) {
-        for (JsonObject cmd : commands) {
-            String fullName = cmd.getString("fullName");
-            String description = cmd.getString("description");
-            boolean matches = filter == null || filter.isBlank()
-                    || (fullName != null && fullName.toLowerCase().contains(filter.toLowerCase()))
-                    || (description != null && description.toLowerCase().contains(filter.toLowerCase()));
-            if (matches) {
-                JsonObject entry = new JsonObject();
-                entry.put("command", fullName);
-                entry.put("description", description);
-                Object subs = cmd.get("subcommands");
-                if (subs instanceof Collection<?> subList && !subList.isEmpty()) {
-                    entry.put("hasSubcommands", true);
-                    entry.put("subcommandCount", subList.size());
-                }
-                result.add(entry);
-            }
-            Object subs = cmd.get("subcommands");
-            if (subs instanceof Collection<?>) {
-                collectCommands(
-                        ((Collection<Object>) subs).stream()
-                                .filter(JsonObject.class::isInstance)
-                                .map(JsonObject.class::cast)
-                                .toList(),
-                        result, filter);
-            }
-        }
-    }
-
     private String executeCliListCommands(JsonObject args) {
         String filter = args.getString("filter");
         List<JsonObject> commands = loadCommandMetadata();
         List<JsonObject> result = new ArrayList<>();
-        collectCommands(commands, result, filter);
+        Ask.collectCommands(commands, result, filter);
 
         JsonObject response = new JsonObject();
         response.put("count", result.size());
@@ -891,28 +623,6 @@ public class Ask extends CamelCommand {
         return response.toJson();
     }
 
-    @SuppressWarnings("unchecked")
-    static JsonObject findCommand(List<JsonObject> commands, String fullName) {
-        for (JsonObject cmd : commands) {
-            if (fullName.equals(cmd.getString("fullName"))) {
-                return cmd;
-            }
-            Object subs = cmd.get("subcommands");
-            if (subs instanceof Collection<?>) {
-                JsonObject found = findCommand(
-                        ((Collection<Object>) subs).stream()
-                                .filter(JsonObject.class::isInstance)
-                                .map(JsonObject.class::cast)
-                                .toList(),
-                        fullName);
-                if (found != null) {
-                    return found;
-                }
-            }
-        }
-        return null;
-    }
-
     @SuppressWarnings("unchecked")
     private String executeCliCommandHelp(JsonObject args) {
         String command = args.getString("command");
@@ -921,7 +631,7 @@ public class Ask extends CamelCommand {
         }
 
         List<JsonObject> commands = loadCommandMetadata();
-        JsonObject cmd = findCommand(commands, command);
+        JsonObject cmd = Ask.findCommand(commands, command);
         if (cmd == null) {
             return "Command not found: " + command + ". Use cli_list_commands to see available commands.";
         }
@@ -979,9 +689,8 @@ public class Ask extends CamelCommand {
             return "Error: CLI not available";
         }
 
-        String[] cmdArgs = tokenizeCommand(command.trim());
+        String[] cmdArgs = Ask.tokenizeCommand(command.trim());
 
-        // capture output by temporarily swapping the Printer on main
         StringBuilder captured = new StringBuilder();
         Printer capturingPrinter = new Printer() {
             @Override
@@ -1005,7 +714,7 @@ public class Ask extends CamelCommand {
             }
         };
 
-        // also capture PicoCLI's own output (usage/help text)
+        CamelJBangMain main = (CamelJBangMain) commandLine.getCommand();
         StringWriter sw = new StringWriter();
         PrintWriter pw = new PrintWriter(sw);
         PrintWriter originalOut = commandLine.getOut();
@@ -1013,8 +722,8 @@ public class Ask extends CamelCommand {
         commandLine.setOut(pw);
         commandLine.setErr(pw);
 
-        Printer originalPrinter = getMain().getOut();
-        getMain().setOut(capturingPrinter);
+        Printer originalPrinter = main.getOut();
+        main.setOut(capturingPrinter);
         try {
             int exitCode = commandLine.execute(cmdArgs);
             pw.flush();
@@ -1029,39 +738,12 @@ public class Ask extends CamelCommand {
         } catch (Exception e) {
             return "Error executing command: " + e.getMessage();
         } finally {
-            getMain().setOut(originalPrinter);
+            main.setOut(originalPrinter);
             commandLine.setOut(originalOut);
             commandLine.setErr(originalErr);
         }
     }
 
-    static String[] tokenizeCommand(String command) {
-        List<String> tokens = new ArrayList<>();
-        StringBuilder current = new StringBuilder();
-        boolean inSingleQuote = false;
-        boolean inDoubleQuote = false;
-
-        for (int i = 0; i < command.length(); i++) {
-            char c = command.charAt(i);
-            if (c == '\'' && !inDoubleQuote) {
-                inSingleQuote = !inSingleQuote;
-            } else if (c == '"' && !inSingleQuote) {
-                inDoubleQuote = !inDoubleQuote;
-            } else if (Character.isWhitespace(c) && !inSingleQuote && !inDoubleQuote) {
-                if (!current.isEmpty()) {
-                    tokens.add(current.toString());
-                    current.setLength(0);
-                }
-            } else {
-                current.append(c);
-            }
-        }
-        if (!current.isEmpty()) {
-            tokens.add(current.toString());
-        }
-        return tokens.toArray(String[]::new);
-    }
-
     // ---- File tools ----
 
     private String executeListFiles(JsonObject args) throws IOException {
@@ -1143,16 +825,16 @@ public class Ask extends CamelCommand {
         return catalog;
     }
 
-    // ---- JSON schema helpers for tool parameters ----
+    // ---- JSON schema helpers ----
 
-    private static JsonObject emptyParams() {
+    public static JsonObject emptyParams() {
         JsonObject schema = new JsonObject();
         schema.put("type", "object");
         schema.put("properties", new JsonObject());
         return schema;
     }
 
-    private static JsonObject objectParams(Map<String, JsonObject> properties) {
+    public static JsonObject objectParams(Map<String, JsonObject> properties) {
         JsonObject props = new JsonObject();
         Map<String, JsonObject> ordered = new LinkedHashMap<>(properties);
         for (Map.Entry<String, JsonObject> entry : ordered.entrySet()) {
@@ -1164,17 +846,20 @@ public class Ask extends CamelCommand {
         return schema;
     }
 
-    private static JsonObject stringProp(String description) {
+    public static JsonObject stringProp(String description) {
         JsonObject prop = new JsonObject();
         prop.put("type", "string");
         prop.put("description", description);
         return prop;
     }
 
-    private static String truncate(String text, int maxLen) {
-        if (text == null) {
-            return "null";
+    static boolean matchesFilter(String name, String title, String description, String filter) {
+        if (filter == null || filter.isBlank()) {
+            return true;
         }
-        return text.length() <= maxLen ? text : text.substring(0, maxLen) + "...";
+        String lf = filter.toLowerCase();
+        return (name != null && name.toLowerCase().contains(lf))
+                || (title != null && title.toLowerCase().contains(lf))
+                || (description != null && description.toLowerCase().contains(lf));
     }
 }
diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
index ac87f338677c..d2abee4f8f44 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/LlmClient.java
@@ -42,7 +42,7 @@ import org.apache.camel.util.json.Jsoner;
 /**
  * Shared LLM HTTP client supporting Ollama, OpenAI-compatible, and Anthropic (including Vertex AI) APIs.
  */
-class LlmClient {
+public class LlmClient {
 
     private static final String DEFAULT_OLLAMA_URL = "http://localhost:11434";
     private static final String DEFAULT_ANTHROPIC_URL = "https://api.anthropic.com";
@@ -58,7 +58,7 @@ class LlmClient {
             "claude-opus-4-5", "claude-opus-4-5@20251101",
             "claude-haiku-4-5", "claude-haiku-4-5@20251001");
 
-    enum ApiType {
+    public enum ApiType {
         ollama,
         openai,
         anthropic
@@ -66,31 +66,31 @@ class LlmClient {
 
     // -- Unified abstractions for tool-calling across API formats --
 
-    record ToolDef(String name, String description, JsonObject parameters) {
+    public record ToolDef(String name, String description, JsonObject parameters) {
     }
 
-    record ToolCall(String id, String name, JsonObject arguments) {
+    public record ToolCall(String id, String name, JsonObject arguments) {
     }
 
-    record ToolResult(String toolCallId, String content) {
+    public record ToolResult(String toolCallId, String content) {
     }
 
-    record Message(String role, String content, List<ToolCall> toolCalls, List<ToolResult> toolResults) {
+    public record Message(String role, String content, List<ToolCall> toolCalls, List<ToolResult> toolResults) {
 
-        static Message user(String text) {
+        public static Message user(String text) {
             return new Message("user", text, null, null);
         }
 
-        static Message assistantWithToolCalls(String text, List<ToolCall> calls) {
+        public static Message assistantWithToolCalls(String text, List<ToolCall> calls) {
             return new Message("assistant", text, calls, null);
         }
 
-        static Message toolResults(List<ToolResult> results) {
+        public static Message toolResults(List<ToolResult> results) {
             return new Message("tool", null, null, results);
         }
     }
 
-    record ChatResponse(String text, List<ToolCall> toolCalls, String stopReason, boolean streamed) {
+    public record ChatResponse(String text, List<ToolCall> toolCalls, String stopReason, boolean streamed) {
     }
 
     // -- Configuration --
@@ -104,7 +104,23 @@ class LlmClient {
     boolean stream;
     int maxTokens;
     boolean verbose;
-    Printer printer;
+    Printer printer = new Printer() {
+        @Override
+        public void println() {
+        }
+
+        @Override
+        public void println(String line) {
+        }
+
+        @Override
+        public void print(String output) {
+        }
+
+        @Override
+        public void printf(String format, Object... args) {
+        }
+    };
 
     private final HttpClient httpClient = HttpClient.newBuilder()
             .connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
@@ -116,63 +132,63 @@ class LlmClient {
 
     // -- Builder --
 
-    static LlmClient create() {
+    public static LlmClient create() {
         return new LlmClient();
     }
 
-    LlmClient withApiType(ApiType apiType) {
+    public LlmClient withApiType(ApiType apiType) {
         this.apiType = apiType;
         return this;
     }
 
-    LlmClient withUrl(String url) {
+    public LlmClient withUrl(String url) {
         this.url = url;
         return this;
     }
 
-    LlmClient withApiKey(String apiKey) {
+    public LlmClient withApiKey(String apiKey) {
         this.apiKey = apiKey;
         return this;
     }
 
-    LlmClient withModel(String model) {
+    public LlmClient withModel(String model) {
         this.model = model;
         return this;
     }
 
-    LlmClient withTimeout(int timeout) {
+    public LlmClient withTimeout(int timeout) {
         this.timeout = timeout;
         return this;
     }
 
-    LlmClient withTemperature(double temperature) {
+    public LlmClient withTemperature(double temperature) {
         this.temperature = temperature;
         return this;
     }
 
-    LlmClient withStream(boolean stream) {
+    public LlmClient withStream(boolean stream) {
         this.stream = stream;
         return this;
     }
 
-    LlmClient withMaxTokens(int maxTokens) {
+    public LlmClient withMaxTokens(int maxTokens) {
         this.maxTokens = maxTokens;
         return this;
     }
 
-    LlmClient withVerbose(boolean verbose) {
+    public LlmClient withVerbose(boolean verbose) {
         this.verbose = verbose;
         return this;
     }
 
-    LlmClient withPrinter(Printer printer) {
+    public LlmClient withPrinter(Printer printer) {
         this.printer = printer;
         return this;
     }
 
     // -- Auto-detection --
 
-    boolean detectEndpoint() {
+    public boolean detectEndpoint() {
         boolean found;
         if (tryExplicitUrl()) {
             found = true;
@@ -232,7 +248,7 @@ class LlmClient {
 
     // -- Chat with tools (for ask) --
 
-    ChatResponse chatWithTools(String systemPrompt, List<Message> messages, List<ToolDef> tools) {
+    public ChatResponse chatWithTools(String systemPrompt, List<Message> messages, List<ToolDef> tools) {
         return switch (apiType) {
             case ollama -> chatOllamaFormat(systemPrompt, messages, tools);
             case openai -> chatOpenAiFormat(systemPrompt, messages, tools);
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
new file mode 100644
index 000000000000..52d2257bd676
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/AiPanel.java
@@ -0,0 +1,406 @@
+/*
+ * 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 java.util.concurrent.atomic.AtomicBoolean;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.markdown.MarkdownView;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.block.Block;
+import dev.tamboui.widgets.block.BorderType;
+import dev.tamboui.widgets.block.Borders;
+import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.dsl.jbang.core.commands.AskTools;
+import org.apache.camel.dsl.jbang.core.commands.LlmClient;
+
+/**
+ * AI prompt panel for the TUI. Communicates directly with an LLM via {@link LlmClient} and uses the same tool
+ * definitions as {@code camel ask}. Toggled with F8 when the TUI runs with {@code --mcp} mode.
+ */
+class AiPanel {
+
+    private static final int[] SPLIT_PERCENTS = { 25, 50, 75, 100 };
+    private static final int MAX_ITERATIONS = 10;
+
+    private boolean visible;
+    private int splitIndex = 1; // default 50%
+    private MonitorContext ctx;
+
+    // Input state
+    private final StringBuilder inputBuffer = new StringBuilder();
+    private int cursorPos;
+
+    // Conversation display
+    private final List<ConversationEntry> conversation = new ArrayList<>();
+    private int scrollOffset;
+
+    // LLM state
+    private LlmClient client;
+    private List<LlmClient.Message> messages;
+    private List<LlmClient.ToolDef> tools;
+    private AskTools askTools;
+    private final AtomicBoolean thinking = new AtomicBoolean();
+    private volatile Thread agentThread;
+    private String initError;
+
+    record ConversationEntry(String role, String text) {
+    }
+
+    void setContext(MonitorContext ctx) {
+        this.ctx = ctx;
+    }
+
+    boolean isOpen() {
+        return visible;
+    }
+
+    int panelPercent() {
+        return SPLIT_PERCENTS[splitIndex];
+    }
+
+    void cycleHeight() {
+        splitIndex = (splitIndex + 1) % SPLIT_PERCENTS.length;
+    }
+
+    void open() {
+        visible = true;
+        if (client == null) {
+            initClient();
+        }
+    }
+
+    void close() {
+        visible = false;
+    }
+
+    void destroy() {
+        close();
+        Thread t = agentThread;
+        if (t != null) {
+            t.interrupt();
+        }
+    }
+
+    private void initClient() {
+        try {
+            client = LlmClient.create()
+                    .withTemperature(0.3)
+                    .withTimeout(120)
+                    .withMaxTokens(4096);
+            if (!client.detectEndpoint()) {
+                initError = "No LLM service reachable. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, or start Ollama.";
+                client = null;
+                return;
+            }
+            initError = null;
+            messages = new ArrayList<>();
+            long pid = ctx != null && ctx.selectedPid != null ? Long.parseLong(ctx.selectedPid) : -1;
+            String name = ctx != null ? ctx.selectedName() : null;
+            askTools = new AskTools(pid);
+            tools = askTools.buildToolDefinitions();
+        } catch (Exception e) {
+            initError = "Failed to initialize AI: " + e.getMessage();
+            client = null;
+        }
+    }
+
+    boolean handleKeyEvent(KeyEvent ke) {
+        if (ke.isKey(KeyCode.F8)) {
+            close();
+            return true;
+        }
+        if (ke.isKey(KeyCode.PAGE_UP)) {
+            scrollOffset += 5;
+            return true;
+        }
+        if (ke.isKey(KeyCode.PAGE_DOWN)) {
+            scrollOffset = Math.max(0, scrollOffset - 5);
+            return true;
+        }
+        if (thinking.get()) {
+            if (ke.isCtrlC()) {
+                Thread t = agentThread;
+                if (t != null) {
+                    t.interrupt();
+                }
+                thinking.set(false);
+                conversation.add(new ConversationEntry("system", "(cancelled)"));
+                return true;
+            }
+            return true;
+        }
+        if (ke.isKey(KeyCode.ENTER)) {
+            if (!inputBuffer.isEmpty()) {
+                submitQuestion();
+            }
+            return true;
+        }
+        if (ke.isKey(KeyCode.BACKSPACE)) {
+            if (cursorPos > 0) {
+                inputBuffer.deleteCharAt(cursorPos - 1);
+                cursorPos--;
+            }
+            return true;
+        }
+        if (ke.isKey(KeyCode.DELETE)) {
+            if (cursorPos < inputBuffer.length()) {
+                inputBuffer.deleteCharAt(cursorPos);
+            }
+            return true;
+        }
+        if (ke.isKey(KeyCode.LEFT)) {
+            if (cursorPos > 0) {
+                cursorPos--;
+            }
+            return true;
+        }
+        if (ke.isKey(KeyCode.RIGHT)) {
+            if (cursorPos < inputBuffer.length()) {
+                cursorPos++;
+            }
+            return true;
+        }
+        if (ke.isKey(KeyCode.HOME)) {
+            cursorPos = 0;
+            return true;
+        }
+        if (ke.isKey(KeyCode.END)) {
+            cursorPos = inputBuffer.length();
+            return true;
+        }
+        if (ke.code() == KeyCode.CHAR && !ke.hasCtrl() && !ke.hasAlt()) {
+            inputBuffer.insert(cursorPos, ke.character());
+            cursorPos++;
+            return true;
+        }
+        return true;
+    }
+
+    private void submitQuestion() {
+        String question = inputBuffer.toString().trim();
+        inputBuffer.setLength(0);
+        cursorPos = 0;
+        scrollOffset = 0;
+
+        conversation.add(new ConversationEntry("user", question));
+        thinking.set(true);
+
+        // rebuild tools if target process changed
+        long pid = ctx != null && ctx.selectedPid != null ? Long.parseLong(ctx.selectedPid) : -1;
+        String name = ctx != null ? ctx.selectedName() : null;
+        askTools = new AskTools(pid);
+        tools = askTools.buildToolDefinitions();
+        String systemPrompt = AskTools.buildSystemPrompt(pid, name);
+
+        agentThread = new Thread(() -> {
+            try {
+                runAgentLoop(systemPrompt, question);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            } catch (Exception e) {
+                conversation.add(new ConversationEntry("error", e.getMessage()));
+            } finally {
+                thinking.set(false);
+                agentThread = null;
+            }
+        }, "tui-ai-agent");
+        agentThread.setDaemon(true);
+        agentThread.start();
+    }
+
+    private void runAgentLoop(String systemPrompt, String question) throws InterruptedException {
+        if (messages == null) {
+            messages = new ArrayList<>();
+        }
+        messages.add(LlmClient.Message.user(question));
+
+        for (int i = 0; i < MAX_ITERATIONS; i++) {
+            if (Thread.interrupted()) {
+                throw new InterruptedException();
+            }
+
+            LlmClient.ChatResponse response = client.chatWithTools(systemPrompt, messages, tools);
+            if (response == null) {
+                conversation.add(new ConversationEntry("error", "No response from LLM"));
+                return;
+            }
+
+            // check for error response (null text, no tool calls, error stop reason)
+            if ("error".equals(response.stopReason())
+                    && (response.toolCalls() == null || response.toolCalls().isEmpty())
+                    && response.text() == null) {
+                conversation.add(new ConversationEntry("error", "LLM request failed. Check API key and endpoint."));
+                return;
+            }
+
+            if (response.toolCalls() != null && !response.toolCalls().isEmpty()) {
+                messages.add(LlmClient.Message.assistantWithToolCalls(response.text(), response.toolCalls()));
+
+                List<LlmClient.ToolResult> results = new ArrayList<>();
+                for (LlmClient.ToolCall toolCall : response.toolCalls()) {
+                    if (Thread.interrupted()) {
+                        throw new InterruptedException();
+                    }
+                    String result = askTools.executeTool(toolCall.name(), toolCall.arguments());
+                    results.add(new LlmClient.ToolResult(toolCall.id(), result));
+                }
+                messages.add(LlmClient.Message.toolResults(results));
+            } else {
+                String text = response.text();
+                if (text != null && !text.isBlank()) {
+                    conversation.add(new ConversationEntry("assistant", text));
+                } else {
+                    conversation.add(new ConversationEntry("error", "Empty response from LLM."));
+                }
+                messages.add(LlmClient.Message.assistantWithToolCalls(text, List.of()));
+                return;
+            }
+        }
+        conversation.add(new ConversationEntry(
+                "error",
+                "Reached maximum iterations (" + MAX_ITERATIONS + ") without a final answer."));
+    }
+
+    void render(Frame frame, Rect area) {
+        Block block = Block.builder()
+                .borders(Borders.ALL)
+                .borderType(BorderType.ROUNDED)
+                .title(Title.from(Line.from(Span.styled(" AI ", Style.EMPTY.bold()))))
+                .build();
+        frame.renderWidget(block, area);
+        Rect inner = block.inner(area);
+        if (inner.height() < 2) {
+            return;
+        }
+
+        // Split inner area: conversation (fill) + separator (1 row) + input (1 row) + padding (1 row)
+        List<Rect> parts = Layout.vertical()
+                .constraints(Constraint.fill(), Constraint.length(1), Constraint.length(1), Constraint.length(1))
+                .split(inner);
+        Rect conversationArea = parts.get(0);
+        Rect separatorArea = parts.get(1);
+        Rect inputArea = parts.get(2);
+        // parts.get(3) is empty padding row above the bottom border
+
+        renderConversation(frame, conversationArea);
+        // horizontal line separator
+        String line = "─".repeat(separatorArea.width());
+        frame.renderWidget(Paragraph.from(Line.from(Span.styled(line, Style.EMPTY.dim()))),
+                separatorArea);
+        renderInput(frame, inputArea);
+    }
+
+    private void renderConversation(Frame frame, Rect area) {
+        if (area.height() < 1) {
+            return;
+        }
+
+        StringBuilder md = new StringBuilder();
+
+        if (initError != null) {
+            md.append("**Error:** ").append(initError).append("\n\n");
+        } else if (conversation.isEmpty() && !thinking.get()) {
+            md.append("*Ask a question about your Camel application...*\n");
+        }
+
+        for (ConversationEntry entry : conversation) {
+            switch (entry.role()) {
+                case "user" -> md.append("**You:** ").append(entry.text()).append("\n\n");
+                case "assistant" -> md.append(entry.text()).append("\n\n");
+                case "error" -> md.append("**Error:** ").append(entry.text()).append("\n\n");
+                case "system" -> md.append("*").append(entry.text()).append("*\n\n");
+                default -> {
+                }
+            }
+        }
+
+        if (thinking.get()) {
+            long dots = (System.currentTimeMillis() / 500) % 4;
+            md.append("*🤔 thinking").append(".".repeat((int) dots + 1)).append("*\n");
+        }
+
+        MarkdownView view = MarkdownView.builder()
+                .source(md.toString())
+                .scroll(scrollOffset)
+                .build();
+        frame.renderWidget(view, area);
+    }
+
+    private void renderInput(Frame frame, Rect area) {
+        String prompt = "> ";
+        String text = inputBuffer.toString();
+
+        List<Span> spans = new ArrayList<>();
+        spans.add(Span.styled(prompt, Style.EMPTY.fg(Color.CYAN).bold()));
+
+        if (thinking.get()) {
+            spans.add(Span.styled(text, Style.EMPTY.dim()));
+        } else {
+            // Render with cursor
+            int maxWidth = area.width() - prompt.length();
+            if (maxWidth <= 0) {
+                return;
+            }
+            // Ensure cursor is visible by adjusting text window
+            int windowStart = 0;
+            if (cursorPos > maxWidth - 1) {
+                windowStart = cursorPos - maxWidth + 1;
+            }
+            String visible = text.substring(windowStart,
+                    Math.min(text.length(), windowStart + maxWidth));
+            int cursorInWindow = cursorPos - windowStart;
+
+            if (cursorInWindow >= 0 && cursorInWindow < visible.length()) {
+                spans.add(Span.raw(visible.substring(0, cursorInWindow)));
+                spans.add(Span.styled(String.valueOf(visible.charAt(cursorInWindow)),
+                        Style.EMPTY.reversed()));
+                spans.add(Span.raw(visible.substring(cursorInWindow + 1)));
+            } else {
+                spans.add(Span.raw(visible));
+                if (cursorInWindow == visible.length()) {
+                    spans.add(Span.styled(" ", Style.EMPTY.reversed()));
+                }
+            }
+        }
+
+        frame.renderWidget(Paragraph.from(Line.from(spans)), area);
+    }
+
+    void renderFooter(List<Span> spans) {
+        MonitorContext.hint(spans, "F8", "close");
+        MonitorContext.hint(spans, "Shift+F8", panelPercent() + "%");
+        MonitorContext.hint(spans, "PgUp/Dn", "scroll");
+        if (!thinking.get()) {
+            MonitorContext.hint(spans, "Enter", "send");
+        } else {
+            MonitorContext.hint(spans, "Ctrl+C", "cancel");
+        }
+    }
+
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 2fac10ed617a..1be046368239 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -185,6 +185,7 @@ public class CamelMonitor extends CamelCommand {
     private final DrawOverlay drawOverlay = new DrawOverlay();
     private final HelpOverlay helpOverlay = new HelpOverlay();
     private final ShellPanel shellPanel = new ShellPanel();
+    private final AiPanel aiPanel = new AiPanel();
 
     private final ActionsPopup actionsPopup = new ActionsPopup(
             () -> data.get().stream()
@@ -280,6 +281,7 @@ public class CamelMonitor extends CamelCommand {
         actionsPopup.setContext(ctx);
         actionsPopup.setResetStatsAction(this::resetStats);
         shellPanel.setContext(ctx);
+        aiPanel.setContext(ctx);
         actionsPopup.setOpenShellAction(shellPanel::open);
         actionsPopup.setBrowseFilesAction(this::openFilesPopup);
         logTab = new LogTab(ctx);
@@ -347,6 +349,7 @@ public class CamelMonitor extends CamelCommand {
                     this::render);
         } finally {
             shellPanel.destroy();
+            aiPanel.destroy();
             if (mcpServer != null) {
                 mcpServer.stop();
             }
@@ -406,6 +409,13 @@ public class CamelMonitor extends CamelCommand {
                 }
                 return shellPanel.handleKeyEvent(ke);
             }
+            if (aiPanel.isOpen()) {
+                if (ke.isKey(KeyCode.F8) && ke.hasShift()) {
+                    aiPanel.cycleHeight();
+                    return true;
+                }
+                return aiPanel.handleKeyEvent(ke);
+            }
             if (actionsPopup.isVisible()) {
                 return actionsPopup.handleKeyEvent(ke);
             }
@@ -641,10 +651,24 @@ public class CamelMonitor extends CamelCommand {
             if (shellPanel.isOpen()) {
                 shellPanel.close();
             } else {
+                if (aiPanel.isOpen()) {
+                    aiPanel.close();
+                }
                 shellPanel.open();
             }
             return true;
         }
+        if (ke.isKey(KeyCode.F8) && mcp) {
+            if (aiPanel.isOpen()) {
+                aiPanel.close();
+            } else {
+                if (shellPanel.isOpen()) {
+                    shellPanel.close();
+                }
+                aiPanel.open();
+            }
+            return true;
+        }
         if (ke.isKey(KeyCode.F2)) {
             if (tabsState.selected() == TAB_ROUTES && routesTab != null) {
                 actionsPopup.setPreSelectedRouteId(routesTab.selectedRouteId());
@@ -1002,7 +1026,8 @@ public class CamelMonitor extends CamelCommand {
         renderHeader(frame, mainChunks.get(0));
         renderTabs(frame, mainChunks.get(1));
         Rect contentArea = mainChunks.get(2);
-        ctx.shellPercent = shellPanel.isOpen() ? shellPanel.panelPercent() : 0;
+        ctx.shellPercent = shellPanel.isOpen() ? shellPanel.panelPercent()
+                : aiPanel.isOpen() ? aiPanel.panelPercent() : 0;
         if (shellPanel.isOpen()) {
             List<Rect> splitChunks = Layout.vertical()
                     .constraints(Constraint.percentage(100 - shellPanel.panelPercent()),
@@ -1010,6 +1035,13 @@ public class CamelMonitor extends CamelCommand {
                     .split(contentArea);
             renderContent(frame, splitChunks.get(0));
             shellPanel.render(frame, splitChunks.get(1));
+        } else if (aiPanel.isOpen()) {
+            List<Rect> splitChunks = Layout.vertical()
+                    .constraints(Constraint.percentage(100 - aiPanel.panelPercent()),
+                            Constraint.percentage(aiPanel.panelPercent()))
+                    .split(contentArea);
+            renderContent(frame, splitChunks.get(0));
+            aiPanel.render(frame, splitChunks.get(1));
         } else {
             renderContent(frame, contentArea);
         }
@@ -1763,6 +1795,8 @@ public class CamelMonitor extends CamelCommand {
             hint(spans, "Esc", "close");
         } else if (shellPanel.isOpen()) {
             shellPanel.renderFooter(spans);
+        } else if (aiPanel.isOpen()) {
+            aiPanel.renderFooter(spans);
         } else {
             MonitorTab tab = activeTab();
 
@@ -1857,6 +1891,9 @@ public class CamelMonitor extends CamelCommand {
             hint(fKeySpans, "F3", "switch");
         }
         hint(fKeySpans, "F6", "shell");
+        if (mcp) {
+            hint(fKeySpans, "F8", "AI");
+        }
         spans.addAll(insertPos, fKeySpans);
         // Return total F-key span count. The footer drop loop uses this to remove pairs from
         // the tail (F6, then F3, F2), stopping before the first pair (F1 help when present).


(camel) 05/06: CAMEL-23855: camel-jbang - Add missing MCP tools to AI assistant

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-23855
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 92b4f3c20c1ef332d369a0e4f48934a250724d05
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Jun 29 18:45:44 2026 +0200

    CAMEL-23855: camel-jbang - Add missing MCP tools to AI assistant
    
    Co-Authored-By: Claude <no...@anthropic.com>
    Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
 .../camel/dsl/jbang/core/commands/AskTools.java    | 130 +++++++++++++++++++++
 1 file changed, 130 insertions(+)

diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
index 50c196a83a92..05a89ddddbe4 100644
--- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
+++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/AskTools.java
@@ -112,6 +112,26 @@ public class AskTools {
                 "get_properties",
                 "Show configuration properties of the running Camel application.",
                 emptyParams()));
+        tools.add(new LlmClient.ToolDef(
+                "get_memory",
+                "Show JVM memory usage (heap/non-heap), garbage collection stats, and thread counts.",
+                emptyParams()));
+        tools.add(new LlmClient.ToolDef(
+                "get_errors",
+                "Get captured routing errors from the running Camel application. Returns error details including exception, exchange context, and route information.",
+                emptyParams()));
+        tools.add(new LlmClient.ToolDef(
+                "get_history",
+                "Get the message history trace of the last completed exchange. Shows the route path, processors visited, headers, body, and timing.",
+                emptyParams()));
+        tools.add(new LlmClient.ToolDef(
+                "get_variables",
+                "Show exchange variables in the Camel context.",
+                emptyParams()));
+        tools.add(new LlmClient.ToolDef(
+                "get_services",
+                "Show services registered in the Camel service registry.",
+                emptyParams()));
 
         tools.add(new LlmClient.ToolDef(
                 "get_route_source",
@@ -133,12 +153,40 @@ public class AskTools {
                 "get_top_processors",
                 "Show top processor statistics: which processors are slowest and most active.",
                 emptyParams()));
+        tools.add(new LlmClient.ToolDef(
+                "get_route_topology",
+                "Get the inter-route topology showing how routes connect to each other and to external endpoints.",
+                emptyParams()));
         tools.add(new LlmClient.ToolDef(
                 "trace_control",
                 "Enable, disable, or dump message tracing.",
                 objectParams(Map.of(
                         "action", stringProp("Action: enable, disable, or dump")))));
 
+        tools.add(new LlmClient.ToolDef(
+                "send_message",
+                "Send a test message to a Camel endpoint in the running application.",
+                objectParams(Map.of(
+                        "endpoint", stringProp("Endpoint URI to send to (e.g., direct:myRoute, seda:queue)"),
+                        "body", stringProp("Message body to send"),
+                        "headers", stringProp("Message headers as key=value pairs separated by newlines")))));
+        tools.add(new LlmClient.ToolDef(
+                "eval_expression",
+                "Evaluate a Camel expression in the given language (e.g., simple, jsonpath, xpath) against the running context.",
+                objectParams(Map.of(
+                        "language", stringProp("Expression language (e.g., simple, jsonpath, xpath, jq)"),
+                        "expression", stringProp("Expression to evaluate")))));
+        tools.add(new LlmClient.ToolDef(
+                "browse_endpoint",
+                "Browse messages in a Camel endpoint (e.g., browse messages queued in a SEDA endpoint).",
+                objectParams(Map.of(
+                        "endpoint", stringProp("Endpoint URI to browse (e.g., seda:queue)"),
+                        "limit", stringProp("Maximum number of messages to return (default: 50)")))));
+        tools.add(new LlmClient.ToolDef(
+                "get_thread_dump",
+                "Get a JVM thread dump showing thread names, states, and stack traces.",
+                emptyParams()));
+
         tools.add(new LlmClient.ToolDef(
                 "stop_route",
                 "Gracefully stop a route. The route will finish processing in-flight exchanges before stopping.",
@@ -255,12 +303,30 @@ public class AskTools {
                     targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "consumers");
                 case "get_properties" ->
                     targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "properties");
+                case "get_memory" ->
+                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "memory");
+                case "get_errors" -> targetPid < 0 ? NO_PROCESS : executeGetErrors();
+                case "get_history" -> targetPid < 0 ? NO_PROCESS : executeGetHistory();
+                case "get_variables" ->
+                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "variables");
+                case "get_services" ->
+                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.readStatusSection(targetPid, "services");
                 case "get_route_source" -> targetPid < 0 ? NO_PROCESS : executeRouteSource(args);
                 case "get_route_dump" -> targetPid < 0 ? NO_PROCESS : executeRouteDump(args);
                 case "get_route_structure" -> targetPid < 0 ? NO_PROCESS : executeRouteStructure(args);
                 case "get_top_processors" ->
                     targetPid < 0 ? NO_PROCESS : RuntimeHelper.executeAction(targetPid, "top-processors", null);
+                case "get_route_topology" ->
+                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.executeAction(targetPid, "route-topology", root -> {
+                        root.put("metric", "true");
+                        root.put("external", "true");
+                    });
                 case "trace_control" -> targetPid < 0 ? NO_PROCESS : executeTraceControl(args);
+                case "send_message" -> targetPid < 0 ? NO_PROCESS : executeSendMessage(args);
+                case "eval_expression" -> targetPid < 0 ? NO_PROCESS : executeEvalExpression(args);
+                case "browse_endpoint" -> targetPid < 0 ? NO_PROCESS : executeBrowseEndpoint(args);
+                case "get_thread_dump" ->
+                    targetPid < 0 ? NO_PROCESS : RuntimeHelper.executeAction(targetPid, "thread-dump", null);
                 case "stop_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "stop");
                 case "start_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "start");
                 case "suspend_route" -> targetPid < 0 ? NO_PROCESS : executeRouteCommand(args, "suspend");
@@ -414,6 +480,70 @@ public class AskTools {
         });
     }
 
+    private String executeGetErrors() {
+        JsonObject errors = RuntimeHelper.readErrorFile(targetPid);
+        if (errors == null) {
+            return "No errors captured.";
+        }
+        return errors.toJson();
+    }
+
+    private String executeGetHistory() {
+        JsonObject history = RuntimeHelper.readHistoryFile(targetPid);
+        if (history == null) {
+            return "No message history available.";
+        }
+        return history.toJson();
+    }
+
+    private String executeSendMessage(JsonObject args) {
+        String endpoint = args.getString("endpoint");
+        if (endpoint == null || endpoint.isBlank()) {
+            return "Error: endpoint is required";
+        }
+        String body = args.getString("body");
+        String headers = args.getString("headers");
+        JsonObject result = RuntimeHelper.sendMessage(targetPid, endpoint, body, headers);
+        return result.toJson();
+    }
+
+    private String executeEvalExpression(JsonObject args) {
+        String language = args.getString("language");
+        String expression = args.getString("expression");
+        if (language == null || language.isBlank()) {
+            return "Error: language is required";
+        }
+        if (expression == null || expression.isBlank()) {
+            return "Error: expression is required";
+        }
+        return RuntimeHelper.executeAction(targetPid, "eval", root -> {
+            root.put("language", language);
+            root.put("predicate", "false");
+            root.put("template", Jsoner.escape(expression));
+        });
+    }
+
+    private String executeBrowseEndpoint(JsonObject args) {
+        String endpoint = args.getString("endpoint");
+        if (endpoint == null || endpoint.isBlank()) {
+            return "Error: endpoint is required";
+        }
+        String limitStr = args.getString("limit");
+        int limit = 50;
+        if (limitStr != null && !limitStr.isBlank()) {
+            try {
+                limit = Integer.parseInt(limitStr);
+            } catch (NumberFormatException e) {
+                // use default
+            }
+        }
+        int browseLimit = limit;
+        return RuntimeHelper.executeAction(targetPid, "browse", root -> {
+            root.put("filter", endpoint);
+            root.put("limit", browseLimit);
+        });
+    }
+
     // ---- Catalog tools ----
 
     private String executeCatalogComponents(JsonObject args) {


(camel) 06/06: CAMEL-23855: camel-jbang - Show AI panel always and fix shell scrollback

Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

davsclaus pushed a commit to branch fix/CAMEL-23855
in repository https://gitbox.apache.org/repos/asf/camel.git

commit fbf71a6136e65121ba580db9779f64b38aca0124
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Mon Jun 29 18:59:00 2026 +0200

    CAMEL-23855: camel-jbang - Show AI panel always and fix shell scrollback
    
    Co-Authored-By: Claude <no...@anthropic.com>
    Signed-off-by: Claus Ibsen <cl...@gmail.com>
---
 .../dsl/jbang/core/commands/tui/CamelMonitor.java  |  8 +++-----
 .../dsl/jbang/core/commands/tui/ShellPanel.java    | 23 ++++++++++------------
 2 files changed, 13 insertions(+), 18 deletions(-)

diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
index 94b4472a1e7c..dea72389ce84 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java
@@ -319,12 +319,12 @@ public class CamelMonitor extends CamelCommand {
 
         eventLog = new TuiEventLog(500);
         Path mcpJsonFile = null;
+        actionsPopup.setAiActivityLog(aiPanel::getActivityLog);
         if (mcp) {
             mcpServer = new TuiMcpServer(mcpPort, this);
             try {
                 mcpServer.start();
                 actionsPopup.setMcpEnabled(true, mcpPort, mcpServer::getConnectedClient, mcpServer::getActivityLog);
-                actionsPopup.setAiActivityLog(aiPanel::getActivityLog);
                 mcpJsonFile = writeMcpJson(mcpPort);
             } catch (java.net.BindException e) {
                 System.err.println("MCP server failed to start: port " + mcpPort + " is already in use.");
@@ -659,7 +659,7 @@ public class CamelMonitor extends CamelCommand {
             }
             return true;
         }
-        if (ke.isKey(KeyCode.F8) && mcp) {
+        if (ke.isKey(KeyCode.F8)) {
             if (aiPanel.isOpen()) {
                 aiPanel.close();
             } else {
@@ -1892,9 +1892,7 @@ public class CamelMonitor extends CamelCommand {
             hint(fKeySpans, "F3", "switch");
         }
         hint(fKeySpans, "F6", "shell");
-        if (mcp) {
-            hint(fKeySpans, "F8", "AI");
-        }
+        hint(fKeySpans, "F8", "AI");
         spans.addAll(insertPos, fKeySpans);
         // Return total F-key span count. The footer drop loop uses this to remove pairs from
         // the tail (F6, then F3, F2), stopping before the first pair (F1 help when present).
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
index fbdee894f0e0..d8a0f98d47bf 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
@@ -18,7 +18,7 @@ package org.apache.camel.dsl.jbang.core.commands.tui;
 
 import java.io.IOException;
 import java.io.OutputStream;
-import java.lang.reflect.Method;
+import java.lang.reflect.Field;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -138,13 +138,13 @@ class ShellPanel {
             return true;
         }
 
-        // Shift+PageUp/Down for scrollback through history
-        if (ke.isKey(KeyCode.PAGE_UP) && ke.hasShift()) {
+        // PageUp/Down for scrollback through history
+        if (ke.isKey(KeyCode.PAGE_UP)) {
             int histSize = screenTerminal != null ? getHistorySize(screenTerminal) : 0;
             scrollOffset = Math.min(scrollOffset + lastHeight, histSize);
             return true;
         }
-        if (ke.isKey(KeyCode.PAGE_DOWN) && ke.hasShift()) {
+        if (ke.isKey(KeyCode.PAGE_DOWN)) {
             scrollOffset = Math.max(0, scrollOffset - lastHeight);
             return true;
         }
@@ -280,7 +280,7 @@ class ShellPanel {
     void renderFooter(List<Span> spans) {
         MonitorContext.hint(spans, "F6", "close");
         MonitorContext.hint(spans, "Shift+F6", SPLIT_PERCENTS[splitIndex] + "%");
-        MonitorContext.hint(spans, "Shift+PgUp/Dn", "scroll");
+        MonitorContext.hint(spans, "PgUp/Dn", "scroll");
     }
 
     private List<Line> renderLiveView(long[] screen, int width, int height) {
@@ -619,20 +619,17 @@ class ShellPanel {
     @SuppressWarnings("unchecked")
     private static List<long[]> getHistory(ScreenTerminal st) {
         try {
-            Method m = ScreenTerminal.class.getMethod("getHistory");
-            return (List<long[]>) m.invoke(st);
+            Field f = ScreenTerminal.class.getDeclaredField("history");
+            f.setAccessible(true);
+            List<long[]> history = (List<long[]>) f.get(st);
+            return history != null ? history : Collections.emptyList();
         } catch (Exception e) {
             return Collections.emptyList();
         }
     }
 
     private static int getHistorySize(ScreenTerminal st) {
-        try {
-            Method m = ScreenTerminal.class.getMethod("getHistorySize");
-            return (int) m.invoke(st);
-        } catch (Exception e) {
-            return 0;
-        }
+        return getHistory(st).size();
     }
 
     private static class DelegateOutputStream extends OutputStream {