ntermqt 0.1.0__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.
Files changed (52) hide show
  1. nterm/__init__.py +54 -0
  2. nterm/__main__.py +619 -0
  3. nterm/askpass/__init__.py +22 -0
  4. nterm/askpass/server.py +393 -0
  5. nterm/config.py +158 -0
  6. nterm/connection/__init__.py +17 -0
  7. nterm/connection/profile.py +296 -0
  8. nterm/manager/__init__.py +29 -0
  9. nterm/manager/connect_dialog.py +322 -0
  10. nterm/manager/editor.py +262 -0
  11. nterm/manager/io.py +678 -0
  12. nterm/manager/models.py +346 -0
  13. nterm/manager/settings.py +264 -0
  14. nterm/manager/tree.py +493 -0
  15. nterm/resources.py +48 -0
  16. nterm/session/__init__.py +60 -0
  17. nterm/session/askpass_ssh.py +399 -0
  18. nterm/session/base.py +110 -0
  19. nterm/session/interactive_ssh.py +522 -0
  20. nterm/session/pty_transport.py +571 -0
  21. nterm/session/ssh.py +610 -0
  22. nterm/terminal/__init__.py +11 -0
  23. nterm/terminal/bridge.py +83 -0
  24. nterm/terminal/resources/terminal.html +253 -0
  25. nterm/terminal/resources/terminal.js +414 -0
  26. nterm/terminal/resources/xterm-addon-fit.min.js +8 -0
  27. nterm/terminal/resources/xterm-addon-unicode11.min.js +8 -0
  28. nterm/terminal/resources/xterm-addon-web-links.min.js +8 -0
  29. nterm/terminal/resources/xterm.css +209 -0
  30. nterm/terminal/resources/xterm.min.js +8 -0
  31. nterm/terminal/widget.py +380 -0
  32. nterm/theme/__init__.py +10 -0
  33. nterm/theme/engine.py +456 -0
  34. nterm/theme/stylesheet.py +377 -0
  35. nterm/theme/themes/clean.yaml +0 -0
  36. nterm/theme/themes/default.yaml +36 -0
  37. nterm/theme/themes/dracula.yaml +36 -0
  38. nterm/theme/themes/gruvbox_dark.yaml +36 -0
  39. nterm/theme/themes/gruvbox_hybrid.yaml +38 -0
  40. nterm/theme/themes/gruvbox_light.yaml +36 -0
  41. nterm/vault/__init__.py +32 -0
  42. nterm/vault/credential_manager.py +163 -0
  43. nterm/vault/keychain.py +135 -0
  44. nterm/vault/manager_ui.py +962 -0
  45. nterm/vault/profile.py +219 -0
  46. nterm/vault/resolver.py +250 -0
  47. nterm/vault/store.py +642 -0
  48. ntermqt-0.1.0.dist-info/METADATA +327 -0
  49. ntermqt-0.1.0.dist-info/RECORD +52 -0
  50. ntermqt-0.1.0.dist-info/WHEEL +5 -0
  51. ntermqt-0.1.0.dist-info/entry_points.txt +5 -0
  52. ntermqt-0.1.0.dist-info/top_level.txt +1 -0
nterm/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """
2
+ nterm - A themeable SSH terminal widget for PyQt6.
3
+
4
+ Clean architecture with:
5
+ - Connection profiles (fully serializable)
6
+ - Session management with auto-reconnect
7
+ - Jump host / bastion support
8
+ - YubiKey/FIDO2 agent auth (native SSH + PTY)
9
+ - xterm.js rendering
10
+ - Themeable UI
11
+
12
+ Session types:
13
+ - SSHSession: Paramiko-based, programmatic auth
14
+ - InteractiveSSHSession: Native ssh with PTY for full interactive auth
15
+ - HybridSSHSession: Interactive auth with ControlMaster reuse (Unix only)
16
+ """
17
+
18
+ __version__ = "0.2.0"
19
+ __author__ = "Scott Peterman"
20
+
21
+ from .connection.profile import (
22
+ ConnectionProfile,
23
+ AuthConfig,
24
+ AuthMethod,
25
+ JumpHostConfig,
26
+ )
27
+ from .session.base import Session, SessionState
28
+ from .session.ssh import SSHSession
29
+ from .session.interactive_ssh import InteractiveSSHSession, HybridSSHSession
30
+ from .session.pty_transport import is_pty_available, IS_WINDOWS
31
+ from .terminal.widget import TerminalWidget
32
+ from .theme.engine import Theme, ThemeEngine
33
+
34
+ __all__ = [
35
+ # Connection
36
+ "ConnectionProfile",
37
+ "AuthConfig",
38
+ "AuthMethod",
39
+ "JumpHostConfig",
40
+ # Sessions
41
+ "Session",
42
+ "SessionState",
43
+ "SSHSession",
44
+ "InteractiveSSHSession",
45
+ "HybridSSHSession",
46
+ # Utilities
47
+ "is_pty_available",
48
+ "IS_WINDOWS",
49
+ # Terminal
50
+ "TerminalWidget",
51
+ # Themes
52
+ "Theme",
53
+ "ThemeEngine",
54
+ ]
nterm/__main__.py ADDED
@@ -0,0 +1,619 @@
1
+ """
2
+ Example: Session Manager with Tabbed Terminal
3
+
4
+ Demonstrates integrating the session tree with the terminal widget
5
+ and vault for a complete SSH client experience.
6
+ """
7
+
8
+ import sys
9
+ from pathlib import Path
10
+ from PyQt6.QtWidgets import (
11
+ QApplication, QMainWindow, QSplitter, QTabWidget,
12
+ QWidget, QVBoxLayout, QHBoxLayout, QMessageBox,
13
+ QDialog, QLabel, QLineEdit, QPushButton, QCheckBox,
14
+ QMenuBar, QMenu
15
+ )
16
+ from PyQt6.QtCore import Qt
17
+ from PyQt6.QtGui import QAction, QKeySequence
18
+
19
+ from nterm.manager import (
20
+ SessionTreeWidget, SessionStore, SavedSession, QuickConnectDialog,
21
+ SettingsDialog, ExportDialog, ImportDialog, ImportTerminalTelemetryDialog
22
+ )
23
+ from nterm.terminal.widget import TerminalWidget
24
+ from nterm.session.ssh import SSHSession
25
+ from nterm.connection.profile import ConnectionProfile, AuthConfig
26
+ from nterm.vault import CredentialManagerWidget
27
+ from nterm.vault.resolver import CredentialResolver
28
+ from nterm.theme.engine import ThemeEngine, Theme
29
+ from nterm.theme.stylesheet import generate_stylesheet
30
+ from nterm.manager.connect_dialog import ConnectDialog
31
+ from nterm.config import get_settings_manager, get_settings, save_settings
32
+
33
+
34
+
35
+ # Vault location
36
+ NTERM_DIR = Path.home() / ".nterm"
37
+
38
+
39
+ class VaultUnlockDialog(QDialog):
40
+ """Dialog to unlock the credential vault on startup."""
41
+
42
+ def __init__(self, resolver: CredentialResolver, parent=None):
43
+ super().__init__(parent)
44
+ self.resolver = resolver
45
+ self._unlocked = False
46
+
47
+ self.setWindowTitle("Unlock Vault")
48
+ self.setFixedWidth(350)
49
+ self.setModal(True)
50
+
51
+ # Apply default theme styling
52
+ default_theme = Theme.default()
53
+ self.setStyleSheet(generate_stylesheet(default_theme))
54
+
55
+ layout = QVBoxLayout(self)
56
+
57
+ # Icon/message
58
+ if not resolver.is_initialized():
59
+ msg = QLabel("No vault found. Create a new vault password:")
60
+ self._is_new = True
61
+ else:
62
+ msg = QLabel("Enter vault password to unlock credentials:")
63
+ self._is_new = False
64
+ layout.addWidget(msg)
65
+
66
+ # Password input
67
+ self._password_input = QLineEdit()
68
+ self._password_input.setEchoMode(QLineEdit.EchoMode.Password)
69
+ self._password_input.setPlaceholderText("Vault password")
70
+ self._password_input.returnPressed.connect(self._on_unlock)
71
+ layout.addWidget(self._password_input)
72
+
73
+ # Confirm password (new vault only)
74
+ if self._is_new:
75
+ self._confirm_input = QLineEdit()
76
+ self._confirm_input.setEchoMode(QLineEdit.EchoMode.Password)
77
+ self._confirm_input.setPlaceholderText("Confirm password")
78
+ self._confirm_input.returnPressed.connect(self._on_unlock)
79
+ layout.addWidget(self._confirm_input)
80
+
81
+ # Error label
82
+ self._error_label = QLabel()
83
+ self._error_label.setStyleSheet("color: #f38ba8;")
84
+ self._error_label.hide()
85
+ layout.addWidget(self._error_label)
86
+
87
+ # Buttons
88
+ btn_layout = QHBoxLayout()
89
+ btn_layout.addStretch()
90
+
91
+ self._skip_btn = QPushButton("Skip")
92
+ self._skip_btn.setToolTip("Continue without vault (agent auth only)")
93
+ self._skip_btn.clicked.connect(self.accept)
94
+ btn_layout.addWidget(self._skip_btn)
95
+
96
+ self._unlock_btn = QPushButton("Create Vault" if self._is_new else "Unlock")
97
+ self._unlock_btn.setDefault(True)
98
+ self._unlock_btn.clicked.connect(self._on_unlock)
99
+ btn_layout.addWidget(self._unlock_btn)
100
+
101
+ layout.addLayout(btn_layout)
102
+
103
+ self._password_input.setFocus()
104
+
105
+ def _on_unlock(self):
106
+ """Attempt to unlock/create vault."""
107
+ password = self._password_input.text()
108
+
109
+ if not password:
110
+ self._show_error("Password required")
111
+ return
112
+
113
+ if self._is_new:
114
+ confirm = self._confirm_input.text()
115
+ if password != confirm:
116
+ self._show_error("Passwords don't match")
117
+ return
118
+
119
+ try:
120
+ self.resolver.init_vault(password)
121
+ self._unlocked = True
122
+ self.accept()
123
+ except Exception as e:
124
+ self._show_error(f"Failed to create vault: {e}")
125
+ else:
126
+ if self.resolver.unlock_vault(password):
127
+ self._unlocked = True
128
+ self.accept()
129
+ else:
130
+ self._show_error("Incorrect password")
131
+ self._password_input.selectAll()
132
+ self._password_input.setFocus()
133
+
134
+ def _show_error(self, msg: str):
135
+ """Show error message."""
136
+ self._error_label.setText(msg)
137
+ self._error_label.show()
138
+
139
+ def is_unlocked(self) -> bool:
140
+ """Check if vault was successfully unlocked."""
141
+ return self._unlocked
142
+
143
+
144
+ class CredentialManagerDialog(QDialog):
145
+ """Dialog wrapper for the credential manager widget."""
146
+
147
+ def __init__(
148
+ self,
149
+ credential_resolver: CredentialResolver,
150
+ theme: Theme = None,
151
+ parent=None
152
+ ):
153
+ super().__init__(parent)
154
+ self.credential_resolver = credential_resolver
155
+
156
+ self.setWindowTitle("Credential Manager")
157
+ self.setMinimumSize(700, 500)
158
+ self.resize(800, 600)
159
+
160
+ layout = QVBoxLayout(self)
161
+ layout.setContentsMargins(0, 0, 0, 0)
162
+
163
+ # Credential manager widget
164
+ self.manager = CredentialManagerWidget(store=credential_resolver.store)
165
+ layout.addWidget(self.manager)
166
+
167
+ # Apply theme if provided
168
+ if theme:
169
+ self.manager.set_theme(theme)
170
+ self.setStyleSheet(generate_stylesheet(theme))
171
+
172
+ # Try auto-unlock if available
173
+ self.manager.try_auto_unlock()
174
+
175
+
176
+ class TerminalTab(QWidget):
177
+ """A terminal tab with session info."""
178
+
179
+ def __init__(
180
+ self,
181
+ session: SavedSession,
182
+ profile: ConnectionProfile,
183
+ credential_resolver: CredentialResolver = None,
184
+ parent=None
185
+ ):
186
+ super().__init__(parent)
187
+ self.session = session
188
+ self.profile = profile
189
+ self.credential_resolver = credential_resolver
190
+
191
+ layout = QVBoxLayout(self)
192
+ layout.setContentsMargins(0, 0, 0, 0)
193
+
194
+ self.terminal = TerminalWidget()
195
+ layout.addWidget(self.terminal)
196
+
197
+ # Pass the vault to SSHSession for credential resolution
198
+ vault = credential_resolver if credential_resolver else None
199
+ self.ssh_session = SSHSession(profile, vault=vault)
200
+ self.terminal.attach_session(self.ssh_session)
201
+
202
+ def connect(self):
203
+ """Start the SSH connection."""
204
+ self.ssh_session.connect()
205
+
206
+ def disconnect(self):
207
+ """Disconnect the session."""
208
+ self.ssh_session.disconnect()
209
+
210
+
211
+ class MainWindow(QMainWindow):
212
+ """
213
+ Main application window with session tree and tabbed terminals.
214
+ """
215
+
216
+ def __init__(self, credential_resolver: CredentialResolver):
217
+ super().__init__()
218
+ self.setWindowTitle("nterm")
219
+ self.resize(1200, 800)
220
+
221
+ # Initialize stores
222
+ self.session_store = SessionStore()
223
+ self.credential_resolver = credential_resolver
224
+ self.theme_engine = ThemeEngine()
225
+
226
+ # Load persistent settings
227
+ self.settings_manager = get_settings_manager()
228
+ self.app_settings = self.settings_manager.settings
229
+
230
+ # Apply saved window geometry
231
+ self.resize(self.app_settings.window_width, self.app_settings.window_height)
232
+ if self.app_settings.window_x is not None and self.app_settings.window_y is not None:
233
+ self.move(self.app_settings.window_x, self.app_settings.window_y)
234
+ if self.app_settings.window_maximized:
235
+ self.showMaximized()
236
+
237
+ # Initialize stores
238
+ self.session_store = SessionStore()
239
+ self.credential_resolver = credential_resolver
240
+ self.theme_engine = ThemeEngine()
241
+
242
+ # Load saved theme (instead of default)
243
+ saved_theme = self.theme_engine.get_theme(self.app_settings.theme_name)
244
+ self.current_theme = saved_theme if saved_theme else self.theme_engine.current
245
+
246
+ self._setup_ui()
247
+ self._connect_signals()
248
+ self._refresh_credentials()
249
+
250
+ # Apply initial stylesheet
251
+ 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
+
259
+ def _on_settings_changed(self, settings):
260
+ """Handle settings changes from dialog."""
261
+ self.app_settings = settings
262
+
263
+ # Apply multiline threshold to all open terminals
264
+ for i in range(self.tab_widget.count()):
265
+ tab = self.tab_widget.widget(i)
266
+ if isinstance(tab, TerminalTab):
267
+ tab.terminal.set_multiline_threshold(settings.multiline_paste_threshold)
268
+
269
+
270
+ def _refresh_credentials(self):
271
+ """Refresh credential list for session editor."""
272
+ self._credential_names = []
273
+ if self.credential_resolver.is_initialized():
274
+ try:
275
+ creds = self.credential_resolver.list_credentials()
276
+ self._credential_names = [c.name for c in creds]
277
+ except:
278
+ pass
279
+
280
+ def _setup_ui(self):
281
+ """Build the main UI."""
282
+ # Menu bar
283
+ self._setup_menu_bar()
284
+
285
+ # Main splitter: tree | tabs
286
+ splitter = QSplitter(Qt.Orientation.Horizontal)
287
+
288
+ # Session tree (left panel)
289
+ self.session_tree = SessionTreeWidget(self.session_store)
290
+ self.session_tree.setMinimumWidth(200)
291
+ self.session_tree.setMaximumWidth(400)
292
+ splitter.addWidget(self.session_tree)
293
+
294
+ # Tab widget (right panel)
295
+ self.tab_widget = QTabWidget()
296
+ self.tab_widget.setTabsClosable(True)
297
+ self.tab_widget.setMovable(True)
298
+ self.tab_widget.setDocumentMode(True)
299
+ splitter.addWidget(self.tab_widget)
300
+
301
+ # Set initial sizes (tree: 250px, tabs: rest)
302
+ splitter.setSizes([250, 950])
303
+
304
+ self.setCentralWidget(splitter)
305
+
306
+ def _connect_signals(self):
307
+ """Connect UI signals."""
308
+ self.session_tree.connect_requested.connect(self._on_connect_requested)
309
+ self.session_tree.quick_connect_requested.connect(self._on_quick_connect)
310
+ self.tab_widget.tabCloseRequested.connect(self._close_tab)
311
+
312
+ def _setup_menu_bar(self):
313
+ """Setup application menu bar."""
314
+ menubar = self.menuBar()
315
+
316
+ # File menu
317
+ file_menu = menubar.addMenu("&File")
318
+
319
+ quick_connect_action = QAction("&Quick Connect...", self)
320
+ quick_connect_action.setShortcut(QKeySequence("Ctrl+N"))
321
+ quick_connect_action.triggered.connect(self._on_quick_connect)
322
+ file_menu.addAction(quick_connect_action)
323
+
324
+ file_menu.addSeparator()
325
+
326
+ import_action = QAction("&Import Sessions...", self)
327
+ import_action.setShortcut(QKeySequence("Ctrl+I"))
328
+ import_action.triggered.connect(self._on_import_sessions)
329
+ file_menu.addAction(import_action)
330
+
331
+ export_action = QAction("&Export Sessions...", self)
332
+ export_action.setShortcut(QKeySequence("Ctrl+E"))
333
+ export_action.triggered.connect(self._on_export_sessions)
334
+ file_menu.addAction(export_action)
335
+
336
+ file_menu.addSeparator()
337
+
338
+ tt_import_action = QAction("Import from &TerminalTelemetry...", self)
339
+ tt_import_action.triggered.connect(self._on_import_terminal_telemetry)
340
+ file_menu.addAction(tt_import_action)
341
+
342
+ file_menu.addSeparator()
343
+
344
+ quit_action = QAction("&Quit", self)
345
+ quit_action.setShortcut(QKeySequence.StandardKey.Quit)
346
+ quit_action.triggered.connect(self.close)
347
+ file_menu.addAction(quit_action)
348
+
349
+ # Edit menu
350
+ edit_menu = menubar.addMenu("&Edit")
351
+
352
+ settings_action = QAction("&Settings...", self)
353
+ settings_action.setShortcut(QKeySequence("Ctrl+,"))
354
+ settings_action.triggered.connect(self._on_settings)
355
+ edit_menu.addAction(settings_action)
356
+
357
+ edit_menu.addSeparator()
358
+
359
+ cred_manager_action = QAction("&Credential Manager...", self)
360
+ cred_manager_action.setShortcut(QKeySequence("Ctrl+Shift+C"))
361
+ cred_manager_action.triggered.connect(self._on_credential_manager)
362
+ edit_menu.addAction(cred_manager_action)
363
+
364
+ # View menu
365
+ view_menu = menubar.addMenu("&View")
366
+
367
+ # Theme submenu
368
+ theme_menu = view_menu.addMenu("&Theme")
369
+ for theme_name in self.theme_engine.list_themes():
370
+ action = QAction(theme_name.replace("_", " ").title(), self)
371
+ action.setData(theme_name)
372
+ action.triggered.connect(lambda checked, n=theme_name: self._apply_theme_by_name(n))
373
+ theme_menu.addAction(action)
374
+
375
+ def _on_import_sessions(self):
376
+ """Show import dialog."""
377
+ dialog = ImportDialog(self.session_store, self)
378
+ if dialog.exec():
379
+ self.session_tree.refresh()
380
+
381
+ def _on_export_sessions(self):
382
+ """Show export dialog."""
383
+ dialog = ExportDialog(self.session_store, self)
384
+ dialog.exec()
385
+
386
+ def _on_import_terminal_telemetry(self):
387
+ """Show TerminalTelemetry import dialog."""
388
+ dialog = ImportTerminalTelemetryDialog(self.session_store, self)
389
+ if dialog.exec():
390
+ self.session_tree.refresh()
391
+
392
+ def _on_settings(self):
393
+ """Show settings dialog."""
394
+ dialog = SettingsDialog(self.theme_engine, self.current_theme, self)
395
+ dialog.theme_changed.connect(self._apply_theme)
396
+ dialog.exec()
397
+
398
+ def _on_credential_manager(self):
399
+ """Show credential manager dialog."""
400
+ dialog = CredentialManagerDialog(
401
+ self.credential_resolver,
402
+ self.current_theme,
403
+ self
404
+ )
405
+ dialog.exec()
406
+ # Refresh credential list in case any were added/removed
407
+ self._refresh_credentials()
408
+
409
+ def _apply_theme(self, theme: Theme):
410
+ """Apply theme to all terminals and Qt UI."""
411
+ self.current_theme = theme
412
+
413
+ # Update Qt stylesheet
414
+ self._apply_qt_theme(theme)
415
+
416
+ # Update all open terminal tabs
417
+ for i in range(self.tab_widget.count()):
418
+ tab = self.tab_widget.widget(i)
419
+ if isinstance(tab, TerminalTab):
420
+ tab.terminal.set_theme(theme)
421
+
422
+ def _apply_qt_theme(self, theme: Theme):
423
+ """Apply theme stylesheet to Qt widgets."""
424
+ stylesheet = generate_stylesheet(theme)
425
+ self.setStyleSheet(stylesheet)
426
+
427
+ def _apply_theme_by_name(self, name: str):
428
+ """Apply theme by name."""
429
+ theme = self.theme_engine.get_theme(name)
430
+ if theme:
431
+ self.theme_engine.current = theme
432
+ self._apply_theme(theme)
433
+
434
+ def _on_connect_requested(self, session: SavedSession, mode: str):
435
+ """Handle connect request from session tree."""
436
+ # Show connection dialog
437
+ dialog = ConnectDialog(
438
+ session,
439
+ credential_resolver=self.credential_resolver,
440
+ credential_names=self._credential_names,
441
+ parent=self
442
+ )
443
+
444
+ if not dialog.exec():
445
+ return # User cancelled
446
+
447
+ profile = dialog.get_profile()
448
+ if not profile:
449
+ return
450
+
451
+ if mode == SessionTreeWidget.MODE_TAB:
452
+ self._open_in_tab(session, profile)
453
+ elif mode == SessionTreeWidget.MODE_WINDOW:
454
+ self._open_in_window(session, profile)
455
+
456
+ def _on_quick_connect(self):
457
+ """Handle quick connect request."""
458
+ dialog = QuickConnectDialog(self._credential_names, self)
459
+ if dialog.exec():
460
+ session = dialog.get_session()
461
+ mode = dialog.get_connect_mode()
462
+
463
+ # Show connection dialog for auth options
464
+ connect_dialog = ConnectDialog(
465
+ session,
466
+ credential_resolver=self.credential_resolver,
467
+ credential_names=self._credential_names,
468
+ parent=self
469
+ )
470
+
471
+ if not connect_dialog.exec():
472
+ return
473
+
474
+ profile = connect_dialog.get_profile()
475
+ if profile:
476
+ if mode == "tab":
477
+ self._open_in_tab(session, profile)
478
+ else:
479
+ self._open_in_window(session, profile)
480
+
481
+ def _build_profile(self, session: SavedSession) -> ConnectionProfile:
482
+ """Build ConnectionProfile from SavedSession."""
483
+ auth_methods = []
484
+
485
+ # Try to resolve credential from vault
486
+ if session.credential_name and self.credential_resolver.is_initialized():
487
+ try:
488
+ profile = self.credential_resolver.create_profile_for_credential(
489
+ session.credential_name,
490
+ session.hostname,
491
+ session.port
492
+ )
493
+ return profile
494
+ except Exception as e:
495
+ QMessageBox.warning(
496
+ self,
497
+ "Credential Error",
498
+ f"Failed to load credential '{session.credential_name}': {e}\n\n"
499
+ "Falling back to SSH agent."
500
+ )
501
+
502
+ # Default to agent auth
503
+ auth_methods.append(AuthConfig.agent_auth("$USER")) # Will use current user
504
+
505
+ return ConnectionProfile(
506
+ name=session.name,
507
+ hostname=session.hostname,
508
+ port=session.port,
509
+ auth_methods=auth_methods,
510
+ )
511
+
512
+ def _open_in_tab(self, session: SavedSession, profile: ConnectionProfile):
513
+ """Open session in a new tab."""
514
+ tab = TerminalTab(session, profile, self.credential_resolver)
515
+ tab.terminal.set_theme(self.current_theme)
516
+
517
+ idx = self.tab_widget.addTab(tab, session.name)
518
+ self.tab_widget.setCurrentIndex(idx)
519
+ self.tab_widget.setTabToolTip(idx, f"{session.hostname}:{session.port}")
520
+
521
+ tab.connect()
522
+
523
+ def _open_in_window(self, session: SavedSession, profile: ConnectionProfile):
524
+ """Open session in a separate window."""
525
+ window = TerminalWindow(session, profile, self.current_theme, self.credential_resolver)
526
+ window.show()
527
+
528
+ # Keep reference to prevent garbage collection
529
+ if not hasattr(self, '_child_windows'):
530
+ self._child_windows = []
531
+ self._child_windows.append(window)
532
+ window.destroyed.connect(lambda: self._child_windows.remove(window))
533
+
534
+ def _close_tab(self, index: int):
535
+ """Close a terminal tab."""
536
+ tab = self.tab_widget.widget(index)
537
+ if isinstance(tab, TerminalTab):
538
+ tab.disconnect()
539
+ self.tab_widget.removeTab(index)
540
+
541
+ def closeEvent(self, event):
542
+ """Clean up on close."""
543
+ if not self.isMaximized():
544
+ self.app_settings.window_width = self.width()
545
+ self.app_settings.window_height = self.height()
546
+ self.app_settings.window_x = self.x()
547
+ self.app_settings.window_y = self.y()
548
+ self.app_settings.window_maximized = self.isMaximized()
549
+
550
+ save_settings()
551
+ # Disconnect all tabs
552
+ for i in range(self.tab_widget.count()):
553
+ tab = self.tab_widget.widget(i)
554
+ if isinstance(tab, TerminalTab):
555
+ tab.disconnect()
556
+
557
+ self.session_store.close()
558
+ event.accept()
559
+
560
+
561
+ class TerminalWindow(QMainWindow):
562
+ """Standalone terminal window for a single session."""
563
+
564
+ def __init__(
565
+ self,
566
+ session: SavedSession,
567
+ profile: ConnectionProfile,
568
+ theme: Theme,
569
+ credential_resolver: CredentialResolver = None
570
+ ):
571
+ super().__init__()
572
+ self.setWindowTitle(f"{session.name} - {session.hostname}")
573
+ self.resize(1000, 700)
574
+
575
+ # Apply Qt theme
576
+ self.setStyleSheet(generate_stylesheet(theme))
577
+
578
+ self.tab = TerminalTab(session, profile, credential_resolver)
579
+ self.tab.terminal.set_theme(theme)
580
+ self.setCentralWidget(self.tab)
581
+
582
+ self.tab.connect()
583
+
584
+ def closeEvent(self, event):
585
+ """Disconnect on close."""
586
+ self.tab.disconnect()
587
+ event.accept()
588
+
589
+
590
+ def main():
591
+ app = QApplication(sys.argv)
592
+
593
+ # Optional: Apply app-wide style
594
+ app.setStyle("Fusion")
595
+
596
+ # Ensure .nterm directory exists
597
+ NTERM_DIR.mkdir(parents=True, exist_ok=True)
598
+
599
+ # Initialize credential resolver
600
+ resolver = CredentialResolver()
601
+
602
+ # Unlock vault on startup
603
+ unlock_dialog = VaultUnlockDialog(resolver)
604
+ unlock_dialog.exec()
605
+
606
+ if unlock_dialog.is_unlocked():
607
+ print(f"Vault unlocked: {resolver.db_path}")
608
+ else:
609
+ print("Continuing without vault (agent auth only)")
610
+
611
+ # Create and show main window
612
+ window = MainWindow(resolver)
613
+ window.show()
614
+
615
+ sys.exit(app.exec())
616
+
617
+
618
+ if __name__ == "__main__":
619
+ main()
@@ -0,0 +1,22 @@
1
+ """
2
+ SSH_ASKPASS implementation for GUI-based authentication.
3
+
4
+ This module provides:
5
+ - AskpassServer: Listens for SSH authentication requests
6
+ - AskpassRequest/Response: Data structures for the protocol
7
+ - BlockingAskpassHandler: Helper for synchronous GUI integration
8
+ """
9
+
10
+ from .server import (
11
+ AskpassServer,
12
+ AskpassRequest,
13
+ AskpassResponse,
14
+ BlockingAskpassHandler,
15
+ )
16
+
17
+ __all__ = [
18
+ "AskpassServer",
19
+ "AskpassRequest",
20
+ "AskpassResponse",
21
+ "BlockingAskpassHandler",
22
+ ]