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();
+            }
+        }
+    }
+}


Reply via email to