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 <[email protected]> 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 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../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.");
