This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new cb28e34bd9e0 camel-jbang - TUI send message dialog improvements
cb28e34bd9e0 is described below
commit cb28e34bd9e0905529909f8e6663100170901fd4
Author: Claus Ibsen <[email protected]>
AuthorDate: Thu Jul 9 11:32:13 2026 +0200
camel-jbang - TUI send message dialog improvements
- Multi-line body using TextArea for JSON payloads and large text
- Up/Down arrows navigate within body text, Tab moves between fields
- Enter inserts newline in body, F5 sends message (consistent with SQL tab)
- Word wrap toggle (w) for response panel to prevent text clipping
- Unescape stack traces with Jsoner.unescape matching ErrorsTab pattern
- Fix tab characters in responses via TuiHelper.fixControlChars
- Reorder Screen submenu: Themes before Take Screenshot
- Remove + shortcut from body/headers fields to allow typing + character
- Show PgUp/Dn hint when response content exists
- Show Enter to replay hint in history panel title
camel-jbang-tui: Send dialog defaults to InOut and adds body/headers toggle
- Default exchange pattern to InOut instead of InOnly
- Remove redundant exchangePattern from response header lines
- Add body/headers toggle (b/h keys) for the response panel
so it shows just the body by default with optional detailed
mode showing exchangeId and exchange headers
- Error responses always show regardless of toggle state
camel-jbang-tui: Fix chart title spacing and round x-axis labels
- Add trailing space in chart titles before border dashes
- Round renderPoints to nearest multiple of 20 so x-axis labels
show clean values like -80s, -60s, -40s, -20s instead of
-89s, -66s, -44s, -22s
camel-jbang-tui: Add AI Prompt, Shell and Switch to F2 and Go to menus
- Add Shell (F6) shortcut hint to F2 actions menu
- Add AI Prompt (F8) entry to AI & MCP submenu
- Use book icon for Setup MCP entry
- Add Switch Integration (F3), Shell (F6), AI Prompt (F8) to
Go to popup so all key actions are discoverable
- Remove F3, F6, F8 from footer to save space
camel-jbang-tui: Shift+F2 opens Go to popup with improved filtering
- Shift+F2 shortcut to open Go to popup directly
- Show (Shift+F2) hint on Go to menu entry
- Short filters (1-2 chars) use consecutive substring matching
so typing AI only shows AI entries, not fuzzy matches
- Shortcut column widened to 2 chars for F-key alignment
- Fix underline style bleeding into padding space
- Shrink popup when not all entries fit so scrollbar is obvious
camel-jbang-tui: Fix Diagram F1 help text rendering as markdown
camel-jbang-tui: Add MCP canvas overlay and batch shape drawing
Adds tui_canvas_open/tui_canvas_close MCP tools that open a full-screen
blank canvas for AI free-form drawing. The canvas covers header, tabs
and content area, leaving only the footer with Esc close hint.
Canvas open returns screen dimensions and theme mode so AI can
center content and pick appropriate colors. Accepts optional shapes
array to draw immediately in the same call.
Also adds shapes batch parameter to tui_draw for drawing multiple
shapes in a single round-trip. Multi-line text (\n) is now supported
in text shapes. Refactors callDrawShape to reuse shared parseShape.
camel-jbang-tui: Fix Theme.isDark() to correctly identify all dark themes
camel-jbang-tui: Dismiss all popups and panels when canvas opens
camel-jbang-tui: Add MCP tui_animate and tui_animate_status tools
camel-jbang-tui: Add built-in Camel animation easter egg
Add pre-built animations system to avoid LLM token generation overhead.
The camel animation features the ASCII camel art with eye blinking and
tail wiggling. Available via F2 > Screen > Camel Animation, or through
the MCP tui_animate tool with name="camel".
Co-Authored-By: Claude Opus 4.6 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 64 +++-
.../jbang/core/commands/tui/BuiltinAnimations.java | 158 +++++++++
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 106 +++++-
.../dsl/jbang/core/commands/tui/CanvasOverlay.java | 76 ++++
.../jbang/core/commands/tui/CircuitBreakerTab.java | 4 +-
.../dsl/jbang/core/commands/tui/DiagramTab.java | 240 ++++++-------
.../dsl/jbang/core/commands/tui/DrawOverlay.java | 9 +-
.../dsl/jbang/core/commands/tui/EndpointsTab.java | 12 +-
.../dsl/jbang/core/commands/tui/GotoTabPopup.java | 10 +-
.../dsl/jbang/core/commands/tui/McpFacade.java | 15 +
.../dsl/jbang/core/commands/tui/PopupManager.java | 7 +
.../jbang/core/commands/tui/SendMessagePopup.java | 206 +++++++----
.../camel/dsl/jbang/core/commands/tui/Theme.java | 2 +-
.../dsl/jbang/core/commands/tui/TuiMcpServer.java | 385 +++++++++++++++++----
14 files changed, 1008 insertions(+), 286 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 75be54bd7bf2..ce9f6f4c043e 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
@@ -72,10 +72,12 @@ class ActionsPopup {
TAPE_INSTRUCTIONS,
CAPTION,
SHOW_KEYSTROKES,
+ CAMEL_ANIMATION,
SETUP_AI,
MCP_INFO,
MCP_LOG,
- AI_LOG
+ AI_LOG,
+ AI_PROMPT
}
private final Supplier<Set<String>> runningNames;
@@ -90,7 +92,9 @@ class ActionsPopup {
private Runnable resetScreenAction;
private Runnable themeRefreshAction;
private Runnable openShellAction;
+ private Runnable openAiPromptAction;
private Runnable browseFilesAction;
+ private Runnable camelAnimationAction;
private Runnable switchIntegrationAction;
private final Supplier<Boolean> tapeRecordingActive;
private MonitorContext ctx;
@@ -188,10 +192,18 @@ class ActionsPopup {
this.openShellAction = openShellAction;
}
+ void setOpenAiPromptAction(Runnable openAiPromptAction) {
+ this.openAiPromptAction = openAiPromptAction;
+ }
+
void setBrowseFilesAction(Runnable browseFilesAction) {
this.browseFilesAction = browseFilesAction;
}
+ void setCamelAnimationAction(Runnable camelAnimationAction) {
+ this.camelAnimationAction = camelAnimationAction;
+ }
+
void setSwitchIntegrationAction(Runnable switchIntegrationAction) {
this.switchIntegrationAction = switchIntegrationAction;
}
@@ -267,9 +279,11 @@ class ActionsPopup {
List<Action> flat = new ArrayList<>();
flat.add(Action.BACK);
flat.add(null);
- flat.addAll(List.of(Action.SCREENSHOT, Action.THEMES_SUBMENU,
Action.RESET_SCREEN));
+ flat.addAll(List.of(Action.THEMES_SUBMENU, Action.SCREENSHOT,
Action.RESET_SCREEN));
flat.add(null);
flat.addAll(List.of(Action.TAPE_RECORDING, Action.TAPE_INSTRUCTIONS,
Action.CAPTION, Action.SHOW_KEYSTROKES));
+ flat.add(null);
+ flat.add(Action.CAMEL_ANIMATION);
return flat;
}
@@ -277,7 +291,7 @@ class ActionsPopup {
List<Action> flat = new ArrayList<>();
flat.add(Action.BACK);
flat.add(null);
- flat.addAll(List.of(Action.SETUP_AI, Action.AI_LOG, Action.MCP_INFO,
Action.MCP_LOG));
+ flat.addAll(List.of(Action.AI_PROMPT, Action.SETUP_AI, Action.AI_LOG,
Action.MCP_INFO, Action.MCP_LOG));
return flat;
}
@@ -359,19 +373,22 @@ class ActionsPopup {
if ("screen".equals(currentSubmenu)) {
labels.add("..");
labels.add("───");
- labels.add("Take Screenshot");
labels.add("Themes...");
+ labels.add("Take Screenshot");
labels.add("Reset Screen");
labels.add("───");
labels.add(tapeRecordingActive.get() ? "Stop Tape Recording" :
"Start Tape Recording");
labels.add("Tape Recording Guide");
labels.add("Caption...");
labels.add(keystrokesEnabled.get() ? "Hide Keystrokes" : "Show
Keystrokes");
+ labels.add("───");
+ labels.add("Camel Animation");
return labels;
}
if ("mcp".equals(currentSubmenu)) {
labels.add("..");
labels.add("───");
+ labels.add("AI Prompt");
labels.add("Setup MCP");
labels.add("AI Log");
labels.add("MCP Info");
@@ -379,7 +396,7 @@ class ActionsPopup {
return labels;
}
// Main menu
- labels.add("Go to...");
+ labels.add("Go to... (Shift+F2)");
labels.add("Switch Integration (F3)");
labels.add("───");
labels.add("Send Message");
@@ -407,6 +424,10 @@ class ActionsPopup {
actionsMenuState.select(0);
}
+ void openGotoTab() {
+ gotoTabPopup.open();
+ }
+
void close() {
showActionsMenu = false;
currentSubmenu = null;
@@ -441,7 +462,7 @@ class ActionsPopup {
boolean handleKeyEvent(KeyEvent ke) {
if (sendMessagePopup.isVisible()) {
- if (ke.isConfirm()) {
+ if (ke.isKey(KeyCode.F5)) {
sendMessagePopup.doSend(ctx, scheduler);
} else {
sendMessagePopup.handleKeyEvent(ke);
@@ -619,6 +640,11 @@ class ActionsPopup {
} else if (action == Action.RUN_INFRA) {
showActionsMenu = false;
infraBrowserPopup.open();
+ } else if (action == Action.AI_PROMPT) {
+ showActionsMenu = false;
+ if (openAiPromptAction != null) {
+ openAiPromptAction.run();
+ }
} else if (action == Action.SETUP_AI) {
showActionsMenu = false;
openSetupAI();
@@ -657,6 +683,11 @@ class ActionsPopup {
} else if (action == Action.CAPTION) {
showActionsMenu = false;
captionOverlay.openInline();
+ } else if (action == Action.CAMEL_ANIMATION) {
+ showActionsMenu = false;
+ if (camelAnimationAction != null) {
+ camelAnimationAction.run();
+ }
}
}
}
@@ -885,7 +916,7 @@ class ActionsPopup {
boolean canSwitch = hasMultipleIntegrations();
List<ListItem> items = new ArrayList<>();
// Group 0: Navigation
- items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.GO_TO, "Go
to...")));
+ items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.GO_TO, "Go to...
(Shift+F2)")));
items.add(canSwitch
? ListItem.from(TuiIcons.menuItem(TuiIcons.SWITCH, "Switch
Integration (F3)"))
: ListItem.from(TuiIcons.menuItem(TuiIcons.SWITCH, "Switch
Integration (F3)")).style(Style.EMPTY.dim()));
@@ -914,7 +945,7 @@ class ActionsPopup {
}
// Group 4: Shell
items.add(ListItem.from(divider).style(Style.EMPTY.dim()));
- items.add(ListItem.from(" >_ Shell"));
+ items.add(ListItem.from(" >_ Shell (F6)"));
ListWidget list = ListWidget.builder()
.items(items.toArray(ListItem[]::new))
.highlightStyle(Theme.selectionBg())
@@ -948,14 +979,16 @@ class ActionsPopup {
List<ListItem> items = new ArrayList<>();
items.add(ListItem.from(" .."));
items.add(ListItem.from(divider).style(Style.EMPTY.dim()));
- items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.SCREENSHOT, "Take
Screenshot")));
items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.THEMES,
"Themes...")));
+ items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.SCREENSHOT, "Take
Screenshot")));
items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.CLEAN, "Reset
Screen")));
items.add(ListItem.from(divider).style(Style.EMPTY.dim()));
items.add(ListItem.from(tapeLabel));
items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.DOCUMENT, "Tape
Recording Guide")));
items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.CAPTION,
"Caption...")));
items.add(ListItem.from(keystrokeLabel));
+ items.add(ListItem.from(divider).style(Style.EMPTY.dim()));
+ items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.CAMEL, "Camel
Animation")));
ListWidget list = ListWidget.builder()
.items(items.toArray(ListItem[]::new))
.highlightStyle(Theme.selectionBg())
@@ -984,7 +1017,8 @@ class ActionsPopup {
List<ListItem> items = new ArrayList<>();
items.add(ListItem.from(" .."));
items.add(ListItem.from(divider).style(Style.EMPTY.dim()));
- items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.MCP_BRAIN, "Setup
MCP")));
+ items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.MCP_BRAIN, "AI
Prompt (F8)")));
+ items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.README, "Setup
MCP")));
items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.CAPTION, "AI
Log")));
items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.MCP, "MCP Info")));
items.add(ListItem.from(TuiIcons.menuItem(TuiIcons.MCP_LOG, "MCP
Log")));
@@ -1219,6 +1253,16 @@ class ActionsPopup {
case MCP_INFO -> openMcpInfo();
case MCP_LOG -> openMcpLog();
case AI_LOG -> openAiLog();
+ case AI_PROMPT -> {
+ if (openAiPromptAction != null) {
+ openAiPromptAction.run();
+ }
+ }
+ case CAMEL_ANIMATION -> {
+ if (camelAnimationAction != null) {
+ camelAnimationAction.run();
+ }
+ }
default -> {
return false;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BuiltinAnimations.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BuiltinAnimations.java
new file mode 100644
index 000000000000..6f1480fde809
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/BuiltinAnimations.java
@@ -0,0 +1,158 @@
+/*
+ * 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 dev.tamboui.style.Color;
+
+/**
+ * Pre-built animations that can be played via tui_animate with a name
parameter. Avoids the AI having to generate large
+ * frame payloads token by token.
+ */
+final class BuiltinAnimations {
+
+ record Frame(long delayMs, List<DrawOverlay.DrawCell> cells) {
+ }
+
+ // Camel art variants for eye blink and tail wiggle
+ private static final String[] CAMEL_OPEN = {
+ " ,,__",
+ "/o. \\___/\\",
+ "\\__/ \\",
+ " | | |",
+ " | | |~",
+ " (_) (_) (_)",
+ };
+ private static final String[] CAMEL_BLINK = {
+ " ,,__",
+ "/-. \\___/\\",
+ "\\__/ \\",
+ " | | |",
+ " | | |~",
+ " (_) (_) (_)",
+ };
+ private static final String[] CAMEL_TAIL_UP = {
+ " ,,__",
+ "/o. \\___/\\",
+ "\\__/ \\",
+ " | | |",
+ " | | |\\",
+ " (_) (_) (_)",
+ };
+ private static final String[] CAMEL_BLINK_TAIL_UP = {
+ " ,,__",
+ "/-. \\___/\\",
+ "\\__/ \\",
+ " | | |",
+ " | | |\\",
+ " (_) (_) (_)",
+ };
+
+ private BuiltinAnimations() {
+ }
+
+ static List<String> names() {
+ return List.of("camel");
+ }
+
+ static List<Frame> get(String name) {
+ return switch (name) {
+ case "camel" -> camelLogo();
+ default -> null;
+ };
+ }
+
+ private static void addText(List<DrawOverlay.DrawCell> cells, int x, int
y, String text, Color color) {
+ cells.addAll(DrawOverlay.generateText(x, y, text, color));
+ }
+
+ private static void addCamel(List<DrawOverlay.DrawCell> cells, int x, int
y, String[] art, int lines) {
+ for (int i = 0; i < lines && i < art.length; i++) {
+ addText(cells, x, y + i, art[i], Color.YELLOW);
+ }
+ }
+
+ private static void addBigCamelText(List<DrawOverlay.DrawCell> cells, int
x, int y) {
+ addText(cells, x, y, " ██████╗ █████╗ ███╗ ███╗███████╗██╗",
Color.YELLOW);
+ addText(cells, x, y + 1, "██╔════╝ ██╔══██╗████╗ ████║██╔════╝██║",
Color.YELLOW);
+ addText(cells, x, y + 2, "██║ ███████║██╔████╔██║█████╗ ██║",
Color.YELLOW);
+ addText(cells, x, y + 3, "██║ ██╔══██║██║╚██╔╝██║██╔══╝ ██║",
Color.YELLOW);
+ addText(cells, x, y + 4, "╚██████╗ ██║ ██║██║ ╚═╝
██║███████╗███████╗", Color.YELLOW);
+ addText(cells, x, y + 5, " ╚═════╝ ╚═╝ ╚═╝╚═╝
╚═╝╚══════╝╚══════╝", Color.YELLOW);
+ }
+
+ private static List<DrawOverlay.DrawCell> buildFinalFrame(int cx, int cy,
String[] camelArt) {
+ List<DrawOverlay.DrawCell> cells = new ArrayList<>();
+ addText(cells, 52, 2, "A P A C H E", Color.WHITE);
+ addCamel(cells, cx, cy, camelArt, camelArt.length);
+ addBigCamelText(cells, 38, 14);
+ addText(cells, 42, 22, "The Open Source Integration Framework",
Color.WHITE);
+ return cells;
+ }
+
+ private static List<Frame> camelLogo() {
+ List<Frame> frames = new ArrayList<>();
+ int cx = 57;
+ int cy = 5;
+
+ // Frame 1: Camel head (first 2 lines)
+ List<DrawOverlay.DrawCell> f1 = new ArrayList<>();
+ addCamel(f1, cx, cy, CAMEL_OPEN, 2);
+ frames.add(new Frame(0, f1));
+
+ // Frame 2: Camel body (first 4 lines)
+ List<DrawOverlay.DrawCell> f2 = new ArrayList<>();
+ addCamel(f2, cx, cy, CAMEL_OPEN, 4);
+ frames.add(new Frame(400, f2));
+
+ // Frame 3: Full camel (all 6 lines)
+ List<DrawOverlay.DrawCell> f3 = new ArrayList<>();
+ addCamel(f3, cx, cy, CAMEL_OPEN, 6);
+ frames.add(new Frame(400, f3));
+
+ // Frame 4: "APACHE" appears above
+ List<DrawOverlay.DrawCell> f4 = new ArrayList<>();
+ addText(f4, 52, 2, "A P A C H E", Color.WHITE);
+ addCamel(f4, cx, cy, CAMEL_OPEN, 6);
+ frames.add(new Frame(500, f4));
+
+ // Frame 5: Large "CAMEL" text appears
+ List<DrawOverlay.DrawCell> f5 = new ArrayList<>();
+ addText(f5, 52, 2, "A P A C H E", Color.WHITE);
+ addCamel(f5, cx, cy, CAMEL_OPEN, 6);
+ addBigCamelText(f5, 38, 14);
+ frames.add(new Frame(500, f5));
+
+ // Frame 6: Tagline appears
+ frames.add(new Frame(600, buildFinalFrame(cx, cy, CAMEL_OPEN)));
+
+ // Frames 7-14: Eye blink + tail wiggle cycle (2 rounds)
+ for (int round = 0; round < 2; round++) {
+ frames.add(new Frame(500, buildFinalFrame(cx, cy, CAMEL_BLINK)));
+ frames.add(new Frame(150, buildFinalFrame(cx, cy, CAMEL_OPEN)));
+ frames.add(new Frame(300, buildFinalFrame(cx, cy, CAMEL_TAIL_UP)));
+ frames.add(new Frame(200, buildFinalFrame(cx, cy, CAMEL_OPEN)));
+ }
+
+ // Final hold before auto-close
+ frames.add(new Frame(2000, buildFinalFrame(cx, cy, CAMEL_OPEN)));
+
+ return frames;
+ }
+}
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 0339c91c4361..db02c46c6b1e 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
@@ -133,6 +133,7 @@ public class CamelMonitor extends CamelCommand {
private final CaptionOverlay captionOverlay = new CaptionOverlay();
private final RecordingManager recordingManager = new
RecordingManager(captionOverlay);
private final DrawOverlay drawOverlay = new DrawOverlay();
+ private final CanvasOverlay canvasOverlay = new CanvasOverlay();
private final HelpOverlay helpOverlay = new HelpOverlay();
private final ShellPanel shellPanel = new ShellPanel();
private final AiPanel aiPanel = new AiPanel();
@@ -268,6 +269,7 @@ public class CamelMonitor extends CamelCommand {
shellPanel.setContext(ctx);
aiPanel.setContext(ctx);
actionsPopup.setOpenShellAction(shellPanel::open);
+ actionsPopup.setOpenAiPromptAction(aiPanel::open);
actionsPopup.setBrowseFilesAction(this::openFilesPopup);
actionsPopup.setSwitchIntegrationAction(
() -> popupManager.openSwitchPopup(ctx.selectedPid,
getNonVanishingIntegrations()));
@@ -311,10 +313,21 @@ public class CamelMonitor extends CamelCommand {
}
});
- actionsPopup.setGotoTabSupport(tabRegistry.allTabEntries(), () -> {
+ List<TabRegistry.TabEntry> gotoEntries = tabRegistry.allTabEntries();
+ gotoEntries.add(
+ new TabRegistry.TabEntry(TuiIcons.SWITCH, "Switch
Integration", "Switch between integrations", "F3", -10, -1));
+ gotoEntries.add(new TabRegistry.TabEntry(TuiIcons.COMPUTER, "Shell",
"Open embedded terminal", "F6", -11, -1));
+ gotoEntries.add(new TabRegistry.TabEntry(TuiIcons.MCP_BRAIN, "AI
Prompt", "Open AI prompt panel", "F8", -12, -1));
+ actionsPopup.setGotoTabSupport(gotoEntries, () -> {
TabRegistry.TabEntry entry =
actionsPopup.consumePendingGotoEntry();
if (entry != null) {
- if (entry.moreIndex() >= 0) {
+ if (entry.tabIndex() == -10) {
+ popupManager.openSwitchPopup(ctx.selectedPid,
getNonVanishingIntegrations());
+ } else if (entry.tabIndex() == -11) {
+ shellPanel.open();
+ } else if (entry.tabIndex() == -12) {
+ aiPanel.open();
+ } else if (entry.moreIndex() >= 0) {
tabRegistry.selectMoreTab(entry.moreIndex());
} else {
tabRegistry.handleTabKey(entry.tabIndex(), ctx,
dataService);
@@ -384,9 +397,18 @@ public class CamelMonitor extends CamelCommand {
// Auto-select if there's exactly one integration running
tabRegistry.overviewTab().selectCurrentIntegration();
+ canvasOverlay.setOnOpen(() -> {
+ popupManager.dismissAll();
+ actionsPopup.close();
+ helpOverlay.close();
+ captionOverlay.close();
+ shellPanel.close();
+ aiPanel.close();
+ });
+ canvasOverlay.setOnClose(drawOverlay::clear);
mcpFacade = new McpFacade(
ctx, dataService.data(), tabsState, recordingManager,
- captionOverlay, drawOverlay, helpOverlay,
+ captionOverlay, drawOverlay, canvasOverlay, helpOverlay,
actionsPopup, filesBrowser,
tabRegistry,
pendingKeys,
@@ -464,6 +486,7 @@ public class CamelMonitor extends CamelCommand {
actionsPopup.setScheduler(tui.scheduler());
actionsPopup.setResetScreenAction(() -> tui.terminal().clear());
actionsPopup.setThemeRefreshAction(() -> tui.terminal().clear());
+ actionsPopup.setCamelAnimationAction(() ->
playBuiltinAnimation("camel"));
// Preload diagram data if an integration was auto-selected
tabRegistry.routesTab().preloadDiagram();
tabRegistry.diagramTab().preloadDiagram();
@@ -510,6 +533,9 @@ public class CamelMonitor extends CamelCommand {
if (helpOverlay.isVisible()) {
return helpOverlay.handleKeyEvent(ke);
}
+ if (canvasOverlay.isVisible()) {
+ return canvasOverlay.handleKeyEvent(ke);
+ }
if (shellPanel.isOpen()) {
// Shift+F6 cycles shell height — handle before delegating to
shell
if (ke.isKey(KeyCode.F6) && ke.hasShift()) {
@@ -701,6 +727,10 @@ public class CamelMonitor extends CamelCommand {
}
return true;
}
+ if (ke.isKey(KeyCode.F2) && ke.hasShift()) {
+ actionsPopup.openGotoTab();
+ return true;
+ }
if (ke.isKey(KeyCode.F2)) {
if (tabRegistry.selectedTabIndex() == TAB_ROUTES &&
tabRegistry.routesTab() != null) {
actionsPopup.setPreSelectedRouteId(tabRegistry.routesTab().selectedRouteId());
@@ -1080,14 +1110,23 @@ public class CamelMonitor extends CamelCommand {
setNotification(reloadMsg, false);
}
- renderHeader(frame, mainChunks.get(0));
- renderTabs(frame, mainChunks.get(1));
- lastTabsArea = mainChunks.get(1);
- Rect contentArea = mainChunks.get(2);
- lastContentArea = contentArea;
+ Rect contentArea;
+ if (canvasOverlay.isVisible()) {
+ // Canvas covers header + tabs + content
+ contentArea = new Rect(area.x(), area.y(), area.width(),
area.height() - 1);
+ lastContentArea = contentArea;
+ } else {
+ renderHeader(frame, mainChunks.get(0));
+ renderTabs(frame, mainChunks.get(1));
+ lastTabsArea = mainChunks.get(1);
+ contentArea = mainChunks.get(2);
+ lastContentArea = contentArea;
+ }
shellPanel.tickAnimation();
aiPanel.tickAnimation();
- if (shellPanel.isOpen()) {
+ if (canvasOverlay.isVisible()) {
+ canvasOverlay.render(frame, contentArea);
+ } else if (shellPanel.isOpen()) {
shellPanel.initHeight(contentArea.height());
int ph = shellPanel.panelHeight();
ctx.shellPercent = ph * 100 / Math.max(1, contentArea.height());
@@ -1117,7 +1156,6 @@ public class CamelMonitor extends CamelCommand {
renderContent(frame, contentArea);
panelSplit.clearBorderPos();
}
- // Overlays render on top of the full content area regardless of shell
state
if (drawOverlay.isVisible()) {
drawOverlay.render(frame, contentArea);
}
@@ -1674,6 +1712,36 @@ public class CamelMonitor extends CamelCommand {
dataService.metrics().resetStats(pid);
}
+ private void playBuiltinAnimation(String name) {
+ List<BuiltinAnimations.Frame> frames = BuiltinAnimations.get(name);
+ if (frames == null) {
+ return;
+ }
+ canvasOverlay.open();
+ Thread thread = new Thread(() -> {
+ try {
+ for (BuiltinAnimations.Frame f : frames) {
+ if (!canvasOverlay.isVisible()) {
+ return;
+ }
+ if (f.delayMs() > 0) {
+ Thread.sleep(f.delayMs());
+ }
+ if (!canvasOverlay.isVisible()) {
+ return;
+ }
+ drawOverlay.setDrawing(f.cells(), 0);
+ }
+ Thread.sleep(100);
+ canvasOverlay.close();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }, "camel-animation");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
private void sendRouteCommand(String pid, String routeId, String command) {
dataService.enableBurstMode();
JsonObject root = new JsonObject();
@@ -1713,6 +1781,12 @@ public class CamelMonitor extends CamelCommand {
return;
}
+ if (canvasOverlay.isVisible()) {
+ canvasOverlay.renderFooter(spans);
+ frame.renderWidget(Paragraph.from(Line.from(spans)), area);
+ return;
+ }
+
if (filesBrowser.isVisible()) {
filesBrowser.renderFooter(spans);
} else if (popupManager.isSwitchPopupVisible()) {
@@ -1819,25 +1893,19 @@ public class CamelMonitor extends CamelCommand {
hint(fKeySpans, "F1", "help");
}
hint(fKeySpans, "F2", "actions");
- if (popupManager.getNonVanishingIntegrations().size() > 1) {
- hint(fKeySpans, "F3", "switch");
- }
- hint(fKeySpans, "F6", "shell");
- hint(fKeySpans, "F8", "AI");
spans.addAll(insertPos, fKeySpans);
// Return total F-key span count. The footer drop loop uses this to
remove pairs from
- // the tail (F6, then F3, F2), stopping before the first pair (F1 help
when present).
+ // the tail, stopping before the first pair (F1 help when present).
return fKeySpans.size();
}
/**
* Drops secondary F-key hint pairs from an overflowing footer. The F-key
pairs are inserted at position 2 (after
* the first tab hint), so the last pair's key span sits at index {@code
fKeyTotal}. Pairs are removed from the
- * tail, so F6 goes first, then F3, then F2, and the loop stops at 2 so
the first pair (F1 help when present) is
- * always preserved.
+ * tail, so F2 goes first, and the loop stops at 2 so the first pair (F1
help when present) is always preserved.
*
* @param spans the footer spans, mutated in place by removing
dropped pairs
- * @param fKeyTotal total number of F-key spans that were inserted (e.g.
8 for F1/F2/F3/F6)
+ * @param fKeyTotal total number of F-key spans that were inserted (e.g.
4 for F1/F2)
* @param hintsWidth the current rendered width of {@code spans}
* @param available the available footer width
* @return the rendered width of {@code spans} after dropping
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CanvasOverlay.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CanvasOverlay.java
new file mode 100644
index 000000000000..83bb1667e35c
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CanvasOverlay.java
@@ -0,0 +1,76 @@
+/*
+ * 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.List;
+
+import dev.tamboui.layout.Rect;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Span;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.Clear;
+
+import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hintLast;
+
+class CanvasOverlay {
+
+ private boolean visible;
+ private Runnable onOpen;
+ private Runnable onClose;
+
+ void open() {
+ if (onOpen != null) {
+ onOpen.run();
+ }
+ visible = true;
+ }
+
+ void close() {
+ visible = false;
+ if (onClose != null) {
+ onClose.run();
+ }
+ }
+
+ boolean isVisible() {
+ return visible;
+ }
+
+ void setOnOpen(Runnable onOpen) {
+ this.onOpen = onOpen;
+ }
+
+ void setOnClose(Runnable onClose) {
+ this.onClose = onClose;
+ }
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ if (ke.isCancel()) {
+ close();
+ return true;
+ }
+ return true;
+ }
+
+ void render(Frame frame, Rect area) {
+ frame.renderWidget(Clear.INSTANCE, area);
+ }
+
+ void renderFooter(List<Span> spans) {
+ hintLast(spans, "Esc", "close");
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTab.java
index 14380ce8f00a..eedf97e70c36 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CircuitBreakerTab.java
@@ -301,7 +301,7 @@ class CircuitBreakerTab extends AbstractTableTab {
LinkedList<Long> successHist = cbSuccessHistory.get(key);
LinkedList<Long> failHist = cbFailHistory.get(key);
- int renderPoints = Math.min(MAX_CHART_POINTS, Math.max(2,
vSplit.get(1).width() - 6));
+ int renderPoints = Math.max(20, (Math.min(MAX_CHART_POINTS,
vSplit.get(1).width() - 6) / 20) * 20);
long[] successArr = new long[renderPoints];
long[] failArr = new long[renderPoints];
if (successHist != null) {
@@ -326,7 +326,7 @@ class CircuitBreakerTab extends AbstractTableTab {
Span.styled("▬", Theme.success()),
Span.raw(String.format(" ok:%-4d ", curSuccess)),
Span.styled("▬", Theme.error()),
- Span.raw(String.format(" fail:%-4d msg/s", curFail)));
+ Span.raw(String.format(" fail:%-4d msg/s ", curFail)));
frame.renderWidget(DualSparkline.builder()
.topData(successArr)
.bottomData(failArr)
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
index 71618179c167..cca6df972afe 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DiagramTab.java
@@ -736,129 +736,129 @@ class DiagramTab extends AbstractTab {
@Override
public String getHelpText() {
return """
- # Diagram
+ # Diagram
- The Diagram tab shows a visual topology of how
routes connect to each other.
- This helps you understand the overall message
flow in your integration.
+ The Diagram tab shows a visual topology of how routes connect
to each other.
+ This helps you understand the overall message flow in your
integration.
- ## Topology View
+ ## Topology View
- The topology view shows all routes and their
connections:
- - **Trigger routes** (timer, cron, etc.)
appear at the top
- - **Downstream routes** appear below,
connected by arrows
- - Routes that are connected show edges between
them
-
- ## Example Topology
-
- ```
- ┌──────────────────┐
- │ order-generator │
- │ (timer://gen) │
- │ │
- │ 3748 │
- └──────────────────┘
- │
- ▼
- ┌──────────────────┐
- │ process-order │
- │ (direct:process) │
- │ │
- │ 3748/12! │
- └──────────────────┘
- ```
-
- Each box represents a route. The first line is
the route ID,
- the second line shows the `from` endpoint, and
the bottom line
- shows metrics when enabled. Arrows show how
routes connect.
-
- ## Metrics
-
- When metrics are enabled, each route box shows
exchange counts:
- - **Green** number — successful exchanges
- - **Red** number — failed exchanges
- - Combined as `3748/12` means 3748 ok and 12
failed
-
- ## External Systems
-
- Press `e` to cycle through three external
modes:
-
- - **off** — no external endpoints shown
- - **edges** — external endpoints that are
truly outside Camel are shown
- as dashed boxes in top/bottom bands. Routes
sharing an external
- endpoint (e.g. kafka) are connected with a
direct arrow.
- - **all** — same as edges, but routes sharing
an external endpoint
- are connected through an intermediary dashed
box showing the
- endpoint name, instead of a direct arrow.
-
- External system boxes are drawn with dashed
borders to distinguish
- them from route boxes. Dashed edges connect
routes to external systems.
-
- ## Navigation
-
- In the topology view, use arrow keys to select
route boxes:
- - `↑↓` moves between layers
(upstream/downstream routes)
- - `←→` moves between routes in the same layer
-
- When a route is selected, an **Info panel**
appears on the left
- showing key metrics: state, uptime,
throughput, exchange counts,
- and processing times.
-
- Press `Enter` on a selected route to **drill
down** into its
- internal EIP structure (the route diagram).
Press `Esc` to
- return to the topology view.
-
- ## Route Diagram
-
- In the route diagram, each EIP node shows its
type tag (colored)
- and endpoint URI or description. Nodes that
connect to other routes
- display a `↵` indicator — press `Enter` to
jump directly to the
- linked route's diagram.
-
- Navigation history is maintained as a stack:
pressing `Esc` goes
- back to the previous route, and eventually
back to the topology view.
-
- ## Route Structure Preview
-
- A compact tree structure preview appears in
the bottom-right corner
- of the diagram area — like a minimap of the
route's EIP structure.
-
- In **topology mode**, the preview shows the
structure of the currently
- selected route and updates as you navigate
between route boxes.
-
- In **drill-down mode**, the preview highlights
the currently selected
- EIP node (shown in yellow) as you navigate
with arrow keys, giving
- you an at-a-glance view of where you are in
the route.
-
- ## Keys
-
- **Topology view:**
- - `↑↓←→` — navigate between route boxes
- - `Enter` — drill down into selected route
- - `c` — show route source code
- - `Esc` — close diagram
-
- **Route diagram:**
- - `↑↓←→` — navigate between EIP nodes
- - `Enter` — jump to linked route (when `↵`
indicator shown)
- - `c` — show source code at selected node
- - `Esc` — go back (previous route or topology)
- - `t` — jump back to topology view
-
- **Source view:**
- - `↑↓` — move cursor between lines
- - `Ctrl+↑↓` — scroll viewport without moving
cursor
- - `←→` — horizontal scroll
- - `PgUp/PgDn` — page jump
- - `Home/End` — go to top/bottom
- - `Enter` — select the closest diagram node at
cursor line
- - `Esc/c` — close source view
-
- **Common:**
- - `m` — toggle metrics on/off (default: on)
- - `e` — toggle external systems on/off
(topology only)
- - `n` — toggle description labels on/off
- - `PgUp/PgDn` — page scroll
- - `Home/End` — top/end
+ The topology view shows all routes and their connections:
+ - **Trigger routes** (timer, cron, etc.) appear at the top
+ - **Downstream routes** appear below, connected by arrows
+ - Routes that are connected show edges between them
+
+ ## Example Topology
+
+ ```
+ ┌──────────────────┐
+ │ order-generator │
+ │ (timer://gen) │
+ │ │
+ │ 3748 │
+ └──────────────────┘
+ │
+ ▼
+ ┌──────────────────┐
+ │ process-order │
+ │ (direct:process) │
+ │ │
+ │ 3748/12! │
+ └──────────────────┘
+ ```
+
+ Each box represents a route. The first line is the route ID,
+ the second line shows the `from` endpoint, and the bottom line
+ shows metrics when enabled. Arrows show how routes connect.
+
+ ## Metrics
+
+ When metrics are enabled, each route box shows exchange counts:
+ - **Green** number — successful exchanges
+ - **Red** number — failed exchanges
+ - Combined as `3748/12` means 3748 ok and 12 failed
+
+ ## External Systems
+
+ Press `e` to cycle through three external modes:
+
+ - **off** — no external endpoints shown
+ - **edges** — external endpoints that are truly outside Camel
are shown
+ as dashed boxes in top/bottom bands. Routes sharing an
external
+ endpoint (e.g. kafka) are connected with a direct arrow.
+ - **all** — same as edges, but routes sharing an external
endpoint
+ are connected through an intermediary dashed box showing the
+ endpoint name, instead of a direct arrow.
+
+ External system boxes are drawn with dashed borders to
distinguish
+ them from route boxes. Dashed edges connect routes to external
systems.
+
+ ## Navigation
+
+ In the topology view, use arrow keys to select route boxes:
+ - `↑↓` moves between layers (upstream/downstream routes)
+ - `←→` moves between routes in the same layer
+
+ When a route is selected, an **Info panel** appears on the left
+ showing key metrics: state, uptime, throughput, exchange
counts,
+ and processing times.
+
+ Press `Enter` on a selected route to **drill down** into its
+ internal EIP structure (the route diagram). Press `Esc` to
+ return to the topology view.
+
+ ## Route Diagram
+
+ In the route diagram, each EIP node shows its type tag
(colored)
+ and endpoint URI or description. Nodes that connect to other
routes
+ display a `↵` indicator — press `Enter` to jump directly to the
+ linked route's diagram.
+
+ Navigation history is maintained as a stack: pressing `Esc`
goes
+ back to the previous route, and eventually back to the
topology view.
+
+ ## Route Structure Preview
+
+ A compact tree structure preview appears in the bottom-right
corner
+ of the diagram area — like a minimap of the route's EIP
structure.
+
+ In **topology mode**, the preview shows the structure of the
currently
+ selected route and updates as you navigate between route boxes.
+
+ In **drill-down mode**, the preview highlights the currently
selected
+ EIP node (shown in yellow) as you navigate with arrow keys,
giving
+ you an at-a-glance view of where you are in the route.
+
+ ## Keys
+
+ **Topology view:**
+ - `↑↓←→` — navigate between route boxes
+ - `Enter` — drill down into selected route
+ - `c` — show route source code
+ - `Esc` — close diagram
+
+ **Route diagram:**
+ - `↑↓←→` — navigate between EIP nodes
+ - `Enter` — jump to linked route (when `↵` indicator shown)
+ - `c` — show source code at selected node
+ - `Esc` — go back (previous route or topology)
+ - `t` — jump back to topology view
+
+ **Source view:**
+ - `↑↓` — move cursor between lines
+ - `Ctrl+↑↓` — scroll viewport without moving cursor
+ - `←→` — horizontal scroll
+ - `PgUp/PgDn` — page jump
+ - `Home/End` — go to top/bottom
+ - `Enter` — select the closest diagram node at cursor line
+ - `Esc/c` — close source view
+
+ **Common:**
+ - `m` — toggle metrics on/off (default: on)
+ - `e` — toggle external systems on/off (topology only)
+ - `n` — toggle description labels on/off
+ - `PgUp/PgDn` — page scroll
+ - `Home/End` — top/end
""";
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
index 215dd18b6eb4..08f941f0cd91 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/DrawOverlay.java
@@ -164,10 +164,17 @@ class DrawOverlay {
List<DrawCell> cells = new ArrayList<>();
Style s = Style.EMPTY.fg(color).bold();
int col = x;
+ int row = y;
for (int i = 0; i < text.length();) {
int cp = text.codePointAt(i);
+ if (cp == '\n') {
+ row++;
+ col = x;
+ i++;
+ continue;
+ }
String ch = new String(Character.toChars(cp));
- cells.add(new DrawCell(col, y, ch, s));
+ cells.add(new DrawCell(col, row, ch, s));
col += Math.max(1, dev.tamboui.text.CharWidth.of(cp));
i += Character.charCount(cp);
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java
index 866ce7fff829..e254dc1681a1 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/EndpointsTab.java
@@ -504,7 +504,7 @@ class EndpointsTab extends AbstractTableTab {
LinkedList<Long> inHist = inHistMap.getOrDefault(pid, new
LinkedList<>());
LinkedList<Long> outHist = outHistMap.getOrDefault(pid, new
LinkedList<>());
- int renderPoints = Math.min(MAX_CHART_POINTS, Math.max(2,
hParts.get(1).width() - 6));
+ int renderPoints = Math.max(20, (Math.min(MAX_CHART_POINTS,
hParts.get(1).width() - 6) / 20) * 20);
long[] inArr = new long[renderPoints];
long[] outArr = new long[renderPoints];
for (int i = 0; i < renderPoints; i++) {
@@ -524,7 +524,7 @@ class EndpointsTab extends AbstractTableTab {
Span.styled("▬", Theme.success()),
Span.raw(String.format(" in:%-4s ",
MetricsCollector.formatThroughput(curIn))),
Span.styled("▬", Style.EMPTY.fg(Theme.accent())),
- Span.raw(String.format(" out:%-4s msg/s",
MetricsCollector.formatThroughput(curOut))));
+ Span.raw(String.format(" out:%-4s msg/s ",
MetricsCollector.formatThroughput(curOut))));
Rect rightArea = hParts.get(1);
frame.renderWidget(DualSparkline.builder()
@@ -609,7 +609,7 @@ class EndpointsTab extends AbstractTableTab {
LinkedList<Long> inHist = perEndpointInHistory.getOrDefault(key, new
LinkedList<>());
LinkedList<Long> outHist = perEndpointOutHistory.getOrDefault(key, new
LinkedList<>());
- int renderPoints = Math.min(MAX_CHART_POINTS, Math.max(2,
hParts.get(1).width() - 6));
+ int renderPoints = Math.max(20, (Math.min(MAX_CHART_POINTS,
hParts.get(1).width() - 6) / 20) * 20);
long[] inArr = new long[renderPoints];
long[] outArr = new long[renderPoints];
for (int i = 0; i < renderPoints; i++) {
@@ -637,7 +637,7 @@ class EndpointsTab extends AbstractTableTab {
Span.styled("▬", Theme.success()),
Span.raw(String.format(" in:%-4s ",
MetricsCollector.formatThroughput(curIn))),
Span.styled("▬", Style.EMPTY.fg(Theme.accent())),
- Span.raw(String.format(" out:%-4s msg/s",
MetricsCollector.formatThroughput(curOut))));
+ Span.raw(String.format(" out:%-4s msg/s ",
MetricsCollector.formatThroughput(curOut))));
frame.renderWidget(DualSparkline.builder()
.topData(inArr)
@@ -656,7 +656,7 @@ class EndpointsTab extends AbstractTableTab {
LinkedList<Long> inHist = endpointInSizeHistory.getOrDefault(pid, new
LinkedList<>());
LinkedList<Long> outHist = endpointOutSizeHistory.getOrDefault(pid,
new LinkedList<>());
- int renderPoints = Math.min(MAX_CHART_POINTS, Math.max(2, area.width()
- 6));
+ int renderPoints = Math.max(20, (Math.min(MAX_CHART_POINTS,
area.width() - 6) / 20) * 20);
long[] inArr = new long[renderPoints];
long[] outArr = new long[renderPoints];
for (int i = 0; i < renderPoints; i++) {
@@ -676,7 +676,7 @@ class EndpointsTab extends AbstractTableTab {
Span.styled("▬", Theme.label()),
Span.raw(String.format(" in:%-8s ", sizeToString(curIn))),
Span.styled("▬", Theme.notice()),
- Span.raw(String.format(" out:%-8s avg body",
sizeToString(curOut))));
+ Span.raw(String.format(" out:%-8s avg body ",
sizeToString(curOut))));
frame.renderWidget(DualSparkline.builder()
.topData(inArr)
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoTabPopup.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoTabPopup.java
index 03b4dea5b560..633ffbf47737 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoTabPopup.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/GotoTabPopup.java
@@ -168,7 +168,8 @@ class GotoTabPopup {
int popupW = Math.min(100, area.width() - 4);
int descColWidth = popupW - nameColWidth - 8;
int contentH = filteredEntries.size() + 2;
- int popupH = Math.min(contentH + 2, area.height() - 4);
+ int maxH = area.height() - 4;
+ int popupH = contentH + 2 <= maxH ? contentH + 2 : Math.min(contentH +
2, maxH - 6);
int x = area.left() + Math.max(0, (area.width() - popupW) / 2);
int y = area.top() + 2;
Rect popup = new Rect(x, y, Math.min(popupW, area.width()),
Math.min(popupH, area.height() - 2));
@@ -192,6 +193,9 @@ class GotoTabPopup {
String sc = entry.shortcut();
spans.add(Span.raw(" "));
spans.add(Span.styled(sc, Theme.mnemonic()));
+ if (sc.length() < 2) {
+ spans.add(Span.raw(" "));
+ }
spans.add(Span.raw(" " + entry.icon() + " "));
String name = entry.name();
if (name.length() > nameColWidth) {
@@ -292,8 +296,8 @@ class GotoTabPopup {
String f = filter.filter();
for (TabRegistry.TabEntry entry : allEntries) {
boolean nameMatch;
- if (f.length() == 1) {
- nameMatch = entry.name().toLowerCase().startsWith(f);
+ if (f.length() <= 2) {
+ nameMatch = entry.name().toLowerCase().contains(f);
} else {
nameMatch = filter.match(entry.name()) != null;
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
index 14b1f5c68a52..79edb125357c 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/McpFacade.java
@@ -93,6 +93,7 @@ class McpFacade {
private final RecordingManager recordingManager;
private final CaptionOverlay captionOverlay;
private final DrawOverlay drawOverlay;
+ private final CanvasOverlay canvasOverlay;
private final HelpOverlay helpOverlay;
private final ActionsPopup actionsPopup;
private final FilesBrowser filesBrowser;
@@ -107,6 +108,7 @@ class McpFacade {
RecordingManager recordingManager,
CaptionOverlay captionOverlay,
DrawOverlay drawOverlay,
+ CanvasOverlay canvasOverlay,
HelpOverlay helpOverlay,
ActionsPopup actionsPopup,
FilesBrowser filesBrowser,
@@ -119,6 +121,7 @@ class McpFacade {
this.recordingManager = recordingManager;
this.captionOverlay = captionOverlay;
this.drawOverlay = drawOverlay;
+ this.canvasOverlay = canvasOverlay;
this.helpOverlay = helpOverlay;
this.actionsPopup = actionsPopup;
this.filesBrowser = filesBrowser;
@@ -217,6 +220,18 @@ class McpFacade {
drawOverlay.clear();
}
+ void openCanvas() {
+ canvasOverlay.open();
+ }
+
+ void closeCanvas() {
+ canvasOverlay.close();
+ }
+
+ boolean isCanvasVisible() {
+ return canvasOverlay.isVisible();
+ }
+
// ---- Tab navigation ----
String navigateToTab(String tabName) {
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
index 964936940de7..12a0b954abdb 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/PopupManager.java
@@ -148,6 +148,13 @@ class PopupManager {
}
}
+ void dismissAll() {
+ showMorePopup = false;
+ showSwitchPopup = false;
+ showKillConfirm = false;
+ filesBrowser.reset();
+ }
+
void closeMorePopup() {
showMorePopup = 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 00c845dc6991..3539b9f95571 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
@@ -26,6 +26,7 @@ import java.util.concurrent.ScheduledExecutorService;
import dev.tamboui.layout.Constraint;
import dev.tamboui.layout.Layout;
import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Overflow;
import dev.tamboui.style.Style;
import dev.tamboui.terminal.Frame;
import dev.tamboui.text.Line;
@@ -38,6 +39,8 @@ import dev.tamboui.widgets.block.Block;
import dev.tamboui.widgets.block.BorderType;
import dev.tamboui.widgets.block.Borders;
import dev.tamboui.widgets.block.Title;
+import dev.tamboui.widgets.input.TextArea;
+import dev.tamboui.widgets.input.TextAreaState;
import dev.tamboui.widgets.input.TextInput;
import dev.tamboui.widgets.input.TextInputState;
import dev.tamboui.widgets.paragraph.Paragraph;
@@ -45,6 +48,7 @@ import
org.apache.camel.dsl.jbang.core.common.CamelCommandHelper;
import org.apache.camel.dsl.jbang.core.common.PathUtils;
import org.apache.camel.util.json.JsonArray;
import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.*;
import static org.apache.camel.dsl.jbang.core.commands.tui.TuiHelper.hint;
@@ -66,7 +70,7 @@ class SendMessagePopup {
private String integrationName;
private List<RouteInfo> routes;
private int selectedRouteIndex;
- private final TextInputState bodyState = new TextInputState("");
+ private final TextAreaState bodyState = new TextAreaState("");
private int selectedField = FIELD_BODY;
private boolean inOut;
@@ -84,6 +88,9 @@ class SendMessagePopup {
private boolean responseError;
private int responseScroll;
private boolean prettyPrint;
+ private boolean wordWrap = true;
+ private boolean showResponseBody = true;
+ private boolean showResponseHeaders;
// History
private final List<SendHistoryEntry> history = new ArrayList<>();
@@ -103,7 +110,7 @@ class SendMessagePopup {
this.selectedRouteIndex = findSmartDefault(preSelectRouteId);
this.bodyState.clear();
this.selectedField = FIELD_BODY;
- this.inOut = false;
+ this.inOut = true;
this.sending = false;
this.headers = null;
this.selectedHeader = 0;
@@ -129,6 +136,10 @@ class SendMessagePopup {
return true;
}
if (ke.isConfirm()) {
+ if (selectedField == FIELD_BODY) {
+ bodyState.insert('\n');
+ return true;
+ }
if (selectedField == FIELD_HISTORY && !history.isEmpty()) {
replayHistoryEntry(history.get(historyIndex));
}
@@ -145,18 +156,40 @@ class SendMessagePopup {
return true;
}
- // Toggle pretty print (only when not editing text)
- if (ke.isChar('p') && selectedField != FIELD_BODY && selectedField !=
FIELD_HEADERS) {
- prettyPrint = !prettyPrint;
- if (responseLines != null) {
- rebuildResponseLines();
- responseScroll = 0;
+ // Toggle response display options (only when not editing text)
+ if (selectedField != FIELD_BODY && selectedField != FIELD_HEADERS) {
+ if (ke.isChar('b')) {
+ showResponseBody = !showResponseBody;
+ if (responseLines != null) {
+ rebuildResponseLines();
+ responseScroll = 0;
+ }
+ return true;
+ }
+ if (ke.isChar('h')) {
+ showResponseHeaders = !showResponseHeaders;
+ if (responseLines != null) {
+ rebuildResponseLines();
+ responseScroll = 0;
+ }
+ return true;
+ }
+ if (ke.isChar('p')) {
+ prettyPrint = !prettyPrint;
+ if (responseLines != null) {
+ rebuildResponseLines();
+ responseScroll = 0;
+ }
+ return true;
+ }
+ if (ke.isChar('w')) {
+ wordWrap = !wordWrap;
+ return true;
}
- return true;
}
if (selectedField == FIELD_BODY) {
- if (ke.isKey(KeyCode.TAB) || ke.isDown()) {
+ if (ke.isKey(KeyCode.TAB)) {
if (hasHeaders()) {
selectedField = FIELD_HEADERS;
selectedHeader = 0;
@@ -167,18 +200,59 @@ class SendMessagePopup {
return true;
}
if (ke.isUp()) {
- if (routes.size() > 1) {
- selectedField = FIELD_ROUTE;
+ if (bodyState.cursorRow() == 0) {
+ if (routes.size() > 1) {
+ selectedField = FIELD_ROUTE;
+ } else {
+ goToLastField();
+ }
} else {
- goToLastField();
+ bodyState.moveCursorUp();
}
return true;
}
- if (ke.isChar('+')) {
- addHeader();
+ if (ke.isDown()) {
+ if (bodyState.cursorRow() >= bodyState.lineCount() - 1) {
+ if (hasHeaders()) {
+ selectedField = FIELD_HEADERS;
+ selectedHeader = 0;
+ editingHeaderKey = true;
+ } else {
+ selectedField = FIELD_MODE;
+ }
+ } else {
+ bodyState.moveCursorDown();
+ }
+ return true;
+ }
+ if (ke.isLeft()) {
+ bodyState.moveCursorLeft();
+ return true;
+ }
+ if (ke.isRight()) {
+ bodyState.moveCursorRight();
+ return true;
+ }
+ if (ke.isHome()) {
+ bodyState.moveCursorToLineStart();
+ return true;
+ }
+ if (ke.isEnd()) {
+ bodyState.moveCursorToLineEnd();
+ return true;
+ }
+ if (ke.isDeleteBackward()) {
+ bodyState.deleteBackward();
+ return true;
+ }
+ if (ke.isDeleteForward()) {
+ bodyState.deleteForward();
+ return true;
+ }
+ if (ke.code() == KeyCode.CHAR) {
+ bodyState.insert(ke.character());
return true;
}
- FormHelper.handleTextInput(ke, bodyState);
return true;
}
if (selectedField == FIELD_ROUTE) {
@@ -276,10 +350,6 @@ class SendMessagePopup {
FormHelper.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;
@@ -368,21 +438,16 @@ class SendMessagePopup {
}
void handlePaste(String text) {
- if (!visible || sending) {
+ if (!visible || sending || text == null || text.isEmpty()) {
return;
}
- FormHelper.handlePaste(text, activeTextInput());
- }
-
- private TextInputState activeTextInput() {
if (selectedField == FIELD_BODY) {
- return bodyState;
- }
- if (selectedField == FIELD_HEADERS && hasHeaders()) {
+ bodyState.insert(text);
+ } else if (selectedField == FIELD_HEADERS && hasHeaders()) {
FormHelper.HeaderEntry he = headers.get(selectedHeader);
- return editingHeaderKey ? he.keyInput() : he.valueInput();
+ TextInputState target = editingHeaderKey ? he.keyInput() :
he.valueInput();
+ FormHelper.handlePaste(text, target);
}
- return null;
}
void doSend(MonitorContext ctx, ScheduledExecutorService scheduler) {
@@ -464,7 +529,6 @@ class SendMessagePopup {
if (exchangeId != null) {
hdrLines.add("exchangeId: " + exchangeId);
}
- hdrLines.add("exchangePattern: " + mep);
JsonObject message = response.getMap("message");
if (captureInOut && message != null) {
@@ -502,11 +566,21 @@ class SendMessagePopup {
if (errMsg == null) {
errMsg = status != null ? status : "unknown error";
}
+ try {
+ errMsg = Jsoner.unescape(errMsg);
+ } catch (Exception e) {
+ // ignore
+ }
errLines.add("Exception: " + errMsg);
String stackTrace = exception.getString("stackTrace");
if (stackTrace != null) {
+ try {
+ stackTrace = Jsoner.unescape(stackTrace);
+ } catch (Exception e) {
+ // ignore
+ }
for (String line : stackTrace.split("\n")) {
- errLines.add(line);
+ errLines.add(TuiHelper.fixControlChars(line));
}
}
} else {
@@ -584,12 +658,7 @@ class SendMessagePopup {
}
// Restore body
- bodyState.clear();
- if (entry.body != null) {
- for (int i = 0; i < entry.body.length(); i++) {
- bodyState.insert(entry.body.charAt(i));
- }
- }
+ bodyState.setText(entry.body != null ? entry.body : "");
// Restore headers
headers = null;
@@ -632,17 +701,19 @@ class SendMessagePopup {
private void rebuildResponseLines() {
List<String> lines = new ArrayList<>();
- if (responseHeaderLines != null) {
+ if (responseHeaderLines != null && (responseError ||
showResponseHeaders)) {
lines.addAll(responseHeaderLines);
}
- if (responseRawBody != null && !responseRawBody.isEmpty()) {
- lines.add("");
+ if (showResponseBody && responseRawBody != null &&
!responseRawBody.isEmpty()) {
+ if (!lines.isEmpty()) {
+ lines.add("");
+ }
String body = responseRawBody;
if (prettyPrint) {
body = CamelCommandHelper.valueAsStringPretty(body, false);
}
for (String line : body.split("\n", -1)) {
- lines.add(line);
+ lines.add(TuiHelper.fixControlChars(line));
}
}
responseLines = lines;
@@ -663,8 +734,9 @@ class SendMessagePopup {
area.left() + padX, area.top() + padY,
area.width() - padX * 2, area.height() - padY * 2);
+ int bodyHeight = 6;
int headerCount = hasHeaders() ? headers.size() : 0;
- int requestHeight = 7 + headerCount + (headerCount > 0 ? 1 : 0);
+ int requestHeight = 5 + bodyHeight + headerCount + (headerCount > 0 ?
1 : 0);
if (routes.size() > 1) {
requestHeight += 1;
}
@@ -722,22 +794,29 @@ class SendMessagePopup {
row++;
}
- // Body input
+ // Body input (multi-line text area)
row++;
+ int bodyHeight = 6;
FormHelper.renderLabel(frame, innerX, row, labelW, "Body:",
selectedField == FIELD_BODY);
- Rect bodyArea = new Rect(innerX + labelW, row, fieldW, 1);
+ Rect bodyArea = new Rect(innerX + labelW, row, fieldW, bodyHeight);
+ Style bodyBorderStyle = selectedField == FIELD_BODY ?
Style.EMPTY.fg(Theme.accent()) : Theme.muted();
+ Block bodyBlock = Block.builder()
+ .borders(Borders.ALL)
+ .borderType(BorderType.ROUNDED)
+ .borderStyle(bodyBorderStyle)
+ .build();
+ Rect bodyInner = bodyBlock.inner(bodyArea);
+ frame.renderWidget(bodyBlock, bodyArea);
+ TextArea textArea = TextArea.builder()
+ .cursorStyle(Style.EMPTY.reversed())
+ .placeholder("body text, JSON, or file:payload.json")
+ .build();
if (selectedField == FIELD_BODY && !sending) {
- TextInput textInput = TextInput.builder()
- .cursorStyle(Style.EMPTY.reversed())
- .placeholder("body text or file:payload.json")
- .build();
- frame.renderStatefulWidget(textInput, bodyArea, bodyState);
+ textArea.renderWithCursor(bodyInner, frame.buffer(), bodyState,
frame);
} 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);
+ textArea.render(bodyInner, frame.buffer(), bodyState);
}
+ row += bodyHeight - 1;
// Headers section
if (hasHeaders()) {
@@ -840,7 +919,7 @@ class SendMessagePopup {
if (responseLines == null || responseLines.isEmpty()) {
String placeholder = responseStatus == null && !sending
- ? " Press Enter to send message"
+ ? " Press F5 to send message"
: " No response content";
frame.renderWidget(
Paragraph.builder()
@@ -851,6 +930,8 @@ class SendMessagePopup {
return;
}
+ Overflow overflow = wordWrap ? Overflow.WRAP_WORD : Overflow.CLIP;
+
int visibleLines = area.height() - 2;
if (visibleLines < 1) {
visibleLines = 1;
@@ -878,13 +959,15 @@ class SendMessagePopup {
frame.renderWidget(
Paragraph.builder()
.text(Text.from(lines))
+ .overflow(overflow)
.block(Block.builder().borderType(BorderType.ROUNDED).borders(Borders.ALL).title(title).build())
.build(),
area);
}
private void renderHistory(Frame frame, Rect area) {
- String title = " History [" + history.size() + "] ";
+ String title = " History [" + history.size() + "]"
+ + (selectedField == FIELD_HISTORY ? " — Enter to replay
" : " ");
if (history.isEmpty()) {
frame.renderWidget(
@@ -947,13 +1030,14 @@ class SendMessagePopup {
void renderFooter(List<Span> spans) {
hint(spans, "Esc", "back");
hint(spans, "Tab", "fields");
- hint(spans, "Enter", "send");
+ hint(spans, "F5", "send");
hint(spans, "+", "header");
+ hint(spans, "b", "body" + (showResponseBody ? " [on]" : ""));
+ hint(spans, "h", "headers" + (showResponseHeaders ? " [on]" : ""));
hint(spans, "p", "pretty" + (prettyPrint ? " [on]" : ""));
- if (!history.isEmpty()) {
- hintLast(spans, TuiIcons.HINT_SCROLL, "history");
- } else {
- hintLast(spans, "PgUp/Dn", "scroll");
+ hint(spans, "w", "wrap" + (wordWrap ? " [on]" : ""));
+ if (responseLines != null && !responseLines.isEmpty()) {
+ hintLast(spans, "PgUp/Dn", "response");
}
}
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Theme.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Theme.java
index b03634569bef..2cfa6dc25ee0 100644
---
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Theme.java
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/Theme.java
@@ -313,7 +313,7 @@ public final class Theme {
/** True if the active mode is dark. */
public static boolean isDark() {
engine();
- return mode == ThemeMode.DARK;
+ return !mode.isLight();
}
/** Cycle to the next theme in declaration order, persist it (outside test
mode), and activate it. */
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 a286a3e63b38..82734e04457d 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
@@ -67,6 +67,19 @@ class TuiMcpServer {
record LogEntry(String timestamp, LogLevel level, String message, String
requestBody, String responseBody) {
}
+ static final class AnimationState {
+ final String id;
+ final int totalFrames;
+ final AtomicInteger currentFrame = new AtomicInteger();
+ volatile boolean cancelled;
+ volatile String status = "running";
+
+ AnimationState(String id, int totalFrames) {
+ this.id = id;
+ this.totalFrames = totalFrames;
+ }
+ }
+
private final int port;
private final McpFacade facade;
private HttpServer server;
@@ -74,6 +87,8 @@ class TuiMcpServer {
private volatile long lastActivity;
private volatile long lastToolCallTime;
private final AtomicInteger toolCallCount = new AtomicInteger();
+ private final AtomicInteger animCounter = new AtomicInteger();
+ private volatile AnimationState currentAnimation;
private final List<LogEntry> activityLog = new ArrayList<>();
TuiMcpServer(int port, McpFacade facade) {
@@ -350,12 +365,22 @@ class TuiMcpServer {
+ "fg (string, optional
foreground color: red/green/blue/yellow/cyan/magenta/white/gray/black), "
+ "bg (string, optional
background color, same values), "
+ "bold (boolean, optional)"),
+ "shapes", propDef("array",
+ "Array of shape objects to draw (batch mode).
Each shape has: "
+ + "shape (string, required:
box/highlight/underline/arrow-down/arrow-up/arrow-left/arrow-right/text), "
+ + "x (integer, column), y
(integer, row), "
+ + "width (integer, for
box/highlight/underline), "
+ + "height (integer, for
box/highlight), "
+ + "length (integer, for
arrows), "
+ + "text (string, for text
shape), "
+ + "color (string:
red/green/blue/yellow/cyan/magenta/white/gray/black). "
+ + "Use shapes instead of
cells for high-level drawing in a single call."),
"duration", propDef("integer",
"Auto-dismiss drawing after this many seconds.
"
+ "If omitted, drawing
stays until cleared with tui_draw_clear or replaced by another tui_draw call."),
"append", propDef("boolean",
"If true, add cells to the existing drawing
instead of replacing it. Default false.")),
- List.of("cells")));
+ List.of()));
toolList.add(toolDef(
"tui_draw_clear",
"Clears the drawing overlay and restores the screen to its
normal state. "
@@ -385,6 +410,45 @@ class TuiMcpServer {
"If true, add to existing drawing instead of
replacing it. Default false.")),
List.of("shape", "x", "y")));
+ toolList.add(toolDef(
+ "tui_canvas_open",
+ "Opens a full blank canvas screen for free-form drawing. "
+ + "Use tui_draw / tui_draw_shape to draw on
the canvas. "
+ + "The user can press Esc to dismiss.",
+ Map.of("shapes", propDef("array",
+ "Optional array of shapes to draw immediately on the
canvas. "
+ + "Same format as tui_draw
shapes parameter. "
+ + "Saves a round-trip vs
separate tui_canvas_open + tui_draw calls."))));
+ toolList.add(toolDef(
+ "tui_canvas_close",
+ "Closes the canvas and returns to the normal TUI screen. Also
clears any drawing.",
+ Map.of()));
+ toolList.add(toolDef(
+ "tui_animate",
+ "Run a keyframe animation on the canvas. Auto-opens the
canvas, "
+ + "plays frames sequentially with specified
delays, then optionally auto-closes. "
+ + "User can press Esc to stop early. Returns
immediately; "
+ + "use tui_animate_status to check progress. "
+ + "Use 'name' for built-in animations (instant
start, no token cost): "
+ + String.join(", ", BuiltinAnimations.names())
+ ".",
+ Map.of("frames", propDef("array",
+ "Array of keyframes. Each keyframe is an object with: "
+ + "delay (integer,
milliseconds to wait before drawing this frame), "
+ + "shapes (array of shape
objects, same format as tui_draw shapes). "
+ + "Not required when 'name'
is provided."),
+ "name", propDef("string",
+ "Name of a built-in animation: "
+ + String.join(", ",
BuiltinAnimations.names())
+ + ". When set, 'frames' is
ignored."),
+ "autoClose", propDef("boolean",
+ "If true, close the canvas when animation
finishes (default: false)"))));
+ toolList.add(toolDef(
+ "tui_animate_status",
+ "Check progress of a running animation. Returns animationId,
status "
+ + "(running/completed/cancelled),
currentFrame, and totalFrames.",
+ Map.of("animationId", propDef("string",
+ "Animation ID to check. If omitted, returns status of
the latest animation."))));
+
// --- Structured data tools ---
toolList.add(toolDef(
@@ -637,6 +701,10 @@ class TuiMcpServer {
case "tui_get_spans" -> callGetSpans(args);
case "tui_locate" -> callLocate(args);
case "tui_draw_shape" -> callDrawShape(args);
+ case "tui_canvas_open" -> callCanvasOpen(args);
+ case "tui_canvas_close" -> callCanvasClose();
+ case "tui_animate" -> callAnimate(args);
+ case "tui_animate_status" -> callAnimateStatus(args);
default -> {
isError = true;
yield "Unknown tool: " + toolName;
@@ -1092,49 +1160,59 @@ class TuiMcpServer {
@SuppressWarnings("unchecked")
private String callDraw(Map<String, Object> args) {
- Object cellsArg = args.get("cells");
- if (!(cellsArg instanceof List)) {
- return "Error: cells must be an array";
- }
- List<Object> cellsList = (List<Object>) cellsArg;
- if (cellsList.isEmpty()) {
- return "Error: cells array is empty";
- }
-
List<DrawOverlay.DrawCell> drawCells = new ArrayList<>();
- for (Object item : cellsList) {
- if (!(item instanceof Map)) {
- continue;
- }
- Map<String, Object> cell = (Map<String, Object>) item;
+ int cellCount = 0;
+ int shapeCount = 0;
+
+ // Process raw cells
+ if (args.get("cells") instanceof List<?> cellsList) {
+ for (Object item : cellsList) {
+ if (!(item instanceof Map)) {
+ continue;
+ }
+ Map<String, Object> cell = (Map<String, Object>) item;
- int x = cell.get("x") instanceof Number n ? n.intValue() : -1;
- int y = cell.get("y") instanceof Number n ? n.intValue() : -1;
- String ch = cell.get("char") instanceof String s ? s : " ";
- if (x < 0 || y < 0) {
- continue;
- }
+ int x = cell.get("x") instanceof Number n ? n.intValue() : -1;
+ int y = cell.get("y") instanceof Number n ? n.intValue() : -1;
+ String ch = cell.get("char") instanceof String s ? s : " ";
+ if (x < 0 || y < 0) {
+ continue;
+ }
- dev.tamboui.style.Style style = dev.tamboui.style.Style.EMPTY;
- dev.tamboui.style.Color fg = DrawOverlay.parseColor(
- cell.get("fg") instanceof String s ? s : null);
- dev.tamboui.style.Color bg = DrawOverlay.parseColor(
- cell.get("bg") instanceof String s ? s : null);
- if (fg != null) {
- style = style.fg(fg);
- }
- if (bg != null) {
- style = style.bg(bg);
- }
- if (Boolean.TRUE.equals(cell.get("bold"))) {
- style = style.bold();
+ dev.tamboui.style.Style style = dev.tamboui.style.Style.EMPTY;
+ dev.tamboui.style.Color fg = DrawOverlay.parseColor(
+ cell.get("fg") instanceof String s ? s : null);
+ dev.tamboui.style.Color bg = DrawOverlay.parseColor(
+ cell.get("bg") instanceof String s ? s : null);
+ if (fg != null) {
+ style = style.fg(fg);
+ }
+ if (bg != null) {
+ style = style.bg(bg);
+ }
+ if (Boolean.TRUE.equals(cell.get("bold"))) {
+ style = style.bold();
+ }
+
+ drawCells.add(new DrawOverlay.DrawCell(x, y, ch, style));
+ cellCount++;
}
+ }
- drawCells.add(new DrawOverlay.DrawCell(x, y, ch, style));
+ // Process shapes
+ if (args.get("shapes") instanceof List<?> shapesList) {
+ for (Object item : shapesList) {
+ if (!(item instanceof Map)) {
+ continue;
+ }
+ Map<String, Object> s = (Map<String, Object>) item;
+ drawCells.addAll(parseShape(s));
+ shapeCount++;
+ }
}
if (drawCells.isEmpty()) {
- return "Error: no valid cells in array";
+ return "Error: no valid cells or shapes provided";
}
boolean append = Boolean.TRUE.equals(args.get("append"));
@@ -1149,9 +1227,50 @@ class TuiMcpServer {
facade.setDrawing(drawCells, duration);
}
- return "Drawing " + drawCells.size() + " cell(s)"
- + (append ? " (appended)" : "")
- + (duration > 0 ? ", auto-dismiss in " + duration + "s" : "");
+ StringBuilder sb = new StringBuilder("Drawing ");
+ if (cellCount > 0) {
+ sb.append(cellCount).append(" cell(s)");
+ }
+ if (shapeCount > 0) {
+ if (cellCount > 0) {
+ sb.append(" + ");
+ }
+ sb.append(shapeCount).append(" shape(s)");
+ }
+ if (append) {
+ sb.append(" (appended)");
+ }
+ if (duration > 0) {
+ sb.append(", auto-dismiss in ").append(duration).append("s");
+ }
+ return sb.toString();
+ }
+
+ private List<DrawOverlay.DrawCell> parseShape(Map<String, Object> s) {
+ String shape = s.get("shape") instanceof String v ? v : null;
+ if (shape == null) {
+ return List.of();
+ }
+ int x = s.get("x") instanceof Number n ? n.intValue() : 0;
+ int y = s.get("y") instanceof Number n ? n.intValue() : 0;
+ int width = s.get("width") instanceof Number n ? n.intValue() : 0;
+ int height = s.get("height") instanceof Number n ? n.intValue() : 1;
+ int length = s.get("length") instanceof Number n ? n.intValue() : 5;
+ String text = s.get("text") instanceof String v ? v : null;
+ String colorName = s.get("color") instanceof String v ? v : null;
+
+ Color color = DrawOverlay.parseColor(colorName);
+ if (color == null) {
+ color = "highlight".equals(shape) ? Color.YELLOW : Color.RED;
+ }
+ if (height < 1) {
+ height = 1;
+ }
+
+ if ("text".equals(shape)) {
+ return DrawOverlay.generateText(x, y, text != null ? text : "",
color);
+ }
+ return DrawOverlay.generateShape(shape, x, y, width, height, length,
color);
}
private String callDrawClear() {
@@ -1159,6 +1278,166 @@ class TuiMcpServer {
return "Drawing cleared";
}
+ private String callCanvasOpen(Map<String, Object> args) {
+ facade.openCanvas();
+
+ // Draw shapes if provided
+ int shapeCount = 0;
+ if (args.get("shapes") instanceof List<?> shapesList) {
+ List<DrawOverlay.DrawCell> drawCells = new ArrayList<>();
+ for (Object item : shapesList) {
+ if (item instanceof Map) {
+ drawCells.addAll(parseShape((Map<String, Object>) item));
+ shapeCount++;
+ }
+ }
+ if (!drawCells.isEmpty()) {
+ facade.setDrawing(drawCells, 0);
+ }
+ }
+
+ Buffer buf = facade.getLastBuffer();
+ int w = buf != null ? buf.area().width() : 0;
+ int h = buf != null ? buf.area().height() - 1 : 0;
+
+ JsonObject result = new JsonObject();
+ result.put("status", "Canvas opened");
+ result.put("width", w);
+ result.put("height", h);
+ result.put("theme", Theme.isDark() ? "dark" : "light");
+ if (shapeCount > 0) {
+ result.put("shapesDrawn", shapeCount);
+ }
+ return Jsoner.serialize(result);
+ }
+
+ private String callCanvasClose() {
+ facade.closeCanvas();
+ AnimationState anim = currentAnimation;
+ if (anim != null && "running".equals(anim.status)) {
+ anim.cancelled = true;
+ anim.status = "cancelled";
+ }
+ return "Canvas closed";
+ }
+
+ @SuppressWarnings("unchecked")
+ private String callAnimate(Map<String, Object> args) {
+ // Cancel any running animation
+ AnimationState prev = currentAnimation;
+ if (prev != null && "running".equals(prev.status)) {
+ prev.cancelled = true;
+ prev.status = "cancelled";
+ }
+
+ boolean requestedAutoClose = args.get("autoClose") instanceof Boolean
b && b;
+
+ // Pre-parse all frames
+ record ParsedFrame(long delayMs, List<DrawOverlay.DrawCell> cells) {
+ }
+ List<ParsedFrame> frames = new ArrayList<>();
+
+ // Check for built-in animation by name
+ String animName = args.get("name") instanceof String s ? s : null;
+ if (animName != null) {
+ List<BuiltinAnimations.Frame> builtin =
BuiltinAnimations.get(animName);
+ if (builtin == null) {
+ return "Error: unknown animation '" + animName + "'.
Available: "
+ + String.join(", ", BuiltinAnimations.names());
+ }
+ for (BuiltinAnimations.Frame bf : builtin) {
+ frames.add(new ParsedFrame(bf.delayMs(), bf.cells()));
+ }
+ requestedAutoClose = true;
+ } else {
+ List<?> framesList = args.get("frames") instanceof List<?> l ? l :
List.of();
+ if (framesList.isEmpty()) {
+ return "Error: frames array or name is required";
+ }
+ for (Object item : framesList) {
+ if (item instanceof Map<?, ?> m) {
+ long delay = m.get("delay") instanceof Number n ?
n.longValue() : 0;
+ delay = Math.max(0, Math.min(30_000, delay));
+ List<DrawOverlay.DrawCell> cells = new ArrayList<>();
+ if (m.get("shapes") instanceof List<?> shapesList) {
+ for (Object s : shapesList) {
+ if (s instanceof Map) {
+ cells.addAll(parseShape((Map<String, Object>)
s));
+ }
+ }
+ }
+ frames.add(new ParsedFrame(delay, cells));
+ }
+ }
+ }
+
+ String id = "anim-" + animCounter.incrementAndGet();
+ AnimationState anim = new AnimationState(id, frames.size());
+ currentAnimation = anim;
+
+ boolean autoClose = requestedAutoClose;
+
+ // Open canvas
+ facade.openCanvas();
+
+ // Launch animation on a daemon thread
+ Thread animThread = new Thread(() -> {
+ try {
+ for (int i = 0; i < frames.size(); i++) {
+ if (anim.cancelled || !facade.isCanvasVisible()) {
+ anim.cancelled = true;
+ anim.status = "cancelled";
+ return;
+ }
+ ParsedFrame f = frames.get(i);
+ if (f.delayMs > 0) {
+ Thread.sleep(f.delayMs);
+ }
+ if (anim.cancelled || !facade.isCanvasVisible()) {
+ anim.cancelled = true;
+ anim.status = "cancelled";
+ return;
+ }
+ facade.setDrawing(f.cells, 0);
+ anim.currentFrame.set(i + 1);
+ }
+ anim.status = "completed";
+ if (autoClose) {
+ facade.closeCanvas();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ anim.status = "cancelled";
+ }
+ }, "tui-animate-" + id);
+ animThread.setDaemon(true);
+ animThread.start();
+
+ JsonObject result = new JsonObject();
+ result.put("animationId", id);
+ result.put("totalFrames", frames.size());
+ result.put("status", "running");
+ return Jsoner.serialize(result);
+ }
+
+ private String callAnimateStatus(Map<String, Object> args) {
+ AnimationState anim = currentAnimation;
+ if (anim == null) {
+ return "No animation has been started";
+ }
+ String requestedId = args.get("animationId") instanceof String s ? s :
null;
+ if (requestedId != null && !requestedId.equals(anim.id)) {
+ return "Animation not found: " + requestedId;
+ }
+
+ JsonObject result = new JsonObject();
+ result.put("animationId", anim.id);
+ result.put("status", anim.status);
+ result.put("currentFrame", anim.currentFrame.get());
+ result.put("totalFrames", anim.totalFrames);
+ return Jsoner.serialize(result);
+ }
+
private String callGetTable(Map<String, Object> args) {
String tab = args.get("tab") instanceof String s ? s : null;
JsonObject data = facade.getTableData(tab);
@@ -1454,36 +1733,16 @@ class TuiMcpServer {
if (shape == null) {
return "Error: 'shape' is required";
}
- int x = args.get("x") instanceof Number n ? n.intValue() : 0;
- int y = args.get("y") instanceof Number n ? n.intValue() : 0;
- int width = args.get("width") instanceof Number n ? n.intValue() : 0;
- int height = args.get("height") instanceof Number n ? n.intValue() :
Math.max(1, 0);
- int length = args.get("length") instanceof Number n ? n.intValue() : 5;
- String text = args.get("text") instanceof String s ? s : null;
- String colorName = args.get("color") instanceof String s ? s : null;
- int duration = args.get("duration") instanceof Number n ? n.intValue()
: 0;
-
- Color color = DrawOverlay.parseColor(colorName);
- if (color == null) {
- color = "highlight".equals(shape) ? Color.YELLOW : Color.RED;
- }
-
- if (height < 1) {
- height = 1;
- }
-
- List<DrawOverlay.DrawCell> cells;
- if ("text".equals(shape)) {
- cells = DrawOverlay.generateText(x, y, text != null ? text : "",
color);
- } else {
- cells = DrawOverlay.generateShape(shape, x, y, width, height,
length, color);
- }
+ List<DrawOverlay.DrawCell> cells = parseShape(args);
if (cells.isEmpty() && !"text".equals(shape)) {
return "Unknown shape: " + shape
+ ". Use: box, highlight, underline, arrow-down, arrow-up,
arrow-left, arrow-right, text";
}
+ int x = args.get("x") instanceof Number n ? n.intValue() : 0;
+ int y = args.get("y") instanceof Number n ? n.intValue() : 0;
+ int duration = args.get("duration") instanceof Number n ? n.intValue()
: 0;
boolean append = args.get("append") instanceof Boolean b && b;
if (append) {
facade.appendDrawing(cells);