From f17a6cf4c73a03a6c4b01b608d63de8fc34b131d Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Thu, 28 May 2026 12:48:31 +0200 Subject: [PATCH 1/6] CAMEL-23634: camel-jbang - TUI Send Message dialog Co-Authored-By: Claude --- .../jbang/core/commands/tui/ActionsPopup.java | 76 +++- .../jbang/core/commands/tui/CamelMonitor.java | 4 + .../jbang/core/commands/tui/RoutesTab.java | 13 + .../core/commands/tui/SendMessagePopup.java | 379 ++++++++++++++++++ 4 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java 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 0ecdd69acbd8c..1370db60ed280 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 @@ -23,6 +23,7 @@ import java.util.Iterator; import java.util.List; import java.util.Set; +import java.util.concurrent.ScheduledExecutorService; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -66,8 +67,9 @@ class ActionsPopup { private static final int ACTION_CLASSPATH = 8; private static final int ACTION_MCP_INFO = 9; private static final int ACTION_MCP_LOG = 10; - private static final int ACTION_RESET_STATS = 11; - private static final int ACTION_STOP_ALL = 12; + private static final int ACTION_SEND_MESSAGE = 11; + private static final int ACTION_RESET_STATS = 12; + private static final int ACTION_STOP_ALL = 13; private final Supplier> runningNames; private final Supplier> integrations; @@ -107,14 +109,17 @@ class ActionsPopup { private final DoctorPopup doctorPopup = new DoctorPopup(); private final ClasspathPopup classpathPopup = new ClasspathPopup(); + private final SendMessagePopup sendMessagePopup = new SendMessagePopup(); private final StopAllPopup stopAllPopup; private final CaptionOverlay captionOverlay; + private ScheduledExecutorService scheduler; private final List pendingLaunches = new ArrayList<>(); private String launchNotification; private boolean launchNotificationError; private long launchNotificationExpiry; private volatile String pendingAutoSelect; + private String preSelectedRouteId; ActionsPopup(Supplier> runningNames, Supplier> integrations, Supplier> infraServices, CaptionOverlay captionOverlay, @@ -135,6 +140,14 @@ void setContext(MonitorContext ctx) { this.ctx = ctx; } + void setScheduler(ScheduledExecutorService scheduler) { + this.scheduler = scheduler; + } + + void setPreSelectedRouteId(String routeId) { + this.preSelectedRouteId = routeId; + } + void setResetStatsAction(Runnable resetStatsAction) { this.resetStatsAction = resetStatsAction; } @@ -149,13 +162,13 @@ void setMcpEnabled( } private int actionCount() { - return mcpEnabled ? 13 : 11; + return mcpEnabled ? 14 : 12; } boolean isVisible() { return showActionsMenu || showExampleBrowser || runOptionsForm.isVisible() || showDocPicker || showDocViewer || mcpLogPopup.isVisible() || doctorPopup.isVisible() || classpathPopup.isVisible() - || stopAllPopup.isVisible() || captionOverlay.isInlineMode(); + || sendMessagePopup.isVisible() || stopAllPopup.isVisible() || captionOverlay.isInlineMode(); } SelectionContext getSelectionContext() { @@ -201,11 +214,12 @@ List getActionLabels() { labels.add("Tape Recording Guide"); labels.add("Run Doctor"); labels.add("Show Classpath"); - labels.add("Reset Stats"); if (mcpEnabled) { labels.add("MCP Info"); labels.add("MCP Log"); } + labels.add("Send Message"); + labels.add("Reset Stats"); labels.add("Stop All"); return labels; } @@ -224,6 +238,7 @@ void close() { mcpLogPopup.close(); doctorPopup.close(); classpathPopup.close(); + sendMessagePopup.close(); stopAllPopup.close(); captionOverlay.close(); } @@ -237,6 +252,14 @@ boolean notificationError() { } boolean handleKeyEvent(KeyEvent ke) { + if (sendMessagePopup.isVisible()) { + if (ke.isConfirm()) { + sendMessagePopup.doSend(ctx, scheduler); + } else { + sendMessagePopup.handleKeyEvent(ke); + } + return true; + } if (mcpLogPopup.handleKeyEvent(ke)) { return true; } @@ -355,6 +378,9 @@ boolean handleKeyEvent(KeyEvent ke) { } else if (action == ACTION_MCP_LOG) { showActionsMenu = false; openMcpLog(); + } else if (action == ACTION_SEND_MESSAGE) { + showActionsMenu = false; + openSendMessage(); } else if (action == ACTION_RESET_STATS) { showActionsMenu = false; if (resetStatsAction != null) { @@ -403,6 +429,9 @@ void render(Frame frame, Rect area) { if (classpathPopup.isVisible()) { classpathPopup.render(frame, area); } + if (sendMessagePopup.isVisible()) { + sendMessagePopup.render(frame, area); + } if (captionOverlay.isInlineMode()) { captionOverlay.render(frame, area); } @@ -497,11 +526,12 @@ private void renderActionsMenu(Frame frame, Rect area) { items.add(ListItem.from(" 📄 Tape Recording Guide")); items.add(ListItem.from(" 🩺 Run Doctor")); items.add(ListItem.from(" 📦 Show Classpath")); - items.add(ListItem.from(" 🔄 Reset Stats")); if (mcpEnabled) { items.add(ListItem.from(" 🤖 MCP Info")); items.add(ListItem.from(" 📋 MCP Log")); } + items.add(ListItem.from(" 📩 Send Message")); + items.add(ListItem.from(" 🔄 Reset Stats")); items.add(ListItem.from(stopLabel)); ListWidget list = ListWidget.builder() .items(items.toArray(ListItem[]::new)) @@ -777,6 +807,40 @@ private int resolveAction(int index) { return index; } + private void openSendMessage() { + if (ctx == null) { + return; + } + String pid = ctx.selectedPid; + if (pid == null) { + List ints = integrations.get(); + List alive = ints.stream().filter(i -> !i.vanishing && i.pid != null).toList(); + if (alive.size() == 1) { + pid = alive.get(0).pid; + } + } + if (pid == null) { + setNotification("Select an integration first", true); + return; + } + IntegrationInfo info = findIntegration(pid); + if (info == null || info.routes.isEmpty()) { + setNotification("No routes available", true); + return; + } + sendMessagePopup.open(ctx, pid, info.name, info.routes, preSelectedRouteId); + preSelectedRouteId = null; + } + + private IntegrationInfo findIntegration(String pid) { + for (IntegrationInfo i : integrations.get()) { + if (pid.equals(i.pid)) { + return i; + } + } + return null; + } + private void openTapeInstructions() { docLines = null; docContent = "# Tape Recording Guide\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 ba7e66f43ab03..c3db71b8032e4 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 @@ -343,6 +343,7 @@ public Integer doCall() throws Exception { try (var tui = TuiBackendHelper.createTuiRunner()) { this.runner = tui; ctx.runner = tui; + actionsPopup.setScheduler(tui.scheduler()); // Intercept Ctrl+C: quit the TUI cleanly instead of letting // the JVM tear down the classloader while we're still running Signal.handle(new Signal("INT"), sig -> tui.quit()); @@ -511,6 +512,9 @@ private boolean handleEvent(Event event, TuiRunner runner) { // F2 opens actions menu (global) if (ke.isKey(KeyCode.F2)) { + if (tabsState.selected() == TAB_ROUTES && routesTab != null) { + actionsPopup.setPreSelectedRouteId(routesTab.selectedRouteId()); + } actionsPopup.open(); return true; } diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java index 239239ba03371..f8ce5dd3cb6fc 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/RoutesTab.java @@ -843,6 +843,19 @@ private Style routeTopSortStyle(String column) { // ---- Route actions ---- + String selectedRouteId() { + IntegrationInfo info = ctx.findSelectedIntegration(); + if (info == null || info.routes.isEmpty()) { + return null; + } + List sortedRoutes = new ArrayList<>(info.routes); + sortedRoutes.sort(this::sortRoute); + Integer sel = routeTableState.selected(); + RouteInfo route = (sel != null && sel >= 0 && sel < sortedRoutes.size()) + ? sortedRoutes.get(sel) : sortedRoutes.get(0); + return route.routeId; + } + private String selectedRouteState() { IntegrationInfo info = ctx.findSelectedIntegration(); if (info == null || info.routes.isEmpty()) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java new file mode 100644 index 0000000000000..af0ac26519ba8 --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java @@ -0,0 +1,379 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.camel.dsl.jbang.core.commands.tui; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ScheduledExecutorService; + +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.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.input.TextInput; +import dev.tamboui.widgets.input.TextInputState; +import dev.tamboui.widgets.paragraph.Paragraph; +import org.apache.camel.dsl.jbang.core.common.PathUtils; +import org.apache.camel.util.json.JsonObject; + +class SendMessagePopup { + + private static final int FIELD_ROUTE = 0; + private static final int FIELD_BODY = 1; + private static final int FIELD_MODE = 2; + private static final int FIELD_COUNT = 3; + + private boolean visible; + private boolean sending; + private String pid; + private String integrationName; + private List routes; + private int selectedRouteIndex; + private final TextInputState bodyState = new TextInputState(""); + private int selectedField = FIELD_BODY; + private boolean inOut; + private String resultMessage; + private boolean resultError; + + boolean isVisible() { + return visible; + } + + void open(MonitorContext ctx, String pid, String name, List routes, String preSelectRouteId) { + if (pid == null || routes == null || routes.isEmpty()) { + return; + } + this.pid = pid; + this.integrationName = name; + this.routes = new ArrayList<>(routes); + this.selectedRouteIndex = findSmartDefault(preSelectRouteId); + this.bodyState.clear(); + this.selectedField = FIELD_BODY; + this.inOut = false; + this.resultMessage = null; + this.resultError = false; + this.sending = false; + this.visible = true; + } + + void close() { + visible = false; + } + + boolean handleKeyEvent(KeyEvent ke) { + if (!visible) { + return false; + } + if (sending) { + return true; + } + if (ke.isCancel()) { + close(); + return true; + } + if (ke.isConfirm()) { + return true; + } + if (selectedField == FIELD_BODY) { + if (ke.isKey(KeyCode.TAB) || ke.isDown()) { + selectedField = FIELD_MODE; + return true; + } + if (ke.isUp()) { + if (routes.size() > 1) { + selectedField = FIELD_ROUTE; + } + return true; + } + handleTextInput(ke); + return true; + } + if (selectedField == FIELD_ROUTE) { + if (ke.isKey(KeyCode.TAB) || ke.isDown()) { + selectedField = FIELD_BODY; + return true; + } + if (ke.isUp()) { + selectedField = FIELD_MODE; + return true; + } + if (ke.isLeft()) { + selectedRouteIndex = (selectedRouteIndex - 1 + routes.size()) % routes.size(); + return true; + } + if (ke.isRight()) { + selectedRouteIndex = (selectedRouteIndex + 1) % routes.size(); + return true; + } + return true; + } + if (selectedField == FIELD_MODE) { + if (ke.isKey(KeyCode.TAB) || ke.isDown()) { + if (routes.size() > 1) { + selectedField = FIELD_ROUTE; + } else { + selectedField = FIELD_BODY; + } + return true; + } + if (ke.isUp()) { + selectedField = FIELD_BODY; + return true; + } + if (ke.isLeft() || ke.isRight() || ke.code() == KeyCode.CHAR) { + inOut = !inOut; + return true; + } + return true; + } + return true; + } + + void doSend(MonitorContext ctx, ScheduledExecutorService scheduler) { + if (!visible || sending || ctx == null || scheduler == null) { + return; + } + sending = true; + resultMessage = "Sending..."; + resultError = false; + + String body = bodyState.text(); + RouteInfo route = routes.get(selectedRouteIndex); + String endpoint = route.routeId; + String mep = inOut ? "InOut" : "InOnly"; + String targetPid = pid; + + scheduler.execute(() -> { + try { + Path outputFile = ctx.getOutputFile(targetPid); + PathUtils.deleteFile(outputFile); + + JsonObject root = new JsonObject(); + root.put("action", "send"); + root.put("endpoint", endpoint); + root.put("body", body); + root.put("exchangePattern", mep); + root.put("pollTimeout", 20000); + root.put("poll", false); + + Path actionFile = ctx.getActionFile(targetPid); + PathUtils.writeTextSafely(root.toJson(), actionFile); + + JsonObject response = MonitorContext.pollJsonResponse(outputFile, 25000); + PathUtils.deleteFile(outputFile); + + if (response != null) { + String status = response.getString("status"); + Object elapsed = response.get("elapsed"); + if ("success".equals(status)) { + String msg = "Sent (" + elapsed + "ms)"; + JsonObject message = response.getMap("message"); + if (inOut && message != null) { + String replyBody = objToString(message.get("body")); + if (replyBody != null && !replyBody.isEmpty()) { + msg += " - Reply: " + truncate(replyBody, 40); + } + } + resultMessage = msg; + resultError = false; + } else { + JsonObject exception = response.getMap("exception"); + if (exception != null) { + String exMsg = exception.getString("message"); + resultMessage = "Error: " + (exMsg != null ? truncate(exMsg, 50) : status); + } else { + resultMessage = "Error: " + (status != null ? status : "unknown"); + } + resultError = true; + } + } else { + resultMessage = "No response from integration"; + resultError = true; + } + } catch (Exception e) { + resultMessage = "Error: " + e.getMessage(); + resultError = true; + } finally { + sending = false; + } + }); + } + + void render(Frame frame, Rect area) { + if (!visible) { + return; + } + + int popupW = Math.min(62, area.width() - 4); + int popupH = routes.size() > 1 ? 14 : 12; + 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())); + + frame.renderWidget(Clear.INSTANCE, popup); + + String title = " Send Message"; + if (integrationName != null) { + title += " - " + truncate(integrationName, 20); + } + title += " "; + Block block = Block.builder() + .borderType(BorderType.ROUNDED) + .title(title) + .titleBottom(Title.from(Line.from( + Span.styled(" Enter", Style.EMPTY.fg(Color.DARK_GRAY)), + Span.styled(" send ", Style.EMPTY.dim()), + Span.styled("Esc", Style.EMPTY.fg(Color.DARK_GRAY)), + Span.styled(" close ", Style.EMPTY.dim())))) + .build(); + frame.renderWidget(block, popup); + + int innerX = popup.left() + 2; + int innerW = popup.width() - 4; + int labelW = 8; + int fieldW = innerW - labelW; + int row = popup.top() + 1; + + // Route selector (only if multiple routes) + if (routes.size() > 1) { + row++; + renderLabel(frame, innerX, row, labelW, "Route:", selectedField == FIELD_ROUTE); + RouteInfo ri = routes.get(selectedRouteIndex); + String routeDisplay = ri.routeId + " (" + truncateUri(ri.from, fieldW - ri.routeId.length() - 6) + ")"; + String arrow = selectedField == FIELD_ROUTE ? "◀ " : " "; + String arrowR = selectedField == FIELD_ROUTE ? " ▶" : " "; + Style routeStyle = selectedField == FIELD_ROUTE ? Style.EMPTY.bold() : Style.EMPTY; + Rect routeArea = new Rect(innerX + labelW, row, fieldW, 1); + frame.renderWidget(Paragraph.from(Line.from( + Span.styled(arrow, routeStyle), + Span.styled(routeDisplay, routeStyle), + Span.styled(arrowR, routeStyle))), routeArea); + } + + // Body input + row += 2; + renderLabel(frame, innerX, row, labelW, "Body:", selectedField == FIELD_BODY); + Rect bodyArea = new Rect(innerX + labelW, row, fieldW, 1); + if (selectedField == FIELD_BODY && !sending) { + TextInput textInput = TextInput.builder() + .cursorStyle(Style.EMPTY.reversed()) + .build(); + frame.renderStatefulWidget(textInput, bodyArea, bodyState); + } else { + String text = bodyState.text(); + Style style = text.isEmpty() ? Style.EMPTY.dim() : Style.EMPTY; + frame.renderWidget(Paragraph.from(Line.from( + Span.styled(text.isEmpty() ? "—" : text, style))), bodyArea); + } + + // Mode toggle + row += 2; + renderLabel(frame, innerX, row, labelW, "Mode:", selectedField == FIELD_MODE); + Rect modeArea = new Rect(innerX + labelW, row, fieldW, 1); + Style inOnlyStyle = !inOut ? Style.EMPTY.bold().reversed() : Style.EMPTY.dim(); + Style inOutStyle = inOut ? Style.EMPTY.bold().reversed() : Style.EMPTY.dim(); + frame.renderWidget(Paragraph.from(Line.from( + Span.styled(" InOnly ", inOnlyStyle), + Span.raw(" "), + Span.styled(" InOut ", inOutStyle))), modeArea); + + // Result line + if (resultMessage != null) { + row += 2; + Style resultStyle = resultError + ? Style.EMPTY.fg(Color.LIGHT_RED) + : Style.EMPTY.fg(Color.GREEN); + Rect resultArea = new Rect(innerX, row, innerW, 1); + frame.renderWidget(Paragraph.from(Line.from( + Span.styled(resultMessage, resultStyle))), resultArea); + } + } + + private int findSmartDefault(String preSelectRouteId) { + if (preSelectRouteId != null) { + for (int i = 0; i < routes.size(); i++) { + if (preSelectRouteId.equals(routes.get(i).routeId)) { + return i; + } + } + } + for (int i = 0; i < routes.size(); i++) { + String from = routes.get(i).from; + if (from != null && (from.startsWith("direct:") || from.startsWith("seda:") + || from.startsWith("platform-http:"))) { + return i; + } + } + return 0; + } + + private void handleTextInput(KeyEvent ke) { + if (ke.isDeleteBackward()) { + bodyState.deleteBackward(); + } else if (ke.isDeleteForward()) { + bodyState.deleteForward(); + } else if (ke.isLeft()) { + bodyState.moveCursorLeft(); + } else if (ke.isRight()) { + bodyState.moveCursorRight(); + } else if (ke.isHome()) { + bodyState.moveCursorToStart(); + } else if (ke.isEnd()) { + bodyState.moveCursorToEnd(); + } else if (ke.code() == KeyCode.CHAR) { + bodyState.insert(ke.character()); + } + } + + private void renderLabel(Frame frame, int x, int y, int w, String label, boolean selected) { + Style style = selected ? Style.EMPTY.bold() : Style.EMPTY.dim(); + Rect labelArea = new Rect(x, y, w, 1); + frame.renderWidget(Paragraph.from(Line.from(Span.styled(label, style))), labelArea); + } + + private static String truncate(String s, int max) { + if (s == null) { + return ""; + } + return s.length() <= max ? s : s.substring(0, max - 1) + "…"; + } + + private static String truncateUri(String uri, int max) { + if (uri == null) { + return ""; + } + int q = uri.indexOf('?'); + String clean = q > 0 ? uri.substring(0, q) : uri; + return truncate(clean, max); + } + + private static String objToString(Object obj) { + if (obj == null) { + return null; + } + return obj.toString(); + } +} From 75c8d77a7ba46d46ca8f955bc13d8a6a9ccb2977 Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Thu, 28 May 2026 12:56:18 +0200 Subject: [PATCH 2/6] CAMEL-23634: Use standard HINT_KEY_STYLE for Send Message popup Co-Authored-By: Claude --- .../dsl/jbang/core/commands/tui/SendMessagePopup.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java index af0ac26519ba8..6169f8cb6106a 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java @@ -244,10 +244,10 @@ void render(Frame frame, Rect area) { .borderType(BorderType.ROUNDED) .title(title) .titleBottom(Title.from(Line.from( - Span.styled(" Enter", Style.EMPTY.fg(Color.DARK_GRAY)), - Span.styled(" send ", Style.EMPTY.dim()), - Span.styled("Esc", Style.EMPTY.fg(Color.DARK_GRAY)), - Span.styled(" close ", Style.EMPTY.dim())))) + Span.styled(" Enter", MonitorContext.HINT_KEY_STYLE), + Span.raw(" send │"), + Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), + Span.raw(" close ")))) .build(); frame.renderWidget(block, popup); From cc82ca4608b57d2e3ea2bde61a8ef53fb328b929 Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Thu, 28 May 2026 13:06:09 +0200 Subject: [PATCH 3/6] CAMEL-23634: camel-jbang - TUI Send Message add headers support Co-Authored-By: Claude --- .../core/commands/tui/SendMessagePopup.java | 239 ++++++++++++++++-- 1 file changed, 224 insertions(+), 15 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java index 6169f8cb6106a..c1e7e92eda534 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java @@ -37,14 +37,15 @@ import dev.tamboui.widgets.input.TextInputState; import dev.tamboui.widgets.paragraph.Paragraph; import org.apache.camel.dsl.jbang.core.common.PathUtils; +import org.apache.camel.util.json.JsonArray; import org.apache.camel.util.json.JsonObject; class SendMessagePopup { private static final int FIELD_ROUTE = 0; private static final int FIELD_BODY = 1; - private static final int FIELD_MODE = 2; - private static final int FIELD_COUNT = 3; + private static final int FIELD_HEADERS = 2; + private static final int FIELD_MODE = 3; private boolean visible; private boolean sending; @@ -58,6 +59,10 @@ class SendMessagePopup { private String resultMessage; private boolean resultError; + private List headers; + private int selectedHeader; + private boolean editingHeaderKey; + boolean isVisible() { return visible; } @@ -76,6 +81,9 @@ void open(MonitorContext ctx, String pid, String name, List routes, S this.resultMessage = null; this.resultError = false; this.sending = false; + this.headers = null; + this.selectedHeader = 0; + this.editingHeaderKey = true; this.visible = true; } @@ -99,7 +107,13 @@ boolean handleKeyEvent(KeyEvent ke) { } if (selectedField == FIELD_BODY) { if (ke.isKey(KeyCode.TAB) || ke.isDown()) { - selectedField = FIELD_MODE; + if (hasHeaders()) { + selectedField = FIELD_HEADERS; + selectedHeader = 0; + editingHeaderKey = true; + } else { + selectedField = FIELD_MODE; + } return true; } if (ke.isUp()) { @@ -108,7 +122,7 @@ boolean handleKeyEvent(KeyEvent ke) { } return true; } - handleTextInput(ke); + handleTextInput(ke, bodyState); return true; } if (selectedField == FIELD_ROUTE) { @@ -130,6 +144,9 @@ boolean handleKeyEvent(KeyEvent ke) { } return true; } + if (selectedField == FIELD_HEADERS) { + return handleHeaderKeyEvent(ke); + } if (selectedField == FIELD_MODE) { if (ke.isKey(KeyCode.TAB) || ke.isDown()) { if (routes.size() > 1) { @@ -140,7 +157,17 @@ boolean handleKeyEvent(KeyEvent ke) { return true; } if (ke.isUp()) { - selectedField = FIELD_BODY; + if (hasHeaders()) { + selectedField = FIELD_HEADERS; + selectedHeader = headers.size() - 1; + editingHeaderKey = false; + } else { + selectedField = FIELD_BODY; + } + return true; + } + if (ke.isChar('+')) { + addHeader(); return true; } if (ke.isLeft() || ke.isRight() || ke.code() == KeyCode.CHAR) { @@ -152,6 +179,101 @@ boolean handleKeyEvent(KeyEvent ke) { return true; } + private boolean handleHeaderKeyEvent(KeyEvent ke) { + HeaderEntry current = headers.get(selectedHeader); + TextInputState activeInput = editingHeaderKey ? current.keyInput : current.valueInput; + + if (ke.isChar('+')) { + addHeader(); + return true; + } + if (ke.isKey(KeyCode.TAB) || ke.isDown()) { + if (editingHeaderKey) { + editingHeaderKey = false; + } else if (selectedHeader < headers.size() - 1) { + selectedHeader++; + editingHeaderKey = true; + } else { + selectedField = FIELD_MODE; + } + return true; + } + if (ke.isUp()) { + if (editingHeaderKey) { + if (selectedHeader > 0) { + selectedHeader--; + editingHeaderKey = false; + } else { + selectedField = FIELD_BODY; + } + } else { + editingHeaderKey = true; + } + return true; + } + if (ke.isDeleteBackward()) { + if (editingHeaderKey && current.keyInput.text().isEmpty()) { + headers.remove(selectedHeader); + if (headers.isEmpty()) { + headers = null; + selectedField = FIELD_BODY; + } else if (selectedHeader >= headers.size()) { + selectedHeader = headers.size() - 1; + } + return true; + } + activeInput.deleteBackward(); + return true; + } + if (ke.isDeleteForward()) { + activeInput.deleteForward(); + return true; + } + if (ke.isLeft()) { + if (!editingHeaderKey && activeInput.cursorPosition() == 0) { + editingHeaderKey = true; + } else { + activeInput.moveCursorLeft(); + } + return true; + } + if (ke.isRight()) { + if (editingHeaderKey && activeInput.cursorPosition() == activeInput.text().length()) { + editingHeaderKey = false; + } else { + activeInput.moveCursorRight(); + } + return true; + } + if (ke.isHome()) { + activeInput.moveCursorToStart(); + return true; + } + if (ke.isEnd()) { + activeInput.moveCursorToEnd(); + return true; + } + if (ke.code() == KeyCode.CHAR) { + activeInput.insert(ke.character()); + return true; + } + return true; + } + + private void addHeader() { + if (headers == null) { + headers = new ArrayList<>(); + } + headers.add(new HeaderEntry(new TextInputState(""), new TextInputState(""))); + selectedField = FIELD_HEADERS; + selectedHeader = headers.size() - 1; + editingHeaderKey = true; + } + + private boolean hasHeaders() { + return headers != null && !headers.isEmpty(); + } + void doSend(MonitorContext ctx, ScheduledExecutorService scheduler) { if (!visible || sending || ctx == null || scheduler == null) { return; @@ -165,6 +287,7 @@ void doSend(MonitorContext ctx, ScheduledExecutorService scheduler) { String endpoint = route.routeId; String mep = inOut ? "InOut" : "InOnly"; String targetPid = pid; + List hdrs = headers != null ? new ArrayList<>(headers) : null; scheduler.execute(() -> { try { @@ -179,6 +302,23 @@ void doSend(MonitorContext ctx, ScheduledExecutorService scheduler) { root.put("pollTimeout", 20000); root.put("poll", false); + if (hdrs != null && !hdrs.isEmpty()) { + JsonArray arr = new JsonArray(); + for (HeaderEntry he : hdrs) { + String k = he.keyInput.text().trim(); + String v = he.valueInput.text(); + if (!k.isEmpty()) { + JsonObject jo = new JsonObject(); + jo.put("key", k); + jo.put("value", v); + arr.add(jo); + } + } + if (!arr.isEmpty()) { + root.put("headers", arr); + } + } + Path actionFile = ctx.getActionFile(targetPid); PathUtils.writeTextSafely(root.toJson(), actionFile); @@ -227,8 +367,10 @@ void render(Frame frame, Rect area) { return; } - int popupW = Math.min(62, area.width() - 4); - int popupH = routes.size() > 1 ? 14 : 12; + int headerCount = hasHeaders() ? headers.size() : 0; + int popupW = Math.min(80, area.width() - 4); + int baseH = routes.size() > 1 ? 14 : 12; + int popupH = baseH + (headerCount > 0 ? headerCount + 1 : 0); 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())); @@ -244,6 +386,8 @@ void render(Frame frame, Rect area) { .borderType(BorderType.ROUNDED) .title(title) .titleBottom(Title.from(Line.from( + Span.styled(" +", MonitorContext.HINT_KEY_STYLE), + Span.raw(" header │"), Span.styled(" Enter", MonitorContext.HINT_KEY_STYLE), Span.raw(" send │"), Span.styled(" Esc", MonitorContext.HINT_KEY_STYLE), @@ -289,6 +433,68 @@ void render(Frame frame, Rect area) { Span.styled(text.isEmpty() ? "—" : text, style))), bodyArea); } + // Headers section + if (hasHeaders()) { + row++; + int keyW = Math.min(20, fieldW / 3); + int valW = fieldW - keyW - 3; + for (int i = 0; i < headers.size(); i++) { + row++; + boolean isSelected = selectedField == FIELD_HEADERS && selectedHeader == i; + String label = i == 0 ? "Hdrs:" : ""; + renderLabel(frame, innerX, row, labelW, label, + isSelected || (i == 0 && selectedField == FIELD_HEADERS)); + + HeaderEntry he = headers.get(i); + int fieldX = innerX + labelW; + + // Key field + Rect keyArea = new Rect(fieldX, row, keyW, 1); + if (isSelected && editingHeaderKey && !sending) { + TextInput keyInput = TextInput.builder() + .cursorStyle(Style.EMPTY.reversed()) + .build(); + frame.renderStatefulWidget(keyInput, keyArea, he.keyInput); + } else { + String keyText = he.keyInput.text(); + Style keyStyle; + if (keyText.isEmpty()) { + keyStyle = Style.EMPTY.dim(); + keyText = ""; + } else { + keyStyle = isSelected ? Style.EMPTY.bold() : Style.EMPTY; + } + frame.renderWidget(Paragraph.from(Line.from( + Span.styled(keyText, keyStyle))), keyArea); + } + + // Separator + Rect sepArea = new Rect(fieldX + keyW, row, 3, 1); + frame.renderWidget(Paragraph.from(Line.from( + Span.styled(" : ", Style.EMPTY.dim()))), sepArea); + + // Value field + Rect valArea = new Rect(fieldX + keyW + 3, row, valW, 1); + if (isSelected && !editingHeaderKey && !sending) { + TextInput valInput = TextInput.builder() + .cursorStyle(Style.EMPTY.reversed()) + .build(); + frame.renderStatefulWidget(valInput, valArea, he.valueInput); + } else { + String valText = he.valueInput.text(); + Style valStyle; + if (valText.isEmpty()) { + valStyle = Style.EMPTY.dim(); + valText = ""; + } else { + valStyle = isSelected ? Style.EMPTY.bold() : Style.EMPTY; + } + frame.renderWidget(Paragraph.from(Line.from( + Span.styled(valText, valStyle))), valArea); + } + } + } + // Mode toggle row += 2; renderLabel(frame, innerX, row, labelW, "Mode:", selectedField == FIELD_MODE); @@ -330,21 +536,21 @@ private int findSmartDefault(String preSelectRouteId) { return 0; } - private void handleTextInput(KeyEvent ke) { + private void handleTextInput(KeyEvent ke, TextInputState state) { if (ke.isDeleteBackward()) { - bodyState.deleteBackward(); + state.deleteBackward(); } else if (ke.isDeleteForward()) { - bodyState.deleteForward(); + state.deleteForward(); } else if (ke.isLeft()) { - bodyState.moveCursorLeft(); + state.moveCursorLeft(); } else if (ke.isRight()) { - bodyState.moveCursorRight(); + state.moveCursorRight(); } else if (ke.isHome()) { - bodyState.moveCursorToStart(); + state.moveCursorToStart(); } else if (ke.isEnd()) { - bodyState.moveCursorToEnd(); + state.moveCursorToEnd(); } else if (ke.code() == KeyCode.CHAR) { - bodyState.insert(ke.character()); + state.insert(ke.character()); } } @@ -376,4 +582,7 @@ private static String objToString(Object obj) { } return obj.toString(); } + + record HeaderEntry(TextInputState keyInput, TextInputState valueInput) { + } } From da5b130e672ba705985c8f9e36e9727505b25ec6 Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Thu, 28 May 2026 13:29:44 +0200 Subject: [PATCH 4/6] CAMEL-23634: camel-jbang - TUI Send Message clipboard paste support Co-Authored-By: Claude --- .../jbang/core/commands/tui/ActionsPopup.java | 6 +++++ .../jbang/core/commands/tui/CamelMonitor.java | 7 +++++ .../core/commands/tui/SendMessagePopup.java | 26 +++++++++++++++++++ 3 files changed, 39 insertions(+) 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 1370db60ed280..2b65b04418249 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 @@ -251,6 +251,12 @@ boolean notificationError() { return launchNotificationError; } + void handlePaste(String text) { + if (sendMessagePopup.isVisible()) { + sendMessagePopup.handlePaste(text); + } + } + boolean handleKeyEvent(KeyEvent ke) { if (sendMessagePopup.isVisible()) { if (ke.isConfirm()) { 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 c3db71b8032e4..f1613f9fd1ff9 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 @@ -64,6 +64,7 @@ import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; import dev.tamboui.tui.event.KeyModifiers; +import dev.tamboui.tui.event.PasteEvent; import dev.tamboui.tui.event.TickEvent; import dev.tamboui.widgets.Clear; import dev.tamboui.widgets.barchart.Bar; @@ -642,6 +643,12 @@ private boolean handleEvent(Event event, TuiRunner runner) { return true; } } + if (event instanceof PasteEvent pe) { + if (actionsPopup.isVisible()) { + actionsPopup.handlePaste(pe.text()); + return true; + } + } if (event instanceof TickEvent) { long now = System.currentTimeMillis(); boolean keyProcessed = false; diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java index c1e7e92eda534..ae5b056149314 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/SendMessagePopup.java @@ -274,6 +274,32 @@ private boolean hasHeaders() { return headers != null && !headers.isEmpty(); } + void handlePaste(String text) { + if (!visible || sending || text == null || text.isEmpty()) { + return; + } + TextInputState target = activeTextInput(); + if (target != null) { + for (int i = 0; i < text.length(); i++) { + char ch = text.charAt(i); + if (ch != '\n' && ch != '\r') { + target.insert(ch); + } + } + } + } + + private TextInputState activeTextInput() { + if (selectedField == FIELD_BODY) { + return bodyState; + } + if (selectedField == FIELD_HEADERS && hasHeaders()) { + HeaderEntry he = headers.get(selectedHeader); + return editingHeaderKey ? he.keyInput : he.valueInput; + } + return null; + } + void doSend(MonitorContext ctx, ScheduledExecutorService scheduler) { if (!visible || sending || ctx == null || scheduler == null) { return; From 111ead5adcb15229ede444c635a7d1d57f5b06af Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Thu, 28 May 2026 13:37:21 +0200 Subject: [PATCH 5/6] CAMEL-23634: camel-jbang - TUI fix F5 refresh on History tab Co-Authored-By: Claude --- .../apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java | 3 ++- .../apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) 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 f1613f9fd1ff9..e57cf8f46eec0 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 @@ -2199,8 +2199,9 @@ private void refreshDataSync() { refreshErrorData(pids); } - // Refresh trace data only when the Inspect tab is visible + // Refresh history and trace data only when the History tab is visible if (tabsState.selected() == TAB_HISTORY) { + refreshHistoryData(pids); refreshTraceData(pids); } } catch (Exception e) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java index 48341aa1365bf..465c6118e03eb 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java @@ -226,6 +226,9 @@ public boolean handleKeyEvent(KeyEvent ke) { return true; } if (ke.isKey(KeyCode.F5)) { + historyEntries = Collections.emptyList(); + historyDetailScroll = 0; + historyDetailHScroll = 0; return true; } } From 246e338ef16d3c81817c4f34802358789a0783db Mon Sep 17 00:00:00 2001 From: Claus Ibsen Date: Thu, 28 May 2026 13:40:58 +0200 Subject: [PATCH 6/6] CAMEL-23634: Fix F5 refresh in History tab F5 now triggers a one-shot refresh of history data instead of being a no-op. Co-Authored-By: Claude --- .../camel/dsl/jbang/core/commands/tui/CamelMonitor.java | 7 +++++-- .../camel/dsl/jbang/core/commands/tui/HistoryTab.java | 2 ++ 2 files changed, 7 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 e57cf8f46eec0..9e5e8f376e0e6 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 @@ -2199,9 +2199,12 @@ private void refreshDataSync() { refreshErrorData(pids); } - // Refresh history and trace data only when the History tab is visible + // Refresh trace data only when the History tab is visible if (tabsState.selected() == TAB_HISTORY) { - refreshHistoryData(pids); + if (historyTab.historyRefreshRequested) { + historyTab.historyRefreshRequested = false; + refreshHistoryData(pids); + } refreshTraceData(pids); } } catch (Exception e) { diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java index 465c6118e03eb..8fbfae4162af7 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/HistoryTab.java @@ -91,6 +91,7 @@ class HistoryTab implements MonitorTab { private boolean historyWordWrap = true; private int historyDetailScroll; private int historyDetailHScroll; + volatile boolean historyRefreshRequested; HistoryTab(MonitorContext ctx, AtomicReference> traces, @@ -229,6 +230,7 @@ public boolean handleKeyEvent(KeyEvent ke) { historyEntries = Collections.emptyList(); historyDetailScroll = 0; historyDetailHScroll = 0; + historyRefreshRequested = true; return true; } }