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,429 @@
1
+ """CAT browser/ — PreviewController: the one owner of preview lifecycle.
2
+
3
+ ▷ click ─► start_for_file(index.html)
4
+ ├─ LiveServer.start() (127.0.0.1:auto port)
5
+ ├─ BrowserEngine.start() (real Chromium)
6
+ ├─ navigate(entry url)
7
+ └─ PreviewFileWatcher.start() (debounced live reload)
8
+
9
+ ⏻ click ─► stop_preview() server/browser/watcher torn down,
10
+ editor session untouched
11
+ CAT exit ─► shutdown() idempotent, atexit-registered
12
+
13
+ State model lives here (spec section 22): server_state + preview_state
14
+ progress STOPPED → STARTING → RUNNING/ERROR, and every transition is
15
+ reported through `on_event(kind, **info)` so the UI can render progress
16
+ without any of this code touching widgets.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import atexit
22
+ import os
23
+ import threading
24
+ from typing import Callable, Optional
25
+
26
+ from .engine import BrowserEngine, PLAYWRIGHT_AVAILABLE
27
+ from .navigation import NavigationHistory
28
+ from .preview_entry import find_entry_file, relative_url_for
29
+ from .project_detector import (detect_project, dependencies_installed,
30
+ STATIC)
31
+ from .devserver import DevServerProcess
32
+ from .server import LiveServer
33
+ from .state import PreviewState, ServerState
34
+ from .watcher import PreviewFileWatcher, is_web_file
35
+
36
+
37
+ class PreviewController:
38
+ """Owns Server + Engine + Watcher + Navigation for one workspace."""
39
+
40
+ def __init__(self, root: str, engine: Optional[BrowserEngine] = None,
41
+ on_event: Optional[Callable] = None,
42
+ devserver_factory=None):
43
+ self.root = os.path.abspath(os.path.expanduser(root))
44
+ self.on_event = on_event or (lambda *a, **k: None)
45
+ self.server = LiveServer(self.root)
46
+ self.engine = engine or BrowserEngine()
47
+ self.history = NavigationHistory()
48
+ self.watcher: Optional[PreviewFileWatcher] = None
49
+ # v0.7.10 spec section 19-20: framework workspaces (Vite/Next/
50
+ # CRA) reuse THEIR OWN dev server; this factory builds that
51
+ # process handle (injectable for tests).
52
+ self._devserver_factory = devserver_factory or DevServerProcess
53
+ self.devserver = None
54
+ self.detection = detect_project(self.root)
55
+ self.server_state = ServerState.STOPPED
56
+ self.preview_state = PreviewState.STOPPED
57
+ self.last_error = ""
58
+ self.entry_url = ""
59
+ self._lock = threading.RLock()
60
+ self._hot_css_enabled = True
61
+ self._pending_ai_paths = set()
62
+ self._ai_debouncer = None
63
+ _register_instance(self)
64
+
65
+ # ------------------------------------------------------------ events --
66
+ def _emit(self, kind: str, **info):
67
+ try:
68
+ self.on_event(kind, **info)
69
+ except Exception:
70
+ pass
71
+
72
+ def _set_server_state(self, state: ServerState, error=""):
73
+ self.server_state = state
74
+ if error:
75
+ self.last_error = error
76
+ self._emit("server", state=state, error=error)
77
+
78
+ def _set_preview_state(self, state: PreviewState, error=""):
79
+ self.preview_state = state
80
+ if error:
81
+ self.last_error = error
82
+ self._emit("preview", state=state, error=error)
83
+
84
+ # ------------------------------------------------------------- props --
85
+ @property
86
+ def running(self) -> bool:
87
+ if self.preview_state is not PreviewState.RUNNING:
88
+ return False
89
+ if self.devserver is not None:
90
+ # External (framework) mode: the project's own server is
91
+ # the source of truth, CAT's static one stays idle.
92
+ return self.devserver.running or bool(self.history.current)
93
+ return self.server.running
94
+
95
+ @property
96
+ def base_url(self) -> str:
97
+ """The URL everything is served from right now."""
98
+ if self.devserver is not None and self.devserver.url:
99
+ return self.devserver.url
100
+ return self.server.url
101
+
102
+ @property
103
+ def url(self) -> str:
104
+ return self.history.current or ""
105
+
106
+ # ------------------------------------------------------------- start --
107
+ def _start_static_server(self) -> bool:
108
+ """CAT's own 127.0.0.1 static LiveServer (the default path)."""
109
+ self._emit("activity", text="● Starting local server…")
110
+ self._set_server_state(ServerState.STARTING)
111
+ if not self.server.running:
112
+ if not self.server.start():
113
+ self._set_server_state(
114
+ ServerState.ERROR,
115
+ "Failed to start development server "
116
+ "(port bind refused). CAT will retry next time.")
117
+ self._set_preview_state(PreviewState.ERROR,
118
+ "Server failed to start.")
119
+ return False
120
+ if not self.server.wait_ready():
121
+ self._set_server_state(ServerState.ERROR,
122
+ "Server did not answer.")
123
+ self._set_preview_state(PreviewState.ERROR,
124
+ "Server did not answer.")
125
+ return False
126
+ self._set_server_state(ServerState.RUNNING)
127
+ return True
128
+
129
+ def _start_framework_devserver(self, detection) -> bool:
130
+ """Spec section 19-20: the project already HAS a dev server
131
+ (Vite/Next/CRA) — start IT and reuse it; never stack a second
132
+ server on top of a running one."""
133
+ self._emit("activity", text=f"● {detection.label} detected — "
134
+ f"starting its dev server…")
135
+ self._set_server_state(ServerState.STARTING)
136
+ ds = self._devserver_factory(
137
+ detection.root, detection.command,
138
+ on_activity=lambda t: self._emit("activity", text=t))
139
+ if not ds.start():
140
+ tail = getattr(ds, "last_output", "")
141
+ self._set_server_state(
142
+ ServerState.ERROR,
143
+ f"{detection.label} dev server failed to start."
144
+ + (f"\n{tail}" if tail else ""))
145
+ self._set_preview_state(PreviewState.ERROR,
146
+ "Dev server failed to start.")
147
+ return False
148
+ self.devserver = ds
149
+ self._set_server_state(ServerState.RUNNING)
150
+ return True
151
+
152
+ def start_for_file(self, path: str) -> bool:
153
+ """▷ — entry point from an open editor tab (or any html file).
154
+ Detects the project type FIRST (spec section 20): framework
155
+ projects get their own dev server + HMR; everything else is
156
+ served by CAT's static LiveServer."""
157
+ path = os.path.abspath(os.path.expanduser(path))
158
+ entry, web_root = find_entry_file(path, self.root)
159
+
160
+ # Re-detect per start: the workspace may have gained/lost its
161
+ # package.json since this controller was created.
162
+ self.detection = detect_project(self.root)
163
+ detection = self.detection
164
+ use_external = (detection.is_framework
165
+ and dependencies_installed(detection))
166
+ if not entry and not use_external:
167
+ self._set_preview_state(
168
+ PreviewState.ERROR,
169
+ "No HTML entry point found in this workspace.")
170
+ self._emit("error", reason="no-entry")
171
+ return False
172
+
173
+ with self._lock:
174
+ if use_external:
175
+ # A Vite index.html at the project root is NOT a plain
176
+ # static page — only the real dev server can render it.
177
+ if not self._start_framework_devserver(detection):
178
+ return False
179
+ url = self.devserver.url
180
+ else:
181
+ if not self._start_static_server():
182
+ return False
183
+ rel = relative_url_for(entry, web_root) if entry else ""
184
+ url = self.base_url.rstrip("/") + "/" + rel.lstrip("/")
185
+
186
+ self.entry_url = url
187
+ self.watcher = self._ensure_watcher()
188
+ self._emit("activity", text="● Starting browser engine…")
189
+ self._set_preview_state(PreviewState.STARTING)
190
+ try:
191
+ self.engine.start()
192
+ snap = self.navigate(url, push=True)
193
+ if snap is not None and snap.ok:
194
+ self._emit("activity", text="✓ Preview synchronized")
195
+ except Exception as e:
196
+ # Headless engine failed or Playwright missing — local server is still running
197
+ # and fully functional for real-time viewing in CAT Browser app
198
+ self._emit("activity", text=f"● Live server ready for CAT Browser: {url}")
199
+
200
+ self._set_preview_state(PreviewState.RUNNING)
201
+ return True
202
+
203
+ def navigate_current_workspace(self) -> bool:
204
+ """Preview the workspace root itself (no specific file)."""
205
+ entry, _web_root = find_entry_file(None, self.root)
206
+ if not entry:
207
+ self._set_preview_state(PreviewState.ERROR,
208
+ "No HTML entry point in this workspace.")
209
+ return False
210
+ return self.start_for_file(entry)
211
+
212
+ # ---------------------------------------------------------- navigate --
213
+ def navigate(self, url: str, push: bool = True):
214
+ """Load a URL in the running preview; records history."""
215
+ if not self.engine.available:
216
+ self._set_preview_state(PreviewState.ERROR, "Browser not running.")
217
+ return None
218
+ self._emit("activity", text=f"● Loading {url}")
219
+ try:
220
+ snap = self.engine.navigate(url)
221
+ except Exception as e:
222
+ self._set_preview_state(PreviewState.ERROR, str(e))
223
+ return None
224
+ if push and url != self.history.current:
225
+ self.history.push(url)
226
+ else:
227
+ self.history.replace_current(url)
228
+ if snap.ok:
229
+ self._set_preview_state(PreviewState.RUNNING)
230
+ else:
231
+ # A single broken page must not kill the whole stack.
232
+ self._set_preview_state(PreviewState.ERROR, snap.error)
233
+ self._emit("snapshot", snapshot=snap)
234
+ return snap
235
+
236
+ def reload(self):
237
+ if not self.running and not self.engine.available:
238
+ return None
239
+ self._emit("activity", text="● Reloading preview…")
240
+ try:
241
+ snap = self.engine.reload()
242
+ except Exception as e:
243
+ self._set_preview_state(PreviewState.ERROR, str(e))
244
+ return None
245
+ if snap.ok:
246
+ self._set_preview_state(PreviewState.RUNNING)
247
+ self._emit("activity", text="✓ Preview synchronized")
248
+ else:
249
+ self._set_preview_state(PreviewState.ERROR, snap.error)
250
+ self._emit("snapshot", snapshot=snap)
251
+ return snap
252
+
253
+ def back(self):
254
+ target = self.history.back()
255
+ if target is None:
256
+ return None
257
+ return self.navigate(target, push=False)
258
+
259
+ def forward(self):
260
+ target = self.history.forward()
261
+ if target is None:
262
+ return None
263
+ return self.navigate(target, push=False)
264
+
265
+ def open_path(self, path: str):
266
+ """Navigate the RUNNING preview to a workspace file (URL bar /
267
+ explorer integration). No-op when stopped."""
268
+ if not self.running:
269
+ return None
270
+ rel = relative_url_for(os.path.abspath(path), self.root)
271
+ if rel is None:
272
+ return None
273
+ return self.navigate(self.base_url.rstrip("/") + "/" + rel)
274
+
275
+ # ------------------------------------------------------------ hot path --
276
+ def on_files_changed(self, changed_paths) -> None:
277
+ """Watcher/AI-write hook. Debounced upstream; decides between
278
+ CSS-only hot update and full reload. Never raises.
279
+
280
+ Framework mode (Vite/Next): their OWN HMR pipeline reacts to
281
+ file changes — reloading again from CAT would double-fire — so
282
+ we surface the activity lines (spec section 18) and leave the
283
+ updating to the project's dev server."""
284
+ try:
285
+ paths = [p for p in (changed_paths or []) if is_web_file(p)]
286
+ if not paths:
287
+ return
288
+ if self.devserver is not None and self.devserver.running:
289
+ for p in sorted(paths)[:6]:
290
+ self._emit("activity",
291
+ text=f"● Writing {os.path.basename(p)}")
292
+ self._emit("activity", text="✓ Framework HMR applied")
293
+ return
294
+ css_only = all(p.lower().endswith(".css") for p in paths)
295
+ if css_only and self._hot_css_enabled:
296
+ hrefs = [os.path.relpath(p, self.root).replace(os.sep, "/")
297
+ for p in paths]
298
+ reached = self.server.notify_hot_css(hrefs)
299
+ done = False
300
+ if reached == 0 and getattr(self.engine, "available", False):
301
+ done = bool(self.engine.hot_css(hrefs))
302
+ else:
303
+ done = True
304
+ if done:
305
+ self._emit("activity",
306
+ text=f"✓ CSS updated ({len(paths)} file(s))")
307
+ return
308
+ for p in sorted(paths)[:6]:
309
+ self._emit("activity",
310
+ text=f"● Writing {os.path.basename(p)}")
311
+ self.server.notify_reload()
312
+ if getattr(self.engine, "available", False):
313
+ self.reload()
314
+ except Exception as e:
315
+ self._emit("error", reason="hot-reload", detail=str(e))
316
+
317
+ def notify_ai_wrote(self, path: str):
318
+ """Explicit fast-path notification from CAT's own write tools —
319
+ bypasses waiting for the OS watcher but STILL debounced (spec
320
+ section 13): a burst of AI writes coalesces into ONE preview
321
+ update ~150 ms after the last one lands."""
322
+ try:
323
+ if not is_web_file(path) or not self.engine.available:
324
+ return
325
+ with self._lock:
326
+ self._pending_ai_paths.add(os.path.abspath(path))
327
+ if self._ai_debouncer is None:
328
+ from .watcher import Debouncer
329
+ self._ai_debouncer = Debouncer()
330
+
331
+ def flush():
332
+ with self._lock:
333
+ batch = set(self._pending_ai_paths)
334
+ self._pending_ai_paths.clear()
335
+ if batch:
336
+ self.on_files_changed(batch)
337
+
338
+ self._ai_debouncer.schedule(flush)
339
+ except Exception as e:
340
+ self._emit("error", reason="ai-write", detail=str(e))
341
+
342
+ # ----------------------------------------------------------- watcher --
343
+ def _ensure_watcher(self) -> Optional[PreviewFileWatcher]:
344
+ if self.watcher is not None and self.watcher.active \
345
+ and self.watcher.root == self.root:
346
+ return self.watcher
347
+ try:
348
+ watcher = PreviewFileWatcher(
349
+ lambda batch: self.on_files_changed(batch),
350
+ on_activity=lambda p: self._emit(
351
+ "activity",
352
+ text=f"● Writing {os.path.basename(p)}"))
353
+ watcher.start(self.root)
354
+ self.watcher = watcher
355
+ return watcher
356
+ except Exception:
357
+ self.watcher = None
358
+ return None
359
+
360
+ # -------------------------------------------------------------- stop --
361
+ def stop_preview(self):
362
+ """⏻ — tear the preview down; the workspace/editor are untouched.
363
+ Idempotent."""
364
+ with self._lock:
365
+ watcher, self.watcher = self.watcher, None
366
+ if watcher is not None:
367
+ try:
368
+ watcher.stop()
369
+ except Exception:
370
+ pass
371
+ try:
372
+ self.engine.close()
373
+ except Exception:
374
+ pass
375
+ try:
376
+ self.server.stop()
377
+ except Exception:
378
+ pass
379
+ devserver, self.devserver = self.devserver, None
380
+ if devserver is not None:
381
+ # Kill the framework's npm/node process TREE (§21: no
382
+ # orphans survive CAT, crashes included — atexit calls
383
+ # this same path).
384
+ try:
385
+ devserver.stop()
386
+ except Exception:
387
+ pass
388
+ self.history.clear()
389
+ self._set_preview_state(PreviewState.STOPPED)
390
+ self._set_server_state(ServerState.STOPPED)
391
+
392
+ def restart_engine(self):
393
+ """Recover from a wedged browser without touching the server."""
394
+ try:
395
+ self.engine.close()
396
+ except Exception:
397
+ pass
398
+ try:
399
+ self.engine.start()
400
+ except Exception as e:
401
+ self._set_preview_state(PreviewState.ERROR, str(e))
402
+
403
+
404
+ # ---------------------------------------------------------------------------
405
+ # process-wide lifecycle: every controller ever created is stopped at exit
406
+ # so no orphan Chromium/server can survive CAT.
407
+ _CONTROLLERS = []
408
+ _atexit_installed = False
409
+
410
+
411
+ def _register_instance(ctrl: PreviewController):
412
+ global _atexit_installed
413
+ _CONTROLLERS.append(ctrl)
414
+ del _CONTROLLERS[:-8:] # keep the list bounded
415
+ if not _atexit_installed:
416
+ atexit.register(_shutdown_all)
417
+ _atexit_installed = True
418
+
419
+
420
+ def _shutdown_all():
421
+ for ctrl in list(_CONTROLLERS):
422
+ try:
423
+ ctrl.stop_preview()
424
+ except Exception:
425
+ pass
426
+
427
+
428
+ def playwright_available() -> bool:
429
+ return PLAYWRIGHT_AVAILABLE
@@ -0,0 +1,95 @@
1
+ """CAT browser/ — entry-point detection + workspace-relative URLs.
2
+
3
+ Given an open file (or nothing), decide WHAT to preview and WHERE the
4
+ web root is:
5
+
6
+ index.html anywhere in the tree → its folder becomes the web root
7
+ docs/site/index.html → web root = docs/site
8
+ no html at all → (None, root) → honest error
9
+
10
+ Keeps the controller free of path heuristics and is unit-testable.
11
+ """
12
+
13
+ import os
14
+ from typing import Optional, Tuple
15
+
16
+ ENTRY_NAMES = ("index.html", "index.htm", "default.html")
17
+
18
+
19
+ def _is_html(path: str) -> bool:
20
+ return os.path.splitext(path)[1].lower() in (".html", ".htm")
21
+
22
+
23
+ def find_entry_file(open_path: Optional[str],
24
+ workspace_root: str) -> Tuple[Optional[str], str]:
25
+ """Return (entry_html_path, web_root).
26
+
27
+ Order:
28
+ 1. the open file itself, when it's HTML;
29
+ 2. an index.html next to the open file, walking UP to the root;
30
+ 3. workspace_root/index.html;
31
+ 4. shallowest index.html anywhere under the root (bounded walk).
32
+ """
33
+ root = os.path.abspath(workspace_root)
34
+ if open_path and _is_html(open_path) and os.path.isfile(open_path):
35
+ # prefer a sibling index if the open file sits inside a folder
36
+ # that has one (e.g. user opened src/about.html but src/index.html
37
+ # exists → still treat src/ as the site)
38
+ folder = os.path.dirname(open_path)
39
+ for name in ENTRY_NAMES:
40
+ candidate = os.path.join(folder, name)
41
+ if os.path.isfile(candidate):
42
+ return candidate, folder
43
+ return open_path, folder
44
+ if open_path and os.path.isdir(open_path):
45
+ for name in ENTRY_NAMES:
46
+ candidate = os.path.join(open_path, name)
47
+ if os.path.isfile(candidate):
48
+ return candidate, open_path
49
+ # direct root entries
50
+ for name in ENTRY_NAMES:
51
+ candidate = os.path.join(root, name)
52
+ if os.path.isfile(candidate):
53
+ return candidate, root
54
+ # bounded search (depth ≤ 3) for the shallowest entry page
55
+ best = None
56
+ best_depth = 99
57
+ base_depth = root.rstrip(os.sep).count(os.sep)
58
+ for dirpath, dirnames, filenames in os.walk(root):
59
+ depth = dirpath.rstrip(os.sep).count(os.sep) - base_depth
60
+ if depth > 3:
61
+ dirnames[:] = []
62
+ continue
63
+ dirnames[:] = [d for d in dirnames
64
+ if d not in ("node_modules", ".git", "__pycache__",
65
+ ".venv", "venv")]
66
+ for name in ENTRY_NAMES:
67
+ if name in filenames:
68
+ if depth < best_depth:
69
+ best = os.path.join(dirpath, name)
70
+ best_depth = depth
71
+ break
72
+ if best is not None:
73
+ return best, os.path.dirname(best)
74
+ return None, root
75
+
76
+
77
+ def relative_url_for(path: str, web_root: str) -> Optional[str]:
78
+ """Filesystem path → URL path relative to the served root."""
79
+ path = os.path.abspath(path)
80
+ web_root = os.path.abspath(web_root).rstrip(os.sep) + os.sep
81
+ try:
82
+ rel = os.path.relpath(path, web_root)
83
+ except ValueError:
84
+ return None
85
+ if rel.startswith(".."):
86
+ return None
87
+ return rel.replace(os.sep, "/")
88
+
89
+
90
+ def url_for_file(path: str, server_url: str,
91
+ web_root: str) -> Optional[str]:
92
+ rel = relative_url_for(path, web_root)
93
+ if rel is None:
94
+ return None
95
+ return server_url.rstrip("/") + "/" + rel
@@ -0,0 +1,144 @@
1
+ """CAT browser/ — project detection (spec section 20).
2
+
3
+ Decide WHAT KIND of web project the workspace is before starting any
4
+ server, so the preview can REUSE the project's own dev server (Vite /
5
+ Next / CRA) instead of blindly stacking a second static one on top:
6
+
7
+ index.html → STATIC (CAT's LiveServer)
8
+ package.json + vite.config.* → VITE (npm run dev)
9
+ package.json + next.config.* → NEXT (npm run dev)
10
+ package.json + react-scripts → CRA (npm start)
11
+ package.json + <other> → NODE (best-effort npm run dev)
12
+ nothing recognizable → STATIC
13
+
14
+ Detection is pure filesystem reading — no subprocesses, no network —
15
+ so it is safe to call from the UI thread and trivially unit-testable.
16
+ """
17
+
18
+ import json
19
+ import os
20
+ from dataclasses import dataclass, field
21
+ from typing import Optional
22
+
23
+
24
+ # Recognized project kinds -------------------------------------------------
25
+ STATIC = "static"
26
+ VITE = "vite"
27
+ NEXT = "next"
28
+ CRA = "cra"
29
+ NODE = "node"
30
+
31
+ _VITE_CONFIGS = ("vite.config.js", "vite.config.ts", "vite.config.mjs",
32
+ "vite.config.mts", "vite.config.cjs")
33
+ _NEXT_CONFIGS = ("next.config.js", "next.config.ts", "next.config.mjs",
34
+ "next.config.cjs")
35
+
36
+
37
+ @dataclass
38
+ class Detection:
39
+ """One immutable detection result for a workspace root."""
40
+ kind: str = STATIC
41
+ root: str = ""
42
+ # Shell-ish command tokens to spawn (argv list, no shell).
43
+ command: tuple = ()
44
+ # Human explanation surfaced in the preview activity strip.
45
+ detail: str = ""
46
+ # Files that identified the project (for honest status lines).
47
+ evidence: tuple = field(default_factory=tuple)
48
+
49
+ @property
50
+ def is_framework(self) -> bool:
51
+ return self.kind in (VITE, NEXT, CRA, NODE)
52
+
53
+ @property
54
+ def label(self) -> str:
55
+ return {"static": "Static site", VITE: "Vite", NEXT: "Next.js",
56
+ CRA: "Create React App", NODE: "Node"}.get(
57
+ self.kind, self.kind)
58
+
59
+
60
+ def _read_scripts(pkg_path: str) -> dict:
61
+ try:
62
+ with open(pkg_path, "r", encoding="utf-8", errors="replace") as f:
63
+ data = json.load(f)
64
+ return data.get("scripts") or {}
65
+ except Exception:
66
+ return {}
67
+
68
+
69
+ def detect_project(workspace_root: str) -> Detection:
70
+ """Detect the project type of `workspace_root`. Never raises."""
71
+ root = os.path.abspath(os.path.expanduser(workspace_root or ""))
72
+ if not root or not os.path.isdir(root):
73
+ return Detection(kind=STATIC, root=root,
74
+ detail="Not a folder — serving statically.")
75
+
76
+ pkg_path = os.path.join(root, "package.json")
77
+ has_pkg = os.path.isfile(pkg_path)
78
+
79
+ if has_pkg:
80
+ scripts = _read_scripts(pkg_path)
81
+ evidence = ["package.json"]
82
+
83
+ vite_cfg = next((c for c in _VITE_CONFIGS
84
+ if os.path.isfile(os.path.join(root, c))), None)
85
+ dep_vite = False
86
+ try:
87
+ with open(pkg_path, "r", encoding="utf-8",
88
+ errors="replace") as f:
89
+ deps = json.load(f)
90
+ all_deps = {}
91
+ all_deps.update(deps.get("dependencies") or {})
92
+ all_deps.update(deps.get("devDependencies") or {})
93
+ dep_vite = "vite" in all_deps
94
+ except Exception:
95
+ pass
96
+ if vite_cfg or (dep_vite and "dev" in scripts):
97
+ if vite_cfg:
98
+ evidence.append(vite_cfg)
99
+ cmd = scripts.get("dev") or scripts.get("serve") or "vite"
100
+ return Detection(kind=VITE, root=root,
101
+ command=("npm", "run", "dev"),
102
+ detail=f"Vite project (`npm run {cmd}`) — "
103
+ f"reusing its own HMR dev server.",
104
+ evidence=tuple(evidence))
105
+
106
+ next_cfg = next((c for c in _NEXT_CONFIGS
107
+ if os.path.isfile(os.path.join(root, c))), None)
108
+ if next_cfg or "next" in (scripts.get("dev") or ""):
109
+ if next_cfg:
110
+ evidence.append(next_cfg)
111
+ return Detection(kind=NEXT, root=root,
112
+ command=("npm", "run", "dev"),
113
+ detail="Next.js project (`npm run dev`) — "
114
+ "reusing its own dev server.",
115
+ evidence=tuple(evidence))
116
+
117
+ start_script = scripts.get("start", "")
118
+ if "react-scripts" in start_script:
119
+ return Detection(kind=CRA, root=root,
120
+ command=("npm", "start"),
121
+ detail="Create React App (`npm start`) — "
122
+ "reusing its own dev server.",
123
+ evidence=tuple(evidence))
124
+
125
+ if scripts.get("dev"):
126
+ return Detection(kind=NODE, root=root,
127
+ command=("npm", "run", "dev"),
128
+ detail="Node project (`npm run dev`).",
129
+ evidence=tuple(evidence))
130
+ # package.json but no runnable dev script → static fallback.
131
+ return Detection(kind=STATIC, root=root,
132
+ detail="package.json without a dev script — "
133
+ "serving statically.")
134
+
135
+ return Detection(kind=STATIC, root=root,
136
+ detail="Static HTML project.")
137
+
138
+
139
+ def dependencies_installed(detection: Detection) -> bool:
140
+ """Whether the framework project can actually be started right now
141
+ (its node_modules are present). Static projects are always ready."""
142
+ if not detection.is_framework:
143
+ return True
144
+ return os.path.isdir(os.path.join(detection.root, "node_modules"))