portacode 1.3.31__py3-none-any.whl → 1.3.33__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of portacode might be problematic. Click here for more details.
- portacode/_version.py +2 -2
- portacode/connection/handlers/project_state/git_manager.py +19 -10
- portacode/connection/handlers/project_state/handlers.py +9 -9
- portacode/connection/handlers/project_state/manager.py +25 -11
- portacode/connection/handlers/session.py +298 -78
- {portacode-1.3.31.dist-info → portacode-1.3.33.dist-info}/METADATA +1 -1
- {portacode-1.3.31.dist-info → portacode-1.3.33.dist-info}/RECORD +11 -11
- {portacode-1.3.31.dist-info → portacode-1.3.33.dist-info}/WHEEL +0 -0
- {portacode-1.3.31.dist-info → portacode-1.3.33.dist-info}/entry_points.txt +0 -0
- {portacode-1.3.31.dist-info → portacode-1.3.33.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.3.31.dist-info → portacode-1.3.33.dist-info}/top_level.txt +0 -0
portacode/_version.py
CHANGED
|
@@ -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
|
-
__version_tuple__ = version_tuple = (1, 3,
|
|
31
|
+
__version__ = version = '1.3.33'
|
|
32
|
+
__version_tuple__ = version_tuple = (1, 3, 33)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -350,9 +350,18 @@ class GitManager:
|
|
|
350
350
|
status_output = self.repo.git.status(*rel_paths, porcelain=True)
|
|
351
351
|
if status_output.strip():
|
|
352
352
|
for line in status_output.strip().split('\n'):
|
|
353
|
+
# Git porcelain format: XY path (X=index, Y=worktree, then space, then path)
|
|
354
|
+
# Some files may have renamed format: XY path -> new_path
|
|
353
355
|
if len(line) >= 3:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
+
# Skip first 3 characters (2 status + 1 space) to get the file path
|
|
357
|
+
# But git uses exactly 2 chars for status then space, so position 3 onwards is path
|
|
358
|
+
parts = line.split(None, 1) # Split on first whitespace to separate status from path
|
|
359
|
+
if len(parts) >= 2:
|
|
360
|
+
file_path_from_status = parts[1]
|
|
361
|
+
# Handle renames (format: "old_path -> new_path")
|
|
362
|
+
if ' -> ' in file_path_from_status:
|
|
363
|
+
file_path_from_status = file_path_from_status.split(' -> ')[1]
|
|
364
|
+
status_map[file_path_from_status] = line
|
|
356
365
|
except Exception as e:
|
|
357
366
|
logger.debug("Error getting batch status: %s", e)
|
|
358
367
|
|
|
@@ -469,7 +478,6 @@ class GitManager:
|
|
|
469
478
|
elif index_status == 'D' or worktree_status == 'D':
|
|
470
479
|
has_deleted = True
|
|
471
480
|
|
|
472
|
-
# Priority order: untracked > modified/deleted > clean
|
|
473
481
|
if has_untracked:
|
|
474
482
|
return {"is_tracked": False, "status": "untracked", "is_ignored": False, "is_staged": is_staged}
|
|
475
483
|
elif has_deleted:
|
|
@@ -1836,16 +1844,17 @@ class GitManager:
|
|
|
1836
1844
|
"""Monitor git changes periodically and trigger callback when changes are detected."""
|
|
1837
1845
|
try:
|
|
1838
1846
|
while self._monitoring_enabled:
|
|
1839
|
-
await asyncio.sleep(
|
|
1840
|
-
|
|
1847
|
+
await asyncio.sleep(5.0) # Check every 5000ms
|
|
1848
|
+
|
|
1841
1849
|
if not self._monitoring_enabled or not self.is_git_repo:
|
|
1842
1850
|
break
|
|
1843
|
-
|
|
1851
|
+
|
|
1844
1852
|
try:
|
|
1845
|
-
# Get current status
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
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)
|
|
1849
1858
|
|
|
1850
1859
|
# Compare with cached status
|
|
1851
1860
|
status_changed = (
|
|
@@ -408,10 +408,10 @@ class ProjectStateGitStageHandler(AsyncHandler):
|
|
|
408
408
|
success = git_manager.stage_file(file_paths_to_stage[0])
|
|
409
409
|
else:
|
|
410
410
|
success = git_manager.stage_files(file_paths_to_stage)
|
|
411
|
-
|
|
411
|
+
|
|
412
412
|
if success:
|
|
413
|
-
# Refresh
|
|
414
|
-
await manager._refresh_project_state(source_client_session)
|
|
413
|
+
# Refresh git status only (no filesystem changes from staging)
|
|
414
|
+
await manager._refresh_project_state(source_client_session, git_only=True)
|
|
415
415
|
|
|
416
416
|
# Build response
|
|
417
417
|
response = {
|
|
@@ -482,10 +482,10 @@ class ProjectStateGitUnstageHandler(AsyncHandler):
|
|
|
482
482
|
success = git_manager.unstage_file(file_paths_to_unstage[0])
|
|
483
483
|
else:
|
|
484
484
|
success = git_manager.unstage_files(file_paths_to_unstage)
|
|
485
|
-
|
|
485
|
+
|
|
486
486
|
if success:
|
|
487
|
-
# Refresh
|
|
488
|
-
await manager._refresh_project_state(source_client_session)
|
|
487
|
+
# Refresh git status only (no filesystem changes from unstaging)
|
|
488
|
+
await manager._refresh_project_state(source_client_session, git_only=True)
|
|
489
489
|
|
|
490
490
|
# Build response
|
|
491
491
|
response = {
|
|
@@ -620,9 +620,9 @@ class ProjectStateGitCommitHandler(AsyncHandler):
|
|
|
620
620
|
if success:
|
|
621
621
|
# Get the commit hash of the new commit
|
|
622
622
|
commit_hash = git_manager.get_head_commit_hash()
|
|
623
|
-
|
|
624
|
-
# Refresh
|
|
625
|
-
await manager._refresh_project_state(source_client_session)
|
|
623
|
+
|
|
624
|
+
# Refresh git status only (no filesystem changes from commit)
|
|
625
|
+
await manager._refresh_project_state(source_client_session, git_only=True)
|
|
626
626
|
except Exception as e:
|
|
627
627
|
error_message = str(e)
|
|
628
628
|
logger.error("Error during commit: %s", error_message)
|
|
@@ -152,7 +152,8 @@ class ProjectStateManager:
|
|
|
152
152
|
async def git_change_callback():
|
|
153
153
|
"""Callback when git status changes are detected."""
|
|
154
154
|
logger.debug("Git change detected, refreshing project state for %s", client_session_id)
|
|
155
|
-
|
|
155
|
+
# Git directory changes only affect git status, not filesystem
|
|
156
|
+
await self._refresh_project_state(client_session_id, git_only=True)
|
|
156
157
|
|
|
157
158
|
git_manager = GitManager(project_folder_path, change_callback=git_change_callback)
|
|
158
159
|
self.git_managers[client_session_id] = git_manager
|
|
@@ -877,9 +878,17 @@ class ProjectStateManager:
|
|
|
877
878
|
self._pending_changes.clear()
|
|
878
879
|
logger.debug("🔍 [TRACE] ✅ Finished processing file changes")
|
|
879
880
|
|
|
880
|
-
async def _refresh_project_state(self, client_session_id: str):
|
|
881
|
-
"""Refresh project state after file changes.
|
|
882
|
-
|
|
881
|
+
async def _refresh_project_state(self, client_session_id: str, git_only: bool = False):
|
|
882
|
+
"""Refresh project state after file changes.
|
|
883
|
+
|
|
884
|
+
Args:
|
|
885
|
+
client_session_id: The client session ID
|
|
886
|
+
git_only: If True, only git status changed (skip filesystem operations like
|
|
887
|
+
detecting new directories and syncing file state). Use this for
|
|
888
|
+
git operations (stage, unstage, revert) to avoid unnecessary work.
|
|
889
|
+
"""
|
|
890
|
+
logger.debug("🔍 [TRACE] _refresh_project_state called for session: %s (git_only=%s)",
|
|
891
|
+
client_session_id, git_only)
|
|
883
892
|
|
|
884
893
|
if client_session_id not in self.projects:
|
|
885
894
|
logger.debug("🔍 [TRACE] ❌ Session not found in projects: %s", client_session_id)
|
|
@@ -930,15 +939,20 @@ class ProjectStateManager:
|
|
|
930
939
|
old_status_summary, project_state.git_status_summary)
|
|
931
940
|
else:
|
|
932
941
|
logger.debug("🔍 [TRACE] ❌ No git manager found for session: %s", client_session_id)
|
|
933
|
-
|
|
934
|
-
#
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
942
|
+
|
|
943
|
+
# For git-only operations, skip scanning for new directories
|
|
944
|
+
# but still sync items to update git attributes for UI
|
|
945
|
+
if not git_only:
|
|
946
|
+
# Detect and add new directories in expanded folders before syncing
|
|
947
|
+
logger.debug("🔍 [TRACE] Detecting and adding new directories...")
|
|
948
|
+
await self._detect_and_add_new_directories(project_state)
|
|
949
|
+
else:
|
|
950
|
+
logger.debug("🔍 [TRACE] Skipping directory detection (git_only=True)")
|
|
951
|
+
|
|
952
|
+
# Always sync state to update git attributes on items (needed for UI updates)
|
|
939
953
|
logger.debug("🔍 [TRACE] Syncing all state with monitored folders...")
|
|
940
954
|
await self._sync_all_state_with_monitored_folders(project_state)
|
|
941
|
-
|
|
955
|
+
|
|
942
956
|
# Send update to clients
|
|
943
957
|
logger.debug("🔍 [TRACE] About to send project state update...")
|
|
944
958
|
await self._send_project_state_update(project_state)
|
|
@@ -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
|
-
|
|
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
|
|
23
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
"""
|
|
297
|
-
|
|
298
|
-
self.
|
|
299
|
-
self._buffer_size_bytes += data_bytes
|
|
300
|
-
|
|
301
|
-
# Remove oldest entries until we're under the size limit
|
|
302
|
-
while self._buffer_size_bytes > TERMINAL_BUFFER_SIZE_LIMIT_BYTES and self._buffer:
|
|
303
|
-
oldest_data = self._buffer.popleft()
|
|
304
|
-
self._buffer_size_bytes -= len(oldest_data.encode('utf-8'))
|
|
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
|
|
308
|
-
|
|
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,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
portacode/README.md,sha256=4dKtpvR8LNgZPVz37GmkQCMWIr_u25Ao63iW56s7Ke4,775
|
|
2
2
|
portacode/__init__.py,sha256=oB3sV1wXr-um-RXio73UG8E5Xx6cF2ZVJveqjNmC-vQ,1086
|
|
3
3
|
portacode/__main__.py,sha256=jmHTGC1hzmo9iKJLv-SSYe9BSIbPPZ2IOpecI03PlTs,296
|
|
4
|
-
portacode/_version.py,sha256=
|
|
4
|
+
portacode/_version.py,sha256=nJBSGzQLAZMH96LyzR0NHlWYQsAa05C0c2ZTZZIV0ss,706
|
|
5
5
|
portacode/cli.py,sha256=eDqcZMVFHKzqqWxedhhx8ylu5WMVCLqeJQkbPR7RcJE,16333
|
|
6
6
|
portacode/data.py,sha256=5-s291bv8J354myaHm1Y7CQZTZyRzMU3TGe5U4hb-FA,1591
|
|
7
7
|
portacode/keypair.py,sha256=PAcOYqlVLOoZTPYi6LvLjfsY6BkrWbLOhSZLb8r5sHs,3635
|
|
@@ -21,16 +21,16 @@ portacode/connection/handlers/file_handlers.py,sha256=kBj-o3HkqZTKsju2ZxRgBB3Ke4
|
|
|
21
21
|
portacode/connection/handlers/project_aware_file_handlers.py,sha256=n0M2WmBNWPwzigdIkyZiAsePUQGXVqYSsDyOxm-Nsok,9253
|
|
22
22
|
portacode/connection/handlers/project_state_handlers.py,sha256=v6ZefGW9i7n1aZLq2jOGumJIjYb6aHlPI4m1jkYewm8,1686
|
|
23
23
|
portacode/connection/handlers/registry.py,sha256=qXGE60sYEWg6ZtVQzFcZ5YI2XWR6lMgw4hAL9x5qR1I,6181
|
|
24
|
-
portacode/connection/handlers/session.py,sha256=
|
|
24
|
+
portacode/connection/handlers/session.py,sha256=XWiD4dofzZB9AH7EDqbWeJ-1CrSNPCUTR2nE2UEZh7Y,35568
|
|
25
25
|
portacode/connection/handlers/system_handlers.py,sha256=65V5ctT0dIBc-oWG91e62MbdvU0z6x6JCTQuIqCWmZ0,5242
|
|
26
26
|
portacode/connection/handlers/tab_factory.py,sha256=VBZnwtxgeNJCsfBzUjkFWAAGBdijvai4MS2dXnhFY8U,18000
|
|
27
27
|
portacode/connection/handlers/terminal_handlers.py,sha256=HRwHW1GiqG1NtHVEqXHKaYkFfQEzCDDH6YIlHcb4XD8,11866
|
|
28
28
|
portacode/connection/handlers/project_state/README.md,sha256=trdd4ig6ungmwH5SpbSLfyxbL-QgPlGNU-_XrMEiXtw,10114
|
|
29
29
|
portacode/connection/handlers/project_state/__init__.py,sha256=5ucIqk6Iclqg6bKkL8r_wVs5Tlt6B9J7yQH6yQUt7gc,2541
|
|
30
30
|
portacode/connection/handlers/project_state/file_system_watcher.py,sha256=2zingW9BoNKRijghHC2eHHdRoyDRdLmIl1yH1y-iuF8,10831
|
|
31
|
-
portacode/connection/handlers/project_state/git_manager.py,sha256=
|
|
32
|
-
portacode/connection/handlers/project_state/handlers.py,sha256=
|
|
33
|
-
portacode/connection/handlers/project_state/manager.py,sha256=
|
|
31
|
+
portacode/connection/handlers/project_state/git_manager.py,sha256=GO0AEXzHEaKOBGZP043_V2KgGz8zqmSahWJ5KHgC_Cs,88845
|
|
32
|
+
portacode/connection/handlers/project_state/handlers.py,sha256=nhs-3yiENdewAzVZnSdn2Ir-e6TQ9Nz_Bxk3iiFPd9c,37985
|
|
33
|
+
portacode/connection/handlers/project_state/manager.py,sha256=XX3wMgGdPbRgBBs_R1dXtQ4D9j-itETrJR_6IfBeDU0,61296
|
|
34
34
|
portacode/connection/handlers/project_state/models.py,sha256=EZTKvxHKs8QlQUbzI0u2IqfzfRRXZixUIDBwTGCJATI,4313
|
|
35
35
|
portacode/connection/handlers/project_state/utils.py,sha256=LsbQr9TH9Bz30FqikmtTxco4PlB_n0kUIuPKQ6Fb_mo,1665
|
|
36
36
|
portacode/static/js/test-ntp-clock.html,sha256=bUow9sifIuLNPqKvuPbpQozmEE6RhdCI4Plib3CqUmw,2130
|
|
@@ -38,7 +38,7 @@ portacode/static/js/utils/ntp-clock.js,sha256=KMeHGT-IlUSlxVRZZ899z25dQCJh6EJbgX
|
|
|
38
38
|
portacode/utils/NTP_ARCHITECTURE.md,sha256=WkESTbz5SNAgdmDKk3DrHMhtYOPji_Kt3_a9arWdRig,3894
|
|
39
39
|
portacode/utils/__init__.py,sha256=NgBlWTuNJESfIYJzP_3adI1yJQJR0XJLRpSdVNaBAN0,33
|
|
40
40
|
portacode/utils/ntp_clock.py,sha256=6QJOVZr9VQuxIyJt9KNG4dR-nZ3bKNyipMxjqDWP89Y,5152
|
|
41
|
-
portacode-1.3.
|
|
41
|
+
portacode-1.3.33.dist-info/licenses/LICENSE,sha256=2FGbCnUDgRYuQTkB1O1dUUpu5CVAjK1j4_p6ack9Z54,1066
|
|
42
42
|
test_modules/README.md,sha256=Do_agkm9WhSzueXjRAkV_xEj6Emy5zB3N3VKY5Roce8,9274
|
|
43
43
|
test_modules/__init__.py,sha256=1LcbHodIHsB0g-g4NGjSn6AMuCoGbymvXPYLOb6Z7F0,53
|
|
44
44
|
test_modules/test_device_online.py,sha256=yiSyVaMwKAugqIX_ZIxmLXiOlmA_8IRXiUp12YmpB98,1653
|
|
@@ -63,8 +63,8 @@ testing_framework/core/playwright_manager.py,sha256=8xl-19b8NQjKNdiRyDjyeXlYyKPZ
|
|
|
63
63
|
testing_framework/core/runner.py,sha256=j2QwNJmAxVBmJvcbVS7DgPJUKPNzqfLmt_4NNdaKmZU,19297
|
|
64
64
|
testing_framework/core/shared_cli_manager.py,sha256=BESSNtyQb7BOlaOvZmm04T8Uezjms4KCBs2MzTxvzYQ,8790
|
|
65
65
|
testing_framework/core/test_discovery.py,sha256=2FZ9fJ8Dp5dloA-fkgXoJ_gCMC_nYPBnA3Hs2xlagzM,4928
|
|
66
|
-
portacode-1.3.
|
|
67
|
-
portacode-1.3.
|
|
68
|
-
portacode-1.3.
|
|
69
|
-
portacode-1.3.
|
|
70
|
-
portacode-1.3.
|
|
66
|
+
portacode-1.3.33.dist-info/METADATA,sha256=pSZ7HgbTSK_h7QT8gblUhywYF5EWMed_z_17PL_i2Ss,6989
|
|
67
|
+
portacode-1.3.33.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
68
|
+
portacode-1.3.33.dist-info/entry_points.txt,sha256=lLUUL-BM6_wwe44Xv0__5NQ1BnAz6jWjSMFvZdWW3zU,48
|
|
69
|
+
portacode-1.3.33.dist-info/top_level.txt,sha256=TGhTYUxfW8SyVZc_zGgzjzc24gGT7nSw8Qf73liVRKM,41
|
|
70
|
+
portacode-1.3.33.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|