portacode 0.3.16.dev10__py3-none-any.whl → 1.4.11.dev1__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (92) hide show
  1. portacode/_version.py +16 -3
  2. portacode/cli.py +143 -17
  3. portacode/connection/client.py +149 -10
  4. portacode/connection/handlers/WEBSOCKET_PROTOCOL.md +928 -42
  5. portacode/connection/handlers/__init__.py +34 -5
  6. portacode/connection/handlers/base.py +78 -16
  7. portacode/connection/handlers/chunked_content.py +244 -0
  8. portacode/connection/handlers/diff_handlers.py +603 -0
  9. portacode/connection/handlers/file_handlers.py +902 -17
  10. portacode/connection/handlers/project_aware_file_handlers.py +226 -0
  11. portacode/connection/handlers/project_state/README.md +312 -0
  12. portacode/connection/handlers/project_state/__init__.py +92 -0
  13. portacode/connection/handlers/project_state/file_system_watcher.py +179 -0
  14. portacode/connection/handlers/project_state/git_manager.py +1502 -0
  15. portacode/connection/handlers/project_state/handlers.py +875 -0
  16. portacode/connection/handlers/project_state/manager.py +1331 -0
  17. portacode/connection/handlers/project_state/models.py +108 -0
  18. portacode/connection/handlers/project_state/utils.py +50 -0
  19. portacode/connection/handlers/project_state_handlers.py +45 -948
  20. portacode/connection/handlers/proxmox_infra.py +361 -0
  21. portacode/connection/handlers/registry.py +15 -4
  22. portacode/connection/handlers/session.py +483 -32
  23. portacode/connection/handlers/system_handlers.py +147 -8
  24. portacode/connection/handlers/tab_factory.py +389 -0
  25. portacode/connection/handlers/terminal_handlers.py +21 -8
  26. portacode/connection/handlers/update_handler.py +61 -0
  27. portacode/connection/multiplex.py +60 -2
  28. portacode/connection/terminal.py +256 -17
  29. portacode/keypair.py +63 -1
  30. portacode/link_capture/__init__.py +38 -0
  31. portacode/link_capture/__pycache__/__init__.cpython-311.pyc +0 -0
  32. portacode/link_capture/bin/__pycache__/link_capture_wrapper.cpython-311.pyc +0 -0
  33. portacode/link_capture/bin/elinks +3 -0
  34. portacode/link_capture/bin/gio-open +3 -0
  35. portacode/link_capture/bin/gnome-open +3 -0
  36. portacode/link_capture/bin/gvfs-open +3 -0
  37. portacode/link_capture/bin/kde-open +3 -0
  38. portacode/link_capture/bin/kfmclient +3 -0
  39. portacode/link_capture/bin/link_capture_exec.sh +11 -0
  40. portacode/link_capture/bin/link_capture_wrapper.py +75 -0
  41. portacode/link_capture/bin/links +3 -0
  42. portacode/link_capture/bin/links2 +3 -0
  43. portacode/link_capture/bin/lynx +3 -0
  44. portacode/link_capture/bin/mate-open +3 -0
  45. portacode/link_capture/bin/netsurf +3 -0
  46. portacode/link_capture/bin/sensible-browser +3 -0
  47. portacode/link_capture/bin/w3m +3 -0
  48. portacode/link_capture/bin/x-www-browser +3 -0
  49. portacode/link_capture/bin/xdg-open +3 -0
  50. portacode/logging_categories.py +140 -0
  51. portacode/pairing.py +103 -0
  52. portacode/static/js/test-ntp-clock.html +63 -0
  53. portacode/static/js/utils/ntp-clock.js +232 -0
  54. portacode/utils/NTP_ARCHITECTURE.md +136 -0
  55. portacode/utils/__init__.py +1 -0
  56. portacode/utils/diff_apply.py +456 -0
  57. portacode/utils/diff_renderer.py +371 -0
  58. portacode/utils/ntp_clock.py +65 -0
  59. portacode-1.4.11.dev1.dist-info/METADATA +298 -0
  60. portacode-1.4.11.dev1.dist-info/RECORD +97 -0
  61. {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info}/WHEEL +1 -1
  62. portacode-1.4.11.dev1.dist-info/top_level.txt +3 -0
  63. test_modules/README.md +296 -0
  64. test_modules/__init__.py +1 -0
  65. test_modules/test_device_online.py +44 -0
  66. test_modules/test_file_operations.py +743 -0
  67. test_modules/test_git_status_ui.py +370 -0
  68. test_modules/test_login_flow.py +50 -0
  69. test_modules/test_navigate_testing_folder.py +361 -0
  70. test_modules/test_play_store_screenshots.py +294 -0
  71. test_modules/test_terminal_buffer_performance.py +261 -0
  72. test_modules/test_terminal_interaction.py +80 -0
  73. test_modules/test_terminal_loading_race_condition.py +95 -0
  74. test_modules/test_terminal_start.py +56 -0
  75. testing_framework/.env.example +21 -0
  76. testing_framework/README.md +334 -0
  77. testing_framework/__init__.py +17 -0
  78. testing_framework/cli.py +326 -0
  79. testing_framework/core/__init__.py +1 -0
  80. testing_framework/core/base_test.py +336 -0
  81. testing_framework/core/cli_manager.py +177 -0
  82. testing_framework/core/hierarchical_runner.py +577 -0
  83. testing_framework/core/playwright_manager.py +520 -0
  84. testing_framework/core/runner.py +447 -0
  85. testing_framework/core/shared_cli_manager.py +234 -0
  86. testing_framework/core/test_discovery.py +112 -0
  87. testing_framework/requirements.txt +12 -0
  88. portacode-0.3.16.dev10.dist-info/METADATA +0 -238
  89. portacode-0.3.16.dev10.dist-info/RECORD +0 -29
  90. portacode-0.3.16.dev10.dist-info/top_level.txt +0 -1
  91. {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info}/entry_points.txt +0 -0
  92. {portacode-0.3.16.dev10.dist-info → portacode-1.4.11.dev1.dist-info/licenses}/LICENSE +0 -0
