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/05/24 21:03:09 UTC
(camel) branch tui-mcp-CAMEL-23606 created (now e42e47310cb7)
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a change to branch tui-mcp-CAMEL-23606
in repository https://gitbox.apache.org/repos/asf/camel.git
at e42e47310cb7 CAMEL-23606: camel-tui - extract McpLogPopup from ActionsPopup
This branch includes the following new commits:
new 245115570f7e CAMEL-23606: camel-tui - embedded MCP server for AI agent observability
new 0375ee87ac47 CAMEL-23606: camel-tui - MCP caption tool and inline caption mode
new 20f572763943 CAMEL-23606: camel-tui - MCP navigation tool for tab switching and integration selection
new 4cb4ccd0c139 CAMEL-23606: camel-tui - MCP key injection tool for AI-driven demo recording
new 4964c535742b CAMEL-23606: camel-tui - MCP footer connection status and daemon threads
new cdf8ae0c83a5 CAMEL-23606: camel-tui - MCP caption auto-dismiss with non-blocking duration
new c833d549e9db CAMEL-23606: camel-tui - caption overlay should not swallow unrelated key events
new d3bcfdab6af5 CAMEL-23606: camel-tui - MCP log master/detail view with request/response capture
new e42e47310cb7 CAMEL-23606: camel-tui - extract McpLogPopup from ActionsPopup
The 9 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) 08/09: CAMEL-23606: camel-tui - MCP log master/detail view with request/response capture
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch tui-mcp-CAMEL-23606
in repository https://gitbox.apache.org/repos/asf/camel.git
commit d3bcfdab6af53a4df8a1d6dd545211c458c5d209
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun May 24 22:52:59 2026 +0200
CAMEL-23606: camel-tui - MCP log master/detail view with request/response capture
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 182 ++++++++++++++++-----
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 49 ++++--
2 files changed, 182 insertions(+), 49 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 44d40c48c2b8..e04744fcdb82 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
@@ -49,6 +49,7 @@ import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
import org.apache.camel.util.json.JsonObject;
+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;
@@ -97,6 +98,11 @@ class ActionsPopup {
private String docTitle;
private int docScroll;
+ private boolean showMcpLog;
+ private List<TuiMcpServer.LogEntry> mcpLogEntries;
+ private int mcpLogSelected;
+ private int mcpLogDetailScroll;
+
private final DoctorPopup doctorPopup = new DoctorPopup();
private final ClasspathPopup classpathPopup = new ClasspathPopup();
private final StopAllPopup stopAllPopup;
@@ -137,7 +143,7 @@ class ActionsPopup {
boolean isVisible() {
return showActionsMenu || showExampleBrowser || runOptionsForm.isVisible() || showDocPicker || showDocViewer
- || doctorPopup.isVisible() || classpathPopup.isVisible()
+ || showMcpLog || doctorPopup.isVisible() || classpathPopup.isVisible()
|| stopAllPopup.isVisible() || captionOverlay.isInputVisible();
}
@@ -152,6 +158,7 @@ class ActionsPopup {
runOptionsForm.close();
showDocPicker = false;
showDocViewer = false;
+ showMcpLog = false;
doctorPopup.close();
classpathPopup.close();
stopAllPopup.close();
@@ -167,6 +174,26 @@ class ActionsPopup {
}
boolean handleKeyEvent(KeyEvent ke) {
+ if (showMcpLog) {
+ if (ke.isCancel()) {
+ showMcpLog = false;
+ } else if (ke.isUp() || ke.isChar('k')) {
+ if (mcpLogEntries != null && !mcpLogEntries.isEmpty()) {
+ mcpLogSelected = Math.max(0, mcpLogSelected - 1);
+ mcpLogDetailScroll = 0;
+ }
+ } else if (ke.isDown() || ke.isChar('j')) {
+ if (mcpLogEntries != null && !mcpLogEntries.isEmpty()) {
+ mcpLogSelected = Math.min(mcpLogEntries.size() - 1, mcpLogSelected + 1);
+ mcpLogDetailScroll = 0;
+ }
+ } else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
+ mcpLogDetailScroll = Math.max(0, mcpLogDetailScroll - 5);
+ } else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
+ mcpLogDetailScroll += 5;
+ }
+ return true;
+ }
if (showDocViewer) {
if (ke.isCancel()) {
showDocViewer = false;
@@ -307,6 +334,9 @@ class ActionsPopup {
if (showDocViewer) {
renderDocViewer(frame, area);
}
+ if (showMcpLog) {
+ renderMcpLog(frame, area);
+ }
if (doctorPopup.isVisible()) {
doctorPopup.render(frame, area);
}
@@ -338,6 +368,12 @@ class ActionsPopup {
doctorPopup.renderFooter(spans);
return;
}
+ if (showMcpLog) {
+ hint(spans, "↑↓", "select");
+ hint(spans, "PgUp/Dn", "scroll detail");
+ hintLast(spans, "Esc", "back");
+ return;
+ }
if (showDocViewer) {
hint(spans, "↑↓", "scroll");
hintLast(spans, "Esc", "back");
@@ -732,45 +768,113 @@ class ActionsPopup {
}
private void openMcpLog() {
- List<TuiMcpServer.LogEntry> entries = mcpActivityLog != null ? mcpActivityLog.get() : List.of();
- if (entries.isEmpty()) {
- docContent = "No MCP activity yet.";
- docLines = null;
- } else {
- docContent = null;
- List<Line> lines = new ArrayList<>();
- for (TuiMcpServer.LogEntry entry : entries) {
- String levelTag;
- Style levelStyle;
- switch (entry.level()) {
- case CONNECT:
- levelTag = " CONNECT ";
- levelStyle = Style.EMPTY.fg(Color.GREEN);
- break;
- case TOOL:
- levelTag = " TOOL ";
- levelStyle = Style.EMPTY.fg(Color.CYAN);
- break;
- case ERROR:
- levelTag = " ERROR ";
- levelStyle = Style.EMPTY.fg(Color.LIGHT_RED);
- break;
- default:
- levelTag = " INFO ";
- levelStyle = Style.EMPTY.fg(Color.GREEN);
- break;
- }
- lines.add(Line.from(
- Span.styled(entry.timestamp(), Style.EMPTY.dim()),
- Span.styled(levelTag, levelStyle),
- Span.raw(entry.message())));
- }
- docLines = lines;
+ mcpLogEntries = mcpActivityLog != null ? mcpActivityLog.get() : List.of();
+ mcpLogSelected = mcpLogEntries.isEmpty() ? 0 : mcpLogEntries.size() - 1;
+ mcpLogDetailScroll = 0;
+ showMcpLog = true;
+ }
+
+ private void renderMcpLog(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 (mcpLogEntries == null || mcpLogEntries.isEmpty()) {
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" MCP 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 MCP activity yet.", 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);
+
+ // Master: log entry list
+ List<ListItem> items = new ArrayList<>();
+ for (TuiMcpServer.LogEntry entry : mcpLogEntries) {
+ Style levelStyle = switch (entry.level()) {
+ case CONNECT -> Style.EMPTY.fg(Color.GREEN);
+ case TOOL -> Style.EMPTY.fg(Color.CYAN);
+ case ERROR -> Style.EMPTY.fg(Color.LIGHT_RED);
+ default -> Style.EMPTY.fg(Color.GREEN);
+ };
+ String levelTag = switch (entry.level()) {
+ case CONNECT -> " CONNECT ";
+ case TOOL -> " TOOL ";
+ case ERROR -> " ERROR ";
+ default -> " INFO ";
+ };
+ 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(mcpLogSelected);
+ 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)
+ .title(" MCP Log ")
+ .build())
+ .build();
+ frame.renderStatefulWidget(list, masterArea, masterState);
+
+ // Detail: request + response JSON
+ TuiMcpServer.LogEntry selected = mcpLogEntries.get(mcpLogSelected);
+ List<Line> detailLines = new ArrayList<>();
+ if (selected.requestBody() != null) {
+ detailLines.add(Line.from(Span.styled("▶ Request", Style.EMPTY.fg(Color.YELLOW).bold())));
+ addJsonLines(detailLines, selected.requestBody());
+ detailLines.add(Line.from(Span.raw("")));
+ }
+ if (selected.responseBody() != null) {
+ detailLines.add(Line.from(Span.styled("◀ Response", Style.EMPTY.fg(Color.GREEN).bold())));
+ addJsonLines(detailLines, selected.responseBody());
+ }
+ if (selected.requestBody() == null && selected.responseBody() == null) {
+ detailLines.add(Line.from(Span.styled("(no request/response data)", Style.EMPTY.dim())));
+ }
+
+ Block detailBlock = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" Detail ")
+ .titleBottom(Title.from(Line.from(
+ Span.styled(" ↑↓", MonitorContext.HINT_KEY_STYLE), Span.raw(" select │"),
+ Span.styled(" PgUp/Dn", MonitorContext.HINT_KEY_STYLE), Span.raw(" scroll │"),
+ Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" back "))))
+ .build();
+ frame.renderWidget(detailBlock, detailArea);
+ Rect detailInner = detailBlock.inner(detailArea);
+
+ int visibleLines = detailInner.height();
+ int totalLines = detailLines.size();
+ int clampedScroll = Math.min(mcpLogDetailScroll, Math.max(0, totalLines - visibleLines));
+ int end = Math.min(clampedScroll + visibleLines, totalLines);
+ if (clampedScroll < end) {
+ List<Line> visible = detailLines.subList(clampedScroll, end);
+ frame.renderWidget(
+ Paragraph.builder().text(Text.from(visible.toArray(Line[]::new))).build(),
+ detailInner);
+ }
+ }
+
+ private static void addJsonLines(List<Line> lines, String json) {
+ String pretty = Jsoner.prettyPrint(json, 2);
+ for (String line : pretty.split("\n", -1)) {
+ lines.add(Line.from(Span.styled(" " + line, Style.EMPTY.dim())));
}
- docTitle = "MCP Log";
- docScroll = 0;
- showDocViewer = true;
- docViewerFromExampleBrowser = false;
}
private void openClasspath() {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index 914b22311f4e..d094c7a21757 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -62,7 +62,7 @@ class TuiMcpServer {
ERROR
}
- record LogEntry(String timestamp, LogLevel level, String message) {
+ record LogEntry(String timestamp, LogLevel level, String message, String requestBody, String responseBody) {
}
private final int port;
@@ -104,7 +104,11 @@ class TuiMcpServer {
}
private synchronized void log(LogLevel level, String message) {
- activityLog.add(new LogEntry(TIME_FMT.format(Instant.now()), level, message));
+ log(level, message, null, null);
+ }
+
+ private synchronized void log(LogLevel level, String message, String requestBody, String responseBody) {
+ activityLog.add(new LogEntry(TIME_FMT.format(Instant.now()), level, message, requestBody, responseBody));
if (activityLog.size() > MAX_LOG_ENTRIES) {
activityLog.remove(0);
}
@@ -160,7 +164,8 @@ class TuiMcpServer {
if (result == null) {
sendError(exchange, request, -32601, "Method not found: " + jsonrpcMethod);
} else {
- sendResult(exchange, request, result);
+ String responseJson = sendResult(exchange, request, result);
+ logMethodCall(jsonrpcMethod, request, body, responseJson);
}
} catch (Exception e) {
exchange.sendResponseHeaders(500, -1);
@@ -169,6 +174,30 @@ class TuiMcpServer {
}
}
+ private void logMethodCall(String jsonrpcMethod, JsonObject request, String requestBody, String responseBody) {
+ switch (jsonrpcMethod) {
+ case "initialize" -> {
+ JsonObject params = (JsonObject) request.get("params");
+ String name = "unknown";
+ if (params != null) {
+ JsonObject clientInfo = (JsonObject) params.get("clientInfo");
+ if (clientInfo != null && clientInfo.get("name") != null) {
+ name = (String) clientInfo.get("name");
+ }
+ }
+ log(LogLevel.CONNECT, "Client connected: " + name, requestBody, responseBody);
+ }
+ case "tools/call" -> {
+ JsonObject params = (JsonObject) request.get("params");
+ String toolName = params != null ? (String) params.get("name") : "unknown";
+ log(LogLevel.TOOL, "Tool call: " + toolName, requestBody, responseBody);
+ }
+ case "tools/list" -> log(LogLevel.INFO, "Tools listed", requestBody, responseBody);
+ case "ping" -> log(LogLevel.INFO, "Ping", requestBody, responseBody);
+ default -> log(LogLevel.INFO, jsonrpcMethod, requestBody, responseBody);
+ }
+ }
+
private JsonObject handleInitialize(JsonObject request) {
JsonObject params = (JsonObject) request.get("params");
if (params != null) {
@@ -177,7 +206,6 @@ class TuiMcpServer {
clientName = (String) clientInfo.get("name");
}
}
- log(LogLevel.CONNECT, "Client connected: " + (clientName != null ? clientName : "unknown"));
JsonObject result = new JsonObject();
result.put("protocolVersion", PROTOCOL_VERSION);
@@ -222,7 +250,7 @@ class TuiMcpServer {
Map.of("text", propDef("string", "The caption text to display"),
"duration", propDef("integer",
"Auto-dismiss after this many seconds. Caption won't block key events. "
- + "If omitted, caption stays until dismissed by a key press.")),
+ + "If omitted, caption stays until dismissed by a key press.")),
List.of("text")));
toolList.add(toolDef(
"tui_navigate",
@@ -258,7 +286,6 @@ class TuiMcpServer {
}
lastToolCallTime = System.currentTimeMillis();
- log(LogLevel.TOOL, "Tool call: " + toolName);
String text;
boolean isError = false;
@@ -436,12 +463,12 @@ class TuiMcpServer {
// --- JSON-RPC helpers ---
- private void sendResult(HttpExchange exchange, JsonObject request, JsonObject result) throws IOException {
+ private String sendResult(HttpExchange exchange, JsonObject request, JsonObject result) throws IOException {
JsonObject response = new JsonObject();
response.put("jsonrpc", "2.0");
response.put("id", request.get("id"));
response.put("result", result);
- sendJson(exchange, 200, response);
+ return sendJson(exchange, 200, response);
}
private void sendError(HttpExchange exchange, JsonObject request, int code, String message) throws IOException {
@@ -456,13 +483,15 @@ class TuiMcpServer {
sendJson(exchange, 200, response);
}
- private void sendJson(HttpExchange exchange, int status, JsonObject json) throws IOException {
- byte[] bytes = Jsoner.serialize(json).getBytes(StandardCharsets.UTF_8);
+ private String sendJson(HttpExchange exchange, int status, JsonObject json) throws IOException {
+ String serialized = Jsoner.serialize(json);
+ byte[] bytes = serialized.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
+ return serialized;
}
// --- Tool definition helpers ---
(camel) 04/09: CAMEL-23606: camel-tui - MCP key injection tool for AI-driven demo recording
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch tui-mcp-CAMEL-23606
in repository https://gitbox.apache.org/repos/asf/camel.git
commit 4cb4ccd0c1396bf5dc22d964a5f57e3b8ea241d8
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun May 24 19:40:19 2026 +0200
CAMEL-23606: camel-tui - MCP key injection tool for AI-driven demo recording
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 3 +-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 90 ++++++++++++++++++++++
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 33 ++++++++
3 files changed, 125 insertions(+), 1 deletion(-)
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 bfce21ee00ef..e567ae83972c 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
@@ -700,7 +700,8 @@ class ActionsPopup {
+ "| `tui_get_events` | Returns recent key presses and navigation events |\n"
+ "| `tui_get_state` | Returns active tab, selected integration, etc. |\n"
+ "| `tui_show_caption` | Shows a message on the TUI screen |\n"
- + "| `tui_navigate` | Switch tabs and select integrations |\n\n"
+ + "| `tui_navigate` | Switch tabs and select integrations |\n"
+ + "| `tui_send_keys` | Send key presses to control the TUI |\n\n"
+ "## Setup for Claude Code\n\n"
+ "Run this command to connect Claude Code to the TUI:\n\n"
+ " claude mcp add --transport http camel-tui " + url + "\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 ce9cdb0e5248..c0ae386a3002 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
@@ -35,8 +35,10 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
+import java.util.Queue;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
@@ -58,6 +60,7 @@ import dev.tamboui.tui.TuiRunner;
import dev.tamboui.tui.event.Event;
import dev.tamboui.tui.event.KeyCode;
import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.tui.event.KeyModifiers;
import dev.tamboui.tui.event.TickEvent;
import dev.tamboui.widgets.Clear;
import dev.tamboui.widgets.barchart.Bar;
@@ -212,6 +215,7 @@ public class CamelMonitor extends CamelCommand {
private boolean recording;
private TuiEventLog eventLog;
private TuiMcpServer mcpServer;
+ private final Queue<PendingKey> pendingKeys = new ConcurrentLinkedQueue<>();
private final List<KeyRecord> recentKeys = new ArrayList<>();
private final CaptionOverlay captionOverlay = new CaptionOverlay();
@@ -588,6 +592,12 @@ public class CamelMonitor extends CamelCommand {
}
if (event instanceof TickEvent) {
long now = System.currentTimeMillis();
+ PendingKey pk = pendingKeys.peek();
+ if (pk != null && now >= pk.fireAt()) {
+ pendingKeys.poll();
+ handleEvent(pk.event(), runner);
+ return true;
+ }
actionsPopup.tick(now);
captionOverlay.tick(now);
if (recording && !recentKeys.isEmpty()) {
@@ -3097,4 +3107,84 @@ public class CamelMonitor extends CamelCommand {
.toList();
}
+ int injectKeys(List<String> keys, int delayMs) {
+ long fireAt = System.currentTimeMillis();
+ int count = 0;
+ for (String key : keys) {
+ KeyEvent ke = parseKey(key);
+ if (ke != null) {
+ pendingKeys.add(new PendingKey(ke, fireAt));
+ fireAt += delayMs;
+ count++;
+ }
+ }
+ return count;
+ }
+
+ static KeyEvent parseKey(String key) {
+ if (key == null || key.isEmpty()) {
+ return null;
+ }
+
+ boolean ctrl = false;
+ boolean shift = false;
+ String remainder = key;
+ while (remainder.contains("+")) {
+ int plus = remainder.indexOf('+');
+ String mod = remainder.substring(0, plus).trim();
+ remainder = remainder.substring(plus + 1).trim();
+ if (mod.equalsIgnoreCase("Ctrl")) {
+ ctrl = true;
+ } else if (mod.equalsIgnoreCase("Shift")) {
+ shift = true;
+ }
+ }
+
+ KeyModifiers mods = KeyModifiers.of(ctrl, false, shift);
+
+ KeyCode code = switch (remainder.toLowerCase(Locale.ROOT)) {
+ case "enter", "return" -> KeyCode.ENTER;
+ case "esc", "escape" -> KeyCode.ESCAPE;
+ case "tab" -> KeyCode.TAB;
+ case "backspace" -> KeyCode.BACKSPACE;
+ case "delete", "del" -> KeyCode.DELETE;
+ case "up" -> KeyCode.UP;
+ case "down" -> KeyCode.DOWN;
+ case "left" -> KeyCode.LEFT;
+ case "right" -> KeyCode.RIGHT;
+ case "home" -> KeyCode.HOME;
+ case "end" -> KeyCode.END;
+ case "pageup", "pgup" -> KeyCode.PAGE_UP;
+ case "pagedown", "pgdn" -> KeyCode.PAGE_DOWN;
+ case "f1" -> KeyCode.F1;
+ case "f2" -> KeyCode.F2;
+ case "f3" -> KeyCode.F3;
+ case "f4" -> KeyCode.F4;
+ case "f5" -> KeyCode.F5;
+ case "f6" -> KeyCode.F6;
+ case "f7" -> KeyCode.F7;
+ case "f8" -> KeyCode.F8;
+ case "f9" -> KeyCode.F9;
+ case "f10" -> KeyCode.F10;
+ case "f11" -> KeyCode.F11;
+ case "f12" -> KeyCode.F12;
+ case "space" -> null;
+ default -> null;
+ };
+
+ if (code != null) {
+ return KeyEvent.ofKey(code, mods);
+ }
+ if ("space".equalsIgnoreCase(remainder)) {
+ return KeyEvent.ofChar(' ', mods);
+ }
+ if (remainder.length() == 1) {
+ return KeyEvent.ofChar(remainder.charAt(0), mods);
+ }
+ return null;
+ }
+
+ private record PendingKey(KeyEvent event, long fireAt) {
+ }
+
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index 3cfd6e483b69..9c1e9378cc03 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -221,6 +221,17 @@ class TuiMcpServer {
Map.of("tab", propDef("string", "Tab to switch to (e.g. 'Routes', 'Health')"),
"integration", propDef("string", "Integration name or PID to select"))));
+ toolList.add(toolDef(
+ "tui_send_keys",
+ "Sends key presses to the TUI. Use this to control the TUI for recording demos. "
+ + "Keys are processed one per tick with the specified delay between them. "
+ + "Key names: Enter, Esc, Tab, Backspace, Delete, Up, Down, Left, Right, "
+ + "Home, End, PgUp, PgDn, Space, F1-F12, or any single character. "
+ + "Modifiers: Ctrl+x, Shift+x, Ctrl+Shift+x.",
+ Map.of("keys", propDef("array", "Array of key name strings to send"),
+ "delay", propDef("integer", "Delay in milliseconds between keys (default 150)")),
+ List.of("keys")));
+
JsonObject result = new JsonObject();
result.put("tools", toolList);
return result;
@@ -247,6 +258,7 @@ class TuiMcpServer {
case "tui_get_state" -> callGetState();
case "tui_show_caption" -> callShowCaption(args);
case "tui_navigate" -> callNavigate(args);
+ case "tui_send_keys" -> callSendKeys(args);
default -> {
isError = true;
yield "Unknown tool: " + toolName;
@@ -376,6 +388,27 @@ class TuiMcpServer {
return Jsoner.serialize(result);
}
+ @SuppressWarnings("unchecked")
+ private String callSendKeys(Map<String, Object> args) {
+ Object keysArg = args.get("keys");
+ if (!(keysArg instanceof List)) {
+ return "Error: keys must be an array of strings";
+ }
+ List<String> keys = ((List<Object>) keysArg).stream()
+ .map(String::valueOf)
+ .toList();
+ if (keys.isEmpty()) {
+ return "Error: keys array is empty";
+ }
+ int delay = 150;
+ Object delayArg = args.get("delay");
+ if (delayArg instanceof Number n) {
+ delay = Math.max(50, n.intValue());
+ }
+ int sent = monitor.injectKeys(keys, delay);
+ return "Queued " + sent + " key(s) with " + delay + "ms delay";
+ }
+
private static JsonArray toJsonArray(List<String> list) {
JsonArray arr = new JsonArray();
arr.addAll(list);
(camel) 03/09: CAMEL-23606: camel-tui - MCP navigation tool for tab switching and integration selection
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch tui-mcp-CAMEL-23606
in repository https://gitbox.apache.org/repos/asf/camel.git
commit 20f572763943dfc58cc132b8bda723d5bc71550f
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun May 24 19:24:43 2026 +0200
CAMEL-23606: camel-tui - MCP navigation tool for tab switching and integration selection
Adds tui_navigate MCP tool so AI agents can switch tabs and select
integrations without key injection. Returns available options on error
so the agent can self-correct.
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 7 +++-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 37 ++++++++++++++++
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 49 ++++++++++++++++++++++
3 files changed, 91 insertions(+), 2 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 0a2d768614e0..bfce21ee00ef 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
@@ -699,7 +699,8 @@ class ActionsPopup {
+ "| `tui_get_screen` | Returns the current screen content as text |\n"
+ "| `tui_get_events` | Returns recent key presses and navigation events |\n"
+ "| `tui_get_state` | Returns active tab, selected integration, etc. |\n"
- + "| `tui_show_caption` | Shows a message on the TUI screen |\n\n"
+ + "| `tui_show_caption` | Shows a message on the TUI screen |\n"
+ + "| `tui_navigate` | Switch tabs and select integrations |\n\n"
+ "## Setup for Claude Code\n\n"
+ "Run this command to connect Claude Code to the TUI:\n\n"
+ " claude mcp add --transport http camel-tui " + url + "\n\n"
@@ -720,7 +721,9 @@ class ActionsPopup {
+ "- \"What tab am I on and what integration is selected?\"\n"
+ "- \"What keys did I press in the last minute?\"\n"
+ "- \"What color is the throughput chart?\"\n"
- + "- \"Show me a message on the TUI screen\"\n";
+ + "- \"Show me a message on the TUI screen\"\n"
+ + "- \"Switch to the Health tab\"\n"
+ + "- \"Select the myApp integration\"\n";
docTitle = "MCP Info";
docScroll = 0;
showDocViewer = true;
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 e11639e0a850..ce9cdb0e5248 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
@@ -3060,4 +3060,41 @@ public class CamelMonitor extends CamelCommand {
captionOverlay.showCaption(text);
}
+ String navigateToTab(String tabName) {
+ for (int i = 0; i < TAB_NAMES.length; i++) {
+ if (TAB_NAMES[i].equalsIgnoreCase(tabName)) {
+ handleTabKey(i);
+ return TAB_NAMES[i];
+ }
+ }
+ return null;
+ }
+
+ String selectIntegration(String nameOrPid) {
+ List<IntegrationInfo> infos = data.get();
+ for (IntegrationInfo info : infos) {
+ if (info.vanishing) {
+ continue;
+ }
+ if (nameOrPid.equals(info.pid)
+ || (info.name != null && info.name.equalsIgnoreCase(nameOrPid))) {
+ ctx.selectedPid = info.pid;
+ ctx.infraTableFocused = false;
+ return info.name != null ? info.name : info.pid;
+ }
+ }
+ return null;
+ }
+
+ List<String> getTabNames() {
+ return List.of(TAB_NAMES);
+ }
+
+ List<String> getIntegrationNames() {
+ return data.get().stream()
+ .filter(i -> !i.vanishing)
+ .map(i -> i.name != null ? i.name : i.pid)
+ .toList();
+ }
+
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index 0d3fd0854fd9..3cfd6e483b69 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -213,6 +213,13 @@ class TuiMcpServer {
+ "Supports \\n for newlines.",
Map.of("text", propDef("string", "The caption text to display")),
List.of("text")));
+ toolList.add(toolDef(
+ "tui_navigate",
+ "Navigates the TUI: switch tabs and/or select an integration. "
+ + "Both parameters are optional — set whichever you want to change. "
+ + "Tab names: Overview, Log, Routes, Consumers, Endpoints, HTTP, Health, Inspect, Circuit Breaker.",
+ Map.of("tab", propDef("string", "Tab to switch to (e.g. 'Routes', 'Health')"),
+ "integration", propDef("string", "Integration name or PID to select"))));
JsonObject result = new JsonObject();
result.put("tools", toolList);
@@ -239,6 +246,7 @@ class TuiMcpServer {
case "tui_get_events" -> callGetEvents(args);
case "tui_get_state" -> callGetState();
case "tui_show_caption" -> callShowCaption(args);
+ case "tui_navigate" -> callNavigate(args);
default -> {
isError = true;
yield "Unknown tool: " + toolName;
@@ -333,6 +341,47 @@ class TuiMcpServer {
return "Caption displayed: " + text;
}
+ private String callNavigate(Map<String, Object> args) {
+ JsonObject result = new JsonObject();
+ String tab = (String) args.get("tab");
+ String integration = (String) args.get("integration");
+
+ if (tab == null && integration == null) {
+ result.put("error", "Provide at least one of: tab, integration");
+ result.put("availableTabs", toJsonArray(monitor.getTabNames()));
+ result.put("availableIntegrations", toJsonArray(monitor.getIntegrationNames()));
+ return Jsoner.serialize(result);
+ }
+
+ if (integration != null) {
+ String selected = monitor.selectIntegration(integration);
+ if (selected != null) {
+ result.put("selectedIntegration", selected);
+ } else {
+ result.put("integrationError", "Not found: " + integration);
+ result.put("availableIntegrations", toJsonArray(monitor.getIntegrationNames()));
+ }
+ }
+
+ if (tab != null) {
+ String switched = monitor.navigateToTab(tab);
+ if (switched != null) {
+ result.put("activeTab", switched);
+ } else {
+ result.put("tabError", "Unknown tab: " + tab);
+ result.put("availableTabs", toJsonArray(monitor.getTabNames()));
+ }
+ }
+
+ return Jsoner.serialize(result);
+ }
+
+ private static JsonArray toJsonArray(List<String> list) {
+ JsonArray arr = new JsonArray();
+ arr.addAll(list);
+ return arr;
+ }
+
// --- JSON-RPC helpers ---
private void sendResult(HttpExchange exchange, JsonObject request, JsonObject result) throws IOException {
(camel) 07/09: CAMEL-23606: camel-tui - caption overlay should not swallow unrelated key events
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch tui-mcp-CAMEL-23606
in repository https://gitbox.apache.org/repos/asf/camel.git
commit c833d549e9dbf7f7480f07765ae10f3a620bfb98
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun May 24 22:49:00 2026 +0200
CAMEL-23606: camel-tui - caption overlay should not swallow unrelated key events
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java | 5 +++--
1 file changed, 3 insertions(+), 2 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 4e5e8597ee3f..02634727d5f7 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
@@ -348,8 +348,9 @@ public class CamelMonitor extends CamelCommand {
}
}
if (captionOverlay.isCaptionVisible()) {
- captionOverlay.handleKeyEvent(ke);
- return true;
+ if (captionOverlay.handleKeyEvent(ke)) {
+ return true;
+ }
}
if (ke.hasCtrl() && ke.hasShift() && ke.isChar('t')) {
captionOverlay.openInline();
(camel) 09/09: CAMEL-23606: camel-tui - extract McpLogPopup from ActionsPopup
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch tui-mcp-CAMEL-23606
in repository https://gitbox.apache.org/repos/asf/camel.git
commit e42e47310cb7944d24b444d33d9e6de04ae2eca6
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun May 24 22:56:03 2026 +0200
CAMEL-23606: camel-tui - extract McpLogPopup from ActionsPopup
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 148 +--------------
.../dsl/jbang/core/commands/tui/McpLogPopup.java | 210 +++++++++++++++++++++
2 files changed, 220 insertions(+), 138 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 e04744fcdb82..8a36f8a2f907 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
@@ -49,7 +49,6 @@ import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
import org.apache.camel.util.json.JsonObject;
-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;
@@ -98,10 +97,7 @@ class ActionsPopup {
private String docTitle;
private int docScroll;
- private boolean showMcpLog;
- private List<TuiMcpServer.LogEntry> mcpLogEntries;
- private int mcpLogSelected;
- private int mcpLogDetailScroll;
+ private final McpLogPopup mcpLogPopup = new McpLogPopup();
private final DoctorPopup doctorPopup = new DoctorPopup();
private final ClasspathPopup classpathPopup = new ClasspathPopup();
@@ -135,6 +131,7 @@ class ActionsPopup {
this.mcpPort = port;
this.mcpConnectedClient = connectedClient;
this.mcpActivityLog = activityLog;
+ mcpLogPopup.setActivityLog(activityLog);
}
private int actionCount() {
@@ -143,7 +140,7 @@ class ActionsPopup {
boolean isVisible() {
return showActionsMenu || showExampleBrowser || runOptionsForm.isVisible() || showDocPicker || showDocViewer
- || showMcpLog || doctorPopup.isVisible() || classpathPopup.isVisible()
+ || mcpLogPopup.isVisible() || doctorPopup.isVisible() || classpathPopup.isVisible()
|| stopAllPopup.isVisible() || captionOverlay.isInputVisible();
}
@@ -158,7 +155,7 @@ class ActionsPopup {
runOptionsForm.close();
showDocPicker = false;
showDocViewer = false;
- showMcpLog = false;
+ mcpLogPopup.close();
doctorPopup.close();
classpathPopup.close();
stopAllPopup.close();
@@ -174,24 +171,7 @@ class ActionsPopup {
}
boolean handleKeyEvent(KeyEvent ke) {
- if (showMcpLog) {
- if (ke.isCancel()) {
- showMcpLog = false;
- } else if (ke.isUp() || ke.isChar('k')) {
- if (mcpLogEntries != null && !mcpLogEntries.isEmpty()) {
- mcpLogSelected = Math.max(0, mcpLogSelected - 1);
- mcpLogDetailScroll = 0;
- }
- } else if (ke.isDown() || ke.isChar('j')) {
- if (mcpLogEntries != null && !mcpLogEntries.isEmpty()) {
- mcpLogSelected = Math.min(mcpLogEntries.size() - 1, mcpLogSelected + 1);
- mcpLogDetailScroll = 0;
- }
- } else if (ke.isPageUp() || ke.isKey(KeyCode.PAGE_UP)) {
- mcpLogDetailScroll = Math.max(0, mcpLogDetailScroll - 5);
- } else if (ke.isPageDown() || ke.isKey(KeyCode.PAGE_DOWN)) {
- mcpLogDetailScroll += 5;
- }
+ if (mcpLogPopup.handleKeyEvent(ke)) {
return true;
}
if (showDocViewer) {
@@ -334,8 +314,8 @@ class ActionsPopup {
if (showDocViewer) {
renderDocViewer(frame, area);
}
- if (showMcpLog) {
- renderMcpLog(frame, area);
+ if (mcpLogPopup.isVisible()) {
+ mcpLogPopup.render(frame, area);
}
if (doctorPopup.isVisible()) {
doctorPopup.render(frame, area);
@@ -368,10 +348,8 @@ class ActionsPopup {
doctorPopup.renderFooter(spans);
return;
}
- if (showMcpLog) {
- hint(spans, "↑↓", "select");
- hint(spans, "PgUp/Dn", "scroll detail");
- hintLast(spans, "Esc", "back");
+ if (mcpLogPopup.isVisible()) {
+ mcpLogPopup.renderFooter(spans);
return;
}
if (showDocViewer) {
@@ -768,113 +746,7 @@ class ActionsPopup {
}
private void openMcpLog() {
- mcpLogEntries = mcpActivityLog != null ? mcpActivityLog.get() : List.of();
- mcpLogSelected = mcpLogEntries.isEmpty() ? 0 : mcpLogEntries.size() - 1;
- mcpLogDetailScroll = 0;
- showMcpLog = true;
- }
-
- private void renderMcpLog(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 (mcpLogEntries == null || mcpLogEntries.isEmpty()) {
- Block block = Block.builder()
- .borderType(BorderType.ROUNDED)
- .title(" MCP 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 MCP activity yet.", 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);
-
- // Master: log entry list
- List<ListItem> items = new ArrayList<>();
- for (TuiMcpServer.LogEntry entry : mcpLogEntries) {
- Style levelStyle = switch (entry.level()) {
- case CONNECT -> Style.EMPTY.fg(Color.GREEN);
- case TOOL -> Style.EMPTY.fg(Color.CYAN);
- case ERROR -> Style.EMPTY.fg(Color.LIGHT_RED);
- default -> Style.EMPTY.fg(Color.GREEN);
- };
- String levelTag = switch (entry.level()) {
- case CONNECT -> " CONNECT ";
- case TOOL -> " TOOL ";
- case ERROR -> " ERROR ";
- default -> " INFO ";
- };
- 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(mcpLogSelected);
- 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)
- .title(" MCP Log ")
- .build())
- .build();
- frame.renderStatefulWidget(list, masterArea, masterState);
-
- // Detail: request + response JSON
- TuiMcpServer.LogEntry selected = mcpLogEntries.get(mcpLogSelected);
- List<Line> detailLines = new ArrayList<>();
- if (selected.requestBody() != null) {
- detailLines.add(Line.from(Span.styled("▶ Request", Style.EMPTY.fg(Color.YELLOW).bold())));
- addJsonLines(detailLines, selected.requestBody());
- detailLines.add(Line.from(Span.raw("")));
- }
- if (selected.responseBody() != null) {
- detailLines.add(Line.from(Span.styled("◀ Response", Style.EMPTY.fg(Color.GREEN).bold())));
- addJsonLines(detailLines, selected.responseBody());
- }
- if (selected.requestBody() == null && selected.responseBody() == null) {
- detailLines.add(Line.from(Span.styled("(no request/response data)", Style.EMPTY.dim())));
- }
-
- Block detailBlock = Block.builder()
- .borderType(BorderType.ROUNDED)
- .title(" Detail ")
- .titleBottom(Title.from(Line.from(
- Span.styled(" ↑↓", MonitorContext.HINT_KEY_STYLE), Span.raw(" select │"),
- Span.styled(" PgUp/Dn", MonitorContext.HINT_KEY_STYLE), Span.raw(" scroll │"),
- Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" back "))))
- .build();
- frame.renderWidget(detailBlock, detailArea);
- Rect detailInner = detailBlock.inner(detailArea);
-
- int visibleLines = detailInner.height();
- int totalLines = detailLines.size();
- int clampedScroll = Math.min(mcpLogDetailScroll, Math.max(0, totalLines - visibleLines));
- int end = Math.min(clampedScroll + visibleLines, totalLines);
- if (clampedScroll < end) {
- List<Line> visible = detailLines.subList(clampedScroll, end);
- frame.renderWidget(
- Paragraph.builder().text(Text.from(visible.toArray(Line[]::new))).build(),
- detailInner);
- }
- }
-
- private static void addJsonLines(List<Line> lines, String json) {
- String pretty = Jsoner.prettyPrint(json, 2);
- for (String line : pretty.split("\n", -1)) {
- lines.add(Line.from(Span.styled(" " + line, Style.EMPTY.dim())));
- }
+ mcpLogPopup.open();
}
private void openClasspath() {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpLogPopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpLogPopup.java
new file mode 100644
index 000000000000..def5d1c5f3d4
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpLogPopup.java
@@ -0,0 +1,210 @@
+/*
+ * 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.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 McpLogPopup {
+
+ private boolean visible;
+ private Supplier<List<TuiMcpServer.LogEntry>> activityLog;
+ private List<TuiMcpServer.LogEntry> entries;
+ private int selected;
+ private int detailScroll;
+
+ void setActivityLog(Supplier<List<TuiMcpServer.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)
+ .title(" MCP 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 MCP activity yet.", 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 (TuiMcpServer.LogEntry entry : entries) {
+ Style levelStyle = switch (entry.level()) {
+ case CONNECT -> Style.EMPTY.fg(Color.GREEN);
+ case TOOL -> Style.EMPTY.fg(Color.CYAN);
+ case ERROR -> Style.EMPTY.fg(Color.LIGHT_RED);
+ default -> Style.EMPTY.fg(Color.GREEN);
+ };
+ String levelTag = switch (entry.level()) {
+ case CONNECT -> " CONNECT ";
+ case TOOL -> " TOOL ";
+ case ERROR -> " ERROR ";
+ default -> " INFO ";
+ };
+ 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)
+ .title(" MCP Log ")
+ .build())
+ .build();
+ frame.renderStatefulWidget(list, area, masterState);
+ }
+
+ private void renderDetail(Frame frame, Rect area) {
+ TuiMcpServer.LogEntry entry = entries.get(selected);
+ List<Line> lines = new ArrayList<>();
+ if (entry.requestBody() != null) {
+ lines.add(Line.from(Span.styled("▶ Request", Style.EMPTY.fg(Color.YELLOW).bold())));
+ addJsonLines(lines, entry.requestBody());
+ lines.add(Line.from(Span.raw("")));
+ }
+ if (entry.responseBody() != null) {
+ lines.add(Line.from(Span.styled("◀ Response", Style.EMPTY.fg(Color.GREEN).bold())));
+ addJsonLines(lines, entry.responseBody());
+ }
+ if (entry.requestBody() == null && entry.responseBody() == null) {
+ lines.add(Line.from(Span.styled("(no request/response data)", Style.EMPTY.dim())));
+ }
+
+ Block detailBlock = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" Detail ")
+ .titleBottom(Title.from(Line.from(
+ Span.styled(" ↑↓", MonitorContext.HINT_KEY_STYLE), Span.raw(" select │"),
+ Span.styled(" PgUp/Dn", MonitorContext.HINT_KEY_STYLE), Span.raw(" scroll │"),
+ Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" back "))))
+ .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) {
+ String pretty = Jsoner.prettyPrint(json, 2);
+ for (String line : pretty.split("\n", -1)) {
+ lines.add(Line.from(Span.styled(" " + line, Style.EMPTY.dim())));
+ }
+ }
+}
(camel) 01/09: CAMEL-23606: camel-tui - embedded MCP server for AI agent observability
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch tui-mcp-CAMEL-23606
in repository https://gitbox.apache.org/repos/asf/camel.git
commit 245115570f7e27d0382b13e57c1325f4c79b2634
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun May 24 18:38:12 2026 +0200
CAMEL-23606: camel-tui - embedded MCP server for AI agent observability
Adds an embedded MCP (Model Context Protocol) server to the Camel TUI,
allowing AI coding agents to observe the live terminal session. Uses JDK's
built-in HttpServer with the Streamable HTTP transport (spec 2025-03-26),
requiring zero new dependencies.
Phase 1 provides three observation tools:
- tui_get_screen: returns current screen content (plain text or ANSI)
- tui_get_events: returns recent key presses and navigation events
- tui_get_state: returns active tab, selected integration, etc.
Features:
- --mcp flag to enable, --mcp-port to configure (default 8123)
- Auto-generates .mcp.json for zero-config Claude Code discovery
- Footer indicator with connected client name and activity dot
- F2 menu: MCP Info (setup guide) and MCP Log (colored activity log)
- Graceful handling when port is already in use
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 205 +++++++++--
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 123 +++++++
.../dsl/jbang/core/commands/tui/TuiEventLog.java | 57 ++++
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 374 +++++++++++++++++++++
4 files changed, 728 insertions(+), 31 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 2db68d8234be..46022d003015 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
@@ -44,6 +44,7 @@ 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.dsl.jbang.core.common.ExampleHelper;
import org.apache.camel.dsl.jbang.core.common.LauncherHelper;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
@@ -61,8 +62,9 @@ class ActionsPopup {
private static final int ACTION_SHOW_KEYSTROKES = 4;
private static final int ACTION_DOCTOR = 5;
private static final int ACTION_CLASSPATH = 6;
- private static final int ACTION_STOP_ALL = 7;
- private static final int ACTION_COUNT = 8;
+ private static final int ACTION_MCP_INFO = 7;
+ private static final int ACTION_MCP_LOG = 8;
+ private static final int ACTION_STOP_ALL = 9;
private final Supplier<Set<String>> runningNames;
private final Supplier<List<IntegrationInfo>> integrations;
@@ -70,6 +72,10 @@ class ActionsPopup {
private final Runnable toggleKeystrokes;
private final Supplier<Boolean> keystrokesEnabled;
private MonitorContext ctx;
+ private boolean mcpEnabled;
+ private int mcpPort;
+ private Supplier<String> mcpConnectedClient;
+ private Supplier<List<TuiMcpServer.LogEntry>> mcpActivityLog;
private boolean showActionsMenu;
private final ListState actionsMenuState = new ListState();
@@ -87,6 +93,7 @@ class ActionsPopup {
private boolean showDocViewer;
private boolean docViewerFromExampleBrowser;
private String docContent;
+ private List<Line> docLines;
private String docTitle;
private int docScroll;
@@ -116,6 +123,18 @@ class ActionsPopup {
this.ctx = ctx;
}
+ void setMcpEnabled(
+ boolean enabled, int port, Supplier<String> connectedClient, Supplier<List<TuiMcpServer.LogEntry>> activityLog) {
+ this.mcpEnabled = enabled;
+ this.mcpPort = port;
+ this.mcpConnectedClient = connectedClient;
+ this.mcpActivityLog = activityLog;
+ }
+
+ private int actionCount() {
+ return mcpEnabled ? 10 : 8;
+ }
+
boolean isVisible() {
return showActionsMenu || showExampleBrowser || runOptionsForm.isVisible() || showDocPicker || showDocViewer
|| doctorPopup.isVisible() || classpathPopup.isVisible()
@@ -230,31 +249,38 @@ class ActionsPopup {
} else if (ke.isUp()) {
actionsMenuState.selectPrevious();
} else if (ke.isDown()) {
- actionsMenuState.selectNext(ACTION_COUNT);
+ actionsMenuState.selectNext(actionCount());
} else if (ke.isConfirm()) {
Integer sel = actionsMenuState.selected();
if (sel != null) {
- if (sel == ACTION_RUN_EXAMPLE) {
+ int action = resolveAction(sel);
+ if (action == ACTION_RUN_EXAMPLE) {
openExampleBrowser();
- } else if (sel == ACTION_SHOW_DOCS) {
+ } else if (action == ACTION_SHOW_DOCS) {
openDocPicker();
- } else if (sel == ACTION_SCREENSHOT) {
+ } else if (action == ACTION_SCREENSHOT) {
showActionsMenu = false;
screenshotAction.run();
- } else if (sel == ACTION_SHOW_KEYSTROKES) {
+ } else if (action == ACTION_SHOW_KEYSTROKES) {
showActionsMenu = false;
toggleKeystrokes.run();
- } else if (sel == ACTION_DOCTOR) {
+ } else if (action == ACTION_DOCTOR) {
showActionsMenu = false;
doctorPopup.open();
- } else if (sel == ACTION_CLASSPATH) {
+ } else if (action == ACTION_CLASSPATH) {
showActionsMenu = false;
openClasspath();
- } else if (sel == ACTION_STOP_ALL) {
+ } else if (action == ACTION_MCP_INFO) {
+ showActionsMenu = false;
+ openMcpInfo();
+ } else if (action == ACTION_MCP_LOG) {
+ showActionsMenu = false;
+ openMcpLog();
+ } else if (action == ACTION_STOP_ALL) {
showActionsMenu = false;
stopAllPopup.open();
checkStopAllNotification();
- } else if (sel == ACTION_CAPTION) {
+ } else if (action == ACTION_CAPTION) {
showActionsMenu = false;
captionOverlay.openInput();
}
@@ -352,8 +378,9 @@ class ActionsPopup {
// ---- Rendering ----
private void renderActionsMenu(Frame frame, Rect area) {
+ int count = actionCount();
int popupW = 34;
- int popupH = 2 + ACTION_COUNT;
+ int popupH = 2 + count;
int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
int y = area.top() + Math.max(0, (area.height() - popupH) / 2);
Rect popup = new Rect(x, y, Math.min(popupW, area.width()), Math.min(popupH, area.height()));
@@ -366,15 +393,21 @@ class ActionsPopup {
String stopLabel = stopAllPopup.hasBothGroups()
? " 🛑 Stop All..."
: " 🛑 Stop All";
+ List<ListItem> items = new ArrayList<>();
+ items.add(ListItem.from(" 🐪 Run an example..."));
+ items.add(ListItem.from(" 📖 Show Documentation"));
+ items.add(ListItem.from(" 💬 Caption... (Ctrl+T)"));
+ items.add(ListItem.from(" 📸 Take Screenshot"));
+ items.add(ListItem.from(keystrokeLabel));
+ items.add(ListItem.from(" 🩺 Run Doctor"));
+ items.add(ListItem.from(" 📦 Show Classpath"));
+ if (mcpEnabled) {
+ items.add(ListItem.from(" 🤖 MCP Info"));
+ items.add(ListItem.from(" 📋 MCP Log"));
+ }
+ items.add(ListItem.from(stopLabel));
ListWidget list = ListWidget.builder()
- .items(ListItem.from(" 🐪 Run an example..."),
- ListItem.from(" 📖 Show Documentation"),
- ListItem.from(" 💬 Caption... (Ctrl+T)"),
- ListItem.from(" 📸 Take Screenshot"),
- ListItem.from(keystrokeLabel),
- ListItem.from(" 🩺 Run Doctor"),
- ListItem.from(" 📦 Show Classpath"),
- ListItem.from(stopLabel))
+ .items(items.toArray(ListItem[]::new))
.highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
.highlightSymbol("")
.scrollMode(ScrollMode.NONE)
@@ -465,18 +498,32 @@ class ActionsPopup {
private void renderDocViewer(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);
- MarkdownView view = MarkdownView.builder()
- .source(docContent)
- .scroll(docScroll)
- .block(Block.builder()
- .borderType(BorderType.ROUNDED)
- .title(" " + docTitle + " ")
- .titleBottom(Title.from(Line.from(
- Span.styled(" ↑↓", MonitorContext.HINT_KEY_STYLE), Span.raw(" scroll │"),
- Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" back "))))
- .build())
+ Block block = Block.builder()
+ .borderType(BorderType.ROUNDED)
+ .title(" " + docTitle + " ")
+ .titleBottom(Title.from(Line.from(
+ Span.styled(" ↑↓", MonitorContext.HINT_KEY_STYLE), Span.raw(" scroll │"),
+ Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), Span.raw(" back "))))
.build();
- frame.renderWidget(view, popup);
+ if (docLines != null) {
+ frame.renderWidget(block, popup);
+ Rect inner = block.inner(popup);
+ int visibleLines = inner.height();
+ int totalLines = docLines.size();
+ int clampedScroll = Math.min(docScroll, Math.max(0, totalLines - visibleLines));
+ int end = Math.min(clampedScroll + visibleLines, totalLines);
+ List<Line> visible = docLines.subList(clampedScroll, end);
+ frame.renderWidget(
+ Paragraph.builder().text(Text.from(visible.toArray(Line[]::new))).build(),
+ inner);
+ } else {
+ MarkdownView view = MarkdownView.builder()
+ .source(docContent)
+ .scroll(docScroll)
+ .block(block)
+ .build();
+ frame.renderWidget(view, popup);
+ }
}
private void renderDocPicker(Frame frame, Rect area) {
@@ -544,6 +591,7 @@ class ActionsPopup {
if (ctx == null) {
return;
}
+ docLines = null;
showDocPicker = false;
try {
Path outputFile = ctx.getOutputFile(info.pid);
@@ -601,6 +649,7 @@ class ActionsPopup {
}
if (content != null && !content.isEmpty()) {
docContent = isAdoc ? DocHelper.asciidocToMarkdown(content) : content;
+ docLines = null;
docTitle = name;
docScroll = 0;
showExampleBrowser = false;
@@ -624,6 +673,100 @@ class ActionsPopup {
}
}
+ private int resolveAction(int index) {
+ if (!mcpEnabled && index >= ACTION_MCP_INFO) {
+ return index + 2;
+ }
+ return index;
+ }
+
+ private void openMcpInfo() {
+ docLines = null;
+ String url = "http://localhost:" + mcpPort + "/mcp";
+ String client = mcpConnectedClient != null ? mcpConnectedClient.get() : null;
+ String status = client != null
+ ? "**Connected:** " + client
+ : "**Status:** Not connected";
+ docContent = "# MCP Server\n\n"
+ + status + "\n\n"
+ + "The TUI has an embedded MCP (Model Context Protocol) server running at:\n\n"
+ + " " + url + "\n\n"
+ + "This allows AI coding agents to observe your TUI session — see the screen,\n"
+ + "follow your key presses, and understand what you're doing.\n\n"
+ + "## Available Tools\n\n"
+ + "| Tool | Description |\n"
+ + "|------|-------------|\n"
+ + "| `tui_get_screen` | Returns the current screen content as text |\n"
+ + "| `tui_get_events` | Returns recent key presses and navigation events |\n"
+ + "| `tui_get_state` | Returns active tab, selected integration, etc. |\n\n"
+ + "## Setup for Claude Code\n\n"
+ + "Run this command to connect Claude Code to the TUI:\n\n"
+ + " claude mcp add --transport http camel-tui " + url + "\n\n"
+ + "Or add to your project's `.mcp.json`:\n\n"
+ + " {\n"
+ + " \"mcpServers\": {\n"
+ + " \"camel-tui\": {\n"
+ + " \"type\": \"http\",\n"
+ + " \"url\": \"" + url + "\"\n"
+ + " }\n"
+ + " }\n"
+ + " }\n\n"
+ + "A `.mcp.json` file is auto-generated in the current directory while the TUI\n"
+ + "is running with `--mcp` enabled. It is removed when the TUI exits.\n\n"
+ + "## Usage Examples\n\n"
+ + "Once connected, ask your AI agent:\n\n"
+ + "- \"What's on my Camel TUI screen right now?\"\n"
+ + "- \"What tab am I on and what integration is selected?\"\n"
+ + "- \"What keys did I press in the last minute?\"\n"
+ + "- \"What color is the throughput chart?\"\n";
+ docTitle = "MCP Info";
+ docScroll = 0;
+ showDocViewer = true;
+ docViewerFromExampleBrowser = false;
+ }
+
+ private void openMcpLog() {
+ List<TuiMcpServer.LogEntry> entries = mcpActivityLog != null ? mcpActivityLog.get() : List.of();
+ if (entries.isEmpty()) {
+ docContent = "No MCP activity yet.";
+ docLines = null;
+ } else {
+ docContent = null;
+ List<Line> lines = new ArrayList<>();
+ for (TuiMcpServer.LogEntry entry : entries) {
+ String levelTag;
+ Style levelStyle;
+ switch (entry.level()) {
+ case CONNECT:
+ levelTag = " CONNECT ";
+ levelStyle = Style.EMPTY.fg(Color.GREEN);
+ break;
+ case TOOL:
+ levelTag = " TOOL ";
+ levelStyle = Style.EMPTY.fg(Color.CYAN);
+ break;
+ case ERROR:
+ levelTag = " ERROR ";
+ levelStyle = Style.EMPTY.fg(Color.LIGHT_RED);
+ break;
+ default:
+ levelTag = " INFO ";
+ levelStyle = Style.EMPTY.fg(Color.GREEN);
+ break;
+ }
+ lines.add(Line.from(
+ Span.styled(entry.timestamp(), Style.EMPTY.dim()),
+ Span.styled(levelTag, levelStyle),
+ Span.raw(entry.message())));
+ }
+ docLines = lines;
+ }
+ docTitle = "MCP Log";
+ docScroll = 0;
+ showDocViewer = true;
+ docViewerFromExampleBrowser = false;
+ }
+
private void openClasspath() {
if (ctx == null) {
return;
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 72a1265c6d39..7018f442d50d 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
@@ -130,6 +130,15 @@ public class CamelMonitor extends CamelCommand {
arity = "0..1")
String record;
+ @CommandLine.Option(names = { "--mcp" },
+ description = "Enable embedded MCP server for AI agent access to the TUI")
+ boolean mcp;
+
+ @CommandLine.Option(names = { "--mcp-port" },
+ description = "MCP server port (default: ${DEFAULT-VALUE})",
+ defaultValue = "8123")
+ int mcpPort = 8123;
+
// State
private final AtomicReference<List<IntegrationInfo>> data = new AtomicReference<>(Collections.emptyList());
private final AtomicReference<List<InfraInfo>> infraData = new AtomicReference<>(Collections.emptyList());
@@ -201,6 +210,8 @@ public class CamelMonitor extends CamelCommand {
private volatile long screenshotMessageTime;
private volatile boolean pendingScreenshot;
private boolean recording;
+ private TuiEventLog eventLog;
+ private TuiMcpServer mcpServer;
private final List<KeyRecord> recentKeys = new ArrayList<>();
private final CaptionOverlay captionOverlay = new CaptionOverlay();
@@ -281,6 +292,22 @@ public class CamelMonitor extends CamelCommand {
// Initial data load (synchronous before TUI starts)
refreshDataSync();
+ eventLog = new TuiEventLog(500);
+ Path mcpJsonFile = null;
+ if (mcp) {
+ mcpServer = new TuiMcpServer(mcpPort, this);
+ try {
+ mcpServer.start();
+ actionsPopup.setMcpEnabled(true, mcpPort, mcpServer::getConnectedClient, mcpServer::getActivityLog);
+ mcpJsonFile = writeMcpJson(mcpPort);
+ } catch (java.net.BindException e) {
+ System.err.println("MCP server failed to start: port " + mcpPort + " is already in use.");
+ System.err.println("Use --mcp-port to specify a different port, e.g.: camel tui --mcp --mcp-port 8124");
+ mcpServer = null;
+ mcp = false;
+ }
+ }
+
try (var tui = TuiRunner.create()) {
this.runner = tui;
ctx.runner = tui;
@@ -291,6 +318,10 @@ public class CamelMonitor extends CamelCommand {
this::handleEvent,
this::render);
} finally {
+ if (mcpServer != null) {
+ mcpServer.stop();
+ }
+ deleteMcpJson(mcpJsonFile);
this.runner = null;
}
return 0;
@@ -300,6 +331,12 @@ public class CamelMonitor extends CamelCommand {
private boolean handleEvent(Event event, TuiRunner runner) {
if (event instanceof KeyEvent ke) {
+ if (eventLog != null) {
+ String elabel = keyLabel(ke);
+ if (elabel != null) {
+ eventLog.record(elabel, elabel);
+ }
+ }
if (recording) {
String label = keyLabel(ke);
if (label != null) {
@@ -1623,6 +1660,21 @@ public class CamelMonitor extends CamelCommand {
spans.addAll(keySpans);
}
+ if (mcp && !recording) {
+ String client = mcpServer != null ? mcpServer.getConnectedClient() : null;
+ boolean active = mcpServer != null && mcpServer.isRecentActivity();
+ String dot = active ? " ●" : " ○";
+ String mcpLabel = client != null
+ ? "MCP :" + mcpPort + " (" + client + ")"
+ : "MCP :" + mcpPort;
+ int hintsWidth = spans.stream().mapToInt(s -> s.width()).sum();
+ int mcpWidth = mcpLabel.length() + dot.length() + 1;
+ int gap = Math.max(1, area.width() - hintsWidth - mcpWidth);
+ spans.add(Span.raw(" ".repeat(gap)));
+ spans.add(Span.styled(mcpLabel, Style.EMPTY.fg(client != null ? Color.GREEN : Color.CYAN)));
+ spans.add(Span.styled(dot, Style.EMPTY.fg(active ? Color.GREEN : Color.DARK_GRAY)));
+ }
+
frame.renderWidget(Paragraph.from(Line.from(spans)), area);
}
@@ -2929,4 +2981,75 @@ public class CamelMonitor extends CamelCommand {
record VanishingInfraInfo(InfraInfo info, long startTime) {
}
+ // ---- MCP .mcp.json lifecycle ----
+
+ private static Path writeMcpJson(int port) {
+ Path path = Path.of(".mcp.json");
+ try {
+ String json = "{\n"
+ + " \"mcpServers\": {\n"
+ + " \"camel-tui\": {\n"
+ + " \"type\": \"http\",\n"
+ + " \"url\": \"http://localhost:" + port + "/mcp\"\n"
+ + " }\n"
+ + " }\n"
+ + "}\n";
+ Files.writeString(path, json);
+ return path;
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ private static void deleteMcpJson(Path path) {
+ if (path != null) {
+ try {
+ Files.deleteIfExists(path);
+ } catch (IOException e) {
+ // best effort
+ }
+ }
+ }
+
+ // ---- MCP accessor methods ----
+
+ private static final String[] TAB_NAMES = {
+ "Overview", "Log", "Routes", "Consumers", "Endpoints",
+ "HTTP", "Health", "Inspect", "Circuit Breaker"
+ };
+
+ Buffer getLastBuffer() {
+ return lastBuffer;
+ }
+
+ TuiEventLog getEventLog() {
+ return eventLog;
+ }
+
+ int getActiveTabIndex() {
+ return tabsState.selected();
+ }
+
+ String getActiveTabName() {
+ int idx = tabsState.selected();
+ return idx >= 0 && idx < TAB_NAMES.length ? TAB_NAMES[idx] : "Unknown";
+ }
+
+ String getSelectedPid() {
+ return ctx != null ? ctx.selectedPid : null;
+ }
+
+ String getSelectedIntegrationName() {
+ if (ctx == null) {
+ return null;
+ }
+ IntegrationInfo info = ctx.findSelectedIntegration();
+ return info != null ? info.name : null;
+ }
+
+ int getIntegrationCount() {
+ List<IntegrationInfo> list = data.get();
+ return (int) list.stream().filter(i -> !i.vanishing).count();
+ }
+
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiEventLog.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiEventLog.java
new file mode 100644
index 000000000000..52df4fe7e0f2
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiEventLog.java
@@ -0,0 +1,57 @@
+/*
+ * 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.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Thread-safe bounded ring buffer for TUI key events. Used by the embedded MCP server to expose recent user
+ * interactions to AI agents.
+ */
+class TuiEventLog {
+
+ record Event(String key, String label, Instant timestamp) {
+ }
+
+ private final Event[] buffer;
+ private int head;
+ private int size;
+
+ TuiEventLog(int capacity) {
+ this.buffer = new Event[capacity];
+ }
+
+ synchronized void record(String key, String label) {
+ buffer[head] = new Event(key, label, Instant.now());
+ head = (head + 1) % buffer.length;
+ if (size < buffer.length) {
+ size++;
+ }
+ }
+
+ synchronized List<Event> getRecent(int limit) {
+ int count = Math.min(limit, size);
+ List<Event> result = new ArrayList<>(count);
+ int start = (head - count + buffer.length) % buffer.length;
+ for (int i = 0; i < count; i++) {
+ result.add(buffer[(start + i) % buffer.length]);
+ }
+ return result;
+ }
+}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
new file mode 100644
index 000000000000..6b694e036b03
--- /dev/null
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -0,0 +1,374 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import dev.tamboui.buffer.Buffer;
+import dev.tamboui.export.ExportRequest;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+
+/**
+ * Embedded MCP (Model Context Protocol) server for the Camel TUI.
+ * <p>
+ * Implements the Streamable HTTP transport (spec 2025-03-26) using JDK's built-in HttpServer. Exposes tools that let AI
+ * agents observe the live TUI session: screen content, key events, and navigation state.
+ * <p>
+ * Binds to 127.0.0.1 only for security.
+ */
+class TuiMcpServer {
+
+ private static final String SERVER_NAME = "camel-tui-mcp";
+ private static final String SERVER_VERSION = "1.0.0";
+ private static final String PROTOCOL_VERSION = "2025-03-26";
+ private static final long CLIENT_TIMEOUT_MS = 60_000;
+
+ private static final int MAX_LOG_ENTRIES = 200;
+ private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("HH:mm:ss")
+ .withZone(ZoneId.systemDefault());
+
+ enum LogLevel {
+ INFO,
+ CONNECT,
+ TOOL,
+ ERROR
+ }
+
+ record LogEntry(String timestamp, LogLevel level, String message) {
+ }
+
+ private final int port;
+ private final CamelMonitor monitor;
+ private HttpServer server;
+ private volatile String clientName;
+ private volatile long lastActivity;
+ private volatile long lastToolCallTime;
+ private final List<LogEntry> activityLog = new ArrayList<>();
+
+ TuiMcpServer(int port, CamelMonitor monitor) {
+ this.port = port;
+ this.monitor = monitor;
+ }
+
+ void start() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
+ server.createContext("/mcp", this::handleMcp);
+ server.start();
+ log(LogLevel.INFO, "Server started on port " + port);
+ }
+
+ void stop() {
+ if (server != null) {
+ server.stop(0);
+ }
+ }
+
+ synchronized List<LogEntry> getActivityLog() {
+ return new ArrayList<>(activityLog);
+ }
+
+ private synchronized void log(LogLevel level, String message) {
+ activityLog.add(new LogEntry(TIME_FMT.format(Instant.now()), level, message));
+ if (activityLog.size() > MAX_LOG_ENTRIES) {
+ activityLog.remove(0);
+ }
+ }
+
+ boolean isRecentActivity() {
+ return System.currentTimeMillis() - lastToolCallTime < 2000;
+ }
+
+ String getConnectedClient() {
+ if (clientName != null && System.currentTimeMillis() - lastActivity < CLIENT_TIMEOUT_MS) {
+ return clientName;
+ }
+ return null;
+ }
+
+ private void handleMcp(HttpExchange exchange) throws IOException {
+ try {
+ lastActivity = System.currentTimeMillis();
+ String method = exchange.getRequestMethod();
+ if (!"POST".equals(method)) {
+ exchange.sendResponseHeaders(405, -1);
+ return;
+ }
+
+ String body;
+ try (InputStream is = exchange.getRequestBody()) {
+ body = new String(is.readAllBytes(), StandardCharsets.UTF_8);
+ }
+
+ JsonObject request = Jsoner.deserialize(body, new JsonObject());
+ String jsonrpcMethod = (String) request.get("method");
+
+ if (jsonrpcMethod == null) {
+ sendError(exchange, request, -32600, "Invalid Request");
+ return;
+ }
+
+ // Notifications have no "id" — respond with 202
+ if (!request.containsKey("id")) {
+ exchange.sendResponseHeaders(202, -1);
+ return;
+ }
+
+ JsonObject result = switch (jsonrpcMethod) {
+ case "initialize" -> handleInitialize(request);
+ case "tools/list" -> handleToolsList();
+ case "tools/call" -> handleToolsCall(request);
+ case "ping" -> new JsonObject();
+ default -> null;
+ };
+
+ if (result == null) {
+ sendError(exchange, request, -32601, "Method not found: " + jsonrpcMethod);
+ } else {
+ sendResult(exchange, request, result);
+ }
+ } catch (Exception e) {
+ exchange.sendResponseHeaders(500, -1);
+ } finally {
+ exchange.close();
+ }
+ }
+
+ private JsonObject handleInitialize(JsonObject request) {
+ JsonObject params = (JsonObject) request.get("params");
+ if (params != null) {
+ JsonObject clientInfo = (JsonObject) params.get("clientInfo");
+ if (clientInfo != null) {
+ clientName = (String) clientInfo.get("name");
+ }
+ }
+ log(LogLevel.CONNECT, "Client connected: " + (clientName != null ? clientName : "unknown"));
+
+ JsonObject result = new JsonObject();
+ result.put("protocolVersion", PROTOCOL_VERSION);
+
+ JsonObject serverInfo = new JsonObject();
+ serverInfo.put("name", SERVER_NAME);
+ serverInfo.put("version", SERVER_VERSION);
+ result.put("serverInfo", serverInfo);
+
+ JsonObject capabilities = new JsonObject();
+ JsonObject tools = new JsonObject();
+ tools.put("listChanged", false);
+ capabilities.put("tools", tools);
+ result.put("capabilities", capabilities);
+
+ return result;
+ }
+
+ private JsonObject handleToolsList() {
+ JsonArray toolList = new JsonArray();
+ toolList.add(toolDef(
+ "tui_get_screen",
+ "Returns the current TUI screen content as text. "
+ + "Shows exactly what the user sees in their terminal. "
+ + "Use ansi=true to include ANSI color codes for color-related questions.",
+ Map.of("ansi", propDef("boolean", "Include ANSI color codes in the output (default false)"))));
+ toolList.add(toolDef(
+ "tui_get_events",
+ "Returns recent user input events (key presses, navigation). "
+ + "Each event has a key, human-readable label, and timestamp.",
+ Map.of("limit", propDef("integer", "Maximum number of events to return (default 50)"))));
+ toolList.add(toolDef(
+ "tui_get_state",
+ "Returns the current TUI navigation state: active tab, selected integration, "
+ + "and integration count.",
+ Map.of()));
+
+ JsonObject result = new JsonObject();
+ result.put("tools", toolList);
+ return result;
+ }
+
+ @SuppressWarnings("unchecked")
+ private JsonObject handleToolsCall(JsonObject request) {
+ JsonObject params = (JsonObject) request.get("params");
+ String toolName = params != null ? (String) params.get("name") : null;
+ Map<String, Object> args = params != null ? (Map<String, Object>) params.get("arguments") : Map.of();
+ if (args == null) {
+ args = Map.of();
+ }
+
+ lastToolCallTime = System.currentTimeMillis();
+ log(LogLevel.TOOL, "Tool call: " + toolName);
+
+ String text;
+ boolean isError = false;
+ try {
+ text = switch (toolName) {
+ case "tui_get_screen" -> callGetScreen(args);
+ case "tui_get_events" -> callGetEvents(args);
+ case "tui_get_state" -> callGetState();
+ default -> {
+ isError = true;
+ yield "Unknown tool: " + toolName;
+ }
+ };
+ } catch (Exception e) {
+ text = "Error: " + e.getMessage();
+ isError = true;
+ log(LogLevel.ERROR, "Tool error: " + toolName + " - " + e.getMessage());
+ }
+
+ JsonObject content = new JsonObject();
+ content.put("type", "text");
+ content.put("text", text);
+
+ JsonArray contentArray = new JsonArray();
+ contentArray.add(content);
+
+ JsonObject result = new JsonObject();
+ result.put("content", contentArray);
+ if (isError) {
+ result.put("isError", true);
+ }
+ return result;
+ }
+
+ private String callGetScreen(Map<String, Object> args) {
+ Buffer buf = monitor.getLastBuffer();
+ if (buf == null) {
+ return "Screen not yet available";
+ }
+ boolean ansi = Boolean.TRUE.equals(args.get("ansi"));
+ String screen = ansi
+ ? ExportRequest.export(buf).text().options(o -> o.styles(true)).toString()
+ : ExportRequest.export(buf).text().toString();
+
+ JsonObject result = new JsonObject();
+ result.put("screen", screen);
+ result.put("width", buf.area().width());
+ result.put("height", buf.area().height());
+ return Jsoner.serialize(result);
+ }
+
+ private String callGetEvents(Map<String, Object> args) {
+ int limit = 50;
+ Object limitArg = args.get("limit");
+ if (limitArg instanceof Number n) {
+ limit = n.intValue();
+ }
+
+ TuiEventLog eventLog = monitor.getEventLog();
+ List<TuiEventLog.Event> events = eventLog.getRecent(limit);
+
+ JsonArray eventsArray = new JsonArray();
+ for (TuiEventLog.Event event : events) {
+ JsonObject obj = new JsonObject();
+ obj.put("key", event.key());
+ obj.put("label", event.label());
+ obj.put("timestamp", event.timestamp().toString());
+ eventsArray.add(obj);
+ }
+
+ JsonObject result = new JsonObject();
+ result.put("events", eventsArray);
+ result.put("count", events.size());
+ return Jsoner.serialize(result);
+ }
+
+ private String callGetState() {
+ JsonObject result = new JsonObject();
+ result.put("activeTab", monitor.getActiveTabName());
+ result.put("tabIndex", monitor.getActiveTabIndex());
+
+ String pid = monitor.getSelectedPid();
+ if (pid != null) {
+ result.put("selectedPid", pid);
+ }
+ String name = monitor.getSelectedIntegrationName();
+ if (name != null) {
+ result.put("selectedIntegration", name);
+ }
+ result.put("integrationCount", monitor.getIntegrationCount());
+ return Jsoner.serialize(result);
+ }
+
+ // --- JSON-RPC helpers ---
+
+ private void sendResult(HttpExchange exchange, JsonObject request, JsonObject result) throws IOException {
+ JsonObject response = new JsonObject();
+ response.put("jsonrpc", "2.0");
+ response.put("id", request.get("id"));
+ response.put("result", result);
+ sendJson(exchange, 200, response);
+ }
+
+ private void sendError(HttpExchange exchange, JsonObject request, int code, String message) throws IOException {
+ JsonObject error = new JsonObject();
+ error.put("code", code);
+ error.put("message", message);
+
+ JsonObject response = new JsonObject();
+ response.put("jsonrpc", "2.0");
+ response.put("id", request.get("id"));
+ response.put("error", error);
+ sendJson(exchange, 200, response);
+ }
+
+ private void sendJson(HttpExchange exchange, int status, JsonObject json) throws IOException {
+ byte[] bytes = Jsoner.serialize(json).getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "application/json");
+ exchange.sendResponseHeaders(status, bytes.length);
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+
+ // --- Tool definition helpers ---
+
+ private JsonObject toolDef(String name, String description, Map<String, JsonObject> properties) {
+ JsonObject schema = new JsonObject();
+ schema.put("type", "object");
+ if (!properties.isEmpty()) {
+ JsonObject props = new JsonObject();
+ props.putAll(properties);
+ schema.put("properties", props);
+ }
+
+ JsonObject tool = new JsonObject();
+ tool.put("name", name);
+ tool.put("description", description);
+ tool.put("inputSchema", schema);
+ return tool;
+ }
+
+ private JsonObject propDef(String type, String description) {
+ JsonObject prop = new JsonObject();
+ prop.put("type", type);
+ prop.put("description", description);
+ return prop;
+ }
+}
(camel) 06/09: CAMEL-23606: camel-tui - MCP caption auto-dismiss with non-blocking duration
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch tui-mcp-CAMEL-23606
in repository https://gitbox.apache.org/repos/asf/camel.git
commit cdf8ae0c83a504bd043463cf02b823d53669ac9a
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun May 24 22:34:02 2026 +0200
CAMEL-23606: camel-tui - MCP caption auto-dismiss with non-blocking duration
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 4 ++++
.../jbang/core/commands/tui/CaptionOverlay.java | 28 +++++++++++++++++++++-
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 13 +++++++++-
3 files changed, 43 insertions(+), 2 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 552f55752413..4e5e8597ee3f 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
@@ -3080,6 +3080,10 @@ public class CamelMonitor extends CamelCommand {
captionOverlay.showCaption(text);
}
+ void showCaption(String text, int durationSeconds) {
+ captionOverlay.showCaption(text, durationSeconds);
+ }
+
String navigateToTab(String tabName) {
for (int i = 0; i < TAB_NAMES.length; i++) {
if (TAB_NAMES[i].equalsIgnoreCase(tabName)) {
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
index 24050181509a..eb315794e93b 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
@@ -56,6 +56,7 @@ class CaptionOverlay {
private String captionText;
private long captionStartTime;
private long captionFullyTypedTime;
+ private long captionAutoDismissTime;
boolean isInputVisible() {
return showInput;
@@ -91,6 +92,18 @@ class CaptionOverlay {
captionText = text;
captionStartTime = System.currentTimeMillis();
captionFullyTypedTime = 0;
+ captionAutoDismissTime = 0;
+ }
+
+ void showCaption(String text, int durationSeconds) {
+ captionText = text;
+ captionStartTime = System.currentTimeMillis();
+ captionFullyTypedTime = 0;
+ if (durationSeconds > 0) {
+ captionAutoDismissTime = System.currentTimeMillis() + (durationSeconds * 1000L);
+ } else {
+ captionAutoDismissTime = 0;
+ }
}
void close() {
@@ -150,6 +163,9 @@ class CaptionOverlay {
return true;
}
if (captionText != null) {
+ if (captionAutoDismissTime > 0) {
+ return false;
+ }
captionText = null;
captionFullyTypedTime = 0;
return true;
@@ -166,6 +182,14 @@ class CaptionOverlay {
if (captionText == null || inlineMode) {
return;
}
+
+ if (captionAutoDismissTime > 0 && now > captionAutoDismissTime) {
+ captionText = null;
+ captionFullyTypedTime = 0;
+ captionAutoDismissTime = 0;
+ return;
+ }
+
int totalChars = captionText.length();
long elapsed = now - captionStartTime;
int charsToShow = (int) (elapsed / CHAR_DELAY_MS);
@@ -173,7 +197,9 @@ class CaptionOverlay {
if (charsToShow >= totalChars && captionFullyTypedTime == 0) {
captionFullyTypedTime = now;
}
- if (captionFullyTypedTime > 0 && now - captionFullyTypedTime > HOLD_DURATION_MS + FADE_DURATION_MS) {
+ if (captionAutoDismissTime == 0
+ && captionFullyTypedTime > 0
+ && now - captionFullyTypedTime > HOLD_DURATION_MS + FADE_DURATION_MS) {
captionText = null;
captionFullyTypedTime = 0;
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index 0e6e92080529..914b22311f4e 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -219,7 +219,10 @@ class TuiMcpServer {
"Shows a caption message on the TUI screen with a typewriter animation. "
+ "Use this to display messages to the user. "
+ "Supports \\n for newlines.",
- Map.of("text", propDef("string", "The caption text to display")),
+ Map.of("text", propDef("string", "The caption text to display"),
+ "duration", propDef("integer",
+ "Auto-dismiss after this many seconds. Caption won't block key events. "
+ + "If omitted, caption stays until dismissed by a key press.")),
List.of("text")));
toolList.add(toolDef(
"tui_navigate",
@@ -357,6 +360,14 @@ class TuiMcpServer {
if (text == null || text.isBlank()) {
return "Error: text is required";
}
+ Object durationArg = args.get("duration");
+ if (durationArg instanceof Number n) {
+ int duration = n.intValue();
+ if (duration > 0) {
+ monitor.showCaption(text, duration);
+ return "Caption displayed (auto-dismiss in " + duration + "s): " + text;
+ }
+ }
monitor.showCaption(text);
return "Caption displayed: " + text;
}
(camel) 02/09: CAMEL-23606: camel-tui - MCP caption tool and inline caption mode
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch tui-mcp-CAMEL-23606
in repository https://gitbox.apache.org/repos/asf/camel.git
commit 0375ee87ac4772a504752b23c3e01586343017da
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun May 24 19:19:41 2026 +0200
CAMEL-23606: camel-tui - MCP caption tool and inline caption mode
Adds tui_show_caption MCP tool so AI agents can display messages on the
TUI screen with the typewriter animation. Also adds Ctrl+Shift+T inline
caption mode where keystrokes render directly on screen without a dialog.
- tui_show_caption: text parameter (required), triggers caption display
- Ctrl+Shift+T: live typing mode with blinking cursor, finishes on
Enter/Esc or 3s idle timeout, then hold+fade as usual
- MCP Info guide updated with tui_show_caption in tools table
- toolDef helper now supports required fields in JSON Schema
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 6 +-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 8 ++
.../jbang/core/commands/tui/CaptionOverlay.java | 98 +++++++++++++++++++++-
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 26 ++++++
4 files changed, 134 insertions(+), 4 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 46022d003015..0a2d768614e0 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
@@ -698,7 +698,8 @@ class ActionsPopup {
+ "|------|-------------|\n"
+ "| `tui_get_screen` | Returns the current screen content as text |\n"
+ "| `tui_get_events` | Returns recent key presses and navigation events |\n"
- + "| `tui_get_state` | Returns active tab, selected integration, etc. |\n\n"
+ + "| `tui_get_state` | Returns active tab, selected integration, etc. |\n"
+ + "| `tui_show_caption` | Shows a message on the TUI screen |\n\n"
+ "## Setup for Claude Code\n\n"
+ "Run this command to connect Claude Code to the TUI:\n\n"
+ " claude mcp add --transport http camel-tui " + url + "\n\n"
@@ -718,7 +719,8 @@ class ActionsPopup {
+ "- \"What's on my Camel TUI screen right now?\"\n"
+ "- \"What tab am I on and what integration is selected?\"\n"
+ "- \"What keys did I press in the last minute?\"\n"
- + "- \"What color is the throughput chart?\"\n";
+ + "- \"What color is the throughput chart?\"\n"
+ + "- \"Show me a message on the TUI screen\"\n";
docTitle = "MCP Info";
docScroll = 0;
showDocViewer = true;
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 7018f442d50d..e11639e0a850 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
@@ -347,6 +347,10 @@ public class CamelMonitor extends CamelCommand {
captionOverlay.handleKeyEvent(ke);
return true;
}
+ if (ke.hasCtrl() && ke.hasShift() && ke.isChar('t')) {
+ captionOverlay.openInline();
+ return true;
+ }
if (ke.hasCtrl() && ke.isChar('t')) {
captionOverlay.openInput();
return true;
@@ -3052,4 +3056,8 @@ public class CamelMonitor extends CamelCommand {
return (int) list.stream().filter(i -> !i.vanishing).count();
}
+ void showCaption(String text) {
+ captionOverlay.showCaption(text);
+ }
+
}
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
index 6574e4eb640d..24050181509a 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CaptionOverlay.java
@@ -44,10 +44,15 @@ class CaptionOverlay {
private static final long CHAR_DELAY_MS = 50;
private static final long HOLD_DURATION_MS = 3000;
private static final long FADE_DURATION_MS = 1000;
+ private static final long INLINE_IDLE_TIMEOUT_MS = 3000;
private boolean showInput;
private TextInputState inputState;
+ private boolean inlineMode;
+ private StringBuilder inlineBuffer;
+ private long inlineLastKeystroke;
+
private String captionText;
private long captionStartTime;
private long captionFullyTypedTime;
@@ -56,12 +61,16 @@ class CaptionOverlay {
return showInput;
}
+ boolean isInlineMode() {
+ return inlineMode;
+ }
+
boolean isCaptionVisible() {
return captionText != null;
}
boolean isVisible() {
- return showInput || captionText != null;
+ return showInput || inlineMode || captionText != null;
}
void openInput() {
@@ -69,9 +78,26 @@ class CaptionOverlay {
inputState = new TextInputState("");
}
+ void openInline() {
+ inlineMode = true;
+ inlineBuffer = new StringBuilder();
+ inlineLastKeystroke = System.currentTimeMillis();
+ captionText = "";
+ captionStartTime = System.currentTimeMillis();
+ captionFullyTypedTime = 0;
+ }
+
+ void showCaption(String text) {
+ captionText = text;
+ captionStartTime = System.currentTimeMillis();
+ captionFullyTypedTime = 0;
+ }
+
void close() {
showInput = false;
inputState = null;
+ inlineMode = false;
+ inlineBuffer = null;
captionText = null;
captionFullyTypedTime = 0;
}
@@ -107,6 +133,22 @@ class CaptionOverlay {
}
return true;
}
+ if (inlineMode) {
+ if (ke.isCancel() || ke.isConfirm()) {
+ finishInline();
+ } else if (ke.isDeleteBackward()) {
+ if (!inlineBuffer.isEmpty()) {
+ inlineBuffer.deleteCharAt(inlineBuffer.length() - 1);
+ captionText = inlineBuffer.toString();
+ inlineLastKeystroke = System.currentTimeMillis();
+ }
+ } else if (ke.code() == KeyCode.CHAR) {
+ inlineBuffer.append(ke.character());
+ captionText = inlineBuffer.toString();
+ inlineLastKeystroke = System.currentTimeMillis();
+ }
+ return true;
+ }
if (captionText != null) {
captionText = null;
captionFullyTypedTime = 0;
@@ -116,7 +158,12 @@ class CaptionOverlay {
}
void tick(long now) {
- if (captionText == null) {
+ if (inlineMode && !inlineBuffer.isEmpty()
+ && now - inlineLastKeystroke > INLINE_IDLE_TIMEOUT_MS) {
+ finishInline();
+ return;
+ }
+ if (captionText == null || inlineMode) {
return;
}
int totalChars = captionText.length();
@@ -132,11 +179,27 @@ class CaptionOverlay {
}
}
+ private void finishInline() {
+ inlineMode = false;
+ if (inlineBuffer.isEmpty()) {
+ captionText = null;
+ inlineBuffer = null;
+ return;
+ }
+ captionText = inlineBuffer.toString();
+ inlineBuffer = null;
+ captionFullyTypedTime = System.currentTimeMillis();
+ }
+
void render(Frame frame, Rect area) {
if (showInput) {
renderInput(frame, area);
return;
}
+ if (inlineMode) {
+ renderInline(frame, area);
+ return;
+ }
if (captionText != null) {
renderCaption(frame, area);
}
@@ -146,6 +209,9 @@ class CaptionOverlay {
if (showInput) {
hint(spans, "Enter", "show");
hintLast(spans, "Esc", "cancel");
+ } else if (inlineMode) {
+ hint(spans, "Enter", "finish");
+ hintLast(spans, "Esc", "cancel");
} else if (captionText != null) {
hintLast(spans, "any key", "dismiss");
}
@@ -180,6 +246,34 @@ class CaptionOverlay {
frame.renderStatefulWidget(textInput, inputArea, inputState);
}
+ private void renderInline(Frame frame, Rect area) {
+ Style style = Style.EMPTY.fg(Color.WHITE).bold();
+ String text = inlineBuffer != null ? inlineBuffer.toString() : "";
+ boolean cursorVisible = (System.currentTimeMillis() / 500) % 2 == 0;
+ String display = text + (cursorVisible ? "▌" : " ");
+
+ String[] parts = display.split("\\\\n", -1);
+ List<Line> lines = new ArrayList<>();
+ int maxWidth = 0;
+ for (String part : parts) {
+ lines.add(Line.from(Span.styled(" " + part + " ", style)));
+ maxWidth = Math.max(maxWidth, part.length() + 4);
+ }
+
+ int captionW = Math.min(Math.max(maxWidth, 6), area.width() - 2);
+ int captionH = lines.size();
+ int captionX = area.left() + Math.max(0, (area.width() - captionW) / 2);
+ int captionY = area.top() + Math.max(0, (area.height() - captionH) / 2);
+ Rect captionArea = new Rect(
+ captionX, captionY, Math.min(captionW, area.width()),
+ Math.min(captionH, area.height()));
+
+ frame.renderWidget(Clear.INSTANCE, captionArea);
+ frame.renderWidget(Paragraph.builder()
+ .text(Text.from(lines.toArray(Line[]::new)))
+ .build(), captionArea);
+ }
+
private void renderCaption(Frame frame, Rect area) {
long now = System.currentTimeMillis();
long elapsed = now - captionStartTime;
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index 6b694e036b03..0d3fd0854fd9 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -206,6 +206,13 @@ class TuiMcpServer {
"Returns the current TUI navigation state: active tab, selected integration, "
+ "and integration count.",
Map.of()));
+ toolList.add(toolDef(
+ "tui_show_caption",
+ "Shows a caption message on the TUI screen with a typewriter animation. "
+ + "Use this to display messages to the user. "
+ + "Supports \\n for newlines.",
+ Map.of("text", propDef("string", "The caption text to display")),
+ List.of("text")));
JsonObject result = new JsonObject();
result.put("tools", toolList);
@@ -231,6 +238,7 @@ class TuiMcpServer {
case "tui_get_screen" -> callGetScreen(args);
case "tui_get_events" -> callGetEvents(args);
case "tui_get_state" -> callGetState();
+ case "tui_show_caption" -> callShowCaption(args);
default -> {
isError = true;
yield "Unknown tool: " + toolName;
@@ -316,6 +324,15 @@ class TuiMcpServer {
return Jsoner.serialize(result);
}
+ private String callShowCaption(Map<String, Object> args) {
+ String text = (String) args.get("text");
+ if (text == null || text.isBlank()) {
+ return "Error: text is required";
+ }
+ monitor.showCaption(text);
+ return "Caption displayed: " + text;
+ }
+
// --- JSON-RPC helpers ---
private void sendResult(HttpExchange exchange, JsonObject request, JsonObject result) throws IOException {
@@ -350,6 +367,10 @@ class TuiMcpServer {
// --- Tool definition helpers ---
private JsonObject toolDef(String name, String description, Map<String, JsonObject> properties) {
+ return toolDef(name, description, properties, List.of());
+ }
+
+ private JsonObject toolDef(String name, String description, Map<String, JsonObject> properties, List<String> required) {
JsonObject schema = new JsonObject();
schema.put("type", "object");
if (!properties.isEmpty()) {
@@ -357,6 +378,11 @@ class TuiMcpServer {
props.putAll(properties);
schema.put("properties", props);
}
+ if (!required.isEmpty()) {
+ JsonArray req = new JsonArray();
+ req.addAll(required);
+ schema.put("required", req);
+ }
JsonObject tool = new JsonObject();
tool.put("name", name);
(camel) 05/09: CAMEL-23606: camel-tui - MCP footer connection status and daemon threads
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch tui-mcp-CAMEL-23606
in repository https://gitbox.apache.org/repos/asf/camel.git
commit 4964c535742b1eb1af529c92bb90e9b73acfc230
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun May 24 22:13:31 2026 +0200
CAMEL-23606: camel-tui - MCP footer connection status and daemon threads
Co-Authored-By: Claude Opus 4.6 <no...@anthropic.com>
---
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 2 +-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 24 +++++++++++++++-------
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 14 ++++++++++---
3 files changed, 29 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 e567ae83972c..44d40c48c2b8 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
@@ -686,7 +686,7 @@ class ActionsPopup {
String client = mcpConnectedClient != null ? mcpConnectedClient.get() : null;
String status = client != null
? "**Connected:** " + client
- : "**Status:** Not connected";
+ : "**Status:** Waiting for connection";
docContent = "# MCP Server\n\n"
+ status + "\n\n"
+ "The TUI has an embedded MCP (Model Context Protocol) server running at:\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 c0ae386a3002..552f55752413 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
@@ -1677,16 +1677,26 @@ public class CamelMonitor extends CamelCommand {
if (mcp && !recording) {
String client = mcpServer != null ? mcpServer.getConnectedClient() : null;
boolean active = mcpServer != null && mcpServer.isRecentActivity();
- String dot = active ? " ●" : " ○";
- String mcpLabel = client != null
- ? "MCP :" + mcpPort + " (" + client + ")"
- : "MCP :" + mcpPort;
+ String mcpLabel = "MCP :" + mcpPort;
+ String suffix;
+ Style labelStyle;
+ Style suffixStyle;
+ if (client != null) {
+ suffix = active ? " ●" : " ○";
+ mcpLabel += " (" + client + ")";
+ labelStyle = Style.EMPTY.fg(Color.GREEN);
+ suffixStyle = Style.EMPTY.fg(active ? Color.GREEN : Color.DARK_GRAY);
+ } else {
+ suffix = " ✗";
+ labelStyle = Style.EMPTY.dim();
+ suffixStyle = Style.EMPTY.fg(Color.RED);
+ }
int hintsWidth = spans.stream().mapToInt(s -> s.width()).sum();
- int mcpWidth = mcpLabel.length() + dot.length() + 1;
+ int mcpWidth = mcpLabel.length() + suffix.length() + 1;
int gap = Math.max(1, area.width() - hintsWidth - mcpWidth);
spans.add(Span.raw(" ".repeat(gap)));
- spans.add(Span.styled(mcpLabel, Style.EMPTY.fg(client != null ? Color.GREEN : Color.CYAN)));
- spans.add(Span.styled(dot, Style.EMPTY.fg(active ? Color.GREEN : Color.DARK_GRAY)));
+ spans.add(Span.styled(mcpLabel, labelStyle));
+ spans.add(Span.styled(suffix, suffixStyle));
}
frame.renderWidget(Paragraph.from(Line.from(spans)), area);
diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
index 9c1e9378cc03..0e6e92080529 100644
--- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
+++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiMcpServer.java
@@ -81,13 +81,21 @@ class TuiMcpServer {
void start() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
server.createContext("/mcp", this::handleMcp);
+ server.setExecutor(java.util.concurrent.Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "mcp-handler");
+ t.setDaemon(true);
+ return t;
+ }));
server.start();
log(LogLevel.INFO, "Server started on port " + port);
}
void stop() {
if (server != null) {
- server.stop(0);
+ server.stop(1);
+ if (server.getExecutor() instanceof java.util.concurrent.ExecutorService es) {
+ es.shutdownNow();
+ }
}
}
@@ -107,8 +115,8 @@ class TuiMcpServer {
}
String getConnectedClient() {
- if (clientName != null && System.currentTimeMillis() - lastActivity < CLIENT_TIMEOUT_MS) {
- return clientName;
+ if (System.currentTimeMillis() - lastActivity < CLIENT_TIMEOUT_MS) {
+ return clientName != null ? clientName : "unknown";
}
return null;
}