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/scripting/cli.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nterm/scripting/cli.py
|
|
3
|
+
|
|
4
|
+
Command-line interface for nterm scripting API.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
nterm-cli devices
|
|
8
|
+
nterm-cli search leaf
|
|
9
|
+
nterm-cli device eng-leaf-1
|
|
10
|
+
nterm-cli credentials --unlock
|
|
11
|
+
nterm-cli status
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import sys
|
|
15
|
+
import json
|
|
16
|
+
import getpass
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from .api import NTermAPI, DeviceInfo, CredentialInfo
|
|
22
|
+
|
|
23
|
+
# Shared API instance
|
|
24
|
+
_api: Optional[NTermAPI] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_api() -> NTermAPI:
|
|
28
|
+
global _api
|
|
29
|
+
if _api is None:
|
|
30
|
+
_api = NTermAPI()
|
|
31
|
+
return _api
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def format_table(items: list, columns: list[tuple[str, str, int]]) -> str:
|
|
35
|
+
"""
|
|
36
|
+
Format items as a simple table.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
items: List of objects with attributes
|
|
40
|
+
columns: List of (attr_name, header, width) tuples
|
|
41
|
+
"""
|
|
42
|
+
if not items:
|
|
43
|
+
return "No results."
|
|
44
|
+
|
|
45
|
+
# Build header
|
|
46
|
+
header = ""
|
|
47
|
+
separator = ""
|
|
48
|
+
for attr, name, width in columns:
|
|
49
|
+
header += f"{name:<{width}} "
|
|
50
|
+
separator += "-" * width + " "
|
|
51
|
+
|
|
52
|
+
lines = [header.rstrip(), separator.rstrip()]
|
|
53
|
+
|
|
54
|
+
# Build rows
|
|
55
|
+
for item in items:
|
|
56
|
+
row = ""
|
|
57
|
+
for attr, name, width in columns:
|
|
58
|
+
val = getattr(item, attr, "")
|
|
59
|
+
if val is None:
|
|
60
|
+
val = ""
|
|
61
|
+
val_str = str(val)[:width - 1] # Truncate if needed
|
|
62
|
+
row += f"{val_str:<{width}} "
|
|
63
|
+
lines.append(row.rstrip())
|
|
64
|
+
|
|
65
|
+
return "\n".join(lines)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@click.group()
|
|
69
|
+
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
|
|
70
|
+
@click.pass_context
|
|
71
|
+
def cli(ctx, output_json):
|
|
72
|
+
"""nterm command-line interface for device and credential management."""
|
|
73
|
+
ctx.ensure_object(dict)
|
|
74
|
+
ctx.obj["json"] = output_json
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@cli.command("devices")
|
|
78
|
+
@click.option("-p", "--pattern", default=None, help="Filter by glob pattern (e.g., 'eng-*')")
|
|
79
|
+
@click.option("-f", "--folder", default=None, help="Filter by folder name")
|
|
80
|
+
@click.pass_context
|
|
81
|
+
def list_devices(ctx, pattern, folder):
|
|
82
|
+
"""List saved devices/sessions."""
|
|
83
|
+
api = get_api()
|
|
84
|
+
devices = api.devices(pattern=pattern, folder=folder)
|
|
85
|
+
|
|
86
|
+
if ctx.obj["json"]:
|
|
87
|
+
click.echo(json.dumps([d.to_dict() for d in devices], indent=2))
|
|
88
|
+
else:
|
|
89
|
+
columns = [
|
|
90
|
+
("name", "NAME", 25),
|
|
91
|
+
("hostname", "HOSTNAME", 20),
|
|
92
|
+
("port", "PORT", 6),
|
|
93
|
+
("folder", "FOLDER", 15),
|
|
94
|
+
("credential", "CREDENTIAL", 20),
|
|
95
|
+
]
|
|
96
|
+
click.echo(format_table(devices, columns))
|
|
97
|
+
click.echo(f"\n{len(devices)} device(s)")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@cli.command("search")
|
|
101
|
+
@click.argument("query")
|
|
102
|
+
@click.pass_context
|
|
103
|
+
def search_devices(ctx, query):
|
|
104
|
+
"""Search devices by name, hostname, or description."""
|
|
105
|
+
api = get_api()
|
|
106
|
+
devices = api.search(query)
|
|
107
|
+
|
|
108
|
+
if ctx.obj["json"]:
|
|
109
|
+
click.echo(json.dumps([d.to_dict() for d in devices], indent=2))
|
|
110
|
+
else:
|
|
111
|
+
columns = [
|
|
112
|
+
("name", "NAME", 25),
|
|
113
|
+
("hostname", "HOSTNAME", 20),
|
|
114
|
+
("port", "PORT", 6),
|
|
115
|
+
("folder", "FOLDER", 15),
|
|
116
|
+
("credential", "CREDENTIAL", 20),
|
|
117
|
+
]
|
|
118
|
+
click.echo(format_table(devices, columns))
|
|
119
|
+
click.echo(f"\n{len(devices)} result(s)")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@cli.command("device")
|
|
123
|
+
@click.argument("name")
|
|
124
|
+
@click.pass_context
|
|
125
|
+
def get_device(ctx, name):
|
|
126
|
+
"""Get details for a specific device."""
|
|
127
|
+
api = get_api()
|
|
128
|
+
device = api.device(name)
|
|
129
|
+
|
|
130
|
+
if not device:
|
|
131
|
+
click.echo(f"Device '{name}' not found.", err=True)
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
if ctx.obj["json"]:
|
|
135
|
+
click.echo(json.dumps(device.to_dict(), indent=2))
|
|
136
|
+
else:
|
|
137
|
+
click.echo(f"Name: {device.name}")
|
|
138
|
+
click.echo(f"Hostname: {device.hostname}")
|
|
139
|
+
click.echo(f"Port: {device.port}")
|
|
140
|
+
click.echo(f"Folder: {device.folder or '(root)'}")
|
|
141
|
+
click.echo(f"Credential: {device.credential or '(none)'}")
|
|
142
|
+
click.echo(f"Last Connected: {device.last_connected or 'never'}")
|
|
143
|
+
click.echo(f"Connect Count: {device.connect_count}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@cli.command("folders")
|
|
147
|
+
@click.pass_context
|
|
148
|
+
def list_folders(ctx):
|
|
149
|
+
"""List all folders."""
|
|
150
|
+
api = get_api()
|
|
151
|
+
folders = api.folders()
|
|
152
|
+
|
|
153
|
+
if ctx.obj["json"]:
|
|
154
|
+
click.echo(json.dumps(folders, indent=2))
|
|
155
|
+
else:
|
|
156
|
+
if folders:
|
|
157
|
+
for f in folders:
|
|
158
|
+
click.echo(f" {f}")
|
|
159
|
+
click.echo(f"\n{len(folders)} folder(s)")
|
|
160
|
+
else:
|
|
161
|
+
click.echo("No folders.")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@cli.command("credentials")
|
|
165
|
+
@click.option("-p", "--pattern", default=None, help="Filter by glob pattern")
|
|
166
|
+
@click.option("-u", "--unlock", is_flag=True, help="Prompt for vault password")
|
|
167
|
+
@click.option("--password", default=None, help="Vault password (use --unlock for interactive)")
|
|
168
|
+
@click.pass_context
|
|
169
|
+
def list_credentials(ctx, pattern, unlock, password):
|
|
170
|
+
"""List credentials (requires unlocked vault)."""
|
|
171
|
+
api = get_api()
|
|
172
|
+
|
|
173
|
+
# Handle vault unlock
|
|
174
|
+
if not api.vault_initialized:
|
|
175
|
+
click.echo("Vault not initialized. Run nterm to create a vault first.", err=True)
|
|
176
|
+
sys.exit(1)
|
|
177
|
+
|
|
178
|
+
if not api.vault_unlocked:
|
|
179
|
+
if unlock:
|
|
180
|
+
password = getpass.getpass("Vault password: ")
|
|
181
|
+
|
|
182
|
+
if password:
|
|
183
|
+
if not api.unlock(password):
|
|
184
|
+
click.echo("Failed to unlock vault. Wrong password?", err=True)
|
|
185
|
+
sys.exit(1)
|
|
186
|
+
else:
|
|
187
|
+
click.echo("Vault is locked. Use --unlock or --password to unlock.", err=True)
|
|
188
|
+
sys.exit(1)
|
|
189
|
+
|
|
190
|
+
credentials = api.credentials(pattern=pattern)
|
|
191
|
+
|
|
192
|
+
if ctx.obj["json"]:
|
|
193
|
+
click.echo(json.dumps([c.to_dict() for c in credentials], indent=2))
|
|
194
|
+
else:
|
|
195
|
+
columns = [
|
|
196
|
+
("name", "NAME", 20),
|
|
197
|
+
("username", "USERNAME", 15),
|
|
198
|
+
("has_password", "PASS", 5),
|
|
199
|
+
("has_key", "KEY", 5),
|
|
200
|
+
("is_default", "DEFAULT", 8),
|
|
201
|
+
("jump_host", "JUMP HOST", 20),
|
|
202
|
+
]
|
|
203
|
+
click.echo(format_table(credentials, columns))
|
|
204
|
+
click.echo(f"\n{len(credentials)} credential(s)")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@cli.command("credential")
|
|
208
|
+
@click.argument("name")
|
|
209
|
+
@click.option("-u", "--unlock", is_flag=True, help="Prompt for vault password")
|
|
210
|
+
@click.option("--password", default=None, help="Vault password")
|
|
211
|
+
@click.pass_context
|
|
212
|
+
def get_credential(ctx, name, unlock, password):
|
|
213
|
+
"""Get details for a specific credential."""
|
|
214
|
+
api = get_api()
|
|
215
|
+
|
|
216
|
+
# Handle vault unlock
|
|
217
|
+
if not api.vault_unlocked:
|
|
218
|
+
if unlock:
|
|
219
|
+
password = getpass.getpass("Vault password: ")
|
|
220
|
+
|
|
221
|
+
if password:
|
|
222
|
+
if not api.unlock(password):
|
|
223
|
+
click.echo("Failed to unlock vault.", err=True)
|
|
224
|
+
sys.exit(1)
|
|
225
|
+
else:
|
|
226
|
+
click.echo("Vault is locked. Use --unlock or --password.", err=True)
|
|
227
|
+
sys.exit(1)
|
|
228
|
+
|
|
229
|
+
cred = api.credential(name)
|
|
230
|
+
|
|
231
|
+
if not cred:
|
|
232
|
+
click.echo(f"Credential '{name}' not found.", err=True)
|
|
233
|
+
sys.exit(1)
|
|
234
|
+
|
|
235
|
+
if ctx.obj["json"]:
|
|
236
|
+
click.echo(json.dumps(cred.to_dict(), indent=2))
|
|
237
|
+
else:
|
|
238
|
+
click.echo(f"Name: {cred.name}")
|
|
239
|
+
click.echo(f"Username: {cred.username}")
|
|
240
|
+
click.echo(f"Has Password: {cred.has_password}")
|
|
241
|
+
click.echo(f"Has Key: {cred.has_key}")
|
|
242
|
+
click.echo(f"Is Default: {cred.is_default}")
|
|
243
|
+
click.echo(f"Jump Host: {cred.jump_host or '(none)'}")
|
|
244
|
+
click.echo(f"Match Hosts: {', '.join(cred.match_hosts) or '(any)'}")
|
|
245
|
+
click.echo(f"Match Tags: {', '.join(cred.match_tags) or '(any)'}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@cli.command("resolve")
|
|
249
|
+
@click.argument("hostname")
|
|
250
|
+
@click.option("-u", "--unlock", is_flag=True, help="Prompt for vault password")
|
|
251
|
+
@click.option("--password", default=None, help="Vault password")
|
|
252
|
+
@click.pass_context
|
|
253
|
+
def resolve_credential(ctx, hostname, unlock, password):
|
|
254
|
+
"""Find which credential would be used for a hostname."""
|
|
255
|
+
api = get_api()
|
|
256
|
+
|
|
257
|
+
# Handle vault unlock
|
|
258
|
+
if not api.vault_unlocked:
|
|
259
|
+
if unlock:
|
|
260
|
+
password = getpass.getpass("Vault password: ")
|
|
261
|
+
|
|
262
|
+
if password:
|
|
263
|
+
if not api.unlock(password):
|
|
264
|
+
click.echo("Failed to unlock vault.", err=True)
|
|
265
|
+
sys.exit(1)
|
|
266
|
+
else:
|
|
267
|
+
click.echo("Vault is locked. Use --unlock or --password.", err=True)
|
|
268
|
+
sys.exit(1)
|
|
269
|
+
|
|
270
|
+
cred_name = api.resolve_credential(hostname)
|
|
271
|
+
|
|
272
|
+
if ctx.obj["json"]:
|
|
273
|
+
click.echo(json.dumps({"hostname": hostname, "credential": cred_name}))
|
|
274
|
+
else:
|
|
275
|
+
if cred_name:
|
|
276
|
+
click.echo(f"{hostname} -> {cred_name}")
|
|
277
|
+
else:
|
|
278
|
+
click.echo(f"No matching credential for {hostname}")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@cli.command("status")
|
|
282
|
+
@click.pass_context
|
|
283
|
+
def show_status(ctx):
|
|
284
|
+
"""Show API status summary."""
|
|
285
|
+
api = get_api()
|
|
286
|
+
status = api.status()
|
|
287
|
+
|
|
288
|
+
if ctx.obj["json"]:
|
|
289
|
+
click.echo(json.dumps(status, indent=2))
|
|
290
|
+
else:
|
|
291
|
+
click.echo(f"Devices: {status['devices']}")
|
|
292
|
+
click.echo(f"Folders: {status['folders']}")
|
|
293
|
+
click.echo(f"Vault Initialized: {status['vault_initialized']}")
|
|
294
|
+
click.echo(f"Vault Unlocked: {status['vault_unlocked']}")
|
|
295
|
+
if status['vault_unlocked']:
|
|
296
|
+
click.echo(f"Credentials: {status['credentials']}")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def main():
|
|
300
|
+
"""Entry point for CLI."""
|
|
301
|
+
cli(obj={})
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
main()
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Standalone local terminal session.
|
|
3
|
+
Minimal Session-compatible wrapper for local PTY processes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Optional, Callable, List
|
|
12
|
+
|
|
13
|
+
from .pty_transport import create_pty, is_pty_available, PTYTransport
|
|
14
|
+
from .base import SessionState, SessionEvent, DataReceived, StateChanged
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# IPython startup code to inject the nterm API
|
|
20
|
+
IPYTHON_STARTUP = '''
|
|
21
|
+
from nterm.scripting import api
|
|
22
|
+
print("\\n\\033[1;36mnterm API loaded.\\033[0m")
|
|
23
|
+
print(" api.devices() - List saved devices")
|
|
24
|
+
print(" api.search(query) - Search devices")
|
|
25
|
+
print(" api.credentials() - List credentials (after api.unlock())")
|
|
26
|
+
print(" api.help() - Show all commands")
|
|
27
|
+
print()
|
|
28
|
+
'''
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LocalTerminal:
|
|
32
|
+
"""
|
|
33
|
+
Lightweight local PTY session.
|
|
34
|
+
|
|
35
|
+
Implements just enough of Session interface for TerminalWidget.
|
|
36
|
+
Completely separate from SSH session management.
|
|
37
|
+
|
|
38
|
+
Usage:
|
|
39
|
+
# Default shell
|
|
40
|
+
session = LocalTerminal()
|
|
41
|
+
|
|
42
|
+
# IPython with nterm API pre-loaded
|
|
43
|
+
session = LocalTerminal.ipython()
|
|
44
|
+
|
|
45
|
+
# Python REPL
|
|
46
|
+
session = LocalTerminal.python()
|
|
47
|
+
|
|
48
|
+
# Arbitrary command
|
|
49
|
+
session = LocalTerminal(['htop'])
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, command: Optional[List[str]] = None):
|
|
53
|
+
"""
|
|
54
|
+
Initialize local terminal session.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
command: Command to run. Defaults to user's shell.
|
|
58
|
+
"""
|
|
59
|
+
self._command = command or self._default_shell()
|
|
60
|
+
self._pty: Optional[PTYTransport] = None
|
|
61
|
+
self._state = SessionState.DISCONNECTED
|
|
62
|
+
self._stop = threading.Event()
|
|
63
|
+
self._handler: Optional[Callable[[SessionEvent], None]] = None
|
|
64
|
+
self._cols, self._rows = 120, 40
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _default_shell() -> List[str]:
|
|
68
|
+
"""Get user's default shell."""
|
|
69
|
+
if sys.platform == 'win32':
|
|
70
|
+
return [os.environ.get('COMSPEC', 'cmd.exe')]
|
|
71
|
+
return [os.environ.get('SHELL', '/bin/bash')]
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def ipython(cls, with_api: bool = True) -> 'LocalTerminal':
|
|
75
|
+
"""
|
|
76
|
+
Create IPython session in current venv.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
with_api: If True, pre-load nterm scripting API into namespace
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
LocalTerminal configured to run IPython
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
RuntimeError: If IPython is not installed
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
import IPython # noqa: F401
|
|
89
|
+
except ImportError:
|
|
90
|
+
raise RuntimeError(
|
|
91
|
+
"IPython not installed. Install with: pip install ntermqt[scripting]"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if with_api:
|
|
95
|
+
# Use -i with -c to run startup code then go interactive
|
|
96
|
+
cmd = [
|
|
97
|
+
sys.executable, '-m', 'IPython',
|
|
98
|
+
'-i', '-c', IPYTHON_STARTUP
|
|
99
|
+
]
|
|
100
|
+
else:
|
|
101
|
+
cmd = [sys.executable, '-m', 'IPython']
|
|
102
|
+
return cls(cmd)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def python(cls) -> 'LocalTerminal':
|
|
106
|
+
"""
|
|
107
|
+
Create Python REPL session in current venv.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
LocalTerminal configured to run Python
|
|
111
|
+
"""
|
|
112
|
+
return cls([sys.executable])
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def shell(cls, shell: str = None) -> 'LocalTerminal':
|
|
116
|
+
"""
|
|
117
|
+
Create shell session.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
shell: Optional shell path. Defaults to user's default shell.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
LocalTerminal configured to run shell
|
|
124
|
+
"""
|
|
125
|
+
if shell:
|
|
126
|
+
return cls([shell])
|
|
127
|
+
return cls()
|
|
128
|
+
|
|
129
|
+
# -------------------------------------------------------------------------
|
|
130
|
+
# Session interface (minimal subset for TerminalWidget compatibility)
|
|
131
|
+
# -------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def state(self) -> SessionState:
|
|
135
|
+
"""Current session state."""
|
|
136
|
+
return self._state
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def is_connected(self) -> bool:
|
|
140
|
+
"""Check if process is running."""
|
|
141
|
+
return self._state == SessionState.CONNECTED
|
|
142
|
+
|
|
143
|
+
def set_event_handler(self, handler: Callable[[SessionEvent], None]) -> None:
|
|
144
|
+
"""Set callback for session events."""
|
|
145
|
+
self._handler = handler
|
|
146
|
+
|
|
147
|
+
def set_auto_reconnect(self, enabled: bool) -> None:
|
|
148
|
+
"""No-op for local sessions."""
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
def connect(self) -> None:
|
|
152
|
+
"""Start the local process."""
|
|
153
|
+
if not is_pty_available():
|
|
154
|
+
self._set_state(SessionState.FAILED, "PTY unavailable. On Windows, install pywinpty.")
|
|
155
|
+
return
|
|
156
|
+
self._stop.clear()
|
|
157
|
+
threading.Thread(target=self._run, daemon=True).start()
|
|
158
|
+
|
|
159
|
+
def write(self, data: bytes) -> None:
|
|
160
|
+
"""Send input to process."""
|
|
161
|
+
if self._pty:
|
|
162
|
+
self._pty.write(data)
|
|
163
|
+
|
|
164
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
165
|
+
"""Resize terminal."""
|
|
166
|
+
self._cols, self._rows = cols, rows
|
|
167
|
+
if self._pty:
|
|
168
|
+
self._pty.resize(cols, rows)
|
|
169
|
+
|
|
170
|
+
def disconnect(self) -> None:
|
|
171
|
+
"""Terminate the process."""
|
|
172
|
+
self._stop.set()
|
|
173
|
+
if self._pty:
|
|
174
|
+
self._pty.close()
|
|
175
|
+
self._pty = None
|
|
176
|
+
self._set_state(SessionState.DISCONNECTED, "Closed")
|
|
177
|
+
|
|
178
|
+
# -------------------------------------------------------------------------
|
|
179
|
+
# Internal
|
|
180
|
+
# -------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
def _set_state(self, state: SessionState, msg: str = ""):
|
|
183
|
+
"""Update state and notify handler."""
|
|
184
|
+
old, self._state = self._state, state
|
|
185
|
+
logger.debug(f"LocalTerminal: {old.name} -> {state.name} {msg}")
|
|
186
|
+
if self._handler:
|
|
187
|
+
try:
|
|
188
|
+
self._handler(StateChanged(old, state, msg))
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.exception(f"Event handler error: {e}")
|
|
191
|
+
|
|
192
|
+
def _run(self):
|
|
193
|
+
"""Main PTY read loop (runs in thread)."""
|
|
194
|
+
try:
|
|
195
|
+
self._set_state(SessionState.CONNECTING)
|
|
196
|
+
|
|
197
|
+
logger.info(f"Spawning: {' '.join(self._command)}")
|
|
198
|
+
|
|
199
|
+
self._pty = create_pty()
|
|
200
|
+
self._pty.spawn(self._command, echo=True) # Local apps need echo
|
|
201
|
+
self._pty.resize(self._cols, self._rows)
|
|
202
|
+
|
|
203
|
+
self._set_state(SessionState.CONNECTED)
|
|
204
|
+
|
|
205
|
+
# Read loop
|
|
206
|
+
while not self._stop.is_set() and self._pty.is_alive:
|
|
207
|
+
data = self._pty.read(8192)
|
|
208
|
+
if data and self._handler:
|
|
209
|
+
self._handler(DataReceived(data))
|
|
210
|
+
else:
|
|
211
|
+
time.sleep(0.01)
|
|
212
|
+
|
|
213
|
+
# Process exited
|
|
214
|
+
if not self._stop.is_set():
|
|
215
|
+
exit_code = self._pty.exit_code if self._pty else None
|
|
216
|
+
msg = f"Process exited (code {exit_code})" if exit_code is not None else "Process exited"
|
|
217
|
+
self._set_state(SessionState.DISCONNECTED, msg)
|
|
218
|
+
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.exception("LocalTerminal failed")
|
|
221
|
+
self._set_state(SessionState.FAILED, str(e))
|
|
222
|
+
finally:
|
|
223
|
+
if self._pty:
|
|
224
|
+
self._pty.close()
|
|
225
|
+
self._pty = None
|