@@ -1,17 +1,155 @@
1
1
  """System command handlers."""
2
2
 
3
+ import concurrent.futures
4
+ import getpass
5
+ import importlib.util
3
6
  import logging
4
7
  import os
5
8
  import platform
9
+ import shutil
10
+ import threading
6
11
  from pathlib import Path
7
12
  from typing import Any, Dict
13
+
8
14
  from portacode import __version__
9
15
  import psutil
10
16
 
17
+ try:
18
+ from importlib import metadata as importlib_metadata
19
+ except ImportError: # pragma: no cover - py<3.8
20
+ import importlib_metadata
21
+
11
22
  from .base import SyncHandler
23
+ from .proxmox_infra import get_infra_snapshot
12
24
 
13
25
  logger = logging.getLogger(__name__)
14
26
 
27
+ # Global CPU monitoring
28
+ _cpu_percent = 0.0
29
+ _cpu_thread = None
30
+ _cpu_lock = threading.Lock()
31
+
32
+ def _cpu_monitor():
33
+ """Background thread to update CPU usage every 5 seconds."""
34
+ global _cpu_percent
35
+ while True:
36
+ _cpu_percent = psutil.cpu_percent(interval=5.0)
37
+
38
+ def _ensure_cpu_thread():
39
+ """Ensure CPU monitoring thread is running (singleton)."""
40
+ global _cpu_thread
41
+ with _cpu_lock:
42
+ if _cpu_thread is None or not _cpu_thread.is_alive():
43
+ _cpu_thread = threading.Thread(target=_cpu_monitor, daemon=True)
44
+ _cpu_thread.start()
45
+
46
+
47
+ def _get_user_context() -> Dict[str, Any]:
48
+ """Gather current CLI user plus permission hints."""
49
+ context = {}
50
+ login_source = "os.getlogin"
51
+ try:
52
+ username = os.getlogin()
53
+ except Exception:
54
+ login_source = "getpass"
55
+ username = getpass.getuser()
56
+
57
+ context["username"] = username
58
+ context["username_source"] = login_source
59
+ context["home"] = str(Path.home())
60
+
61
+ uid = getattr(os, "getuid", None)
62
+ euid = getattr(os, "geteuid", None)
63
+ context["uid"] = uid() if uid else None
64
+ context["euid"] = euid() if euid else context["uid"]
65
+ if os.name == "nt":
66
+ try:
67
+ import ctypes
68
+
69
+ context["is_root"] = bool(ctypes.windll.shell32.IsUserAnAdmin())
70
+ except Exception:
71
+ context["is_root"] = None
72
+ else:
73
+ context["is_root"] = context["euid"] == 0 if context["euid"] is not None else False
74
+
75
+ context["has_sudo"] = shutil.which("sudo") is not None
76
+ context["sudo_user"] = os.environ.get("SUDO_USER")
77
+ context["is_sudo_session"] = bool(os.environ.get("SUDO_UID"))
78
+ return context
79
+
80
+
81
+ def _get_playwright_info() -> Dict[str, Any]:
82
+ """Return Playwright presence, version, and browser binaries if available."""
83
+ result: Dict[str, Any] = {
84
+ "installed": False,
85
+ "version": None,
86
+ "browsers": {},
87
+ "error": None,
88
+ }
89
+
90
+ if importlib.util.find_spec("playwright") is None:
91
+ return result
92
+
93
+ result["installed"] = True
94
+ try:
95
+ result["version"] = importlib_metadata.version("playwright")
96
+ except Exception as exc:
97
+ logger.debug("Unable to read Playwright version metadata: %s", exc)
98
+
99
+ def _inspect_browsers() -> Dict[str, Any]:
100
+ from playwright.sync_api import sync_playwright
101
+
102
+ browsers_data: Dict[str, Any] = {}
103
+ with sync_playwright() as p:
104
+ for name in ("chromium", "firefox", "webkit"):
105
+ browser_type = getattr(p, name, None)
106
+ if browser_type is None:
107
+ continue
108
+ exec_path = getattr(browser_type, "executable_path", None)
109
+ browsers_data[name] = {
110
+ "available": bool(exec_path),
111
+ "executable_path": exec_path,
112
+ }
113
+ return browsers_data
114
+
115
+ try:
116
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
117
+ future = executor.submit(_inspect_browsers)
118
+ browsers = future.result(timeout=5)
119
+ result["browsers"] = browsers
120
+ except concurrent.futures.TimeoutError:
121
+ msg = "Playwright inspection timed out"
122
+ logger.warning(msg)
123
+ result["error"] = msg
124
+ except Exception as exc:
125
+ logger.warning("Playwright browser inspection failed: %s", exc)
126
+ result["error"] = str(exc)
127
+
128
+ return result
129
+
130
+
131
+ def _get_proxmox_info() -> Dict[str, Any]:
132
+ """Detect if the current host is a Proxmox node."""
133
+ info: Dict[str, Any] = {"is_proxmox_node": False, "version": None}
134
+ release_file = Path("/etc/proxmox-release")
135
+ if release_file.exists():
136
+ info["is_proxmox_node"] = True
137
+ try:
138
+ info["version"] = release_file.read_text().strip()
139
+ except Exception:
140
+ info["version"] = None
141
+ elif Path("/etc/pve").exists():
142
+ info["is_proxmox_node"] = True
143
+ if not info["version"]:
144
+ version_hint = Path("/etc/pve/.version")
145
+ if version_hint.exists():
146
+ try:
147
+ info["version"] = version_hint.read_text().strip()
148
+ except Exception:
149
+ info["version"] = None
150
+ info["infra"] = get_infra_snapshot()
151
+ return info
152
+
15
153
 
