portacode 0.3.4.dev0__py3-none-any.whl → 1.4.11.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of portacode might be problematic. Click here for more details.

Files changed (93) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +155 -19
  3. portacode/connection/client.py +152 -12
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +1577 -0
  5. portacode/connection/handlers/__init__.py +43 -1
  6. portacode/connection/handlers/base.py +122 -18
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -0
  20. portacode/connection/handlers/proxmox_infra.py +307 -0
  21. portacode/connection/handlers/registry.py +53 -10
  22. portacode/connection/handlers/session.py +705 -53
  23. portacode/connection/handlers/system_handlers.py +142 -8
  24. portacode/connection/handlers/tab_factory.py +389 -0
  25. portacode/connection/handlers/terminal_handlers.py +150 -11
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +695 -28
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/service.py +6 -0
  53. portacode/static/js/test-ntp-clock.html +63 -0
  54. portacode/static/js/utils/ntp-clock.js +232 -0
  55. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  56. portacode/utils/__init__.py +1 -0
  57. portacode/utils/diff_apply.py +456 -0
  58. portacode/utils/diff_renderer.py +371 -0
  59. portacode/utils/ntp_clock.py +65 -0
  60. portacode-1.4.11.dev0.dist-info/METADATA +298 -0
  61. portacode-1.4.11.dev0.dist-info/RECORD +97 -0
  62. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/WHEEL +1 -1
  63. portacode-1.4.11.dev0.dist-info/top_level.txt +3 -0
  64. test_modules/README.md +296 -0
  65. test_modules/__init__.py +1 -0
  66. test_modules/test_device_online.py +44 -0
  67. test_modules/test_file_operations.py +743 -0
  68. test_modules/test_git_status_ui.py +370 -0
  69. test_modules/test_login_flow.py +50 -0
  70. test_modules/test_navigate_testing_folder.py +361 -0
  71. test_modules/test_play_store_screenshots.py +294 -0
  72. test_modules/test_terminal_buffer_performance.py +261 -0
  73. test_modules/test_terminal_interaction.py +80 -0
  74. test_modules/test_terminal_loading_race_condition.py +95 -0
  75. test_modules/test_terminal_start.py +56 -0
  76. testing_framework/.env.example +21 -0
  77. testing_framework/README.md +334 -0
  78. testing_framework/__init__.py +17 -0
  79. testing_framework/cli.py +326 -0
  80. testing_framework/core/__init__.py +1 -0
  81. testing_framework/core/base_test.py +336 -0
  82. testing_framework/core/cli_manager.py +177 -0
  83. testing_framework/core/hierarchical_runner.py +577 -0
  84. testing_framework/core/playwright_manager.py +520 -0
  85. testing_framework/core/runner.py +447 -0
  86. testing_framework/core/shared_cli_manager.py +234 -0
  87. testing_framework/core/test_discovery.py +112 -0
  88. testing_framework/requirements.txt +12 -0
  89. portacode-0.3.4.dev0.dist-info/METADATA +0 -236
  90. portacode-0.3.4.dev0.dist-info/RECORD +0 -27
  91. portacode-0.3.4.dev0.dist-info/top_level.txt +0 -1
  92. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info}/entry_points.txt +0 -0
  93. {portacode-0.3.4.dev0.dist-info → portacode-1.4.11.dev0.dist-info/licenses}/LICENSE +0 -0
@@ -1,26 +1,68 @@
1
1
  """Terminal session management."""
2
2
 
3
3
  import asyncio
4
+ import json
4
5
  import logging
5
6
  import os
7
+ import struct
6
8
  import sys
9
+ import time
7
10
  import uuid
8
11
  from asyncio.subprocess import Process
9
12
  from pathlib import Path
10
- from typing import Any, Dict, Optional, List, TYPE_CHECKING
11
- from collections import deque
13
+ from typing import Any, Awaitable, Callable, Dict, Optional, List, TYPE_CHECKING
14
+
15
+ from platformdirs import user_data_dir
16
+
17
+ from portacode.link_capture import prepare_link_capture_bin
18
+
19
+ import pyte
12
20
 
13
21
  if TYPE_CHECKING:
14
22
  from ..multiplex import Channel
15
23
 
24
+ # Terminal data rate limiting configuration
25
+ TERMINAL_DATA_RATE_LIMIT_MS = 60 # Minimum time between terminal_data events (milliseconds)
26
+ TERMINAL_DATA_MAX_WAIT_MS = 1000 # Maximum time to wait before sending accumulated data (milliseconds)
27
+ TERMINAL_DATA_INITIAL_WAIT_MS = 10 # Time to wait for additional data even on first event (milliseconds)
28
+
29
+ # Terminal buffer configuration - using pyte for proper screen state management
30
+ TERMINAL_COLUMNS = 80 # Default terminal width
31
+ TERMINAL_ROWS = 24 # Default terminal height (visible area)
32
+ TERMINAL_SCROLLBACK_LIMIT = 1000 # Maximum number of scrollback lines to preserve
33
+
34
+ # Link event folder for capturing helper notifications
35
+ _LINK_EVENT_ROOT = Path(user_data_dir("portacode", "portacode")) / "link_events"
36
+ _LINK_EVENT_POLL_INTERVAL = 0.5 # seconds
37
+ LINK_EVENT_THROTTLE_SECONDS = 5.0
38
+ LINK_CAPTURE_ORIGINAL_BROWSER_ENV = "PORTACODE_LINK_CAPTURE_ORIGINAL_BROWSER"
39
+
16
40
  logger = logging.getLogger(__name__)
17
41
 
18
42
  _IS_WINDOWS = sys.platform.startswith("win")
19
43
 
