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.

Files changed (92) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +143 -17
  3. portacode/connection/client.py +149 -10
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +824 -21
  5. portacode/connection/handlers/__init__.py +28 -1
  6. portacode/connection/handlers/base.py +78 -16
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -2185
  20. portacode/connection/handlers/proxmox_infra.py +361 -0
  21. portacode/connection/handlers/registry.py +15 -4
  22. portacode/connection/handlers/session.py +483 -32
  23. portacode/connection/handlers/system_handlers.py +147 -8
  24. portacode/connection/handlers/tab_factory.py +53 -46
  25. portacode/connection/handlers/terminal_handlers.py +21 -8
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +214 -24
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/static/js/test-ntp-clock.html +63 -0
  53. portacode/static/js/utils/ntp-clock.js +232 -0
  54. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  55. portacode/utils/__init__.py +1 -0
  56. portacode/utils/diff_apply.py +456 -0
  57. portacode/utils/diff_renderer.py +371 -0
  58. portacode/utils/ntp_clock.py +65 -0
  59. portacode-1.4.11.dev1.dist-info/METADATA +298 -0
  60. portacode-1.4.11.dev1.dist-info/RECORD +97 -0
  61. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
  62. portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
  63. test_modules/README.md +296 -0
  64. test_modules/__init__.py +1 -0
  65. test_modules/test_device_online.py +44 -0
  66. test_modules/test_file_operations.py +743 -0
  67. test_modules/test_git_status_ui.py +370 -0
  68. test_modules/test_login_flow.py +50 -0
  69. test_modules/test_navigate_testing_folder.py +361 -0
  70. test_modules/test_play_store_screenshots.py +294 -0
  71. test_modules/test_terminal_buffer_performance.py +261 -0
  72. test_modules/test_terminal_interaction.py +80 -0
  73. test_modules/test_terminal_loading_race_condition.py +95 -0
  74. test_modules/test_terminal_start.py +56 -0
  75. testing_framework/.env.example +21 -0
  76. testing_framework/README.md +334 -0
  77. testing_framework/__init__.py +17 -0
  78. testing_framework/cli.py +326 -0
  79. testing_framework/core/__init__.py +1 -0
  80. testing_framework/core/base_test.py +336 -0
  81. testing_framework/core/cli_manager.py +177 -0
  82. testing_framework/core/hierarchical_runner.py +577 -0
  83. testing_framework/core/playwright_manager.py +520 -0
  84. testing_framework/core/runner.py +447 -0
  85. testing_framework/core/shared_cli_manager.py +234 -0
  86. testing_framework/core/test_discovery.py +112 -0
  87. testing_framework/requirements.txt +12 -0
  88. portacode-0.3.19.dev4.dist-info/METADATA +0 -241
  89. portacode-0.3.19.dev4.dist-info/RECORD +0 -30
  90. portacode-0.3.19.dev4.dist-info/top_level.txt +0 -1
  91. {portacode-0.3.19.dev4.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
  92. {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
- from collections import deque
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 size limit configuration
23
- TERMINAL_BUFFER_SIZE_LIMIT_BYTES = 10 * 1024 # Maximum buffer size in bytes (10KB)
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
- self._buffer: deque[str] = deque()
56
- self._buffer_size_bytes = 0 # Track total buffer size in bytes
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
- # Add to buffer for snapshots with size limiting
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
- # Add new data to pending buffer
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
- """Add data to buffer while maintaining size limit."""
227
- data_bytes = len(data.encode('utf-8'))
228
- self._buffer.append(data)
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 concatenated last buffer contents suitable for UI."""
238
- return "".join(self._buffer)
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=_build_child_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=_build_child_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=_build_child_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)