portacode 0.3.19.dev4__py3-none-any.whl → 1.4.11.dev1__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 +16 -3
- portacode/cli.py +143 -17
- portacode/connection/client.py +149 -10
- portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
- portacode/connection/handlers/__init__.py +28 -1
- portacode/connection/handlers/base.py +78 -16
- portacode/connection/handlers/chunked_content.py +244 -0
- portacode/connection/handlers/diff_handlers.py +603 -0
- portacode/connection/handlers/file_handlers.py +902 -17
- portacode/connection/handlers/project_aware_file_handlers.py +226 -0
- portacode/connection/handlers/project_state/README.md +312 -0
- portacode/connection/handlers/project_state/__init__.py +92 -0
- portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
- portacode/connection/handlers/project_state/git_manager.py +1502 -0
- portacode/connection/handlers/project_state/handlers.py +875 -0
- portacode/connection/handlers/project_state/manager.py +1331 -0
- portacode/connection/handlers/project_state/models.py +108 -0
- portacode/connection/handlers/project_state/utils.py +50 -0
- portacode/connection/handlers/project_state_handlers.py +45 -2185
- portacode/connection/handlers/proxmox_infra.py +361 -0
- portacode/connection/handlers/registry.py +15 -4
- portacode/connection/handlers/session.py +483 -32
- portacode/connection/handlers/system_handlers.py +147 -8
- portacode/connection/handlers/tab_factory.py +53 -46
- portacode/connection/handlers/terminal_handlers.py +21 -8
- portacode/connection/handlers/update_handler.py +61 -0
- portacode/connection/multiplex.py +60 -2
- portacode/connection/terminal.py +214 -24
- 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/logging_categories.py +140 -0
- portacode/pairing.py +103 -0
- portacode/static/js/test-ntp-clock.html +63 -0
- portacode/static/js/utils/ntp-clock.js +232 -0
- portacode/utils/NTP_ARCHITECTURE.md +136 -0
- portacode/utils/__init__.py +1 -0
- portacode/utils/diff_apply.py +456 -0
- portacode/utils/diff_renderer.py +371 -0
- portacode/utils/ntp_clock.py +65 -0
- portacode-1.4.11.dev1.dist-info/METADATA +298 -0
- portacode-1.4.11.dev1.dist-info/RECORD +97 -0
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
- portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
- test_modules/README.md +296 -0
- test_modules/__init__.py +1 -0
- test_modules/test_device_online.py +44 -0
- test_modules/test_file_operations.py +743 -0
- test_modules/test_git_status_ui.py +370 -0
- test_modules/test_login_flow.py +50 -0
- test_modules/test_navigate_testing_folder.py +361 -0
- test_modules/test_play_store_screenshots.py +294 -0
- test_modules/test_terminal_buffer_performance.py +261 -0
- test_modules/test_terminal_interaction.py +80 -0
- test_modules/test_terminal_loading_race_condition.py +95 -0
- test_modules/test_terminal_start.py +56 -0
- testing_framework/.env.example +21 -0
- testing_framework/README.md +334 -0
- testing_framework/__init__.py +17 -0
- testing_framework/cli.py +326 -0
- testing_framework/core/__init__.py +1 -0
- testing_framework/core/base_test.py +336 -0
- testing_framework/core/cli_manager.py +177 -0
- testing_framework/core/hierarchical_runner.py +577 -0
- testing_framework/core/playwright_manager.py +520 -0
- testing_framework/core/runner.py +447 -0
- testing_framework/core/shared_cli_manager.py +234 -0
- testing_framework/core/test_discovery.py +112 -0
- testing_framework/requirements.txt +12 -0
- portacode-0.3.19.dev4.dist-info/METADATA +0 -241
- portacode-0.3.19.dev4.dist-info/RECORD +0 -30
- portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
- {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +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:
|
|
@@ -161,10 +291,14 @@ class TerminalSession:
|
|
|
161
291
|
async def _send_terminal_data_now(self, data: str) -> None:
|
|
162
292
|
"""Send terminal data immediately and update last send time."""
|
|
163
293
|
self._last_send_time = time.time()
|
|
164
|
-
|
|
165
|
-
|
|
294
|
+
data_size = len(data.encode('utf-8'))
|
|
295
|
+
|
|
296
|
+
logger.info("session: Attempting to send terminal_data for terminal %s (data_size=%d bytes)",
|
|
297
|
+
self.id, data_size)
|
|
298
|
+
|
|
299
|
+
# Feed data to pyte screen for proper terminal state management
|
|
166
300
|
self._add_to_buffer(data)
|
|
167
|
-
|
|
301
|
+
|
|
168
302
|
try:
|
|
169
303
|
# Send terminal data via control channel with client session targeting
|
|
170
304
|
if self.terminal_manager:
|
|
@@ -174,18 +308,25 @@ class TerminalSession:
|
|
|
174
308
|
"data": data,
|
|
175
309
|
"project_id": self.project_id
|
|
176
310
|
}, project_id=self.project_id)
|
|
311
|
+
logger.info("session: Successfully queued terminal_data for terminal %s via terminal_manager", self.id)
|
|
177
312
|
else:
|
|
178
313
|
# Fallback to raw channel for backward compatibility
|
|
179
314
|
await self.channel.send(data)
|
|
315
|
+
logger.info("session: Successfully sent terminal_data for terminal %s via raw channel", self.id)
|
|
180
316
|
except Exception as exc:
|
|
181
|
-
logger.warning("Failed to forward terminal output: %s", exc)
|
|
317
|
+
logger.warning("session: Failed to forward terminal output for terminal %s: %s", self.id, exc)
|
|
182
318
|
|
|
183
319
|
async def _flush_pending_data(self) -> None:
|
|
184
320
|
"""Send accumulated pending data and reset pending buffer."""
|
|
185
321
|
if self._pending_data:
|
|
322
|
+
pending_size = len(self._pending_data.encode('utf-8'))
|
|
323
|
+
logger.info("session: Flushing pending terminal_data for terminal %s (pending_size=%d bytes)",
|
|
324
|
+
self.id, pending_size)
|
|
186
325
|
data_to_send = self._pending_data
|
|
187
326
|
self._pending_data = ""
|
|
188
327
|
await self._send_terminal_data_now(data_to_send)
|
|
328
|
+
else:
|
|
329
|
+
logger.debug("session: No pending data to flush for terminal %s", self.id)
|
|
189
330
|
|
|
190
331
|
# Clear the debounce task
|
|
191
332
|
self._debounce_task = None
|
|
@@ -194,48 +335,301 @@ class TerminalSession:
|
|
|
194
335
|
"""Handle new terminal data with rate limiting and debouncing."""
|
|
195
336
|
current_time = time.time()
|
|
196
337
|
time_since_last_send = (current_time - self._last_send_time) * 1000 # Convert to milliseconds
|
|
197
|
-
|
|
198
|
-
|
|
338
|
+
data_size = len(data.encode('utf-8'))
|
|
339
|
+
|
|
340
|
+
logger.info("session: Received terminal_data for terminal %s (data_size=%d bytes, time_since_last_send=%.1fms)",
|
|
341
|
+
self.id, data_size, time_since_last_send)
|
|
342
|
+
|
|
343
|
+
# Add new data to pending buffer (no trimming needed - pyte handles screen state)
|
|
199
344
|
self._pending_data += data
|
|
200
|
-
|
|
345
|
+
|
|
201
346
|
# Cancel existing debounce task if any
|
|
202
347
|
if self._debounce_task and not self._debounce_task.done():
|
|
348
|
+
logger.debug("session: Cancelling existing debounce task for terminal %s", self.id)
|
|
203
349
|
self._debounce_task.cancel()
|
|
204
|
-
|
|
350
|
+
|
|
205
351
|
# Always set up a debounce timer to catch rapid consecutive outputs
|
|
206
352
|
async def _debounce_timer():
|
|
207
353
|
try:
|
|
208
354
|
if time_since_last_send >= TERMINAL_DATA_RATE_LIMIT_MS:
|
|
209
355
|
# Enough time has passed since last send, wait initial delay for more data
|
|
210
356
|
wait_time = TERMINAL_DATA_INITIAL_WAIT_MS / 1000
|
|
357
|
+
logger.info("session: Rate limit satisfied for terminal %s, waiting %.1fms for more data",
|
|
358
|
+
self.id, wait_time * 1000)
|
|
211
359
|
else:
|
|
212
360
|
# Too soon since last send, wait for either the rate limit period or max wait time
|
|
213
361
|
wait_time = min(
|
|
214
362
|
(TERMINAL_DATA_RATE_LIMIT_MS - time_since_last_send) / 1000,
|
|
215
363
|
TERMINAL_DATA_MAX_WAIT_MS / 1000
|
|
216
364
|
)
|
|
365
|
+
logger.info("session: Rate limit active for terminal %s, waiting %.1fms before send (time_since_last=%.1fms, rate_limit=%dms)",
|
|
366
|
+
self.id, wait_time * 1000, time_since_last_send, TERMINAL_DATA_RATE_LIMIT_MS)
|
|
367
|
+
|
|
217
368
|
await asyncio.sleep(wait_time)
|
|
369
|
+
logger.info("session: Debounce timer expired for terminal %s, flushing pending data", self.id)
|
|
218
370
|
await self._flush_pending_data()
|
|
219
371
|
except asyncio.CancelledError:
|
|
372
|
+
logger.debug("session: Debounce timer cancelled for terminal %s (new data arrived)", self.id)
|
|
220
373
|
# Timer was cancelled, another data event came in
|
|
221
374
|
pass
|
|
222
|
-
|
|
375
|
+
|
|
223
376
|
self._debounce_task = asyncio.create_task(_debounce_timer())
|
|
377
|
+
logger.info("session: Started debounce timer for terminal %s", self.id)
|
|
224
378
|
|
|
225
379
|
def _add_to_buffer(self, data: str) -> None:
|
|
226
|
-
"""
|
|
227
|
-
|
|
228
|
-
self.
|
|
229
|
-
self._buffer_size_bytes += data_bytes
|
|
230
|
-
|
|
231
|
-
# Remove oldest entries until we're under the size limit
|
|
232
|
-
while self._buffer_size_bytes > TERMINAL_BUFFER_SIZE_LIMIT_BYTES and self._buffer:
|
|
233
|
-
oldest_data = self._buffer.popleft()
|
|
234
|
-
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)
|
|
235
383
|
|
|
236
384
|
def snapshot_buffer(self) -> str:
|
|
237
|
-
"""Return
|
|
238
|
-
|
|
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
|
|
239
633
|
|
|
240
634
|
async def reattach_channel(self, new_channel: "Channel") -> None:
|
|
241
635
|
"""Reattach this session to a new channel after reconnection."""
|
|
@@ -362,6 +756,9 @@ class SessionManager:
|
|
|
362
756
|
self.mux = mux
|
|
363
757
|
self.terminal_manager = terminal_manager
|
|
364
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()
|
|
365
762
|
|
|
366
763
|
def _allocate_channel_id(self) -> str:
|
|
367
764
|
"""Allocate a new unique channel ID for a terminal session using UUID."""
|
|
@@ -391,6 +788,27 @@ class SessionManager:
|
|
|
391
788
|
|
|
392
789
|
logger.info("Launching terminal %s using shell=%s on channel=%s", term_id, shell, channel_id)
|
|
393
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
|
+
|
|
394
812
|
if _IS_WINDOWS:
|
|
395
813
|
try:
|
|
396
814
|
from winpty import PtyProcess
|
|
@@ -398,13 +816,14 @@ class SessionManager:
|
|
|
398
816
|
logger.error("winpty (pywinpty) not found: %s", exc)
|
|
399
817
|
raise RuntimeError("pywinpty not installed on client")
|
|
400
818
|
|
|
401
|
-
pty_proc = PtyProcess.spawn(shell, cwd=cwd or None, env=
|
|
819
|
+
pty_proc = PtyProcess.spawn(shell, cwd=cwd or None, env=env)
|
|
402
820
|
session = WindowsTerminalSession(term_id, pty_proc, channel, project_id, self.terminal_manager)
|
|
403
821
|
else:
|
|
404
822
|
# Unix: try real PTY for proper TTY semantics
|
|
405
823
|
try:
|
|
406
824
|
import pty
|
|
407
825
|
master_fd, slave_fd = pty.openpty()
|
|
826
|
+
_configure_pty_window_size(slave_fd, TERMINAL_ROWS, TERMINAL_COLUMNS)
|
|
408
827
|
proc = await asyncio.create_subprocess_exec(
|
|
409
828
|
shell,
|
|
410
829
|
stdin=slave_fd,
|
|
@@ -412,7 +831,7 @@ class SessionManager:
|
|
|
412
831
|
stderr=slave_fd,
|
|
413
832
|
preexec_fn=os.setsid,
|
|
414
833
|
cwd=cwd,
|
|
415
|
-
env=
|
|
834
|
+
env=env,
|
|
416
835
|
)
|
|
417
836
|
# Wrap master_fd into a StreamReader
|
|
418
837
|
loop = asyncio.get_running_loop()
|
|
@@ -430,7 +849,7 @@ class SessionManager:
|
|
|
430
849
|
stdout=asyncio.subprocess.PIPE,
|
|
431
850
|
stderr=asyncio.subprocess.STDOUT,
|
|
432
851
|
cwd=cwd,
|
|
433
|
-
env=
|
|
852
|
+
env=env,
|
|
434
853
|
)
|
|
435
854
|
session = TerminalSession(term_id, proc, channel, project_id, self.terminal_manager)
|
|
436
855
|
|
|
@@ -446,6 +865,38 @@ class SessionManager:
|
|
|
446
865
|
"project_id": project_id,
|
|
447
866
|
}
|
|
448
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
|
+
|
|
449
900
|
def get_session(self, terminal_id: str) -> Optional[TerminalSession]:
|
|
450
901
|
"""Get a terminal session by ID."""
|
|
451
902
|
return self._sessions.get(terminal_id)
|
|
@@ -513,4 +964,4 @@ class SessionManager:
|
|
|
513
964
|
await sess.reattach_channel(new_channel)
|
|
514
965
|
logger.info("Successfully reattached terminal %s", sess.id)
|
|
515
966
|
except Exception as exc:
|
|
516
|
-
logger.error("Failed to reattach terminal %s: %s", sess.id, exc)
|
|
967
|
+
logger.error("Failed to reattach terminal %s: %s", sess.id, exc)
|