44
+
45
+ def _configure_pty_window_size(fd: int, rows: int, cols: int) -> None:
46
+ """Set the PTY window size so subprocesses see a real terminal."""
47
+ if _IS_WINDOWS:
48
+ return
49
+ try:
50
+ import fcntl
51
+ import termios
52
+ winsize = struct.pack("HHHH", rows, cols, 0, 0)
53
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
54
+ except ImportError:
55
+ logger.debug("termios/fcntl unavailable; skipping PTY window sizing")
56
+ except OSError as exc:
57
+ logger.warning("Failed to set PTY window size (%sx%s): %s", cols, rows, exc)
58
+
59
+
60
+
20
61
  # Minimal, safe defaults for interactive shells
21
62
  _DEFAULT_ENV = {
22
63
  "TERM": "xterm-256color",
23
64
  "LANG": "C.UTF-8",
65
+ "SHELL": "/bin/bash",
24
66
  }
25
67
 
26
68
 
@@ -29,18 +71,124 @@ def _build_child_env() -> Dict[str, str]:
29
71
  env = os.environ.copy()
30
72
  for k, v in _DEFAULT_ENV.items():
31
73
  env.setdefault(k, v)
74
+ env.setdefault("COLUMNS", str(TERMINAL_COLUMNS))
75
+ env.setdefault("LINES", str(TERMINAL_ROWS))
32
76
  return env
33
77
 
34
78
 
79
+ _LINK_EVENT_DISPATCHER: Optional["LinkEventDispatcher"] = None
80
+
81
+ class LinkEventDispatcher:
82
+ """Watch a shared folder for link capture files."""
83
+
84
+ def __init__(self, directory: Optional[Path]):
85
+ self.directory = directory
86
+ self._task: Optional[asyncio.Task[None]] = None
87
+
88
+ # Callbacks that are notified whenever a new event file is processed
89
+ self._callbacks: List[Callable[[Dict[str, Any]], Awaitable[None]]] = []
90
+
91
+ def start(self) -> None:
92
+ if not self.directory:
93
+ return
94
+ if self._task and not self._task.done():
95
+ return
96
+ try:
97
+ self.directory.mkdir(parents=True, exist_ok=True)
98
+ except Exception as exc:
99
+ logger.warning("link_watcher: Failed to create directory %s: %s", self.directory, exc)
100
+ return
101
+ self._task = asyncio.create_task(self._run())
102
+
103
+ async def stop(self) -> None:
104
+ if not self._task:
105
+ return
106
+ self._task.cancel()
107
+ try:
108
+ await self._task
109
+ except asyncio.CancelledError:
110
+ pass
111
+ self._task = None
112
+
113
+ async def _run(self) -> None:
114
+ while True:
115
+ try:
116
+ if self.directory.exists():
117
+ for entry in sorted(self.directory.iterdir()):
118
+ if not entry.is_file():
119
+ continue
120
+ await self._process_entry(entry)
121
+ await asyncio.sleep(_LINK_EVENT_POLL_INTERVAL)
122
+ except asyncio.CancelledError:
123
+ break
124
+ except Exception as exc:
125
+ logger.warning("link_watcher: error scanning %s: %s", self.directory, exc)
126
+ await asyncio.sleep(_LINK_EVENT_POLL_INTERVAL)
127
+
128
+ async def _process_entry(self, entry: Path) -> None:
129
+ try:
130
+ raw = entry.read_text(encoding="utf-8")
131
+ payload = json.loads(raw)
132
+ except Exception as exc:
133
+ logger.warning("link_watcher: failed to read %s: %s", entry, exc)
134
+ else:
135
+ terminal_id = payload.get("terminal_id")
136
+ link = payload.get("url")
137
+ if link:
138
+ logger.info("link_watcher: terminal %s captured link %s", terminal_id, link)
139
+ else:
140
+ logger.info("link_watcher: terminal %s observed link capture without url (%s)", terminal_id, payload)
141
+ await self._notify_callbacks(payload)
142
+ finally:
143
+ try:
144
+ entry.unlink(missing_ok=True)
145
+ except Exception as exc:
146
+ logger.warning("link_watcher: failed to remove %s: %s", entry, exc)
147
+
148
+ def register_callback(self, callback: Callable[[Dict[str, Any]], Awaitable[None]]) -> None:
149
+ """Register a coroutine callback for processed link events."""
150
+ if callback in self._callbacks:
151
+ return
152
+ self._callbacks.append(callback)
153
+
154
+ async def _notify_callbacks(self, payload: Dict[str, Any]) -> None:
155
+ if not self._callbacks:
156
+ return
157
+ for callback in list(self._callbacks):
158
+ try:
159
+ result = callback(payload)
160
+ if asyncio.iscoroutine(result):
161
+ await result
162
+ except Exception as exc:
163
+ logger.warning("link_watcher: callback raised an exception: %s", exc)
164
+
165
+
166
+ def _get_link_event_dispatcher() -> "LinkEventDispatcher":
167
+ global _LINK_EVENT_DISPATCHER
168
+ if _LINK_EVENT_DISPATCHER is None:
169
+ _LINK_EVENT_DISPATCHER = LinkEventDispatcher(_LINK_EVENT_ROOT)
170
+ return _LINK_EVENT_DISPATCHER
171
+
172
+
35
173
  class TerminalSession:
36
174
  """Represents a local shell subprocess bound to a mux channel."""
37
175
 
38
- def __init__(self, session_id: str, proc: Process, channel: "Channel"):
176
+ def __init__(self, session_id: str, proc: Process, channel: "Channel", project_id: Optional[str] = None, terminal_manager: Optional["TerminalManager"] = None):
39
177
  self.id = session_id
40
178
  self.proc = proc
41
179
  self.channel = channel
180
+ self.project_id = project_id
181
+ self.terminal_manager = terminal_manager
42
182
  self._reader_task: Optional[asyncio.Task[None]] = None
