cct-cli 0.7.9.0__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.
Files changed (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,494 @@
1
+ """CAT browser/ — the REAL browser engine: Chromium via Playwright.
2
+
3
+ A dedicated worker thread owns the Playwright sync API (its only
4
+ supported mode outside asyncio). Every public call marshals a job onto
5
+ that thread and waits for the result, so callers (UI workers) never
6
+ touch Playwright from the wrong thread and never block the UI.
7
+
8
+ What runs is genuine Chromium: HTML, CSS, JavaScript, animations,
9
+ responsive layout, images, SVG, fonts, fetch, WebSockets — everything
10
+ the real engine supports. The terminal pane renders a structured
11
+ snapshot of the LIVE DOM (see dom_outline) plus console/errors, which
12
+ keeps CAT terminal-first while the actual rendering work happens in
13
+ Chromium. The architecture keeps a screenshot hook so a graphical
14
+ embedded surface can be added later without touching callers.
15
+
16
+ If playwright/chromium isn't installed, start() raises
17
+ BrowserUnavailableError with the exact fix; nothing else in CAT breaks.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import threading
23
+ import time
24
+ import queue as _queue
25
+ from dataclasses import dataclass, field
26
+ from typing import Callable, List, Optional
27
+
28
+ from .state import PreviewSnapshot
29
+
30
+ try:
31
+ from playwright.sync_api import sync_playwright # noqa: F401
32
+ PLAYWRIGHT_AVAILABLE = True
33
+ except Exception:
34
+ PLAYWRIGHT_AVAILABLE = False
35
+
36
+
37
+ class BrowserUnavailableError(RuntimeError):
38
+ pass
39
+
40
+
41
+ _OUTLINE_JS = """
42
+ () => {
43
+ const out = [];
44
+ const push = (tag, cls, text, extra) => {
45
+ text = (text || '').replace(/\\s+/g, ' ').trim();
46
+ if (!text && !extra) return;
47
+ out.push({t: tag, c: cls || '', x: text.slice(0, 160), e: extra || ''});
48
+ };
49
+ document.querySelectorAll(
50
+ 'h1,h2,h3,h4,p,li,a,button,input,textarea,select,img,section,nav,footer,header'
51
+ ).forEach(el => {
52
+ if (el.offsetParent === null && el.tagName !== 'INPUT') {
53
+ // skip hidden nodes but keep form fields (they have no offsetParent)
54
+ const st = getComputedStyle(el);
55
+ if (st.display === 'none' || st.visibility === 'hidden') return;
56
+ }
57
+ const tag = el.tagName.toLowerCase();
58
+ if (tag === 'img') {
59
+ const ok = el.complete && el.naturalWidth > 0;
60
+ push('img', '', el.alt || el.src.split('/').pop(), ok ? '' : 'BROKEN');
61
+ return;
62
+ }
63
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') {
64
+ push(tag, el.type || '', el.placeholder ||
65
+ el.getAttribute('aria-label') || tag.toUpperCase(), '');
66
+ return;
67
+ }
68
+ let text = '';
69
+ if (tag === 'a' || tag === 'button') text = el.textContent;
70
+ else if (tag === 'li') text = el.textContent;
71
+ else text = el.textContent;
72
+ push(tag, el.className && String(el.className).slice(0, 40),
73
+ text,
74
+ tag === 'a' ? (el.getAttribute('href') || '').slice(0, 80) : '');
75
+ });
76
+ return out.slice(0, 400);
77
+ }
78
+ """
79
+
80
+ _COUNTS_JS = """
81
+ () => ({
82
+ elements: document.querySelectorAll('*').length,
83
+ imagesLoaded: Array.from(document.images)
84
+ .filter(i => i.complete && i.naturalWidth > 0).length,
85
+ imagesBroken: Array.from(document.images)
86
+ .filter(i => i.complete && i.naturalWidth === 0).length,
87
+ })
88
+ """
89
+
90
+
91
+ @dataclass
92
+ class _Job:
93
+ fn: Callable
94
+ result: list = field(default_factory=list)
95
+ done: threading.Event = field(default_factory=threading.Event)
96
+
97
+
98
+ class BrowserEngine:
99
+ """Chromium worker. All calls are synchronous from the caller's
100
+ perspective and executed on the engine's own thread."""
101
+
102
+ def __init__(self, headless: bool = True):
103
+ self._headless = headless
104
+ self._queue: "_queue.Queue[Optional[_Job]]" = _queue.Queue()
105
+ self._thread: Optional[threading.Thread] = None
106
+ self._ready = threading.Event()
107
+ self._stop = False
108
+ self._pw = None
109
+ self._browser = None
110
+ self._context = None
111
+ self._page = None
112
+ self.console: List[str] = []
113
+ self.js_errors: List[str] = []
114
+ self.last_status: Optional[int] = None
115
+ self._nav_error: Optional[str] = None
116
+ self.viewport = {"width": 1024, "height": 700}
117
+
118
+ # ------------------------------------------------------------ jobs --
119
+ def _submit(self, fn, timeout=30.0):
120
+ if self._thread is None or not self._thread.is_alive():
121
+ raise RuntimeError("engine not started")
122
+ job = _Job(fn=fn)
123
+ self._queue.put(job)
124
+ if not job.done.wait(timeout):
125
+ raise TimeoutError("browser engine did not respond")
126
+ if job.result and isinstance(job.result[0], Exception):
127
+ raise job.result[0]
128
+ return job.result[0] if job.result else None
129
+
130
+ def _loop(self):
131
+ try:
132
+ from playwright.sync_api import sync_playwright
133
+ self._pw = sync_playwright().start()
134
+ self._browser = self._pw.chromium.launch(headless=self._headless)
135
+ self._context = self._browser.new_context(viewport=self.viewport)
136
+ self._page = self._context.new_page()
137
+ page = self._page
138
+
139
+ def _on_console(msg):
140
+ kind = msg.type
141
+ if kind in ("error", "warning"):
142
+ line = f"[{kind}] {msg.text}"
143
+ self.console.append(line)
144
+ del self.console[:-100:]
145
+ elif kind == "log" and len(self.console) < 200:
146
+ self.console.append(f"[log] {msg.text}")
147
+
148
+ def _on_pageerror(err):
149
+ line = f"ReferenceError-class: {err}" \
150
+ if "ReferenceError" in str(err) else str(err)
151
+ self.js_errors.append(str(err))
152
+ del self.js_errors[:-50:]
153
+ self.console.append(f"[pageerror] {line}")
154
+ del self.console[:-100:]
155
+
156
+ def _on_response(resp):
157
+ try:
158
+ if resp.url == page.url:
159
+ self.last_status = resp.status
160
+ except Exception:
161
+ pass
162
+
163
+ page.on("console", _on_console)
164
+ page.on("pageerror", _on_pageerror)
165
+ page.on("response", _on_response)
166
+ except BrowserUnavailableError:
167
+ raise
168
+ except Exception as e:
169
+ self._launch_error = e
170
+ finally:
171
+ self._ready.set()
172
+ while not self._stop:
173
+ try:
174
+ job = self._queue.get(timeout=0.25)
175
+ except _queue.Empty:
176
+ continue
177
+ if job is None:
178
+ break
179
+ try:
180
+ res = job.fn()
181
+ job.result.append(res)
182
+ except Exception as e:
183
+ job.result.append(e)
184
+ finally:
185
+ job.done.set()
186
+
187
+ # ----------------------------------------------------------- start --
188
+ def start(self, timeout=45.0):
189
+ """Launch Chromium on the engine thread. Raises
190
+ BrowserUnavailableError when the stack isn't installed."""
191
+ if self._thread is not None and self._thread.is_alive():
192
+ return True
193
+ if not PLAYWRIGHT_AVAILABLE:
194
+ raise BrowserUnavailableError(
195
+ "Playwright is not installed. Run:\n"
196
+ " pip install playwright\n"
197
+ " playwright install chromium")
198
+ self._launch_error = None
199
+ self._thread = threading.Thread(
200
+ target=self._loop, name="cat-browser-engine", daemon=True)
201
+ self._thread.start()
202
+ if not self._ready.wait(timeout):
203
+ raise TimeoutError("Chromium did not launch in time")
204
+ if not PLAYWRIGHT_AVAILABLE or self._browser is None:
205
+ err = getattr(self, "_launch_error", None)
206
+ raise BrowserUnavailableError(
207
+ f"Chromium failed to launch: {err}. "
208
+ "Try: playwright install chromium")
209
+ return True
210
+
211
+ # -------------------------------------------------------- internals --
212
+ def _navigate(self, url):
213
+ page = self._page
214
+ del self.console[:]
215
+ del self.js_errors[:]
216
+ self._nav_error = None
217
+ self.last_status = None
218
+ try:
219
+ resp = page.goto(url, wait_until="load", timeout=25000)
220
+ if resp is not None:
221
+ self.last_status = resp.status
222
+ except Exception as e:
223
+ self._nav_error = str(e).split("\n")[0][:200]
224
+ try:
225
+ page.wait_for_timeout(120) # let late JS paint settle briefly
226
+ except Exception:
227
+ pass
228
+ return self._snapshot_locked(url)
229
+
230
+ def _snapshot_locked(self, url: str) -> PreviewSnapshot:
231
+ page = self._page
232
+ snap = PreviewSnapshot(url=url)
233
+ snap.console_tail = tuple(self.console[-8:])
234
+ snap.js_errors = tuple(self.js_errors[-5:])
235
+ if self._nav_error:
236
+ snap.ok = False
237
+ snap.error = self._nav_error
238
+ return snap
239
+ try:
240
+ snap.title = page.title() or ""
241
+ except Exception:
242
+ pass
243
+ snap.status_code = self.last_status
244
+ if self.last_status and self.last_status >= 400:
245
+ snap.ok = False
246
+ snap.error = f"HTTP {self.last_status} while loading {url}"
247
+ return snap
248
+ try:
249
+ counts = page.evaluate(_COUNTS_JS) or {}
250
+ snap.elements = int(counts.get("elements", 0))
251
+ snap.images_loaded = int(counts.get("imagesLoaded", 0))
252
+ snap.images_broken = int(counts.get("imagesBroken", 0))
253
+ raw = page.evaluate(_OUTLINE_JS) or []
254
+ lines = []
255
+ for item in raw[:160]:
256
+ t, x = item.get("t", ""), item.get("x", "")
257
+ e = item.get("e", "")
258
+ prefix = {
259
+ "h1": "# ", "h2": "## ", "h3": "### ",
260
+ "a": " ↗ ", "button": " [ ] ",
261
+ "li": " • ", "input": " ⌨ ", "textarea": " ⌨ ",
262
+ "select": " ⌨ ", "img": " 🖼 ", "p": " ",
263
+ "nav": "§ nav", "header": "§ header",
264
+ "footer": "§ footer", "section": "§ section",
265
+ }.get(t, " ")
266
+ line = f"{prefix}{x}".rstrip()
267
+ if t == "img":
268
+ line += "" if e != "BROKEN" else " ✗"
269
+ if line.strip():
270
+ lines.append(line)
271
+ snap.outline_lines = tuple(lines)
272
+ except Exception as e:
273
+ snap.error = f"snapshot failed: {e}"
274
+ return snap
275
+
276
+ # ----------------------------------------------------------- public --
277
+ @property
278
+ def available(self) -> bool:
279
+ return self._thread is not None and self._thread.is_alive() \
280
+ and self._page is not None
281
+
282
+ def navigate(self, url: str, timeout=35.0) -> PreviewSnapshot:
283
+ return self._submit(lambda: self._navigate(url), timeout)
284
+
285
+ def reload(self, timeout=35.0) -> PreviewSnapshot:
286
+ def do_reload():
287
+ url = self._page.url
288
+ try:
289
+ self._page.reload(wait_until="load", timeout=25000)
290
+ except Exception:
291
+ pass
292
+ return self._snapshot_locked(url)
293
+ return self._submit(do_reload, timeout)
294
+
295
+ def back(self, timeout=20.0) -> Optional[PreviewSnapshot]:
296
+ def go_back():
297
+ page = self._page
298
+ try:
299
+ page.go_back(wait_until="load", timeout=15000)
300
+ except Exception:
301
+ return None
302
+ time.sleep(0.05)
303
+ return self._snapshot_locked(page.url)
304
+ return self._submit(go_back, timeout)
305
+
306
+ def forward(self, timeout=20.0) -> Optional[PreviewSnapshot]:
307
+ def go_fwd():
308
+ page = self._page
309
+ try:
310
+ page.go_forward(wait_until="load", timeout=15000)
311
+ except Exception:
312
+ return None
313
+ time.sleep(0.05)
314
+ return self._snapshot_locked(page.url)
315
+ return self._submit(go_fwd, timeout)
316
+
317
+ def hot_css(self, hrefs) -> bool:
318
+ """CSS-only hot update through the page's own WebSocket channel
319
+ listener (server.notify_hot_css triggers it); falls back to a
320
+ direct style re-fetch when no WS client is connected."""
321
+ def inject():
322
+ page = self._page
323
+ try:
324
+ page.evaluate(
325
+ """(hrefs) => {
326
+ document.querySelectorAll(
327
+ 'link[rel=\\"stylesheet\\"]').forEach(l => {
328
+ const key = (l.getAttribute('href') || '');
329
+ if (!hrefs.length || hrefs.some(h =>
330
+ key.indexOf(h.split('?')[0]) !== -1)) {
331
+ const clone = l.cloneNode();
332
+ clone.href = l.href.replace(/([?&])__cat=\\d+/, '')
333
+ + (l.href.indexOf('?') === -1 ? '?' : '&')
334
+ + '__cat=' + Date.now();
335
+ l.parentNode.replaceChild(clone, l);
336
+ }
337
+ });
338
+ }""", list(hrefs or []))
339
+ return True
340
+ except Exception:
341
+ return False
342
+ try:
343
+ return bool(self._submit(inject, timeout=10.0))
344
+ except Exception:
345
+ return False
346
+
347
+ def evaluate(self, js: str, timeout=10.0):
348
+ return self._submit(lambda: self._page.evaluate(js), timeout)
349
+
350
+ def screenshot(self, path: str) -> bool:
351
+ def shot():
352
+ try:
353
+ self._page.screenshot(path=path, full_page=False)
354
+ return True
355
+ except Exception:
356
+ return False
357
+ try:
358
+ return bool(self._submit(shot, timeout=15.0))
359
+ except Exception:
360
+ return False
361
+
362
+ def resize_viewport(self, width: int, height: int):
363
+ def do_resize():
364
+ try:
365
+ self._page.set_viewport_size({"width": int(width),
366
+ "height": int(height)})
367
+ except Exception:
368
+ pass
369
+ try:
370
+ self._submit(do_resize, timeout=8.0)
371
+ except Exception:
372
+ pass
373
+
374
+ def click(self, selector_or_text: str, timeout: float = 10.0) -> bool:
375
+ def do_click():
376
+ page = self._page
377
+ try:
378
+ if selector_or_text.startswith(("#", ".", "[", "button", "a", "input")):
379
+ page.click(selector_or_text, timeout=int(timeout * 1000))
380
+ return True
381
+ except Exception:
382
+ pass
383
+ try:
384
+ page.get_by_text(selector_or_text, exact=False).first.click(timeout=int(timeout * 1000))
385
+ return True
386
+ except Exception:
387
+ pass
388
+ try:
389
+ page.click(f"text={selector_or_text}", timeout=int(timeout * 1000))
390
+ return True
391
+ except Exception as e:
392
+ raise RuntimeError(f"Could not click '{selector_or_text}': {e}")
393
+ return bool(self._submit(do_click, timeout=timeout + 2.0))
394
+
395
+ def type_text(self, selector_or_placeholder: str, text: str, timeout: float = 10.0) -> bool:
396
+ def do_type():
397
+ page = self._page
398
+ try:
399
+ if selector_or_placeholder.startswith(("#", ".", "[", "input", "textarea")):
400
+ page.fill(selector_or_placeholder, text, timeout=int(timeout * 1000))
401
+ return True
402
+ except Exception:
403
+ pass
404
+ try:
405
+ page.get_by_placeholder(selector_or_placeholder).first.fill(text, timeout=int(timeout * 1000))
406
+ return True
407
+ except Exception:
408
+ pass
409
+ try:
410
+ page.fill(f"input[name='{selector_or_placeholder}']", text, timeout=int(timeout * 1000))
411
+ return True
412
+ except Exception as e:
413
+ raise RuntimeError(f"Could not type into '{selector_or_placeholder}': {e}")
414
+ return bool(self._submit(do_type, timeout=timeout + 2.0))
415
+
416
+ def get_interactive_elements(self, timeout: float = 10.0) -> list:
417
+ js = """
418
+ () => {
419
+ const els = [];
420
+ document.querySelectorAll('a, button, input, textarea, select, [role="button"]').forEach((el, idx) => {
421
+ if (el.offsetParent === null && el.tagName !== 'INPUT') return;
422
+ const tag = el.tagName.toLowerCase();
423
+ const text = (el.textContent || el.placeholder || el.value || el.getAttribute('aria-label') || '').trim();
424
+ const id = el.id ? '#' + el.id : '';
425
+ const href = el.getAttribute('href') || '';
426
+ els.push({
427
+ tag: tag,
428
+ text: text.slice(0, 80),
429
+ id: id,
430
+ selector: id || (tag + (el.className ? '.' + el.className.trim().split(/\\s+/)[0] : '')),
431
+ href: href,
432
+ type: el.type || ''
433
+ });
434
+ });
435
+ return els.slice(0, 100);
436
+ }
437
+ """
438
+ return self._submit(lambda: self._page.evaluate(js), timeout) or []
439
+
440
+ def workflow_verify(self, expected_selectors: list = None, timeout: float = 10.0) -> dict:
441
+ def do_verify():
442
+ page = self._page
443
+ res = {
444
+ "url": page.url,
445
+ "title": page.title() or "",
446
+ "console_errors": len(self.js_errors),
447
+ "js_errors": list(self.js_errors),
448
+ "http_status": self.last_status,
449
+ "missing_selectors": [],
450
+ "verified": True,
451
+ }
452
+ if self.last_status and self.last_status >= 400:
453
+ res["verified"] = False
454
+ res["error"] = f"HTTP {self.last_status}"
455
+ if self.js_errors:
456
+ res["verified"] = False
457
+ if expected_selectors:
458
+ for sel in expected_selectors:
459
+ try:
460
+ count = page.locator(sel).count()
461
+ if count == 0:
462
+ res["missing_selectors"].append(sel)
463
+ res["verified"] = False
464
+ except Exception:
465
+ res["missing_selectors"].append(sel)
466
+ res["verified"] = False
467
+ return res
468
+ return self._submit(do_verify, timeout)
469
+
470
+ # ------------------------------------------------------------- stop --
471
+ def close(self, timeout=6.0):
472
+ """Idempotent shutdown: kills Chromium + the worker thread."""
473
+ self._stop = True
474
+ try:
475
+ if self._thread is not None and self._thread.is_alive():
476
+ self._queue.put(None)
477
+ self._thread.join(timeout=timeout)
478
+ except Exception:
479
+ pass
480
+ for closer in ("_page", "_context", "_browser", "_pw"):
481
+ obj = getattr(self, closer, None)
482
+ setattr(self, closer, None)
483
+ if obj is None:
484
+ continue
485
+ try:
486
+ if closer == "_page":
487
+ obj.close()
488
+ elif closer in ("_context", "_browser"):
489
+ obj.close()
490
+ else:
491
+ obj.stop()
492
+ except Exception:
493
+ pass
494
+ self._thread = None
@@ -0,0 +1,84 @@
1
+ """CAT browser/ — navigation history (back / forward / reload).
2
+
3
+ Pure logic, no I/O — the controller asks this class where to go next
4
+ and then drives the engine. Duplicate consecutive entries are not
5
+ pushed (a reload must not create history), and pushing truncates the
6
+ forward branch exactly like every real browser.
7
+ """
8
+
9
+ from typing import List, Optional
10
+
11
+
12
+ class NavigationHistory:
13
+ """A bounded back/forward stack of URLs."""
14
+
15
+ def __init__(self, limit: int = 100):
16
+ self._limit = max(2, int(limit))
17
+ self._entries: List[str] = []
18
+ self._index = -1
19
+
20
+ # ------------------------------------------------------------- state --
21
+ @property
22
+ def current(self) -> Optional[str]:
23
+ if 0 <= self._index < len(self._entries):
24
+ return self._entries[self._index]
25
+ return None
26
+
27
+ @property
28
+ def index(self) -> int:
29
+ return self._index
30
+
31
+ def __len__(self):
32
+ return len(self._entries)
33
+
34
+ def can_back(self) -> bool:
35
+ return self._index > 0
36
+
37
+ def can_forward(self) -> bool:
38
+ return self._index < len(self._entries) - 1
39
+
40
+ # ------------------------------------------------------------ moves --
41
+ def push(self, url: str) -> Optional[str]:
42
+ """Record a navigation. Returns the URL that should be loaded
43
+ (None when `url` is a duplicate of the current entry — nothing
44
+ to do). Truncates any forward branch."""
45
+ if not url:
46
+ return None
47
+ if self.current == url:
48
+ return None
49
+ # drop forward branch + enforce the cap
50
+ self._entries = self._entries[:self._index + 1]
51
+ self._entries.append(url)
52
+ if len(self._entries) > self._limit:
53
+ self._entries = self._entries[-self._limit:]
54
+ self._index = len(self._entries) - 1
55
+ return url
56
+
57
+ def replace_current(self, url: str) -> None:
58
+ """Rewrite the current entry in place (redirects)."""
59
+ if not url:
60
+ return
61
+ if self._entries and self._index >= 0:
62
+ self._entries[self._index] = url
63
+ else:
64
+ self.push(url)
65
+
66
+ def back(self) -> Optional[str]:
67
+ if not self.can_back():
68
+ return None
69
+ self._index -= 1
70
+ return self._entries[self._index]
71
+
72
+ def forward(self) -> Optional[str]:
73
+ if not self.can_forward():
74
+ return None
75
+ self._index += 1
76
+ return self._entries[self._index]
77
+
78
+ def reload_url(self) -> Optional[str]:
79
+ return self.current
80
+
81
+ # ------------------------------------------------------------ reset --
82
+ def clear(self) -> None:
83
+ self._entries = []
84
+ self._index = -1