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 +132 -9
- nterm/examples/basic_terminal.py +415 -0
- nterm/manager/tree.py +125 -42
- nterm/scripting/__init__.py +43 -0
- nterm/scripting/api.py +447 -0
- nterm/scripting/cli.py +305 -0
- nterm/session/local_terminal.py +225 -0
- nterm/session/pty_transport.py +105 -91
- nterm/terminal/bridge.py +10 -0
- nterm/terminal/resources/terminal.html +9 -4
- nterm/terminal/resources/terminal.js +14 -1
- nterm/terminal/widget.py +73 -2
- nterm/theme/engine.py +45 -0
- nterm/theme/themes/nord_hybrid.yaml +43 -0
- nterm/vault/store.py +3 -3
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.4.dist-info}/METADATA +157 -21
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.4.dist-info}/RECORD +20 -14
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.4.dist-info}/entry_points.txt +1 -0
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.4.dist-info}/WHEEL +0 -0
- {ntermqt-0.1.1.dist-info → ntermqt-0.1.4.dist-info}/top_level.txt +0 -0
nterm/session/pty_transport.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Cross-platform PTY for interactive
|
|
2
|
+
Cross-platform PTY for interactive terminals.
|
|
3
3
|
|
|
4
4
|
Provides a unified interface for pseudo-terminal operations
|
|
5
5
|
on both Unix (pty module) and Windows (pywinpty/ConPTY).
|
|
@@ -47,70 +47,72 @@ else:
|
|
|
47
47
|
class PTYTransport(ABC):
|
|
48
48
|
"""
|
|
49
49
|
Abstract PTY interface.
|
|
50
|
-
|
|
50
|
+
|
|
51
51
|
Provides cross-platform pseudo-terminal functionality for
|
|
52
52
|
spawning and interacting with processes that require a TTY.
|
|
53
53
|
"""
|
|
54
|
-
|
|
54
|
+
|
|
55
55
|
@abstractmethod
|
|
56
|
-
def spawn(self, command: list[str], env: dict = None) -> None:
|
|
56
|
+
def spawn(self, command: list[str], env: dict = None, echo: bool = True) -> None:
|
|
57
57
|
"""
|
|
58
58
|
Spawn process with PTY.
|
|
59
|
-
|
|
59
|
+
|
|
60
60
|
Args:
|
|
61
61
|
command: Command and arguments to execute
|
|
62
62
|
env: Optional environment variables
|
|
63
|
+
echo: Whether to echo input back to terminal. Set False for SSH
|
|
64
|
+
(remote handles echo), True for local shells/apps.
|
|
63
65
|
"""
|
|
64
66
|
pass
|
|
65
|
-
|
|
67
|
+
|
|
66
68
|
@abstractmethod
|
|
67
69
|
def read(self, size: int = 4096) -> bytes:
|
|
68
70
|
"""
|
|
69
71
|
Read from PTY (non-blocking).
|
|
70
|
-
|
|
72
|
+
|
|
71
73
|
Args:
|
|
72
74
|
size: Maximum bytes to read
|
|
73
|
-
|
|
75
|
+
|
|
74
76
|
Returns:
|
|
75
77
|
Bytes read, empty if nothing available
|
|
76
78
|
"""
|
|
77
79
|
pass
|
|
78
|
-
|
|
80
|
+
|
|
79
81
|
@abstractmethod
|
|
80
82
|
def write(self, data: bytes) -> int:
|
|
81
83
|
"""
|
|
82
84
|
Write to PTY.
|
|
83
|
-
|
|
85
|
+
|
|
84
86
|
Args:
|
|
85
87
|
data: Bytes to write
|
|
86
|
-
|
|
88
|
+
|
|
87
89
|
Returns:
|
|
88
90
|
Number of bytes written
|
|
89
91
|
"""
|
|
90
92
|
pass
|
|
91
|
-
|
|
93
|
+
|
|
92
94
|
@abstractmethod
|
|
93
95
|
def resize(self, cols: int, rows: int) -> None:
|
|
94
96
|
"""
|
|
95
97
|
Resize PTY.
|
|
96
|
-
|
|
98
|
+
|
|
97
99
|
Args:
|
|
98
100
|
cols: Number of columns
|
|
99
101
|
rows: Number of rows
|
|
100
102
|
"""
|
|
101
103
|
pass
|
|
102
|
-
|
|
104
|
+
|
|
103
105
|
@abstractmethod
|
|
104
106
|
def close(self) -> None:
|
|
105
107
|
"""Close PTY and terminate process."""
|
|
106
108
|
pass
|
|
107
|
-
|
|
109
|
+
|
|
108
110
|
@property
|
|
109
111
|
@abstractmethod
|
|
110
112
|
def is_alive(self) -> bool:
|
|
111
113
|
"""Check if process is still running."""
|
|
112
114
|
pass
|
|
113
|
-
|
|
115
|
+
|
|
114
116
|
@property
|
|
115
117
|
@abstractmethod
|
|
116
118
|
def exit_code(self) -> Optional[int]:
|
|
@@ -121,37 +123,37 @@ class PTYTransport(ABC):
|
|
|
121
123
|
class UnixPTY(PTYTransport):
|
|
122
124
|
"""
|
|
123
125
|
Unix PTY implementation using subprocess + pty.
|
|
124
|
-
|
|
126
|
+
|
|
125
127
|
Uses subprocess.Popen with start_new_session=True and preexec_fn
|
|
126
128
|
to properly set up the controlling terminal.
|
|
127
129
|
"""
|
|
128
|
-
|
|
130
|
+
|
|
129
131
|
def __init__(self):
|
|
130
132
|
self._master_fd: Optional[int] = None
|
|
131
133
|
self._proc: Optional[subprocess.Popen] = None
|
|
132
134
|
self._exit_code: Optional[int] = None
|
|
133
|
-
|
|
134
|
-
def spawn(self, command: list[str], env: dict = None) -> None:
|
|
135
|
+
|
|
136
|
+
def spawn(self, command: list[str], env: dict = None, echo: bool = True) -> None:
|
|
135
137
|
"""Spawn process with PTY using subprocess."""
|
|
136
|
-
|
|
138
|
+
|
|
137
139
|
# Merge environment
|
|
138
140
|
spawn_env = os.environ.copy()
|
|
139
141
|
if env:
|
|
140
142
|
spawn_env.update(env)
|
|
141
143
|
spawn_env['TERM'] = 'xterm-256color'
|
|
142
|
-
|
|
144
|
+
|
|
143
145
|
# Create pseudo-terminal pair
|
|
144
146
|
self._master_fd, slave_fd = pty.openpty()
|
|
145
147
|
slave_name = os.ttyname(slave_fd)
|
|
146
148
|
spawn_env['SSH_TTY'] = slave_name
|
|
147
|
-
|
|
149
|
+
|
|
148
150
|
# Determine TIOCSCTTY value for this platform
|
|
149
151
|
import platform
|
|
150
152
|
if platform.system() == 'Darwin':
|
|
151
153
|
tiocsctty = 0x20007461
|
|
152
154
|
else:
|
|
153
155
|
tiocsctty = 0x540E
|
|
154
|
-
|
|
156
|
+
|
|
155
157
|
def setup_child():
|
|
156
158
|
"""
|
|
157
159
|
Called in child process after fork, before exec.
|
|
@@ -162,52 +164,52 @@ class UnixPTY(PTYTransport):
|
|
|
162
164
|
os.close(slave_fd)
|
|
163
165
|
except:
|
|
164
166
|
pass
|
|
165
|
-
|
|
167
|
+
|
|
166
168
|
# start_new_session=True already called setsid()
|
|
167
|
-
# Now open the slave device fresh - first open after setsid
|
|
169
|
+
# Now open the slave device fresh - first open after setsid
|
|
168
170
|
# becomes controlling terminal on Linux
|
|
169
171
|
new_slave = os.open(slave_name, os.O_RDWR)
|
|
170
|
-
|
|
172
|
+
|
|
171
173
|
# Explicitly set as controlling terminal (needed on some systems)
|
|
172
174
|
try:
|
|
173
175
|
fcntl.ioctl(new_slave, tiocsctty, 0)
|
|
174
176
|
except (OSError, IOError):
|
|
175
177
|
pass
|
|
176
|
-
|
|
178
|
+
|
|
177
179
|
# Redirect stdin/stdout/stderr to the PTY
|
|
178
180
|
os.dup2(new_slave, 0)
|
|
179
|
-
os.dup2(new_slave, 1)
|
|
181
|
+
os.dup2(new_slave, 1)
|
|
180
182
|
os.dup2(new_slave, 2)
|
|
181
|
-
|
|
183
|
+
|
|
182
184
|
if new_slave > 2:
|
|
183
185
|
os.close(new_slave)
|
|
184
|
-
|
|
186
|
+
|
|
185
187
|
# Spawn the process
|
|
186
188
|
self._proc = subprocess.Popen(
|
|
187
189
|
command,
|
|
188
190
|
stdin=slave_fd,
|
|
189
191
|
stdout=slave_fd,
|
|
190
192
|
stderr=slave_fd,
|
|
191
|
-
start_new_session=True, # Calls setsid()
|
|
193
|
+
start_new_session=True, # Calls setsid()
|
|
192
194
|
preexec_fn=setup_child,
|
|
193
195
|
env=spawn_env,
|
|
194
196
|
close_fds=True,
|
|
195
197
|
)
|
|
196
|
-
|
|
198
|
+
|
|
197
199
|
# Close slave in parent
|
|
198
200
|
os.close(slave_fd)
|
|
199
|
-
|
|
201
|
+
|
|
200
202
|
# Set master to non-blocking
|
|
201
203
|
flags = fcntl.fcntl(self._master_fd, fcntl.F_GETFL)
|
|
202
204
|
fcntl.fcntl(self._master_fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
203
|
-
|
|
205
|
+
|
|
204
206
|
logger.debug(f"Spawned PID {self._proc.pid}: {' '.join(command)}")
|
|
205
|
-
|
|
207
|
+
|
|
206
208
|
def read(self, size: int = 4096) -> bytes:
|
|
207
209
|
"""Read from PTY (non-blocking)."""
|
|
208
210
|
if self._master_fd is None:
|
|
209
211
|
return b''
|
|
210
|
-
|
|
212
|
+
|
|
211
213
|
try:
|
|
212
214
|
# Check if data available
|
|
213
215
|
r, _, _ = select.select([self._master_fd], [], [], 0)
|
|
@@ -221,7 +223,7 @@ class UnixPTY(PTYTransport):
|
|
|
221
223
|
if e.errno == 5: # EIO
|
|
222
224
|
self._check_exit()
|
|
223
225
|
return b''
|
|
224
|
-
|
|
226
|
+
|
|
225
227
|
def write(self, data: bytes) -> int:
|
|
226
228
|
"""Write to PTY."""
|
|
227
229
|
if self._master_fd is None:
|
|
@@ -230,7 +232,7 @@ class UnixPTY(PTYTransport):
|
|
|
230
232
|
return os.write(self._master_fd, data)
|
|
231
233
|
except OSError:
|
|
232
234
|
return 0
|
|
233
|
-
|
|
235
|
+
|
|
234
236
|
def resize(self, cols: int, rows: int) -> None:
|
|
235
237
|
"""Resize PTY using TIOCSWINSZ ioctl."""
|
|
236
238
|
if self._master_fd is not None:
|
|
@@ -239,7 +241,7 @@ class UnixPTY(PTYTransport):
|
|
|
239
241
|
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, winsize)
|
|
240
242
|
except OSError as e:
|
|
241
243
|
logger.warning(f"Resize failed: {e}")
|
|
242
|
-
|
|
244
|
+
|
|
243
245
|
def close(self) -> None:
|
|
244
246
|
"""Close PTY and terminate process."""
|
|
245
247
|
if self._master_fd is not None:
|
|
@@ -248,43 +250,43 @@ class UnixPTY(PTYTransport):
|
|
|
248
250
|
except OSError:
|
|
249
251
|
pass
|
|
250
252
|
self._master_fd = None
|
|
251
|
-
|
|
253
|
+
|
|
252
254
|
if self._proc is not None:
|
|
253
255
|
try:
|
|
254
256
|
self._proc.terminate() # SIGTERM
|
|
255
257
|
except OSError:
|
|
256
258
|
pass
|
|
257
|
-
|
|
259
|
+
|
|
258
260
|
# Wait briefly, then force kill
|
|
259
261
|
import time
|
|
260
262
|
for _ in range(10):
|
|
261
263
|
if self._proc.poll() is not None:
|
|
262
264
|
break
|
|
263
265
|
time.sleep(0.1)
|
|
264
|
-
|
|
266
|
+
|
|
265
267
|
if self._proc.poll() is None:
|
|
266
268
|
try:
|
|
267
269
|
self._proc.kill() # SIGKILL
|
|
268
270
|
except OSError:
|
|
269
271
|
pass
|
|
270
|
-
|
|
272
|
+
|
|
271
273
|
self._check_exit()
|
|
272
|
-
|
|
274
|
+
|
|
273
275
|
def _check_exit(self) -> None:
|
|
274
276
|
"""Check and store exit status."""
|
|
275
277
|
if self._proc is not None and self._exit_code is None:
|
|
276
278
|
retcode = self._proc.poll()
|
|
277
279
|
if retcode is not None:
|
|
278
280
|
self._exit_code = retcode
|
|
279
|
-
|
|
281
|
+
|
|
280
282
|
@property
|
|
281
283
|
def is_alive(self) -> bool:
|
|
282
284
|
"""Check if process is still running."""
|
|
283
285
|
if self._proc is None:
|
|
284
286
|
return False
|
|
285
|
-
|
|
287
|
+
|
|
286
288
|
return self._proc.poll() is None
|
|
287
|
-
|
|
289
|
+
|
|
288
290
|
@property
|
|
289
291
|
def exit_code(self) -> Optional[int]:
|
|
290
292
|
"""Get exit code if process has terminated."""
|
|
@@ -295,10 +297,10 @@ class UnixPTY(PTYTransport):
|
|
|
295
297
|
class WindowsPTY(PTYTransport):
|
|
296
298
|
"""
|
|
297
299
|
Windows ConPTY implementation using pywinpty.
|
|
298
|
-
|
|
300
|
+
|
|
299
301
|
Requires Windows 10 1809+ and pywinpty package.
|
|
300
302
|
"""
|
|
301
|
-
|
|
303
|
+
|
|
302
304
|
def __init__(self):
|
|
303
305
|
if not HAS_WINPTY:
|
|
304
306
|
raise RuntimeError(
|
|
@@ -308,33 +310,34 @@ class WindowsPTY(PTYTransport):
|
|
|
308
310
|
self._exit_code: Optional[int] = None
|
|
309
311
|
self._cols = 120
|
|
310
312
|
self._rows = 40
|
|
311
|
-
|
|
312
|
-
def spawn(self, command: list[str], env: dict = None) -> None:
|
|
313
|
+
|
|
314
|
+
def spawn(self, command: list[str], env: dict = None, echo: bool = True) -> None:
|
|
313
315
|
"""Spawn process with ConPTY."""
|
|
314
316
|
# pywinpty wants command as string
|
|
315
317
|
cmd_str = subprocess.list2cmdline(command)
|
|
316
|
-
|
|
318
|
+
|
|
317
319
|
# Merge environment
|
|
318
320
|
spawn_env = os.environ.copy()
|
|
319
321
|
if env:
|
|
320
322
|
spawn_env.update(env)
|
|
321
323
|
spawn_env.setdefault('TERM', 'xterm-256color')
|
|
322
|
-
|
|
324
|
+
|
|
323
325
|
# Create PTY
|
|
324
326
|
self._pty = WinPTY(
|
|
325
327
|
cols=self._cols,
|
|
326
328
|
rows=self._rows,
|
|
327
329
|
)
|
|
328
|
-
|
|
330
|
+
|
|
329
331
|
# Spawn process
|
|
332
|
+
# Note: ConPTY handles echo automatically based on the application
|
|
330
333
|
self._pty.spawn(cmd_str, env=spawn_env)
|
|
331
334
|
logger.debug(f"Spawned: {cmd_str}")
|
|
332
|
-
|
|
335
|
+
|
|
333
336
|
def read(self, size: int = 4096) -> bytes:
|
|
334
337
|
"""Read from PTY (non-blocking)."""
|
|
335
338
|
if self._pty is None:
|
|
336
339
|
return b''
|
|
337
|
-
|
|
340
|
+
|
|
338
341
|
try:
|
|
339
342
|
# pywinpty read with timeout=0 for non-blocking
|
|
340
343
|
data = self._pty.read(size, blocking=False)
|
|
@@ -347,12 +350,12 @@ class WindowsPTY(PTYTransport):
|
|
|
347
350
|
except Exception as e:
|
|
348
351
|
logger.debug(f"Read error: {e}")
|
|
349
352
|
return b''
|
|
350
|
-
|
|
353
|
+
|
|
351
354
|
def write(self, data: bytes) -> int:
|
|
352
355
|
"""Write to PTY."""
|
|
353
356
|
if self._pty is None:
|
|
354
357
|
return 0
|
|
355
|
-
|
|
358
|
+
|
|
356
359
|
try:
|
|
357
360
|
# pywinpty expects str
|
|
358
361
|
if isinstance(data, bytes):
|
|
@@ -364,7 +367,7 @@ class WindowsPTY(PTYTransport):
|
|
|
364
367
|
except Exception as e:
|
|
365
368
|
logger.debug(f"Write error: {e}")
|
|
366
369
|
return 0
|
|
367
|
-
|
|
370
|
+
|
|
368
371
|
def resize(self, cols: int, rows: int) -> None:
|
|
369
372
|
"""Resize ConPTY."""
|
|
370
373
|
self._cols = cols
|
|
@@ -374,7 +377,7 @@ class WindowsPTY(PTYTransport):
|
|
|
374
377
|
self._pty.set_size(cols, rows)
|
|
375
378
|
except Exception as e:
|
|
376
379
|
logger.warning(f"Resize failed: {e}")
|
|
377
|
-
|
|
380
|
+
|
|
378
381
|
def close(self) -> None:
|
|
379
382
|
"""Close PTY."""
|
|
380
383
|
if self._pty is not None:
|
|
@@ -386,7 +389,7 @@ class WindowsPTY(PTYTransport):
|
|
|
386
389
|
except Exception as e:
|
|
387
390
|
logger.debug(f"Close error: {e}")
|
|
388
391
|
self._pty = None
|
|
389
|
-
|
|
392
|
+
|
|
390
393
|
@property
|
|
391
394
|
def is_alive(self) -> bool:
|
|
392
395
|
"""Check if process is still running."""
|
|
@@ -399,7 +402,7 @@ class WindowsPTY(PTYTransport):
|
|
|
399
402
|
return alive
|
|
400
403
|
except:
|
|
401
404
|
return False
|
|
402
|
-
|
|
405
|
+
|
|
403
406
|
@property
|
|
404
407
|
def exit_code(self) -> Optional[int]:
|
|
405
408
|
"""Get exit code if process has terminated."""
|
|
@@ -415,15 +418,15 @@ class WindowsPTY(PTYTransport):
|
|
|
415
418
|
class PexpectPTY(PTYTransport):
|
|
416
419
|
"""
|
|
417
420
|
PTY implementation using pexpect.
|
|
418
|
-
|
|
421
|
+
|
|
419
422
|
pexpect is specifically designed to handle interactive programs
|
|
420
423
|
and properly captures /dev/tty output. This is the most reliable
|
|
421
424
|
method for capturing SSH keyboard-interactive prompts, YubiKey
|
|
422
425
|
challenges, etc.
|
|
423
|
-
|
|
426
|
+
|
|
424
427
|
Requires: pip install pexpect
|
|
425
428
|
"""
|
|
426
|
-
|
|
429
|
+
|
|
427
430
|
def __init__(self):
|
|
428
431
|
if not HAS_PEXPECT:
|
|
429
432
|
raise RuntimeError(
|
|
@@ -433,20 +436,29 @@ class PexpectPTY(PTYTransport):
|
|
|
433
436
|
self._exit_code: Optional[int] = None
|
|
434
437
|
self._cols = 120
|
|
435
438
|
self._rows = 40
|
|
436
|
-
|
|
437
|
-
def spawn(self, command: list[str], env: dict = None) -> None:
|
|
438
|
-
"""
|
|
439
|
+
|
|
440
|
+
def spawn(self, command: list[str], env: dict = None, echo: bool = True) -> None:
|
|
441
|
+
"""
|
|
442
|
+
Spawn process using pexpect.
|
|
443
|
+
|
|
444
|
+
Args:
|
|
445
|
+
command: Command and arguments to execute
|
|
446
|
+
env: Optional environment variables
|
|
447
|
+
echo: Whether PTY should echo input. Set False for SSH (remote
|
|
448
|
+
PTY handles echo), True for local shells and applications
|
|
449
|
+
like vim that need to see their own input.
|
|
450
|
+
"""
|
|
439
451
|
# Merge environment
|
|
440
452
|
spawn_env = os.environ.copy()
|
|
441
453
|
if env:
|
|
442
454
|
spawn_env.update(env)
|
|
443
455
|
spawn_env['TERM'] = 'xterm-256color'
|
|
444
|
-
|
|
456
|
+
|
|
445
457
|
# pexpect.spawn takes command as string or list
|
|
446
458
|
# Using list form for proper argument handling
|
|
447
459
|
cmd = command[0]
|
|
448
460
|
args = command[1:] if len(command) > 1 else []
|
|
449
|
-
|
|
461
|
+
|
|
450
462
|
self._child = pexpect.spawn(
|
|
451
463
|
cmd,
|
|
452
464
|
args=args,
|
|
@@ -454,17 +466,19 @@ class PexpectPTY(PTYTransport):
|
|
|
454
466
|
encoding=None, # Binary mode
|
|
455
467
|
dimensions=(self._rows, self._cols),
|
|
456
468
|
)
|
|
457
|
-
|
|
458
|
-
#
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
469
|
+
|
|
470
|
+
# Control echo based on use case:
|
|
471
|
+
# - SSH: echo=False (remote PTY handles echo)
|
|
472
|
+
# - Local shell/apps: echo=True (local PTY must echo)
|
|
473
|
+
self._child.setecho(echo)
|
|
474
|
+
|
|
475
|
+
logger.debug(f"Spawned via pexpect (echo={echo}): {' '.join(command)}")
|
|
476
|
+
|
|
463
477
|
def read(self, size: int = 4096) -> bytes:
|
|
464
478
|
"""Read from PTY (non-blocking)."""
|
|
465
479
|
if self._child is None:
|
|
466
480
|
return b''
|
|
467
|
-
|
|
481
|
+
|
|
468
482
|
try:
|
|
469
483
|
# Use read_nonblocking for non-blocking read
|
|
470
484
|
data = self._child.read_nonblocking(size, timeout=0)
|
|
@@ -477,19 +491,19 @@ class PexpectPTY(PTYTransport):
|
|
|
477
491
|
except Exception as e:
|
|
478
492
|
logger.debug(f"Read error: {e}")
|
|
479
493
|
return b''
|
|
480
|
-
|
|
494
|
+
|
|
481
495
|
def write(self, data: bytes) -> int:
|
|
482
496
|
"""Write to PTY."""
|
|
483
497
|
if self._child is None:
|
|
484
498
|
return 0
|
|
485
|
-
|
|
499
|
+
|
|
486
500
|
try:
|
|
487
501
|
self._child.send(data)
|
|
488
502
|
return len(data)
|
|
489
503
|
except Exception as e:
|
|
490
504
|
logger.debug(f"Write error: {e}")
|
|
491
505
|
return 0
|
|
492
|
-
|
|
506
|
+
|
|
493
507
|
def resize(self, cols: int, rows: int) -> None:
|
|
494
508
|
"""Resize PTY."""
|
|
495
509
|
self._cols = cols
|
|
@@ -499,7 +513,7 @@ class PexpectPTY(PTYTransport):
|
|
|
499
513
|
self._child.setwinsize(rows, cols)
|
|
500
514
|
except Exception as e:
|
|
501
515
|
logger.warning(f"Resize failed: {e}")
|
|
502
|
-
|
|
516
|
+
|
|
503
517
|
def close(self) -> None:
|
|
504
518
|
"""Close PTY and terminate process."""
|
|
505
519
|
if self._child is not None:
|
|
@@ -509,7 +523,7 @@ class PexpectPTY(PTYTransport):
|
|
|
509
523
|
logger.debug(f"Close error: {e}")
|
|
510
524
|
self._check_exit()
|
|
511
525
|
self._child = None
|
|
512
|
-
|
|
526
|
+
|
|
513
527
|
def _check_exit(self) -> None:
|
|
514
528
|
"""Check and store exit status."""
|
|
515
529
|
if self._child is not None and self._exit_code is None:
|
|
@@ -517,7 +531,7 @@ class PexpectPTY(PTYTransport):
|
|
|
517
531
|
self._exit_code = self._child.exitstatus
|
|
518
532
|
if self._exit_code is None:
|
|
519
533
|
self._exit_code = self._child.signalstatus or -1
|
|
520
|
-
|
|
534
|
+
|
|
521
535
|
@property
|
|
522
536
|
def is_alive(self) -> bool:
|
|
523
537
|
"""Check if process is still running."""
|
|
@@ -527,7 +541,7 @@ class PexpectPTY(PTYTransport):
|
|
|
527
541
|
if not alive:
|
|
528
542
|
self._check_exit()
|
|
529
543
|
return alive
|
|
530
|
-
|
|
544
|
+
|
|
531
545
|
@property
|
|
532
546
|
def exit_code(self) -> Optional[int]:
|
|
533
547
|
"""Get exit code if process has terminated."""
|
|
@@ -538,13 +552,13 @@ class PexpectPTY(PTYTransport):
|
|
|
538
552
|
def create_pty(use_pexpect: bool = True) -> PTYTransport:
|
|
539
553
|
"""
|
|
540
554
|
Factory for platform-appropriate PTY.
|
|
541
|
-
|
|
555
|
+
|
|
542
556
|
Args:
|
|
543
557
|
use_pexpect: If True and pexpect is available, use PexpectPTY (recommended)
|
|
544
|
-
|
|
558
|
+
|
|
545
559
|
Returns:
|
|
546
560
|
PTYTransport instance for current platform
|
|
547
|
-
|
|
561
|
+
|
|
548
562
|
Raises:
|
|
549
563
|
RuntimeError: If PTY support not available
|
|
550
564
|
"""
|
|
@@ -554,12 +568,12 @@ def create_pty(use_pexpect: bool = True) -> PTYTransport:
|
|
|
554
568
|
"pywinpty required for Windows. Install with: pip install pywinpty"
|
|
555
569
|
)
|
|
556
570
|
return WindowsPTY()
|
|
557
|
-
|
|
571
|
+
|
|
558
572
|
# On Unix, prefer pexpect for best /dev/tty capture
|
|
559
573
|
if use_pexpect and HAS_PEXPECT:
|
|
560
574
|
logger.debug("Using PexpectPTY for best /dev/tty capture")
|
|
561
575
|
return PexpectPTY()
|
|
562
|
-
|
|
576
|
+
|
|
563
577
|
logger.debug("Using UnixPTY")
|
|
564
578
|
return UnixPTY()
|
|
565
579
|
|
|
@@ -568,4 +582,4 @@ def is_pty_available() -> bool:
|
|
|
568
582
|
"""Check if PTY support is available on current platform."""
|
|
569
583
|
if IS_WINDOWS:
|
|
570
584
|
return HAS_WINPTY
|
|
571
|
-
return True # Always available on Unix
|
|
585
|
+
return True # Always available on Unix
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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 {
|