16
154
  def _get_os_info() -> Dict[str, Any]:
17
155
  """Get operating system information with robust error handling."""
@@ -98,15 +236,13 @@ class SystemInfoHandler(SyncHandler):
98
236
  """Get system information including OS details."""
99
237
  logger.debug("Collecting system information...")
100
238
 
239
+ # Ensure CPU monitoring thread is running
240
+ _ensure_cpu_thread()
241
+
101
242
  # Collect basic system metrics
102
243
  info = {}
103
244
 
104
- try:
105
- info["cpu_percent"] = psutil.cpu_percent(interval=0.1)
106
- logger.debug("CPU usage: %s%%", info["cpu_percent"])
107
- except Exception as e:
108
- logger.warning("Failed to get CPU info: %s", e)
109
- info["cpu_percent"] = 0.0
245
+ info["cpu_percent"] = _cpu_percent
110
246
 
111
247
  try:
112
248
  info["memory"] = psutil.virtual_memory()._asdict()
@@ -124,11 +260,14 @@ class SystemInfoHandler(SyncHandler):
124
260
 
125
261
  # Add OS information - this is critical for proper shell detection
126
262
  info["os_info"] = _get_os_info()
127
- logger.info("System info collected successfully with OS info: %s", info.get("os_info", {}).get("os_type", "Unknown"))
263
+ info["user_context"] = _get_user_context()
264
+ info["playwright"] = _get_playwright_info()
265
+ info["proxmox"] = _get_proxmox_info()
266
+ # logger.info("System info collected successfully with OS info: %s", info.get("os_info", {}).get("os_type", "Unknown"))
128
267
 
