This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new c294e9ec4d add terminal widget to hop gui #5938 (#6576)
c294e9ec4d is described below

commit c294e9ec4d68bbe228f10481a52ca72f98ea1d96
Author: Bart Maertens <[email protected]>
AuthorDate: Mon Feb 16 11:58:31 2026 +0000

    add terminal widget to hop gui #5938 (#6576)
    
    * updated terminal tabs, refactored terminal ui and menu
    
    * hop gui terminal, working initial version. #5938
    
    * fallback to simple terminal is jedi terminal is not available. #5938
    
    * updated config options, color schemes on Windows. #5938
    
    * terminal minor updates and cleanup. #5938
    
    * store and restore terminal type in state. #5938
    
    * windows terminal color updates. #5938
    
    * windows terminal color updates. #5938
    
    * windows terminal color updates. #5938
    
    * match powershell colors in windows jedit terminal. #5938
    
    * exclude windows powershell background from toolbar. #5938
    
    * simplified layout for terminal and execution panel. #5938
    
    * updates after resolving merge conflict
    
    * updated bottom toolbar layout. #5938
    
    * terminal widget simplifications. #5938
    
    * code cleanup. #5938
    
    * code cleanup. #5938
    
    * comments cleanup. #5938
    
    * conditionally show/hide bottom left toolbar buttons. #5938
---
 .../apache/hop/projects/gui/ProjectsGuiPlugin.java |  12 +
 ui/pom.xml                                         |  38 ++
 .../main/java/org/apache/hop/ui/core/PropsUi.java  |   1 +
 .../org/apache/hop/ui/core/gui/GuiResource.java    |   2 +
 .../main/java/org/apache/hop/ui/hopgui/HopGui.java | 721 ++++++++++++++-------
 .../org/apache/hop/ui/hopgui/HopGuiKeyHandler.java |  52 +-
 .../ui/hopgui/SidebarToolbarItemDescriptor.java    |  74 +++
 .../ui/hopgui/delegates/HopGuiFileDelegate.java    |   6 +
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |  31 +-
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  |  30 +-
 .../configuration/tabs/ConfigGuiOptionsTab.java    |   2 +
 .../ui/hopgui/terminal/HopGuiTerminalPanel.java    | 698 ++++++++++++++++++++
 .../hop/ui/hopgui/terminal/ITerminalWidget.java    |  70 ++
 .../hop/ui/hopgui/terminal/JediTerminalWidget.java | 451 +++++++++++++
 .../ui/hopgui/terminal/TerminalShellDetector.java  | 197 ++++++
 .../ui/hopgui/messages/messages_en_US.properties   |   3 +
 ui/src/main/resources/ui/images/terminal.svg       |  17 +
 .../hopgui/terminal/HopGuiTerminalPanelTest.java   | 181 ++++++
 .../hopgui/terminal/TerminalShellDetectorTest.java |  76 +++
 19 files changed, 2391 insertions(+), 271 deletions(-)

diff --git 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
index 2f42f6b05a..a61fccfbf9 100644
--- 
a/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
+++ 
b/plugins/misc/projects/src/main/java/org/apache/hop/projects/gui/ProjectsGuiPlugin.java
@@ -162,6 +162,12 @@ public class ProjectsGuiPlugin {
       //
       hopGui.fileDelegate.closeAllFiles();
 
+      // Clear all terminal tabs (saves current project's terminals first)
+      //
+      if (hopGui.getTerminalPanel() != null) {
+        hopGui.getTerminalPanel().clearAllTerminals();
+      }
+
       // This is called only in Hop GUI so we want to start with a new set of 
variables
       // It avoids variables from one project showing up in another
       //
@@ -207,6 +213,12 @@ public class ProjectsGuiPlugin {
       //
       hopGui.auditDelegate.openLastFiles();
 
+      // Restore terminal tabs for the new project (per-project terminals)
+      //
+      if (hopGui.getTerminalPanel() != null && 
hopGui.getProps().openLastFile()) {
+        hopGui.getDisplay().asyncExec(() -> 
hopGui.getTerminalPanel().restoreTerminals());
+      }
+
       // Clear last used, fill it with something useful.
       //
       IVariables hopGuiVariables = Variables.getADefaultVariableSpace();
diff --git a/ui/pom.xml b/ui/pom.xml
index a11300b27a..6e59c68049 100644
--- a/ui/pom.xml
+++ b/ui/pom.xml
@@ -30,6 +30,8 @@
     <name>Hop GUI</name>
 
     <properties>
+        <jediterm.version>3.57</jediterm.version>
+        <pty4j.version>0.13.4</pty4j.version>
         <xmlgraphics-commons.version>2.9</xmlgraphics-commons.version>
     </properties>
 
@@ -63,6 +65,34 @@
             <version>${org.eclipse.platform.version}</version>
             <scope>compile</scope>
         </dependency>
+        <!-- JediTerm - JetBrains terminal emulator for POC/testing -->
+        <dependency>
+            <groupId>org.jetbrains.jediterm</groupId>
+            <artifactId>jediterm-core</artifactId>
+            <version>${jediterm.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.jetbrains.jediterm</groupId>
+            <artifactId>jediterm-ui</artifactId>
+            <version>${jediterm.version}</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.jetbrains.pty4j</groupId>
+                    <artifactId>pty4j</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>org.jetbrains.pty4j</groupId>
+            <artifactId>pty4j</artifactId>
+            <version>${pty4j.version}</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>org.jetbrains.pty4j</groupId>
+                    <artifactId>purejavacomm</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
         <dependency>
             <groupId>org.apache.hop</groupId>
             <artifactId>hop-core</artifactId>
@@ -96,4 +126,12 @@
             <scope>test</scope>
         </dependency>
     </dependencies>
+
+    <repositories>
+        <!-- JetBrains repository for JediTerm -->
+        <repository>
+            <id>jetbrains-intellij-dependencies</id>
+            
<url>https://packages.jetbrains.team/maven/p/ij/intellij-dependencies</url>
+        </repository>
+    </repositories>
 </project>
diff --git a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java 
b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
index 8755f35c2f..ddd6e4b97e 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
@@ -84,6 +84,7 @@ public class PropsUi extends Props {
       "GraphExtraViewVerticalOrientation";
   private static final String DISABLE_ZOOM_SCROLLING = "DisableZoomScrolling";
   private static final String ENABLE_INFINITE_CANVAS_MOVE = 
"EnableInfiniteCanvasMove";
+  private static final String USE_ADVANCED_TERMINAL = "UseAdvancedTerminal";
 
   public static final int DEFAULT_MAX_EXECUTION_LOGGING_TEXT_SIZE = 2000000;
   private Map<RGB, RGB> contrastingColors;
diff --git a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
index dbd5930bf7..64964b5fec 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiResource.java
@@ -249,6 +249,7 @@ public class GuiResource {
   @Getter private Image imageStop;
   @Getter private Image imageSynonym;
   @Getter private Image imageTable;
+  @Getter private Image imageTerminal;
   @Getter private Image imageUndo;
   @Getter private Image imageUnselectAll;
   @Getter private Image imageUp;
@@ -777,6 +778,7 @@ public class GuiResource {
         loadAsResource(display, "ui/images/show-selected.svg", 
ConstUi.SMALL_ICON_SIZE);
     imageSynonym = loadAsResource(display, "ui/images/view.svg", 
ConstUi.SMALL_ICON_SIZE);
     imageTable = loadAsResource(display, "ui/images/table.svg", 
ConstUi.SMALL_ICON_SIZE);
+    imageTerminal = loadAsResource(display, "ui/images/terminal.svg", 
ConstUi.SMALL_ICON_SIZE);
     imageUser = loadAsResource(display, "ui/images/user.svg", 
ConstUi.SMALL_ICON_SIZE);
     imageClose = loadAsResource(display, "ui/images/close.svg", 
ConstUi.SMALL_ICON_SIZE);
     imageDelete = loadAsResource(display, "ui/images/delete.svg", 
ConstUi.SMALL_ICON_SIZE);
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index af27e96f05..010a2a7d60 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -30,6 +30,7 @@ import java.util.Arrays;
 import java.util.Comparator;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 import java.util.UUID;
 import lombok.Getter;
 import lombok.Setter;
@@ -204,6 +205,10 @@ public class HopGui
   public static final String ID_MAIN_MENU_EDIT_NAV_PREV = 
"20400-menu-edit-nav-previous";
   public static final String ID_MAIN_MENU_EDIT_NAV_NEXT = 
"20410-menu-edit-nav-next";
 
+  public static final String ID_MAIN_MENU_VIEW_PARENT_ID = "25000-menu-view";
+  public static final String ID_MAIN_MENU_VIEW_TERMINAL = 
"25010-menu-view-terminal";
+  public static final String ID_MAIN_MENU_VIEW_NEW_TERMINAL = 
"25020-menu-view-new-terminal";
+
   public static final String ID_MAIN_MENU_RUN_PARENT_ID = "30000-menu-run";
   public static final String ID_MAIN_MENU_RUN_START = "30010-menu-run-execute";
   public static final String ID_MAIN_MENU_RUN_PAUSE = "30030-menu-run-pause";
@@ -231,6 +236,16 @@ public class HopGui
 
   public static final String GUI_PLUGIN_PERSPECTIVES_PARENT_ID = 
"HopGui-Perspectives";
 
+  /** Perspective id for the file explorer / data orchestration perspective. */
+  public static final String PERSPECTIVE_ID_EXPLORER = "explorer-perspective";
+
+  /** Id for the execution results toggle button in the sidebar bottom 
toolbar. */
+  public static final String SIDEBAR_TOOLBAR_ITEM_EXECUTION_RESULTS =
+      "HopGui-SidebarToolbar-ExecutionResults";
+
+  /** Id for the terminal toggle button in the sidebar bottom toolbar. */
+  public static final String SIDEBAR_TOOLBAR_ITEM_TERMINAL = 
"HopGui-SidebarToolbar-Terminal";
+
   public static final String DEFAULT_HOP_GUI_NAMESPACE = "hop-gui";
 
   public boolean firstShowing = true;
@@ -268,11 +283,18 @@ public class HopGui
   private GuiToolbarWidgets statusToolbarWidgets;
 
   private Composite perspectivesSidebar;
+  private ToolBar bottomToolbar;
+  private final java.util.List<SidebarToolbarItemDescriptor> 
sidebarToolbarDescriptors =
+      new java.util.ArrayList<>();
   private java.util.List<SidebarButton> sidebarButtons = new 
java.util.ArrayList<>();
   private Composite mainPerspectivesComposite;
   private HopPerspectiveManager perspectiveManager;
   private IHopPerspective activePerspective;
-  private ToolItem explorerPerspectiveToolItem;
+  private org.apache.hop.ui.hopgui.terminal.HopGuiTerminalPanel terminalPanel;
+
+  public org.apache.hop.ui.hopgui.terminal.HopGuiTerminalPanel 
getTerminalPanel() {
+    return terminalPanel;
+  }
 
   private static final PrintStream originalSystemOut = System.out;
   private static final PrintStream originalSystemErr = System.err;
@@ -483,6 +505,8 @@ public class HopGui
             auditDelegate.openLastFiles();
           }
 
+          // Terminal restoration is handled by the Projects plugin
+
           // We need to start tracking file history again.
           //
           reOpeningFiles = false;
@@ -622,7 +646,10 @@ public class HopGui
 
         // Create styled sidebar button with hover, selection, and rounded 
corners
         // This works for both desktop SWT and web/RAP modes
-        createStyledSidebarButton(perspectivesSidebar, image, tooltip, 
perspective);
+        // Get the perspectives container from the sidebar
+        Composite perspectivesContainer =
+            (Composite) perspectivesSidebar.getData("perspectivesContainer");
+        createStyledSidebarButton(perspectivesContainer, image, tooltip, 
perspective);
       }
 
       perspectivesSidebar.layout(true, true);
@@ -659,6 +686,229 @@ public class HopGui
     return item;
   }
 
+  /**
+   * Create a styled sidebar button with modern appearance. Features rounded 
corners, hover effects,
+   * and selection colors.
+   */
+  private void createStyledSidebarButton(
+      Composite parent, Image image, String tooltip, IHopPerspective 
perspective) {
+
+    SidebarButton button = new SidebarButton(parent, image, tooltip, 
perspective);
+    sidebarButtons.add(button);
+
+    GridData gd = new GridData();
+    gd.widthHint = (int) (34 * PropsUi.getNativeZoomFactor());
+    gd.heightHint = (int) (34 * PropsUi.getNativeZoomFactor());
+    button.composite.setLayoutData(gd);
+  }
+
+  /** Custom sidebar button class with hover, selection, and rounded corners */
+  private class SidebarButton {
+    Control composite; // Use Control to allow both Composite (RAP) and Canvas 
(desktop)
+    Image image;
+    IHopPerspective perspective;
+    boolean isHovered = false;
+    boolean isSelected = false;
+
+    Color selectionBg = GuiResource.getInstance().getColorLightBlue();
+    Color hoverBg = GuiResource.getInstance().getColorGray();
+    Color normalBg = GuiResource.getInstance().getWidgetBackGroundColor();
+
+    public SidebarButton(
+        Composite parent, Image image, String tooltip, IHopPerspective 
perspective) {
+      this.image = image;
+      this.perspective = perspective;
+
+      // Create a label inside the composite to display the image (only for 
RAP)
+      final Label imageLabel;
+      if (EnvironmentUtils.getInstance().isWeb()) {
+        // For RAP/web: use Composite with Label child
+        Composite comp = new Composite(parent, SWT.NONE);
+        composite = comp;
+        comp.setToolTipText(tooltip);
+        comp.setBackground(normalBg);
+
+        // Set custom variant for CSS styling in RAP
+        comp.setData("org.eclipse.rap.rwt.customVariant", "sidebarButton");
+
+        // Use GridLayout to center the image without stretching
+        GridLayout layout = new GridLayout(1, false);
+        layout.marginWidth = 0;
+        layout.marginHeight = 0;
+        layout.horizontalSpacing = 0;
+        layout.verticalSpacing = 0;
+        comp.setLayout(layout);
+
+        imageLabel = new Label(comp, SWT.NONE);
+        imageLabel.setImage(image);
+        imageLabel.setBackground(normalBg);
+        imageLabel.setToolTipText(tooltip);
+        imageLabel.setData("org.eclipse.rap.rwt.customVariant", 
"sidebarButton");
+
+        // Center the label in the composite
+        GridData gd = new GridData(SWT.CENTER, SWT.CENTER, true, true);
+        imageLabel.setLayoutData(gd);
+      } else {
+        Canvas canvas = new Canvas(parent, SWT.NONE);
+        composite = canvas;
+        canvas.setToolTipText(tooltip);
+        canvas.setBackground(normalBg);
+        imageLabel = null;
+      }
+
+      // Update background colors method for both RAP and desktop
+      final Runnable updateColors =
+          () -> {
+            if (EnvironmentUtils.getInstance().isWeb()) {
+              // For RAP, update composite background color (rounded corners 
applied via CSS)
+              Color bgColor;
+              if (isSelected) {
+                bgColor = selectionBg;
+              } else if (isHovered) {
+                bgColor = hoverBg;
+              } else {
+                bgColor = normalBg;
+              }
+              composite.setBackground(bgColor);
+              // Keep label background transparent/matching to show composite 
background
+              if (imageLabel != null) {
+                imageLabel.setBackground(bgColor);
+              }
+              // Force redraw in RAP
+              composite.redraw();
+              if (composite instanceof Composite) {
+                ((Composite) composite).layout();
+              }
+            } else {
+              // For desktop Canvas, trigger repaint
+              composite.redraw();
+            }
+          };
+
+      // Only add paint listener for desktop SWT (RAP doesn't support it)
+      if (!EnvironmentUtils.getInstance().isWeb()) {
+        composite.addPaintListener(
+            e -> {
+              GC gc = e.gc;
+              Point size = composite.getSize();
+
+              gc.setAntialias(SWT.ON);
+
+              // Choose background color
+              if (isSelected) {
+                gc.setBackground(selectionBg);
+              } else if (isHovered) {
+                gc.setBackground(hoverBg);
+              } else {
+                gc.setBackground(normalBg);
+              }
+
+              // Fill rounded rectangle
+              gc.fillRoundRectangle(4, 4, size.x - 8, size.y - 8, 8, 8);
+
+              // Draw image centered
+              if (image != null && !image.isDisposed()) {
+                Rectangle imgBounds = image.getBounds();
+                int x = (size.x - imgBounds.width) / 2;
+                int y = (size.y - imgBounds.height) / 2;
+                gc.drawImage(image, x, y);
+              }
+            });
+      }
+
+      // Mouse listeners
+      composite.addListener(
+          SWT.MouseEnter,
+          e -> {
+            isHovered = true;
+            updateColors.run();
+          });
+
+      composite.addListener(
+          SWT.MouseExit,
+          e -> {
+            isHovered = false;
+            updateColors.run();
+          });
+
+      composite.addListener(
+          SWT.MouseDown,
+          e -> {
+            // Deselect all other buttons
+            for (SidebarButton btn : sidebarButtons) {
+              btn.setSelected(false);
+            }
+
+            // Select this button
+            setSelected(true);
+
+            // Handle perspective activation
+            if (perspective instanceof ExplorerPerspective explorerPerspective
+                && mainPerspectivesComposite != null
+                && !mainPerspectivesComposite.isDisposed()) {
+              StackLayout layout = (StackLayout) 
mainPerspectivesComposite.getLayout();
+              if (layout.topControl == explorerPerspective.getControl()) {
+                explorerPerspective.toggleFileExplorerPanel();
+                return;
+              }
+            }
+            setActivePerspective(perspective);
+          });
+
+      // Also attach listeners to the image label for better hit detection 
(RAP only)
+      if (imageLabel != null) {
+        imageLabel.addListener(
+            SWT.MouseEnter,
+            e -> {
+              isHovered = true;
+              updateColors.run();
+            });
+        imageLabel.addListener(
+            SWT.MouseExit,
+            e -> {
+              isHovered = false;
+              updateColors.run();
+            });
+        imageLabel.addListener(
+            SWT.MouseDown,
+            e -> {
+              // Deselect all other buttons
+              for (SidebarButton btn : sidebarButtons) {
+                btn.setSelected(false);
+              }
+
+              // Select this button
+              setSelected(true);
+
+              // Handle perspective activation
+              if (perspective instanceof ExplorerPerspective 
explorerPerspective
+                  && mainPerspectivesComposite != null
+                  && !mainPerspectivesComposite.isDisposed()) {
+                StackLayout layout = (StackLayout) 
mainPerspectivesComposite.getLayout();
+                if (layout.topControl == explorerPerspective.getControl()) {
+                  explorerPerspective.toggleFileExplorerPanel();
+                  return;
+                }
+              }
+              setActivePerspective(perspective);
+            });
+      }
+
+      // Store the update method for later use
+      composite.setData("updateColors", updateColors);
+    }
+
+    public void setSelected(boolean selected) {
+      this.isSelected = selected;
+      if (!composite.isDisposed()) {
+        Runnable updateColors = (Runnable) composite.getData("updateColors");
+        if (updateColors != null) {
+          updateColors.run();
+        }
+      }
+    }
+  }
+
   private static Display setupDisplay() {
     // Bootstrap Hop
     //
@@ -955,6 +1205,14 @@ public class HopGui
   @GuiKeyboardShortcut(control = true, key = 'c')
   @GuiOsxKeyboardShortcut(command = true, key = 'c')
   public void menuEditCopySelected() {
+    Control focusControl = display.getFocusControl();
+    if (focusControl instanceof org.eclipse.swt.custom.StyledText styledText) {
+      if (styledText.getSelectionCount() > 0) {
+        return;
+      }
+    }
+
+    // Otherwise, delegate to the active file type handler (pipeline/workflow)
     getActiveFileTypeHandler().copySelectedToClipboard();
   }
 
@@ -967,6 +1225,13 @@ public class HopGui
   @GuiKeyboardShortcut(control = true, key = 'v')
   @GuiOsxKeyboardShortcut(command = true, key = 'v')
   public void menuEditPaste() {
+    Control focusControl = display.getFocusControl();
+    if (focusControl instanceof org.eclipse.swt.custom.StyledText) {
+      // Terminal handles paste internally
+      return;
+    }
+
+    // Otherwise, delegate to the active file type handler (pipeline/workflow)
     getActiveFileTypeHandler().pasteFromClipboard();
   }
 
@@ -1131,6 +1396,51 @@ public class HopGui
     getActivePerspective().navigateToNextFile();
   }
 
+  // ======================== View Menu ========================
+
+  @GuiMenuElement(
+      root = ID_MAIN_MENU,
+      id = ID_MAIN_MENU_VIEW_PARENT_ID,
+      label = "i18n::HopGui.Menu.View",
+      parentId = ID_MAIN_MENU)
+  public void menuView() {
+    // Nothing is done here.
+  }
+
+  @GuiMenuElement(
+      root = ID_MAIN_MENU,
+      id = ID_MAIN_MENU_VIEW_TERMINAL,
+      label = "i18n::HopGui.Menu.View.Terminal",
+      parentId = ID_MAIN_MENU_VIEW_PARENT_ID)
+  @GuiKeyboardShortcut(control = true, key = '`')
+  @GuiOsxKeyboardShortcut(control = true, key = '`')
+  public void menuViewTerminal() {
+    if (EnvironmentUtils.getInstance().isWeb()) {
+      return;
+    }
+    if (terminalPanel != null) {
+      terminalPanel.toggleTerminal();
+    }
+  }
+
+  @GuiMenuElement(
+      root = ID_MAIN_MENU,
+      id = ID_MAIN_MENU_VIEW_NEW_TERMINAL,
+      label = "i18n::HopGui.Menu.View.NewTerminal",
+      parentId = ID_MAIN_MENU_VIEW_PARENT_ID)
+  @GuiKeyboardShortcut(control = true, shift = true, key = '`')
+  @GuiOsxKeyboardShortcut(control = true, shift = true, key = '`')
+  public void menuViewNewTerminal() {
+    if (EnvironmentUtils.getInstance().isWeb()) {
+      return;
+    }
+    if (terminalPanel != null) {
+      terminalPanel.createNewTerminal(null, null);
+    }
+  }
+
+  // ======================== Run Menu ========================
+
   @GuiMenuElement(
       root = ID_MAIN_MENU,
       id = ID_MAIN_MENU_RUN_PARENT_ID,
@@ -1292,14 +1602,67 @@ public class HopGui
     // Create custom sidebar composite instead of ToolBar for better control
     perspectivesSidebar = new Composite(mainHopGuiComposite, SWT.NONE);
     PropsUi.setLook(perspectivesSidebar);
+    perspectivesSidebar.setLayout(new FormLayout());
 
-    // Use GridLayout for vertical stacking
-    org.eclipse.swt.layout.GridLayout sidebarLayout =
+    // Container for perspective buttons (uses GridLayout for stacking)
+    Composite perspectivesContainer = new Composite(perspectivesSidebar, 
SWT.NONE);
+    org.eclipse.swt.layout.GridLayout perspectivesLayout =
         new org.eclipse.swt.layout.GridLayout(1, false);
-    sidebarLayout.marginWidth = 1;
-    sidebarLayout.marginHeight = 2;
-    sidebarLayout.verticalSpacing = 1; // Minimal spacing between buttons
-    perspectivesSidebar.setLayout(sidebarLayout);
+    perspectivesLayout.marginWidth = 1;
+    perspectivesLayout.marginHeight = 2;
+    perspectivesLayout.verticalSpacing = 1; // Minimal spacing between buttons
+    perspectivesContainer.setLayout(perspectivesLayout);
+    FormData fdPerspectivesContainer = new FormData();
+    fdPerspectivesContainer.left = new FormAttachment(0, 0);
+    fdPerspectivesContainer.top = new FormAttachment(0, 0);
+    fdPerspectivesContainer.right = new FormAttachment(100, 0);
+    perspectivesContainer.setLayoutData(fdPerspectivesContainer);
+
+    bottomToolbar = new ToolBar(perspectivesSidebar, SWT.WRAP | SWT.RIGHT | 
SWT.VERTICAL);
+    PropsUi.setLook(bottomToolbar, Props.WIDGET_STYLE_TOOLBAR);
+    FormData fdBottomToolbar = new FormData();
+    fdBottomToolbar.left = new FormAttachment(0, 0);
+    fdBottomToolbar.right = new FormAttachment(100, 0);
+    // Add small margin at bottom to create visual separation from 
project/environment dropdowns
+    fdBottomToolbar.bottom = new FormAttachment(100, -4);
+    bottomToolbar.setLayoutData(fdBottomToolbar);
+
+    // Register built-in sidebar toolbar items (visibility depends on active 
perspective).
+    // File explorer: both terminal and execution. Other perspectives: 
terminal only.
+    // List order: terminal then execution; refresh draws in reverse so 
execution appears above.
+    int sidebarIconSize = 21;
+    sidebarToolbarDescriptors.add(
+        SidebarToolbarItemDescriptor.builder()
+            .id(SIDEBAR_TOOLBAR_ITEM_TERMINAL)
+            .imagePath("ui/images/terminal.svg")
+            .imageSize(sidebarIconSize)
+            .tooltip("Toggle Terminal Panel")
+            .onSelect(
+                () -> {
+                  if (terminalPanel != null) {
+                    terminalPanel.toggleTerminal();
+                  }
+                })
+            .available(!EnvironmentUtils.getInstance().isWeb())
+            .build());
+    sidebarToolbarDescriptors.add(
+        SidebarToolbarItemDescriptor.builder()
+            .id(SIDEBAR_TOOLBAR_ITEM_EXECUTION_RESULTS)
+            .visibleForPerspectiveIds(Set.of(PERSPECTIVE_ID_EXPLORER))
+            .imagePath("ui/images/show-results.svg")
+            .imageSize(sidebarIconSize)
+            .tooltip("Toggle Execution Results (Logging/Metrics/Problems)")
+            .onSelect(this::toggleExecutionResults)
+            .available(true)
+            .build());
+
+    refreshBottomToolbarItems();
+
+    // Anchor perspectives container above bottom toolbar
+    fdPerspectivesContainer.bottom = new FormAttachment(bottomToolbar, 0);
+
+    // Store perspectivesContainer for use in loadPerspectives
+    perspectivesSidebar.setData("perspectivesContainer", 
perspectivesContainer);
 
     FormData fdSidebar = new FormData();
     fdSidebar.left = new FormAttachment(0, 0);
@@ -1311,17 +1674,34 @@ public class HopGui
 
   /**
    * Add a main composite where the various perspectives can parent on to show 
stuff... Its area is
-   * to just below the main toolbar and to the right of the perspectives 
toolbar
+   * to just below the main toolbar and to the right of the perspectives 
toolbar.
+   *
+   * <p>Wraps everything in a HopGuiTerminalPanel which provides the terminal 
panel at the bottom,
+   * with perspectives rendering in the top section.
    */
   private void addMainPerspectivesComposite() {
-    mainPerspectivesComposite = new Composite(mainHopGuiComposite, 
SWT.NO_BACKGROUND);
-    mainPerspectivesComposite.setLayout(new StackLayout());
-    FormData fdMain = new FormData();
-    fdMain.top = new FormAttachment(0, 0);
-    fdMain.left = new FormAttachment(perspectivesSidebar, 0);
-    fdMain.bottom = new FormAttachment(100, 0);
-    fdMain.right = new FormAttachment(100, 0);
-    mainPerspectivesComposite.setLayoutData(fdMain);
+    if (EnvironmentUtils.getInstance().isWeb()) {
+      mainPerspectivesComposite = new Composite(mainHopGuiComposite, SWT.NONE);
+      FormData fdPerspectives = new FormData();
+      fdPerspectives.top = new FormAttachment(0, 0);
+      fdPerspectives.left = new FormAttachment(perspectivesSidebar, 0);
+      fdPerspectives.bottom = new FormAttachment(100, 0);
+      fdPerspectives.right = new FormAttachment(100, 0);
+      mainPerspectivesComposite.setLayoutData(fdPerspectives);
+      mainPerspectivesComposite.setLayout(new StackLayout());
+    } else {
+      terminalPanel =
+          new 
org.apache.hop.ui.hopgui.terminal.HopGuiTerminalPanel(mainHopGuiComposite, 
this);
+      FormData fdTerminalPanel = new FormData();
+      fdTerminalPanel.top = new FormAttachment(0, 0);
+      fdTerminalPanel.left = new FormAttachment(perspectivesSidebar, 0);
+      fdTerminalPanel.bottom = new FormAttachment(100, 0);
+      fdTerminalPanel.right = new FormAttachment(100, 0);
+      terminalPanel.setLayoutData(fdTerminalPanel);
+
+      mainPerspectivesComposite = terminalPanel.getPerspectiveComposite();
+      mainPerspectivesComposite.setLayout(new StackLayout());
+    }
   }
 
   public void setUndoMenu(IUndo undoInterface) {
@@ -1473,6 +1853,11 @@ public class HopGui
     if (control == null || control.isDisposed()) {
       return;
     }
+
+    if (control.getData("HOP_TERMINAL_WIDGET") == Boolean.TRUE) {
+      return;
+    }
+
     control.removeKeyListener(keyHandler);
     control.addKeyListener(keyHandler);
 
@@ -1532,249 +1917,97 @@ public class HopGui
     layout.topControl = perspective.getControl();
     mainPerspectivesComposite.layout();
 
-    // Selection is handled by updateSidebarButtonSelection()
-
-    // Update sidebar button selection states
-    updateSidebarButtonSelection(perspective);
-
     // Notify the perspective that it has been activated.
     //
     perspective.perspectiveActivated();
 
     perspectiveManager.notifyPerspectiveActivated(perspective);
-  }
 
-  public boolean isActivePerspective(IHopPerspective perspective) {
-    return activePerspective != null && activePerspective.equals(perspective);
+    refreshBottomToolbarItems();
   }
 
-  /** Update the visual selection state of sidebar buttons when perspective 
changes. */
-  private void updateSidebarButtonSelection(IHopPerspective activePerspective) 
{
-    for (SidebarButton button : sidebarButtons) {
-      button.setSelected(button.perspective.equals(activePerspective));
+  /**
+   * Register an item for the bottom-left sidebar toolbar. Visibility is 
determined by the active
+   * perspective (see {@link SidebarToolbarItemDescriptor}). If the toolbar 
already exists, it is
+   * refreshed immediately.
+   */
+  public void addSidebarToolbarItem(SidebarToolbarItemDescriptor descriptor) {
+    if (descriptor != null && !sidebarToolbarDescriptors.contains(descriptor)) 
{
+      sidebarToolbarDescriptors.add(descriptor);
+      if (bottomToolbar != null && !bottomToolbar.isDisposed()) {
+        refreshBottomToolbarItems();
+      }
     }
   }
 
   /**
-   * Create a styled sidebar button with modern appearance. Features rounded 
corners, hover effects,
-   * and selection colors.
+   * Refresh the bottom sidebar toolbar so only items visible for the current 
perspective are shown.
+   * Items are added in reverse descriptor order so that the second, third, 
etc. buttons appear
+   * above the first (SWT vertical toolbar lays out first-added at top). This 
avoids overlapping and
+   * keeps perspective-specific buttons (e.g. execution) above the 
always-visible ones (e.g.
+   * terminal).
    */
-  private void createStyledSidebarButton(
-      Composite parent, Image image, String tooltip, IHopPerspective 
perspective) {
-
-    SidebarButton button = new SidebarButton(parent, image, tooltip, 
perspective);
-    sidebarButtons.add(button);
-
-    org.eclipse.swt.layout.GridData gd = new org.eclipse.swt.layout.GridData();
-    gd.widthHint = (int) (34 * PropsUi.getNativeZoomFactor());
-    gd.heightHint = (int) (34 * PropsUi.getNativeZoomFactor());
-    button.composite.setLayoutData(gd);
-  }
-
-  /** Custom sidebar button class with hover, selection, and rounded corners */
-  private class SidebarButton {
-    Control composite; // Use Control to allow both Composite (RAP) and Canvas 
(desktop)
-    Image image;
-    IHopPerspective perspective;
-    boolean isHovered = false;
-    boolean isSelected = false;
-
-    Color selectionBg = GuiResource.getInstance().getColorLightBlue();
-    Color hoverBg = GuiResource.getInstance().getColorGray();
-    Color normalBg = GuiResource.getInstance().getWidgetBackGroundColor();
-
-    public SidebarButton(
-        Composite parent, Image image, String tooltip, IHopPerspective 
perspective) {
-      this.image = image;
-      this.perspective = perspective;
-
-      // Create a label inside the composite to display the image (only for 
RAP)
-      final Label imageLabel;
-      if (EnvironmentUtils.getInstance().isWeb()) {
-        // For RAP/web: use Composite with Label child
-        Composite comp = new Composite(parent, SWT.NONE);
-        composite = comp;
-        comp.setToolTipText(tooltip);
-        comp.setBackground(normalBg);
-
-        // Set custom variant for CSS styling in RAP
-        comp.setData("org.eclipse.rap.rwt.customVariant", "sidebarButton");
-
-        // Use GridLayout to center the image without stretching
-        GridLayout layout = new GridLayout(1, false);
-        layout.marginWidth = 0;
-        layout.marginHeight = 0;
-        layout.horizontalSpacing = 0;
-        layout.verticalSpacing = 0;
-        comp.setLayout(layout);
-
-        imageLabel = new Label(comp, SWT.NONE);
-        imageLabel.setImage(image);
-        imageLabel.setBackground(normalBg);
-        imageLabel.setToolTipText(tooltip);
-        imageLabel.setData("org.eclipse.rap.rwt.customVariant", 
"sidebarButton");
+  public void refreshBottomToolbarItems() {
+    if (bottomToolbar == null || bottomToolbar.isDisposed()) {
+      return;
+    }
+    String activePerspectiveId = activePerspective != null ? 
activePerspective.getId() : "";
 
-        // Center the label in the composite
-        GridData gd = new GridData(SWT.CENTER, SWT.CENTER, true, true);
-        imageLabel.setLayoutData(gd);
+    // Collect visible descriptors, then add in reverse order so extra buttons 
go on top
+    List<SidebarToolbarItemDescriptor> visible = new ArrayList<>();
+    for (SidebarToolbarItemDescriptor d : sidebarToolbarDescriptors) {
+      if (!d.isAvailable()) {
+        continue;
+      }
+      boolean show;
+      if (!d.getVisibleForPerspectiveIds().isEmpty()) {
+        show = d.getVisibleForPerspectiveIds().contains(activePerspectiveId);
+      } else if (d.getHiddenForPerspectiveIds().isEmpty()) {
+        show = true;
       } else {
-        Canvas canvas = new Canvas(parent, SWT.NONE);
-        composite = canvas;
-        canvas.setToolTipText(tooltip);
-        canvas.setBackground(normalBg);
-        imageLabel = null;
+        show = !d.getHiddenForPerspectiveIds().contains(activePerspectiveId);
       }
-
-      // Update background colors method for both RAP and desktop
-      final Runnable updateColors =
-          () -> {
-            if (EnvironmentUtils.getInstance().isWeb()) {
-              // For RAP, update composite background color (rounded corners 
applied via CSS)
-              Color bgColor;
-              if (isSelected) {
-                bgColor = selectionBg;
-              } else if (isHovered) {
-                bgColor = hoverBg;
-              } else {
-                bgColor = normalBg;
-              }
-              composite.setBackground(bgColor);
-              // Keep label background transparent/matching to show composite 
background
-              if (imageLabel != null) {
-                imageLabel.setBackground(bgColor);
-              }
-              // Force redraw in RAP
-              composite.redraw();
-              if (composite instanceof Composite) {
-                ((Composite) composite).layout();
-              }
-            } else {
-              // For desktop Canvas, trigger repaint
-              composite.redraw();
-            }
-          };
-
-      // Only add paint listener for desktop SWT (RAP doesn't support it)
-      if (!EnvironmentUtils.getInstance().isWeb()) {
-        composite.addPaintListener(
-            e -> {
-              GC gc = e.gc;
-              Point size = composite.getSize();
-
-              gc.setAntialias(SWT.ON);
-
-              // Choose background color
-              if (isSelected) {
-                gc.setBackground(selectionBg);
-              } else if (isHovered) {
-                gc.setBackground(hoverBg);
-              } else {
-                gc.setBackground(normalBg);
-              }
-
-              // Fill rounded rectangle
-              gc.fillRoundRectangle(4, 4, size.x - 8, size.y - 8, 8, 8);
-
-              // Draw image centered
-              if (image != null && !image.isDisposed()) {
-                Rectangle imgBounds = image.getBounds();
-                int x = (size.x - imgBounds.width) / 2;
-                int y = (size.y - imgBounds.height) / 2;
-                gc.drawImage(image, x, y);
-              }
-            });
+      if (show) {
+        visible.add(d);
       }
+    }
 
-      // Mouse listeners
-      composite.addListener(
-          SWT.MouseEnter,
-          e -> {
-            isHovered = true;
-            updateColors.run();
-          });
-
-      composite.addListener(
-          SWT.MouseExit,
-          e -> {
-            isHovered = false;
-            updateColors.run();
-          });
-
-      composite.addListener(
-          SWT.MouseDown,
-          e -> {
-            // Deselect all other buttons
-            for (SidebarButton btn : sidebarButtons) {
-              btn.setSelected(false);
-            }
-
-            // Select this button
-            setSelected(true);
+    // Dispose existing items
+    for (ToolItem item : bottomToolbar.getItems()) {
+      item.dispose();
+    }
 
-            // Handle perspective activation
-            if (perspective instanceof ExplorerPerspective explorerPerspective
-                && mainPerspectivesComposite != null
-                && !mainPerspectivesComposite.isDisposed()) {
-              StackLayout layout = (StackLayout) 
mainPerspectivesComposite.getLayout();
-              if (layout.topControl == explorerPerspective.getControl()) {
-                explorerPerspective.toggleFileExplorerPanel();
-                return;
-              }
+    // Add in reverse order: last in list becomes first (top) in toolbar so we 
don't overlap
+    for (int i = visible.size() - 1; i >= 0; i--) {
+      SidebarToolbarItemDescriptor d = visible.get(i);
+      ToolItem item = new ToolItem(bottomToolbar, SWT.PUSH);
+      Image img =
+          GuiResource.getInstance().getImage(d.getImagePath(), 
d.getImageSize(), d.getImageSize());
+      item.setImage(img);
+      item.setToolTipText(d.getTooltip());
+      item.setData("descriptor", d);
+      item.addListener(
+          SWT.Selection,
+          event -> {
+            SidebarToolbarItemDescriptor desc =
+                (SidebarToolbarItemDescriptor) item.getData("descriptor");
+            if (desc != null && desc.getOnSelect() != null) {
+              desc.getOnSelect().run();
             }
-            setActivePerspective(perspective);
           });
-
-      // Also attach listeners to the image label for better hit detection 
(RAP only)
-      if (imageLabel != null) {
-        imageLabel.addListener(
-            SWT.MouseEnter,
-            e -> {
-              isHovered = true;
-              updateColors.run();
-            });
-        imageLabel.addListener(
-            SWT.MouseExit,
-            e -> {
-              isHovered = false;
-              updateColors.run();
-            });
-        imageLabel.addListener(
-            SWT.MouseDown,
-            e -> {
-              // Deselect all other buttons
-              for (SidebarButton btn : sidebarButtons) {
-                btn.setSelected(false);
-              }
-
-              // Select this button
-              setSelected(true);
-
-              // Handle perspective activation
-              if (perspective instanceof ExplorerPerspective 
explorerPerspective
-                  && mainPerspectivesComposite != null
-                  && !mainPerspectivesComposite.isDisposed()) {
-                StackLayout layout = (StackLayout) 
mainPerspectivesComposite.getLayout();
-                if (layout.topControl == explorerPerspective.getControl()) {
-                  explorerPerspective.toggleFileExplorerPanel();
-                  return;
-                }
-              }
-              setActivePerspective(perspective);
-            });
-      }
-
-      // Store the update method for later use
-      composite.setData("updateColors", updateColors);
     }
+    bottomToolbar.pack();
+    bottomToolbar.getParent().layout(true, true);
+  }
 
-    public void setSelected(boolean selected) {
-      this.isSelected = selected;
-      if (!composite.isDisposed()) {
-        Runnable updateColors = (Runnable) composite.getData("updateColors");
-        if (updateColors != null) {
-          updateColors.run();
-        }
-      }
+  public boolean isActivePerspective(IHopPerspective perspective) {
+    return activePerspective != null && activePerspective.equals(perspective);
+  }
+
+  /** Update the visual selection state of sidebar buttons when perspective 
changes. */
+  private void updateSidebarButtonSelection(IHopPerspective activePerspective) 
{
+    for (SidebarButton button : sidebarButtons) {
+      button.setSelected(button.perspective.equals(activePerspective));
     }
   }
 
@@ -1863,6 +2096,20 @@ public class HopGui
     return null;
   }
 
+  /** Toggle execution results panel for the currently active pipeline or 
workflow */
+  public void toggleExecutionResults() {
+    HopGuiPipelineGraph pipelineGraph = getActivePipelineGraph();
+    if (pipelineGraph != null) {
+      pipelineGraph.showExecutionResults();
+      return;
+    }
+
+    HopGuiWorkflowGraph workflowGraph = getActiveWorkflowGraph();
+    if (workflowGraph != null) {
+      workflowGraph.showExecutionResults();
+    }
+  }
+
   public static MetadataPerspective getMetadataPerspective() {
     return 
HopGui.getInstance().getPerspectiveManager().findPerspective(MetadataPerspective.class);
   }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
index c202228f1e..e3bfa0fac8 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGuiKeyHandler.java
@@ -68,15 +68,22 @@ public class HopGuiKeyHandler extends KeyAdapter {
     // TODO: allow for keyboard shortcut priorities for certain objects.
     //
 
-    // Ignore shortcuts inside Text or Combo widgets
+    // If the event has already been handled by another listener (e.g. 
terminal), skip it
+    if (!event.doit) {
+      return;
+    }
+
+    // Ignore shortcuts inside Text, Combo, or StyledText widgets (including 
terminal)
     if (event.widget instanceof Text
         || event.widget instanceof Combo
-        || event.widget instanceof CCombo) {
-      // Ignore Copy/Cut/Paste/Select all
-      String keys = new String(new char[] {'a', 'c', 'v', 'x'});
-      if ((event.stateMask & (SWT.CONTROL + SWT.COMMAND)) != 0
-          && keys.indexOf(event.keyCode) >= 0) {
-        return;
+        || event.widget instanceof CCombo
+        || event.widget instanceof org.eclipse.swt.custom.StyledText) {
+      // Ignore Copy/Cut/Paste/Select all - check both keyCode and character
+      if ((event.stateMask & (SWT.CONTROL + SWT.COMMAND)) != 0) {
+        char key = Character.toLowerCase((char) event.keyCode);
+        if (key == 'a' || key == 'c' || key == 'v' || key == 'x') {
+          return;
+        }
       }
       // Ignore DEL and Backspace
       if (event.keyCode == SWT.DEL || event.character == SWT.BS) {
@@ -108,6 +115,11 @@ public class HopGuiKeyHandler extends KeyAdapter {
         if (!control.isVisible()) {
           return false;
         }
+        // Also skip if the event widget (focused widget) is NOT within this 
control's hierarchy
+        // This prevents pipeline/workflow shortcuts from firing when focus is 
in the terminal
+        if (!isWidgetInControlHierarchy(event.widget, control)) {
+          return false;
+        }
       } catch (SWTException e) {
         // Invalid thread: none of our business, bail out
         //
@@ -166,4 +178,30 @@ public class HopGuiKeyHandler extends KeyAdapter {
     }
     return false;
   }
+
+  /**
+   * Check if a widget is within the hierarchy of a control
+   *
+   * @param widget The widget to check (typically the focused widget)
+   * @param control The parent control to check against
+   * @return true if widget is within control's hierarchy, false otherwise
+   */
+  private boolean isWidgetInControlHierarchy(Object widget, Control control) {
+    if (!(widget instanceof Control)) {
+      return false;
+    }
+
+    Control current = (Control) widget;
+    while (current != null) {
+      if (current == control) {
+        return true;
+      }
+      try {
+        current = current.getParent();
+      } catch (Exception e) {
+        return false;
+      }
+    }
+    return false;
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/SidebarToolbarItemDescriptor.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/SidebarToolbarItemDescriptor.java
new file mode 100644
index 0000000000..9815ae8ac4
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/SidebarToolbarItemDescriptor.java
@@ -0,0 +1,74 @@
+/*
+ * 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.hop.ui.hopgui;
+
+import java.util.Collections;
+import java.util.Set;
+import lombok.Builder;
+import lombok.Value;
+
+/**
+ * Descriptor for a button in the bottom-left sidebar toolbar (below the 
perspective buttons).
+ * Visibility is driven by the active perspective so any combination of 
buttons can be shown or
+ * hidden per perspective.
+ *
+ * <p>Visibility rules (evaluated in order):
+ *
+ * <ul>
+ *   <li>If {@link #visibleForPerspectiveIds} is non-empty: button is visible 
only when the active
+ *       perspective id is in that set (e.g. only in file explorer).
+ *   <li>Else: button is visible in all perspectives except those in {@link
+ *       #hiddenForPerspectiveIds} (empty = visible everywhere, e.g. terminal).
+ * </ul>
+ *
+ * This allows e.g. "only in explorer", "always", or "everywhere except X".
+ */
+@Value
+@Builder
+public class SidebarToolbarItemDescriptor {
+
+  /** Unique id for this item (e.g. for extensions to replace or identify). */
+  String id;
+
+  /**
+   * When non-empty: button is visible only when the active perspective id is 
in this set. When
+   * empty: visibility is "all perspectives except {@link 
#hiddenForPerspectiveIds}".
+   */
+  @Builder.Default Set<String> visibleForPerspectiveIds = 
Collections.emptySet();
+
+  /**
+   * Only used when {@link #visibleForPerspectiveIds} is empty. Perspective 
ids in this set hide the
+   * button; empty = visible in all perspectives.
+   */
+  @Builder.Default Set<String> hiddenForPerspectiveIds = 
Collections.emptySet();
+
+  /** Image path (e.g. "ui/images/show-results.svg"). */
+  String imagePath;
+
+  /** Icon size in pixels. */
+  int imageSize;
+
+  /** Tooltip text. */
+  String tooltip;
+
+  /** Called when the button is selected. */
+  Runnable onSelect;
+
+  /** Whether this item is available (e.g. terminal only when not in web). */
+  @Builder.Default boolean available = true;
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
index 94dd7ff987..f4ea9e5e79 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/delegates/HopGuiFileDelegate.java
@@ -318,6 +318,12 @@ public class HopGuiFileDelegate {
     //
     hopGui.auditDelegate.writeLastOpenFiles();
 
+    // Save all open terminal tabs
+    //
+    if (hopGui.getTerminalPanel() != null) {
+      hopGui.getTerminalPanel().saveTerminalsOnShutdown();
+    }
+
     return true;
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index 3cfeed8cef..70d83168aa 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -3575,6 +3575,8 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
 
   public void drawPipelineImage(GC swtGc, int width, int height) {
 
+    if (EnvironmentUtils.getInstance().isWeb()) {}
+
     IGc gc = new SwtGc(swtGc, width, height, iconSize);
     try {
       PropsUi propsUi = PropsUi.getInstance();
@@ -4206,7 +4208,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
 
   /** If the extra tab view at the bottom is empty, we close it. */
   public void checkEmptyExtraView() {
-    if (extraViewTabFolder.getItemCount() == 0) {
+    if (extraViewTabFolder != null && extraViewTabFolder.getItemCount() == 0) {
       disposeExtraView();
     }
   }
@@ -4217,6 +4219,8 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     }
 
     extraViewTabFolder.dispose();
+    extraViewTabFolder = null;
+
     sashForm.layout();
     sashForm.setWeights(100);
 
@@ -4232,15 +4236,13 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     //
     boolean maximized = sashForm.getMaximizedControl() != null;
     if (maximized) {
-      // Minimize
-      //
+      // Restore
       sashForm.setMaximizedControl(null);
       minMaxItem.setImage(GuiResource.getInstance().getImageMaximizePanel());
       minMaxItem.setToolTipText(
           BaseMessages.getString(PKG, 
"PipelineGraph.ExecutionResultsPanel.MaxButton.Tooltip"));
     } else {
       // Maximize
-      //
       sashForm.setMaximizedControl(extraViewTabFolder);
       minMaxItem.setImage(GuiResource.getInstance().getImageMinimizePanel());
       minMaxItem.setToolTipText(
@@ -4268,11 +4270,19 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
   /** Add an extra view to the main composite SashForm */
   public void addExtraView() {
 
-    // Add a tab folder ...
-    //
+    // Always use standalone mode - execution results render in pipeline's own 
sashForm
+    // Add a tab folder in the pipeline's sashForm
     extraViewTabFolder = new CTabFolder(sashForm, SWT.MULTI);
     PropsUi.setLook(extraViewTabFolder, Props.WIDGET_STYLE_TAB);
 
+    // Layout the tab folder to fill its parent
+    FormData fdTabFolder = new FormData();
+    fdTabFolder.left = new FormAttachment(0, 0);
+    fdTabFolder.right = new FormAttachment(100, 0);
+    fdTabFolder.top = new FormAttachment(0, 0);
+    fdTabFolder.bottom = new FormAttachment(100, 0);
+    extraViewTabFolder.setLayoutData(fdTabFolder);
+
     extraViewTabFolder.addMouseListener(
         new MouseAdapter() {
 
@@ -4286,13 +4296,6 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
           }
         });
 
-    FormData fdTabFolder = new FormData();
-    fdTabFolder.left = new FormAttachment(0, 0);
-    fdTabFolder.right = new FormAttachment(100, 0);
-    fdTabFolder.top = new FormAttachment(0, 0);
-    fdTabFolder.bottom = new FormAttachment(100, 0);
-    extraViewTabFolder.setLayoutData(fdTabFolder);
-
     // Create toolbar for close and min/max to the upper right corner...
     //
     ToolBar extraViewToolBar = new ToolBar(extraViewTabFolder, SWT.FLAT);
@@ -4323,6 +4326,8 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
     int height = extraViewToolBar.computeSize(SWT.DEFAULT, SWT.DEFAULT).y;
     extraViewTabFolder.setTabHeight(Math.max(height, 
extraViewTabFolder.getTabHeight()));
 
+    // Refresh layout for standalone mode
+    sashForm.layout(true, true);
     sashForm.setWeights(60, 40);
   }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index 2ec438a9ac..6bdf21a40b 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -3615,11 +3615,19 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
   /** Add an extra view to the main composite SashForm */
   public void addExtraView() {
 
-    // Add a tab folder ...
-    //
+    // Always use standalone mode - execution results render in workflow's own 
sashForm
+    // Add a tab folder in the workflow's sashForm
     extraViewTabFolder = new CTabFolder(sashForm, SWT.MULTI);
     PropsUi.setLook(extraViewTabFolder, Props.WIDGET_STYLE_TAB);
 
+    // Layout the tab folder to fill its parent
+    FormData fdTabFolder = new FormData();
+    fdTabFolder.left = new FormAttachment(0, 0);
+    fdTabFolder.right = new FormAttachment(100, 0);
+    fdTabFolder.top = new FormAttachment(0, 0);
+    fdTabFolder.bottom = new FormAttachment(100, 0);
+    extraViewTabFolder.setLayoutData(fdTabFolder);
+
     extraViewTabFolder.addMouseListener(
         new MouseAdapter() {
 
@@ -3633,13 +3641,6 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
           }
         });
 
-    FormData fdTabFolder = new FormData();
-    fdTabFolder.left = new FormAttachment(0, 0);
-    fdTabFolder.right = new FormAttachment(100, 0);
-    fdTabFolder.top = new FormAttachment(0, 0);
-    fdTabFolder.bottom = new FormAttachment(100, 0);
-    extraViewTabFolder.setLayoutData(fdTabFolder);
-
     // Create toolbar for close and min/max to the upper right corner...
     //
     ToolBar extraViewToolBar = new ToolBar(extraViewTabFolder, SWT.FLAT);
@@ -3670,6 +3671,8 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     int height = extraViewToolBar.computeSize(SWT.DEFAULT, SWT.DEFAULT).y;
     extraViewTabFolder.setTabHeight(Math.max(height, 
extraViewTabFolder.getTabHeight()));
 
+    // Refresh layout for standalone mode
+    sashForm.layout(true, true);
     sashForm.setWeights(new int[] {60, 40});
   }
 
@@ -3691,13 +3694,12 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
 
   /** If the extra tab view at the bottom is empty, we close it. */
   public void checkEmptyExtraView() {
-    if (extraViewTabFolder.getItemCount() == 0) {
+    if (extraViewTabFolder != null && extraViewTabFolder.getItemCount() == 0) {
       disposeExtraView();
     }
   }
 
   private void rotateExtraView() {
-
     // Toggle orientation
     boolean orientation = 
!PropsUi.getInstance().isGraphExtraViewVerticalOrientation();
     PropsUi.getInstance().setGraphExtraViewVerticalOrientation(orientation);
@@ -3717,6 +3719,8 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     }
 
     extraViewTabFolder.dispose();
+    extraViewTabFolder = null;
+
     sashForm.layout();
     sashForm.setWeights(100);
 
@@ -3733,15 +3737,13 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
     //
     boolean maximized = sashForm.getMaximizedControl() != null;
     if (maximized) {
-      // Minimize
-      //
+      // Restore
       sashForm.setMaximizedControl(null);
       minMaxItem.setImage(GuiResource.getInstance().getImageMaximizePanel());
       minMaxItem.setToolTipText(
           BaseMessages.getString(PKG, 
"WorkflowGraph.ExecutionResultsPanel.MaxButton.Tooltip"));
     } else {
       // Maximize
-      //
       sashForm.setMaximizedControl(extraViewTabFolder);
       minMaxItem.setImage(GuiResource.getInstance().getImageMinimizePanel());
       minMaxItem.setToolTipText(
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
index b3d69cbac5..8aab5e4b41 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
@@ -205,6 +205,8 @@ public class ConfigGuiOptionsTab {
     Shell shell = wTabFolder.getShell();
     PropsUi props = PropsUi.getInstance();
     int margin = PropsUi.getMargin();
+    int middle = props.getMiddlePct();
+    int h = (int) (40 * props.getZoomFactor());
 
     CTabItem wLookTab = new CTabItem(wTabFolder, SWT.NONE);
     wLookTab.setFont(GuiResource.getInstance().getFontDefault());
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanel.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanel.java
new file mode 100644
index 0000000000..2929f8c841
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanel.java
@@ -0,0 +1,698 @@
+/*
+ * 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.hop.ui.hopgui.terminal;
+
+import org.apache.commons.lang.StringUtils;
+import org.apache.hop.history.AuditList;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.history.AuditState;
+import org.apache.hop.history.AuditStateMap;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.gui.HopNamespace;
+import org.apache.hop.ui.core.widget.TabFolderReorder;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.perspective.TabClosable;
+import org.apache.hop.ui.hopgui.perspective.TabCloseHandler;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabFolder2Adapter;
+import org.eclipse.swt.custom.CTabFolderEvent;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.swt.widgets.ToolBar;
+import org.eclipse.swt.widgets.ToolItem;
+
+/**
+ * Terminal panel for Hop GUI providing integrated command-line access.
+ *
+ * <p>The panel wraps the main perspectives composite in a SashForm, with 
perspectives in the top
+ * section and the terminal panel in the bottom section. The terminal panel 
persists across
+ * perspective switches.
+ */
+public class HopGuiTerminalPanel extends Composite implements TabClosable {
+
+  private final HopGui hopGui;
+
+  private SashForm verticalSash;
+  private Composite perspectiveComposite;
+  private Composite bottomPanelComposite;
+  private Composite terminalComposite;
+  private CTabFolder terminalTabs;
+  private CTabItem newTerminalTab;
+
+  private boolean terminalVisible = false;
+  private int terminalHeightPercent = 35;
+  private boolean isClearing = false;
+  private int terminalCounter = 1;
+
+  private static final String TERMINAL_AUDIT_TYPE = "terminal";
+
+  // State map keys
+  private static final String STATE_TAB_NAME = "tabName";
+  private static final String STATE_SHELL_PATH = "shellPath";
+  private static final String STATE_WORKING_DIR = "workingDirectory";
+
+  /**
+   * Constructor - Creates the terminal panel structure
+   *
+   * @param parent The parent composite (mainHopGuiComposite from HopGui)
+   * @param hopGui The HopGui instance
+   */
+  public HopGuiTerminalPanel(Composite parent, HopGui hopGui) {
+    super(parent, SWT.NONE);
+    this.hopGui = hopGui;
+
+    createContents();
+  }
+
+  /** Create the UI structure */
+  private void createContents() {
+    setLayout(new FormLayout());
+
+    verticalSash = new SashForm(this, SWT.VERTICAL | SWT.SMOOTH);
+    FormData fdSash = new FormData();
+    fdSash.left = new FormAttachment(0, 0);
+    fdSash.top = new FormAttachment(0, 0);
+    fdSash.right = new FormAttachment(100, 0);
+    fdSash.bottom = new FormAttachment(100, 0);
+    verticalSash.setLayoutData(fdSash);
+
+    perspectiveComposite = new Composite(verticalSash, SWT.NONE);
+    perspectiveComposite.setLayout(new FormLayout());
+
+    bottomPanelComposite = new Composite(verticalSash, SWT.NONE);
+    bottomPanelComposite.setLayout(new FormLayout());
+    createBottomPanel();
+
+    verticalSash.setMaximizedControl(perspectiveComposite);
+  }
+
+  /** Create the bottom panel with terminal */
+  private void createBottomPanel() {
+    // Terminal area directly in bottom panel composite
+    terminalComposite = new Composite(bottomPanelComposite, SWT.NONE);
+    terminalComposite.setLayout(new FormLayout());
+
+    FormData fdTerminal = new FormData();
+    fdTerminal.left = new FormAttachment(0, 0);
+    fdTerminal.top = new FormAttachment(0, 0);
+    fdTerminal.right = new FormAttachment(100, 0);
+    fdTerminal.bottom = new FormAttachment(100, 0);
+    terminalComposite.setLayoutData(fdTerminal);
+
+    createTerminalArea();
+  }
+
+  /** Create the terminal area with tab folder */
+  private void createTerminalArea() {
+    terminalTabs = new CTabFolder(terminalComposite, SWT.MULTI | SWT.BORDER);
+    PropsUi.setLook(terminalTabs, PropsUi.WIDGET_STYLE_TAB);
+    FormData fdTabs = new FormData();
+    fdTabs.left = new FormAttachment(0, 0);
+    fdTabs.top = new FormAttachment(0, 0);
+    fdTabs.right = new FormAttachment(100, 0);
+    fdTabs.bottom = new FormAttachment(100, 0);
+    terminalTabs.setLayoutData(fdTabs);
+
+    createTerminalToolbar();
+
+    newTerminalTab = new CTabItem(terminalTabs, SWT.NONE);
+    newTerminalTab.setText("+");
+    newTerminalTab.setToolTipText("Create a new terminal");
+    Composite newTerminalPlaceholder = new Composite(terminalTabs, SWT.NONE);
+    newTerminalTab.setControl(newTerminalPlaceholder);
+
+    new TabCloseHandler(this);
+    new TabFolderReorder(terminalTabs);
+
+    final boolean[] isClosingTab = {false};
+    terminalTabs.addListener(
+        SWT.Selection,
+        event -> {
+          CTabItem item = terminalTabs.getSelection();
+          if (item == newTerminalTab) {
+            if (isClearing || isClosingTab[0]) {
+              return;
+            }
+            createNewTerminal(null, null);
+            return;
+          }
+
+          if (item != null) {
+            ITerminalWidget widget = (ITerminalWidget) 
item.getData("terminalWidget");
+            if (widget != null && widget instanceof JediTerminalWidget) {
+              Composite composite = widget.getTerminalComposite();
+              if (composite != null && !composite.isDisposed()) {
+                composite.forceFocus();
+              }
+            }
+          }
+        });
+
+    terminalTabs.addCTabFolder2Listener(
+        new CTabFolder2Adapter() {
+          @Override
+          public void close(CTabFolderEvent event) {
+            isClosingTab[0] = true;
+            try {
+              CTabItem item = (CTabItem) event.item;
+              if (item == newTerminalTab) {
+                event.doit = false;
+                return;
+              }
+              closeTab(event, item);
+            } finally {
+              getDisplay()
+                  .asyncExec(
+                      () -> {
+                        isClosingTab[0] = false;
+                      });
+            }
+          }
+        });
+    terminalTabs.addListener(
+        SWT.MouseDoubleClick,
+        event -> {
+          CTabItem item = terminalTabs.getSelection();
+          if (item != null && item != newTerminalTab) {
+            renameTerminalTab(item);
+          }
+        });
+  }
+
+  public void createNewTerminal(String workingDirectory, String shellPath) {
+    createNewTerminal(workingDirectory, shellPath, null);
+  }
+
+  public void createNewTerminal(String workingDirectory, String shellPath, 
String customTabName) {
+    if (shellPath == null) {
+      shellPath = TerminalShellDetector.detectDefaultShell();
+    }
+
+    if (workingDirectory == null) {
+      workingDirectory = getDefaultWorkingDirectory();
+    }
+
+    CTabItem terminalTab = new CTabItem(terminalTabs, SWT.CLOSE, 1);
+
+    String terminalId = "terminal-" + terminalCounter++ + "-" + 
System.currentTimeMillis();
+
+    if (customTabName != null && !customTabName.trim().isEmpty()) {
+      terminalTab.setText(customTabName);
+    } else {
+      String shellName = extractShellName(shellPath);
+      terminalTab.setText(shellName + " (" + (terminalCounter - 1) + ")");
+    }
+    terminalTab.setImage(GuiResource.getInstance().getImageTerminal());
+    terminalTab.setToolTipText("Terminal: " + shellPath + " in " + 
workingDirectory);
+
+    terminalTab.setData("terminalId", terminalId);
+    terminalTab.setData("workingDirectory", workingDirectory);
+    terminalTab.setData("shellPath", shellPath);
+
+    Composite terminalWidgetComposite = new Composite(terminalTabs, SWT.NONE);
+    terminalWidgetComposite.setLayout(new FormLayout());
+    terminalTab.setControl(terminalWidgetComposite);
+
+    ITerminalWidget terminalWidget =
+        new JediTerminalWidget(terminalWidgetComposite, shellPath, 
workingDirectory);
+
+    terminalTab.setData("terminalWidget", terminalWidget);
+
+    updateTabTextWithTerminalType(terminalTab, terminalWidget);
+
+    registerTerminal(terminalId, workingDirectory, shellPath);
+
+    terminalTabs.setSelection(terminalTab);
+
+    if (!terminalVisible) {
+      showTerminal();
+    }
+
+    getDisplay()
+        .asyncExec(
+            () -> {
+              if (terminalWidget == null) {
+                return;
+              }
+
+              if (terminalWidget instanceof JediTerminalWidget) {
+                Composite composite = terminalWidget.getTerminalComposite();
+                if (composite != null && !composite.isDisposed()) {
+                  composite.setFocus();
+                  composite.forceFocus();
+                }
+              }
+            });
+  }
+
+  /** Extract shell name from full path (e.g., "/bin/bash" -> "bash") */
+  private String extractShellName(String shellPath) {
+    if (shellPath == null || shellPath.isEmpty()) {
+      return "Terminal";
+    }
+
+    // Handle Windows paths
+    if (shellPath.contains("\\")) {
+      int lastBackslash = shellPath.lastIndexOf('\\');
+      shellPath = shellPath.substring(lastBackslash + 1);
+    }
+
+    // Handle Unix paths
+    if (shellPath.contains("/")) {
+      int lastSlash = shellPath.lastIndexOf('/');
+      shellPath = shellPath.substring(lastSlash + 1);
+    }
+
+    // Remove .exe extension
+    if (shellPath.endsWith(".exe")) {
+      shellPath = shellPath.substring(0, shellPath.length() - 4);
+    }
+
+    return shellPath;
+  }
+
+  private void updateTabTextWithTerminalType(CTabItem terminalTab, 
ITerminalWidget terminalWidget) {
+    if (terminalTab == null || terminalWidget == null) {
+      return;
+    }
+
+    String currentText = terminalTab.getText();
+    String indicator = " [JT]";
+
+    if (!currentText.contains(indicator)) {
+      terminalTab.setText(currentText + indicator);
+    }
+  }
+
+  /** Show the terminal panel */
+  public void showTerminal() {
+    if (!terminalVisible) {
+      verticalSash.setMaximizedControl(null);
+      int perspectivePercent = 100 - terminalHeightPercent;
+      verticalSash.setWeights(new int[] {perspectivePercent, 
terminalHeightPercent});
+      terminalVisible = true;
+
+      if (terminalTabs.getItemCount() <= 1) {
+        createNewTerminal(null, null);
+      }
+
+      layout(true, true);
+    }
+  }
+
+  /** Check if terminal panel is currently visible */
+  public boolean isTerminalVisible() {
+    return terminalVisible;
+  }
+
+  /** Hide the terminal panel */
+  public void hideTerminal() {
+    if (terminalVisible) {
+      terminalVisible = false;
+      verticalSash.setMaximizedControl(perspectiveComposite);
+      layout(true, true);
+    }
+  }
+
+  /** Toggle terminal panel visibility */
+  public void toggleTerminal() {
+    if (terminalVisible) {
+      hideTerminal();
+    } else {
+      showTerminal();
+    }
+  }
+
+  /** Close a terminal tab (implements TabClosable interface) */
+  @Override
+  public void closeTab(CTabFolderEvent event, CTabItem tabItem) {
+    if (tabItem == newTerminalTab) {
+      if (event != null) {
+        event.doit = false;
+      }
+      return;
+    }
+
+    ITerminalWidget widget = (ITerminalWidget) 
tabItem.getData("terminalWidget");
+    if (widget != null) {
+      widget.dispose();
+    }
+
+    String terminalId = (String) tabItem.getData("terminalId");
+    if (terminalId != null) {
+      unregisterTerminal(terminalId);
+    }
+
+    tabItem.dispose();
+  }
+
+  /** Get the terminal tabs folder (implements TabClosable interface) */
+  @Override
+  public CTabFolder getTabFolder() {
+    return terminalTabs;
+  }
+
+  /** Get all tabs to the right (excluding the + tab) */
+  @Override
+  public java.util.List<CTabItem> getTabsToRight(CTabItem selectedTabItem) {
+    java.util.List<CTabItem> items = new java.util.ArrayList<>();
+    for (int i = getTabFolder().getItems().length - 1; i >= 0; i--) {
+      CTabItem item = getTabFolder().getItems()[i];
+      if (selectedTabItem.equals(item)) {
+        break;
+      } else if (item != newTerminalTab) {
+        items.add(item);
+      }
+    }
+    return items;
+  }
+
+  /** Get all tabs to the left (excluding the + tab) */
+  @Override
+  public java.util.List<CTabItem> getTabsToLeft(CTabItem selectedTabItem) {
+    java.util.List<CTabItem> items = new java.util.ArrayList<>();
+    for (CTabItem item : getTabFolder().getItems()) {
+      if (selectedTabItem.equals(item)) {
+        break;
+      } else if (item != newTerminalTab) {
+        items.add(item);
+      }
+    }
+    return items;
+  }
+
+  /** Get all other tabs (excluding the + tab) */
+  @Override
+  public java.util.List<CTabItem> getOtherTabs(CTabItem selectedTabItem) {
+    java.util.List<CTabItem> items = new java.util.ArrayList<>();
+    for (CTabItem item : getTabFolder().getItems()) {
+      if (!selectedTabItem.equals(item) && item != newTerminalTab) {
+        items.add(item);
+      }
+    }
+    return items;
+  }
+
+  /** Create toolbar with panel controls (maximize/minimize, close) */
+  private void createTerminalToolbar() {
+    ToolBar toolBar = new ToolBar(terminalTabs, SWT.FLAT);
+    terminalTabs.setTopRight(toolBar, SWT.RIGHT);
+    PropsUi.setLook(toolBar);
+
+    GuiResource gui = GuiResource.getInstance();
+    if (PropsUi.getInstance().isDarkMode()) {
+      toolBar.setBackground(gui.getColorWhite());
+    } else {
+      toolBar.setBackground(terminalTabs.getBackground());
+    }
+
+    // Maximize/Minimize button
+    final ToolItem maximizeItem = new ToolItem(toolBar, SWT.PUSH);
+    maximizeItem.setImage(GuiResource.getInstance().getImageMaximizePanel());
+    maximizeItem.setToolTipText("Maximize terminal panel");
+    maximizeItem.addListener(
+        SWT.Selection,
+        e -> {
+          if (verticalSash.getMaximizedControl() == null) {
+            // Maximize terminal panel
+            verticalSash.setMaximizedControl(bottomPanelComposite);
+            
maximizeItem.setImage(GuiResource.getInstance().getImageMinimizePanel());
+            maximizeItem.setToolTipText("Restore terminal panel");
+          } else {
+            // Restore normal split
+            verticalSash.setMaximizedControl(null);
+            verticalSash.setWeights(new int[] {100 - terminalHeightPercent, 
terminalHeightPercent});
+            
maximizeItem.setImage(GuiResource.getInstance().getImageMaximizePanel());
+            maximizeItem.setToolTipText("Maximize terminal panel");
+          }
+        });
+
+    // Close button
+    final ToolItem closeItem = new ToolItem(toolBar, SWT.PUSH);
+    closeItem.setImage(GuiResource.getInstance().getImageClose());
+    closeItem.setToolTipText("Close terminal panel");
+    closeItem.addListener(SWT.Selection, e -> hideTerminal());
+
+    int height = toolBar.computeSize(SWT.DEFAULT, SWT.DEFAULT).y;
+    terminalTabs.setTabHeight(Math.max(height, terminalTabs.getTabHeight()));
+  }
+
+  /** Rename a terminal tab via dialog */
+  private void renameTerminalTab(CTabItem item) {
+    if (item == null || item == newTerminalTab) {
+      return;
+    }
+
+    final Text text = new Text(terminalTabs, SWT.BORDER);
+    text.setText(item.getText());
+
+    org.eclipse.swt.graphics.Rectangle bounds = item.getBounds();
+    text.setBounds(bounds.x, bounds.y, bounds.width, bounds.height);
+    text.moveAbove(null);
+
+    text.setFocus();
+    text.selectAll();
+
+    text.addListener(
+        SWT.Traverse,
+        event -> {
+          if (event.detail == SWT.TRAVERSE_RETURN) {
+            String newName = text.getText().trim();
+            if (!newName.isEmpty()) {
+              item.setText(newName);
+              saveOpenTerminals();
+            }
+            text.dispose();
+            event.doit = false;
+          } else if (event.detail == SWT.TRAVERSE_ESCAPE) {
+            text.dispose();
+            event.doit = false;
+          }
+        });
+
+    text.addListener(
+        SWT.FocusOut,
+        event -> {
+          if (!text.isDisposed()) {
+            String newName = text.getText().trim();
+            if (!newName.isEmpty()) {
+              item.setText(newName);
+              saveOpenTerminals();
+            }
+            text.dispose();
+          }
+        });
+  }
+
+  /** Save terminals on shutdown */
+  public void saveTerminalsOnShutdown() {
+    saveOpenTerminals();
+  }
+
+  /** Save all open terminals */
+  private void saveOpenTerminals() {
+    try {
+      java.util.List<String> terminalIds = new java.util.ArrayList<>();
+      AuditStateMap stateMap = new AuditStateMap();
+
+      for (CTabItem item : terminalTabs.getItems()) {
+        if (item == newTerminalTab) {
+          continue;
+        }
+        String terminalId = (String) item.getData("terminalId");
+        if (terminalId != null) {
+          terminalIds.add(terminalId);
+
+          java.util.Map<String, Object> state = new java.util.HashMap<>();
+          state.put(STATE_TAB_NAME, item.getText());
+          state.put(STATE_WORKING_DIR, item.getData("workingDirectory"));
+          state.put(STATE_SHELL_PATH, item.getData("shellPath"));
+
+          stateMap.add(new AuditState(terminalId, state));
+        }
+      }
+
+      AuditList auditList = new AuditList(terminalIds);
+      AuditManager.getActive()
+          .storeList(HopNamespace.getNamespace(), TERMINAL_AUDIT_TYPE, 
auditList);
+
+      AuditManager.getActive()
+          .saveAuditStateMap(HopNamespace.getNamespace(), TERMINAL_AUDIT_TYPE, 
stateMap);
+
+      hopGui
+          .getLog()
+          .logDebug("Saved " + terminalIds.size() + " open terminal(s) for 
current project");
+    } catch (Exception e) {
+      hopGui.getLog().logError("Error saving open terminals", e);
+    }
+  }
+
+  /** Clear all terminals */
+  public void clearAllTerminals() {
+    if (isDisposed() || terminalTabs == null || terminalTabs.isDisposed()) {
+      hopGui.getLog().logDebug("clearAllTerminals: skipped (disposed or not 
initialized)");
+      return;
+    }
+
+    isClearing = true;
+
+    try {
+      saveOpenTerminals();
+
+      java.util.List<CTabItem> itemsToClose = new java.util.ArrayList<>();
+      for (CTabItem item : terminalTabs.getItems()) {
+        if (item != newTerminalTab && !item.isDisposed()) {
+          itemsToClose.add(item);
+        }
+      }
+
+      for (CTabItem item : itemsToClose) {
+        if (!item.isDisposed()) {
+          ITerminalWidget widget = (ITerminalWidget) 
item.getData("terminalWidget");
+          if (widget != null) {
+            widget.dispose();
+          }
+          item.dispose();
+        }
+      }
+
+      if (terminalVisible) {
+        hideTerminal();
+      }
+    } finally {
+      isClearing = false;
+    }
+  }
+
+  private void registerTerminal(String terminalId, String workingDirectory, 
String shellPath) {
+    // Terminal state is saved on shutdown
+  }
+
+  private void unregisterTerminal(String terminalId) {
+    saveOpenTerminals();
+  }
+
+  private String getDefaultWorkingDirectory() {
+    try {
+      String projectHome = hopGui.getVariables().getVariable("PROJECT_HOME");
+      if (StringUtils.isNotEmpty(projectHome)) {
+        projectHome = hopGui.getVariables().resolve(projectHome);
+        if (StringUtils.isNotEmpty(projectHome)) {
+          return projectHome;
+        }
+      }
+    } catch (Exception e) {
+      // Ignore
+    }
+
+    return System.getProperty("user.home");
+  }
+
+  /** Restore terminals from previous session */
+  public void restoreTerminals() {
+    try {
+      String namespace = HopNamespace.getNamespace();
+
+      int existingCount = 0;
+      for (CTabItem item : terminalTabs.getItems()) {
+        if (item != newTerminalTab) {
+          existingCount++;
+        }
+      }
+
+      if (existingCount > 0) {
+        return;
+      }
+
+      AuditList auditList = AuditManager.getActive().retrieveList(namespace, 
TERMINAL_AUDIT_TYPE);
+
+      if (auditList.getNames().isEmpty()) {
+        return;
+      }
+
+      AuditStateMap stateMap;
+      try {
+        stateMap =
+            AuditManager.getActive()
+                .loadAuditStateMap(HopNamespace.getNamespace(), 
TERMINAL_AUDIT_TYPE);
+      } catch (Exception e) {
+        hopGui.getLog().logError("Error loading terminal state map", e);
+        stateMap = new AuditStateMap();
+      }
+
+      for (String terminalId : auditList.getNames()) {
+        String customTabName = null;
+        String workingDir = null;
+        String shellPath = null;
+
+        AuditState state = stateMap.get(terminalId);
+        if (state != null && state.getStateMap() != null) {
+          Object tabNameObj = state.getStateMap().get(STATE_TAB_NAME);
+          if (tabNameObj != null) {
+            customTabName = tabNameObj.toString();
+          }
+          Object workingDirObj = state.getStateMap().get(STATE_WORKING_DIR);
+          if (workingDirObj != null) {
+            workingDir = workingDirObj.toString();
+          }
+          Object shellPathObj = state.getStateMap().get(STATE_SHELL_PATH);
+          if (shellPathObj != null) {
+            shellPath = shellPathObj.toString();
+          }
+        }
+
+        createNewTerminal(workingDir, shellPath, customTabName);
+      }
+    } catch (Exception e) {
+      hopGui.getLog().logError("Error restoring terminals", e);
+    }
+  }
+
+  /** Get the perspective composite */
+  public Composite getPerspectiveComposite() {
+    return perspectiveComposite;
+  }
+
+  /** Get the terminal tabs folder */
+  public CTabFolder getTerminalTabs() {
+    return terminalTabs;
+  }
+
+  /** Set terminal height percentage */
+  public void setTerminalHeightPercent(int percent) {
+    if (percent > 0 && percent < 100) {
+      this.terminalHeightPercent = percent;
+      if (terminalVisible) {
+        int perspectivePercent = 100 - terminalHeightPercent;
+        verticalSash.setWeights(new int[] {perspectivePercent, 
terminalHeightPercent});
+      }
+    }
+  }
+
+  /** Get current terminal height percentage */
+  public int getTerminalHeightPercent() {
+    return terminalHeightPercent;
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/ITerminalWidget.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/ITerminalWidget.java
new file mode 100644
index 0000000000..042615b213
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/ITerminalWidget.java
@@ -0,0 +1,70 @@
+/*
+ * 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.hop.ui.hopgui.terminal;
+
+import org.eclipse.swt.custom.StyledText;
+import org.eclipse.swt.widgets.Composite;
+
+/** Common interface for terminal widget implementations. */
+public interface ITerminalWidget {
+
+  /**
+   * Get the terminal composite widget
+   *
+   * @return The SWT composite containing the terminal
+   */
+  Composite getTerminalComposite();
+
+  /** Dispose of terminal resources */
+  void dispose();
+
+  /**
+   * Check if terminal is connected/running
+   *
+   * @return true if terminal process is alive
+   */
+  boolean isConnected();
+
+  /**
+   * Get the shell path being used
+   *
+   * @return Path to shell executable (e.g. /bin/zsh)
+   */
+  String getShellPath();
+
+  /**
+   * Get the working directory
+   *
+   * @return Current working directory
+   */
+  String getWorkingDirectory();
+
+  /**
+   * Get the output text widget for focus/copy operations
+   *
+   * @return The StyledText widget showing output
+   */
+  StyledText getOutputText();
+
+  /**
+   * Get terminal type description
+   *
+   * @return Terminal type string
+   */
+  String getTerminalType();
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/JediTerminalWidget.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/JediTerminalWidget.java
new file mode 100644
index 0000000000..b57b75e54e
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/JediTerminalWidget.java
@@ -0,0 +1,451 @@
+/*
+ * 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.hop.ui.hopgui.terminal;
+
+import com.jediterm.terminal.TerminalColor;
+import com.jediterm.terminal.TextStyle;
+import com.jediterm.terminal.TtyConnector;
+import com.jediterm.terminal.ui.JediTermWidget;
+import com.jediterm.terminal.ui.settings.DefaultSettingsProvider;
+import com.pty4j.PtyProcess;
+import com.pty4j.PtyProcessBuilder;
+import com.pty4j.WinSize;
+import java.awt.Frame;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.ui.core.PropsUi;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.awt.SWT_AWT;
+import org.eclipse.swt.custom.StyledText;
+import org.eclipse.swt.graphics.FontData;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Display;
+
+/** JediTerm-based terminal widget using SWT-AWT bridge for embedding AWT 
components in SWT. */
+public class JediTerminalWidget implements ITerminalWidget {
+
+  private static final LogChannel log = new LogChannel("JediTerminal");
+
+  private final String shellPath;
+  private final String workingDirectory;
+
+  private Composite bridgeComposite;
+  private Frame awtFrame;
+  private JediTermWidget jediTermWidget;
+  private PtyProcess ptyProcess;
+  private Pty4JTtyConnector ttyConnector;
+
+  public JediTerminalWidget(Composite parent, String shellPath, String 
workingDirectory) {
+    this.shellPath = shellPath;
+    this.workingDirectory = workingDirectory;
+
+    createWidget(parent, parent.getDisplay());
+
+    // Defer shell process start to avoid blocking UI
+    parent
+        .getDisplay()
+        .asyncExec(
+            () -> {
+              if (!bridgeComposite.isDisposed()) {
+                startShellProcess();
+              }
+            });
+  }
+
+  public void createWidget(Composite parent, Display display) {
+    // Create SWT_AWT bridge composite
+    bridgeComposite = new Composite(parent, SWT.EMBEDDED | SWT.NO_BACKGROUND);
+    PropsUi.setLook(bridgeComposite);
+
+    // Layout the bridge composite to fill parent
+    FormData fd = new FormData();
+    fd.left = new FormAttachment(0, 0);
+    fd.right = new FormAttachment(100, 0);
+    fd.top = new FormAttachment(0, 0);
+    fd.bottom = new FormAttachment(100, 0);
+    bridgeComposite.setLayoutData(fd);
+
+    // Create AWT Frame for JediTerm
+    awtFrame = SWT_AWT.new_Frame(bridgeComposite);
+
+    // Resize AWT frame when composite resizes
+    bridgeComposite.addListener(
+        SWT.Resize,
+        event -> {
+          if (bridgeComposite.isDisposed() || awtFrame == null) {
+            return;
+          }
+          try {
+            Rectangle rect = bridgeComposite.getClientArea();
+            java.awt.EventQueue.invokeLater(
+                () -> {
+                  if (awtFrame != null) {
+                    try {
+                      awtFrame.setSize(rect.width, rect.height);
+                      awtFrame.validate();
+                    } catch (Exception e) {
+                      log.logDebug("Error resizing AWT frame: " + 
e.getMessage());
+                    }
+                  }
+                });
+          } catch (Exception e) {
+            log.logDebug("Error in resize handler: " + e.getMessage());
+          }
+        });
+
+    // Create JediTerm widget with Hop dark mode support
+    boolean isDarkMode = PropsUi.getInstance().isDarkMode();
+    DefaultSettingsProvider settings = createHopSettingsProvider(isDarkMode, 
display);
+    jediTermWidget = new JediTermWidget(settings);
+    awtFrame.add(jediTermWidget);
+
+    awtFrame.setVisible(true);
+
+    // Defer focus until after terminal initialization
+    bridgeComposite.addListener(
+        org.eclipse.swt.SWT.FocusIn,
+        event -> {
+          display.asyncExec(
+              () -> {
+                if (bridgeComposite.isDisposed() || jediTermWidget == null) {
+                  return;
+                }
+                new Thread(
+                        () -> {
+                          try {
+                            java.awt.EventQueue.invokeLater(
+                                () -> {
+                                  if (jediTermWidget != null) {
+                                    try {
+                                      jediTermWidget.requestFocusInWindow();
+                                    } catch (Exception e) {
+                                      log.logDebug("Could not request focus: " 
+ e.getMessage());
+                                    }
+                                  }
+                                });
+                          } catch (Exception e) {
+                            log.logDebug("Error requesting AWT focus: " + 
e.getMessage());
+                          }
+                        })
+                    .start();
+              });
+        });
+  }
+
+  /** Create SettingsProvider with Hop theme and font scaling */
+  private DefaultSettingsProvider createHopSettingsProvider(
+      final boolean isDarkMode, final Display display) {
+    // Get the SWT system font size as base
+    FontData systemFontData = display.getSystemFont().getFontData()[0];
+    int swtFontSize = systemFontData.getHeight();
+
+    // Get font scale from system property, default 1.0
+    float fontScale = 1.0f;
+    String manualScale = System.getProperty("JediTerm.fontScale");
+    if (manualScale != null && !manualScale.isEmpty()) {
+      try {
+        fontScale = Float.parseFloat(manualScale);
+        log.logBasic("JediTerm: Using font scale from system property: " + 
fontScale);
+      } catch (NumberFormatException e) {
+        log.logError("JediTerm: Invalid JediTerm.fontScale value: " + 
manualScale, e);
+      }
+    }
+
+    final int targetFontSize = Math.round(swtFontSize * fontScale);
+
+    return new DefaultSettingsProvider() {
+      @Override
+      public TerminalColor getDefaultBackground() {
+        if (Const.isWindows() && !isDarkMode) {
+          // Windows light mode: PowerShell blue background
+          return new TerminalColor(1, 36, 86);
+        } else if (Const.isWindows() && isDarkMode) {
+          // Windows dark mode: dark gray background
+          return new TerminalColor(43, 43, 43);
+        } else if (isDarkMode) {
+          // Mac/Linux dark mode: dark gray background
+          return new TerminalColor(43, 43, 43);
+        } else {
+          // Mac/Linux light mode: white background
+          return new TerminalColor(255, 255, 255);
+        }
+      }
+
+      @Override
+      public TerminalColor getDefaultForeground() {
+        if (Const.isWindows() && !isDarkMode) {
+          // Windows light mode: PowerShell gray foreground
+          return new TerminalColor(204, 204, 204);
+        } else if (Const.isWindows() && isDarkMode) {
+          // Windows dark mode: light gray foreground
+          return new TerminalColor(187, 187, 187);
+        } else if (isDarkMode) {
+          // Mac/Linux dark mode: light gray foreground
+          return new TerminalColor(187, 187, 187);
+        } else {
+          // Mac/Linux light mode: black foreground
+          return new TerminalColor(0, 0, 0);
+        }
+      }
+
+      @Override
+      public TextStyle getFoundPatternColor() {
+        TerminalColor bg = new TerminalColor(255, 255, 0);
+        TerminalColor fg = new TerminalColor(0, 0, 0);
+        return new TextStyle(fg, bg);
+      }
+
+      @Override
+      public TextStyle getHyperlinkColor() {
+        TerminalColor linkColor =
+            isDarkMode ? new TerminalColor(96, 161, 255) : new 
TerminalColor(0, 0, 238);
+        return new TextStyle(linkColor, null);
+      }
+
+      @Override
+      public java.awt.Font getTerminalFont() {
+        return new java.awt.Font("Monospaced", java.awt.Font.PLAIN, 
targetFontSize);
+      }
+
+      @Override
+      public float getTerminalFontSize() {
+        return (float) targetFontSize;
+      }
+    };
+  }
+
+  public void startShellProcess() {
+    try {
+      // Build PTY process
+      String[] command = getShellCommand();
+      Map<String, String> env = new HashMap<>(System.getenv());
+      env.put("TERM", "xterm-256color");
+
+      PtyProcessBuilder builder =
+          new PtyProcessBuilder()
+              .setCommand(command)
+              .setDirectory(workingDirectory)
+              .setEnvironment(env)
+              .setInitialColumns(80)
+              .setInitialRows(24);
+
+      ptyProcess = builder.start();
+
+      // Create connector to bridge Pty4J and JediTerm
+      ttyConnector = new Pty4JTtyConnector(ptyProcess);
+
+      // Start the terminal session
+      jediTermWidget.setTtyConnector(ttyConnector);
+      jediTermWidget.start();
+
+      // Request focus after terminal initialization
+      new Thread(
+              () -> {
+                try {
+                  Thread.sleep(100);
+                  java.awt.EventQueue.invokeLater(
+                      () -> {
+                        if (jediTermWidget != null) {
+                          try {
+                            jediTermWidget.requestFocusInWindow();
+                          } catch (Exception e) {
+                            log.logDebug("Could not request initial focus: " + 
e.getMessage());
+                          }
+                        }
+                      });
+                } catch (InterruptedException e) {
+                  Thread.currentThread().interrupt();
+                } catch (Exception e) {
+                  log.logDebug("Error requesting initial AWT focus: " + 
e.getMessage());
+                }
+              })
+          .start();
+
+    } catch (Exception e) {
+      log.logError("Error starting JediTerm shell process", e);
+    }
+  }
+
+  private String[] getShellCommand() {
+    String shell = shellPath != null ? shellPath : 
TerminalShellDetector.detectDefaultShell();
+
+    if (Const.isWindows()) {
+      if (shell.toLowerCase().contains("powershell")) {
+        return new String[] {shell, "-NoLogo"};
+      } else {
+        return new String[] {shell};
+      }
+    } else {
+      return new String[] {shell, "-i", "-l"};
+    }
+  }
+
+  public void sendRawInput(String input) {
+    // JediTerm handles input directly
+  }
+
+  @Override
+  public void dispose() {
+    try {
+      if (bridgeComposite != null && !bridgeComposite.isDisposed()) {
+        bridgeComposite.dispose();
+      }
+
+      // Clean up PTY in background thread
+      final JediTermWidget termWidget = jediTermWidget;
+      final Pty4JTtyConnector connector = ttyConnector;
+      final PtyProcess process = ptyProcess;
+
+      new Thread(
+              () -> {
+                try {
+                  if (termWidget != null) {
+                    termWidget.stop();
+                  }
+                  if (connector != null) {
+                    connector.close();
+                  }
+                  if (process != null && process.isAlive()) {
+                    process.destroy();
+                  }
+                } catch (Exception e) {
+                  log.logError("Error cleaning up JediTerm PTY", e);
+                }
+              })
+          .start();
+
+    } catch (Exception e) {
+      log.logError("Error disposing JediTerm", e);
+    }
+  }
+
+  @Override
+  public Composite getTerminalComposite() {
+    return bridgeComposite;
+  }
+
+  @Override
+  public StyledText getOutputText() {
+    // JediTerm uses AWT, not StyledText
+    return null;
+  }
+
+  @Override
+  public String getTerminalType() {
+    return "JediTerm (POC)";
+  }
+
+  @Override
+  public boolean isConnected() {
+    return ptyProcess != null && ptyProcess.isAlive();
+  }
+
+  @Override
+  public String getShellPath() {
+    return shellPath;
+  }
+
+  @Override
+  public String getWorkingDirectory() {
+    return workingDirectory;
+  }
+
+  /** Adapter connecting Pty4J to JediTerm's TtyConnector */
+  private static class Pty4JTtyConnector implements TtyConnector {
+
+    private final PtyProcess ptyProcess;
+    private final InputStream inputStream;
+    private final OutputStream outputStream;
+
+    public Pty4JTtyConnector(PtyProcess ptyProcess) {
+      this.ptyProcess = ptyProcess;
+      this.inputStream = ptyProcess.getInputStream();
+      this.outputStream = ptyProcess.getOutputStream();
+    }
+
+    public boolean init() {
+      return true;
+    }
+
+    public void close() {
+      try {
+        if (ptyProcess != null && ptyProcess.isAlive()) {
+          ptyProcess.destroy();
+        }
+      } catch (Exception e) {
+        log.logError("Error closing Pty4J connector", e);
+      }
+    }
+
+    public String getName() {
+      return "Pty4J Connector";
+    }
+
+    public int read(char[] buf, int offset, int length) throws IOException {
+      byte[] bytes = new byte[length];
+      int bytesRead = inputStream.read(bytes, 0, length);
+      if (bytesRead > 0) {
+        String str = new String(bytes, 0, bytesRead, StandardCharsets.UTF_8);
+        char[] chars = str.toCharArray();
+        System.arraycopy(chars, 0, buf, offset, Math.min(chars.length, 
length));
+        return chars.length;
+      }
+      return bytesRead;
+    }
+
+    public void write(byte[] bytes) throws IOException {
+      outputStream.write(bytes);
+      outputStream.flush();
+    }
+
+    public boolean isConnected() {
+      return ptyProcess != null && ptyProcess.isAlive();
+    }
+
+    public void write(String string) throws IOException {
+      write(string.getBytes(StandardCharsets.UTF_8));
+    }
+
+    public int waitFor() throws InterruptedException {
+      return ptyProcess.waitFor();
+    }
+
+    public boolean ready() throws IOException {
+      return inputStream.available() > 0;
+    }
+
+    public void resize(int cols, int rows) {
+      if (ptyProcess != null && ptyProcess.isAlive()) {
+        try {
+          ptyProcess.setWinSize(new WinSize(cols, rows));
+        } catch (Exception e) {
+          log.logError("Error resizing PTY", e);
+        }
+      }
+    }
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/TerminalShellDetector.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/TerminalShellDetector.java
new file mode 100644
index 0000000000..0124cb395a
--- /dev/null
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/terminal/TerminalShellDetector.java
@@ -0,0 +1,197 @@
+/*
+ * 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.hop.ui.hopgui.terminal;
+
+import java.io.File;
+
+/** Utility class to detect the default shell for the current operating 
system. */
+public class TerminalShellDetector {
+
+  /**
+   * Detect the default shell for the current OS
+   *
+   * @return Full path to the shell executable
+   */
+  public static String detectDefaultShell() {
+    String os = System.getProperty("os.name").toLowerCase();
+
+    if (os.contains("win")) {
+      return detectWindowsShell();
+    } else if (os.contains("mac")) {
+      return detectMacShell();
+    } else {
+      return detectLinuxShell();
+    }
+  }
+
+  /** Detect shell on Windows */
+  private static String detectWindowsShell() {
+    String programFiles = System.getenv("PROGRAMFILES");
+    if (programFiles != null) {
+      String pwsh7 = programFiles + "\\PowerShell\\7\\pwsh.exe";
+      if (new File(pwsh7).exists()) {
+        return pwsh7;
+      }
+    }
+
+    String windir = System.getenv("WINDIR");
+    if (windir != null) {
+      String powershell = windir + 
"\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
+      if (new File(powershell).exists()) {
+        return powershell;
+      }
+    }
+
+    return "cmd.exe";
+  }
+
+  /** Detect shell on macOS */
+  private static String detectMacShell() {
+    String shell = System.getenv("SHELL");
+    if (shell != null && !shell.isEmpty() && new File(shell).exists()) {
+      return shell;
+    }
+
+    if (new File("/bin/zsh").exists()) {
+      return "/bin/zsh";
+    }
+
+    if (new File("/bin/bash").exists()) {
+      return "/bin/bash";
+    }
+
+    return "/bin/sh";
+  }
+
+  /** Detect shell on Linux/Unix */
+  private static String detectLinuxShell() {
+    String shell = System.getenv("SHELL");
+    if (shell != null && !shell.isEmpty() && new File(shell).exists()) {
+      return shell;
+    }
+
+    String[] commonShells = {
+      "/bin/bash", "/usr/bin/bash", "/bin/zsh", "/usr/bin/zsh", "/bin/sh", 
"/usr/bin/sh"
+    };
+
+    for (String shellPath : commonShells) {
+      if (new File(shellPath).exists()) {
+        return shellPath;
+      }
+    }
+
+    return "/bin/sh";
+  }
+
+  /**
+   * Check if a shell path exists and is executable
+   *
+   * @param shellPath Path to shell executable
+   * @return true if shell exists and can be executed
+   */
+  public static boolean isValidShell(String shellPath) {
+    if (shellPath == null || shellPath.isEmpty()) {
+      return false;
+    }
+
+    File shellFile = new File(shellPath);
+    return shellFile.exists() && shellFile.canExecute();
+  }
+
+  /**
+   * Get a list of available shells on the system
+   *
+   * @return Array of shell paths that exist on this system
+   */
+  public static String[] getAvailableShells() {
+    String os = System.getProperty("os.name").toLowerCase();
+
+    if (os.contains("win")) {
+      return getWindowsShells();
+    } else {
+      return getUnixShells();
+    }
+  }
+
+  private static String[] getWindowsShells() {
+    java.util.List<String> shells = new java.util.ArrayList<>();
+
+    // PowerShell 7
+    String programFiles = System.getenv("PROGRAMFILES");
+    if (programFiles != null) {
+      String pwsh7 = programFiles + "\\PowerShell\\7\\pwsh.exe";
+      if (new File(pwsh7).exists()) {
+        shells.add(pwsh7);
+      }
+    }
+
+    // PowerShell 5.x
+    String windir = System.getenv("WINDIR");
+    if (windir != null) {
+      String powershell = windir + 
"\\System32\\WindowsPowerShell\\v1.0\\powershell.exe";
+      if (new File(powershell).exists()) {
+        shells.add(powershell);
+      }
+    }
+
+    // cmd.exe
+    shells.add("cmd.exe");
+
+    // Git Bash (if installed)
+    String[] gitBashPaths = {
+      programFiles + "\\Git\\bin\\bash.exe",
+      "C:\\Program Files\\Git\\bin\\bash.exe",
+      "C:\\Program Files (x86)\\Git\\bin\\bash.exe"
+    };
+    for (String path : gitBashPaths) {
+      if (new File(path).exists()) {
+        shells.add(path);
+        break; // Only add once
+      }
+    }
+
+    return shells.toArray(new String[0]);
+  }
+
+  private static String[] getUnixShells() {
+    java.util.List<String> shells = new java.util.ArrayList<>();
+
+    String[] commonShells = {
+      "/bin/bash",
+      "/usr/bin/bash",
+      "/bin/zsh",
+      "/usr/bin/zsh",
+      "/bin/fish",
+      "/usr/bin/fish",
+      "/bin/sh",
+      "/usr/bin/sh",
+      "/bin/dash",
+      "/bin/ksh",
+      "/bin/tcsh",
+      "/bin/csh"
+    };
+
+    for (String shell : commonShells) {
+      if (new File(shell).exists()) {
+        shells.add(shell);
+      }
+    }
+
+    return shells.toArray(new String[0]);
+  }
+}
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
index 2167ff8df9..dd6117e962 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/hopgui/messages/messages_en_US.properties
@@ -103,6 +103,9 @@ HopGui.Menu.File.Save=&Save
 HopGui.Menu.File.SaveAs=Save &as...
 HopGui.Menu.Help=&Help
 HopGui.Menu.Help.About=About...
+HopGui.Menu.View=&View
+HopGui.Menu.View.Terminal=&Terminal
+HopGui.Menu.View.NewTerminal=&New Terminal
 HopGui.Menu.Redo.Available=Redo \: {0}
 HopGui.Menu.Redo.NotAvailable=Redo \: not available
 HopGui.Menu.Run=&Run
diff --git a/ui/src/main/resources/ui/images/terminal.svg 
b/ui/src/main/resources/ui/images/terminal.svg
new file mode 100644
index 0000000000..db4fbf614d
--- /dev/null
+++ b/ui/src/main/resources/ui/images/terminal.svg
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg xmlns="http://www.w3.org/2000/svg"; viewBox="0 0 24 24" width="24" 
height="24">
+  <!-- Terminal window frame - bright color for dark mode visibility -->
+  <rect x="2" y="3" width="20" height="18" rx="2" fill="none" stroke="#53c3c4" 
stroke-width="1.5"/>
+  
+  <!-- Terminal prompt symbol (>) -->
+  <polyline points="6,9 9,12 6,15" fill="none" stroke="#53c3c4" 
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+  
+  <!-- Command line cursor -->
+  <line x1="11" y1="11" x2="15" y2="11" stroke="#53c3c4" stroke-width="1.5" 
stroke-linecap="round"/>
+  
+  <!-- Top bar dots (like macOS terminal) -->
+  <circle cx="5" cy="6" r="0.8" fill="#53c3c4"/>
+  <circle cx="7.5" cy="6" r="0.8" fill="#53c3c4"/>
+  <circle cx="10" cy="6" r="0.8" fill="#53c3c4"/>
+</svg>
+
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanelTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanelTest.java
new file mode 100644
index 0000000000..01aab9e362
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/HopGuiTerminalPanelTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.hop.ui.hopgui.terminal;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for HopGuiTerminalPanel business logic.
+ *
+ * <p>Note: This class tests non-UI logic only. Full integration tests with 
SWT UI components
+ * require a display environment and are better suited for integration tests.
+ */
+public class HopGuiTerminalPanelTest {
+
+  @Test
+  public void testExtractShellNameLogic() {
+    // Test the logic for extracting shell names from paths
+    String bashPath = "/bin/bash";
+    assertEquals("bash", bashPath.substring(bashPath.lastIndexOf('/') + 1));
+
+    String zshPath = "/bin/zsh";
+    assertEquals("zsh", zshPath.substring(zshPath.lastIndexOf('/') + 1));
+
+    String fishPath = "/usr/local/bin/fish";
+    assertEquals("fish", fishPath.substring(fishPath.lastIndexOf('/') + 1));
+
+    // Windows paths
+    String powershellPath = "C:\\Windows\\System32\\powershell.exe";
+    assertEquals(
+        "powershell.exe",
+        powershellPath.substring(
+            Math.max(powershellPath.lastIndexOf('\\'), 
powershellPath.lastIndexOf('/')) + 1));
+  }
+
+  @Test
+  public void testTerminalHeightPercentBounds() {
+    // Test that terminal height percent validation works correctly
+    assertTrue("10% should be valid", 10 > 0 && 10 < 100);
+    assertTrue("50% should be valid", 50 > 0 && 50 < 100);
+    assertTrue("90% should be valid", 90 > 0 && 90 < 100);
+
+    assertFalse("0% should be invalid", 0 > 0 && 0 < 100);
+    assertFalse("100% should be invalid", 100 > 0 && 100 < 100);
+    assertFalse("-10% should be invalid", -10 > 0 && -10 < 100);
+  }
+
+  @Test
+  public void testLogsWidthPercentBounds() {
+    // Test that logs width percent validation works correctly
+    assertTrue("30% should be valid", 30 > 0 && 30 < 100);
+    assertTrue("50% should be valid", 50 > 0 && 50 < 100);
+    assertTrue("70% should be valid", 70 > 0 && 70 < 100);
+
+    assertFalse("0% should be invalid", 0 > 0 && 0 < 100);
+    assertFalse("100% should be invalid", 100 > 0 && 100 < 100);
+  }
+
+  @Test
+  public void testDefaultConfiguration() {
+    // Test default configuration values
+    int defaultTerminalHeight = 35;
+    int defaultLogsWidth = 50;
+
+    assertEquals("Default terminal height should be 35%", 35, 
defaultTerminalHeight);
+    assertEquals("Default logs width should be 50%", 50, defaultLogsWidth);
+
+    assertTrue(
+        "Default terminal height should be valid",
+        defaultTerminalHeight > 0 && defaultTerminalHeight < 100);
+    assertTrue(
+        "Default logs width should be valid", defaultLogsWidth > 0 && 
defaultLogsWidth < 100);
+  }
+
+  @Test
+  public void testTerminalCounterIncrement() {
+    // Test that terminal counter logic works correctly
+    int counter = 1;
+
+    // Simulate creating 5 terminals
+    String[] expectedNames = {"(1)", "(2)", "(3)", "(4)", "(5)"};
+    for (String expected : expectedNames) {
+      assertEquals(expected, "(" + counter++ + ")");
+    }
+
+    // Counter should now be 6
+    assertEquals(6, counter);
+  }
+
+  @Test
+  public void testVisibilityStateTransitions() {
+    // Test visibility state logic
+    boolean terminalVisible = false;
+    boolean logsVisible = false;
+
+    // Initial state: both hidden
+    assertFalse("Initial: terminal should be hidden", terminalVisible);
+    assertFalse("Initial: logs should be hidden", logsVisible);
+
+    // Show terminal
+    terminalVisible = true;
+    assertTrue("Terminal should be visible", terminalVisible);
+    assertFalse("Logs should still be hidden", logsVisible);
+
+    // Show logs
+    logsVisible = true;
+    assertTrue("Terminal should still be visible", terminalVisible);
+    assertTrue("Logs should be visible", logsVisible);
+
+    // Hide terminal
+    terminalVisible = false;
+    assertFalse("Terminal should be hidden", terminalVisible);
+    assertTrue("Logs should still be visible", logsVisible);
+
+    // Hide logs
+    logsVisible = false;
+    assertFalse("Terminal should be hidden", terminalVisible);
+    assertFalse("Logs should be hidden", logsVisible);
+  }
+
+  @Test
+  public void testBottomPanelLayoutStates() {
+    // Test the four possible layout states for the bottom panel
+    boolean terminalVisible = false;
+    boolean logsVisible = false;
+
+    // State 1: Both hidden - bottom panel should be hidden
+    assertFalse("State 1: Both should be hidden", terminalVisible || 
logsVisible);
+
+    // State 2: Only terminal visible
+    terminalVisible = true;
+    assertTrue("State 2: Terminal visible, logs hidden", terminalVisible && 
!logsVisible);
+
+    // State 3: Only logs visible
+    terminalVisible = false;
+    logsVisible = true;
+    assertTrue("State 3: Logs visible, terminal hidden", !terminalVisible && 
logsVisible);
+
+    // State 4: Both visible - side-by-side
+    terminalVisible = true;
+    assertTrue("State 4: Both visible", terminalVisible && logsVisible);
+  }
+
+  @Test
+  public void testShellPathValidation() {
+    // Test that shell paths are non-null and non-empty
+    String shellPath = TerminalShellDetector.detectDefaultShell();
+
+    assertNotNull("Shell path should not be null", shellPath);
+    assertFalse("Shell path should not be empty", shellPath.isEmpty());
+    assertTrue("Shell path should have at least one character", 
shellPath.length() > 0);
+  }
+
+  @Test
+  public void testWorkingDirectoryDefault() {
+    // Test that working directory defaults to user.home
+    String defaultWorkingDir = System.getProperty("user.home");
+
+    assertNotNull("User home should not be null", defaultWorkingDir);
+    assertFalse("User home should not be empty", defaultWorkingDir.isEmpty());
+  }
+}
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/TerminalShellDetectorTest.java
 
b/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/TerminalShellDetectorTest.java
new file mode 100644
index 0000000000..5ee787fe8e
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/terminal/TerminalShellDetectorTest.java
@@ -0,0 +1,76 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.terminal;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public class TerminalShellDetectorTest {
+
+  @Test
+  public void testDetectDefaultShell() {
+    // Test that we can detect a default shell
+    String shell = TerminalShellDetector.detectDefaultShell();
+
+    assertNotNull("Shell path should not be null", shell);
+    assertFalse("Shell path should not be empty", shell.isEmpty());
+
+    // Verify the shell path contains expected shell names based on OS
+    String os = System.getProperty("os.name").toLowerCase();
+    if (os.contains("win")) {
+      // Windows should return PowerShell or cmd
+      assertTrue(
+          "Windows should detect PowerShell or cmd",
+          shell.contains("powershell") || shell.contains("cmd"));
+    } else if (os.contains("mac") || os.contains("nix") || os.contains("nux")) 
{
+      // Unix-like systems should return a shell in /bin
+      assertTrue("Unix-like systems should return a shell in /bin", 
shell.startsWith("/bin/"));
+    }
+  }
+
+  @Test
+  public void testDetectDefaultShellReturnsExecutable() {
+    // Test that the detected shell is an actual executable file
+    String shell = TerminalShellDetector.detectDefaultShell();
+
+    assertNotNull("Shell path should not be null", shell);
+
+    // On Windows, PowerShell/cmd detection is based on typical locations
+    // On Unix, we verify the shell exists in /bin
+    String os = System.getProperty("os.name").toLowerCase();
+    if (!os.contains("win")) {
+      // For Unix systems, verify the shell path looks valid
+      assertTrue(
+          "Shell should be in /bin or /usr/bin",
+          shell.startsWith("/bin/") || shell.startsWith("/usr/bin/"));
+    }
+  }
+
+  @Test
+  public void testShellDetectionFallback() {
+    // This test verifies that even if preferred shells aren't found,
+    // we still get a fallback shell
+    String shell = TerminalShellDetector.detectDefaultShell();
+
+    assertNotNull("Should always return a shell, even if fallback", shell);
+    assertFalse("Shell path should not be empty", shell.isEmpty());
+  }
+}

Reply via email to