portacode 1.3.32__tar.gz → 1.3.34__tar.gz

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 (97) hide show
  1. {portacode-1.3.32 → portacode-1.3.34}/PKG-INFO +1 -1
  2. {portacode-1.3.32 → portacode-1.3.34}/portacode/_version.py +2 -2
  3. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/project_state/git_manager.py +8 -7
  4. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/project_state/handlers.py +7 -8
  5. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/session.py +298 -78
  6. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/tab_factory.py +1 -47
  7. {portacode-1.3.32 → portacode-1.3.34}/portacode.egg-info/PKG-INFO +1 -1
  8. {portacode-1.3.32 → portacode-1.3.34}/test.sh +0 -0
  9. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/.env.example +4 -1
  10. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/core/playwright_manager.py +29 -9
  11. {portacode-1.3.32 → portacode-1.3.34}/.claude/agents/communication-manager.md +0 -0
  12. {portacode-1.3.32 → portacode-1.3.34}/.claude/settings.local.json +0 -0
  13. {portacode-1.3.32 → portacode-1.3.34}/.gitignore +0 -0
  14. {portacode-1.3.32 → portacode-1.3.34}/.gitmodules +0 -0
  15. {portacode-1.3.32 → portacode-1.3.34}/LICENSE +0 -0
  16. {portacode-1.3.32 → portacode-1.3.34}/MANIFEST.in +0 -0
  17. {portacode-1.3.32 → portacode-1.3.34}/Makefile +0 -0
  18. {portacode-1.3.32 → portacode-1.3.34}/README.md +0 -0
  19. {portacode-1.3.32 → portacode-1.3.34}/backup.sh +0 -0
  20. {portacode-1.3.32 → portacode-1.3.34}/connect.py +0 -0
  21. {portacode-1.3.32 → portacode-1.3.34}/connect.sh +0 -0
  22. {portacode-1.3.32 → portacode-1.3.34}/docker-compose.yaml +0 -0
  23. {portacode-1.3.32 → portacode-1.3.34}/portacode/README.md +0 -0
  24. {portacode-1.3.32 → portacode-1.3.34}/portacode/__init__.py +0 -0
  25. {portacode-1.3.32 → portacode-1.3.34}/portacode/__main__.py +0 -0
  26. {portacode-1.3.32 → portacode-1.3.34}/portacode/cli.py +0 -0
  27. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/README.md +0 -0
  28. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/__init__.py +0 -0
  29. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/client.py +0 -0
  30. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/README.md +0 -0
  31. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +0 -0
  32. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/__init__.py +0 -0
  33. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/base.py +0 -0
  34. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/chunked_content.py +0 -0
  35. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/file_handlers.py +0 -0
  36. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/project_aware_file_handlers.py +0 -0
  37. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/project_state/README.md +0 -0
  38. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/project_state/__init__.py +0 -0
  39. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/project_state/file_system_watcher.py +0 -0
  40. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/project_state/manager.py +0 -0
  41. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/project_state/models.py +0 -0
  42. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/project_state/utils.py +0 -0
  43. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/project_state_handlers.py +0 -0
  44. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/registry.py +0 -0
  45. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/system_handlers.py +0 -0
  46. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/handlers/terminal_handlers.py +0 -0
  47. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/multiplex.py +0 -0
  48. {portacode-1.3.32 → portacode-1.3.34}/portacode/connection/terminal.py +0 -0
  49. {portacode-1.3.32 → portacode-1.3.34}/portacode/data.py +0 -0
  50. {portacode-1.3.32 → portacode-1.3.34}/portacode/keypair.py +0 -0
  51. {portacode-1.3.32 → portacode-1.3.34}/portacode/logging_categories.py +0 -0
  52. {portacode-1.3.32 → portacode-1.3.34}/portacode/service.py +0 -0
  53. {portacode-1.3.32 → portacode-1.3.34}/portacode/static/js/test-ntp-clock.html +0 -0
  54. {portacode-1.3.32 → portacode-1.3.34}/portacode/static/js/utils/ntp-clock.js +0 -0
  55. {portacode-1.3.32 → portacode-1.3.34}/portacode/utils/NTP_ARCHITECTURE.md +0 -0
  56. {portacode-1.3.32 → portacode-1.3.34}/portacode/utils/__init__.py +0 -0
  57. {portacode-1.3.32 → portacode-1.3.34}/portacode/utils/ntp_clock.py +0 -0
  58. {portacode-1.3.32 → portacode-1.3.34}/portacode.egg-info/SOURCES.txt +0 -0
  59. {portacode-1.3.32 → portacode-1.3.34}/portacode.egg-info/dependency_links.txt +0 -0
  60. {portacode-1.3.32 → portacode-1.3.34}/portacode.egg-info/entry_points.txt +0 -0
  61. {portacode-1.3.32 → portacode-1.3.34}/portacode.egg-info/requires.txt +0 -0
  62. {portacode-1.3.32 → portacode-1.3.34}/portacode.egg-info/top_level.txt +0 -0
  63. {portacode-1.3.32 → portacode-1.3.34}/pyproject.toml +0 -0
  64. {portacode-1.3.32 → portacode-1.3.34}/restore.sh +0 -0
  65. {portacode-1.3.32 → portacode-1.3.34}/run_tests.py +0 -0
  66. {portacode-1.3.32 → portacode-1.3.34}/setup.cfg +0 -0
  67. {portacode-1.3.32 → portacode-1.3.34}/setup.py +0 -0
  68. {portacode-1.3.32 → portacode-1.3.34}/test_modules/README.md +0 -0
  69. {portacode-1.3.32 → portacode-1.3.34}/test_modules/__init__.py +0 -0
  70. {portacode-1.3.32 → portacode-1.3.34}/test_modules/test_device_online.py +0 -0
  71. {portacode-1.3.32 → portacode-1.3.34}/test_modules/test_file_operations.py +0 -0
  72. {portacode-1.3.32 → portacode-1.3.34}/test_modules/test_git_status_ui.py +0 -0
  73. {portacode-1.3.32 → portacode-1.3.34}/test_modules/test_login_flow.py +0 -0
  74. {portacode-1.3.32 → portacode-1.3.34}/test_modules/test_navigate_testing_folder.py +0 -0
  75. {portacode-1.3.32 → portacode-1.3.34}/test_modules/test_terminal_buffer_performance.py +0 -0
  76. {portacode-1.3.32 → portacode-1.3.34}/test_modules/test_terminal_interaction.py +0 -0
  77. {portacode-1.3.32 → portacode-1.3.34}/test_modules/test_terminal_loading_race_condition.py +0 -0
  78. {portacode-1.3.32 → portacode-1.3.34}/test_modules/test_terminal_start.py +0 -0
  79. {portacode-1.3.32 → portacode-1.3.34}/test_request_id.py +0 -0
  80. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/README.md +0 -0
  81. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/__init__.py +0 -0
  82. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/cli.py +0 -0
  83. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/core/__init__.py +0 -0
  84. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/core/base_test.py +0 -0
  85. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/core/cli_manager.py +0 -0
  86. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/core/hierarchical_runner.py +0 -0
  87. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/core/runner.py +0 -0
  88. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/core/shared_cli_manager.py +0 -0
  89. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/core/test_discovery.py +0 -0
  90. {portacode-1.3.32 → portacode-1.3.34}/testing_framework/requirements.txt +0 -0
  91. {portacode-1.3.32 → portacode-1.3.34}/todo/agent_context_management.md +0 -0
  92. {portacode-1.3.32 → portacode-1.3.34}/todo/issues/device_performance_degradation.md +0 -0
  93. {portacode-1.3.32 → portacode-1.3.34}/todo/issues/git_data_not_captured_in_proxmox.md +0 -0
  94. {portacode-1.3.32 → portacode-1.3.34}/todo/issues/indefinite_resource_loading.md +0 -0
  95. {portacode-1.3.32 → portacode-1.3.34}/todo/issues/premature_terminal_exit.md +0 -0
  96. {portacode-1.3.32 → portacode-1.3.34}/todo/issues/terminals_exit_upon_starting.md +0 -0
  97. {portacode-1.3.32 → portacode-1.3.34}/tools/test_python_ntp_clock.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.3.32