129
268
  info["portacode_version"] = __version__
130
269
 
131
270
  return {
132
271
  "event": "system_info",
133
272
  "info": info,
134
- }
273
+ }
@@ -0,0 +1,389 @@
1
+ """Tab factory for creating TabInfo objects with appropriate content loading.
2
+
3
+ This module provides a centralized way to create tabs for different file types,
4
+ handling content loading, MIME type detection, and encoding appropriately.
5
+ """
6
+
7
+ import asyncio
8
+ import base64
9
+ import logging
10
+ import mimetypes
11
+ import os
12
+ import uuid
13
+ from pathlib import Path
14
+ from typing import Optional, Dict, Any
15
+
16
+ from .project_state_handlers import TabInfo
17
+ from .project_state.utils import generate_content_hash
18
+ from .file_handlers import cache_content
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # Maximum file size for text content loading (10MB)
23
+ MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024
24
+
25
+ # Maximum file size for binary content loading (50MB)
26
+ MAX_BINARY_FILE_SIZE = 50 * 1024 * 1024
27
+
28
+ # Text file extensions that should be treated as code/text
29
+ TEXT_EXTENSIONS = {
30
+ # Programming languages
31
+ '.py', '.js', '.ts', '.jsx', '.tsx', '.html', '.htm', '.css', '.scss', '.sass',
32
+ '.json', '.xml', '.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf',
33
+ '.java', '.c', '.cpp', '.cc', '.cxx', '.h', '.hpp', '.cs', '.php', '.rb',
34
+ '.go', '.rs', '.kt', '.swift', '.dart', '.scala', '.clj', '.hs', '.ml',
35
+ '.r', '.m', '.pl', '.lua', '.sh', '.bash', '.zsh', '.fish', '.ps1', '.bat',
36
+ '.sql', '.graphql', '.proto', '.thrift',
37
+
38
+ # Markup and documentation
39
+ '.md', '.markdown', '.rst', '.txt', '.rtf', '.tex', '.latex',
40
+ '.adoc', '.asciidoc', '.org',
41
+
42
+ # Configuration and data
43
+ '.env', '.gitignore', '.gitattributes', '.dockerignore', '.editorconfig',
44
+ '.eslintrc', '.prettierrc', '.babelrc', '.tsconfig', '.package-lock',
45
+ '.requirements', '.pipfile', '.gemfile', '.makefile', '.cmake',
46
+
47
+ # Web technologies
48
+ '.vue', '.svelte', '.astro', '.ejs', '.hbs', '.handlebars', '.mustache',
49
+ '.pug', '.jade', '.haml', '.slim',
50
+
51
+ # Other text formats
52
+ '.log', '.diff', '.patch', '.csv', '.tsv', '.properties'
53
+ }
54
+
55
+ # Binary file extensions that should be treated as media
56
+ MEDIA_EXTENSIONS = {
57
+ # Images
58
+ '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.svg',
59
+ '.ico', '.icns', '.cur', '.psd', '.ai', '.eps', '.raw', '.cr2', '.nef',
60
+
61
+ # Audio
62
+ '.mp3', '.wav', '.flac', '.aac', '.ogg', '.oga', '.wma', '.m4a', '.opus', '.webm',
63
+
64
+ # Video
65
+ '.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.3gp',
66
+ '.ogv', '.ts', '.mts', '.m2ts'
67
+ }
68
+
69
+ # Extensions that should be ignored/not loaded
70
+ IGNORED_EXTENSIONS = {
71
+ '.exe', '.dll', '.so', '.dylib', '.bin', '.dat', '.cache', '.tmp', '.temp',
72
+ '.lock', '.pid', '.swp', '.swo', '.bak', '.orig', '.pyc', '.pyo', '.class',
73
+ '.o', '.obj', '.lib', '.a', '.jar', '.war', '.ear', '.zip', '.tar', '.gz',
74
+ '.7z', '.rar', '.deb', '.rpm', '.dmg', '.iso', '.img'
75
+ }
76
+
77
+
78
+ class TabFactory:
79
+ """Factory class for creating TabInfo objects with appropriate content."""
80
+
81
+ def __init__(self):
82
+ self.logger = logger.getChild(self.__class__.__name__)
83
+
84
+ async def create_file_tab(self, file_path: str, tab_id: Optional[str] = None) -> TabInfo:
85
+ """Create a file tab with content loaded based on file type.
86
+
87
+ Args:
88
+ file_path: Absolute path to the file
89
+ tab_id: Optional tab ID, will generate UUID if not provided
90
+
91
+ Returns:
92
+ TabInfo object with appropriate content loaded
93
+ """
94
+ if tab_id is None:
95
+ tab_id = str(uuid.uuid4())
96
+
97
+ file_path = Path(file_path)
98
+
99
+ # Basic tab info
100
+ tab_info = {
101
+ 'tab_id': tab_id,
102
+ 'tab_type': 'file',
103
+ 'title': file_path.name,
104
+ 'file_path': str(file_path),
105
+ 'content': None,
106
+ 'original_content': None,
107
+ 'modified_content': None,
108
+ 'is_dirty': False,
109
+ 'mime_type': None,
110
+ 'encoding': None,
111
+ 'metadata': {}
112
+ }
113
+
114
+ # Check if file exists
115
+ if not file_path.exists():
116
+ self.logger.warning(f"File does not exist: {file_path}")
117
+ tab_info['metadata']['error'] = 'File not found'
118
+ return TabInfo(**tab_info)
119
+
120
+ # Check if it's a file (not directory)
121
+ if not file_path.is_file():
122
+ self.logger.warning(f"Path is not a file: {file_path}")
123
+ tab_info['metadata']['error'] = 'Not a file'
124
+ return TabInfo(**tab_info)
125
+
126
+ # Get file info
127
+ try:
128
+ file_stat = file_path.stat()
129
+ file_size = file_stat.st_size
130
+ tab_info['metadata']['size'] = file_size
131
+ tab_info['metadata']['modified_time'] = file_stat.st_mtime
132
+ except OSError as e:
133
+ self.logger.error(f"Error getting file info for {file_path}: {e}")
134
+ tab_info['metadata']['error'] = f'Cannot access file: {e}'
135
+ return TabInfo(**tab_info)
136
+
137
+ # Determine file type and MIME type
138
+ extension = file_path.suffix.lower()
139
+ mime_type, _ = mimetypes.guess_type(str(file_path))
140
+ tab_info['mime_type'] = mime_type
141
+
142
+ # Determine how to handle the file
143
+ if extension in IGNORED_EXTENSIONS:
144
+ tab_info['metadata']['ignored'] = True
145
+ content = f"# Binary file not displayed\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}"
146
+ tab_info['content'] = content
147
+ content_hash = generate_content_hash(content)
148
+ tab_info['content_hash'] = content_hash
149
+ cache_content(content_hash, content)
150
+ return TabInfo(**tab_info)
151
+
152
+ # Handle different file types
153
+ if extension in TEXT_EXTENSIONS or self._is_text_file(file_path, mime_type):
154
+ await self._load_text_content(file_path, tab_info, file_size)
155
+ elif extension in MEDIA_EXTENSIONS or (mime_type and mime_type.startswith(('image/', 'audio/', 'video/'))):
156
+ await self._load_media_content(file_path, tab_info, file_size, mime_type)
157
+ else:
158
+ # Try to detect if it's a text file by sampling
159
+ if await self._detect_text_file(file_path):
160
+ await self._load_text_content(file_path, tab_info, file_size)
161
+ else:
162
+ await self._load_binary_content(file_path, tab_info, file_size)
163
+
164
+ return TabInfo(**tab_info)
165
+
166
+ async def create_diff_tab_with_title(self, file_path: str, original_content: str,
167
+ modified_content: str, title: str,
168
+ tab_id: Optional[str] = None,
169
+ diff_details: Optional[Dict[str, Any]] = None) -> TabInfo:
170
+ """Create a diff tab with a custom title for git timeline comparisons.
171
+
172
+ Args:
173
+ file_path: Path to the file being compared
174
+ original_content: Original version of the file
175
+ modified_content: Modified version of the file
176
+ title: Custom title for the diff tab
177
+ tab_id: Optional tab ID, will generate UUID if not provided
178
+ diff_details: Optional detailed diff information from diff-match-patch
179
+
180
+ Returns:
181
+ TabInfo object configured for diff viewing with custom title
182
+ """
183
+ if tab_id is None:
184
+ tab_id = str(uuid.uuid4())
185
+
186
+ metadata = {'diff_mode': True, 'timeline_diff': True}
187
+ if diff_details:
188
+ metadata['diff_details'] = diff_details
189
+
190
+ # Cache diff content
191
+ original_hash = generate_content_hash(original_content)
192
+ modified_hash = generate_content_hash(modified_content)
193
+ cache_content(original_hash, original_content)
194
+ cache_content(modified_hash, modified_content)
195
+
196
+ return TabInfo(
197
+ tab_id=tab_id,
198
+ tab_type='diff',
199
+ title=title,
200
+ file_path=str(file_path),
201
+ content=None, # Diff tabs don't use regular content
202
+ original_content=original_content,
203
+ modified_content=modified_content,
204
+ original_content_hash=original_hash,
205
+ modified_content_hash=modified_hash,
206
+ is_dirty=False,
207
+ mime_type=None,
208
+ encoding='utf-8',
209
+ metadata=metadata
210
+ )
211
+
212
+ async def create_untitled_tab(self, content: str = "", language: str = "plaintext",
213
+ tab_id: Optional[str] = None) -> TabInfo:
214
+ """Create an untitled tab for new content.
215
+
216
+ Args:
217
+ content: Initial content for the tab
218
+ language: Programming language for syntax highlighting
219
+ tab_id: Optional tab ID, will generate UUID if not provided
220
+
221
+ Returns:
222
+ TabInfo object for untitled content
223
+ """
224
+ if tab_id is None:
225
+ tab_id = str(uuid.uuid4())
226
+
227
+ # Cache untitled content
228
+ content_hash = generate_content_hash(content)
229
+ cache_content(content_hash, content)
230
+
231
+ return TabInfo(
232
+ tab_id=tab_id,
233
+ tab_type='untitled',
234
+ title="Untitled",
235
+ file_path=None,
236
+ content=content,
237
+ content_hash=content_hash,
238
+ original_content=None,
239
+ modified_content=None,
240
+ is_dirty=bool(content), # Dirty if has initial content
241
+ mime_type=None,
242
+ encoding='utf-8',
243
+ metadata={'language': language}
244
+ )
245
+
246
+ async def _load_text_content(self, file_path: Path, tab_info: Dict[str, Any], file_size: int):
247
+ """Load text content from file."""
248
+ if file_size > MAX_TEXT_FILE_SIZE:
249
+ content = f"# File too large to display\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}\n# Maximum size for text files: {self._format_file_size(MAX_TEXT_FILE_SIZE)}"
250
+ tab_info['content'] = content
251
+ content_hash = generate_content_hash(content)
252
+ tab_info['content_hash'] = content_hash
253
+ cache_content(content_hash, content)
254
+ tab_info['metadata']['truncated'] = True
255
+ return
256
+
257
+ try:
258
+ # Try different encodings
259
+ for encoding in ['utf-8', 'utf-16', 'latin-1', 'cp1252']:
260
+ try:
261
+ content = file_path.read_text(encoding=encoding)
262
+ tab_info['content'] = content
263
+ content_hash = generate_content_hash(content)
264
+ tab_info['content_hash'] = content_hash
265
+ cache_content(content_hash, content)
266
+ tab_info['encoding'] = encoding
267
+ self.logger.debug(f"Successfully loaded {file_path} with {encoding} encoding")
268
+ return
269
+ except UnicodeDecodeError:
270
+ continue
271
+
272
+ # If all encodings fail, treat as binary
273
+ self.logger.warning(f"Could not decode {file_path} as text, treating as binary")
274
+ await self._load_binary_content(file_path, tab_info, file_size)
275
+
276
+ except OSError as e:
277
+ self.logger.error(f"Error reading file {file_path}: {e}")
278
+ content = f"# Error reading file\n# {e}"
279
+ tab_info['content'] = content
280
+ content_hash = generate_content_hash(content)
281
+ tab_info['content_hash'] = content_hash
282
+ cache_content(content_hash, content)
283
+ tab_info['metadata']['error'] = str(e)
284
+
285
+ async def _load_media_content(self, file_path: Path, tab_info: Dict[str, Any],
286
+ file_size: int, mime_type: Optional[str]):
287
+ """Load media content as base64."""
288
+ if file_size > MAX_BINARY_FILE_SIZE:
289
+ content = f"# Media file too large to display\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}"
290
+ tab_info['content'] = content
291
+ content_hash = generate_content_hash(content)
292
+ tab_info['content_hash'] = content_hash
293
+ cache_content(content_hash, content)
294
+ tab_info['metadata']['too_large'] = True
295
+ return
296
+
297
+ try:
298
+ # Determine tab type based on MIME type
299
+ if mime_type:
300
+ if mime_type.startswith('image/'):
301
+ tab_info['tab_type'] = 'image'
302
+ elif mime_type.startswith('audio/'):
303
+ tab_info['tab_type'] = 'audio'
304
+ elif mime_type.startswith('video/'):
305
+ tab_info['tab_type'] = 'video'
306
+
307
+ # Read file as binary and encode as base64
308
+ binary_content = file_path.read_bytes()
309
+ base64_content = base64.b64encode(binary_content).decode('ascii')
310
+
311
+ tab_info['content'] = base64_content
312
+ content_hash = generate_content_hash(base64_content)
313
+ tab_info['content_hash'] = content_hash
314
+ cache_content(content_hash, base64_content)
315
+ tab_info['encoding'] = 'base64'
316
+ tab_info['metadata']['original_size'] = file_size
317
+
318
+ self.logger.debug(f"Loaded media file {file_path} as base64 ({file_size} bytes)")
319
+
320
+ except OSError as e:
321
+ self.logger.error(f"Error reading media file {file_path}: {e}")
322
+ content = f"# Error loading media file\n# {e}"
323
+ tab_info['content'] = content
324
+ content_hash = generate_content_hash(content)
325
+ tab_info['content_hash'] = content_hash
326
+ cache_content(content_hash, content)
327
+ tab_info['metadata']['error'] = str(e)
328
+
329
+ async def _load_binary_content(self, file_path: Path, tab_info: Dict[str, Any], file_size: int):
330
+ """Handle binary files that can't be displayed."""
331
+ content = f"# Binary file\n# File: {file_path.name}\n# Size: {self._format_file_size(file_size)}\n# Type: {tab_info.get('mime_type', 'Unknown')}\n\n# This file contains binary data and cannot be displayed as text."
332
+ tab_info['content'] = content
333
+ content_hash = generate_content_hash(content)
334
+ tab_info['content_hash'] = content_hash
335
+ cache_content(content_hash, content)
336
+ tab_info['metadata']['binary'] = True
337
+ self.logger.debug(f"Marked {file_path} as binary file")
338
+
339
+ def _is_text_file(self, file_path: Path, mime_type: Optional[str]) -> bool:
340
+ """Check if a file should be treated as text based on MIME type."""
341
+ if not mime_type:
342
+ return False
343
+
344
+ return (mime_type.startswith('text/') or
345
+ mime_type in ['application/json', 'application/xml', 'application/javascript',
346
+ 'application/typescript', 'application/x-python', 'application/x-sh'])
347
+
348
+ async def _detect_text_file(self, file_path: Path) -> bool:
349
+ """Try to detect if a file is text by sampling the beginning."""
350
+ try:
351
+ # Read first 1024 bytes
352
+ with open(file_path, 'rb') as f:
353
+ sample = f.read(1024)
354
+
355
+ # Check for null bytes (strong indicator of binary)
356
+ if b'\x00' in sample:
357
+ return False
358
+
359
+ # Try to decode as UTF-8
360
+ try:
361
+ sample.decode('utf-8')
362
+ return True
363
+ except UnicodeDecodeError:
364
+ return False
365
+
366
+ except OSError:
367
+ return False
368
+
369
+ def _format_file_size(self, size_bytes: int) -> str:
370
+ """Format file size in human-readable format."""
371
+ if size_bytes < 1024:
372
+ return f"{size_bytes} B"
373
+ elif size_bytes < 1024 ** 2:
374
+ return f"{size_bytes / 1024:.1f} KB"
375
+ elif size_bytes < 1024 ** 3:
376
+ return f"{size_bytes / (1024 ** 2):.1f} MB"
377
+ else:
378
+ return f"{size_bytes / (1024 ** 3):.1f} GB"
379
+
380
+
381
+ # Global factory instance
382
+ _tab_factory = None
383
+
384
+ def get_tab_factory() -> TabFactory:
385
+ """Get the global tab factory instance."""
386
+ global _tab_factory
387
+ if _tab_factory is None:
388
+ _tab_factory = TabFactory()
389
+ return _tab_factory
@@ -218,24 +218,33 @@ class TerminalListHandler(AsyncHandler):
218
218
 
