ntermqt 0.1.0__py3-none-any.whl → 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
nterm/__main__.py CHANGED
@@ -11,7 +11,7 @@ from PyQt6.QtWidgets import (
11
11
  QApplication, QMainWindow, QSplitter, QTabWidget,
12
12
  QWidget, QVBoxLayout, QHBoxLayout, QMessageBox,
13
13
  QDialog, QLabel, QLineEdit, QPushButton, QCheckBox,
14
- QMenuBar, QMenu
14
+ QMenuBar, QMenu, QTabBar
15
15
  )
16
16
  from PyQt6.QtCore import Qt
17
17
  from PyQt6.QtGui import QAction, QKeySequence
@@ -22,6 +22,7 @@ from nterm.manager import (
22
22
  )
23
23
  from nterm.terminal.widget import TerminalWidget
24
24
  from nterm.session.ssh import SSHSession
25
+ from nterm.session.local_terminal import LocalTerminal
25
26
  from nterm.connection.profile import ConnectionProfile, AuthConfig
26
27
  from nterm.vault import CredentialManagerWidget
27
28
  from nterm.vault.resolver import CredentialResolver
@@ -207,6 +208,52 @@ class TerminalTab(QWidget):
207
208
  """Disconnect the session."""
208
209
  self.ssh_session.disconnect()
209
210
 
211
+ def is_connected(self) -> bool:
212
+ """Check if the session is currently connected."""
213
+ # Try multiple ways to detect connection state
214
+ if hasattr(self.ssh_session, 'is_connected'):
215
+ return self.ssh_session.is_connected
216
+ if hasattr(self.ssh_session, 'connected'):
217
+ return self.ssh_session.connected
218
+ if hasattr(self.ssh_session, '_connected'):
219
+ return self.ssh_session._connected
220
+ if hasattr(self.ssh_session, '_channel'):
221
+ return self.ssh_session._channel is not None
222
+ if hasattr(self.ssh_session, '_transport'):
223
+ transport = self.ssh_session._transport
224
+ return transport is not None and transport.is_active()
225
+ # Default to True if we can't determine - safer to warn
226
+ return True
227
+
228
+
229
+ class LocalTerminalTab(QWidget):
230
+ """A terminal tab for local processes (shell, IPython, etc.)."""
231
+
232
+ def __init__(self, name: str, session: LocalTerminal, parent=None):
233
+ super().__init__(parent)
234
+ self.name = name
235
+ self.local_session = session
236
+
237
+ layout = QVBoxLayout(self)
238
+ layout.setContentsMargins(0, 0, 0, 0)
239
+
240
+ self.terminal = TerminalWidget()
241
+ layout.addWidget(self.terminal)
242
+
243
+ self.terminal.attach_session(self.local_session)
244
+
245
+ def connect(self):
246
+ """Start the local process."""
247
+ self.local_session.connect()
248
+
249
+ def disconnect(self):
250
+ """Terminate the local process."""
251
+ self.local_session.disconnect()
252
+
253
+ def is_connected(self) -> bool:
254
+ """Check if the process is still running."""
255
+ return self.local_session.is_connected
256
+
210
257
 
211
258
  class MainWindow(QMainWindow):
212
259
  """
@@ -249,12 +296,6 @@ class MainWindow(QMainWindow):
249
296
 
250
297
  # Apply initial stylesheet
251
298
  self._apply_qt_theme(self.current_theme)
252
- self._setup_ui()
253
- self._connect_signals()
254
- self._refresh_credentials()
255
-
256
- # Apply initial stylesheet
257
- self._apply_qt_theme(self.current_theme)
258
299
 
259
300
  def _on_settings_changed(self, settings):
260
301
  """Handle settings changes from dialog."""
@@ -263,7 +304,7 @@ class MainWindow(QMainWindow):
263
304
  # Apply multiline threshold to all open terminals
264
305
  for i in range(self.tab_widget.count()):
265
306
  tab = self.tab_widget.widget(i)
266
- if isinstance(tab, TerminalTab):
307
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)):
267
308
  tab.terminal.set_multiline_threshold(settings.multiline_paste_threshold)
268
309
 
269
310
 
@@ -298,6 +339,10 @@ class MainWindow(QMainWindow):
298
339
  self.tab_widget.setDocumentMode(True)
299
340
  splitter.addWidget(self.tab_widget)
300
341
 
342
+ # Enable tab context menu
343
+ self.tab_widget.tabBar().setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
344
+ self.tab_widget.tabBar().customContextMenuRequested.connect(self._show_tab_context_menu)
345
+
301
346
  # Set initial sizes (tree: 250px, tabs: rest)
302
347
  splitter.setSizes([250, 950])
303
348
 
@@ -323,6 +368,20 @@ class MainWindow(QMainWindow):
323
368
 
324
369
  file_menu.addSeparator()
325
370
 
371
+ # --- NEW: Tab management actions ---
372
+ close_tab_action = QAction("Close Tab", self)
373
+ close_tab_action.setShortcut(QKeySequence("Ctrl+W"))
374
+ close_tab_action.triggered.connect(self._close_current_tab)
375
+ file_menu.addAction(close_tab_action)
376
+
377
+ close_all_action = QAction("Close All Tabs", self)
378
+ close_all_action.setShortcut(QKeySequence("Ctrl+Shift+W"))
379
+ close_all_action.triggered.connect(self._close_all_tabs)
380
+ file_menu.addAction(close_all_action)
381
+
382
+ file_menu.addSeparator()
383
+ # --- END NEW ---
384
+
326
385
  import_action = QAction("&Import Sessions...", self)
327
386
  import_action.setShortcut(QKeySequence("Ctrl+I"))
328
387
  import_action.triggered.connect(self._on_import_sessions)
@@ -372,6 +431,32 @@ class MainWindow(QMainWindow):
372
431
  action.triggered.connect(lambda checked, n=theme_name: self._apply_theme_by_name(n))
373
432
  theme_menu.addAction(action)
374
433
 
434
+ # Dev menu
435
+ dev_menu = menubar.addMenu("&Dev")
436
+
437
+ # IPython submenu
438
+ ipython_menu = dev_menu.addMenu("&IPython")
439
+
440
+ ipython_tab_action = QAction("Open in &Tab", self)
441
+ ipython_tab_action.setShortcut(QKeySequence("Ctrl+Shift+I"))
442
+ ipython_tab_action.triggered.connect(lambda: self._open_local("IPython", LocalTerminal.ipython(), "tab"))
443
+ ipython_menu.addAction(ipython_tab_action)
444
+
445
+ ipython_window_action = QAction("Open in &Window", self)
446
+ ipython_window_action.triggered.connect(lambda: self._open_local("IPython", LocalTerminal.ipython(), "window"))
447
+ ipython_menu.addAction(ipython_window_action)
448
+
449
+ # Shell submenu
450
+ shell_menu = dev_menu.addMenu("&Shell")
451
+
452
+ shell_tab_action = QAction("Open in &Tab", self)
453
+ shell_tab_action.triggered.connect(lambda: self._open_local("Shell", LocalTerminal(), "tab"))
454
+ shell_menu.addAction(shell_tab_action)
455
+
456
+ shell_window_action = QAction("Open in &Window", self)
457
+ shell_window_action.triggered.connect(lambda: self._open_local("Shell", LocalTerminal(), "window"))
458
+ shell_menu.addAction(shell_window_action)
459
+
375
460
  def _on_import_sessions(self):
376
461
  """Show import dialog."""
377
462
  dialog = ImportDialog(self.session_store, self)
@@ -416,7 +501,7 @@ class MainWindow(QMainWindow):
416
501
  # Update all open terminal tabs
417
502
  for i in range(self.tab_widget.count()):
418
503
  tab = self.tab_widget.widget(i)
419
- if isinstance(tab, TerminalTab):
504
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)):
420
505
  tab.terminal.set_theme(theme)
421
506
 
422
507
  def _apply_qt_theme(self, theme: Theme):
@@ -531,15 +616,192 @@ class MainWindow(QMainWindow):
531
616
  self._child_windows.append(window)
532
617
  window.destroyed.connect(lambda: self._child_windows.remove(window))
533
618
 
619
+ def _open_local(self, name: str, session: LocalTerminal, mode: str):
620
+ """Open a local terminal session (IPython, shell, etc.)."""
621
+ if mode == "tab":
622
+ tab = LocalTerminalTab(name, session)
623
+ tab.terminal.set_theme(self.current_theme)
624
+ idx = self.tab_widget.addTab(tab, name)
625
+ self.tab_widget.setCurrentIndex(idx)
626
+ self.tab_widget.setTabToolTip(idx, f"{name} (local)")
627
+ tab.connect()
628
+ else:
629
+ window = LocalTerminalWindow(name, session, self.current_theme)
630
+ window.show()
631
+ if not hasattr(self, '_child_windows'):
632
+ self._child_windows = []
633
+ self._child_windows.append(window)
634
+ window.destroyed.connect(lambda: self._child_windows.remove(window))
635
+
636
+ # -------------------------------------------------------------------------
637
+ # Tab Management (NEW)
638
+ # -------------------------------------------------------------------------
639
+
640
+ def _get_active_session_count(self) -> int:
641
+ """Count tabs with active connections."""
642
+ count = 0
643
+ for i in range(self.tab_widget.count()):
644
+ tab = self.tab_widget.widget(i)
645
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)) and tab.is_connected():
646
+ count += 1
647
+ return count
648
+
649
+ def _show_tab_context_menu(self, pos):
650
+ """Show context menu for tab bar."""
651
+ tab_bar = self.tab_widget.tabBar()
652
+ index = tab_bar.tabAt(pos)
653
+
654
+ menu = QMenu(self)
655
+
656
+ if index >= 0:
657
+ # Clicked on a tab
658
+ menu.addAction("Close", lambda: self._close_tab(index))
659
+ menu.addAction("Close Others", lambda: self._close_other_tabs(index))
660
+ menu.addAction("Close Tabs to the Right", lambda: self._close_tabs_to_right(index))
661
+ menu.addSeparator()
662
+
663
+ if self.tab_widget.count() > 0:
664
+ menu.addAction("Close All Tabs", self._close_all_tabs)
665
+
666
+ if menu.actions():
667
+ menu.exec(tab_bar.mapToGlobal(pos))
668
+
669
+ def _close_current_tab(self):
670
+ """Close the currently active tab."""
671
+ index = self.tab_widget.currentIndex()
672
+ if index >= 0:
673
+ self._close_tab(index)
674
+
534
675
  def _close_tab(self, index: int):
535
- """Close a terminal tab."""
676
+ """Close a terminal tab with confirmation if connected."""
536
677
  tab = self.tab_widget.widget(index)
678
+
537
679
  if isinstance(tab, TerminalTab):
680
+ # Check if session is active
681
+ if tab.is_connected():
682
+ reply = QMessageBox.question(
683
+ self,
684
+ "Close Tab",
685
+ f"'{tab.session.name}' has an active connection.\n\n"
686
+ "Disconnect and close this tab?",
687
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
688
+ QMessageBox.StandardButton.No
689
+ )
690
+ if reply != QMessageBox.StandardButton.Yes:
691
+ return
692
+
538
693
  tab.disconnect()
694
+
695
+ elif isinstance(tab, LocalTerminalTab):
696
+ # Check if process is still running
697
+ if tab.is_connected():
698
+ reply = QMessageBox.question(
699
+ self,
700
+ "Close Tab",
701
+ f"'{tab.name}' is still running.\n\n"
702
+ "Terminate and close this tab?",
703
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
704
+ QMessageBox.StandardButton.No
705
+ )
706
+ if reply != QMessageBox.StandardButton.Yes:
707
+ return
708
+
709
+ tab.disconnect()
710
+
539
711
  self.tab_widget.removeTab(index)
540
712
 
713
+ def _close_other_tabs(self, keep_index: int):
714
+ """Close all tabs except the specified one."""
715
+ active_count = self._get_active_session_count()
716
+ tab_to_keep = self.tab_widget.widget(keep_index)
717
+ keep_is_active = isinstance(tab_to_keep, (TerminalTab, LocalTerminalTab)) and tab_to_keep.is_connected()
718
+ other_active = active_count - (1 if keep_is_active else 0)
719
+
720
+ if other_active > 0:
721
+ reply = QMessageBox.question(
722
+ self,
723
+ "Close Other Tabs",
724
+ f"{other_active} other tab(s) have active connections.\n\n"
725
+ "Disconnect and close them?",
726
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
727
+ QMessageBox.StandardButton.No
728
+ )
729
+ if reply != QMessageBox.StandardButton.Yes:
730
+ return
731
+
732
+ # Close tabs from end to avoid index shifting issues
733
+ for i in range(self.tab_widget.count() - 1, -1, -1):
734
+ if i != keep_index:
735
+ tab = self.tab_widget.widget(i)
736
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)):
737
+ tab.disconnect()
738
+ self.tab_widget.removeTab(i)
739
+
740
+ def _close_tabs_to_right(self, index: int):
741
+ """Close all tabs to the right of the specified index."""
742
+ tabs_to_close = self.tab_widget.count() - index - 1
743
+ if tabs_to_close <= 0:
744
+ return
745
+
746
+ # Count active sessions to the right
747
+ active_count = 0
748
+ for i in range(index + 1, self.tab_widget.count()):
749
+ tab = self.tab_widget.widget(i)
750
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)) and tab.is_connected():
751
+ active_count += 1
752
+
753
+ if active_count > 0:
754
+ reply = QMessageBox.question(
755
+ self,
756
+ "Close Tabs",
757
+ f"{active_count} tab(s) to the right have active connections.\n\n"
758
+ "Disconnect and close them?",
759
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
760
+ QMessageBox.StandardButton.No
761
+ )
762
+ if reply != QMessageBox.StandardButton.Yes:
763
+ return
764
+
765
+ # Close from end to avoid index shifting
766
+ for i in range(self.tab_widget.count() - 1, index, -1):
767
+ tab = self.tab_widget.widget(i)
768
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)):
769
+ tab.disconnect()
770
+ self.tab_widget.removeTab(i)
771
+
772
+ def _close_all_tabs(self):
773
+ """Close all tabs with confirmation."""
774
+ if self.tab_widget.count() == 0:
775
+ return
776
+
777
+ active_count = self._get_active_session_count()
778
+
779
+ if active_count > 0:
780
+ reply = QMessageBox.question(
781
+ self,
782
+ "Close All Tabs",
783
+ f"{active_count} tab(s) have active connections.\n\n"
784
+ "Disconnect and close all tabs?",
785
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
786
+ QMessageBox.StandardButton.No
787
+ )
788
+ if reply != QMessageBox.StandardButton.Yes:
789
+ return
790
+
791
+ # Close all tabs
792
+ while self.tab_widget.count() > 0:
793
+ tab = self.tab_widget.widget(0)
794
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)):
795
+ tab.disconnect()
796
+ self.tab_widget.removeTab(0)
797
+
798
+ # -------------------------------------------------------------------------
799
+ # End Tab Management
800
+ # -------------------------------------------------------------------------
801
+
541
802
  def closeEvent(self, event):
542
- """Clean up on close."""
803
+ """Clean up on close with confirmation for active sessions."""
804
+ # Save window geometry first
543
805
  if not self.isMaximized():
544
806
  self.app_settings.window_width = self.width()
545
807
  self.app_settings.window_height = self.height()
@@ -547,11 +809,28 @@ class MainWindow(QMainWindow):
547
809
  self.app_settings.window_y = self.y()
548
810
  self.app_settings.window_maximized = self.isMaximized()
549
811
 
812
+ # Check for active connections
813
+ active_count = self._get_active_session_count()
814
+
815
+ if active_count > 0:
816
+ reply = QMessageBox.question(
817
+ self,
818
+ "Quit nterm",
819
+ f"You have {active_count} active session(s).\n\n"
820
+ "Disconnect all and quit?",
821
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
822
+ QMessageBox.StandardButton.No
823
+ )
824
+ if reply != QMessageBox.StandardButton.Yes:
825
+ event.ignore()
826
+ return
827
+
550
828
  save_settings()
829
+
551
830
  # Disconnect all tabs
552
831
  for i in range(self.tab_widget.count()):
553
832
  tab = self.tab_widget.widget(i)
554
- if isinstance(tab, TerminalTab):
833
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)):
555
834
  tab.disconnect()
556
835
 
557
836
  self.session_store.close()
@@ -582,7 +861,54 @@ class TerminalWindow(QMainWindow):
582
861
  self.tab.connect()
583
862
 
584
863
  def closeEvent(self, event):
585
- """Disconnect on close."""
864
+ """Disconnect on close with confirmation."""
865
+ if self.tab.is_connected():
866
+ reply = QMessageBox.question(
867
+ self,
868
+ "Close Window",
869
+ f"'{self.tab.session.name}' has an active connection.\n\n"
870
+ "Disconnect and close?",
871
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
872
+ QMessageBox.StandardButton.No
873
+ )
874
+ if reply != QMessageBox.StandardButton.Yes:
875
+ event.ignore()
876
+ return
877
+
878
+ self.tab.disconnect()
879
+ event.accept()
880
+
881
+
882
+ class LocalTerminalWindow(QMainWindow):
883
+ """Standalone window for local terminal sessions."""
884
+
885
+ def __init__(self, name: str, session: LocalTerminal, theme: Theme):
886
+ super().__init__()
887
+ self.setWindowTitle(f"{name} - Local")
888
+ self.resize(1000, 700)
889
+
890
+ self.setStyleSheet(generate_stylesheet(theme))
891
+
892
+ self.tab = LocalTerminalTab(name, session)
893
+ self.tab.terminal.set_theme(theme)
894
+ self.setCentralWidget(self.tab)
895
+
896
+ self.tab.connect()
897
+
898
+ def closeEvent(self, event):
899
+ """Terminate on close with confirmation."""
900
+ if self.tab.is_connected():
901
+ reply = QMessageBox.question(
902
+ self,
903
+ "Close Window",
904
+ f"'{self.tab.name}' is still running.\n\nTerminate and close?",
905
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
906
+ QMessageBox.StandardButton.No
907
+ )
908
+ if reply != QMessageBox.StandardButton.Yes:
909
+ event.ignore()
910
+ return
911
+
586
912
  self.tab.disconnect()
587
913
  event.accept()
588
914
 
@@ -0,0 +1,43 @@
1
+ """
2
+ nterm.scripting - Scripting API for nterm
3
+
4
+ Provides programmatic access to nterm's device sessions and credential vault.
5
+ Usable from IPython, CLI, scripts, or as foundation for MCP tools.
6
+
7
+ Quick Start (IPython):
8
+ from nterm.scripting import api
9
+
10
+ api.devices() # List all saved devices
11
+ api.search("leaf") # Search devices
12
+ api.device("eng-leaf-1") # Get specific device
13
+
14
+ api.unlock("vault-password") # Unlock credential vault
15
+ api.credentials() # List credentials
16
+
17
+ api.help() # Show all commands
18
+
19
+ Quick Start (CLI):
20
+ nterm-cli devices
21
+ nterm-cli search leaf
22
+ nterm-cli credentials --unlock
23
+ """
24
+
25
+ from .api import (
26
+ NTermAPI,
27
+ DeviceInfo,
28
+ CredentialInfo,
29
+ get_api,
30
+ reset_api,
31
+ )
32
+
33
+ # Convenience: pre-instantiated API
34
+ api = get_api()
35
+
36
+ __all__ = [
37
+ "NTermAPI",
38
+ "DeviceInfo",
39
+ "CredentialInfo",
40
+ "get_api",
41
+ "reset_api",
42
+ "api",
43
+ ]