3
+ Version: 1.3.34
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '1.3.32'
32
- __version_tuple__ = version_tuple = (1, 3, 32)
31
+ __version__ = version = '1.3.34'
32
+ __version_tuple__ = version_tuple = (1, 3, 34)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -1844,16 +1844,17 @@ class GitManager:
1844
1844
  """Monitor git changes periodically and trigger callback when changes are detected."""
1845
1845
  try:
1846
1846
  while self._monitoring_enabled:
1847
- await asyncio.sleep(1.0) # Check every 1000ms
1848
-
1847
+ await asyncio.sleep(5.0) # Check every 5000ms
1848
+
1849
1849
  if not self._monitoring_enabled or not self.is_git_repo:
1850
1850
  break
1851
-
1851
+
1852
1852
  try:
1853
- # Get current status
1854
- current_status_summary = self.get_status_summary()
1855
- current_detailed_status = self.get_detailed_status()
1856
- current_branch = self.get_branch_name()
1853
+ # Get current status - run in executor to avoid blocking event loop
1854
+ loop = asyncio.get_event_loop()
1855
+ current_status_summary = await loop.run_in_executor(None, self.get_status_summary)
1856
+ current_detailed_status = await loop.run_in_executor(None, self.get_detailed_status)
1857
+ current_branch = await loop.run_in_executor(None, self.get_branch_name)
1857
1858
 