219
219
  async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
220
220
  """Handle the command by executing it and sending the response to the requesting client session."""
221
- logger.info("handler: Processing command %s with reply_channel=%s",
221
+ logger.info("handler: Processing command %s with reply_channel=%s",
222
222
  self.command_name, reply_channel)
223
-
223
+
224
224
  try:
225
225
  response = await self.execute(message)
226
226
  logger.info("handler: Command %s executed successfully", self.command_name)
227
-
227
+
228
+ # Automatically copy request_id if present in the incoming message
229
+ if "request_id" in message and "request_id" not in response:
230
+ response["request_id"] = message["request_id"]
231
+
228
232
  # Get the source client session from the message
229
233
  source_client_session = message.get("source_client_session")
230
234
  project_id = response.get("project_id")
231
-
232
- logger.info("handler: %s response project_id=%s, source_client_session=%s",
235
+
236
+ logger.info("handler: %s response project_id=%s, source_client_session=%s",
233
237
  self.command_name, project_id, source_client_session)
234
-
238
+
235
239
  # Send response only to the requesting client session
236
240
  if source_client_session:
237
241
  # Add client_sessions field to target only the requesting session
238
242
  response["client_sessions"] = [source_client_session]
243
+
244
+ import json
245
+ logger.info("handler: 📤 SENDING EVENT '%s' (via direct control_channel.send)", response.get("event", "unknown"))
246
+ logger.info("handler: 📤 FULL EVENT PAYLOAD: %s", json.dumps(response, indent=2, default=str))
247
+
239
248
  await self.control_channel.send(response)
240
249
  else:
241
250
  # Fallback to original behavior if no source_client_session
@@ -249,15 +258,19 @@ class TerminalListHandler(AsyncHandler):
249
258
  session_manager = self.context.get("session_manager")
250
259
  if not session_manager:
251
260
  raise RuntimeError("Session manager not available")
252
-
261
+
253
262
  # Accept project_id argument: None (default) = only no project, 'all' = all, else = filter by project_id
254
263
  requested_project_id = message.get("project_id")
264
+ logger.info("terminal_list: requested_project_id=%r (type: %s)", requested_project_id, type(requested_project_id))
255
265
 
256
266
  if requested_project_id == "all":
267
+ logger.info("terminal_list: Using 'all' mode to list all terminals")
257
268
  sessions = session_manager.list_sessions(project_id="all")
258
269
  else:
270
+ logger.info("terminal_list: Filtering by project_id=%r", requested_project_id)
259
271
  sessions = session_manager.list_sessions(project_id=requested_project_id)
260
-
272
+
273
+ logger.info("terminal_list: Found %d sessions, returning with project_id=%r", len(sessions), requested_project_id)
261
274
  return {
262
275
  "event": "terminal_list",
263
276
  "sessions": sessions,