ntermqt 0.1.1__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.
nterm/__main__.py CHANGED
@@ -22,6 +22,7 @@ from nterm.manager import (
22
22
  )
23
23
  from nterm.terminal.widget import TerminalWidget
24
24
  from nterm.session.ssh import SSHSession
25
+ from nterm.session.local_terminal import LocalTerminal
25
26
  from nterm.connection.profile import ConnectionProfile, AuthConfig
26
27
  from nterm.vault import CredentialManagerWidget
27
28
  from nterm.vault.resolver import CredentialResolver
@@ -225,6 +226,35 @@ class TerminalTab(QWidget):
225
226
  return True
226
227
 
227
228
 
229
+ class LocalTerminalTab(QWidget):
230
+ """A terminal tab for local processes (shell, IPython, etc.)."""
231
+
232
+ def __init__(self, name: str, session: LocalTerminal, parent=None):
233
+ super().__init__(parent)
234
+ self.name = name
235
+ self.local_session = session
236
+
237
+ layout = QVBoxLayout(self)
238
+ layout.setContentsMargins(0, 0, 0, 0)
239
+
240
+ self.terminal = TerminalWidget()
241
+ layout.addWidget(self.terminal)
242
+
243
+ self.terminal.attach_session(self.local_session)
244
+
245
+ def connect(self):
246
+ """Start the local process."""
247
+ self.local_session.connect()
248
+
249
+ def disconnect(self):
250
+ """Terminate the local process."""
251
+ self.local_session.disconnect()
252
+
253
+ def is_connected(self) -> bool:
254
+ """Check if the process is still running."""
255
+ return self.local_session.is_connected
256
+
257
+
228
258
  class MainWindow(QMainWindow):
