portacode 1.3.32__py3-none-any.whl → 1.4.11.dev5__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.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +2 -2
- portacode/cli.py +158 -14
- portacode/connection/client.py +127 -8
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +370 -4
- portacode/connection/handlers/__init__.py +16 -1
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +674 -17
- portacode/connection/handlers/project_aware_file_handlers.py +11 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +31 -61
- portacode/connection/handlers/project_state/git_manager.py +139 -572
- portacode/connection/handlers/project_state/handlers.py +28 -14
- portacode/connection/handlers/project_state/manager.py +226 -101
- portacode/connection/handlers/proxmox_infra.py +790 -0
- portacode/connection/handlers/session.py +465 -84
- portacode/connection/handlers/system_handlers.py +181 -8
- portacode/connection/handlers/tab_factory.py +1 -47
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/terminal.py +55 -10
- portacode/keypair.py +63 -1
- portacode/link_capture/__init__.py +38 -0
- portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
- portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
- portacode/link_capture/bin/elinks +3 -0
- portacode/link_capture/bin/gio-open +3 -0
- portacode/link_capture/bin/gnome-open +3 -0
- portacode/link_capture/bin/gvfs-open +3 -0
- portacode/link_capture/bin/kde-open +3 -0
- portacode/link_capture/bin/kfmclient +3 -0
- portacode/link_capture/bin/link_capture_exec.sh +11 -0
- portacode/link_capture/bin/link_capture_wrapper.py +75 -0
- portacode/link_capture/bin/links +3 -0
- portacode/link_capture/bin/links2 +3 -0
- portacode/link_capture/bin/lynx +3 -0
- portacode/link_capture/bin/mate-open +3 -0
- portacode/link_capture/bin/netsurf +3 -0
- portacode/link_capture/bin/sensible-browser +3 -0
- portacode/link_capture/bin/w3m +3 -0
- portacode/link_capture/bin/x-www-browser +3 -0
- portacode/link_capture/bin/xdg-open +3 -0
- portacode/pairing.py +103 -0
- portacode/static/js/utils/ntp-clock.js +170 -79
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +45 -131
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/METADATA +71 -3
- portacode-1.4.11.dev5.dist-info/RECORD +97 -0
- test_modules/test_device_online.py +1 -1
- test_modules/test_login_flow.py +8 -4
- test_modules/test_play_store_screenshots.py +294 -0
- testing_framework/.env.example +4 -1
- testing_framework/core/playwright_manager.py +63 -9
- portacode-1.3.32.dist-info/RECORD +0 -70
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/WHEEL +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/entry_points.txt +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.3.32.dist-info → portacode-1.4.11.dev5.dist-info}/top_level.txt +0 -0
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
"""Terminal session management."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
import json
|
|
4
5
|
import logging
|
|
5
6
|
import os
|
|
7
|
+
import struct
|
|
6
8
|
import sys
|
|
7
9
|
import time
|
|
8
10
|
import uuid
|
|
9
11
|
from asyncio.subprocess import Process
|
|
10
12
|
from pathlib import Path
|
|
11
|
-
from typing import Any, Dict, Optional, List, TYPE_CHECKING
|
|
12
|
-
|
|
13
|
+
from typing import Any, Awaitable, Callable, Dict, Optional, List, TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
from platformdirs import user_data_dir
|
|
16
|
+
|
|
17
|
+
from portacode.link_capture import prepare_link_capture_bin
|
|
18
|
+
|
|
19
|
+
import pyte
|
|
13
20
|
|
|
14
21
|
if TYPE_CHECKING:
|
|
15
22
|
from ..multiplex import Channel
|
|
@@ -19,13 +26,38 @@ TERMINAL_DATA_RATE_LIMIT_MS = 60 # Minimum time between terminal_data events (m
|
|
|
19
26
|
TERMINAL_DATA_MAX_WAIT_MS = 1000 # Maximum time to wait before sending accumulated data (milliseconds)
|
|
20
27
|
TERMINAL_DATA_INITIAL_WAIT_MS = 10 # Time to wait for additional data even on first event (milliseconds)
|
|
21
28
|
|
|
22
|
-
# Terminal buffer
|
|
23
|
-
|
|
29
|
+
# Terminal buffer configuration - using pyte for proper screen state management
|
|
30
|
+
TERMINAL_COLUMNS = 80 # Default terminal width
|
|
31
|
+
TERMINAL_ROWS = 24 # Default terminal height (visible area)
|
|
32
|
+
TERMINAL_SCROLLBACK_LIMIT = 1000 # Maximum number of scrollback lines to preserve
|
|
33
|
+
|
|
34
|
+
# Link event folder for capturing helper notifications
|
|
35
|
+
_LINK_EVENT_ROOT = Path(user_data_dir("portacode", "portacode")) / "link_events"
|
|
36
|
+
_LINK_EVENT_POLL_INTERVAL = 0.5 # seconds
|
|
37
|
+
LINK_EVENT_THROTTLE_SECONDS = 5.0
|
|
38
|
+
LINK_CAPTURE_ORIGINAL_BROWSER_ENV = "PORTACODE_LINK_CAPTURE_ORIGINAL_BROWSER"
|
|
24
39
|
|
|
25
40
|
logger = logging.getLogger(__name__)
|
|
26
41
|
|
|
27
42
|
_IS_WINDOWS = sys.platform.startswith("win")
|
|
28
43
|
|
|
44
|
+
|
|
45
|
+
def _configure_pty_window_size(fd: int, rows: int, cols: int) -> None:
|
|
46
|
+
"""Set the PTY window size so subprocesses see a real terminal."""
|
|
47
|
+
if _IS_WINDOWS:
|
|
48
|
+
return
|
|
49
|
+
try:
|
|
50
|
+
import fcntl
|
|
51
|
+
import termios
|
|
52
|
+
winsize = struct.pack("HHHH", rows, cols, 0, 0)
|
|
53
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
|
54
|
+
except ImportError:
|
|
55
|
+
logger.debug("termios/fcntl unavailable; skipping PTY window sizing")
|
|
56
|
+
except OSError as exc:
|
|
57
|
+
logger.warning("Failed to set PTY window size (%sx%s): %s", cols, rows, exc)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
29
61
|
# Minimal, safe defaults for interactive shells
|
|
30
62
|
_DEFAULT_ENV = {
|
|
31
63
|
"TERM": "xterm-256color",
|
|
@@ -39,9 +71,105 @@ def _build_child_env() -> Dict[str, str]:
|
|
|
39
71
|
env = os.environ.copy()
|
|
40
72
|
for k, v in _DEFAULT_ENV.items():
|
|
41
73
|
env.setdefault(k, v)
|
|
74
|
+
env.setdefault("COLUMNS", str(TERMINAL_COLUMNS))
|
|
75
|
+
env.setdefault("LINES", str(TERMINAL_ROWS))
|
|
42
76
|
return env
|
|
43
77
|
|
|
44
78
|
|
|
79
|
+
_LINK_EVENT_DISPATCHER: Optional["LinkEventDispatcher"] = None
|
|
80
|
+
|
|
81
|
+
class LinkEventDispatcher:
|
|
82
|
+
"""Watch a shared folder for link capture files."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, directory: Optional[Path]):
|
|
85
|
+
self.directory = directory
|
|
86
|
+
self._task: Optional[asyncio.Task[None]] = None
|
|
87
|
+
|
|
88
|
+
# Callbacks that are notified whenever a new event file is processed
|
|
89
|
+
self._callbacks: List[Callable[[Dict[str, Any]], Awaitable[None]]] = []
|
|
90
|
+
|
|
91
|
+
def start(self) -> None:
|
|
92
|
+
if not self.directory:
|
|
93
|
+
return
|
|
94
|
+
if self._task and not self._task.done():
|
|
95
|
+
return
|
|
96
|
+
try:
|
|
97
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
logger.warning("link_watcher: Failed to create directory %s: %s", self.directory, exc)
|
|
100
|
+
return
|
|
101
|
+
self._task = asyncio.create_task(self._run())
|
|
102
|
+
|
|
103
|
+
async def stop(self) -> None:
|
|
104
|
+
if not self._task:
|
|
105
|
+
return
|
|
106
|
+
self._task.cancel()
|
|
107
|
+
try:
|
|
108
|
+
await self._task
|
|
109
|
+
except asyncio.CancelledError:
|
|
110
|
+
pass
|
|
111
|
+
self._task = None
|
|
112
|
+
|
|
113
|
+
async def _run(self) -> None:
|
|
114
|
+
while True:
|
|
115
|
+
try:
|
|
116
|
+
if self.directory.exists():
|
|
117
|
+
for entry in sorted(self.directory.iterdir()):
|
|
118
|
+
if not entry.is_file():
|
|
119
|
+
continue
|
|
120
|
+
await self._process_entry(entry)
|
|
121
|
+
await asyncio.sleep(_LINK_EVENT_POLL_INTERVAL)
|
|
122
|
+
except asyncio.CancelledError:
|
|
123
|
+
break
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
logger.warning("link_watcher: error scanning %s: %s", self.directory, exc)
|
|
126
|
+
await asyncio.sleep(_LINK_EVENT_POLL_INTERVAL)
|
|
127
|
+
|
|
128
|
+
async def _process_entry(self, entry: Path) -> None:
|
|
129
|
+
try:
|
|
130
|
+
raw = entry.read_text(encoding="utf-8")
|
|
131
|
+
payload = json.loads(raw)
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
logger.warning("link_watcher: failed to read %s: %s", entry, exc)
|
|
134
|
+
else:
|
|
135
|
+
terminal_id = payload.get("terminal_id")
|
|
136
|
+
link = payload.get("url")
|
|
137
|
+
if link:
|
|
138
|
+
logger.info("link_watcher: terminal %s captured link %s", terminal_id, link)
|
|
139
|
+
else:
|
|
140
|
+
logger.info("link_watcher: terminal %s observed link capture without url (%s)", terminal_id, payload)
|
|
141
|
+
await self._notify_callbacks(payload)
|
|
142
|
+
finally:
|
|
143
|
+
try:
|
|
144
|
+
entry.unlink(missing_ok=True)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
logger.warning("link_watcher: failed to remove %s: %s", entry, exc)
|
|
147
|
+
|
|
148
|
+
def register_callback(self, callback: Callable[[Dict[str, Any]], Awaitable[None]]) -> None:
|
|
149
|
+
"""Register a coroutine callback for processed link events."""
|
|
150
|
+
if callback in self._callbacks:
|
|
151
|
+
return
|
|
152
|
+
self._callbacks.append(callback)
|
|
153
|
+
|
|
154
|
+
async def _notify_callbacks(self, payload: Dict[str, Any]) -> None:
|
|
155
|
+
if not self._callbacks:
|
|
156
|
+
return
|
|
157
|
+
for callback in list(self._callbacks):
|
|
158
|
+
try:
|
|
159
|
+
result = callback(payload)
|
|
160
|
+
if asyncio.iscoroutine(result):
|
|
161
|
+
await result
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
logger.warning("link_watcher: callback raised an exception: %s", exc)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _get_link_event_dispatcher() -> "LinkEventDispatcher":
|
|
167
|
+
global _LINK_EVENT_DISPATCHER
|
|
168
|
+
if _LINK_EVENT_DISPATCHER is None:
|
|
169
|
+
_LINK_EVENT_DISPATCHER = LinkEventDispatcher(_LINK_EVENT_ROOT)
|
|
170
|
+
return _LINK_EVENT_DISPATCHER
|
|
171
|
+
|
|
172
|
+
|
|
45
173
|
class TerminalSession:
|
|
46
174
|
"""Represents a local shell subprocess bound to a mux channel."""
|
|
47
175
|
|
|
@@ -52,9 +180,11 @@ class TerminalSession:
|
|
|
52
180
|
self.project_id = project_id
|
|
53
181
|
self.terminal_manager = terminal_manager
|
|
54
182
|
self._reader_task: Optional[asyncio.Task[None]] = None
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
183
|
+
|
|
184
|
+
# Use pyte for proper terminal screen state management
|
|
185
|
+
self._screen = pyte.HistoryScreen(TERMINAL_COLUMNS, TERMINAL_ROWS, history=TERMINAL_SCROLLBACK_LIMIT)
|
|
186
|
+
self._stream = pyte.Stream(self._screen) # Use Stream (not ByteStream) since data is already decoded to strings
|
|
187
|
+
|
|
58
188
|
# Rate limiting for terminal_data events
|
|
59
189
|
self._last_send_time: float = 0
|
|
60
190
|
self._pending_data: str = ""
|
|
@@ -82,7 +212,7 @@ class TerminalSession:
|
|
|
82
212
|
# Cancel existing reader task if it exists
|
|
83
213
|
if self._reader_task and not self._reader_task.done():
|
|
84
214
|
self._reader_task.cancel()
|
|
85
|
-
|
|
215
|
+
|
|
86
216
|
self._reader_task = asyncio.create_task(_pump())
|
|
87
217
|
|
|
88
218
|
async def write(self, data: str) -> None:
|
|
@@ -162,13 +292,13 @@ class TerminalSession:
|
|
|
162
292
|
"""Send terminal data immediately and update last send time."""
|
|
163
293
|
self._last_send_time = time.time()
|
|
164
294
|
data_size = len(data.encode('utf-8'))
|
|
165
|
-
|
|
166
|
-
logger.info("session: Attempting to send terminal_data for terminal %s (data_size=%d bytes)",
|
|
295
|
+
|
|
296
|
+
logger.info("session: Attempting to send terminal_data for terminal %s (data_size=%d bytes)",
|
|
167
297
|
self.id, data_size)
|
|
168
|
-
|
|
169
|
-
#
|
|
298
|
+
|
|
299
|
+
# Feed data to pyte screen for proper terminal state management
|
|
170
300
|
self._add_to_buffer(data)
|
|
171
|
-
|
|
301
|
+
|
|
172
302
|
try:
|
|
173
303
|
# Send terminal data via control channel with client session targeting
|
|
174
304
|
if self.terminal_manager:
|
|
@@ -206,39 +336,25 @@ class TerminalSession:
|
|
|
206
336
|
current_time = time.time()
|
|
207
337
|
time_since_last_send = (current_time - self._last_send_time) * 1000 # Convert to milliseconds
|
|
208
338
|
data_size = len(data.encode('utf-8'))
|
|
209
|
-
|
|
210
|
-
logger.info("session: Received terminal_data for terminal %s (data_size=%d bytes, time_since_last_send=%.1fms)",
|
|
339
|
+
|
|
340
|
+
logger.info("session: Received terminal_data for terminal %s (data_size=%d bytes, time_since_last_send=%.1fms)",
|
|
211
341
|
self.id, data_size, time_since_last_send)
|
|
212
|
-
|
|
213
|
-
# Add new data to pending buffer
|
|
214
|
-
# Always add the new data first
|
|
342
|
+
|
|
343
|
+
# Add new data to pending buffer (no trimming needed - pyte handles screen state)
|
|
215
344
|
self._pending_data += data
|
|
216
|
-
|
|
217
|
-
# Simple size limiting - only trim if we exceed the 30KB limit significantly
|
|
218
|
-
pending_size = len(self._pending_data.encode('utf-8'))
|
|
219
|
-
if pending_size > TERMINAL_BUFFER_SIZE_LIMIT_BYTES:
|
|
220
|
-
logger.info("session: Buffer size limit exceeded for terminal %s (pending_size=%d bytes, limit=%d bytes), trimming",
|
|
221
|
-
self.id, pending_size, TERMINAL_BUFFER_SIZE_LIMIT_BYTES)
|
|
222
|
-
# Only do minimal ANSI-safe trimming from the beginning
|
|
223
|
-
excess_bytes = pending_size - TERMINAL_BUFFER_SIZE_LIMIT_BYTES
|
|
224
|
-
trim_pos = self._find_minimal_safe_trim_position(excess_bytes)
|
|
225
|
-
|
|
226
|
-
if trim_pos > 0:
|
|
227
|
-
self._pending_data = self._pending_data[trim_pos:]
|
|
228
|
-
logger.info("session: Trimmed %d bytes from pending buffer for terminal %s", trim_pos, self.id)
|
|
229
|
-
|
|
345
|
+
|
|
230
346
|
# Cancel existing debounce task if any
|
|
231
347
|
if self._debounce_task and not self._debounce_task.done():
|
|
232
348
|
logger.debug("session: Cancelling existing debounce task for terminal %s", self.id)
|
|
233
349
|
self._debounce_task.cancel()
|
|
234
|
-
|
|
350
|
+
|
|
235
351
|
# Always set up a debounce timer to catch rapid consecutive outputs
|
|
236
352
|
async def _debounce_timer():
|
|
237
353
|
try:
|
|
238
354
|
if time_since_last_send >= TERMINAL_DATA_RATE_LIMIT_MS:
|
|
239
355
|
# Enough time has passed since last send, wait initial delay for more data
|
|
240
356
|
wait_time = TERMINAL_DATA_INITIAL_WAIT_MS / 1000
|
|
241
|
-
logger.info("session: Rate limit satisfied for terminal %s, waiting %.1fms for more data",
|
|
357
|
+
logger.info("session: Rate limit satisfied for terminal %s, waiting %.1fms for more data",
|
|
242
358
|
self.id, wait_time * 1000)
|
|
243
359
|
else:
|
|
244
360
|
# Too soon since last send, wait for either the rate limit period or max wait time
|
|
@@ -246,9 +362,9 @@ class TerminalSession:
|
|
|
246
362
|
(TERMINAL_DATA_RATE_LIMIT_MS - time_since_last_send) / 1000,
|
|
247
363
|
TERMINAL_DATA_MAX_WAIT_MS / 1000
|
|
248
364
|
)
|
|
249
|
-
logger.info("session: Rate limit active for terminal %s, waiting %.1fms before send (time_since_last=%.1fms, rate_limit=%dms)",
|
|
365
|
+
logger.info("session: Rate limit active for terminal %s, waiting %.1fms before send (time_since_last=%.1fms, rate_limit=%dms)",
|
|
250
366
|
self.id, wait_time * 1000, time_since_last_send, TERMINAL_DATA_RATE_LIMIT_MS)
|
|
251
|
-
|
|
367
|
+
|
|
252
368
|
await asyncio.sleep(wait_time)
|
|
253
369
|
logger.info("session: Debounce timer expired for terminal %s, flushing pending data", self.id)
|
|
254
370
|
await self._flush_pending_data()
|
|
@@ -256,56 +372,264 @@ class TerminalSession:
|
|
|
256
372
|
logger.debug("session: Debounce timer cancelled for terminal %s (new data arrived)", self.id)
|
|
257
373
|
# Timer was cancelled, another data event came in
|
|
258
374
|
pass
|
|
259
|
-
|
|
375
|
+
|
|
260
376
|
self._debounce_task = asyncio.create_task(_debounce_timer())
|
|
261
377
|
logger.info("session: Started debounce timer for terminal %s", self.id)
|
|
262
378
|
|
|
263
|
-
def _find_minimal_safe_trim_position(self, excess_bytes: int) -> int:
|
|
264
|
-
"""Find a minimal safe position to trim that only avoids breaking ANSI sequences."""
|
|
265
|
-
import re
|
|
266
|
-
|
|
267
|
-
# Find the basic character-safe position
|
|
268
|
-
trim_pos = 0
|
|
269
|
-
current_bytes = 0
|
|
270
|
-
for i, char in enumerate(self._pending_data):
|
|
271
|
-
char_bytes = len(char.encode('utf-8'))
|
|
272
|
-
if current_bytes + char_bytes > excess_bytes:
|
|
273
|
-
trim_pos = i
|
|
274
|
-
break
|
|
275
|
-
current_bytes += char_bytes
|
|
276
|
-
|
|
277
|
-
# Only adjust if we're breaking an ANSI sequence
|
|
278
|
-
search_start = max(0, trim_pos - 20) # Much smaller search area
|
|
279
|
-
text_before_trim = self._pending_data[search_start:trim_pos]
|
|
280
|
-
|
|
281
|
-
# Check if we're in the middle of an incomplete ANSI sequence
|
|
282
|
-
incomplete_pattern = r'\x1b\[[0-9;]*$'
|
|
283
|
-
if re.search(incomplete_pattern, text_before_trim):
|
|
284
|
-
# Find the start of this sequence and trim before it
|
|
285
|
-
last_esc = text_before_trim.rfind('\x1b[')
|
|
286
|
-
if last_esc >= 0:
|
|
287
|
-
return search_start + last_esc
|
|
288
|
-
|
|
289
|
-
# Check if we're cutting right after an ESC character
|
|
290
|
-
if trim_pos > 0 and self._pending_data[trim_pos - 1] == '\x1b':
|
|
291
|
-
return trim_pos - 1
|
|
292
|
-
|
|
293
|
-
return trim_pos
|
|
294
|
-
|
|
295
379
|
def _add_to_buffer(self, data: str) -> None:
|
|
296
|
-
"""
|
|
297
|
-
|
|
298
|
-
self.
|
|
299
|
-
self._buffer_size_bytes += data_bytes
|
|
300
|
-
|
|
301
|
-
# Remove oldest entries until we're under the size limit
|
|
302
|
-
while self._buffer_size_bytes > TERMINAL_BUFFER_SIZE_LIMIT_BYTES and self._buffer:
|
|
303
|
-
oldest_data = self._buffer.popleft()
|
|
304
|
-
self._buffer_size_bytes -= len(oldest_data.encode('utf-8'))
|
|
380
|
+
"""Feed data to pyte virtual terminal screen."""
|
|
381
|
+
# Feed the data to pyte - it handles all ANSI parsing and screen state management
|
|
382
|
+
self._stream.feed(data)
|
|
305
383
|
|
|
306
384
|
def snapshot_buffer(self) -> str:
|
|
307
|
-
"""Return
|
|
308
|
-
|
|
385
|
+
"""Return the visible terminal content as ANSI sequences suitable for XTerm.js."""
|
|
386
|
+
# Render screen content to ANSI
|
|
387
|
+
result = self._render_screen_to_ansi()
|
|
388
|
+
|
|
389
|
+
# Add cursor positioning at the end so XTerm.js knows where the cursor should be
|
|
390
|
+
# This is critical - without it, new data gets written at the wrong position causing duplication
|
|
391
|
+
cursor_y = self._screen.cursor.y + 1 # Convert 0-indexed to 1-indexed
|
|
392
|
+
cursor_x = self._screen.cursor.x + 1 # Convert 0-indexed to 1-indexed
|
|
393
|
+
|
|
394
|
+
# Move cursor to the correct position
|
|
395
|
+
result += f'\x1b[{cursor_y};{cursor_x}H'
|
|
396
|
+
|
|
397
|
+
return result
|
|
398
|
+
|
|
399
|
+
def _render_screen_to_ansi(self) -> str:
|
|
400
|
+
"""Convert pyte screen state to ANSI escape sequences.
|
|
401
|
+
|
|
402
|
+
This renders both scrollback history and visible screen with full formatting
|
|
403
|
+
(colors, bold, italics, underline) preserved as ANSI sequences.
|
|
404
|
+
"""
|
|
405
|
+
lines = []
|
|
406
|
+
|
|
407
|
+
# Get scrollback history if available (HistoryScreen provides this)
|
|
408
|
+
if hasattr(self._screen, 'history'):
|
|
409
|
+
# Process scrollback lines (lines that have scrolled off the top)
|
|
410
|
+
history_top = self._screen.history.top
|
|
411
|
+
for line_data in history_top:
|
|
412
|
+
# line_data is a dict mapping column positions to Char objects
|
|
413
|
+
line = self._render_line_to_ansi(line_data, self._screen.columns)
|
|
414
|
+
lines.append(line)
|
|
415
|
+
|
|
416
|
+
# Process visible screen lines
|
|
417
|
+
for y in range(self._screen.lines):
|
|
418
|
+
line_data = self._screen.buffer[y]
|
|
419
|
+
line = self._render_line_to_ansi(line_data, self._screen.columns)
|
|
420
|
+
lines.append(line)
|
|
421
|
+
|
|
422
|
+
# Join all lines with CRLF for proper terminal display
|
|
423
|
+
return '\r\n'.join(lines)
|
|
424
|
+
|
|
425
|
+
def _render_line_to_ansi(self, line_data: Dict[int, 'pyte.screens.Char'], columns: int) -> str:
|
|
426
|
+
"""Convert a single line from pyte format to ANSI escape sequences.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
line_data: Dict mapping column index to Char objects
|
|
430
|
+
columns: Number of columns in the terminal
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
String with ANSI escape codes for formatting
|
|
434
|
+
"""
|
|
435
|
+
result = []
|
|
436
|
+
last_char = None
|
|
437
|
+
did_reset = False # Track if we just emitted a reset code
|
|
438
|
+
|
|
439
|
+
for x in range(columns):
|
|
440
|
+
char = line_data.get(x)
|
|
441
|
+
if char is None:
|
|
442
|
+
# Empty cell - reset formatting if we had any
|
|
443
|
+
if last_char is not None and self._char_has_formatting(last_char):
|
|
444
|
+
result.append('\x1b[0m')
|
|
445
|
+
did_reset = True
|
|
446
|
+
result.append(' ')
|
|
447
|
+
last_char = None
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
# Check if formatting changed from previous character
|
|
451
|
+
format_changed = last_char is None or self._char_format_changed(last_char, char) or did_reset
|
|
452
|
+
|
|
453
|
+
if format_changed:
|
|
454
|
+
# If previous char had formatting and current is different, reset first
|
|
455
|
+
if last_char is not None and self._char_has_formatting(last_char) and not did_reset:
|
|
456
|
+
result.append('\x1b[0m')
|
|
457
|
+
|
|
458
|
+
# Apply new formatting (always apply after reset)
|
|
459
|
+
ansi_codes = self._get_ansi_codes_for_char(char)
|
|
460
|
+
if ansi_codes:
|
|
461
|
+
result.append(f'\x1b[{ansi_codes}m')
|
|
462
|
+
did_reset = False
|
|
463
|
+
else:
|
|
464
|
+
did_reset = True # No formatting to apply after reset
|
|
465
|
+
|
|
466
|
+
# Add the character data
|
|
467
|
+
result.append(char.data)
|
|
468
|
+
last_char = char
|
|
469
|
+
|
|
470
|
+
# Reset formatting at end of line if we had any
|
|
471
|
+
if last_char is not None and self._char_has_formatting(last_char):
|
|
472
|
+
result.append('\x1b[0m')
|
|
473
|
+
|
|
474
|
+
# Strip trailing whitespace from the line
|
|
475
|
+
line_text = ''.join(result).rstrip()
|
|
476
|
+
return line_text
|
|
477
|
+
|
|
478
|
+
def _char_has_formatting(self, char: 'pyte.screens.Char') -> bool:
|
|
479
|
+
"""Check if a character has any formatting applied."""
|
|
480
|
+
return (char.bold or
|
|
481
|
+
(hasattr(char, 'dim') and char.dim) or
|
|
482
|
+
char.italics or
|
|
483
|
+
char.underscore or
|
|
484
|
+
(hasattr(char, 'blink') and char.blink) or
|
|
485
|
+
char.reverse or
|
|
486
|
+
(hasattr(char, 'hidden') and char.hidden) or
|
|
487
|
+
char.strikethrough or
|
|
488
|
+
char.fg != 'default' or
|
|
489
|
+
char.bg != 'default')
|
|
490
|
+
|
|
491
|
+
def _char_format_changed(self, char1: 'pyte.screens.Char', char2: 'pyte.screens.Char') -> bool:
|
|
492
|
+
"""Check if formatting changed between two characters."""
|
|
493
|
+
return (char1.bold != char2.bold or
|
|
494
|
+
(hasattr(char1, 'dim') and hasattr(char2, 'dim') and char1.dim != char2.dim) or
|
|
495
|
+
char1.italics != char2.italics or
|
|
496
|
+
char1.underscore != char2.underscore or
|
|
497
|
+
(hasattr(char1, 'blink') and hasattr(char2, 'blink') and char1.blink != char2.blink) or
|
|
498
|
+
char1.reverse != char2.reverse or
|
|
499
|
+
(hasattr(char1, 'hidden') and hasattr(char2, 'hidden') and char1.hidden != char2.hidden) or
|
|
500
|
+
char1.strikethrough != char2.strikethrough or
|
|
501
|
+
char1.fg != char2.fg or
|
|
502
|
+
char1.bg != char2.bg)
|
|
503
|
+
|
|
504
|
+
def _get_ansi_codes_for_char(self, char: 'pyte.screens.Char') -> str:
|
|
505
|
+
"""Convert pyte Char formatting to ANSI escape codes.
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
String of semicolon-separated ANSI codes (e.g., "1;32;44")
|
|
509
|
+
"""
|
|
510
|
+
codes = []
|
|
511
|
+
|
|
512
|
+
# Text attributes - comprehensive list matching ANSI SGR codes
|
|
513
|
+
if char.bold:
|
|
514
|
+
codes.append('1')
|
|
515
|
+
if hasattr(char, 'dim') and char.dim:
|
|
516
|
+
codes.append('2')
|
|
517
|
+
if char.italics:
|
|
518
|
+
codes.append('3')
|
|
519
|
+
if char.underscore:
|
|
520
|
+
codes.append('4')
|
|
521
|
+
if hasattr(char, 'blink') and char.blink:
|
|
522
|
+
codes.append('5')
|
|
523
|
+
if char.reverse:
|
|
524
|
+
codes.append('7')
|
|
525
|
+
if hasattr(char, 'hidden') and char.hidden:
|
|
526
|
+
codes.append('8')
|
|
527
|
+
if char.strikethrough:
|
|
528
|
+
codes.append('9')
|
|
529
|
+
|
|
530
|
+
# Foreground color
|
|
531
|
+
if char.fg != 'default':
|
|
532
|
+
fg_code = self._color_to_ansi(char.fg, is_background=False)
|
|
533
|
+
if fg_code:
|
|
534
|
+
codes.append(fg_code)
|
|
535
|
+
|
|
536
|
+
# Background color
|
|
537
|
+
if char.bg != 'default':
|
|
538
|
+
bg_code = self._color_to_ansi(char.bg, is_background=True)
|
|
539
|
+
if bg_code:
|
|
540
|
+
codes.append(bg_code)
|
|
541
|
+
|
|
542
|
+
return ';'.join(codes)
|
|
543
|
+
|
|
544
|
+
def _color_to_ansi(self, color, is_background: bool = False) -> Optional[str]:
|
|
545
|
+
"""Convert pyte color to ANSI color code.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
color: Color value (can be string name, int for 256-color, hex string, or tuple for RGB)
|
|
549
|
+
is_background: True for background color, False for foreground
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
ANSI color code string or None
|
|
553
|
+
"""
|
|
554
|
+
# Handle default/None
|
|
555
|
+
if color == 'default' or color is None:
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
# Standard base for 8 basic colors
|
|
559
|
+
base = 40 if is_background else 30
|
|
560
|
+
|
|
561
|
+
if isinstance(color, str):
|
|
562
|
+
# pyte stores colors as lowercase strings
|
|
563
|
+
color_lower = color.lower()
|
|
564
|
+
|
|
565
|
+
# Check for hex color format (pyte stores RGB as hex strings like '4782c8')
|
|
566
|
+
# Hex strings are 6 characters (RRGGBB)
|
|
567
|
+
if len(color_lower) == 6 and all(c in '0123456789abcdef' for c in color_lower):
|
|
568
|
+
try:
|
|
569
|
+
# Parse hex string to RGB
|
|
570
|
+
r = int(color_lower[0:2], 16)
|
|
571
|
+
g = int(color_lower[2:4], 16)
|
|
572
|
+
b = int(color_lower[4:6], 16)
|
|
573
|
+
return f'{"48" if is_background else "38"};2;{r};{g};{b}'
|
|
574
|
+
except ValueError:
|
|
575
|
+
pass # Not a valid hex color, continue to other checks
|
|
576
|
+
|
|
577
|
+
# Named colors (black, red, green, yellow, blue, magenta, cyan, white)
|
|
578
|
+
color_map = {
|
|
579
|
+
'black': 0, 'red': 1, 'green': 2, 'yellow': 3,
|
|
580
|
+
'blue': 4, 'magenta': 5, 'cyan': 6, 'white': 7
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
# Check for bright/intense colors first (pyte may use different formats)
|
|
584
|
+
# Format 1: "brightred", "brightblue", etc.
|
|
585
|
+
if color_lower.startswith('bright') and len(color_lower) > 6:
|
|
586
|
+
color_base = color_lower[6:] # Remove 'bright' prefix
|
|
587
|
+
if color_base in color_map:
|
|
588
|
+
# Bright colors: 90-97 (fg), 100-107 (bg)
|
|
589
|
+
return str(base + 60 + color_map[color_base])
|
|
590
|
+
|
|
591
|
+
# Format 2: "bright_red", "bright_blue", etc.
|
|
592
|
+
if color_lower.startswith('bright_'):
|
|
593
|
+
color_base = color_lower[7:] # Remove 'bright_' prefix
|
|
594
|
+
if color_base in color_map:
|
|
595
|
+
return str(base + 60 + color_map[color_base])
|
|
596
|
+
|
|
597
|
+
# Standard color names
|
|
598
|
+
if color_lower in color_map:
|
|
599
|
+
return str(base + color_map[color_lower])
|
|
600
|
+
|
|
601
|
+
# Some terminals use color names like "brown" instead of "yellow"
|
|
602
|
+
color_aliases = {
|
|
603
|
+
'brown': 3, # yellow
|
|
604
|
+
'lightgray': 7, 'lightgrey': 7, # white
|
|
605
|
+
'darkgray': 0, 'darkgrey': 0, # black
|
|
606
|
+
}
|
|
607
|
+
if color_lower in color_aliases:
|
|
608
|
+
return str(base + color_aliases[color_lower])
|
|
609
|
+
|
|
610
|
+
elif isinstance(color, int):
|
|
611
|
+
# 256-color palette (0-255)
|
|
612
|
+
# Note: 0-15 are the basic and bright colors, 16-231 are 216 color cube, 232-255 are grayscale
|
|
613
|
+
if 0 <= color <= 255:
|
|
614
|
+
return f'{"48" if is_background else "38"};5;{color}'
|
|
615
|
+
|
|
616
|
+
elif isinstance(color, tuple) and len(color) == 3:
|
|
617
|
+
# RGB color (true color / 24-bit)
|
|
618
|
+
try:
|
|
619
|
+
r, g, b = color
|
|
620
|
+
# Ensure values are in valid range
|
|
621
|
+
r = max(0, min(255, int(r)))
|
|
622
|
+
g = max(0, min(255, int(g)))
|
|
623
|
+
b = max(0, min(255, int(b)))
|
|
624
|
+
return f'{"48" if is_background else "38"};2;{r};{g};{b}'
|
|
625
|
+
except (ValueError, TypeError):
|
|
626
|
+
logger.warning("Invalid RGB color tuple: %s", color)
|
|
627
|
+
return None
|
|
628
|
+
|
|
629
|
+
# If we got here, we don't recognize the color format
|
|
630
|
+
logger.info("PYTE_COLOR_DEBUG: Unrecognized color format - type: %s, value: %r, is_bg: %s",
|
|
631
|
+
type(color).__name__, color, is_background)
|
|
632
|
+
return None
|
|
309
633
|
|
|
310
634
|
async def reattach_channel(self, new_channel: "Channel") -> None:
|
|
311
635
|
"""Reattach this session to a new channel after reconnection."""
|
|
@@ -432,6 +756,9 @@ class SessionManager:
|
|
|
432
756
|
self.mux = mux
|
|
433
757
|
self.terminal_manager = terminal_manager
|
|
434
758
|
self._sessions: Dict[str, TerminalSession] = {}
|
|
759
|
+
self._link_event_dispatcher = _get_link_event_dispatcher()
|
|
760
|
+
self._link_event_dispatcher.register_callback(self._handle_link_capture_event)
|
|
761
|
+
self._link_event_dispatcher.start()
|
|
435
762
|
|
|
436
763
|
def _allocate_channel_id(self) -> str:
|
|
437
764
|
"""Allocate a new unique channel ID for a terminal session using UUID."""
|
|
@@ -461,6 +788,27 @@ class SessionManager:
|
|
|
461
788
|
|
|
462
789
|
logger.info("Launching terminal %s using shell=%s on channel=%s", term_id, shell, channel_id)
|
|
463
790
|
|
|
791
|
+
env = _build_child_env()
|
|
792
|
+
|
|
793
|
+
env["PORTACODE_LINK_CHANNEL"] = str(_LINK_EVENT_ROOT)
|
|
794
|
+
env["PORTACODE_TERMINAL_ID"] = term_id
|
|
795
|
+
|
|
796
|
+
bin_dir = prepare_link_capture_bin()
|
|
797
|
+
if bin_dir:
|
|
798
|
+
current_path = env.get("PATH", os.environ.get("PATH", ""))
|
|
799
|
+
path_entries = current_path.split(os.pathsep) if current_path else []
|
|
800
|
+
bin_str = str(bin_dir)
|
|
801
|
+
if bin_str not in path_entries:
|
|
802
|
+
env["PATH"] = os.pathsep.join([bin_str] + path_entries) if path_entries else bin_str
|
|
803
|
+
browser_path = bin_dir / "xdg-open"
|
|
804
|
+
if browser_path.exists():
|
|
805
|
+
original_browser = env.get("BROWSER")
|
|
806
|
+
if original_browser:
|
|
807
|
+
env[LINK_CAPTURE_ORIGINAL_BROWSER_ENV] = original_browser
|
|
808
|
+
elif LINK_CAPTURE_ORIGINAL_BROWSER_ENV in env:
|
|
809
|
+
env.pop(LINK_CAPTURE_ORIGINAL_BROWSER_ENV, None)
|
|
810
|
+
env["BROWSER"] = str(browser_path)
|
|
811
|
+
|
|
464
812
|
if _IS_WINDOWS:
|
|
465
813
|
try:
|
|
466
814
|
from winpty import PtyProcess
|
|
@@ -468,13 +816,14 @@ class SessionManager:
|
|
|
468
816
|
logger.error("winpty (pywinpty) not found: %s", exc)
|
|
469
817
|
raise RuntimeError("pywinpty not installed on client")
|
|
470
818
|
|
|
471
|
-
pty_proc = PtyProcess.spawn(shell, cwd=cwd or None, env=
|
|
819
|
+
pty_proc = PtyProcess.spawn(shell, cwd=cwd or None, env=env)
|
|
472
820
|
session = WindowsTerminalSession(term_id, pty_proc, channel, project_id, self.terminal_manager)
|
|
473
821
|
else:
|
|
474
822
|
# Unix: try real PTY for proper TTY semantics
|
|
475
823
|
try:
|
|
476
824
|
import pty
|
|
477
825
|
master_fd, slave_fd = pty.openpty()
|
|
826
|
+
_configure_pty_window_size(slave_fd, TERMINAL_ROWS, TERMINAL_COLUMNS)
|
|
478
827
|
proc = await asyncio.create_subprocess_exec(
|
|
479
828
|
shell,
|
|
480
829
|
stdin=slave_fd,
|
|
@@ -482,7 +831,7 @@ class SessionManager:
|
|
|
482
831
|
stderr=slave_fd,
|
|
483
832
|
preexec_fn=os.setsid,
|
|
484
833
|
cwd=cwd,
|
|
485
|
-
env=
|
|
834
|
+
env=env,
|
|
486
835
|
)
|
|
487
836
|
# Wrap master_fd into a StreamReader
|
|
488
837
|
loop = asyncio.get_running_loop()
|
|
@@ -500,7 +849,7 @@ class SessionManager:
|
|
|
500
849
|
stdout=asyncio.subprocess.PIPE,
|
|
501
850
|
stderr=asyncio.subprocess.STDOUT,
|
|
502
851
|
cwd=cwd,
|
|
503
|
-
env=
|
|
852
|
+
env=env,
|
|
504
853
|
)
|
|
505
854
|
session = TerminalSession(term_id, proc, channel, project_id, self.terminal_manager)
|
|
506
855
|
|
|
@@ -516,6 +865,38 @@ class SessionManager:
|
|
|
516
865
|
"project_id": project_id,
|
|
517
866
|
}
|
|
518
867
|
|
|
868
|
+
async def _handle_link_capture_event(self, payload: Dict[str, Any]) -> None:
|
|
869
|
+
"""Translate link capture files into websocket events."""
|
|
870
|
+
link = payload.get("url")
|
|
871
|
+
terminal_id = payload.get("terminal_id")
|
|
872
|
+
if not link:
|
|
873
|
+
logger.debug("session_manager: Ignoring link capture without URL (%s)", payload)
|
|
874
|
+
return
|
|
875
|
+
if not terminal_id:
|
|
876
|
+
logger.warning("session_manager: Link capture missing terminal_id: %s", payload)
|
|
877
|
+
return
|
|
878
|
+
session = self.get_session(terminal_id)
|
|
879
|
+
if not session:
|
|
880
|
+
logger.info("session_manager: No active session for terminal %s, dropping link event", terminal_id)
|
|
881
|
+
return
|
|
882
|
+
if not self.terminal_manager:
|
|
883
|
+
logger.warning("session_manager: No terminal_manager available for link event")
|
|
884
|
+
return
|
|
885
|
+
|
|
886
|
+
event_payload = {
|
|
887
|
+
"event": "terminal_link_request",
|
|
888
|
+
"terminal_id": session.id,
|
|
889
|
+
"channel": getattr(session.channel, "id", session.id),
|
|
890
|
+
"url": link,
|
|
891
|
+
"command": payload.get("command"),
|
|
892
|
+
"args": payload.get("args"),
|
|
893
|
+
"pid": getattr(session.proc, "pid", None),
|
|
894
|
+
"timestamp": payload.get("timestamp"),
|
|
895
|
+
"project_id": session.project_id,
|
|
896
|
+
}
|
|
897
|
+
logger.info("session_manager: Dispatching link request for terminal %s to clients", terminal_id)
|
|
898
|
+
await self.terminal_manager._send_session_aware(event_payload, project_id=session.project_id)
|
|
899
|
+
|
|
519
900
|
def get_session(self, terminal_id: str) -> Optional[TerminalSession]:
|
|
520
901
|
"""Get a terminal session by ID."""
|
|
521
902
|
return self._sessions.get(terminal_id)
|
|
@@ -583,4 +964,4 @@ class SessionManager:
|
|
|
583
964
|
await sess.reattach_channel(new_channel)
|
|
584
965
|
logger.info("Successfully reattached terminal %s", sess.id)
|
|
585
966
|
except Exception as exc:
|
|
586
|
-
logger.error("Failed to reattach terminal %s: %s", sess.id, exc)
|
|
967
|
+
logger.error("Failed to reattach terminal %s: %s", sess.id, exc)
|