1858
1859
  # Compare with cached status
1859
1860
  status_changed = (
@@ -764,14 +764,13 @@ class ProjectStateDiffContentHandler(AsyncHandler):
764
764
  if content is None or (content_type == "all" and not all([matching_tab.original_content, matching_tab.modified_content])):
765
765
  if content_type in ["original", "modified", "all"]:
766
766
  # Re-generate the diff content if needed
767
- await manager.create_diff_tab(
768
- source_client_session,
769
- file_path,
770
- from_ref,
771
- to_ref,
772
- from_hash,
773
- to_hash,
774
- activate=False # Don't activate, just ensure content is loaded
767
+ await manager.open_diff_tab(
768
+ source_client_session,
769
+ file_path,
770
+ from_ref,
771
+ to_ref,
772
+ from_hash,
773
+ to_hash
775
774
  )
776
775
 
777
776
  # Try to get content again after regeneration (use same matching logic)
@@ -3,13 +3,15 @@
3
3
  import asyncio
4
4
  import logging
5
5
  import os
6
+ import struct
6
7
  import sys
7
8
  import time
8
9
  import uuid
9
10
  from asyncio.subprocess import Process
10
11
  from pathlib import Path
11
12
  from typing import Any, Dict, Optional, List, TYPE_CHECKING
12
- from collections import deque
13
+
14
+ import pyte
13
15
 
14
16
  if TYPE_CHECKING:
15
17
  from ..multiplex import Channel
@@ -19,13 +21,32 @@ TERMINAL_DATA_RATE_LIMIT_MS = 60 # Minimum time between terminal_data events (m
19
21
  TERMINAL_DATA_MAX_WAIT_MS = 1000 # Maximum time to wait before sending accumulated data (milliseconds)
20
22
  TERMINAL_DATA_INITIAL_WAIT_MS = 10 # Time to wait for additional data even on first event (milliseconds)
21
23
 
22
- # Terminal buffer size limit configuration
23
- TERMINAL_BUFFER_SIZE_LIMIT_BYTES = 30 * 1024 # Maximum buffer size in bytes (30KB)
24
+ # Terminal buffer configuration - using pyte for proper screen state management
25
+ TERMINAL_COLUMNS = 80 # Default terminal width
26
+ TERMINAL_ROWS = 24 # Default terminal height (visible area)
27
+ TERMINAL_SCROLLBACK_LIMIT = 1000 # Maximum number of scrollback lines to preserve
24
28
 
25
29
  logger = logging.getLogger(__name__)
26
30
 
27
31
  _IS_WINDOWS = sys.platform.startswith("win")
28
32
 
33
+
34
+ def _configure_pty_window_size(fd: int, rows: int, cols: int) -> None:
35
+ """Set the PTY window size so subprocesses see a real terminal."""
36
+ if _IS_WINDOWS:
37
+ return
38
+ try:
39
+ import fcntl
40
+ import termios
41
+ winsize = struct.pack("HHHH", rows, cols, 0, 0)
42
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
43
+ except ImportError:
44
+ logger.debug("termios/fcntl unavailable; skipping PTY window sizing")
45
+ except OSError as exc:
46
+ logger.warning("Failed to set PTY window size (%sx%s): %s", cols, rows, exc)
47
+
48
+
49
+
29
50
  # Minimal, safe defaults for interactive shells
30
51
  _DEFAULT_ENV = {
31
52
  "TERM": "xterm-256color",
@@ -39,6 +60,8 @@ def _build_child_env() -> Dict[str, str]:
39
60
  env = os.environ.copy()
40
61
  for k, v in _DEFAULT_ENV.items():
41
62
  env.setdefault(k, v)
63
+ env.setdefault("COLUMNS", str(TERMINAL_COLUMNS))
64
+ env.setdefault("LINES", str(TERMINAL_ROWS))
42
65
  return env
43
66
 
44
67
 
@@ -52,9 +75,11 @@ class TerminalSession:
52
75
  self.project_id = project_id
53
76
  self.terminal_manager = terminal_manager
54
77
  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
-
78
+
79
+ # Use pyte for proper terminal screen state management
80
+ self._screen = pyte.HistoryScreen(TERMINAL_COLUMNS, TERMINAL_ROWS, history=TERMINAL_SCROLLBACK_LIMIT)
81
+ self._stream = pyte.Stream(self._screen) # Use Stream (not ByteStream) since data is already decoded to strings
82
+
58
83
  # Rate limiting for terminal_data events
59
84
  self._last_send_time: float = 0
60
85
  self._pending_data: str = ""
@@ -162,13 +187,13 @@ class TerminalSession:
162
187
  """Send terminal data immediately and update last send time."""
163
188
  self._last_send_time = time.time()
164
189
  data_size = len(data.encode('utf-8'))
165
-
166
- logger.info("session: Attempting to send terminal_data for terminal %s (data_size=%d bytes)",
190
+
191
+ logger.info("session: Attempting to send terminal_data for terminal %s (data_size=%d bytes)",
167
192
  self.id, data_size)
168
-
169
- # Add to buffer for snapshots with size limiting
193
+
194
+ # Feed data to pyte screen for proper terminal state management
170
195
  self._add_to_buffer(data)
171
-
196
+
172
197
  try:
173
198
  # Send terminal data via control channel with client session targeting
174
199
  if self.terminal_manager:
@@ -206,39 +231,25 @@ class TerminalSession:
206
231
  current_time = time.time()
207
232
  time_since_last_send = (current_time - self._last_send_time) * 1000 # Convert to milliseconds
208
233
  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)",
234
+
235
+ logger.info("session: Received terminal_data for terminal %s (data_size=%d bytes, time_since_last_send=%.1fms)",
211
236
  self.id, data_size, time_since_last_send)
212
-
213
- # Add new data to pending buffer with simple size limiting
214
- # Always add the new data first
237
+
238
+ # Add new data to pending buffer (no trimming needed - pyte handles screen state)
215
239
  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
-
240
+
230
241
  # Cancel existing debounce task if any
231
242
  if self._debounce_task and not self._debounce_task.done():
232
243
  logger.debug("session: Cancelling existing debounce task for terminal %s", self.id)
233
244
  self._debounce_task.cancel()
234
-
245
+
235
246
  # Always set up a debounce timer to catch rapid consecutive outputs
236
247
  async def _debounce_timer():
237
248
  try:
238
249
  if time_since_last_send >= TERMINAL_DATA_RATE_LIMIT_MS:
239
250
  # Enough time has passed since last send, wait initial delay for more data
240
251
  wait_time = TERMINAL_DATA_INITIAL_WAIT_MS / 1000
241
- logger.info("session: Rate limit satisfied for terminal %s, waiting %.1fms for more data",
252
+ logger.info("session: Rate limit satisfied for terminal %s, waiting %.1fms for more data",
242
253
  self.id, wait_time * 1000)
243
254
  else:
244
255
  # Too soon since last send, wait for either the rate limit period or max wait time
@@ -246,9 +257,9 @@ class TerminalSession:
246
257
  (TERMINAL_DATA_RATE_LIMIT_MS - time_since_last_send) / 1000,
247
258
  TERMINAL_DATA_MAX_WAIT_MS / 1000
248
259
  )
249
- logger.info("session: Rate limit active for terminal %s, waiting %.1fms before send (time_since_last=%.1fms, rate_limit=%dms)",
260
+ logger.info("session: Rate limit active for terminal %s, waiting %.1fms before send (time_since_last=%.1fms, rate_limit=%dms)",
250
261
  self.id, wait_time * 1000, time_since_last_send, TERMINAL_DATA_RATE_LIMIT_MS)
251
-
262
+
252
263
  await asyncio.sleep(wait_time)
253
264
  logger.info("session: Debounce timer expired for terminal %s, flushing pending data", self.id)
254
265
  await self._flush_pending_data()
@@ -256,56 +267,264 @@ class TerminalSession:
256
267
  logger.debug("session: Debounce timer cancelled for terminal %s (new data arrived)", self.id)
257
268
  # Timer was cancelled, another data event came in
258
269
  pass
259
-
270
+
260
271
  self._debounce_task = asyncio.create_task(_debounce_timer())
261
272
  logger.info("session: Started debounce timer for terminal %s", self.id)
262
273
 
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
274
  def _add_to_buffer(self, data: str) -> None:
296
- """Add data to buffer while maintaining size limit."""
297
- data_bytes = len(data.encode('utf-8'))
298
- self._buffer.append(data)
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'))
275
+ """Feed data to pyte virtual terminal screen."""
276
+ # Feed the data to pyte - it handles all ANSI parsing and screen state management
277
+ self._stream.feed(data)
305
278
 
306
279
  def snapshot_buffer(self) -> str:
307
- """Return concatenated last buffer contents suitable for UI."""
308
- return "".join(self._buffer)
280
+ """Return the visible terminal content as ANSI sequences suitable for XTerm.js."""
281
+ # Render screen content to ANSI
282
+ result = self._render_screen_to_ansi()
283
+
284
+ # Add cursor positioning at the end so XTerm.js knows where the cursor should be
285
+ # This is critical - without it, new data gets written at the wrong position causing duplication
286
+ cursor_y = self._screen.cursor.y + 1 # Convert 0-indexed to 1-indexed
287
+ cursor_x = self._screen.cursor.x + 1 # Convert 0-indexed to 1-indexed
288
+
289
+ # Move cursor to the correct position
290
+ result += f'\x1b[{cursor_y};{cursor_x}H'
291
+
292
+ return result
293
+
294
+ def _render_screen_to_ansi(self) -> str:
295
+ """Convert pyte screen state to ANSI escape sequences.
296
+
297
+ This renders both scrollback history and visible screen with full formatting
298
+ (colors, bold, italics, underline) preserved as ANSI sequences.
299
+ """
300
+ lines = []
301
+
302
+ # Get scrollback history if available (HistoryScreen provides this)
303
+ if hasattr(self._screen, 'history'):
304
+ # Process scrollback lines (lines that have scrolled off the top)
305
+ history_top = self._screen.history.top
306
+ for line_data in history_top:
307
+ # line_data is a dict mapping column positions to Char objects
308
+ line = self._render_line_to_ansi(line_data, self._screen.columns)
309
+ lines.append(line)
310
+
311
+ # Process visible screen lines
312
+ for y in range(self._screen.lines):
313
+ line_data = self._screen.buffer[y]
314
+ line = self._render_line_to_ansi(line_data, self._screen.columns)
315
+ lines.append(line)
316
+
317
+ # Join all lines with CRLF for proper terminal display
318
+ return '\r\n'.join(lines)
319
+
320
+ def _render_line_to_ansi(self, line_data: Dict[int, 'pyte.screens.Char'], columns: int) -> str:
321
+ """Convert a single line from pyte format to ANSI escape sequences.
322
+
323
+ Args:
324
+ line_data: Dict mapping column index to Char objects
325
+ columns: Number of columns in the terminal
326
+
327
+ Returns:
328
+ String with ANSI escape codes for formatting
329
+ """
330
+ result = []
331
+ last_char = None
332
+ did_reset = False # Track if we just emitted a reset code
333
+
334
+ for x in range(columns):
335
+ char = line_data.get(x)
336
+ if char is None:
337
+ # Empty cell - reset formatting if we had any
338
+ if last_char is not None and self._char_has_formatting(last_char):
339
+ result.append('\x1b[0m')
340
+ did_reset = True
341
+ result.append(' ')
342
+ last_char = None
343
+ continue
344
+
345
+ # Check if formatting changed from previous character
346
+ format_changed = last_char is None or self._char_format_changed(last_char, char) or did_reset
347
+
348
+ if format_changed:
349
+ # If previous char had formatting and current is different, reset first
350
+ if last_char is not None and self._char_has_formatting(last_char) and not did_reset:
351
+ result.append('\x1b[0m')
352
+
353
+ # Apply new formatting (always apply after reset)
354
+ ansi_codes = self._get_ansi_codes_for_char(char)
355
+ if ansi_codes:
356
+ result.append(f'\x1b[{ansi_codes}m')
357
+ did_reset = False
358
+ else:
359
+ did_reset = True # No formatting to apply after reset
360
+
361
+ # Add the character data
362
+ result.append(char.data)
363
+ last_char = char
364
+
365
+ # Reset formatting at end of line if we had any
366
+ if last_char is not None and self._char_has_formatting(last_char):
367
+ result.append('\x1b[0m')
368
+
369
+ # Strip trailing whitespace from the line
370
+ line_text = ''.join(result).rstrip()
371
+ return line_text
372
+
373
+ def _char_has_formatting(self, char: 'pyte.screens.Char') -> bool:
374
+ """Check if a character has any formatting applied."""
375
+ return (char.bold or
376
+ (hasattr(char, 'dim') and char.dim) or
377
+ char.italics or
378
+ char.underscore or
379
+ (hasattr(char, 'blink') and char.blink) or
380
+ char.reverse or
381
+ (hasattr(char, 'hidden') and char.hidden) or
382
+ char.strikethrough or
383
+ char.fg != 'default' or
384
+ char.bg != 'default')
385
+
386
+ def _char_format_changed(self, char1: 'pyte.screens.Char', char2: 'pyte.screens.Char') -> bool:
387
+ """Check if formatting changed between two characters."""
388
+ return (char1.bold != char2.bold or
389
+ (hasattr(char1, 'dim') and hasattr(char2, 'dim') and char1.dim != char2.dim) or
390
+ char1.italics != char2.italics or
391
+ char1.underscore != char2.underscore or
392
+ (hasattr(char1, 'blink') and hasattr(char2, 'blink') and char1.blink != char2.blink) or
393
+ char1.reverse != char2.reverse or
394
+ (hasattr(char1, 'hidden') and hasattr(char2, 'hidden') and char1.hidden != char2.hidden) or
395
+ char1.strikethrough != char2.strikethrough or
396
+ char1.fg != char2.fg or
397
+ char1.bg != char2.bg)
398
+
399
+ def _get_ansi_codes_for_char(self, char: 'pyte.screens.Char') -> str:
400
+ """Convert pyte Char formatting to ANSI escape codes.
401
+
402
+ Returns:
403
+ String of semicolon-separated ANSI codes (e.g., "1;32;44")
404
+ """
405
+ codes = []
406
+
407
+ # Text attributes - comprehensive list matching ANSI SGR codes
408
+ if char.bold:
409
+ codes.append('1')
410
+ if hasattr(char, 'dim') and char.dim:
411
+ codes.append('2')
412
+ if char.italics:
413
+ codes.append('3')
414
+ if char.underscore:
415
+ codes.append('4')
416
+ if hasattr(char, 'blink') and char.blink:
417
+ codes.append('5')
418
+ if char.reverse:
419
+ codes.append('7')
420
+ if hasattr(char, 'hidden') and char.hidden:
421
+ codes.append('8')
422
+ if char.strikethrough:
423
+ codes.append('9')
424
+
425
+ # Foreground color
426
+ if char.fg != 'default':
427
+ fg_code = self._color_to_ansi(char.fg, is_background=False)
428
+ if fg_code:
429
+ codes.append(fg_code)
430
+
431
+ # Background color
432
+ if char.bg != 'default':
433
+ bg_code = self._color_to_ansi(char.bg, is_background=True)
434
+ if bg_code:
435
+ codes.append(bg_code)
436
+
437
+ return ';'.join(codes)
438
+
439
+ def _color_to_ansi(self, color, is_background: bool = False) -> Optional[str]:
440
+ """Convert pyte color to ANSI color code.
441
+
442
+ Args:
443
+ color: Color value (can be string name, int for 256-color, hex string, or tuple for RGB)
444
+ is_background: True for background color, False for foreground
445
+
446
+ Returns:
447
+ ANSI color code string or None
448
+ """
449
+ # Handle default/None
450
+ if color == 'default' or color is None:
451
+ return None
452
+
453
+ # Standard base for 8 basic colors
454
+ base = 40 if is_background else 30
455
+
456
+ if isinstance(color, str):
457
+ # pyte stores colors as lowercase strings
458
+ color_lower = color.lower()
459
+
460
+ # Check for hex color format (pyte stores RGB as hex strings like '4782c8')
461
+ # Hex strings are 6 characters (RRGGBB)
462
+ if len(color_lower) == 6 and all(c in '0123456789abcdef' for c in color_lower):
463
+ try:
464
+ # Parse hex string to RGB
465
+ r = int(color_lower[0:2], 16)
466
+ g = int(color_lower[2:4], 16)
467
+ b = int(color_lower[4:6], 16)
468
+ return f'{"48" if is_background else "38"};2;{r};{g};{b}'
469
+ except ValueError:
470
+ pass # Not a valid hex color, continue to other checks
471
+
472
+ # Named colors (black, red, green, yellow, blue, magenta, cyan, white)
473
+ color_map = {
474
+ 'black': 0, 'red': 1, 'green': 2, 'yellow': 3,
475
+ 'blue': 4, 'magenta': 5, 'cyan': 6, 'white': 7
476
+ }
477
+
478
+ # Check for bright/intense colors first (pyte may use different formats)
479
+ # Format 1: "brightred", "brightblue", etc.
480
+ if color_lower.startswith('bright') and len(color_lower) > 6:
481
+ color_base = color_lower[6:] # Remove 'bright' prefix
482
+ if color_base in color_map:
483
+ # Bright colors: 90-97 (fg), 100-107 (bg)
484
+ return str(base + 60 + color_map[color_base])
485
+
486
+ # Format 2: "bright_red", "bright_blue", etc.
487
+ if color_lower.startswith('bright_'):
488
+ color_base = color_lower[7:] # Remove 'bright_' prefix
489
+ if color_base in color_map:
490
+ return str(base + 60 + color_map[color_base])
491
+
492
+ # Standard color names
493
+ if color_lower in color_map:
494
+ return str(base + color_map[color_lower])
495
+
496
+ # Some terminals use color names like "brown" instead of "yellow"
497
+ color_aliases = {
498
+ 'brown': 3, # yellow
499
+ 'lightgray': 7, 'lightgrey': 7, # white
500
+ 'darkgray': 0, 'darkgrey': 0, # black
501
+ }
502
+ if color_lower in color_aliases:
503
+ return str(base + color_aliases[color_lower])
504
+
505
+ elif isinstance(color, int):
506
+ # 256-color palette (0-255)
507
+ # Note: 0-15 are the basic and bright colors, 16-231 are 216 color cube, 232-255 are grayscale
508
+ if 0 <= color <= 255:
509
+ return f'{"48" if is_background else "38"};5;{color}'
510
+
511
+ elif isinstance(color, tuple) and len(color) == 3:
512
+ # RGB color (true color / 24-bit)
513
+ try:
514
+ r, g, b = color
515
+ # Ensure values are in valid range
516
+ r = max(0, min(255, int(r)))
517
+ g = max(0, min(255, int(g)))
518
+ b = max(0, min(255, int(b)))
519
+ return f'{"48" if is_background else "38"};2;{r};{g};{b}'
520
+ except (ValueError, TypeError):
521
+ logger.warning("Invalid RGB color tuple: %s", color)
522
+ return None
523
+
524
+ # If we got here, we don't recognize the color format
525
+ logger.info("PYTE_COLOR_DEBUG: Unrecognized color format - type: %s, value: %r, is_bg: %s",
526
+ type(color).__name__, color, is_background)
527
+ return None
309
528
 
310
529
  async def reattach_channel(self, new_channel: "Channel") -> None:
311
530
  """Reattach this session to a new channel after reconnection."""
@@ -475,6 +694,7 @@ class SessionManager:
475
694
  try:
476
695
  import pty
477
696
  master_fd, slave_fd = pty.openpty()
697
+ _configure_pty_window_size(slave_fd, TERMINAL_ROWS, TERMINAL_COLUMNS)
478
698
  proc = await asyncio.create_subprocess_exec(
479
699
  shell,
480
700
  stdin=slave_fd,
@@ -162,53 +162,7 @@ class TabFactory:
162
162
  await self._load_binary_content(file_path, tab_info, file_size)
163
163
 
164
164
  return TabInfo(**tab_info)
165
-
166
- async def create_diff_tab(self, file_path: str, original_content: str,
167
- modified_content: str, tab_id: Optional[str] = None,
168
- diff_details: Optional[Dict[str, Any]] = None) -> TabInfo:
169
- """Create a diff tab for comparing file versions.
170
-
171
- Args:
172
- file_path: Path to the file being compared
173
- original_content: Original version of the file
174
- modified_content: Modified version of the file
175
- tab_id: Optional tab ID, will generate UUID if not provided
176
- diff_details: Optional detailed diff information from diff-match-patch
177
-
178
- Returns:
179
- TabInfo object configured for diff viewing
180
- """
181
- if tab_id is None:
182
- tab_id = str(uuid.uuid4())
183
-
184
- file_path = Path(file_path)
185
-
186
- metadata = {'diff_mode': True}
187
- if diff_details:
188
- metadata['diff_details'] = diff_details
189
-
190
- # Cache diff content
191
- original_hash = generate_content_hash(original_content)
192
- modified_hash = generate_content_hash(modified_content)
193
- cache_content(original_hash, original_content)
194
- cache_content(modified_hash, modified_content)
195
-
196
- return TabInfo(
197
- tab_id=tab_id,
198
- tab_type='diff',
199
- title=f"{file_path.name} (diff)",
200
- file_path=str(file_path),
201
- content=None, # Diff tabs don't use regular content
202
- original_content=original_content,
203
- modified_content=modified_content,
204
- original_content_hash=original_hash,
205
- modified_content_hash=modified_hash,
206
- is_dirty=False,
207
- mime_type=None,
208
- encoding='utf-8',
209
- metadata=metadata
210
- )
211
-
165
+
212
166
  async def create_diff_tab_with_title(self, file_path: str, original_content: str,
213
167
  modified_content: str, title: str,
214
168
  tab_id: Optional[str] = None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: portacode
3
- Version: 1.3.32
3
+ Version: 1.3.34
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
File without changes
@@ -15,4 +15,7 @@ TEST_HEADLESS=false # true for headless mode, false for visible browser
15
15
  # Optional: Test Output Directories
16
16
  TEST_RESULTS_DIR=test_results
17
17
  TEST_RECORDINGS_DIR=test_recordings
18
- TEST_LOGS_DIR=test_results
18
+ TEST_LOGS_DIR=test_results
19
+
20
+ # Automation testing token (used by the testing framework to bypass captcha. Same token must be defined in ../main.env)
21
+ TEST_RUNNER_BYPASS_TOKEN=same-as-in-main-env
@@ -8,6 +8,7 @@ import logging
8
8
  import json
9
9
  import time
10
10
  from datetime import datetime
11
+ from urllib.parse import urlparse
11
12
 
12
13
  try:
13
14
  from playwright.async_api import async_playwright, Browser, BrowserContext, Page
@@ -71,6 +72,7 @@ class PlaywrightManager:
71
72
  env_headless = os.getenv('TEST_HEADLESS', 'false').lower() in ('true', '1', 'yes')
72
73
  env_video_width = int(os.getenv('TEST_VIDEO_WIDTH', '1920'))
73
74
  env_video_height = int(os.getenv('TEST_VIDEO_HEIGHT', '1080'))
75
+ automation_token = os.getenv('TEST_RUNNER_BYPASS_TOKEN')
74
76
 
75
77
  # Use provided values or fall back to environment
76
78
  self.base_url = url or env_url
@@ -125,14 +127,32 @@ class PlaywrightManager:
125
127
 
126
128
  # Create context with recording enabled and proper viewport
127
129
  video_size = {"width": env_video_width, "height": env_video_height}
128
- self.context = await self.browser.new_context(
129
- record_video_dir=str(self.test_recordings_dir),
130
- record_video_size=video_size,
131
- record_har_path=str(self.har_path),
132
- record_har_omit_content=False,
133
- viewport=video_size
134
- )
135
-
130
+ context_kwargs = {
131
+ "record_video_dir": str(self.test_recordings_dir),
132
+ "record_video_size": video_size,
133
+ "record_har_path": str(self.har_path),
134
+ "record_har_omit_content": False,
135
+ "viewport": video_size
136
+ }
137
+ self.context = await self.browser.new_context(**context_kwargs)
138
+ if automation_token:
139
+ parsed_base = urlparse(self.base_url)
140
+ target_host = parsed_base.hostname
141
+ target_scheme = parsed_base.scheme or "http"
142
+ header_name = "X-Portacode-Automation"
143
+
144
+ async def automation_header_route(route, request):
145
+ headers = dict(request.headers)
146
+ parsed_request = urlparse(request.url)
147
+ if parsed_request.hostname == target_host and parsed_request.scheme == target_scheme:
148
+ headers[header_name] = automation_token
149
+ else:
150
+ headers.pop(header_name, None)
151
+ await route.continue_(headers=headers)
152
+
153
+ await self.context.route("**/*", automation_header_route)
154
+ self.logger.info("Automation bypass header restricted to same-origin requests")
155
+
136
156
  self.logger.info(f"Video recording configured: {env_video_width}x{env_video_height}")
137
157
 
138
158
  # Start tracing
@@ -178,7 +198,7 @@ class PlaywrightManager:
178
198
  """Perform login using provided credentials."""
179
199
  try:
180
200
  # Navigate to login page first
181
- login_url = f"{self.base_url}/accounts/login/"
201
+ login_url = f"{self.base_url}accounts/login/"
182
202
  await self.page.goto(login_url)
183
203
  await self.log_action("navigate_to_login", {"url": login_url})
184
204
  await self.take_screenshot("login_page")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes