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/manager/tree.py ADDED
@@ -0,0 +1,493 @@
1
+ """
2
+ Session tree widget with filtering.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ from typing import Optional, Union
7
+ from enum import Enum, auto
8
+
9
+ from PyQt6.QtWidgets import (
10
+ QWidget, QVBoxLayout, QHBoxLayout, QTreeWidget, QTreeWidgetItem,
11
+ QLineEdit, QPushButton, QMenu, QInputDialog, QMessageBox,
12
+ QAbstractItemView, QSizePolicy
13
+ )
14
+ from PyQt6.QtCore import Qt, pyqtSignal, QTimer
15
+ from PyQt6.QtGui import QIcon, QAction
16
+
17
+ from .models import SessionStore, SavedSession, SessionFolder
18
+
19
+ # Item data roles
20
+ ROLE_ITEM_TYPE = Qt.ItemDataRole.UserRole
21
+ ROLE_ITEM_ID = Qt.ItemDataRole.UserRole + 1
22
+
23
+
24
+ class ItemType(Enum):
25
+ FOLDER = auto()
26
+ SESSION = auto()
27
+
28
+
29
+ class SessionTreeWidget(QWidget):
30
+ """
31
+ Tree-based session browser with filtering.
32
+
33
+ Signals:
34
+ connect_requested(session, mode): Emitted when user wants to connect
35
+ session_selected(session): Emitted when selection changes
36
+ """
37
+
38
+ # Connect modes
39
+ MODE_TAB = "tab"
40
+ MODE_WINDOW = "window"
41
+ MODE_QUICK = "quick"
42
+
43
+ # Signals
44
+ connect_requested = pyqtSignal(object, str) # (SavedSession, mode)
45
+ session_selected = pyqtSignal(object) # SavedSession or None
46
+ quick_connect_requested = pyqtSignal() # For quick connect dialog
47
+
48
+ def __init__(self, store: SessionStore = None, parent: QWidget = None):
49
+ super().__init__(parent)
50
+ self.store = store or SessionStore()
51
+
52
+ self._filter_timer = QTimer()
53
+ self._filter_timer.setSingleShot(True)
54
+ self._filter_timer.timeout.connect(self._apply_filter)
55
+
56
+ self._setup_ui()
57
+ self.refresh()
58
+
59
+ def _setup_ui(self) -> None:
60
+ """Build the UI."""
61
+ layout = QVBoxLayout(self)
62
+ layout.setContentsMargins(0, 0, 0, 0)
63
+ layout.setSpacing(4)
64
+
65
+ # Toolbar row
66
+ toolbar = QHBoxLayout()
67
+ toolbar.setSpacing(4)
68
+
69
+ # Filter input
70
+ self._filter_input = QLineEdit()
71
+ self._filter_input.setPlaceholderText("Filter sessions...")
72
+ self._filter_input.setClearButtonEnabled(True)
73
+ self._filter_input.textChanged.connect(self._on_filter_changed)
74
+ toolbar.addWidget(self._filter_input, 1)
75
+
76
+ # Quick connect button
77
+ self._quick_btn = QPushButton("Quick Connect")
78
+ self._quick_btn.clicked.connect(self.quick_connect_requested.emit)
79
+ toolbar.addWidget(self._quick_btn)
80
+
81
+ layout.addLayout(toolbar)
82
+
83
+ # Tree widget
84
+ self._tree = QTreeWidget()
85
+ self._tree.setHeaderHidden(True)
86
+ self._tree.setRootIsDecorated(True)
87
+ self._tree.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
88
+ self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
89
+ self._tree.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
90
+ self._tree.setAnimated(True)
91
+
92
+ # Signals
93
+ self._tree.itemDoubleClicked.connect(self._on_double_click)
94
+ self._tree.itemSelectionChanged.connect(self._on_selection_changed)
95
+ self._tree.customContextMenuRequested.connect(self._show_context_menu)
96
+ self._tree.itemExpanded.connect(self._on_item_expanded)
97
+ self._tree.itemCollapsed.connect(self._on_item_collapsed)
98
+
99
+ layout.addWidget(self._tree)
100
+
101
+ # Action buttons row
102
+ btn_row = QHBoxLayout()
103
+ btn_row.setSpacing(4)
104
+
105
+ self._connect_tab_btn = QPushButton("Connect")
106
+ self._connect_tab_btn.setToolTip("Connect in new tab")
107
+ self._connect_tab_btn.clicked.connect(lambda: self._connect_selected(self.MODE_TAB))
108
+ self._connect_tab_btn.setEnabled(False)
109
+ btn_row.addWidget(self._connect_tab_btn)
110
+
111
+ self._connect_win_btn = QPushButton("New")
112
+ self._connect_win_btn.setToolTip("Connect in separate window")
113
+ self._connect_win_btn.clicked.connect(lambda: self._connect_selected(self.MODE_WINDOW))
114
+ self._connect_win_btn.setEnabled(False)
115
+ btn_row.addWidget(self._connect_win_btn)
116
+
117
+ btn_row.addStretch()
118
+
119
+ self._add_btn = QPushButton("+")
120
+ self._add_btn.setFixedWidth(32)
121
+ self._add_btn.setToolTip("Add session or folder")
122
+ self._add_btn.clicked.connect(self._show_add_menu)
123
+ btn_row.addWidget(self._add_btn)
124
+
125
+ layout.addLayout(btn_row)
126
+
127
+ # -------------------------------------------------------------------------
128
+ # Public API
129
+ # -------------------------------------------------------------------------
130
+
131
+ def refresh(self) -> None:
132
+ """Reload tree from store."""
133
+ self._tree.clear()
134
+ tree_data = self.store.get_tree()
135
+
136
+ # Build folder lookup
137
+ folder_items: dict[int, QTreeWidgetItem] = {}
138
+
139
+ # First pass: create all folder items
140
+ for folder in tree_data["folders"]:
141
+ item = self._create_folder_item(folder)
142
+ folder_items[folder.id] = item
143
+
144
+ # Second pass: parent folders correctly
145
+ for folder in tree_data["folders"]:
146
+ item = folder_items[folder.id]
147
+ if folder.parent_id and folder.parent_id in folder_items:
148
+ folder_items[folder.parent_id].addChild(item)
149
+ else:
150
+ self._tree.addTopLevelItem(item)
151
+
152
+ # Restore expanded state
153
+ item.setExpanded(folder.expanded)
154
+
155
+ # Add sessions
156
+ for session in tree_data["sessions"]:
157
+ item = self._create_session_item(session)
158
+ if session.folder_id and session.folder_id in folder_items:
159
+ folder_items[session.folder_id].addChild(item)
160
+ else:
161
+ self._tree.addTopLevelItem(item)
162
+
163
+ self._apply_filter()
164
+
165
+ def get_selected_session(self) -> Optional[SavedSession]:
166
+ """Get currently selected session, or None."""
167
+ items = self._tree.selectedItems()
168
+ if not items:
169
+ return None
170
+ item = items[0]
171
+ if item.data(0, ROLE_ITEM_TYPE) == ItemType.SESSION:
172
+ session_id = item.data(0, ROLE_ITEM_ID)
173
+ return self.store.get_session(session_id)
174
+ return None
175
+
176
+ def select_session(self, session_id: int) -> None:
177
+ """Select a session by ID."""
178
+ item = self._find_session_item(session_id)
179
+ if item:
180
+ self._tree.setCurrentItem(item)
181
+
182
+ # -------------------------------------------------------------------------
183
+ # Item creation
184
+ # -------------------------------------------------------------------------
185
+
186
+ def _create_folder_item(self, folder: SessionFolder) -> QTreeWidgetItem:
187
+ """Create tree item for a folder."""
188
+ item = QTreeWidgetItem()
189
+ item.setText(0, f"📁 {folder.name}")
190
+ item.setData(0, ROLE_ITEM_TYPE, ItemType.FOLDER)
191
+ item.setData(0, ROLE_ITEM_ID, folder.id)
192
+ item.setFlags(
193
+ item.flags() |
194
+ Qt.ItemFlag.ItemIsDropEnabled
195
+ )
196
+ return item
197
+
198
+ def _create_session_item(self, session: SavedSession) -> QTreeWidgetItem:
199
+ """Create tree item for a session."""
200
+ item = QTreeWidgetItem()
201
+
202
+ # Display text
203
+ display = f"🖥 {session.name}"
204
+ if session.description:
205
+ display += f" ({session.description})"
206
+ item.setText(0, display)
207
+ item.setToolTip(0, f"{session.hostname}:{session.port}")
208
+
209
+ item.setData(0, ROLE_ITEM_TYPE, ItemType.SESSION)
210
+ item.setData(0, ROLE_ITEM_ID, session.id)
211
+ item.setFlags(
212
+ item.flags() |
213
+ Qt.ItemFlag.ItemIsDragEnabled |
214
+ Qt.ItemFlag.ItemNeverHasChildren
215
+ )
216
+ return item
217
+
218
+ def _find_session_item(self, session_id: int) -> Optional[QTreeWidgetItem]:
219
+ """Find tree item for a session ID."""
220
+ iterator = self._tree_iterator()
221
+ for item in iterator:
222
+ if (item.data(0, ROLE_ITEM_TYPE) == ItemType.SESSION and
223
+ item.data(0, ROLE_ITEM_ID) == session_id):
224
+ return item
225
+ return None
226
+
227
+ def _tree_iterator(self):
228
+ """Iterate all items in tree."""
229
+ def recurse(parent):
230
+ for i in range(parent.childCount()):
231
+ child = parent.child(i)
232
+ yield child
233
+ yield from recurse(child)
234
+
235
+ for i in range(self._tree.topLevelItemCount()):
236
+ item = self._tree.topLevelItem(i)
237
+ yield item
238
+ yield from recurse(item)
239
+
240
+ # -------------------------------------------------------------------------
241
+ # Filtering
242
+ # -------------------------------------------------------------------------
243
+
244
+ def _on_filter_changed(self, text: str) -> None:
245
+ """Handle filter text change (debounced)."""
246
+ self._filter_timer.start(150) # 150ms debounce
247
+
248
+ def _apply_filter(self) -> None:
249
+ """Apply current filter to tree."""
250
+ query = self._filter_input.text().strip().lower()
251
+
252
+ if not query:
253
+ # Show everything
254
+ for item in self._tree_iterator():
255
+ item.setHidden(False)
256
+ return
257
+
258
+ # Hide non-matching items, but show folders with matching children
259
+ def process_item(item) -> bool:
260
+ """Returns True if item or any child matches."""
261
+ item_type = item.data(0, ROLE_ITEM_TYPE)
262
+
263
+ if item_type == ItemType.SESSION:
264
+ session_id = item.data(0, ROLE_ITEM_ID)
265
+ session = self.store.get_session(session_id)
266
+ if session:
267
+ matches = (
268
+ query in session.name.lower() or
269
+ query in session.description.lower() or
270
+ query in session.hostname.lower()
271
+ )
272
+ item.setHidden(not matches)
273
+ return matches
274
+ return False
275
+
276
+ elif item_type == ItemType.FOLDER:
277
+ # Check all children
278
+ any_child_visible = False
279
+ for i in range(item.childCount()):
280
+ if process_item(item.child(i)):
281
+ any_child_visible = True
282
+
283
+ item.setHidden(not any_child_visible)
284
+ if any_child_visible:
285
+ item.setExpanded(True)
286
+ return any_child_visible
287
+
288
+ return False
289
+
290
+ for i in range(self._tree.topLevelItemCount()):
291
+ process_item(self._tree.topLevelItem(i))
292
+
293
+ # -------------------------------------------------------------------------
294
+ # Context menu
295
+ # -------------------------------------------------------------------------
296
+
297
+ def _show_context_menu(self, pos) -> None:
298
+ """Show right-click context menu."""
299
+ item = self._tree.itemAt(pos)
300
+ menu = QMenu(self)
301
+
302
+ if item:
303
+ item_type = item.data(0, ROLE_ITEM_TYPE)
304
+
305
+ if item_type == ItemType.SESSION:
306
+ session = self.get_selected_session()
307
+ if session:
308
+ menu.addAction("Connect in Tab",
309
+ lambda: self._connect_session(session, self.MODE_TAB))
310
+ menu.addAction("Connect in Window",
311
+ lambda: self._connect_session(session, self.MODE_WINDOW))
312
+ menu.addSeparator()
313
+ menu.addAction("Edit...", lambda: self._edit_session(session))
314
+ menu.addAction("Duplicate", lambda: self._duplicate_session(session))
315
+ menu.addSeparator()
316
+ menu.addAction("Delete", lambda: self._delete_session(session))
317
+
318
+ elif item_type == ItemType.FOLDER:
319
+ folder_id = item.data(0, ROLE_ITEM_ID)
320
+ folder = self.store.get_folder(folder_id)
321
+ if folder:
322
+ menu.addAction("New Session Here...",
323
+ lambda: self._add_session(folder_id))
324
+ menu.addAction("New Subfolder...",
325
+ lambda: self._add_folder(folder_id))
326
+ menu.addSeparator()
327
+ menu.addAction("Rename...", lambda: self._rename_folder(folder))
328
+ menu.addSeparator()
329
+ menu.addAction("Delete Folder", lambda: self._delete_folder(folder))
330
+
331
+ else:
332
+ # Clicked on empty space
333
+ menu.addAction("New Session...", lambda: self._add_session(None))
334
+ menu.addAction("New Folder...", lambda: self._add_folder(None))
335
+
336
+ if menu.actions():
337
+ menu.exec(self._tree.viewport().mapToGlobal(pos))
338
+
339
+ def _show_add_menu(self) -> None:
340
+ """Show add button menu."""
341
+ menu = QMenu(self)
342
+ menu.addAction("New Session...", lambda: self._add_session(None))
343
+ menu.addAction("New Folder...", lambda: self._add_folder(None))
344
+ menu.exec(self._add_btn.mapToGlobal(self._add_btn.rect().bottomLeft()))
345
+
346
+ # -------------------------------------------------------------------------
347
+ # Actions
348
+ # -------------------------------------------------------------------------
349
+
350
+ def _on_double_click(self, item: QTreeWidgetItem, column: int) -> None:
351
+ """Handle double-click - connect to session."""
352
+ if item.data(0, ROLE_ITEM_TYPE) == ItemType.SESSION:
353
+ session = self.get_selected_session()
354
+ if session:
355
+ self._connect_session(session, self.MODE_TAB)
356
+
357
+ def _on_selection_changed(self) -> None:
358
+ """Handle selection change."""
359
+ session = self.get_selected_session()
360
+ self._connect_tab_btn.setEnabled(session is not None)
361
+ self._connect_win_btn.setEnabled(session is not None)
362
+ self.session_selected.emit(session)
363
+
364
+ def _on_item_expanded(self, item: QTreeWidgetItem) -> None:
365
+ """Save folder expanded state."""
366
+ if item.data(0, ROLE_ITEM_TYPE) == ItemType.FOLDER:
367
+ folder_id = item.data(0, ROLE_ITEM_ID)
368
+ folder = self.store.get_folder(folder_id)
369
+ if folder:
370
+ folder.expanded = True
371
+ self.store.update_folder(folder)
372
+
373
+ def _on_item_collapsed(self, item: QTreeWidgetItem) -> None:
374
+ """Save folder collapsed state."""
375
+ if item.data(0, ROLE_ITEM_TYPE) == ItemType.FOLDER:
376
+ folder_id = item.data(0, ROLE_ITEM_ID)
377
+ folder = self.store.get_folder(folder_id)
378
+ if folder:
379
+ folder.expanded = False
380
+ self.store.update_folder(folder)
381
+
382
+ def _connect_selected(self, mode: str) -> None:
383
+ """Connect to selected session."""
384
+ session = self.get_selected_session()
385
+ if session:
386
+ self._connect_session(session, mode)
387
+
388
+ def _connect_session(self, session: SavedSession, mode: str) -> None:
389
+ """Emit connect request."""
390
+ self.store.record_connect(session.id)
391
+ self.connect_requested.emit(session, mode)
392
+
393
+ def _add_session(self, folder_id: int = None) -> None:
394
+ """Add new session (opens editor)."""
395
+ from .editor import SessionEditorDialog
396
+
397
+ # Get credential names from parent if available
398
+ cred_names = []
399
+ parent = self.parent()
400
+ while parent:
401
+ if hasattr(parent, '_credential_names'):
402
+ cred_names = parent._credential_names
403
+ break
404
+ parent = parent.parent()
405
+
406
+ session = SavedSession(folder_id=folder_id)
407
+ dialog = SessionEditorDialog(session, cred_names, parent=self)
408
+ if dialog.exec():
409
+ session = dialog.get_session()
410
+ self.store.add_session(session)
411
+ self.refresh()
412
+
413
+ def _edit_session(self, session: SavedSession) -> None:
414
+ """Edit existing session."""
415
+ from .editor import SessionEditorDialog
416
+
417
+ # Get credential names from parent if available
418
+ cred_names = []
419
+ parent = self.parent()
420
+ while parent:
421
+ if hasattr(parent, '_credential_names'):
422
+ cred_names = parent._credential_names
423
+ break
424
+ parent = parent.parent()
425
+
426
+ dialog = SessionEditorDialog(session, cred_names, parent=self)
427
+ if dialog.exec():
428
+ updated = dialog.get_session()
429
+ updated.id = session.id
430
+ self.store.update_session(updated)
431
+ self.refresh()
432
+ self.select_session(session.id)
433
+
434
+ def _duplicate_session(self, session: SavedSession) -> None:
435
+ """Duplicate a session."""
436
+ new_session = SavedSession(
437
+ name=f"{session.name} (copy)",
438
+ description=session.description,
439
+ hostname=session.hostname,
440
+ port=session.port,
441
+ credential_name=session.credential_name,
442
+ folder_id=session.folder_id,
443
+ extras=session.extras.copy(),
444
+ )
445
+ new_id = self.store.add_session(new_session)
446
+ self.refresh()
447
+ self.select_session(new_id)
448
+
449
+ def _delete_session(self, session: SavedSession) -> None:
450
+ """Delete a session with confirmation."""
451
+ reply = QMessageBox.question(
452
+ self,
453
+ "Delete Session",
454
+ f"Delete session '{session.name}'?",
455
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
456
+ QMessageBox.StandardButton.No
457
+ )
458
+ if reply == QMessageBox.StandardButton.Yes:
459
+ self.store.delete_session(session.id)
460
+ self.refresh()
461
+
462
+ def _add_folder(self, parent_id: int = None) -> None:
463
+ """Add new folder."""
464
+ name, ok = QInputDialog.getText(
465
+ self, "New Folder", "Folder name:"
466
+ )
467
+ if ok and name.strip():
468
+ self.store.add_folder(name.strip(), parent_id)
469
+ self.refresh()
470
+
471
+ def _rename_folder(self, folder: SessionFolder) -> None:
472
+ """Rename a folder."""
473
+ name, ok = QInputDialog.getText(
474
+ self, "Rename Folder", "Folder name:", text=folder.name
475
+ )
476
+ if ok and name.strip():
477
+ folder.name = name.strip()
478
+ self.store.update_folder(folder)
479
+ self.refresh()
480
+
481
+ def _delete_folder(self, folder: SessionFolder) -> None:
482
+ """Delete a folder with confirmation."""
483
+ reply = QMessageBox.question(
484
+ self,
485
+ "Delete Folder",
486
+ f"Delete folder '{folder.name}'?\n\n"
487
+ "Sessions inside will be moved to the root level.",
488
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
489
+ QMessageBox.StandardButton.No
490
+ )
491
+ if reply == QMessageBox.StandardButton.Yes:
492
+ self.store.delete_folder(folder.id)
493
+ self.refresh()
nterm/resources.py ADDED
@@ -0,0 +1,48 @@
1
+ """Resource manager for development and installed package modes."""
2
+ from pathlib import Path
3
+ from functools import lru_cache
4
+
5
+
6
+ class ResourceManager:
7
+ _instance = None
8
+
9
+ def __new__(cls):
10
+ if cls._instance is None:
11
+ cls._instance = super().__new__(cls)
12
+ cls._instance._package_root = Path(__file__).parent
13
+ return cls._instance
14
+
15
+ @property
16
+ def dev_mode(self) -> bool:
17
+ """True if running from source checkout."""
18
+ return (self._package_root.parent / "pyproject.toml").exists()
19
+
20
+ def get_path(self, *parts: str) -> Path:
21
+ """Get absolute path to a package resource."""
22
+ path = self._package_root.joinpath(*parts)
23
+ if not path.exists():
24
+ raise FileNotFoundError(f"Resource not found: {path}")
25
+ return path
26
+
27
+ def get_uri(self, *parts: str) -> str:
28
+ """Get file:// URI for QWebEngineView."""
29
+ return self.get_path(*parts).as_uri()
30
+
31
+ def read_text(self, *parts: str) -> str:
32
+ return self.get_path(*parts).read_text(encoding="utf-8")
33
+
34
+ def read_bytes(self, *parts: str) -> bytes:
35
+ return self.get_path(*parts).read_bytes()
36
+
37
+ # Convenience shortcuts
38
+ @property
39
+ def terminal_resources(self) -> Path:
40
+ return self.get_path("terminal", "resources")
41
+
42
+ @property
43
+ def themes_dir(self) -> Path:
44
+ return self.get_path("theme", "themes")
45
+
46
+
47
+ # Singleton instance
48
+ resources = ResourceManager()
@@ -0,0 +1,60 @@
1
+ """
2
+ Session management - handles connection lifecycle and I/O.
3
+
4
+ Provides multiple session implementations:
5
+
6
+ - SSHSession: Paramiko-based, programmatic auth (password, key, agent)
7
+ - InteractiveSSHSession: Native ssh binary with PTY for full interactive auth
8
+ - AskpassSSHSession: Native ssh with SSH_ASKPASS for GUI prompts (recommended)
9
+ - HybridSSHSession: Interactive auth with ControlMaster for connection reuse
10
+
11
+ Choose based on your needs:
12
+ - Use SSHSession for automation with stored credentials
13
+ - Use AskpassSSHSession for GUI apps with YubiKey/MFA (recommended)
14
+ - Use InteractiveSSHSession for console-like terminal experience
15
+ - Use HybridSSHSession for interactive auth followed by programmatic control
16
+
17
+ For best results with GUI authentication, use AskpassSSHSession.
18
+ """
19
+
20
+ from .base import (
21
+ Session,
22
+ SessionState,
23
+ SessionEvent,
24
+ DataReceived,
25
+ StateChanged,
26
+ InteractionRequired,
27
+ BannerReceived,
28
+ )
29
+ from .ssh import SSHSession
30
+ from .interactive_ssh import InteractiveSSHSession, HybridSSHSession
31
+ from .askpass_ssh import AskpassSSHSession
32
+ from .pty_transport import (
33
+ PTYTransport,
34
+ create_pty,
35
+ is_pty_available,
36
+ IS_WINDOWS,
37
+ HAS_PEXPECT,
38
+ )
39
+
40
+ __all__ = [
41
+ # Base classes
42
+ "Session",
43
+ "SessionState",
44
+ "SessionEvent",
45
+ "DataReceived",
46
+ "StateChanged",
47
+ "InteractionRequired",
48
+ "BannerReceived",
49
+ # Session implementations
50
+ "SSHSession",
51
+ "InteractiveSSHSession",
52
+ "AskpassSSHSession",
53
+ "HybridSSHSession",
54
+ # PTY support
55
+ "PTYTransport",
56
+ "create_pty",
57
+ "is_pty_available",
58
+ "IS_WINDOWS",
59
+ "HAS_PEXPECT",
60
+ ]