ntermqt 0.1.3__py3-none-any.whl → 0.1.4__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.
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Example nterm application.
4
+
5
+ Demonstrates basic usage of the terminal widget with different session types:
6
+ - SSHSession: Paramiko-based (for password/key auth)
7
+ - AskpassSSHSession: Native SSH with GUI prompts (recommended for YubiKey)
8
+ - InteractiveSSHSession: Native SSH with PTY
9
+ """
10
+
11
+ import sys
12
+ import logging
13
+ from PyQt6.QtWidgets import (
14
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
15
+ QComboBox, QLabel, QPushButton, QStatusBar, QLineEdit, QSpinBox,
16
+ QGroupBox, QFormLayout, QMessageBox, QDialog, QDialogButtonBox,
17
+ QInputDialog
18
+ )
19
+ from PyQt6.QtCore import Qt, pyqtSignal, QObject
20
+
21
+ from nterm import (
22
+ ConnectionProfile, AuthConfig, AuthMethod, JumpHostConfig,
23
+ SSHSession, SessionState, TerminalWidget, Theme, ThemeEngine,
24
+ InteractiveSSHSession, is_pty_available
25
+ )
26
+ from nterm.session import AskpassSSHSession
27
+
28
+ logging.basicConfig(level=logging.DEBUG)
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class YubiKeyDialog(QDialog):
33
+ """Dialog shown when YubiKey touch is required."""
34
+
35
+ def __init__(self, prompt: str, parent=None):
36
+ super().__init__(parent)
37
+ self.setWindowTitle("YubiKey Authentication")
38
+ self.setModal(True)
39
+ self.setMinimumWidth(350)
40
+
41
+ layout = QVBoxLayout(self)
42
+
43
+ # Icon/visual indicator
44
+ icon_label = QLabel("🔑")
45
+ icon_label.setStyleSheet("font-size: 48px;")
46
+ icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
47
+ layout.addWidget(icon_label)
48
+
49
+ # Prompt
50
+ prompt_label = QLabel(prompt)
51
+ prompt_label.setWordWrap(True)
52
+ prompt_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
53
+ prompt_label.setStyleSheet("font-size: 14px; margin: 10px;")
54
+ layout.addWidget(prompt_label)
55
+
56
+ # Instructions
57
+ instructions = QLabel("Touch your YubiKey to authenticate...")
58
+ instructions.setAlignment(Qt.AlignmentFlag.AlignCenter)
59
+ instructions.setStyleSheet("color: gray;")
60
+ layout.addWidget(instructions)
61
+
62
+ # Cancel button
63
+ buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Cancel)
64
+ buttons.rejected.connect(self.reject)
65
+ layout.addWidget(buttons)
66
+
67
+
68
+ class NTermWindow(QMainWindow):
69
+ """Main application window."""
70
+
71
+ def __init__(self):
72
+ super().__init__()
73
+ self.setWindowTitle("nterm - SSH Terminal")
74
+ self.resize(1200, 800)
75
+
76
+ self._session = None
77
+ self._theme_engine = ThemeEngine()
78
+ self._yubikey_dialog = None
79
+
80
+ self._setup_ui()
81
+ self._apply_theme("default")
82
+
83
+ def _setup_ui(self):
84
+ """Set up the user interface."""
85
+ central = QWidget()
86
+ self.setCentralWidget(central)
87
+ layout = QVBoxLayout(central)
88
+ layout.setContentsMargins(0, 0, 0, 0)
89
+ layout.setSpacing(0)
90
+
91
+ # Toolbar
92
+ toolbar = self._create_toolbar()
93
+ layout.addWidget(toolbar)
94
+
95
+ # Terminal
96
+ self._terminal = TerminalWidget()
97
+ self._terminal.session_state_changed.connect(self._on_state_changed)
98
+ self._terminal.interaction_required.connect(self._on_interaction)
99
+ layout.addWidget(self._terminal, 1)
100
+
101
+ # Status bar
102
+ self._status = QStatusBar()
103
+ self.setStatusBar(self._status)
104
+ self._status.showMessage("Disconnected")
105
+
106
+ def _create_toolbar(self) -> QWidget:
107
+ """Create connection toolbar."""
108
+ toolbar = QWidget()
109
+ toolbar.setFixedHeight(100)
110
+ layout = QHBoxLayout(toolbar)
111
+ layout.setContentsMargins(8, 8, 8, 8)
112
+
113
+ # Connection group
114
+ conn_group = QGroupBox("Connection")
115
+ conn_layout = QFormLayout(conn_group)
116
+ conn_layout.setContentsMargins(8, 4, 8, 4)
117
+
118
+ self._host_input = QLineEdit()
119
+ self._host_input.setPlaceholderText("hostname or IP")
120
+ self._host_input.setText("localhost")
121
+ conn_layout.addRow("Host:", self._host_input)
122
+
123
+ port_layout = QHBoxLayout()
124
+ self._port_input = QSpinBox()
125
+ self._port_input.setRange(1, 65535)
126
+ self._port_input.setValue(22)
127
+ port_layout.addWidget(self._port_input)
128
+
129
+ self._user_input = QLineEdit()
130
+ self._user_input.setPlaceholderText("username")
131
+ port_layout.addWidget(QLabel("User:"))
132
+ port_layout.addWidget(self._user_input)
133
+ conn_layout.addRow("Port:", port_layout)
134
+
135
+ layout.addWidget(conn_group)
136
+
137
+ # Session type group
138
+ session_group = QGroupBox("Session Type")
139
+ session_layout = QVBoxLayout(session_group)
140
+ session_layout.setContentsMargins(8, 4, 8, 4)
141
+
142
+ self._session_combo = QComboBox()
143
+ self._session_combo.addItem("Askpass (YubiKey GUI)", "askpass")
144
+ self._session_combo.addItem("Interactive (PTY)", "interactive")
145
+ self._session_combo.addItem("Paramiko", "paramiko")
146
+ self._session_combo.currentIndexChanged.connect(self._on_session_type_changed)
147
+ session_layout.addWidget(self._session_combo)
148
+
149
+ # Status indicator
150
+ self._pty_label = QLabel("✓ GUI auth prompts" if is_pty_available() else "⚠ Limited")
151
+ self._pty_label.setStyleSheet("color: green;" if is_pty_available() else "color: orange;")
152
+ session_layout.addWidget(self._pty_label)
153
+
154
+ layout.addWidget(session_group)
155
+
156
+ # Auth group (for Paramiko mode)
157
+ self._auth_group = QGroupBox("Authentication")
158
+ auth_layout = QFormLayout(self._auth_group)
159
+ auth_layout.setContentsMargins(8, 4, 8, 4)
160
+
161
+ self._auth_combo = QComboBox()
162
+ self._auth_combo.addItems(["Agent", "Password", "Key File"])
163
+ auth_layout.addRow("Method:", self._auth_combo)
164
+
165
+ self._password_input = QLineEdit()
166
+ self._password_input.setEchoMode(QLineEdit.EchoMode.Password)
167
+ self._password_input.setPlaceholderText("(for password auth)")
168
+ auth_layout.addRow("Password:", self._password_input)
169
+
170
+ self._auth_group.setVisible(False)
171
+ layout.addWidget(self._auth_group)
172
+
173
+ # Jump host group
174
+ jump_group = QGroupBox("Jump Host (Optional)")
175
+ jump_layout = QFormLayout(jump_group)
176
+ jump_layout.setContentsMargins(8, 4, 8, 4)
177
+
178
+ self._jump_host_input = QLineEdit()
179
+ self._jump_host_input.setPlaceholderText("bastion.example.com")
180
+ jump_layout.addRow("Host:", self._jump_host_input)
181
+
182
+ self._jump_user_input = QLineEdit()
183
+ self._jump_user_input.setPlaceholderText("(same as main if empty)")
184
+ jump_layout.addRow("User:", self._jump_user_input)
185
+
186
+ layout.addWidget(jump_group)
187
+
188
+ # Theme selector
189
+ theme_group = QGroupBox("Theme")
190
+ theme_layout = QVBoxLayout(theme_group)
191
+ theme_layout.setContentsMargins(8, 4, 8, 4)
192
+
193
+ self._theme_combo = QComboBox()
194
+ self._theme_combo.addItems(self._theme_engine.list_themes())
195
+ self._theme_combo.currentTextChanged.connect(self._apply_theme)
196
+ theme_layout.addWidget(self._theme_combo)
197
+
198
+ layout.addWidget(theme_group)
199
+
200
+ # Buttons
201
+ btn_layout = QVBoxLayout()
202
+
203
+ self._connect_btn = QPushButton("Connect")
204
+ self._connect_btn.clicked.connect(self._connect)
205
+ self._connect_btn.setDefault(True)
206
+ btn_layout.addWidget(self._connect_btn)
207
+
208
+ self._disconnect_btn = QPushButton("Disconnect")
209
+ self._disconnect_btn.clicked.connect(self._disconnect)
210
+ self._disconnect_btn.setEnabled(False)
211
+ btn_layout.addWidget(self._disconnect_btn)
212
+
213
+ layout.addLayout(btn_layout)
214
+ layout.addStretch()
215
+
216
+ return toolbar
217
+
218
+ def _on_session_type_changed(self, index: int):
219
+ """Handle session type change."""
220
+ session_type = self._session_combo.currentData()
221
+ self._auth_group.setVisible(session_type == "paramiko")
222
+
223
+ # Update status label
224
+ if session_type == "askpass":
225
+ self._pty_label.setText("✓ GUI auth prompts")
226
+ self._pty_label.setStyleSheet("color: green;")
227
+ elif session_type == "interactive":
228
+ self._pty_label.setText("⚠ Console prompts")
229
+ self._pty_label.setStyleSheet("color: orange;")
230
+ else:
231
+ self._pty_label.setText("✓ Programmatic auth")
232
+ self._pty_label.setStyleSheet("color: green;")
233
+
234
+ def _apply_theme(self, theme_name: str):
235
+ """Apply selected theme."""
236
+ theme = self._theme_engine.get_theme(theme_name)
237
+ if theme:
238
+ self._terminal.set_theme(theme)
239
+
240
+ def _connect(self):
241
+ """Establish connection."""
242
+ hostname = self._host_input.text().strip()
243
+ port = self._port_input.value()
244
+ username = self._user_input.text().strip()
245
+ session_type = self._session_combo.currentData()
246
+
247
+ if not hostname:
248
+ QMessageBox.warning(self, "Error", "Please enter a hostname")
249
+ return
250
+
251
+ if not username:
252
+ QMessageBox.warning(self, "Error", "Please enter a username")
253
+ return
254
+
255
+ # Build auth config
256
+ if session_type in ("askpass", "interactive"):
257
+ auth = AuthConfig.agent_auth(username)
258
+ else:
259
+ auth_method = self._auth_combo.currentText()
260
+ if auth_method == "Agent":
261
+ auth = AuthConfig.agent_auth(username)
262
+ elif auth_method == "Password":
263
+ password = self._password_input.text()
264
+ if not password:
265
+ QMessageBox.warning(self, "Error", "Please enter a password")
266
+ return
267
+ auth = AuthConfig.password_auth(username, password)
268
+ else:
269
+ auth = AuthConfig.agent_auth(username, allow_fallback=True)
270
+
271
+ # Build jump host config if specified
272
+ jump_hosts = []
273
+ jump_host = self._jump_host_input.text().strip()
274
+ if jump_host:
275
+ jump_user = self._jump_user_input.text().strip() or username
276
+ jump_hosts.append(JumpHostConfig(
277
+ hostname=jump_host,
278
+ auth=AuthConfig.agent_auth(jump_user),
279
+ ))
280
+
281
+ # Create profile
282
+ profile = ConnectionProfile(
283
+ name=f"{username}@{hostname}",
284
+ hostname=hostname,
285
+ port=port,
286
+ auth_methods=[auth],
287
+ jump_hosts=jump_hosts,
288
+ auto_reconnect=False, # Disable for testing
289
+ )
290
+
291
+ # Create appropriate session type
292
+ if session_type == "askpass":
293
+ if not is_pty_available():
294
+ QMessageBox.warning(self, "Error", "PTY support required")
295
+ return
296
+ self._session = AskpassSSHSession(profile)
297
+ elif session_type == "interactive":
298
+ if not is_pty_available():
299
+ QMessageBox.warning(self, "Error", "PTY support required")
300
+ return
301
+ self._session = InteractiveSSHSession(profile)
302
+ else:
303
+ self._session = SSHSession(profile)
304
+
305
+ self._terminal.attach_session(self._session)
306
+
307
+ # Connect
308
+ self._session.connect()
309
+ self._connect_btn.setEnabled(False)
310
+ self._disconnect_btn.setEnabled(True)
311
+
312
+ def _disconnect(self):
313
+ """Disconnect session."""
314
+ # Close any open dialogs
315
+ if self._yubikey_dialog:
316
+ self._yubikey_dialog.close()
317
+ self._yubikey_dialog = None
318
+
319
+ if self._session:
320
+ self._session.disconnect()
321
+ self._terminal.detach_session()
322
+ self._session = None
323
+
324
+ self._connect_btn.setEnabled(True)
325
+ self._disconnect_btn.setEnabled(False)
326
+
327
+ def _on_state_changed(self, state: SessionState, message: str):
328
+ """Handle session state changes."""
329
+ status_text = {
330
+ SessionState.DISCONNECTED: "Disconnected",
331
+ SessionState.CONNECTING: "Connecting...",
332
+ SessionState.AUTHENTICATING: "Authenticating...",
333
+ SessionState.CONNECTED: "Connected",
334
+ SessionState.RECONNECTING: f"Reconnecting: {message}",
335
+ SessionState.FAILED: f"Failed: {message}",
336
+ }.get(state, str(state))
337
+
338
+ self._status.showMessage(status_text)
339
+
340
+ # Close YubiKey dialog on connect/disconnect
341
+ if state in (SessionState.CONNECTED, SessionState.DISCONNECTED, SessionState.FAILED):
342
+ if self._yubikey_dialog:
343
+ self._yubikey_dialog.close()
344
+ self._yubikey_dialog = None
345
+
346
+ if state == SessionState.CONNECTED:
347
+ self._connect_btn.setEnabled(False)
348
+ self._disconnect_btn.setEnabled(True)
349
+ self._terminal.focus()
350
+ elif state in (SessionState.DISCONNECTED, SessionState.FAILED):
351
+ self._connect_btn.setEnabled(True)
352
+ self._disconnect_btn.setEnabled(False)
353
+
354
+ def _on_interaction(self, prompt: str, interaction_type: str):
355
+ """Handle SSH authentication prompts."""
356
+ logger.info(f"Interaction required: {interaction_type} - {prompt}")
357
+
358
+ if not isinstance(self._session, AskpassSSHSession):
359
+ return
360
+
361
+ if interaction_type == "yubikey_touch":
362
+ # Show YubiKey dialog
363
+ self._yubikey_dialog = YubiKeyDialog(prompt, self)
364
+ result = self._yubikey_dialog.exec()
365
+ self._yubikey_dialog = None
366
+
367
+ if result == QDialog.DialogCode.Rejected:
368
+ # User cancelled
369
+ self._session.provide_askpass_response(False, error="Cancelled by user")
370
+ else:
371
+ # YubiKey was touched (dialog closed by external event)
372
+ self._session.provide_askpass_response(True, value="")
373
+
374
+ elif interaction_type == "password":
375
+ # Show password dialog
376
+ password, ok = QInputDialog.getText(
377
+ self, "SSH Authentication", prompt,
378
+ QLineEdit.EchoMode.Password
379
+ )
380
+
381
+ if ok and password:
382
+ self._session.provide_askpass_response(True, value=password)
383
+ else:
384
+ self._session.provide_askpass_response(False, error="Cancelled by user")
385
+
386
+ else:
387
+ # Generic input
388
+ text, ok = QInputDialog.getText(
389
+ self, "SSH Authentication", prompt
390
+ )
391
+
392
+ if ok:
393
+ self._session.provide_askpass_response(True, value=text)
394
+ else:
395
+ self._session.provide_askpass_response(False, error="Cancelled by user")
396
+
397
+ def closeEvent(self, event):
398
+ """Handle window close."""
399
+ if self._session:
400
+ self._session.disconnect()
401
+ event.accept()
402
+
403
+
404
+ def main():
405
+ app = QApplication(sys.argv)
406
+ app.setApplicationName("nterm")
407
+
408
+ window = NTermWindow()
409
+ window.show()
410
+
411
+ sys.exit(app.exec())
412
+
413
+
414
+ if __name__ == "__main__":
415
+ main()
nterm/manager/tree.py CHANGED
@@ -26,121 +26,140 @@ class ItemType(Enum):
26
26
  SESSION = auto()
27
27
 
28
28
 
29
+ class DragDropTreeWidget(QTreeWidget):
30
+ """
31
+ QTreeWidget subclass that emits a signal after internal drag-drop operations.
32
+ """
33
+
34
+ items_moved = pyqtSignal() # Emitted after a drop completes
35
+
36
+ def __init__(self, parent=None):
37
+ super().__init__(parent)
38
+
39
+ def dropEvent(self, event):
40
+ """Handle drop - let Qt do the visual move, then signal for persistence."""
41
+ # Let Qt handle the visual rearrangement
42
+ super().dropEvent(event)
43
+ # Signal that items have moved and need persistence
44
+ self.items_moved.emit()
45
+
46
+
29
47
  class SessionTreeWidget(QWidget):
30
48
  """