43
- self._buffer: deque[str] = deque(maxlen=400)
183
+
184
+ # Use pyte for proper terminal screen state management
185
+ self._screen = pyte.HistoryScreen(TERMINAL_COLUMNS, TERMINAL_ROWS, history=TERMINAL_SCROLLBACK_LIMIT)
186
+ self._stream = pyte.Stream(self._screen) # Use Stream (not ByteStream) since data is already decoded to strings
187
+
188
+ # Rate limiting for terminal_data events
189
+ self._last_send_time: float = 0
190
+ self._pending_data: str = ""
191
+ self._debounce_task: Optional[asyncio.Task[None]] = None
44
192
 
45
193
  async def start_io_forwarding(self) -> None:
46
194
  """Spawn background task that copies stdout/stderr to the channel."""
@@ -54,13 +202,9 @@ class TerminalSession:
54
202
  break
55
203
  text = data.decode(errors="ignore")
56
204
  logging.getLogger("portacode.terminal").debug(f"[MUX] Terminal {self.id} output: {text!r}")
57
- self._buffer.append(text)
58
- try:
59
- await self.channel.send(text)
60
- except Exception as exc:
61
- logger.warning("Failed to forward terminal output: %s", exc)
62
- await asyncio.sleep(0.5)
63
- continue
205
+
206
+ # Use rate-limited sending instead of immediate sending
207
+ await self._handle_terminal_data(text)
64
208
  finally:
65
209
  if self.proc and self.proc.returncode is None:
66
210
  pass # Keep alive across reconnects
@@ -68,7 +212,7 @@ class TerminalSession:
68
212
  # Cancel existing reader task if it exists
69
213
  if self._reader_task and not self._reader_task.done():
70
214
  self._reader_task.cancel()
71
-
215
+
72
216
  self._reader_task = asyncio.create_task(_pump())
73
217
 
74
218
  async def write(self, data: str) -> None:
@@ -76,21 +220,416 @@ class TerminalSession:
76
220
  logger.warning("stdin pipe closed for terminal %s", self.id)
77
221
  return
78
222
  try:
79
- self.proc.stdin.write(data.encode())
80
- await self.proc.stdin.drain()
223
+ if hasattr(self.proc.stdin, 'write') and hasattr(self.proc.stdin, 'drain'):
224
+ # StreamWriter (pipe fallback)
225
+ self.proc.stdin.write(data.encode())
226
+ await self.proc.stdin.drain()
227
+ else:
228
+ # File object (PTY)
229
+ loop = asyncio.get_running_loop()
230
+ await loop.run_in_executor(None, self.proc.stdin.write, data.encode())
231
+ await loop.run_in_executor(None, self.proc.stdin.flush)
81
232
  except Exception as exc:
82
233
  logger.warning("Failed to write to terminal %s: %s", self.id, exc)
83
234
 
84
235
  async def stop(self) -> None:
85
- if self.proc.returncode is None:
86
- self.proc.terminate()
87
- if self._reader_task:
88
- await self._reader_task
89
- await self.proc.wait()
236
+ """Stop the terminal session with comprehensive logging."""
237
+ logger.info("session.stop: Starting stop process for session %s (PID: %s)",
238
+ self.id, getattr(self.proc, 'pid', 'unknown'))
239
+
240
+ try:
241
+ # Check if process is still running
242
+ if self.proc.returncode is None:
243
+ logger.info("session.stop: Terminating process for session %s", self.id)
244
+ self.proc.terminate()
245
+ else:
246
+ logger.info("session.stop: Process for session %s already exited (returncode: %s)",
247
+ self.id, self.proc.returncode)
248
+
249
+ # Wait for reader task to complete
250
+ if self._reader_task and not self._reader_task.done():
251
+ logger.info("session.stop: Waiting for reader task to complete for session %s", self.id)
252
+ try:
253
+ await asyncio.wait_for(self._reader_task, timeout=5.0)
254
+ logger.info("session.stop: Reader task completed for session %s", self.id)
255
+ except asyncio.TimeoutError:
256
+ logger.warning("session.stop: Reader task timeout for session %s, cancelling", self.id)
257
+ self._reader_task.cancel()
258
+ try:
259
+ await self._reader_task
260
+ except asyncio.CancelledError:
261
+ pass
262
+
263
+ # Cancel and flush any pending terminal data
264
+ if self._debounce_task and not self._debounce_task.done():
265
+ logger.info("session.stop: Cancelling debounce task for session %s", self.id)
266
+ self._debounce_task.cancel()
267
+ try:
268
+ await self._debounce_task
269
+ except asyncio.CancelledError:
270
+ pass
271
+
272
+ # Send any remaining pending data
273
+ if self._pending_data:
274
+ logger.info("session.stop: Flushing pending terminal data for session %s", self.id)
275
+ await self._flush_pending_data()
276
+
277
+ # Wait for process to exit
278
+ if self.proc.returncode is None:
279
+ logger.info("session.stop: Waiting for process to exit for session %s", self.id)
280
+ await self.proc.wait()
281
+ logger.info("session.stop: Process exited for session %s (returncode: %s)",
282
+ self.id, self.proc.returncode)
283
+ else:
284
+ logger.info("session.stop: Process already exited for session %s (returncode: %s)",
285
+ self.id, self.proc.returncode)
286
+
287
+ except Exception as exc:
288
+ logger.exception("session.stop: Error stopping session %s: %s", self.id, exc)
289
+ raise
290
+
291
+ async def _send_terminal_data_now(self, data: str) -> None:
292
+ """Send terminal data immediately and update last send time."""
293
+ self._last_send_time = time.time()
294
+ data_size = len(data.encode('utf-8'))
295
+
296
+ logger.info("session: Attempting to send terminal_data for terminal %s (data_size=%d bytes)",
297
+ self.id, data_size)
298
+
299
+ # Feed data to pyte screen for proper terminal state management
300
+ self._add_to_buffer(data)
301
+
302
+ try:
303
+ # Send terminal data via control channel with client session targeting
304
+ if self.terminal_manager:
305
+ await self.terminal_manager._send_session_aware({
306
+ "event": "terminal_data",
307
+ "channel": self.id,
308
+ "data": data,
309
+ "project_id": self.project_id
310
+ }, project_id=self.project_id)
311
+ logger.info("session: Successfully queued terminal_data for terminal %s via terminal_manager", self.id)
312
+ else:
313
+ # Fallback to raw channel for backward compatibility
314
+ await self.channel.send(data)
315
+ logger.info("session: Successfully sent terminal_data for terminal %s via raw channel", self.id)
316
+ except Exception as exc:
317
+ logger.warning("session: Failed to forward terminal output for terminal %s: %s", self.id, exc)
318
+
319
+ async def _flush_pending_data(self) -> None:
320
+ """Send accumulated pending data and reset pending buffer."""
321
+ if self._pending_data:
322
+ pending_size = len(self._pending_data.encode('utf-8'))
323
+ logger.info("session: Flushing pending terminal_data for terminal %s (pending_size=%d bytes)",
324
+ self.id, pending_size)
325
+ data_to_send = self._pending_data
326
+ self._pending_data = ""
327
+ await self._send_terminal_data_now(data_to_send)
328
+ else:
329
+ logger.debug("session: No pending data to flush for terminal %s", self.id)
330
+
331
+ # Clear the debounce task
332
+ self._debounce_task = None
333
+
334
+ async def _handle_terminal_data(self, data: str) -> None:
335
+ """Handle new terminal data with rate limiting and debouncing."""
336
+ current_time = time.time()
337
+ time_since_last_send = (current_time - self._last_send_time) * 1000 # Convert to milliseconds
338
+ data_size = len(data.encode('utf-8'))
339
+
340
+ logger.info("session: Received terminal_data for terminal %s (data_size=%d bytes, time_since_last_send=%.1fms)",
341
+ self.id, data_size, time_since_last_send)
342
+
343
+ # Add new data to pending buffer (no trimming needed - pyte handles screen state)
344
+ self._pending_data += data
345
+
346
+ # Cancel existing debounce task if any
347
+ if self._debounce_task and not self._debounce_task.done():
348
+ logger.debug("session: Cancelling existing debounce task for terminal %s", self.id)
349
+ self._debounce_task.cancel()
350
+
351
+ # Always set up a debounce timer to catch rapid consecutive outputs
352
+ async def _debounce_timer():
353
+ try:
354
+ if time_since_last_send >= TERMINAL_DATA_RATE_LIMIT_MS:
355
+ # Enough time has passed since last send, wait initial delay for more data
356
+ wait_time = TERMINAL_DATA_INITIAL_WAIT_MS / 1000
357
+ logger.info("session: Rate limit satisfied for terminal %s, waiting %.1fms for more data",
358
+ self.id, wait_time * 1000)
359
+ else:
360
+ # Too soon since last send, wait for either the rate limit period or max wait time
361
+ wait_time = min(
362
+ (TERMINAL_DATA_RATE_LIMIT_MS - time_since_last_send) / 1000,
363
+ TERMINAL_DATA_MAX_WAIT_MS / 1000
364
+ )
365
+ logger.info("session: Rate limit active for terminal %s, waiting %.1fms before send (time_since_last=%.1fms, rate_limit=%dms)",
366
+ self.id, wait_time * 1000, time_since_last_send, TERMINAL_DATA_RATE_LIMIT_MS)
367
+
368
+ await asyncio.sleep(wait_time)
369
+ logger.info("session: Debounce timer expired for terminal %s, flushing pending data", self.id)
370
+ await self._flush_pending_data()
371
+ except asyncio.CancelledError:
372
+ logger.debug("session: Debounce timer cancelled for terminal %s (new data arrived)", self.id)
373
+ # Timer was cancelled, another data event came in
374
+ pass
375
+
376
+ self._debounce_task = asyncio.create_task(_debounce_timer())
377
+ logger.info("session: Started debounce timer for terminal %s", self.id)
378
+
379
+ def _add_to_buffer(self, data: str) -> None:
380
+ """Feed data to pyte virtual terminal screen."""
381
+ # Feed the data to pyte - it handles all ANSI parsing and screen state management
382
+ self._stream.feed(data)
90
383
 
91
384
  def snapshot_buffer(self) -> str:
92
- """Return concatenated last buffer contents suitable for UI."""
93
- return "".join(self._buffer)
385
+ """Return the visible terminal content as ANSI sequences suitable for XTerm.js."""
386
+ # Render screen content to ANSI
387
+ result = self._render_screen_to_ansi()
388
+
389
+ # Add cursor positioning at the end so XTerm.js knows where the cursor should be
390
+ # This is critical - without it, new data gets written at the wrong position causing duplication
391
+ cursor_y = self._screen.cursor.y + 1 # Convert 0-indexed to 1-indexed
392
+ cursor_x = self._screen.cursor.x + 1 # Convert 0-indexed to 1-indexed
393
+
394
+ # Move cursor to the correct position
395
+ result += f'\x1b[{cursor_y};{cursor_x}H'
396
+
397
+ return result
398
+
399
+ def _render_screen_to_ansi(self) -> str:
400
+ """Convert pyte screen state to ANSI escape sequences.
401
+
402
+ This renders both scrollback history and visible screen with full formatting
403
+ (colors, bold, italics, underline) preserved as ANSI sequences.
404
+ """
405
+ lines = []
406
+
407
+ # Get scrollback history if available (HistoryScreen provides this)
408
+ if hasattr(self._screen, 'history'):
409
+ # Process scrollback lines (lines that have scrolled off the top)
410
+ history_top = self._screen.history.top
411
+ for line_data in history_top:
412
+ # line_data is a dict mapping column positions to Char objects
413
+ line = self._render_line_to_ansi(line_data, self._screen.columns)
414
+ lines.append(line)
415
+
416
+ # Process visible screen lines
417
+ for y in range(self._screen.lines):
418
+ line_data = self._screen.buffer[y]
419
+ line = self._render_line_to_ansi(line_data, self._screen.columns)
420
+ lines.append(line)
421
+
422
+ # Join all lines with CRLF for proper terminal display
423
+ return '\r\n'.join(lines)
424
+
425
+ def _render_line_to_ansi(self, line_data: Dict[int, 'pyte.screens.Char'], columns: int) -> str:
426
+ """Convert a single line from pyte format to ANSI escape sequences.
427
+
428
+ Args:
429
+ line_data: Dict mapping column index to Char objects
430
+ columns: Number of columns in the terminal
431
+
432
+ Returns:
433
+ String with ANSI escape codes for formatting
434
+ """
435
+ result = []
436
+ last_char = None
437
+ did_reset = False # Track if we just emitted a reset code
438
+
439
+ for x in range(columns):
440
+ char = line_data.get(x)
441
+ if char is None:
442
+ # Empty cell - reset formatting if we had any
443
+ if last_char is not None and self._char_has_formatting(last_char):
444
+ result.append('\x1b[0m')
445
+ did_reset = True
446
+ result.append(' ')
447
+ last_char = None
448
+ continue
449
+
450
+ # Check if formatting changed from previous character
451
+ format_changed = last_char is None or self._char_format_changed(last_char, char) or did_reset
452
+
453
+ if format_changed:
454
+ # If previous char had formatting and current is different, reset first
455
+ if last_char is not None and self._char_has_formatting(last_char) and not did_reset:
456
+ result.append('\x1b[0m')
457
+
458
+ # Apply new formatting (always apply after reset)
459
+ ansi_codes = self._get_ansi_codes_for_char(char)
460
+ if ansi_codes:
461
+ result.append(f'\x1b[{ansi_codes}m')
462
+ did_reset = False
463
+ else:
464
+ did_reset = True # No formatting to apply after reset
465
+
466
+ # Add the character data
467
+ result.append(char.data)
468
+ last_char = char
469
+
470
+ # Reset formatting at end of line if we had any
471
+ if last_char is not None and self._char_has_formatting(last_char):
472
+ result.append('\x1b[0m')
473
+
474
+ # Strip trailing whitespace from the line
475
+ line_text = ''.join(result).rstrip()
476
+ return line_text
477
+
478
+ def _char_has_formatting(self, char: 'pyte.screens.Char') -> bool:
479
+ """Check if a character has any formatting applied."""
480
+ return (char.bold or
481
+ (hasattr(char, 'dim') and char.dim) or
482
+ char.italics or
483
+ char.underscore or
484
+ (hasattr(char, 'blink') and char.blink) or
485
+ char.reverse or
486
+ (hasattr(char, 'hidden') and char.hidden) or
487
+ char.strikethrough or
488
+ char.fg != 'default' or
489
+ char.bg != 'default')
490
+
491
+ def _char_format_changed(self, char1: 'pyte.screens.Char', char2: 'pyte.screens.Char') -> bool:
492
+ """Check if formatting changed between two characters."""
493
+ return (char1.bold != char2.bold or
494
+ (hasattr(char1, 'dim') and hasattr(char2, 'dim') and char1.dim != char2.dim) or
495
+ char1.italics != char2.italics or
496
+ char1.underscore != char2.underscore or
497
+ (hasattr(char1, 'blink') and hasattr(char2, 'blink') and char1.blink != char2.blink) or
498
+ char1.reverse != char2.reverse or
499
+ (hasattr(char1, 'hidden') and hasattr(char2, 'hidden') and char1.hidden != char2.hidden) or
500
+ char1.strikethrough != char2.strikethrough or
501
+ char1.fg != char2.fg or
502
+ char1.bg != char2.bg)
503
+
504
+ def _get_ansi_codes_for_char(self, char: 'pyte.screens.Char') -> str:
505
+ """Convert pyte Char formatting to ANSI escape codes.
506
+
507
+ Returns:
508
+ String of semicolon-separated ANSI codes (e.g., "1;32;44")
509
+ """
510
+ codes = []
511
+
512
+ # Text attributes - comprehensive list matching ANSI SGR codes
513
+ if char.bold:
514
+ codes.append('1')
515
+ if hasattr(char, 'dim') and char.dim:
516
+ codes.append('2')
517
+ if char.italics:
518
+ codes.append('3')
519
+ if char.underscore:
520
+ codes.append('4')
521
+ if hasattr(char, 'blink') and char.blink:
522
+ codes.append('5')
523
+ if char.reverse:
524
+ codes.append('7')
525
+ if hasattr(char, 'hidden') and char.hidden:
526
+ codes.append('8')
527
+ if char.strikethrough:
528
+ codes.append('9')
529
+
530
+ # Foreground color
531
+ if char.fg != 'default':
532
+ fg_code = self._color_to_ansi(char.fg, is_background=False)
533
+ if fg_code:
534
+ codes.append(fg_code)
535
+
536
+ # Background color
537
+ if char.bg != 'default':
538
+ bg_code = self._color_to_ansi(char.bg, is_background=True)
539
+ if bg_code:
540
+ codes.append(bg_code)
541
+
542
+ return ';'.join(codes)
543
+
544
+ def _color_to_ansi(self, color, is_background: bool = False) -> Optional[str]:
545
+ """Convert pyte color to ANSI color code.
546
+
547
+ Args:
548
+ color: Color value (can be string name, int for 256-color, hex string, or tuple for RGB)
549
+ is_background: True for background color, False for foreground
550
+
551
+ Returns:
552
+ ANSI color code string or None
553
+ """
554
+ # Handle default/None
555
+ if color == 'default' or color is None:
556
+ return None
557
+
558
+ # Standard base for 8 basic colors
559
+ base = 40 if is_background else 30
560
+
561
+ if isinstance(color, str):
562
+ # pyte stores colors as lowercase strings
563
+ color_lower = color.lower()
564
+
565
+ # Check for hex color format (pyte stores RGB as hex strings like '4782c8')
566
+ # Hex strings are 6 characters (RRGGBB)
567
+ if len(color_lower) == 6 and all(c in '0123456789abcdef' for c in color_lower):
568
+ try:
569
+ # Parse hex string to RGB
570
+ r = int(color_lower[0:2], 16)
571
+ g = int(color_lower[2:4], 16)
572
+ b = int(color_lower[4:6], 16)
573
+ return f'{"48" if is_background else "38"};2;{r};{g};{b}'
574
+ except ValueError:
575
+ pass # Not a valid hex color, continue to other checks
576
+
577
+ # Named colors (black, red, green, yellow, blue, magenta, cyan, white)
578
+ color_map = {
579
+ 'black': 0, 'red': 1, 'green': 2, 'yellow': 3,
580
+ 'blue': 4, 'magenta': 5, 'cyan': 6, 'white': 7
581
+ }
582
+
583
+ # Check for bright/intense colors first (pyte may use different formats)
584
+ # Format 1: "brightred", "brightblue", etc.
585
+ if color_lower.startswith('bright') and len(color_lower) > 6:
586
+ color_base = color_lower[6:] # Remove 'bright' prefix
587
+ if color_base in color_map:
588
+ # Bright colors: 90-97 (fg), 100-107 (bg)
589
+ return str(base + 60 + color_map[color_base])
590
+
591
+ # Format 2: "bright_red", "bright_blue", etc.
592
+ if color_lower.startswith('bright_'):
593
+ color_base = color_lower[7:] # Remove 'bright_' prefix
594
+ if color_base in color_map:
595
+ return str(base + 60 + color_map[color_base])
596
+
597
+ # Standard color names
598
+ if color_lower in color_map:
599
+ return str(base + color_map[color_lower])
600
+
601
+ # Some terminals use color names like "brown" instead of "yellow"
602
+ color_aliases = {
603
+ 'brown': 3, # yellow
604
+ 'lightgray': 7, 'lightgrey': 7, # white
605
+ 'darkgray': 0, 'darkgrey': 0, # black
606
+ }
607
+ if color_lower in color_aliases:
608
+ return str(base + color_aliases[color_lower])
609
+
610
+ elif isinstance(color, int):
611
+ # 256-color palette (0-255)
612
+ # Note: 0-15 are the basic and bright colors, 16-231 are 216 color cube, 232-255 are grayscale
613
+ if 0 <= color <= 255:
614
+ return f'{"48" if is_background else "38"};5;{color}'
615
+
616
+ elif isinstance(color, tuple) and len(color) == 3:
617
+ # RGB color (true color / 24-bit)
618
+ try:
619
+ r, g, b = color
620
+ # Ensure values are in valid range
621
+ r = max(0, min(255, int(r)))
622
+ g = max(0, min(255, int(g)))
623
+ b = max(0, min(255, int(b)))
624
+ return f'{"48" if is_background else "38"};2;{r};{g};{b}'
625
+ except (ValueError, TypeError):
626
+ logger.warning("Invalid RGB color tuple: %s", color)
627
+ return None
628
+
629
+ # If we got here, we don't recognize the color format
630
+ logger.info("PYTE_COLOR_DEBUG: Unrecognized color format - type: %s, value: %r, is_bg: %s",
631
+ type(color).__name__, color, is_background)
632
+ return None
94
633
 
95
634
  async def reattach_channel(self, new_channel: "Channel") -> None:
96
635
  """Reattach this session to a new channel after reconnection."""
@@ -103,7 +642,7 @@ class TerminalSession:
103
642
  class WindowsTerminalSession(TerminalSession):
104
643
  """Terminal session backed by a Windows ConPTY."""
105
644
 
106
- def __init__(self, session_id: str, pty, channel: "Channel"):
645
+ def __init__(self, session_id: str, pty, channel: "Channel", project_id: Optional[str] = None, terminal_manager: Optional["TerminalManager"] = None):
107
646
  # Create a proxy for the PTY process
108
647
  class _WinPTYProxy:
109
648
  def __init__(self, pty):
@@ -121,7 +660,7 @@ class WindowsTerminalSession(TerminalSession):
121
660
  loop = asyncio.get_running_loop()
122
661
  await loop.run_in_executor(None, self._pty.wait)
123
662
 
124
- super().__init__(session_id, _WinPTYProxy(pty), channel)
663
+ super().__init__(session_id, _WinPTYProxy(pty), channel, project_id, terminal_manager)
125
664
  self._pty = pty
126
665
 
127
666
  async def start_io_forwarding(self) -> None:
@@ -142,13 +681,9 @@ class WindowsTerminalSession(TerminalSession):
142
681
  else:
143
682
  text = data
144
683
  logging.getLogger("portacode.terminal").debug(f"[MUX] Terminal {self.id} output: {text!r}")
145
- self._buffer.append(text)
146
- try:
147
- await self.channel.send(text)
148
- except Exception as exc:
149
- logger.warning("Failed to forward terminal output: %s", exc)
150
- await asyncio.sleep(0.5)
151
- continue
684
+
685
+ # Use rate-limited sending instead of immediate sending
686
+ await self._handle_terminal_data(text)
152
687
  finally:
153
688
  if self._pty and self._pty.isalive():
154
689
  self._pty.kill()
@@ -167,27 +702,74 @@ class WindowsTerminalSession(TerminalSession):
167
702
  logger.warning("Failed to write to terminal %s: %s", self.id, exc)
168
703
 
169
704
  async def stop(self) -> None:
170
- if self._pty.isalive():
171
- self._pty.kill()
172
- if self._reader_task:
173
- await self._reader_task
705
+ """Stop the Windows terminal session with comprehensive logging."""
706
+ logger.info("session.stop: Starting stop process for Windows session %s (PID: %s)",
707
+ self.id, getattr(self._pty, 'pid', 'unknown'))
708
+
709
+ try:
710
+ # Check if PTY is still alive
711
+ if self._pty.isalive():
712
+ logger.info("session.stop: Killing PTY process for session %s", self.id)
713
+ self._pty.kill()
714
+ else:
715
+ logger.info("session.stop: PTY process for session %s already exited", self.id)
716
+
717
+ # Wait for reader task to complete
718
+ if self._reader_task and not self._reader_task.done():
719
+ logger.info("session.stop: Waiting for reader task to complete for Windows session %s", self.id)
720
+ try:
721
+ await asyncio.wait_for(self._reader_task, timeout=5.0)
722
+ logger.info("session.stop: Reader task completed for Windows session %s", self.id)
723
+ except asyncio.TimeoutError:
724
+ logger.warning("session.stop: Reader task timeout for Windows session %s, cancelling", self.id)
725
+ self._reader_task.cancel()
726
+ try:
727
+ await self._reader_task
728
+ except asyncio.CancelledError:
729
+ pass
730
+
731
+ # Cancel and flush any pending terminal data
732
+ if self._debounce_task and not self._debounce_task.done():
733
+ logger.info("session.stop: Cancelling debounce task for Windows session %s", self.id)
734
+ self._debounce_task.cancel()
735
+ try:
736
+ await self._debounce_task
737
+ except asyncio.CancelledError:
738
+ pass
739
+
740
+ # Send any remaining pending data
741
+ if self._pending_data:
742
+ logger.info("session.stop: Flushing pending terminal data for Windows session %s", self.id)
743
+ await self._flush_pending_data()
744
+
745
+ logger.info("session.stop: Successfully stopped Windows session %s", self.id)
746
+
747
+ except Exception as exc:
748
+ logger.exception("session.stop: Error stopping Windows session %s: %s", self.id, exc)
749
+ raise
174
750
 
175
751
 
176
752
  class SessionManager:
177
753
  """Manages terminal sessions."""
178
754
 
179
- def __init__(self, mux):
755
+ def __init__(self, mux, terminal_manager=None):
180
756
  self.mux = mux
757
+ self.terminal_manager = terminal_manager
181
758
  self._sessions: Dict[str, TerminalSession] = {}
759
+ self._link_event_dispatcher = _get_link_event_dispatcher()
760
+ self._link_event_dispatcher.register_callback(self._handle_link_capture_event)
761
+ self._link_event_dispatcher.start()
182
762
 
183
763
  def _allocate_channel_id(self) -> str:
184
764
  """Allocate a new unique channel ID for a terminal session using UUID."""
185
765
  return uuid.uuid4().hex
186
766
 
187
- async def create_session(self, shell: Optional[str] = None, cwd: Optional[str] = None) -> Dict[str, Any]:
767
+ async def create_session(self, shell: Optional[str] = None, cwd: Optional[str] = None, project_id: Optional[str] = None) -> Dict[str, Any]:
188
768
  """Create a new terminal session."""
189
- term_id = uuid.uuid4().hex
190
- channel_id = self._allocate_channel_id()
769
+ # Use the same UUID for both terminal_id and channel_id to ensure consistency
770
+ session_uuid = uuid.uuid4().hex
771
+ term_id = session_uuid
772
+ channel_id = session_uuid
191
773
  channel = self.mux.get_channel(channel_id)
192
774
 
193
775
  # Choose shell - prefer bash over sh for better terminal compatibility
@@ -206,6 +788,27 @@ class SessionManager:
206
788
 
207
789
  logger.info("Launching terminal %s using shell=%s on channel=%s", term_id, shell, channel_id)
208
790
 
791
+ env = _build_child_env()
792
+
793
+ env["PORTACODE_LINK_CHANNEL"] = str(_LINK_EVENT_ROOT)
794
+ env["PORTACODE_TERMINAL_ID"] = term_id
795
+
796
+ bin_dir = prepare_link_capture_bin()
797
+ if bin_dir:
798
+ current_path = env.get("PATH", os.environ.get("PATH", ""))
799
+ path_entries = current_path.split(os.pathsep) if current_path else []
800
+ bin_str = str(bin_dir)
801
+ if bin_str not in path_entries:
802
+ env["PATH"] = os.pathsep.join([bin_str] + path_entries) if path_entries else bin_str
803
+ browser_path = bin_dir / "xdg-open"
804
+ if browser_path.exists():
805
+ original_browser = env.get("BROWSER")
806
+ if original_browser:
807
+ env[LINK_CAPTURE_ORIGINAL_BROWSER_ENV] = original_browser
808
+ elif LINK_CAPTURE_ORIGINAL_BROWSER_ENV in env:
809
+ env.pop(LINK_CAPTURE_ORIGINAL_BROWSER_ENV, None)
810
+ env["BROWSER"] = str(browser_path)
811
+
209
812
  if _IS_WINDOWS:
210
813
  try:
211
814
  from winpty import PtyProcess
@@ -213,13 +816,14 @@ class SessionManager:
213
816
  logger.error("winpty (pywinpty) not found: %s", exc)
214
817
  raise RuntimeError("pywinpty not installed on client")
215
818
 
216
- pty_proc = PtyProcess.spawn(shell, cwd=cwd or None, env=_build_child_env())
217
- session = WindowsTerminalSession(term_id, pty_proc, channel)
819
+ pty_proc = PtyProcess.spawn(shell, cwd=cwd or None, env=env)
820
+ session = WindowsTerminalSession(term_id, pty_proc, channel, project_id, self.terminal_manager)
218
821
  else:
219
822
  # Unix: try real PTY for proper TTY semantics
220
823
  try:
221
824
  import pty
222
825
  master_fd, slave_fd = pty.openpty()
826
+ _configure_pty_window_size(slave_fd, TERMINAL_ROWS, TERMINAL_COLUMNS)
223
827
  proc = await asyncio.create_subprocess_exec(
224
828
  shell,
225
829
  stdin=slave_fd,
@@ -227,7 +831,7 @@ class SessionManager:
227
831
  stderr=slave_fd,
228
832
  preexec_fn=os.setsid,
229
833
  cwd=cwd,
230
- env=_build_child_env(),
834
+ env=env,
231
835
  )
