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 e448c3cc73f58..c19eca80c695b 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,9 +85,12 @@ class ActionsPopup { // Group 4: MCP private static final int ACTION_MCP_INFO = 13; private static final int ACTION_MCP_LOG = 14; + // Group 5: Shell + private static final int ACTION_SHELL = 15; private static final int[] GROUP_SIZES = { 4, 4, 5 }; private static final int MCP_GROUP_SIZE = 2; + private static final int SHELL_GROUP_SIZE = 1; private final Supplier> runningNames; private final Supplier> integrations; @@ -97,6 +100,7 @@ class ActionsPopup { private final Supplier keystrokesEnabled; private final Runnable toggleTapeRecording; private Runnable resetStatsAction; + private Runnable openShellAction; private final Supplier tapeRecordingActive; private MonitorContext ctx; private boolean mcpEnabled; @@ -180,6 +184,10 @@ void setResetStatsAction(Runnable resetStatsAction) { this.resetStatsAction = resetStatsAction; } + void setOpenShellAction(Runnable openShellAction) { + this.openShellAction = openShellAction; + } + void setMcpEnabled( boolean enabled, int port, Supplier connectedClient, Supplier> activityLog) { this.mcpEnabled = enabled; @@ -190,13 +198,13 @@ void setMcpEnabled( } private int visualActionCount() { + // 4 + 4 + 5 actions = 13, plus 2 dividers = 15 + int count = 13 + 2; if (mcpEnabled) { - // 4 + 4 + 5 + 2 actions = 15, plus 3 dividers = 18 - return 15 + 3; - } else { - // 4 + 4 + 5 actions = 13, plus 2 dividers = 15 - return 13 + 2; + count += MCP_GROUP_SIZE + 1; // +1 divider } + count += SHELL_GROUP_SIZE + 1; // +1 divider (shell group) + return count; } private boolean isDividerIndex(int visualIndex) { @@ -210,16 +218,33 @@ private boolean isDividerIndex(int visualIndex) { } if (mcpEnabled) { pos += MCP_GROUP_SIZE; + if (visualIndex == pos) { + return true; + } + pos++; } + // Shell group divider + pos += SHELL_GROUP_SIZE; return false; } private int resolveAction(int visualIndex) { int dividers = 0; int pos = 0; - int groupCount = mcpEnabled ? GROUP_SIZES.length + 1 : GROUP_SIZES.length; + int groupCount = GROUP_SIZES.length; + if (mcpEnabled) { + groupCount++; + } + groupCount++; // shell group for (int i = 0; i < groupCount; i++) { - int gs = i < GROUP_SIZES.length ? GROUP_SIZES[i] : MCP_GROUP_SIZE; + int gs; + if (i < GROUP_SIZES.length) { + gs = GROUP_SIZES[i]; + } else if (mcpEnabled && i == GROUP_SIZES.length) { + gs = MCP_GROUP_SIZE; + } else { + gs = SHELL_GROUP_SIZE; + } pos += gs; if (visualIndex < pos + dividers) { break; @@ -316,6 +341,9 @@ List getActionLabels() { labels.add("MCP Info"); labels.add("MCP Log"); } + // Group 5: Shell + labels.add("───"); + labels.add("Shell"); return labels; } @@ -536,6 +564,11 @@ boolean handleKeyEvent(KeyEvent ke) { } else if (action == ACTION_CAPTION) { showActionsMenu = false; captionOverlay.openInline(); + } else if (action == ACTION_SHELL) { + showActionsMenu = false; + if (openShellAction != null) { + openShellAction.run(); + } } } } @@ -703,6 +736,9 @@ private void renderActionsMenu(Frame frame, Rect area) { 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 9e5e8f376e0e6..5334c8778eeb2 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/CamelMonitor.java @@ -64,6 +64,7 @@ import dev.tamboui.tui.event.KeyCode; import dev.tamboui.tui.event.KeyEvent; import dev.tamboui.tui.event.KeyModifiers; +import dev.tamboui.tui.event.MouseEvent; import dev.tamboui.tui.event.PasteEvent; import dev.tamboui.tui.event.TickEvent; import dev.tamboui.widgets.Clear; @@ -240,6 +241,7 @@ public class CamelMonitor extends CamelCommand { private final Queue pendingKeys = new ConcurrentLinkedQueue<>(); private final List recentKeys = new ArrayList<>(); private final CaptionOverlay captionOverlay = new CaptionOverlay(); + private final ShellPanel shellPanel = new ShellPanel(); private final ActionsPopup actionsPopup = new ActionsPopup( () -> data.get().stream() @@ -306,6 +308,7 @@ public Integer doCall() throws Exception { ctx = new MonitorContext(data, infraData); actionsPopup.setContext(ctx); actionsPopup.setResetStatsAction(this::resetStats); + actionsPopup.setOpenShellAction(shellPanel::open); logTab = new LogTab(ctx); routesTab = new RoutesTab(ctx); consumersTab = new ConsumersTab(ctx); @@ -400,6 +403,9 @@ private boolean handleEvent(Event event, TuiRunner runner) { captionOverlay.openInline(); return true; } + if (shellPanel.isOpen()) { + return shellPanel.handleKeyEvent(ke); + } if (actionsPopup.isVisible()) { return actionsPopup.handleKeyEvent(ke); } @@ -511,6 +517,16 @@ private boolean handleEvent(Event event, TuiRunner runner) { return true; } + // F3 toggles shell panel + if (ke.isKey(KeyCode.F3)) { + if (shellPanel.isOpen()) { + shellPanel.close(); + } else { + shellPanel.open(); + } + return true; + } + // F2 opens actions menu (global) if (ke.isKey(KeyCode.F2)) { if (tabsState.selected() == TAB_ROUTES && routesTab != null) { @@ -643,6 +659,10 @@ private boolean handleEvent(Event event, TuiRunner runner) { return true; } } + if (event instanceof MouseEvent) { + // Consume mouse events (scroll, click) — not forwarded to shell or tabs + return true; + } if (event instanceof PasteEvent pe) { if (actionsPopup.isVisible()) { actionsPopup.handlePaste(pe.text()); @@ -891,13 +911,23 @@ private void render(Frame frame) { // 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)); - if (showKillConfirm) { - renderKillConfirm(frame, mainChunks.get(4)); - } - actionsPopup.render(frame, mainChunks.get(4)); - if (captionOverlay.isCaptionVisible()) { - captionOverlay.render(frame, mainChunks.get(4)); + Rect contentArea = mainChunks.get(4); + if (shellPanel.isOpen()) { + List splitChunks = Layout.vertical() + .constraints(Constraint.percentage(50), Constraint.percentage(50)) + .split(contentArea); + renderContent(frame, splitChunks.get(0)); + shellPanel.render(frame, splitChunks.get(1)); + actionsPopup.render(frame, splitChunks.get(0)); + } else { + renderContent(frame, contentArea); + if (showKillConfirm) { + renderKillConfirm(frame, contentArea); + } + actionsPopup.render(frame, contentArea); + if (captionOverlay.isCaptionVisible()) { + captionOverlay.render(frame, contentArea); + } } renderFooter(frame, mainChunks.get(5)); @@ -1901,6 +1931,12 @@ private void renderFooter(Frame frame, Rect area) { return; } + if (shellPanel.isOpen()) { + shellPanel.renderFooter(spans); + frame.renderWidget(Paragraph.from(Line.from(spans)), area); + return; + } + MonitorTab tab = activeTab(); if (tab != null) { 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 0000000000000..8cc8a65160dfc --- /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,401 @@ +/* + * 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.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.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.terminal.Size; +import org.jline.terminal.impl.LineDisciplineTerminal; +import org.jline.utils.AttributedStringBuilder; +import org.jline.utils.AttributedStyle; + +class ShellPanel { + + private boolean visible; + + private ScreenTerminal screenTerminal; + private LineDisciplineTerminal virtualTerminal; + private Thread shellThread; + + private int lastWidth; + private int lastHeight; + + boolean isOpen() { + return visible; + } + + void open() { + if (visible) { + return; + } + visible = true; + } + + void close() { + visible = false; + stopShell(); + } + + boolean handleKeyEvent(KeyEvent ke) { + if (!visible) { + return false; + } + + // F3 closes the shell panel + if (ke.isKey(KeyCode.F3)) { + 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 the 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; + } + + if (screenTerminal == null) { + frame.renderWidget( + Paragraph.from(Line.from(Span.styled("Starting shell...", Style.EMPTY.dim()))), + area); + return; + } + + // Dump screen buffer + long[] screen = new long[innerWidth * innerHeight]; + int[] cursor = new int[2]; + screenTerminal.dump(screen, cursor); + + // Convert to TamboUI lines + List lines = new ArrayList<>(innerHeight); + for (int row = 0; row < innerHeight; row++) { + List 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)); + } + + // Render separator line with title + List chunks = Layout.vertical() + .constraints(Constraint.length(1), Constraint.fill()) + .split(area); + 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)); + + // Render shell content + frame.renderWidget( + Paragraph.builder() + .text(Text.from(lines)) + .overflow(Overflow.CLIP) + .build(), + chunks.get(1)); + } + + void renderFooter(List spans) { + MonitorContext.hint(spans, "F3", "close"); + } + + 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) { + screenTerminal = null; + virtualTerminal = null; + } + } + + private void runShell(LineDisciplineTerminal terminal) { + try { + PicocliCommandRegistry registry = new PicocliCommandRegistry(CamelJBangMain.getCommandLine()); + String camelVersion = VersionHelper.extractCamelVersion(); + + try (org.jline.shell.Shell shell = org.jline.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) + .build()) { + EnvironmentHelper.setActiveTerminal(terminal); + shell.run(); + } finally { + EnvironmentHelper.setActiveTerminal(null); + } + } catch (Exception e) { + // shell exited + } + } + + 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 (upper 32 bits of cell): + // 0xYXFFFBBB + // Y (bits 28-31): Bit 0=FG set, Bit 1=BG set, Bit 2=Dim, Bit 3=Italic + // X (bits 24-27): Bit 0=Underline, Bit 1=Negative, Bit 2=Concealed, Bit 3=Bold + // FFF (bits 12-23): Foreground r-g-b (3 hex nibbles) + // BBB (bits 0-11): 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 ((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 -> "\033[A".getBytes(StandardCharsets.UTF_8); + case DOWN -> "\033[B".getBytes(StandardCharsets.UTF_8); + case RIGHT -> "\033[C".getBytes(StandardCharsets.UTF_8); + case LEFT -> "\033[D".getBytes(StandardCharsets.UTF_8); + case HOME -> "\033[H".getBytes(StandardCharsets.UTF_8); + case END -> "\033[F".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(); + } + } + } +} diff --git a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java index 87c2c641561a7..b8f38e7a358f7 100644 --- a/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java +++ b/dsl/camel-jbang/camel-jbang-plugin-tui/src/main/java/org/apache/camel/dsl/jbang/core/commands/tui/TuiBackendHelper.java @@ -35,10 +35,10 @@ static TuiRunner createTuiRunner() throws Exception { if (activeTerminal != null) { Backend backend = createBackendForTerminal(activeTerminal); if (backend != null) { - return TuiRunner.create(TuiConfig.builder().backend(backend).build()); + return TuiRunner.create(TuiConfig.builder().backend(backend).mouseCapture(true).build()); } } - return TuiRunner.create(); + return TuiRunner.create(TuiConfig.builder().mouseCapture(true).build()); } private static Backend createBackendForTerminal(Terminal terminal) {