229
259
  """
230
260
  Main application window with session tree and tabbed terminals.
@@ -274,7 +304,7 @@ class MainWindow(QMainWindow):
274
304
  # Apply multiline threshold to all open terminals
275
305
  for i in range(self.tab_widget.count()):
276
306
  tab = self.tab_widget.widget(i)
277
- if isinstance(tab, TerminalTab):
307
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)):
278
308
  tab.terminal.set_multiline_threshold(settings.multiline_paste_threshold)
279
309
 
280
310
 
@@ -401,6 +431,32 @@ class MainWindow(QMainWindow):
401
431
  action.triggered.connect(lambda checked, n=theme_name: self._apply_theme_by_name(n))
402
432
  theme_menu.addAction(action)
403
433
 
434
+ # Dev menu
435
+ dev_menu = menubar.addMenu("&Dev")
436
+
437
+ # IPython submenu
438
+ ipython_menu = dev_menu.addMenu("&IPython")
439
+
440
+ ipython_tab_action = QAction("Open in &Tab", self)
441
+ ipython_tab_action.setShortcut(QKeySequence("Ctrl+Shift+I"))
442
+ ipython_tab_action.triggered.connect(lambda: self._open_local("IPython", LocalTerminal.ipython(), "tab"))
443
+ ipython_menu.addAction(ipython_tab_action)
444
+
445
+ ipython_window_action = QAction("Open in &Window", self)
446
+ ipython_window_action.triggered.connect(lambda: self._open_local("IPython", LocalTerminal.ipython(), "window"))
447
+ ipython_menu.addAction(ipython_window_action)
448
+
449
+ # Shell submenu
450
+ shell_menu = dev_menu.addMenu("&Shell")
451
+
452
+ shell_tab_action = QAction("Open in &Tab", self)
453
+ shell_tab_action.triggered.connect(lambda: self._open_local("Shell", LocalTerminal(), "tab"))
454
+ shell_menu.addAction(shell_tab_action)
455
+
456
+ shell_window_action = QAction("Open in &Window", self)
457
+ shell_window_action.triggered.connect(lambda: self._open_local("Shell", LocalTerminal(), "window"))
458
+ shell_menu.addAction(shell_window_action)
459
+
404
460
  def _on_import_sessions(self):
405
461
  """Show import dialog."""
406
462
  dialog = ImportDialog(self.session_store, self)
@@ -445,7 +501,7 @@ class MainWindow(QMainWindow):
445
501
  # Update all open terminal tabs
446
502
  for i in range(self.tab_widget.count()):
447
503
  tab = self.tab_widget.widget(i)
448
- if isinstance(tab, TerminalTab):
504
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)):
449
505
  tab.terminal.set_theme(theme)
450
506
 
451
507
  def _apply_qt_theme(self, theme: Theme):
@@ -560,6 +616,23 @@ class MainWindow(QMainWindow):
560
616
  self._child_windows.append(window)
561
617
  window.destroyed.connect(lambda: self._child_windows.remove(window))
562
618
 
619
+ def _open_local(self, name: str, session: LocalTerminal, mode: str):
620
+ """Open a local terminal session (IPython, shell, etc.)."""
621
+ if mode == "tab":
622
+ tab = LocalTerminalTab(name, session)
623
+ tab.terminal.set_theme(self.current_theme)
624
+ idx = self.tab_widget.addTab(tab, name)
625
+ self.tab_widget.setCurrentIndex(idx)
626
+ self.tab_widget.setTabToolTip(idx, f"{name} (local)")
627
+ tab.connect()
628
+ else:
629
+ window = LocalTerminalWindow(name, session, self.current_theme)
630
+ window.show()
631
+ if not hasattr(self, '_child_windows'):
632
+ self._child_windows = []
633
+ self._child_windows.append(window)
634
+ window.destroyed.connect(lambda: self._child_windows.remove(window))
635
+
563
636
  # -------------------------------------------------------------------------
564
637
  # Tab Management (NEW)
565
638
  # -------------------------------------------------------------------------
@@ -569,7 +642,7 @@ class MainWindow(QMainWindow):
569
642
  count = 0
570
643
  for i in range(self.tab_widget.count()):
571
644
  tab = self.tab_widget.widget(i)
572
- if isinstance(tab, TerminalTab) and tab.is_connected():
645
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)) and tab.is_connected():
573
646
  count += 1
574
647
  return count
575
648
 
@@ -619,13 +692,29 @@ class MainWindow(QMainWindow):
619
692
 
620
693
  tab.disconnect()
621
694
 
695
+ elif isinstance(tab, LocalTerminalTab):
696
+ # Check if process is still running
697
+ if tab.is_connected():
698
+ reply = QMessageBox.question(
699
+ self,
700
+ "Close Tab",
701
+ f"'{tab.name}' is still running.\n\n"
702
+ "Terminate and close this tab?",
703
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
704
+ QMessageBox.StandardButton.No
705
+ )
706
+ if reply != QMessageBox.StandardButton.Yes:
707
+ return
708
+
709
+ tab.disconnect()
710
+
622
711
  self.tab_widget.removeTab(index)
623
712
 
624
713
  def _close_other_tabs(self, keep_index: int):
625
714
  """Close all tabs except the specified one."""
626
715
  active_count = self._get_active_session_count()
627
716
  tab_to_keep = self.tab_widget.widget(keep_index)
628
- keep_is_active = isinstance(tab_to_keep, TerminalTab) and tab_to_keep.is_connected()
717
+ keep_is_active = isinstance(tab_to_keep, (TerminalTab, LocalTerminalTab)) and tab_to_keep.is_connected()
629
718
  other_active = active_count - (1 if keep_is_active else 0)
630
719
 
631
720
  if other_active > 0:
@@ -644,7 +733,7 @@ class MainWindow(QMainWindow):
644
733
  for i in range(self.tab_widget.count() - 1, -1, -1):
645
734
  if i != keep_index:
646
735
  tab = self.tab_widget.widget(i)
647
- if isinstance(tab, TerminalTab):
736
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)):
648
737
  tab.disconnect()
649
738
  self.tab_widget.removeTab(i)
650
739
 
@@ -658,7 +747,7 @@ class MainWindow(QMainWindow):
658
747
  active_count = 0
659
748
  for i in range(index + 1, self.tab_widget.count()):
660
749
  tab = self.tab_widget.widget(i)
661
- if isinstance(tab, TerminalTab) and tab.is_connected():
750
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)) and tab.is_connected():
662
751
  active_count += 1
663
752
 
664
753
  if active_count > 0:
@@ -676,7 +765,7 @@ class MainWindow(QMainWindow):
676
765
  # Close from end to avoid index shifting
677
766
  for i in range(self.tab_widget.count() - 1, index, -1):
678
767
  tab = self.tab_widget.widget(i)
679
- if isinstance(tab, TerminalTab):
768
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)):
680
769
  tab.disconnect()
681
770
  self.tab_widget.removeTab(i)
682
771
 
@@ -702,7 +791,7 @@ class MainWindow(QMainWindow):
702
791
  # Close all tabs
703
792
  while self.tab_widget.count() > 0:
704
793
  tab = self.tab_widget.widget(0)
705
- if isinstance(tab, TerminalTab):
794
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)):
706
795
  tab.disconnect()
707
796
  self.tab_widget.removeTab(0)
708
797
 
@@ -741,7 +830,7 @@ class MainWindow(QMainWindow):
741
830
  # Disconnect all tabs
742
831
  for i in range(self.tab_widget.count()):
743
832
  tab = self.tab_widget.widget(i)
744
- if isinstance(tab, TerminalTab):
833
+ if isinstance(tab, (TerminalTab, LocalTerminalTab)):
745
834
  tab.disconnect()
746
835
 
747
836
  self.session_store.close()
@@ -790,6 +879,40 @@ class TerminalWindow(QMainWindow):
790
879
  event.accept()
791
880
 
792
881
 
882
+ class LocalTerminalWindow(QMainWindow):
883
+ """Standalone window for local terminal sessions."""
884
+
885
+ def __init__(self, name: str, session: LocalTerminal, theme: Theme):
886
+ super().__init__()
887
+ self.setWindowTitle(f"{name} - Local")
888
+ self.resize(1000, 700)
889
+
890
+ self.setStyleSheet(generate_stylesheet(theme))
891
+
892
+ self.tab = LocalTerminalTab(name, session)
893
+ self.tab.terminal.set_theme(theme)
894
+ self.setCentralWidget(self.tab)
895
+
896
+ self.tab.connect()
897
+
898
+ def closeEvent(self, event):
899
+ """Terminate on close with confirmation."""
900
+ if self.tab.is_connected():
901
+ reply = QMessageBox.question(
902
+ self,
903
+ "Close Window",
904
+ f"'{self.tab.name}' is still running.\n\nTerminate and close?",
905
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
906
+ QMessageBox.StandardButton.No
907
+ )
908
+ if reply != QMessageBox.StandardButton.Yes:
909
+ event.ignore()
910
+ return
911
+
912
+ self.tab.disconnect()
913
+ event.accept()
914
+
915
+
793
916
  def main():
794
917
  app = QApplication(sys.argv)
795
918
 
@@ -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()