232
836
  # Wrap master_fd into a StreamReader
233
837
  loop = asyncio.get_running_loop()
@@ -235,11 +839,8 @@ class SessionManager:
235
839
  protocol = asyncio.StreamReaderProtocol(reader)
236
840
  await loop.connect_read_pipe(lambda: protocol, os.fdopen(master_fd, "rb", buffering=0))
237
841
  proc.stdout = reader
238
- # Use writer for stdin
239
- writer_transport, writer_protocol = await loop.connect_write_pipe(
240
- lambda: asyncio.Protocol(), os.fdopen(master_fd, "wb", buffering=0)
241
- )
242
- proc.stdin = asyncio.StreamWriter(writer_transport, writer_protocol, reader, loop)
842
+ # Use writer for stdin - create a simple file-like wrapper
843
+ proc.stdin = os.fdopen(master_fd, "wb", buffering=0)
243
844
  except Exception:
244
845
  logger.warning("Failed to allocate PTY, falling back to pipes")
245
846
  proc = await asyncio.create_subprocess_exec(
@@ -248,9 +849,9 @@ class SessionManager:
248
849
  stdout=asyncio.subprocess.PIPE,
249
850
  stderr=asyncio.subprocess.STDOUT,
250
851
  cwd=cwd,
251
- env=_build_child_env(),
852
+ env=env,
252
853
  )
253
- session = TerminalSession(term_id, proc, channel)
854
+ session = TerminalSession(term_id, proc, channel, project_id, self.terminal_manager)
254
855
 
255
856
  self._sessions[term_id] = session
256
857
  await session.start_io_forwarding()
@@ -261,7 +862,40 @@ class SessionManager:
261
862
  "pid": session.proc.pid,
262
863
  "shell": shell,
263
864
  "cwd": cwd,
865
+ "project_id": project_id,
866
+ }
867
+
868
+ async def _handle_link_capture_event(self, payload: Dict[str, Any]) -> None:
869
+ """Translate link capture files into websocket events."""
870
+ link = payload.get("url")
871
+ terminal_id = payload.get("terminal_id")
872
+ if not link:
873
+ logger.debug("session_manager: Ignoring link capture without URL (%s)", payload)
874
+ return
875
+ if not terminal_id:
876
+ logger.warning("session_manager: Link capture missing terminal_id: %s", payload)
877
+ return
878
+ session = self.get_session(terminal_id)
879
+ if not session:
880
+ logger.info("session_manager: No active session for terminal %s, dropping link event", terminal_id)
881
+ return
882
+ if not self.terminal_manager:
883
+ logger.warning("session_manager: No terminal_manager available for link event")
884
+ return
885
+
886
+ event_payload = {
887
+ "event": "terminal_link_request",
888
+ "terminal_id": session.id,
889
+ "channel": getattr(session.channel, "id", session.id),
890
+ "url": link,
891
+ "command": payload.get("command"),
892
+ "args": payload.get("args"),
893
+ "pid": getattr(session.proc, "pid", None),
894
+ "timestamp": payload.get("timestamp"),
895
+ "project_id": session.project_id,
264
896
  }
897
+ logger.info("session_manager: Dispatching link request for terminal %s to clients", terminal_id)
898
+ await self.terminal_manager._send_session_aware(event_payload, project_id=session.project_id)
265
899
 
266
900
  def get_session(self, terminal_id: str) -> Optional[TerminalSession]:
267
901
  """Get a terminal session by ID."""
@@ -269,10 +903,27 @@ class SessionManager:
269
903
 
270
904
  def remove_session(self, terminal_id: str) -> Optional[TerminalSession]:
271
905
  """Remove and return a terminal session."""
272
- return self._sessions.pop(terminal_id, None)
906
+ session = self._sessions.pop(terminal_id, None)
907
+ if session:
908
+ logger.info("session_manager: Removed session %s (PID: %s) from session manager",
909
+ terminal_id, getattr(session.proc, 'pid', 'unknown'))
910
+ else:
911
+ logger.warning("session_manager: Attempted to remove non-existent session %s", terminal_id)
912
+ return session
913
+
914
+ def list_sessions(self, project_id: Optional[str] = None) -> List[Dict[str, Any]]:
915
+ """List all terminal sessions, optionally filtered by project_id."""
916
+ filtered_sessions = []
917
+ for s in self._sessions.values():
918
+ if project_id == "all":
919
+ filtered_sessions.append(s)
920
+ elif project_id is None:
921
+ if s.project_id is None:
922
+ filtered_sessions.append(s)
923
+ else:
924
+ if s.project_id == project_id:
925
+ filtered_sessions.append(s)
273
926
 
274
- def list_sessions(self) -> List[Dict[str, Any]]:
275
- """List all terminal sessions."""
276
927
  return [
277
928
  {
278
929
  "terminal_id": s.id,
@@ -284,8 +935,9 @@ class SessionManager:
284
935
  "created_at": None, # Could add timestamp if needed
285
936
  "shell": None, # Could store shell info if needed
286
937
  "cwd": None, # Could store cwd info if needed
938
+ "project_id": s.project_id,
287
939
  }
288
- for s in self._sessions.values()
940
+ for s in filtered_sessions
289
941
  ]
290
942
 
291
943
  async def reattach_sessions(self, mux):
@@ -312,4 +964,4 @@ class SessionManager:
312
964
  await sess.reattach_channel(new_channel)
313
965
  logger.info("Successfully reattached terminal %s", sess.id)
314
966
  except Exception as exc:
315
- logger.error("Failed to reattach terminal %s: %s", sess.id, exc)
967
+ logger.error("Failed to reattach terminal %s: %s", sess.id, exc)