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 fa6740c1bddd CAMEL-23635: camel-jbang - Add shell panel to TUI (#23605)
fa6740c1bddd is described below
commit fa6740c1bddd19f7b4d447d7243030d259999470
Author: Guillaume Nodet <[email protected]>
AuthorDate: Wed Jun 10 12:50:26 2026 +0200
CAMEL-23635: camel-jbang - Add shell panel to TUI (#23605)
Embed a JLine interactive shell inside the TUI using a virtual terminal.
ShellPanel wiring (same pattern as JLine's WebTerminal):
- LineDisciplineTerminal provides the master/slave virtual terminal
- ScreenTerminal acts as VT100 emulator with readable screen buffer
- ScreenTerminalOutputStream bridges terminal output to screen buffer
- Shell runs in background thread via ShellBuilder + PicocliCommandRegistry
- Screen buffer dumped each frame, converted to TamboUI Span/Line widgets
- Key events encoded as ANSI escape sequences and forwarded to terminal
TUI integration:
- F6 shortcut to toggle shell panel open/close
- Shift+F6 cycles shell height (25%/50%/75%)
- F2 Actions menu: Shell entry at the bottom
- Split-screen layout with monitoring tabs
- Separator line with "Shell" title between panels
- Footer hints updated to show F6
Process selection:
- TUI's selected integration propagated via EnvironmentHelper
- camel ask auto-targets the selected process (no --name needed)
- list_processes / select_process tools for runtime switching
Additional fixes:
- EndpointsTab: null guard for history LinkedList entries (NPE fix)
- ActionsPopup: explicit action list in resolveAction (fixes F2 mapping)
- Printer output redirected through virtual terminal writer
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../apache/camel/dsl/jbang/core/commands/Ask.java | 72 ++-
.../dsl/jbang/core/common/EnvironmentHelper.java | 16 +
.../dsl/jbang/core/commands/tui/ActionsPopup.java | 67 ++-
.../dsl/jbang/core/commands/tui/CamelMonitor.java | 46 +-
.../dsl/jbang/core/commands/tui/EndpointsTab.java | 16 +-
.../dsl/jbang/core/commands/tui/ShellPanel.java | 497 +++++++++++++++++++++
6 files changed, 685 insertions(+), 29 deletions(-)
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
index b850f22c155b..9a7e5af16761 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/Ask.java
@@ -250,13 +250,18 @@ public class Ask extends CamelCommand {
// ---- Process discovery (delegates to RuntimeHelper) ----
private RuntimeHelper.ProcessInfo findProcess(String nameOrPid) {
+ // Fall back to TUI-selected process if no explicit name given
+ if ((nameOrPid == null || nameOrPid.isBlank()) &&
EnvironmentHelper.getSelectedProcess() != null) {
+ nameOrPid = EnvironmentHelper.getSelectedProcess();
+ }
+
RuntimeHelper.ProcessInfo found = RuntimeHelper.findProcess(nameOrPid);
if (found != null) {
return found;
}
+ List<RuntimeHelper.ProcessInfo> processes =
RuntimeHelper.discoverProcesses();
if (nameOrPid != null && !nameOrPid.isBlank()) {
- List<RuntimeHelper.ProcessInfo> processes =
RuntimeHelper.discoverProcesses();
if (processes.isEmpty()) {
printer().printErr("No running Camel processes found.");
printer().printErr("Start a Camel application first: camel run
myRoute.yaml");
@@ -267,6 +272,10 @@ public class Ask extends CamelCommand {
} else {
printer().printErr("No Camel process found matching: " +
nameOrPid);
}
+ } else if (processes.size() > 1) {
+ printer().println("Multiple Camel processes found. Use --name to
specify one:");
+ processes.forEach(p -> printer().println(" " + p.name() + " (PID
" + p.pid() + ")"));
+ printer().println();
}
return null;
}
@@ -282,6 +291,13 @@ public class Ask extends CamelCommand {
sb.append("You are connected to a running Camel application: ");
sb.append(process.name()).append(" (PID
").append(process.pid()).append("). ");
sb.append("Use the runtime inspection tools to gather information
about it.\n\n");
+ } else {
+ List<RuntimeHelper.ProcessInfo> available =
RuntimeHelper.discoverProcesses();
+ if (!available.isEmpty()) {
+ sb.append("No Camel process is currently selected. ");
+ sb.append("Use list_processes to see available processes, then
select_process to connect to one. ");
+ sb.append("Runtime inspection tools will not work until a
process is selected.\n\n");
+ }
}
sb.append("You can search the Camel catalog (components, EIPs), browse
examples, ");
@@ -309,6 +325,17 @@ public class Ask extends CamelCommand {
private List<LlmClient.ToolDef> buildToolDefinitions() {
List<LlmClient.ToolDef> tools = new ArrayList<>();
+ // Process discovery and selection
+ tools.add(new LlmClient.ToolDef(
+ "list_processes",
+ "List all running Camel processes with their PID and name. Use
this to discover available processes before selecting one.",
+ emptyParams()));
+ tools.add(new LlmClient.ToolDef(
+ "select_process",
+ "Select a running Camel process by name or PID to inspect.
Required when multiple processes are running. After selection, all runtime
tools (get_routes, get_context, etc.) will target this process.",
+ objectParams(Map.of(
+ "name", stringProp("Name or PID of the Camel process
to connect to")))));
+
// Status-file tools (no parameters needed)
tools.add(new LlmClient.ToolDef(
"get_context",
@@ -475,6 +502,8 @@ public class Ask extends CamelCommand {
try {
return switch (name) {
// Runtime tools (require a running process)
+ case "list_processes" -> executeListProcesses();
+ case "select_process" -> executeSelectProcess(args);
case "get_context" ->
targetPid < 0 ? NO_PROCESS :
RuntimeHelper.readStatusSection(targetPid, "context");
case "get_routes" ->
@@ -524,6 +553,47 @@ public class Ask extends CamelCommand {
}
}
+ private String executeListProcesses() {
+ List<RuntimeHelper.ProcessInfo> processes =
RuntimeHelper.discoverProcesses();
+ if (processes.isEmpty()) {
+ return "No running Camel processes found. Start one with: camel
run <file>";
+ }
+ JsonObject response = new JsonObject();
+ response.put("count", processes.size());
+ List<JsonObject> list = new ArrayList<>();
+ for (RuntimeHelper.ProcessInfo p : processes) {
+ JsonObject entry = new JsonObject();
+ entry.put("pid", p.pid());
+ entry.put("name", p.name());
+ entry.put("selected", p.pid() == targetPid);
+ list.add(entry);
+ }
+ response.put("processes", list);
+ if (targetPid < 0) {
+ response.put("hint", "No process selected. Use select_process to
connect to one.");
+ }
+ return response.toJson();
+ }
+
+ private String executeSelectProcess(JsonObject args) {
+ String name = args.getString("name");
+ if (name == null || name.isBlank()) {
+ return "Error: name or PID is required";
+ }
+ RuntimeHelper.ProcessInfo found = RuntimeHelper.findProcess(name);
+ if (found == null) {
+ List<RuntimeHelper.ProcessInfo> processes =
RuntimeHelper.discoverProcesses();
+ if (processes.isEmpty()) {
+ return "No running Camel processes found.";
+ }
+ StringBuilder sb = new StringBuilder("No process found matching: "
+ name + ". Available:\n");
+ processes.forEach(p -> sb.append(" ").append(p.name()).append("
(PID ").append(p.pid()).append(")\n"));
+ return sb.toString();
+ }
+ targetPid = found.pid();
+ return "Connected to " + found.name() + " (PID " + found.pid() + ").
Runtime tools are now active.";
+ }
+
private String executeRouteSource(JsonObject args) {
String filter = args.getString("filter");
return RuntimeHelper.executeAction(targetPid, "source",
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/EnvironmentHelper.java
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/EnvironmentHelper.java
index 034c59ba1914..3e402369cddb 100644
---
a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/EnvironmentHelper.java
+++
b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/EnvironmentHelper.java
@@ -40,6 +40,7 @@ import org.jline.terminal.Terminal;
public final class EnvironmentHelper {
private static volatile Terminal activeTerminal;
+ private static volatile String selectedProcess;
private EnvironmentHelper() {
}
@@ -58,6 +59,21 @@ public final class EnvironmentHelper {
return activeTerminal;
}
+ /**
+ * Sets the selected Camel process name/PID. Called by the TUI to make the
selected integration available to
+ * subcommands like ask.
+ */
+ public static void setSelectedProcess(String name) {
+ selectedProcess = name;
+ }
+
+ /**
+ * Returns the selected Camel process name, or null if none is selected.
+ */
+ public static String getSelectedProcess() {
+ return selectedProcess;
+ }
+
/**
* Reads a single line from the best available input source: the active
JLine terminal if inside the shell,
* otherwise {@link System#console()}.
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 11891d63a588..c1b4f52444ab 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
@@ -85,11 +85,13 @@ class ActionsPopup {
SHOW_KEYSTROKES,
SETUP_AI,
MCP_INFO,
- MCP_LOG
+ MCP_LOG,
+ SHELL
}
private static final int[] GROUP_SIZES = { 5, 4, 5 };
private static final int MCP_GROUP_SIZE = 3;
+ private static final int SHELL_GROUP_SIZE = 1;
private final Supplier<Set<String>> runningNames;
private final Supplier<List<IntegrationInfo>> integrations;
@@ -101,6 +103,7 @@ class ActionsPopup {
private final Runnable burstCallback;
private Runnable resetStatsAction;
private Runnable resetScreenAction;
+ private Runnable openShellAction;
private Runnable browseFilesAction;
private final Supplier<Boolean> tapeRecordingActive;
private MonitorContext ctx;
@@ -197,6 +200,10 @@ class ActionsPopup {
this.resetScreenAction = resetScreenAction;
}
+ void setOpenShellAction(Runnable openShellAction) {
+ this.openShellAction = openShellAction;
+ }
+
void setBrowseFilesAction(Runnable browseFilesAction) {
this.browseFilesAction = browseFilesAction;
}
@@ -220,6 +227,8 @@ class ActionsPopup {
total += MCP_GROUP_SIZE;
dividers++;
}
+ total += SHELL_GROUP_SIZE;
+ dividers++;
return total + dividers;
}
@@ -234,26 +243,40 @@ class ActionsPopup {
}
if (mcpEnabled) {
pos += MCP_GROUP_SIZE;
+ if (visualIndex == pos) {
+ return true;
+ }
+ pos++;
}
+ pos += SHELL_GROUP_SIZE;
return false;
}
private Action resolveAction(int visualIndex) {
- int dividers = 0;
- int pos = 0;
- int groupCount = mcpEnabled ? GROUP_SIZES.length + 1 :
GROUP_SIZES.length;
- for (int i = 0; i < groupCount; i++) {
- int gs = i < GROUP_SIZES.length ? GROUP_SIZES[i] : MCP_GROUP_SIZE;
- pos += gs;
- if (visualIndex < pos + dividers) {
- break;
- }
- if (i < groupCount - 1) {
- dividers++;
- pos++;
- }
+ List<Action> flat = buildVisualActionList();
+ if (visualIndex >= 0 && visualIndex < flat.size()) {
+ return flat.get(visualIndex);
}
- return Action.values()[visualIndex - dividers];
+ return null;
+ }
+
+ private List<Action> buildVisualActionList() {
+ List<Action> flat = new ArrayList<>();
+ flat.addAll(List.of(
+ Action.SEND_MESSAGE, Action.RUN_EXAMPLE, Action.RUN_FOLDER,
Action.RUN_INFRA, Action.BROWSE_FILES));
+ flat.add(null);
+ flat.addAll(List.of(Action.DOCTOR, Action.RESET_STATS,
Action.RESET_SCREEN, Action.STOP_ALL));
+ flat.add(null);
+ flat.addAll(List.of(
+ Action.SCREENSHOT, Action.TAPE_RECORDING,
Action.TAPE_INSTRUCTIONS, Action.CAPTION,
+ Action.SHOW_KEYSTROKES));
+ if (mcpEnabled) {
+ flat.add(null);
+ flat.addAll(List.of(Action.SETUP_AI, Action.MCP_INFO,
Action.MCP_LOG));
+ }
+ flat.add(null);
+ flat.add(Action.SHELL);
+ return flat;
}
private void navigateActionsMenu(int direction) {
@@ -343,6 +366,8 @@ class ActionsPopup {
labels.add("MCP Info");
labels.add("MCP Log");
}
+ labels.add("───");
+ labels.add("Shell");
return labels;
}
@@ -538,7 +563,14 @@ class ActionsPopup {
Integer sel = actionsMenuState.selected();
if (sel != null) {
Action action = resolveAction(sel);
- if (action == Action.RUN_EXAMPLE) {
+ if (action == null) {
+ // divider selected, ignore
+ } else if (action == Action.SHELL) {
+ showActionsMenu = false;
+ if (openShellAction != null) {
+ openShellAction.run();
+ }
+ } else if (action == Action.RUN_EXAMPLE) {
openExampleBrowser();
} else if (action == Action.RUN_FOLDER) {
openFolderInput();
@@ -776,6 +808,9 @@ class ActionsPopup {
items.add(ListItem.from(" 🤖 MCP Info"));
items.add(ListItem.from(" 📋 MCP Log"));
}
+ // Group 5: Shell
+ items.add(ListItem.from(divider).style(Style.EMPTY.dim()));
+ items.add(ListItem.from(" >_ Shell"));
ListWidget list = ListWidget.builder()
.items(items.toArray(ListItem[]::new))
.highlightStyle(Style.EMPTY.fg(Color.WHITE).bold().onBlue())
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 c6b2aba4ef4a..663421572b55 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
@@ -176,6 +176,7 @@ public class CamelMonitor extends CamelCommand {
private final CaptionOverlay captionOverlay = new CaptionOverlay();
private final DrawOverlay drawOverlay = new DrawOverlay();
private final HelpOverlay helpOverlay = new HelpOverlay();
+ private final ShellPanel shellPanel = new ShellPanel();
private final ActionsPopup actionsPopup = new ActionsPopup(
() -> data.get().stream()
@@ -269,6 +270,8 @@ public class CamelMonitor extends CamelCommand {
ctx = new MonitorContext(data, infraData);
actionsPopup.setContext(ctx);
actionsPopup.setResetStatsAction(this::resetStats);
+ shellPanel.setContext(ctx);
+ actionsPopup.setOpenShellAction(shellPanel::open);
actionsPopup.setBrowseFilesAction(this::openFilesPopup);
logTab = new LogTab(ctx);
diagramTab = new DiagramTab(ctx);
@@ -332,6 +335,7 @@ public class CamelMonitor extends CamelCommand {
this::handleEvent,
this::render);
} finally {
+ shellPanel.destroy();
if (mcpServer != null) {
mcpServer.stop();
}
@@ -383,6 +387,14 @@ public class CamelMonitor extends CamelCommand {
if (helpOverlay.isVisible()) {
return helpOverlay.handleKeyEvent(ke);
}
+ if (shellPanel.isOpen()) {
+ // Shift+F6 cycles shell height — handle before delegating to
shell
+ if (ke.isKey(KeyCode.F6) && ke.hasShift()) {
+ shellPanel.cycleHeight();
+ return true;
+ }
+ return shellPanel.handleKeyEvent(ke);
+ }
if (actionsPopup.isVisible()) {
return actionsPopup.handleKeyEvent(ke);
}
@@ -607,6 +619,14 @@ public class CamelMonitor extends CamelCommand {
}
return true;
}
+ if (ke.isKey(KeyCode.F6)) {
+ if (shellPanel.isOpen()) {
+ shellPanel.close();
+ } else {
+ shellPanel.open();
+ }
+ return true;
+ }
if (ke.isKey(KeyCode.F2)) {
if (tabsState.selected() == TAB_ROUTES && routesTab != null) {
actionsPopup.setPreSelectedRouteId(routesTab.selectedRouteId());
@@ -948,19 +968,30 @@ public class CamelMonitor extends CamelCommand {
// mainChunks.get(1) is the empty spacer row
renderTabs(frame, mainChunks.get(2));
// mainChunks.get(3) is the empty spacer row between tabs and content
- renderContent(frame, mainChunks.get(4));
+ Rect contentArea = mainChunks.get(4);
+ if (shellPanel.isOpen()) {
+ List<Rect> splitChunks = Layout.vertical()
+ .constraints(Constraint.percentage(100 -
shellPanel.panelPercent()),
+ Constraint.percentage(shellPanel.panelPercent()))
+ .split(contentArea);
+ renderContent(frame, splitChunks.get(0));
+ shellPanel.render(frame, splitChunks.get(1));
+ } else {
+ renderContent(frame, contentArea);
+ }
+ // Overlays render on top of the full content area regardless of shell
state
if (drawOverlay.isVisible()) {
- drawOverlay.render(frame, mainChunks.get(4));
+ drawOverlay.render(frame, contentArea);
}
if (showKillConfirm) {
- renderKillConfirm(frame, mainChunks.get(4));
+ renderKillConfirm(frame, contentArea);
}
- actionsPopup.render(frame, mainChunks.get(4));
+ actionsPopup.render(frame, contentArea);
if (captionOverlay.isCaptionVisible()) {
- captionOverlay.render(frame, mainChunks.get(4));
+ captionOverlay.render(frame, contentArea);
}
if (helpOverlay.isVisible()) {
- helpOverlay.render(frame, mainChunks.get(4));
+ helpOverlay.render(frame, contentArea);
}
renderFooter(frame, mainChunks.get(5));
@@ -1627,6 +1658,8 @@ public class CamelMonitor extends CamelCommand {
hint(spans, "Up/Down", "select");
hint(spans, "Enter", "open");
hint(spans, "Esc", "close");
+ } else if (shellPanel.isOpen()) {
+ shellPanel.renderFooter(spans);
} else {
MonitorTab tab = activeTab();
@@ -1702,6 +1735,7 @@ public class CamelMonitor extends CamelCommand {
if (getNonVanishingIntegrations().size() > 1) {
hint(fKeySpans, "F3", "switch");
}
+ hint(fKeySpans, "F6", "shell");
spans.addAll(insertPos, fKeySpans);
}
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 d21e43723292..3d97c4f23bab 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
@@ -430,11 +430,11 @@ class EndpointsTab implements MonitorTab {
for (int i = 0; i < renderPoints; i++) {
int idx = inHist.size() - renderPoints + i;
if (idx >= 0) {
- inArr[i] = inHist.get(idx);
+ inArr[i] = unbox(inHist.get(idx));
}
idx = outHist.size() - renderPoints + i;
if (idx >= 0) {
- outArr[i] = outHist.get(idx);
+ outArr[i] = unbox(outHist.get(idx));
}
}
long curIn = inArr[renderPoints - 1];
@@ -532,11 +532,11 @@ class EndpointsTab implements MonitorTab {
for (int i = 0; i < renderPoints; i++) {
int idx = inHist.size() - renderPoints + i;
if (idx >= 0) {
- inArr[i] = inHist.get(idx);
+ inArr[i] = unbox(inHist.get(idx));
}
idx = outHist.size() - renderPoints + i;
if (idx >= 0) {
- outArr[i] = outHist.get(idx);
+ outArr[i] = unbox(outHist.get(idx));
}
}
long curIn = inArr[renderPoints - 1];
@@ -578,11 +578,11 @@ class EndpointsTab implements MonitorTab {
for (int i = 0; i < renderPoints; i++) {
int idx = inHist.size() - renderPoints + i;
if (idx >= 0) {
- inArr[i] = inHist.get(idx);
+ inArr[i] = unbox(inHist.get(idx));
}
idx = outHist.size() - renderPoints + i;
if (idx >= 0) {
- outArr[i] = outHist.get(idx);
+ outArr[i] = unbox(outHist.get(idx));
}
}
long curIn = inArr[renderPoints - 1];
@@ -607,6 +607,10 @@ class EndpointsTab implements MonitorTab {
.build(), area);
}
+ private static long unbox(Long value) {
+ return value != null ? value : 0L;
+ }
+
private static String sizeToYLabel(long size) {
if (size <= 0) {
return "0 B";
diff --git
a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
new file mode 100644
index 000000000000..2af50c12cf26
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/ShellPanel.java
@@ -0,0 +1,497 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.tui;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+import dev.tamboui.layout.Constraint;
+import dev.tamboui.layout.Layout;
+import dev.tamboui.layout.Rect;
+import dev.tamboui.style.Color;
+import dev.tamboui.style.Overflow;
+import dev.tamboui.style.Style;
+import dev.tamboui.terminal.Frame;
+import dev.tamboui.text.Line;
+import dev.tamboui.text.Span;
+import dev.tamboui.text.Text;
+import dev.tamboui.tui.event.KeyCode;
+import dev.tamboui.tui.event.KeyEvent;
+import dev.tamboui.widgets.paragraph.Paragraph;
+import org.apache.camel.dsl.jbang.core.commands.CamelJBangMain;
+import org.apache.camel.dsl.jbang.core.common.EnvironmentHelper;
+import org.apache.camel.dsl.jbang.core.common.Printer;
+import org.apache.camel.dsl.jbang.core.common.VersionHelper;
+import org.jline.builtins.InteractiveCommandGroup;
+import org.jline.builtins.PosixCommandGroup;
+import org.jline.builtins.ScreenTerminal;
+import org.jline.builtins.ScreenTerminalOutputStream;
+import org.jline.picocli.PicocliCommandRegistry;
+import org.jline.reader.LineReader;
+import org.jline.shell.Shell;
+import org.jline.shell.impl.DefaultCommandDispatcher;
+import org.jline.terminal.Size;
+import org.jline.terminal.impl.LineDisciplineTerminal;
+import org.jline.utils.AttributedStringBuilder;
+import org.jline.utils.AttributedStyle;
+
+/**
+ * Embeds a JLine interactive shell inside the TUI using a virtual terminal.
+ * <p>
+ * The wiring follows the same pattern as JLine's own {@code WebTerminal}:
+ * <ul>
+ * <li>{@link LineDisciplineTerminal} provides the master/slave virtual
terminal</li>
+ * <li>{@link ScreenTerminal} acts as a VT100 emulator with a readable screen
buffer</li>
+ * <li>{@link ScreenTerminalOutputStream} bridges terminal output to the
screen buffer</li>
+ * </ul>
+ * The shell runs in a background thread. On each TUI render frame, the screen
buffer is dumped and converted to TamboUI
+ * widgets. Key events from TamboUI are encoded as ANSI escape sequences and
forwarded to the virtual terminal.
+ */
+class ShellPanel {
+
+ private static final int[] SPLIT_PERCENTS = { 25, 50, 75 };
+
+ private boolean visible;
+ private int splitIndex = 1; // default 50%
+ private MonitorContext ctx;
+
+ private ScreenTerminal screenTerminal;
+ private LineDisciplineTerminal virtualTerminal;
+ private Thread shellThread;
+
+ private int lastWidth;
+ private int lastHeight;
+
+ void setContext(MonitorContext ctx) {
+ this.ctx = ctx;
+ }
+
+ boolean isOpen() {
+ return visible;
+ }
+
+ int panelPercent() {
+ return SPLIT_PERCENTS[splitIndex];
+ }
+
+ void cycleHeight() {
+ splitIndex = (splitIndex + 1) % SPLIT_PERCENTS.length;
+ }
+
+ void open() {
+ visible = true;
+ if (startError != null) {
+ startError = null;
+ screenTerminal = null;
+ virtualTerminal = null;
+ }
+ }
+
+ void close() {
+ visible = false;
+ }
+
+ void destroy() {
+ visible = false;
+ stopShell();
+ }
+
+ boolean handleKeyEvent(KeyEvent ke) {
+ if (!visible) {
+ return false;
+ }
+
+ // F6 hides the shell panel
+ if (ke.isKey(KeyCode.F6)) {
+ close();
+ return true;
+ }
+
+ // Forward everything else to the virtual terminal
+ if (virtualTerminal != null) {
+ try {
+ byte[] bytes = encodeKeyEvent(ke);
+ if (bytes != null && bytes.length > 0) {
+ virtualTerminal.processInputBytes(bytes);
+ }
+ } catch (IOException e) {
+ // terminal closed
+ }
+ }
+ return true;
+ }
+
+ void render(Frame frame, Rect area) {
+ if (!visible) {
+ return;
+ }
+
+ // Reserve 1 row for separator line at top
+ int innerWidth = area.width();
+ int innerHeight = area.height() - 1;
+
+ // Start shell on first render (we now know the size)
+ if (screenTerminal == null && innerWidth > 2 && innerHeight > 2) {
+ startShell(innerWidth, innerHeight);
+ }
+
+ // Handle resize
+ if (screenTerminal != null && (innerWidth != lastWidth || innerHeight
!= lastHeight)) {
+ screenTerminal.setSize(innerWidth, innerHeight);
+ if (virtualTerminal != null) {
+ virtualTerminal.setSize(new Size(innerWidth, innerHeight));
+ }
+ lastWidth = innerWidth;
+ lastHeight = innerHeight;
+ }
+
+ // Split: separator line + content
+ List<Rect> chunks = Layout.vertical()
+ .constraints(Constraint.length(1), Constraint.fill())
+ .split(area);
+
+ // Render separator line with title
+ String sep = "─".repeat(Math.max(0, innerWidth - 8));
+ frame.renderWidget(
+ Paragraph.from(Line.from(
+ Span.styled("── ", Style.EMPTY.dim()),
+ Span.styled("Shell", Style.EMPTY.bold()),
+ Span.styled(" " + sep, Style.EMPTY.dim()))),
+ chunks.get(0));
+
+ // Show error from shell thread crash
+ if (startError != null) {
+ frame.renderWidget(
+ Paragraph.from(Line.from(
+ Span.styled(startError,
Style.EMPTY.fg(Color.LIGHT_RED)))),
+ chunks.get(1));
+ return;
+ }
+
+ if (screenTerminal == null) {
+ return;
+ }
+
+ // Dump screen buffer
+ long[] screen = new long[innerWidth * innerHeight];
+ int[] cursor = new int[2];
+ screenTerminal.dump(screen, cursor);
+
+ // Convert to TamboUI lines
+ List<Line> lines = new ArrayList<>(innerHeight);
+ for (int row = 0; row < innerHeight; row++) {
+ List<Span> spans = new ArrayList<>();
+ int col = 0;
+ while (col < innerWidth) {
+ long cell = screen[row * innerWidth + col];
+ int ch = (int) (cell & 0xffffffffL);
+ long attr = cell >>> 32;
+ Style style = convertAttrToStyle(attr);
+
+ // Merge consecutive cells with same attributes
+ StringBuilder sb = new StringBuilder();
+ sb.appendCodePoint(ch == 0 ? ' ' : ch);
+ int nextCol = col + 1;
+ while (nextCol < innerWidth) {
+ long nextCell = screen[row * innerWidth + nextCol];
+ long nextAttr = nextCell >>> 32;
+ if (nextAttr != attr) {
+ break;
+ }
+ int nextCh = (int) (nextCell & 0xffffffffL);
+ sb.appendCodePoint(nextCh == 0 ? ' ' : nextCh);
+ nextCol++;
+ }
+ spans.add(Span.styled(sb.toString(), style));
+ col = nextCol;
+ }
+ lines.add(Line.from(spans));
+ }
+
+ frame.renderWidget(
+ Paragraph.builder()
+ .text(Text.from(lines))
+ .overflow(Overflow.CLIP)
+ .build(),
+ chunks.get(1));
+ }
+
+ void renderFooter(List<Span> spans) {
+ MonitorContext.hint(spans, "F6", "close");
+ int nextPct = SPLIT_PERCENTS[(splitIndex + 1) % SPLIT_PERCENTS.length];
+ MonitorContext.hint(spans, "Shift+F6", nextPct + "%");
+ }
+
+ private String startError;
+
+ private void startShell(int width, int height) {
+ try {
+ screenTerminal = new ScreenTerminal(width, height);
+ lastWidth = width;
+ lastHeight = height;
+
+ // Delegate OutputStream to break the circular dependency:
+ // LineDisciplineTerminal needs masterOutput at construction,
+ // but ScreenTerminalOutputStream needs the terminal for feedback.
+ DelegateOutputStream delegateOut = new DelegateOutputStream();
+ virtualTerminal = new LineDisciplineTerminal(
+ "tui-shell", "screen-256color", delegateOut,
StandardCharsets.UTF_8);
+ virtualTerminal.setSize(new Size(width, height));
+
+ // Feedback loop: VT100 responses go back as terminal input
+ OutputStream feedbackOutput = new OutputStream() {
+ @Override
+ public void write(int b) throws IOException {
+ virtualTerminal.processInputByte(b);
+ }
+ };
+ delegateOut.delegate = new ScreenTerminalOutputStream(
+ screenTerminal, StandardCharsets.UTF_8, feedbackOutput);
+
+ shellThread = new Thread(() -> runShell(virtualTerminal),
"tui-shell");
+ shellThread.setDaemon(true);
+ shellThread.start();
+ } catch (Exception e) {
+ startError = e.getClass().getSimpleName() + ": " + e.getMessage();
+ screenTerminal = null;
+ virtualTerminal = null;
+ }
+ }
+
+ private void runShell(LineDisciplineTerminal terminal) {
+ try {
+ PicocliCommandRegistry registry = new
PicocliCommandRegistry(CamelJBangMain.getCommandLine());
+ String camelVersion = VersionHelper.extractCamelVersion();
+
+ // Redirect command output (printer()) through the virtual terminal
+ // so it renders in the shell panel instead of the TUI's real
terminal
+ CamelJBangMain main = (CamelJBangMain)
CamelJBangMain.getCommandLine().getCommand();
+ Printer originalPrinter = main.getOut();
+ Printer terminalPrinter = new Printer() {
+ @Override
+ public void println() {
+ terminal.writer().println();
+ terminal.writer().flush();
+ }
+
+ @Override
+ public void println(String line) {
+ terminal.writer().println(line);
+ terminal.writer().flush();
+ }
+
+ @Override
+ public void print(String output) {
+ terminal.writer().print(output);
+ terminal.writer().flush();
+ }
+
+ @Override
+ public void printf(String format, Object... args) {
+ terminal.writer().printf(format, args);
+ terminal.writer().flush();
+ }
+ };
+ main.setOut(terminalPrinter);
+
+ // Propagate TUI's selected integration so ask auto-targets it
+ if (ctx != null && ctx.selectedPid != null) {
+ EnvironmentHelper.setSelectedProcess(ctx.selectedName());
+ }
+
+ try (Shell shell = Shell.builder()
+ .terminal(terminal)
+ .prompt(() -> buildPrompt(camelVersion))
+ .groups(registry, new PosixCommandGroup(), new
InteractiveCommandGroup())
+ .historyCommands(true)
+ .helpCommands(true)
+ .commandHighlighter(true)
+ .variable(LineReader.LIST_MAX, 50)
+ .onReaderReady((reader, dispatcher) -> {
+ if (dispatcher instanceof DefaultCommandDispatcher
dcd) {
+
dcd.session().setWorkingDirectory(Path.of("").toAbsolutePath());
+ }
+ })
+ .build()) {
+ EnvironmentHelper.setActiveTerminal(terminal);
+ shell.run();
+ } finally {
+ EnvironmentHelper.setActiveTerminal(null);
+ EnvironmentHelper.setSelectedProcess(null);
+ main.setOut(originalPrinter);
+ }
+ } catch (Exception e) {
+ startError = "Shell crashed: " + e.getClass().getSimpleName() + ":
" + e.getMessage();
+ }
+ }
+
+ private static String buildPrompt(String camelVersion) {
+ AttributedStringBuilder sb = new AttributedStringBuilder();
+ sb.append("camel",
AttributedStyle.DEFAULT.bold().foregroundRgb(0xF69123));
+ if (camelVersion != null) {
+ sb.append(" ");
+ sb.append(camelVersion,
AttributedStyle.DEFAULT.foreground(AttributedStyle.GREEN));
+ }
+ sb.append("> ", AttributedStyle.DEFAULT);
+ return sb.toAnsi();
+ }
+
+ private void stopShell() {
+ if (shellThread != null) {
+ shellThread.interrupt();
+ shellThread = null;
+ }
+ if (virtualTerminal != null) {
+ try {
+ virtualTerminal.close();
+ } catch (IOException e) {
+ // ignore
+ }
+ virtualTerminal = null;
+ }
+ screenTerminal = null;
+ }
+
+ // Attribute mask from ScreenTerminal:
+ // 0xYXFFFBBB00000000L
+ // X: Bit 0=Underline, Bit 1=Negative, Bit 2=Concealed, Bit 3=Bold
+ // Y: Bit 0=FG set, Bit 1=BG set, Bit 2=Dim, Bit 3=Italic
+ // F: Foreground r-g-b (3 hex nibbles)
+ // B: Background r-g-b (3 hex nibbles)
+ private static Style convertAttrToStyle(long attr) {
+ Style style = Style.EMPTY;
+
+ int x = (int) ((attr >> 24) & 0xF);
+ int y = (int) ((attr >> 28) & 0xF);
+
+ if ((x & 0x8) != 0) {
+ style = style.bold();
+ }
+ if ((x & 0x1) != 0) {
+ style = style.underlined();
+ }
+ if ((x & 0x2) != 0) {
+ style = style.reversed();
+ }
+ if ((y & 0x4) != 0) {
+ style = style.dim();
+ }
+ if ((y & 0x8) != 0) {
+ style = style.italic();
+ }
+
+ // Foreground color (if set)
+ if ((y & 0x1) != 0) {
+ int fg = (int) ((attr >> 12) & 0xFFF);
+ int r = ((fg >> 8) & 0xF) * 17;
+ int g = ((fg >> 4) & 0xF) * 17;
+ int b = (fg & 0xF) * 17;
+ style = style.fg(Color.rgb(r, g, b));
+ }
+
+ // Background color (if set)
+ if ((y & 0x2) != 0) {
+ int bg = (int) (attr & 0xFFF);
+ int r = ((bg >> 8) & 0xF) * 17;
+ int g = ((bg >> 4) & 0xF) * 17;
+ int b = (bg & 0xF) * 17;
+ style = style.bg(Color.rgb(r, g, b));
+ }
+
+ return style;
+ }
+
+ private static byte[] encodeKeyEvent(KeyEvent ke) {
+ if (ke.code() == KeyCode.CHAR) {
+ char ch = ke.character();
+ if (ke.hasCtrl()) {
+ // Ctrl+letter → control character
+ if (ch >= 'a' && ch <= 'z') {
+ return new byte[] { (byte) (ch - 'a' + 1) };
+ }
+ if (ch >= 'A' && ch <= 'Z') {
+ return new byte[] { (byte) (ch - 'A' + 1) };
+ }
+ }
+ return Character.toString(ch).getBytes(StandardCharsets.UTF_8);
+ }
+
+ return switch (ke.code()) {
+ case ENTER -> new byte[] { '\r' };
+ case BACKSPACE -> new byte[] { 0x7f };
+ case TAB -> new byte[] { '\t' };
+ case UP -> "\033OA".getBytes(StandardCharsets.UTF_8);
+ case DOWN -> "\033OB".getBytes(StandardCharsets.UTF_8);
+ case RIGHT -> "\033OC".getBytes(StandardCharsets.UTF_8);
+ case LEFT -> "\033OD".getBytes(StandardCharsets.UTF_8);
+ case HOME -> "\033OH".getBytes(StandardCharsets.UTF_8);
+ case END -> "\033OF".getBytes(StandardCharsets.UTF_8);
+ case PAGE_UP -> "\033[5~".getBytes(StandardCharsets.UTF_8);
+ case PAGE_DOWN -> "\033[6~".getBytes(StandardCharsets.UTF_8);
+ case INSERT -> "\033[2~".getBytes(StandardCharsets.UTF_8);
+ case DELETE -> "\033[3~".getBytes(StandardCharsets.UTF_8);
+ case F1 -> "\033OP".getBytes(StandardCharsets.UTF_8);
+ case F2 -> "\033OQ".getBytes(StandardCharsets.UTF_8);
+ case F3 -> "\033OR".getBytes(StandardCharsets.UTF_8);
+ case F4 -> "\033OS".getBytes(StandardCharsets.UTF_8);
+ case F5 -> "\033[15~".getBytes(StandardCharsets.UTF_8);
+ case F6 -> "\033[17~".getBytes(StandardCharsets.UTF_8);
+ case F7 -> "\033[18~".getBytes(StandardCharsets.UTF_8);
+ case F8 -> "\033[19~".getBytes(StandardCharsets.UTF_8);
+ case F9 -> "\033[20~".getBytes(StandardCharsets.UTF_8);
+ case F10 -> "\033[21~".getBytes(StandardCharsets.UTF_8);
+ case F12 -> "\033[24~".getBytes(StandardCharsets.UTF_8);
+ default -> null;
+ };
+ }
+
+ private static class DelegateOutputStream extends OutputStream {
+ volatile OutputStream delegate;
+
+ @Override
+ public void write(int b) throws IOException {
+ if (delegate != null) {
+ delegate.write(b);
+ }
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ if (delegate != null) {
+ delegate.write(b, off, len);
+ }
+ }
+
+ @Override
+ public void flush() throws IOException {
+ if (delegate != null) {
+ delegate.flush();
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (delegate != null) {
+ delegate.close();
+ }
+ }
+ }
+}