portacode 1.3.32__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 +8 -7
- portacode/connection/handlers/session.py +298 -78
- {portacode-1.3.32.dist-info → portacode-1.3.33.dist-info}/METADATA +1 -1
- {portacode-1.3.32.dist-info → portacode-1.3.33.dist-info}/RECORD +9 -9
- {portacode-1.3.32.dist-info → portacode-1.3.33.dist-info}/WHEEL +0 -0
- {portacode-1.3.32.dist-info → portacode-1.3.33.dist-info}/entry_points.txt +0 -0
- {portacode-1.3.32.dist-info → portacode-1.3.33.dist-info}/licenses/LICENSE +0 -0
- {portacode-1.3.32.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
|
|
@@ -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(
|
|
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
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
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 = (
|
|
@@ -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,14 +21,14 @@ 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=
|
|
31
|
+
portacode/connection/handlers/project_state/git_manager.py,sha256=GO0AEXzHEaKOBGZP043_V2KgGz8zqmSahWJ5KHgC_Cs,88845
|
|
32
32
|
portacode/connection/handlers/project_state/handlers.py,sha256=nhs-3yiENdewAzVZnSdn2Ir-e6TQ9Nz_Bxk3iiFPd9c,37985
|
|
33
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
|
|
@@ -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
|