31
49
  Tree-based session browser with filtering.
32
-
50
+
33
51
  Signals:
34
52
  connect_requested(session, mode): Emitted when user wants to connect
35
53
  session_selected(session): Emitted when selection changes
36
54
  """
37
-
55
+
38
56
  # Connect modes
39
57
  MODE_TAB = "tab"
40
58
  MODE_WINDOW = "window"
41
59
  MODE_QUICK = "quick"
42
-
60
+
43
61
  # Signals
44
62
  connect_requested = pyqtSignal(object, str) # (SavedSession, mode)
45
63
  session_selected = pyqtSignal(object) # SavedSession or None
46
64
  quick_connect_requested = pyqtSignal() # For quick connect dialog
47
-
65
+
48
66
  def __init__(self, store: SessionStore = None, parent: QWidget = None):
49
67
  super().__init__(parent)
50
68
  self.store = store or SessionStore()
51
-
69
+
52
70
  self._filter_timer = QTimer()
53
71
  self._filter_timer.setSingleShot(True)
54
72
  self._filter_timer.timeout.connect(self._apply_filter)
55
-
73
+
56
74
  self._setup_ui()
57
75
  self.refresh()
58
-
76
+
59
77
  def _setup_ui(self) -> None:
60
78
  """Build the UI."""
61
79
  layout = QVBoxLayout(self)
62
80
  layout.setContentsMargins(0, 0, 0, 0)
63
81
  layout.setSpacing(4)
64
-
82
+
65
83
  # Toolbar row
66
84
  toolbar = QHBoxLayout()
67
85
  toolbar.setSpacing(4)
68
-
86
+
69
87
  # Filter input
70
88
  self._filter_input = QLineEdit()
71
89
  self._filter_input.setPlaceholderText("Filter sessions...")
72
90
  self._filter_input.setClearButtonEnabled(True)
73
91
  self._filter_input.textChanged.connect(self._on_filter_changed)
74
92
  toolbar.addWidget(self._filter_input, 1)
75
-
93
+
76
94
  # Quick connect button
77
95
  self._quick_btn = QPushButton("Quick Connect")
78
96
  self._quick_btn.clicked.connect(self.quick_connect_requested.emit)
79
97
  toolbar.addWidget(self._quick_btn)
80
-
98
+
81
99
  layout.addLayout(toolbar)
82
-
83
- # Tree widget
84
- self._tree = QTreeWidget()
100
+
101
+ # Tree widget (using our custom subclass)
102
+ self._tree = DragDropTreeWidget()
85
103
  self._tree.setHeaderHidden(True)
86
104
  self._tree.setRootIsDecorated(True)
87
105
  self._tree.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
88
106
  self._tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
89
107
  self._tree.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
90
108
  self._tree.setAnimated(True)
91
-
109
+
92
110
  # Signals
93
111
  self._tree.itemDoubleClicked.connect(self._on_double_click)
94
112
  self._tree.itemSelectionChanged.connect(self._on_selection_changed)
95
113
  self._tree.customContextMenuRequested.connect(self._show_context_menu)
96
114
  self._tree.itemExpanded.connect(self._on_item_expanded)
97
115
  self._tree.itemCollapsed.connect(self._on_item_collapsed)
98
-
116
+ self._tree.items_moved.connect(self._persist_tree_state) # Handle drag-drop
117
+
99
118
  layout.addWidget(self._tree)
100
-
119
+
101
120
  # Action buttons row
102
121
  btn_row = QHBoxLayout()
103
122
  btn_row.setSpacing(4)
104
-
123
+
105
124
  self._connect_tab_btn = QPushButton("Connect")
106
125
  self._connect_tab_btn.setToolTip("Connect in new tab")
107
126
  self._connect_tab_btn.clicked.connect(lambda: self._connect_selected(self.MODE_TAB))
108
127
  self._connect_tab_btn.setEnabled(False)
109
128
  btn_row.addWidget(self._connect_tab_btn)
110
-
129
+
111
130
  self._connect_win_btn = QPushButton("New")
112
131
  self._connect_win_btn.setToolTip("Connect in separate window")
113
132
  self._connect_win_btn.clicked.connect(lambda: self._connect_selected(self.MODE_WINDOW))
114
133
  self._connect_win_btn.setEnabled(False)
115
134
  btn_row.addWidget(self._connect_win_btn)
116
-
135
+
117
136
  btn_row.addStretch()
118
-
137
+
119
138
  self._add_btn = QPushButton("+")
120
139
  self._add_btn.setFixedWidth(32)
121
140
  self._add_btn.setToolTip("Add session or folder")
122
141
  self._add_btn.clicked.connect(self._show_add_menu)
123
142
  btn_row.addWidget(self._add_btn)
124
-
143
+
125
144
  layout.addLayout(btn_row)
126
-
145
+
127
146
  # -------------------------------------------------------------------------
128
147
  # Public API
129
148
  # -------------------------------------------------------------------------
130
-
149
+
131
150
  def refresh(self) -> None:
132
151
  """Reload tree from store."""
133
152
  self._tree.clear()
134
153
  tree_data = self.store.get_tree()
135
-
154
+
136
155
  # Build folder lookup
137
156
  folder_items: dict[int, QTreeWidgetItem] = {}
138
-
157
+
139
158
  # First pass: create all folder items
140
159
  for folder in tree_data["folders"]:
141
160
  item = self._create_folder_item(folder)
142
161
  folder_items[folder.id] = item
143
-
162
+
144
163
  # Second pass: parent folders correctly
145
164
  for folder in tree_data["folders"]:
146
165
  item = folder_items[folder.id]
@@ -148,10 +167,10 @@ class SessionTreeWidget(QWidget):
148
167
  folder_items[folder.parent_id].addChild(item)
149
168
  else:
150
169
  self._tree.addTopLevelItem(item)
151
-
170
+
152
171
  # Restore expanded state
153
172
  item.setExpanded(folder.expanded)
154
-
173
+
155
174
  # Add sessions
156
175
  for session in tree_data["sessions"]:
157
176
  item = self._create_session_item(session)
@@ -159,9 +178,9 @@ class SessionTreeWidget(QWidget):
159
178
  folder_items[session.folder_id].addChild(item)
160
179
  else:
161
180
  self._tree.addTopLevelItem(item)
162
-
181
+
163
182
  self._apply_filter()
164
-
183
+
165
184
  def get_selected_session(self) -> Optional[SavedSession]:
166
185
  """Get currently selected session, or None."""
167
186
  items = self._tree.selectedItems()
@@ -172,17 +191,17 @@ class SessionTreeWidget(QWidget):
172
191
  session_id = item.data(0, ROLE_ITEM_ID)
173
192
  return self.store.get_session(session_id)
174
193
  return None
175
-
194
+
176
195
  def select_session(self, session_id: int) -> None:
177
196
  """Select a session by ID."""
178
197
  item = self._find_session_item(session_id)
179
198
  if item:
180
199
  self._tree.setCurrentItem(item)
181
-
200
+
182
201
  # -------------------------------------------------------------------------
183
202
  # Item creation
184
203
  # -------------------------------------------------------------------------
185
-
204
+
186
205
  def _create_folder_item(self, folder: SessionFolder) -> QTreeWidgetItem:
187
206
  """Create tree item for a folder."""
188
207
  item = QTreeWidgetItem()
@@ -190,31 +209,32 @@ class SessionTreeWidget(QWidget):
190
209
  item.setData(0, ROLE_ITEM_TYPE, ItemType.FOLDER)
191
210
  item.setData(0, ROLE_ITEM_ID, folder.id)
192
211
  item.setFlags(
193
- item.flags() |
212
+ item.flags() |
213
+ Qt.ItemFlag.ItemIsDragEnabled | # Folders can be dragged too
194
214
  Qt.ItemFlag.ItemIsDropEnabled
195
215
  )
196
216
  return item
197
-
217
+
198
218
  def _create_session_item(self, session: SavedSession) -> QTreeWidgetItem:
199
219
  """Create tree item for a session."""
200
220
  item = QTreeWidgetItem()
201
-
221
+
202
222
  # Display text
203
223
  display = f"🖥 {session.name}"
204
224
  if session.description:
205
225
  display += f" ({session.description})"
206
226
  item.setText(0, display)
207
227
  item.setToolTip(0, f"{session.hostname}:{session.port}")
208
-
228
+
209
229
  item.setData(0, ROLE_ITEM_TYPE, ItemType.SESSION)
210
230
  item.setData(0, ROLE_ITEM_ID, session.id)
211
231
  item.setFlags(
212
- item.flags() |
232
+ item.flags() |
213
233
  Qt.ItemFlag.ItemIsDragEnabled |
214
234
  Qt.ItemFlag.ItemNeverHasChildren
215
235
  )
216
236
  return item
217
-
237
+
218
238
  def _find_session_item(self, session_id: int) -> Optional[QTreeWidgetItem]:
219
239
  """Find tree item for a session ID."""
220
240
  iterator = self._tree_iterator()
@@ -223,7 +243,7 @@ class SessionTreeWidget(QWidget):
223
243
  item.data(0, ROLE_ITEM_ID) == session_id):
224
244
  return item
225
245
  return None
226
-
246
+
227
247
  def _tree_iterator(self):
228
248
  """Iterate all items in tree."""
229
249
  def recurse(parent):
@@ -231,11 +251,74 @@ class SessionTreeWidget(QWidget):
231
251
  child = parent.child(i)
232
252
  yield child
233
253
  yield from recurse(child)
234
-
254
+
235
255
  for i in range(self._tree.topLevelItemCount()):
236
256
  item = self._tree.topLevelItem(i)
237
257
  yield item
238
258
  yield from recurse(item)
259
+
260
+ # -------------------------------------------------------------------------
261
+ # Drag-drop persistence
262
+ # -------------------------------------------------------------------------
263
+
264
+ def _persist_tree_state(self) -> None:
265
+ """
266
+ Persist the current tree state to the store after drag-drop.
267
+
268
+ Walks the visual tree and updates folder_id/parent_id and positions
269
+ to match the current visual arrangement.
270
+ """
271
+ def get_folder_id_for_item(item: QTreeWidgetItem) -> Optional[int]:
272
+ """Get the folder ID that contains this item, or None for root."""
273
+ parent = item.parent()
274
+ if parent is None:
275
+ return None
276
+ # Parent should be a folder
277
+ if parent.data(0, ROLE_ITEM_TYPE) == ItemType.FOLDER:
278
+ return parent.data(0, ROLE_ITEM_ID)
279
+ return None
280
+
281
+ def process_children(parent_item, parent_folder_id: Optional[int]) -> None:
282
+ """Process all children of a parent (either root or folder)."""
283
+ if parent_item is None:
284
+ # Processing root level
285
+ count = self._tree.topLevelItemCount()
286
+ for pos in range(count):
287
+ item = self._tree.topLevelItem(pos)
288
+ process_item(item, None, pos)
289
+ else:
290
+ # Processing folder children
291
+ count = parent_item.childCount()
292
+ for pos in range(count):
293
+ item = parent_item.child(pos)
294
+ process_item(item, parent_folder_id, pos)
295
+
296
+ def process_item(item: QTreeWidgetItem, parent_folder_id: Optional[int], position: int) -> None:
297
+ """Process a single item - update its position and parent."""
298
+ item_type = item.data(0, ROLE_ITEM_TYPE)
299
+ item_id = item.data(0, ROLE_ITEM_ID)
300
+
301
+ if item_type == ItemType.SESSION:
302
+ # Update session's folder and position
303
+ session = self.store.get_session(item_id)
304
+ if session and (session.folder_id != parent_folder_id or session.position != position):
305
+ session.folder_id = parent_folder_id
306
+ session.position = position
307
+ self.store.update_session(session)
308
+
309
+ elif item_type == ItemType.FOLDER:
310
+ # Update folder's parent and position
311
+ folder = self.store.get_folder(item_id)
312
+ if folder and (folder.parent_id != parent_folder_id or folder.position != position):
313
+ folder.parent_id = parent_folder_id
314
+ folder.position = position
315
+ self.store.update_folder(folder)
316
+
317
+ # Recursively process folder's children
318
+ process_children(item, item_id)
319
+
320
+ # Start processing from root level
321
+ process_children(None, None)
239
322
 
240
323
  # -------------------------------------------------------------------------
241
324
  # Filtering
nterm/terminal/bridge.py CHANGED
@@ -38,6 +38,16 @@ class TerminalBridge(QObject):
38
38
  paste_requested = pyqtSignal(str) # base64 clipboard content for confirmation
39
39
  paste_confirmed = pyqtSignal() # user confirmed multiline paste
40
40
  paste_cancelled = pyqtSignal() # user cancelled multiline paste
41
+ # Signal to JS (Python -> JavaScript)
42
+ set_capture_state = pyqtSignal(bool, str) # is_capturing, filename
43
+
44
+ # Signal from JS (JavaScript -> Python)
45
+ capture_toggled = pyqtSignal()
46
+
47
+ @pyqtSlot()
48
+ def onCaptureToggle(self):
49
+ """Called from JS when capture menu item clicked."""
50
+ self.capture_toggled.emit()
41
51
 
42
52
  def __init__(self):
43
53
  super().__init__()
@@ -237,10 +237,15 @@
237
237
  <span class="context-menu-shortcut">Ctrl+Shift+V</span>
238
238
  </div>
239
239
  <div class="context-menu-separator"></div>
240
- <div class="context-menu-item" id="ctx-clear">
241
- <span>Clear Terminal</span>
242
- <span class="context-menu-shortcut"></span>
243
- </div>
240
+ <div class="context-menu-item" id="ctx-capture">
241
+ <span id="ctx-capture-text">Start Capture...</span>
242
+ <span class="context-menu-shortcut"></span>
243
+ </div>
244
+ <div class="context-menu-separator"></div>
245
+ <div class="context-menu-item" id="ctx-clear">
246
+ <span>Clear Terminal</span>
247
+ <span class="context-menu-shortcut"></span>
248
+ </div>
244
249
  </div>
245
250
 
246
251
  <script src="xterm.min.js"></script>
@@ -125,6 +125,12 @@
125
125
  contextMenu = document.getElementById('context-menu');
126
126
  const container = document.getElementById('terminal');
127
127
 
128
+ document.getElementById('ctx-capture').addEventListener('click', () => {
129
+ contextMenu.classList.remove('visible');
130
+ if (bridge) {
131
+ bridge.onCaptureToggle();
132
+ }
133
+ });
128
134
  // Show context menu on right-click
129
135
  container.addEventListener('contextmenu', (e) => {
130
136
  e.preventDefault();
@@ -274,7 +280,14 @@
274
280
  function setupBridge() {
275
281
  new QWebChannel(qt.webChannelTransport, function(channel) {
276
282
  bridge = channel.objects.bridge;
277
-
283
+ bridge.set_capture_state.connect(function(isCapturing, filename) {
284
+ const captureText = document.getElementById('ctx-capture-text');
285
+ if (isCapturing) {
286
+ captureText.textContent = 'Stop Capture (' + filename + ')';
287
+ } else {
288
+ captureText.textContent = 'Start Capture...';
289
+ }
290
+ });
278
291
  // Data from Python to terminal - properly decode UTF-8
279
292
  bridge.write_data.connect(function(dataB64) {
280
293
  try {
nterm/terminal/widget.py CHANGED
@@ -6,11 +6,12 @@ from __future__ import annotations
6
6
  import base64
7
7
  import json
8
8
  import logging
9
+ import re
9
10
  from pathlib import Path
10
- from typing import Optional
11
+ from typing import Optional, BinaryIO
11
12
 
12
13
  from PyQt6.QtCore import Qt, QUrl, pyqtSignal, pyqtSlot
13
- from PyQt6.QtWidgets import QWidget, QVBoxLayout, QApplication
14
+ from PyQt6.QtWidgets import QWidget, QVBoxLayout, QApplication, QFileDialog
14
15
  from PyQt6.QtWebEngineWidgets import QWebEngineView
15
16
  from PyQt6.QtWebEngineCore import QWebEngineSettings
16
17
  from PyQt6.QtWebChannel import QWebChannel
@@ -29,6 +30,9 @@ from nterm.resources import resources
29
30
  # Default threshold for multiline paste warning
30
31
  MULTILINE_PASTE_THRESHOLD = 1
31
32
 
33
+ # ANSI escape sequence pattern for stripping from capture logs
34
+ ANSI_ESCAPE = re.compile(rb'\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b\[[\?0-9;]*[hl]')
35
+
32
36
 
33
37
  class TerminalWidget(QWidget):
34
38
  """
@@ -55,6 +59,10 @@ class TerminalWidget(QWidget):
55
59
  self._multiline_threshold = multiline_threshold
56
60
  self._pending_paste: Optional[bytes] = None # held during confirmation
57
61
 
62
+ # Session capture
63
+ self._capture_file: Optional[BinaryIO] = None
64
+ self._capture_path: Optional[Path] = None
65
+
58
66
  self._setup_ui()
59
67
  self._setup_bridge()
60
68
 
@@ -99,6 +107,9 @@ class TerminalWidget(QWidget):
99
107
  self._bridge.paste_confirmed.connect(self._on_paste_confirmed)
100
108
  self._bridge.paste_cancelled.connect(self._on_paste_cancelled)
101
109
 
110
+ # Capture signals
111
+ self._bridge.capture_toggled.connect(self._on_capture_toggle)
112
+
102
113
  # Load terminal HTML
103
114
  try:
104
115
  html_path = resources.get_path("terminal", "resources", "terminal.html")
@@ -130,6 +141,9 @@ class TerminalWidget(QWidget):
130
141
  self._awaiting_reconnect_confirm = False
131
142
  logger.debug("Detached session")
132
143
 
144
+ # Stop any active capture
145
+ self.stop_capture()
146
+
133
147
  def set_theme(self, theme: Theme) -> None:
134
148
  """
135
149
  Apply theme to terminal.
@@ -157,6 +171,11 @@ class TerminalWidget(QWidget):
157
171
  Args:
158
172
  data: Bytes to display
159
173
  """
174
+ # Session capture - strip ANSI escapes for clean text
175
+ if self._capture_file:
176
+ clean = ANSI_ESCAPE.sub(b'', data)
177
+ self._capture_file.write(clean)
178
+
160
179
  if self._ready:
161
180
  data_b64 = base64.b64encode(data).decode('ascii')
162
181
  self._bridge.write_data.emit(data_b64)
@@ -190,6 +209,58 @@ class TerminalWidget(QWidget):
190
209
  if self._ready:
191
210
  self._bridge.hide_overlay.emit()
192
211
 
212
+ # -------------------------------------------------------------------------
213
+ # Session capture
214
+ # -------------------------------------------------------------------------
215
+
216
+ @property
217
+ def is_capturing(self) -> bool:
218
+ """Check if session capture is active."""
219
+ return self._capture_file is not None
220
+
221
+ def start_capture(self, path: Path) -> None:
222
+ """
223
+ Start capturing session output to file.
224
+
225
+ Args:
226
+ path: File path to write captured output
227
+ """
228
+ self.stop_capture() # Close any existing capture
229
+ self._capture_path = path
230
+ self._capture_file = open(path, 'wb')
231
+ self._bridge.set_capture_state.emit(True, path.name)
232
+ logger.info(f"Started capture: {path}")
233
+
234
+ def stop_capture(self) -> None:
235
+ """Stop capturing session output."""
236
+ if self._capture_file:
237
+ self._capture_file.close()
238
+ logger.info(f"Stopped capture: {self._capture_path}")
239
+ self._capture_file = None
240
+ self._capture_path = None
241
+ self._bridge.set_capture_state.emit(False, "")
242
+
243
+ @pyqtSlot()
244
+ def _on_capture_toggle(self):
245
+ """Handle capture menu item click."""
246
+ if self._capture_file:
247
+ self.stop_capture()
248
+ else:
249
+ # Show file save dialog
250
+ default_name = "session.log"
251
+ if self._session:
252
+ # Use hostname if available for default filename
253
+ default_name = f"session_{self._session.hostname}.log" if hasattr(self._session, 'hostname') else "session.log"
254
+
255
+ path, _ = QFileDialog.getSaveFileName(
256
+ self,
257
+ "Save Session Capture",
258
+ str(Path.home() / default_name),
259
+ "Log Files (*.log *.txt);;All Files (*)"
260
+ )
261
+ if path:
262
+ self.start_capture(Path(path))
263
+
193
264
  # -------------------------------------------------------------------------
194
265
  # Clipboard operations
195
266
  # -------------------------------------------------------------------------
nterm/theme/engine.py CHANGED
@@ -564,6 +564,50 @@ class Theme:
564
564
  overlay_text_color="#3c3836",
565
565
  )
566
566
 
567
+ @classmethod
568
+ def nord_hybrid(cls) -> Theme:
569
+ """
570
+ Nord Hybrid theme.
571
+
572
+ Polar Night UI + Snow Storm terminal.
573
+ Dark chrome, soft light terminal (not harsh white).
574
+ """
575
+ return cls(
576
+ name="nord_hybrid",
577
+ terminal_colors={
578
+ "background": "#eceff4",
579
+ "foreground": "#2e3440",
580
+ "cursor": "#2e3440",
581
+ "cursorAccent": "#eceff4",
582
+ "selectionBackground": "#d8dee9",
583
+ "selectionForeground": "#2e3440",
584
+ "black": "#2e3440",
585
+ "red": "#bf616a",
586
+ "green": "#a3be8c",
587
+ "yellow": "#d08770",
588
+ "blue": "#5e81ac",
589
+ "magenta": "#b48ead",
590
+ "cyan": "#88c0d0",
591
+ "white": "#d8dee9",
592
+ "brightBlack": "#4c566a",
593
+ "brightRed": "#bf616a",
594
+ "brightGreen": "#a3be8c",
595
+ "brightYellow": "#ebcb8b",
596
+ "brightBlue": "#81a1c1",
597
+ "brightMagenta": "#b48ead",
598
+ "brightCyan": "#8fbcbb",
599
+ "brightWhite": "#eceff4",
600
+ },
601
+ font_family="JetBrains Mono, Cascadia Code, Consolas, Menlo, monospace",
602
+ font_size=14,
603
+ background_color="#2e3440",
604
+ foreground_color="#d8dee9",
605
+ border_color="#3b4252",
606
+ accent_color="#88c0d0",
607
+ overlay_background="rgba(46, 52, 64, 0.95)",
608
+ overlay_text_color="#eceff4",
609
+ )
610
+
567
611
  @classmethod
568
612
  def gruvbox_hybrid(cls) -> Theme:
569
613
  """
@@ -630,6 +674,7 @@ class ThemeEngine:
630
674
  self._themes["default"] = Theme.default()
631
675
  self._themes["dracula"] = Theme.dracula()
632
676
  self._themes["nord"] = Theme.nord()
677
+ self._themes["nord_hybrid"] = Theme.nord_hybrid()
633
678
  self._themes["solarized_dark"] = Theme.solarized_dark()
634
679
  self._themes["gruvbox_dark"] = Theme.gruvbox_dark()
635
680
  self._themes["gruvbox_light"] = Theme.gruvbox_light()
@@ -0,0 +1,43 @@
1
+ name: nord_hybrid
2
+
3
+ # Nord Hybrid theme
4
+ # Polar Night UI + softer Snow Storm terminal
5
+ # Adjusted palette for better contrast on light background
6
+
7
+ terminal_colors:
8
+ # Softer terminal - nord4 instead of nord6
9
+ background: "#d8dee9"
10
+ foreground: "#2e3440"
11
+ cursor: "#2e3440"
12
+ cursorAccent: "#d8dee9"
13
+ selectionBackground: "#4c566a"
14
+ selectionForeground: "#eceff4"
15
+ # Darkened palette for light background contrast
16
+ black: "#2e3440"
17
+ red: "#a54242"
18
+ green: "#4e6a3d"
19
+ yellow: "#a07040"
20
+ blue: "#3b6186"
21
+ magenta: "#8a4b7c"
22
+ cyan: "#2b7694"
23
+ white: "#4c566a"
24
+ brightBlack: "#3b4252"
25
+ brightRed: "#bf616a"
26
+ brightGreen: "#5c8045"
27
+ brightYellow: "#d08770"
28
+ brightBlue: "#5e81ac"
29
+ brightMagenta: "#b48ead"
30
+ brightCyan: "#4e9a9a"
31
+ brightWhite: "#2e3440"
32
+
33
+ font_family: "JetBrains Mono, Cascadia Code, Consolas, monospace"
34
+ font_size: 14
35
+
36
+ # Polar Night UI chrome (unchanged)
37
+ background_color: "#2e3440"
38
+ foreground_color: "#d8dee9"
39
+ border_color: "#3b4252"
40
+ accent_color: "#88c0d0"
41
+
42
+ overlay_background: "rgba(46, 52, 64, 0.95)"
43
+ overlay_text_color: "#eceff4"
nterm/vault/store.py CHANGED
@@ -295,7 +295,7 @@ class CredentialStore:
295
295
 
296
296
  # Encrypt sensitive fields
297
297
  password_enc = self._encrypt(password) if password else None
298
- ssh_key_enc = self._encrypt(ssh_key) if ssh_key else None
298
+ ssh_key_enc = self._encrypt(ssh_key.strip()) if ssh_key else None
299
299
  ssh_key_pass_enc = self._encrypt(ssh_key_passphrase) if ssh_key_passphrase else None
300
300
 
301
301
  # Serialize lists
@@ -523,8 +523,8 @@ class CredentialStore:
523
523
  updates['password_enc'] = self._encrypt(kwargs['password']) if kwargs['password'] else None
524
524
 
525
525
  if 'ssh_key' in kwargs:
526
- updates['ssh_key_enc'] = self._encrypt(kwargs['ssh_key']) if kwargs['ssh_key'] else None
527
-
526
+ updates['ssh_key_enc'] = self._encrypt(kwargs['ssh_key'].strip()) if kwargs['ssh_key'] else None
527
+
528
528
  if 'ssh_key_passphrase' in kwargs:
529
529
  updates['ssh_key_passphrase_enc'] = self._encrypt(kwargs['ssh_key_passphrase']) if kwargs['ssh_key_passphrase'] else None
530
530
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ntermqt
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Modern SSH terminal widget for PyQt6 with credential vault and jump host support
5
5
  Author: Scott Peterman
6
6
  License: GPL-3.0
@@ -59,9 +59,11 @@ Built for managing hundreds of devices through bastion hosts with hardware secur
59
59
 
60
60
  **Terminal**
61
61
  - xterm.js rendering via QWebEngineView — full VT100/ANSI support
62
- - Built-in themes: Catppuccin, Dracula, Nord, Solarized, Gruvbox (dark/light/hybrid)
62
+ - 12 built-in themes: Catppuccin, Dracula, Nord, Solarized, Gruvbox, Enterprise variants
63
+ - Hybrid themes: dark UI chrome with light terminal for readability
63
64
  - Custom YAML themes with independent terminal and UI colors
64
65
  - Tab or window per session — pop sessions to separate windows
66
+ - Session capture to file (clean text, ANSI stripped)
65
67
  - Unicode, emoji, box-drawing characters
66
68
 
67
69
  **Authentication**
@@ -306,19 +308,33 @@ session.connect()
306
308
 
307
309
  ## Themes
308
310
 
309
- ### Built-in
311
+ nterm includes 12 built-in themes covering dark, light, and hybrid styles.
312
+
313
+ ### Built-in Themes
310
314
 
311
315
  ```python
312
- Theme.default() # Catppuccin Mocha
313
- Theme.dracula() # Dracula
314
- Theme.nord() # Nord
315
- Theme.solarized_dark() # Solarized Dark
316
- Theme.gruvbox_dark() # Gruvbox Dark
317
- Theme.gruvbox_light() # Gruvbox Light
318
- Theme.gruvbox_hybrid() # Dark UI + Light terminal
316
+ # Dark themes
317
+ Theme.default() # Catppuccin Mocha
318
+ Theme.dracula() # Dracula
319
+ Theme.nord() # Nord
320
+ Theme.solarized_dark() # Solarized Dark
321
+ Theme.gruvbox_dark() # Gruvbox Dark
322
+ Theme.enterprise_dark() # Microsoft-inspired dark
323
+
324
+ # Light themes
325
+ Theme.gruvbox_light() # Gruvbox Light
326
+ Theme.enterprise_light() # Microsoft-inspired light
327
+ Theme.clean() # Warm paper tones
328
+
329
+ # Hybrid themes (dark UI + light terminal)
330
+ Theme.gruvbox_hybrid() # Gruvbox dark chrome, light terminal
331
+ Theme.nord_hybrid() # Nord polar night chrome, snow storm terminal
332
+ Theme.enterprise_hybrid() # VS Code-style dark/light split
319
333
  ```
320
334
 
321
- ### Custom YAML
335
+ **Hybrid themes** combine a dark application chrome (menus, tabs, sidebars) with a light terminal for maximum readability — ideal for long sessions reviewing configs or logs.
336
+
337
+ ### Custom YAML Themes
322
338
 
323
339
  ```yaml
324
340
  # ~/.nterm/themes/my-theme.yaml
@@ -347,6 +363,19 @@ accent_color: "#7aa2f7"
347
363
 
348
364
  ---
349
365
 
366
+ ## Session Capture
367
+
368
+ Capture session output to a file for documentation, auditing, or extracting config snippets.
369
+
370
+ **Right-click in terminal → Start Capture...** to begin recording. Output is saved as clean text with ANSI escape sequences stripped — ready for grep, diff, or pasting into tickets.
371
+
372
+ - Per-session capture (each tab independent)
373
+ - File dialog for save location
374
+ - Menu shows active capture filename
375
+ - Auto-stops when session closes
376
+
377
+ ---
378
+
350
379
  ## Jump Hosts
351
380
 
352
381
  ```python
@@ -6,13 +6,14 @@ nterm/askpass/__init__.py,sha256=UpJBk0EOm0nkRwMVv7YdIB4v75ZJpSYmNsU_GlgzbUg,495
6
6
  nterm/askpass/server.py,sha256=5tvjYryyfu-n8Cw2KbucwaZfWiqYnFk-iBAVBI8FMfw,12873
7
7
  nterm/connection/__init__.py,sha256=2qQ9LGxUxmwem8deOD2WZVkeD6rIVlTlx5Zh2cUEmxY,261
8
8
  nterm/connection/profile.py,sha256=4RMgnRNKCc-dFGEIpmQc_bob5MtzxO04_PljP-qUGLs,9450
9
+ nterm/examples/basic_terminal.py,sha256=vbDI1xl-Radv6GYZ0yC6QUafQp_tSX2pWIf7tk58W8E,15256
9
10
  nterm/manager/__init__.py,sha256=_QIeTap5CTL3jdTS1Q16fAt-PrqcNPUVr9gtJ22f0ng,774
10
11
  nterm/manager/connect_dialog.py,sha256=yd8g_gYttT_UdflRxSfyss8OQTfrvKLUOMg4Kj8FPNo,11711
11
12
  nterm/manager/editor.py,sha256=Fn2YWHJ1EwPYrhKhsi4GTBYwRfCYsHsqgKkLY-LQ8JI,8469
12
13
  nterm/manager/io.py,sha256=R5ksWgpEz0VdVCokcgTN5G3PFgp5QYhjjt40OypSWkY,21687
13
14
  nterm/manager/models.py,sha256=cvC2HzCRadNG1EYsnZN4C9YS6uolHGcUGGZtt-wzGF4,12237
14
15
  nterm/manager/settings.py,sha256=r6MTw_9r1Wl2UX_ALpXIuPbDvJ0D91Y8wRKq6Bfr_3g,9210
15
- nterm/manager/tree.py,sha256=D1aLVH7xy9m-V1PSwu-GFW1r6UYd93zyufrO0HBRidE,18996
16
+ nterm/manager/tree.py,sha256=I78wSjkSuyM6903II-XNyPug9saMSODUNBCHCDrq4ls,22397
16
17
  nterm/scripting/__init__.py,sha256=4WvwvJfJNMwXW6jas8wFreIzKBgjvAhMQnR2cnA_mEE,967
17
18
  nterm/scripting/api.py,sha256=O-EyV0ksj7LATMSSPrDJShE3x4JPuEBs0SsPZdc2yUo,13931
18
19
  nterm/scripting/cli.py,sha256=W2DK4ZnuutaArye_to7CBchg0ogClURxVbGsMdnj1y0,9187
@@ -24,17 +25,17 @@ nterm/session/local_terminal.py,sha256=sG2lFAOpItMiT93dYCi05nrGRS-MB52XG4J-iZbco
24
25
  nterm/session/pty_transport.py,sha256=QwSFqKKuJhgcLWzv1CUKf3aCGDGbbkmmGwIB1L1A2PU,17176
25
26
  nterm/session/ssh.py,sha256=sGOxjBa9FX6GjVwkmfiKsupoLVsrPVk-LSREjlNmAdE,20942
26
27
  nterm/terminal/__init__.py,sha256=uFnG366Z166pK-ijT1dZanVSSFVZCiMGeNKXvss_sDg,184
27
- nterm/terminal/bridge.py,sha256=FSZMArlq-7a3IsKUrOL42MZeDJ4FfdZwC5UTRk1xryc,2860
28
- nterm/terminal/widget.py,sha256=snUBsH4W0njPogGk3TJYJuvye3YCL1GJSt3cV2YFCeI,13165
29
- nterm/terminal/resources/terminal.html,sha256=LNtXx19SVANjObNydWoAqkBJloKz_bt0kbN41Z7EFxI,7778
30
- nterm/terminal/resources/terminal.js,sha256=wZqVhEu-_KASGfDo-s3nmVZKhupI2r-Ak_poIn_1FU8,13224
28
+ nterm/terminal/bridge.py,sha256=mSkxZr3UGyaFI14w08dzekCkOhfUetq0GIjrBtA3qI0,3199
29
+ nterm/terminal/widget.py,sha256=mxUrQxFmigNR6S3vgnzHahTRGYQI2bNYTBqNg47yaR8,15716
30
+ nterm/terminal/resources/terminal.html,sha256=1onb3qUdDa0qzETR8XaKx0UR6BPlCm_ZpMFVgt36ZPA,7985
31
+ nterm/terminal/resources/terminal.js,sha256=zW9n1MRujSXv66ENgU-gzk_mc75EpWye_f88ejChSW4,13852
31
32
  nterm/terminal/resources/xterm-addon-fit.min.js,sha256=x45XlcZIes3ySrQ2eY1KnOw4SBAbKBvGWwYfOdtxS-E,1789
32
33
  nterm/terminal/resources/xterm-addon-unicode11.min.js,sha256=_sT7CbMSksBfUPmKZYj29IDjq7LMjiwciFs0iGNomBM,7500
33
34
  nterm/terminal/resources/xterm-addon-web-links.min.js,sha256=_iizzOZ3_DRg6y7iu111muLnWVW8bzC9V6_EAPu0hK8,3219
34
35
  nterm/terminal/resources/xterm.css,sha256=gy8_LGA7Q61DUf8ElwFQzHqHMBQnbbEmpgZcbdgeSHI,5383
35
36
  nterm/terminal/resources/xterm.min.js,sha256=_B3TGyIePl-SlIbgeoC0d6iq-dzitPnD_-fdJfNwZV0,283670
36
37
  nterm/theme/__init__.py,sha256=ZTywoJliQcFre0Gh7I30n-_7RrPmdR1NHnE4wSkSCsQ,130
37
- nterm/theme/engine.py,sha256=e1mh8JQeErqc59d1-rbcsbVvz86_BEZCrtDapDjQOyk,24718
38
+ nterm/theme/engine.py,sha256=0C3K9hoFOdEVJv3xJXmPs1DPGT2mSVJNtA0dDm4w-uA,26340
38
39
  nterm/theme/stylesheet.py,sha256=Ycy-y_kiP-SLcQFrAEdJtbSDtKm4yvBfxEe-N26qlDg,9004
39
40
  nterm/theme/themes/clean.yaml,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
40
41
  nterm/theme/themes/default.yaml,sha256=niUrI_K8fayPCZDy1gc3hueLtkWjfmm1p1R33JjYgS4,810
@@ -45,15 +46,16 @@ nterm/theme/themes/enterprise_light.yaml,sha256=Q6H5lSsStoFVJNFS63IPp0FaBhkjN9uB
45
46
  nterm/theme/themes/gruvbox_dark.yaml,sha256=cAr-67R7QhW80ncHptpyyrZuUqD65xoSuLtmHeDgQM0,815
46
47
  nterm/theme/themes/gruvbox_hybrid.yaml,sha256=Ml7Ed3sTBjcSYVJ9t961KhiG3DwMAdVdRBtzI4eWZg0,936
47
48
  nterm/theme/themes/gruvbox_light.yaml,sha256=InqYF-TsLLIzhCHpSSHqSxnest5tu28htQ4AaFN4BFY,820
49
+ nterm/theme/themes/nord_hybrid.yaml,sha256=QAT056Jo2UAdQPmbc3GezjpD7Mge-GQSl4wPeSiaqSE,1065
48
50
  nterm/vault/__init__.py,sha256=e1W3GZKOf0FXNerSp1mojl-yaidYIsygnRwTGBd6mfM,708
49
51
  nterm/vault/credential_manager.py,sha256=TWAMfjpntPXEJ-4AauDz2PPS0q140sUebFk8AjvC-A0,5347
50
52
  nterm/vault/keychain.py,sha256=_2-yUhc2ro-An2zvFlJHYyxozM55iJ4bSseOVKMCNGo,4229
51
53
  nterm/vault/manager_ui.py,sha256=qle-W40j6L_pOR0AaOCeyU8myizFTRkISNrloCn0H_Y,34530
52
54
  nterm/vault/profile.py,sha256=qM9TJf68RKdjtxo-sJehO7wS4iTi2G26BKbmlmHLA5M,6246
53
55
  nterm/vault/resolver.py,sha256=GWB2YR9H1MH98RGQBKvitIsjWT_-wSMLuddZNz4wbns,7800
54
- nterm/vault/store.py,sha256=fbmABHWRjkZ5t42O7r74D3B_R2mZ0WPY8STPo3fGMdw,21191
55
- ntermqt-0.1.3.dist-info/METADATA,sha256=oP3lHksgZAXVWTV3GL3e5c3s8snzr1d_0meieLh-xqA,12344
56
- ntermqt-0.1.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
57
- ntermqt-0.1.3.dist-info/entry_points.txt,sha256=Gunr-_3w-aSpfqoMuGKM2PJSCRo9hZ7K1BksUtp1yd8,130
58
- ntermqt-0.1.3.dist-info/top_level.txt,sha256=bZdnNLTHNRNqo9jsOQGUWF7h5st0xW_thH0n2QOxWUo,6
59
- ntermqt-0.1.3.dist-info/RECORD,,
56
+ nterm/vault/store.py,sha256=_0Lfe0WKjm3uSAtxgn9qAPlpBOLCuq9SVgzqsE_qaGQ,21199
57
+ ntermqt-0.1.4.dist-info/METADATA,sha256=riL6P_O6COkgWjf4eWq-7cEFiFiorlhlNCvTdr4xhvg,13573
58
+ ntermqt-0.1.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
59
+ ntermqt-0.1.4.dist-info/entry_points.txt,sha256=Gunr-_3w-aSpfqoMuGKM2PJSCRo9hZ7K1BksUtp1yd8,130
60
+ ntermqt-0.1.4.dist-info/top_level.txt,sha256=bZdnNLTHNRNqo9jsOQGUWF7h5st0xW_thH0n2QOxWUo,6
61
+ ntermqt-0.1.4.dist-info/RECORD,,