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
@@ -0,0 +1,380 @@
1
+ """
2
+ PyQt6 terminal widget using xterm.js.
3
+ """
4
+
5
+ from __future__ import annotations
6
+ import base64
7
+ import json
8
+ import logging
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from PyQt6.QtCore import Qt, QUrl, pyqtSignal, pyqtSlot
13
+ from PyQt6.QtWidgets import QWidget, QVBoxLayout, QApplication
14
+ from PyQt6.QtWebEngineWidgets import QWebEngineView
15
+ from PyQt6.QtWebEngineCore import QWebEngineSettings
16
+ from PyQt6.QtWebChannel import QWebChannel
17
+
18
+ from ..session.base import (
19
+ Session, SessionState, SessionEvent,
20
+ DataReceived, StateChanged, InteractionRequired, BannerReceived
21
+ )
22
+ from ..theme.engine import Theme
23
+ from .bridge import TerminalBridge
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ from nterm.resources import resources
28
+
29
+ # Default threshold for multiline paste warning
30
+ MULTILINE_PASTE_THRESHOLD = 1
31
+
32
+
33
+ class TerminalWidget(QWidget):
34
+ """
35
+ Themeable terminal widget.
36
+
37
+ Renders terminal via xterm.js in QWebEngineView.
38
+ Connects to a Session for I/O.
39
+ """
40
+
41
+ # Public signals
42
+ session_state_changed = pyqtSignal(SessionState, str)
43
+ interaction_required = pyqtSignal(str, str) # prompt, type
44
+ reconnect_requested = pyqtSignal() # emitted when user wants to reconnect
45
+ title_changed = pyqtSignal(str)
46
+
47
+ def __init__(self, parent=None, multiline_threshold: int = MULTILINE_PASTE_THRESHOLD):
48
+ super().__init__(parent)
49
+
50
+ self._session: Optional[Session] = None
51
+ self._theme: Optional[Theme] = None
52
+ self._ready = False
53
+ self._pending_writes: list[bytes] = []
54
+ self._awaiting_reconnect_confirm = False
55
+ self._multiline_threshold = multiline_threshold
56
+ self._pending_paste: Optional[bytes] = None # held during confirmation
57
+
58
+ self._setup_ui()
59
+ self._setup_bridge()
60
+
61
+ def _setup_ui(self):
62
+ """Set up the widget UI."""
63
+ layout = QVBoxLayout(self)
64
+ layout.setContentsMargins(0, 0, 0, 0)
65
+ layout.setSpacing(0)
66
+
67
+ self._webview = QWebEngineView()
68
+ self._webview.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu)
69
+
70
+ # Configure web settings
71
+ settings = self._webview.settings()
72
+ settings.setAttribute(
73
+ QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls,
74
+ True
75
+ )
76
+ settings.setAttribute(
77
+ QWebEngineSettings.WebAttribute.JavascriptEnabled,
78
+ True
79
+ )
80
+
81
+ layout.addWidget(self._webview)
82
+
83
+ def _setup_bridge(self):
84
+ """Set up QWebChannel bridge to JavaScript."""
85
+ self._bridge = TerminalBridge()
86
+ self._channel = QWebChannel()
87
+ self._channel.registerObject("bridge", self._bridge)
88
+ self._webview.page().setWebChannel(self._channel)
89
+
90
+ # Connect bridge signals
91
+ self._bridge.data_from_terminal.connect(self._on_terminal_data)
92
+ self._bridge.size_changed.connect(self._on_terminal_resize)
93
+ self._bridge.terminal_ready.connect(self._on_terminal_ready)
94
+ self._bridge.title_changed.connect(self.title_changed.emit)
95
+
96
+ # Clipboard signals
97
+ self._bridge.selection_copied.connect(self._on_selection_copied)
98
+ self._bridge.paste_requested.connect(self._on_paste_requested)
99
+ self._bridge.paste_confirmed.connect(self._on_paste_confirmed)
100
+ self._bridge.paste_cancelled.connect(self._on_paste_cancelled)
101
+
102
+ # Load terminal HTML
103
+ try:
104
+ html_path = resources.get_path("terminal", "resources", "terminal.html")
105
+ self._webview.setUrl(QUrl.fromLocalFile(str(html_path)))
106
+ except FileNotFoundError as e:
107
+ logger.error(f"Terminal HTML not found: {e}")
108
+
109
+ def attach_session(self, session: Session) -> None:
110
+ """
111
+ Attach a session for I/O.
112
+
113
+ Args:
114
+ session: Session to attach
115
+ """
116
+ if self._session:
117
+ self.detach_session()
118
+
119
+ self._session = session
120
+ self._session.set_auto_reconnect(False) # Widget controls reconnect
121
+ self._session.set_event_handler(self._on_session_event)
122
+ self._awaiting_reconnect_confirm = False
123
+ logger.debug(f"Attached session")
124
+
125
+ def detach_session(self) -> None:
126
+ """Detach current session."""
127
+ if self._session:
128
+ self._session.set_event_handler(None)
129
+ self._session = None
130
+ self._awaiting_reconnect_confirm = False
131
+ logger.debug("Detached session")
132
+
133
+ def set_theme(self, theme: Theme) -> None:
134
+ """
135
+ Apply theme to terminal.
136
+
137
+ Args:
138
+ theme: Theme to apply
139
+ """
140
+ self._theme = theme
141
+ if self._ready:
142
+ self._apply_theme()
143
+
144
+ def _apply_theme(self):
145
+ """Apply current theme to terminal."""
146
+ if self._theme:
147
+ self._bridge.apply_theme.emit(json.dumps(self._theme.terminal_colors))
148
+ self._bridge.set_font.emit(
149
+ self._theme.font_family,
150
+ self._theme.font_size
151
+ )
152
+
153
+ def write(self, data: bytes) -> None:
154
+ """
155
+ Write data to terminal display.
156
+
157
+ Args:
158
+ data: Bytes to display
159
+ """
160
+ if self._ready:
161
+ data_b64 = base64.b64encode(data).decode('ascii')
162
+ self._bridge.write_data.emit(data_b64)
163
+ else:
164
+ self._pending_writes.append(data)
165
+
166
+ def clear(self) -> None:
167
+ """Clear terminal display."""
168
+ if self._ready:
169
+ self._bridge.clear_terminal.emit()
170
+
171
+ def focus(self) -> None:
172
+ """Focus the terminal for input."""
173
+ if self._ready:
174
+ self._bridge.focus_terminal.emit()
175
+ self._webview.setFocus()
176
+
177
+ def show_overlay(self, message: str, show_spinner: bool = False) -> None:
178
+ """
179
+ Show overlay message.
180
+
181
+ Args:
182
+ message: Message to display
183
+ show_spinner: Whether to show the spinner animation
184
+ """
185
+ if self._ready:
186
+ self._bridge.show_overlay.emit(message, show_spinner)
187
+
188
+ def hide_overlay(self) -> None:
189
+ """Hide overlay message."""
190
+ if self._ready:
191
+ self._bridge.hide_overlay.emit()
192
+
193
+ # -------------------------------------------------------------------------
194
+ # Clipboard operations
195
+ # -------------------------------------------------------------------------
196
+
197
+ def copy(self) -> None:
198
+ """Copy current terminal selection to clipboard."""
199
+ if self._ready:
200
+ self._bridge.do_copy.emit()
201
+
202
+ def paste(self) -> None:
203
+ """
204
+ Paste from clipboard with multiline safety check.
205
+
206
+ If content contains more lines than threshold, shows confirmation dialog.
207
+ """
208
+ if not self._ready:
209
+ return
210
+
211
+ clipboard = QApplication.clipboard()
212
+ text = clipboard.text()
213
+
214
+ if not text:
215
+ return
216
+
217
+ # Count newlines
218
+ line_count = text.count('\n')
219
+
220
+ if line_count > self._multiline_threshold:
221
+ # Store pending paste and request confirmation
222
+ self._pending_paste = text.encode('utf-8')
223
+
224
+ # Create preview (first few lines)
225
+ lines = text.split('\n')
226
+ preview_lines = lines[:5]
227
+ preview = '\n'.join(preview_lines)
228
+ if len(lines) > 5:
229
+ preview += f'\n... ({len(lines) - 5} more lines)'
230
+
231
+ self._bridge.show_paste_confirm.emit(preview, len(lines))
232
+ else:
233
+ # Safe to paste directly
234
+ self._send_paste(text.encode('utf-8'))
235
+
236
+ def copy_paste(self) -> None:
237
+ """
238
+ Copy selection, then paste clipboard (combined operation).
239
+
240
+ Useful for workflows where you select text, then want to
241
+ immediately paste what was just copied.
242
+ """
243
+ self.copy()
244
+ # Small delay isn't needed since copy is sync to clipboard
245
+ self.paste()
246
+
247
+ def _send_paste(self, data: bytes) -> None:
248
+ """Actually send paste data to terminal/session."""
249
+ if self._session and self._session.is_connected:
250
+ self._session.write(data)
251
+ elif self._ready:
252
+ # Even if not connected, let terminal display it
253
+ # (will trigger reconnect flow via _on_terminal_data)
254
+ data_b64 = base64.b64encode(data).decode('ascii')
255
+ self._bridge.do_paste.emit(data_b64)
256
+
257
+ def set_multiline_threshold(self, lines: int) -> None:
258
+ """
259
+ Set the line count threshold for multiline paste warnings.
260
+
261
+ Args:
262
+ lines: Number of lines that triggers confirmation (0 to disable)
263
+ """
264
+ self._multiline_threshold = lines
265
+
266
+ # -------------------------------------------------------------------------
267
+ # Bridge callbacks
268
+ # -------------------------------------------------------------------------
269
+
270
+ def _on_terminal_ready(self):
271
+ """Terminal initialized, apply settings."""
272
+ logger.debug("Terminal ready")
273
+ self._ready = True
274
+
275
+ if self._theme:
276
+ self._apply_theme()
277
+
278
+ # Flush pending writes
279
+ for data in self._pending_writes:
280
+ self.write(data)
281
+ self._pending_writes.clear()
282
+
283
+ def _is_disconnected(self) -> bool:
284
+ """Check if session is in a disconnected state."""
285
+ if not self._session:
286
+ return True
287
+ return self._session.state in (
288
+ SessionState.DISCONNECTED,
289
+ SessionState.FAILED,
290
+ )
291
+
292
+ @pyqtSlot(str)
293
+ def _on_terminal_data(self, data_b64: str):
294
+ """User typed in terminal."""
295
+ if self._session and self._session.is_connected:
296
+ data = base64.b64decode(data_b64)
297
+ self._session.write(data)
298
+ elif self._is_disconnected():
299
+ # User typed while disconnected - offer to reconnect
300
+ if self._awaiting_reconnect_confirm:
301
+ # They confirmed - reconnect
302
+ self._awaiting_reconnect_confirm = False
303
+ self.hide_overlay()
304
+ self.reconnect_requested.emit()
305
+ # Actually trigger the reconnection
306
+ if self._session:
307
+ self._session.connect()
308
+ else:
309
+ # First keypress - show prompt
310
+ self._awaiting_reconnect_confirm = True
311
+ self.show_overlay("Disconnected. Press any key to reconnect...", show_spinner=False)
312
+
313
+ @pyqtSlot(int, int)
314
+ def _on_terminal_resize(self, cols: int, rows: int):
315
+ """Terminal resized."""
316
+ logger.debug(f"Terminal resize: {cols}x{rows}")
317
+ if self._session:
318
+ self._session.resize(cols, rows)
319
+
320
+ @pyqtSlot(str)
321
+ def _on_selection_copied(self, text: str):
322
+ """Selection was copied - write to clipboard."""
323
+ clipboard = QApplication.clipboard()
324
+ clipboard.setText(text)
325
+ logger.debug(f"Copied {len(text)} chars to clipboard")
326
+
327
+ @pyqtSlot(str)
328
+ def _on_paste_requested(self, data_b64: str):
329
+ """
330
+ JS requested paste (from context menu or keyboard).
331
+ Route through our paste() method which reads Qt clipboard.
332
+ """
333
+ self.paste()
334
+
335
+ @pyqtSlot()
336
+ def _on_paste_confirmed(self):
337
+ """User confirmed multiline paste."""
338
+ self._bridge.hide_paste_confirm.emit()
339
+ if self._pending_paste:
340
+ self._send_paste(self._pending_paste)
341
+ self._pending_paste = None
342
+
343
+ @pyqtSlot()
344
+ def _on_paste_cancelled(self):
345
+ """User cancelled multiline paste."""
346
+ self._bridge.hide_paste_confirm.emit()
347
+ self._pending_paste = None
348
+ self.focus()
349
+
350
+ def _on_session_event(self, event: SessionEvent):
351
+ """Handle session events."""
352
+ if isinstance(event, DataReceived):
353
+ self.write(event.data)
354
+
355
+ elif isinstance(event, StateChanged):
356
+ self.session_state_changed.emit(event.new_state, event.message)
357
+
358
+ # Reset reconnect confirmation state on any state change
359
+ self._awaiting_reconnect_confirm = False
360
+
361
+ if event.new_state == SessionState.CONNECTING:
362
+ self.show_overlay("Connecting...", show_spinner=True)
363
+ elif event.new_state == SessionState.AUTHENTICATING:
364
+ self.show_overlay("Authenticating...", show_spinner=True)
365
+ elif event.new_state == SessionState.CONNECTED:
366
+ self.hide_overlay()
367
+ elif event.new_state == SessionState.DISCONNECTED:
368
+ self.show_overlay("Disconnected. Press any key to reconnect...", show_spinner=False)
369
+ self._awaiting_reconnect_confirm = True
370
+ elif event.new_state == SessionState.FAILED:
371
+ self.show_overlay(f"Connection failed: {event.message}", show_spinner=False)
372
+ # Don't auto-prompt for reconnect on failure, let them decide
373
+
374
+ elif isinstance(event, InteractionRequired):
375
+ self.interaction_required.emit(event.prompt, event.interaction_type)
376
+ self.show_overlay(event.prompt, show_spinner=False)
377
+
378
+ elif isinstance(event, BannerReceived):
379
+ # Could display banner in overlay or write to terminal
380
+ logger.info(f"Banner: {event.banner[:100]}...")
@@ -0,0 +1,10 @@
1
+ """
2
+ Theme system for terminal styling.
3
+ """
4
+
5
+ from .engine import Theme, ThemeEngine
6
+
7
+ __all__ = [
8
+ "Theme",
9
+ "ThemeEngine",
10
+ ]