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,2891 @@
1
+ """
2
+ FATTY CAT Web Server
3
+
4
+ FastAPI server that serves the PWA frontend and bridges to CAT core.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+ import uuid
15
+ from contextlib import asynccontextmanager
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional, Set
18
+
19
+ from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
22
+ from fastapi.staticfiles import StaticFiles
23
+ from pydantic import BaseModel
24
+
25
+ # Add parent directory to path for CAT core imports (source-checkout only).
26
+ # When installed via pip, `calc_terminal` is already importable — do not
27
+ # mutate sys.path. The guard below keeps `python -m calc_terminal.web.server`
28
+ # working identically in both layouts.
29
+ try:
30
+ import calc_terminal as _ct_check # noqa: F401
31
+ except Exception:
32
+ sys.path.insert(0, str(Path(__file__).parent.parent))
33
+
34
+ from calc_terminal import aicore, projects, workspace as ws_paths
35
+ from calc_terminal.browser.preview import PreviewController, _shutdown_all as preview_shutdown_all
36
+ from calc_terminal.browser.server import LiveServer
37
+ from calc_terminal.browser.devserver import DevServerProcess
38
+ from calc_terminal.browser.project_detector import detect_project, dependencies_installed
39
+ from calc_terminal.browser.preview_entry import find_entry_file, relative_url_for
40
+ from calc_terminal.browser.watcher import PreviewFileWatcher, is_web_file
41
+ from calc_terminal.browser.state import PreviewState, ServerState
42
+ from calc_terminal.permissions import manager as perm_manager, PERMISSION_DEFS, MODES, MODE_LABELS
43
+ from calc_terminal.web.cat_runtime import runtime
44
+ from calc_terminal import eventbus
45
+ from calc_terminal import (
46
+ chat_store,
47
+ mcp,
48
+ theme,
49
+ extensions as cct_ext,
50
+ fomoji_auth,
51
+ identity,
52
+ ai_personalization as ap,
53
+ customization as cust,
54
+ )
55
+ from calc_terminal.providers import provider_manager as pm
56
+ from calc_terminal.ui import theme_css, header as cct_header, welcome_modal
57
+ from calc_terminal.gestures import manager as gestures_mgr
58
+
59
+
60
+ # --- Models ---
61
+
62
+ class CommandRequest(BaseModel):
63
+ cmd: str
64
+ cwd: Optional[str] = None
65
+ env: Optional[Dict[str, str]] = None
66
+ timeout: Optional[float] = 30.0
67
+
68
+
69
+ class CommandResponse(BaseModel):
70
+ returncode: int
71
+ stdout: str
72
+ stderr: str
73
+ success: bool
74
+
75
+
76
+ class FileReadRequest(BaseModel):
77
+ path: str
78
+ encoding: Optional[str] = "utf-8"
79
+
80
+
81
+ class FileWriteRequest(BaseModel):
82
+ path: str
83
+ content: str
84
+ encoding: Optional[str] = "utf-8"
85
+
86
+
87
+ class PreviewStartRequest(BaseModel):
88
+ file_path: str
89
+
90
+
91
+ class PreviewNavigateRequest(BaseModel):
92
+ url: str
93
+
94
+
95
+ class ChatRequest(BaseModel):
96
+ message: str
97
+ mode: Optional[str] = "chat"
98
+ project_path: Optional[str] = None
99
+ chat_id: Optional[str] = None
100
+
101
+
102
+ class WorkspaceRequest(BaseModel):
103
+ path: str
104
+
105
+
106
+ class PermissionToggleRequest(BaseModel):
107
+ key: str
108
+ enabled: bool
109
+
110
+
111
+ class PermissionModeRequest(BaseModel):
112
+ mode: str
113
+
114
+
115
+ class PermissionDecideRequest(BaseModel):
116
+ key: str
117
+ decision: str
118
+ action_text: Optional[str] = ""
119
+
120
+
121
+ class ModeSwitchRequest(BaseModel):
122
+ mode: str
123
+
124
+
125
+ class CustomModeRequest(BaseModel):
126
+ key: str
127
+ label: str
128
+ icon: str
129
+ accent_hex: str
130
+ purpose: Optional[str] = ""
131
+ system_prompt: Optional[str] = ""
132
+
133
+
134
+ class ModelSwitchRequest(BaseModel):
135
+ provider: str
136
+ model: str
137
+ api_key: Optional[str] = None
138
+
139
+
140
+ class CommandDispatchRequest(BaseModel):
141
+ command: str
142
+
143
+
144
+ class FactRequest(BaseModel):
145
+ fact: str
146
+
147
+
148
+ class CustomizationLayoutRequest(BaseModel):
149
+ component_id: str
150
+ updates: Dict[str, Any]
151
+
152
+
153
+ class ExtensionToggleRequest(BaseModel):
154
+ id: str
155
+ enabled: bool
156
+
157
+
158
+ class ExtensionCreateRequest(BaseModel):
159
+ id: str
160
+ name: str
161
+ description: Optional[str] = ""
162
+ icon: Optional[str] = "⚡"
163
+ publisher: Optional[str] = "Custom"
164
+ category: Optional[str] = "Custom"
165
+ version: Optional[str] = "1.0.0"
166
+ script_code: Optional[str] = ""
167
+
168
+
169
+ class ScienceSolveRequest(BaseModel):
170
+ key: Optional[str] = None
171
+ formula: Optional[str] = None
172
+ values: Optional[Dict[str, Any]] = None
173
+ params: Optional[Dict[str, Any]] = None
174
+
175
+
176
+ class ScienceDeriveRequest(BaseModel):
177
+ keyword: Optional[str] = None
178
+ derivation: Optional[str] = None
179
+
180
+
181
+ class ScienceAtomRequest(BaseModel):
182
+ element: str
183
+
184
+
185
+ class RecentPinRequest(BaseModel):
186
+ path: str
187
+
188
+
189
+ class RecentRemoveRequest(BaseModel):
190
+ path: str
191
+
192
+
193
+ class ChatCreateRequest(BaseModel):
194
+ name: Optional[str] = None
195
+ project_path: Optional[str] = None
196
+
197
+
198
+ class ChatRenameRequest(BaseModel):
199
+ name: str
200
+
201
+
202
+ class McpServerRequest(BaseModel):
203
+ id: Optional[str] = None
204
+ name: str
205
+ kind: Optional[str] = "remote"
206
+ command: Optional[str] = ""
207
+ url: Optional[str] = ""
208
+ headers: Optional[Dict[str, str]] = None
209
+ enabled: Optional[bool] = True
210
+
211
+
212
+ class BackupProvidersSaveRequest(BaseModel):
213
+ providers: List[Any]
214
+
215
+
216
+ class ThemeSelectRequest(BaseModel):
217
+ theme: str
218
+
219
+
220
+ class GestureToggleRequest(BaseModel):
221
+ id: str
222
+ enabled: bool
223
+
224
+
225
+ class ProfileActiveRequest(BaseModel):
226
+ id: Optional[str] = None
227
+ name: Optional[str] = None
228
+
229
+
230
+ class ProfileSaveRequest(BaseModel):
231
+ id: Optional[str] = None
232
+ name: Optional[str] = None
233
+ prompt: Optional[str] = None
234
+ temperature: Optional[float] = None
235
+ tone: Optional[str] = None
236
+ profile: Optional[Dict[str, Any]] = None
237
+
238
+
239
+ # --- Global State ---
240
+
241
+ class ServerStateManager:
242
+ def __init__(self):
243
+ self.preview_controllers: Dict[str, PreviewController] = {}
244
+ self.workspace_roots: Dict[str, str] = {}
245
+ self.active_websockets: Set[WebSocket] = set()
246
+ self.processes: Dict[str, asyncio.subprocess.Process] = {}
247
+
248
+ def get_preview_controller(self, workspace_id: str) -> Optional[PreviewController]:
249
+ return self.preview_controllers.get(workspace_id)
250
+
251
+ def set_workspace_root(self, workspace_id: str, path: str):
252
+ self.workspace_roots[workspace_id] = os.path.abspath(path)
253
+
254
+ def get_workspace_root(self, workspace_id: str) -> Optional[str]:
255
+ if workspace_id in self.workspace_roots:
256
+ return self.workspace_roots[workspace_id]
257
+ if workspace_id in ("current", "default", "", None):
258
+ active = ws_paths.active_project()
259
+ if active and os.path.isdir(active):
260
+ return active
261
+ candidate = os.path.abspath(os.path.expanduser(r"c:\Users\ADMIN\Downloads\compressed"))
262
+ if os.path.isdir(candidate):
263
+ return candidate
264
+ try:
265
+ recent = projects.recent()
266
+ if recent and os.path.isdir(recent[0]):
267
+ return recent[0]
268
+ except Exception:
269
+ pass
270
+ return os.getcwd()
271
+ return None
272
+
273
+
274
+ state_manager = ServerStateManager()
275
+
276
+
277
+ # --- Lifespan ---
278
+
279
+ @asynccontextmanager
280
+ async def lifespan(app: FastAPI):
281
+ # Startup
282
+ print("Starting FATTY CAT server...")
283
+ candidate = os.path.abspath(os.path.expanduser(r"c:\Users\ADMIN\Downloads\compressed"))
284
+ if os.path.isdir(candidate):
285
+ state_manager.set_workspace_root("default", candidate)
286
+ state_manager.set_workspace_root("current", candidate)
287
+ ws_paths.set_active_project(candidate)
288
+ try:
289
+ projects.record_opened(candidate)
290
+ except Exception:
291
+ pass
292
+ yield
293
+ # Shutdown
294
+ print("Shutting down FATTY CAT server...")
295
+ preview_shutdown_all()
296
+ for proc in state_manager.processes.values():
297
+ try:
298
+ proc.terminate()
299
+ except Exception:
300
+ pass
301
+
302
+
303
+ # --- FastAPI App ---
304
+
305
+ app = FastAPI(
306
+ title="FATTY CAT",
307
+ description="Mobile PWA for CAT - Coding Agent Terminal",
308
+ version="0.7.9.0",
309
+ lifespan=lifespan,
310
+ )
311
+
312
+ app.add_middleware(
313
+ CORSMiddleware,
314
+ allow_origins=["*"],
315
+ allow_credentials=True,
316
+ allow_methods=["*"],
317
+ allow_headers=["*"],
318
+ )
319
+
320
+
321
+ # --- Static Files & PWA ---
322
+
323
+ # Mount static files
324
+ static_dir = Path(__file__).parent / "static"
325
+ if static_dir.exists():
326
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
327
+
328
+ # Mount workspace files for preview. Writable preview workspaces MUST live in
329
+ # the user's app-data dir — never inside site-packages (read-only when
330
+ # installed) and never tied to the git checkout. Falls back to the legacy
331
+ # repo-relative location only if the app-data dir is unavailable.
332
+ def _preview_workspaces_dir() -> Path:
333
+ try:
334
+ from calc_terminal.first_run import data_dir as _app_data_dir
335
+
336
+ p = Path(_app_data_dir()) / "workspaces"
337
+ p.mkdir(parents=True, exist_ok=True)
338
+ return p
339
+ except Exception:
340
+ pass
341
+ try:
342
+ legacy = Path(__file__).parent.parent.parent / "workspaces"
343
+ legacy.mkdir(exist_ok=True)
344
+ return legacy
345
+ except Exception:
346
+ return Path.cwd() / "workspaces"
347
+
348
+
349
+ workspaces_dir = _preview_workspaces_dir()
350
+
351
+
352
+ # --- Root Route (PWA Entry) ---
353
+
354
+ @app.get("/", response_class=HTMLResponse)
355
+ async def serve_index():
356
+ index_path = static_dir / "index.html"
357
+ if index_path.exists():
358
+ return HTMLResponse(content=index_path.read_text(encoding="utf-8"))
359
+ return HTMLResponse(content="<h1>FATTY CAT</h1><p>index.html not found.</p>", status_code=404)
360
+
361
+
362
+ # --- Health Check ---
363
+
364
+ @app.get("/health")
365
+ async def health_check():
366
+ return {"status": "ok", "service": "FATTY CAT", "version": "0.7.9.0"}
367
+
368
+
369
+ # --- Platform Info ---
370
+
371
+ @app.get("/api/platform")
372
+ async def get_platform_info():
373
+ from calc_terminal.platform import get_platform
374
+ platform = get_platform()
375
+ info = platform.info
376
+ return {
377
+ "type": info.type.value,
378
+ "name": info.name,
379
+ "version": info.version,
380
+ "is_mobile": info.is_mobile,
381
+ "supports_file_system_access": info.supports_file_system_access,
382
+ "supports_notifications": info.supports_notifications,
383
+ }
384
+
385
+
386
+ # --- Capability Discovery & System Health ---
387
+
388
+ @app.get("/api/capabilities")
389
+ async def get_capabilities():
390
+ """Return complete dynamic CAT capability registry and version contract."""
391
+ return runtime.get_capabilities()
392
+
393
+
394
+ @app.get("/api/auth/status")
395
+ async def get_auth_status():
396
+ """Get Fomoji identity and authentication state."""
397
+ try:
398
+ from calc_terminal import fomoji_auth
399
+ return {
400
+ "authenticated": fomoji_auth.is_authenticated(),
401
+ "identity": fomoji_auth.get_identity(),
402
+ "state": fomoji_auth.status(),
403
+ "fomoji_url": fomoji_auth.get_fomoji_url(),
404
+ }
405
+ except Exception as e:
406
+ return {"authenticated": False, "identity": None, "error": str(e)}
407
+
408
+
409
+ @app.get("/api/diagnostics")
410
+ async def get_diagnostics():
411
+ """Return runtime metrics, event log, active model/provider, hardware specs."""
412
+ return runtime.get_diagnostics()
413
+
414
+
415
+ # --- Permissions ---
416
+
417
+ @app.get("/api/permissions")
418
+ async def get_permissions():
419
+ """Get current permission state."""
420
+ snapshot = perm_manager.snapshot()
421
+ return {
422
+ "mode": perm_manager.mode,
423
+ "mode_label": perm_manager.mode_label(),
424
+ "permissions": [
425
+ {"key": k, "label": l, "enabled": v} for k, l, v in snapshot
426
+ ],
427
+ }
428
+
429
+
430
+ @app.post("/api/permissions/mode")
431
+ async def set_permission_mode(request: PermissionModeRequest):
432
+ """Set permission mode (ask/restricted/full)."""
433
+ if request.mode not in MODES:
434
+ raise HTTPException(status_code=400, detail=f"Invalid mode: {request.mode}")
435
+ perm_manager.set_mode(request.mode)
436
+ return {"mode": perm_manager.mode, "mode_label": perm_manager.mode_label()}
437
+
438
+
439
+ @app.post("/api/permissions/toggle")
440
+ async def toggle_permission(request: PermissionToggleRequest):
441
+ """Toggle a permission key on/off."""
442
+ result = perm_manager.set(request.key, request.enabled)
443
+ return {"key": request.key, "enabled": request.enabled}
444
+
445
+
446
+ @app.post("/api/permissions/decide")
447
+ async def decide_permission(request: PermissionDecideRequest):
448
+ """Record a permission decision (allow_once/always_allow/deny/always_deny)."""
449
+ allowed = perm_manager.decide(request.key, request.decision, request.action_text or "")
450
+ return {"key": request.key, "decision": request.decision, "allowed": allowed}
451
+
452
+
453
+ @app.get("/api/permissions/log")
454
+ async def permission_log():
455
+ """Get the permission decision log."""
456
+ return {
457
+ "log": [
458
+ {"time": t, "key": k, "action": a, "decision": d}
459
+ for t, k, a, d in perm_manager.log
460
+ ]
461
+ }
462
+
463
+
464
+ # --- Main Menu Dynamic Endpoint ---
465
+
466
+ @app.get("/api/menu")
467
+ async def get_main_menu():
468
+ """Dynamically get the exact Main Menu structure from CAT CLI.
469
+ Directly bridges to calc_terminal.ui.header.get_nav_items().
470
+ Includes dynamic 'Downloaded Extensions ({count})' and real installed extensions.
471
+ """
472
+ try:
473
+ items = cct_header.get_nav_items()
474
+ except Exception:
475
+ items = cct_header._NAV_ITEMS_BASE
476
+ menu_items = []
477
+ for item in items:
478
+ action_id, icon, label = item
479
+ menu_items.append({
480
+ "action": action_id,
481
+ "icon": icon,
482
+ "label": label,
483
+ "is_separator": action_id.startswith("sep"),
484
+ "is_section_header": action_id.startswith("__section"),
485
+ "is_placeholder": action_id.startswith("__placeholder") or action_id.startswith("__hint"),
486
+ })
487
+ installed = cct_ext.list_installed()
488
+ return {
489
+ "items": menu_items,
490
+ "downloaded_count": len(installed),
491
+ "installed": installed,
492
+ }
493
+
494
+
495
+ # --- Filesystem Browser & Path Validation (Method A & Method B) ---
496
+
497
+ @app.get("/api/fs/browse")
498
+ async def browse_filesystem(path: Optional[str] = None):
499
+ """Real filesystem browser for Method A (Browse).
500
+ Navigates actual folders on the host, parent folders, drives (Windows),
501
+ and enforces protected directory warnings.
502
+ """
503
+ import string
504
+ if not path or not path.strip():
505
+ active = ws_paths.active_project()
506
+ if active and os.path.isdir(active):
507
+ target = os.path.abspath(active)
508
+ else:
509
+ target = os.path.abspath(os.path.expanduser("~"))
510
+ else:
511
+ target = os.path.abspath(os.path.expanduser(path.strip()))
512
+
513
+ if not os.path.exists(target):
514
+ target = os.path.abspath(os.path.expanduser("~"))
515
+
516
+ drives = []
517
+ if sys.platform == "win32":
518
+ for letter in string.ascii_uppercase:
519
+ d_root = f"{letter}:\\"
520
+ if os.path.exists(d_root):
521
+ drives.append(d_root)
522
+ else:
523
+ drives.append("/")
524
+
525
+ parent_path = str(Path(target).parent) if Path(target).parent != Path(target) else None
526
+ items = []
527
+ err = None
528
+ try:
529
+ with os.scandir(target) as it:
530
+ for entry in it:
531
+ try:
532
+ if entry.is_dir(follow_symlinks=False):
533
+ p = entry.path
534
+ is_prot = ws_paths.path_is_protected(p)
535
+ items.append({
536
+ "name": entry.name,
537
+ "path": p,
538
+ "is_dir": True,
539
+ "protected": is_prot,
540
+ })
541
+ except (PermissionError, OSError):
542
+ continue
543
+ items.sort(key=lambda x: x["name"].lower())
544
+ except (PermissionError, OSError) as e:
545
+ err = str(e)
546
+
547
+ return {
548
+ "current_path": target,
549
+ "parent_path": parent_path,
550
+ "drives": drives,
551
+ "folders": items,
552
+ "error": err,
553
+ "protected": ws_paths.path_is_protected(target),
554
+ }
555
+
556
+
557
+ @app.get("/api/fs/validate")
558
+ async def validate_filesystem_path(path: str):
559
+ """Validate path for Method B (Enter Path) with live validation."""
560
+ if not path or not path.strip():
561
+ return {"valid": False, "message": "Please enter a folder path"}
562
+ expanded = os.path.abspath(os.path.expanduser(path.strip()))
563
+ if not os.path.exists(expanded):
564
+ return {"valid": False, "message": "⚠ Path does not exist", "path": expanded}
565
+ if not os.path.isdir(expanded):
566
+ return {"valid": False, "message": "⚠ Not a directory", "path": expanded}
567
+ if not os.access(expanded, os.R_OK):
568
+ return {"valid": False, "message": "⚠ No read permission", "path": expanded}
569
+ if ws_paths.path_is_protected(expanded):
570
+ return {"valid": False, "message": "⚠ Protected system folder — refusing to use as workspace", "path": expanded}
571
+ name = os.path.basename(expanded) or expanded
572
+ return {"valid": True, "message": f"✓ {name} — folder is valid", "path": expanded, "name": name}
573
+
574
+
575
+ # --- Workspace Management ---
576
+
577
+ @app.post("/api/workspace/open")
578
+ async def open_workspace(request: WorkspaceRequest):
579
+ """Open folder using the exact CAT workspace logic."""
580
+ path = os.path.abspath(os.path.expanduser(request.path))
581
+ if not os.path.exists(path):
582
+ try:
583
+ os.makedirs(path, exist_ok=True)
584
+ except Exception:
585
+ raise HTTPException(status_code=400, detail=f"Path does not exist and could not be created: {path}")
586
+ if not os.path.isdir(path):
587
+ raise HTTPException(status_code=400, detail=f"Not a valid directory: {path}")
588
+ if not os.access(path, os.R_OK):
589
+ raise HTTPException(status_code=403, detail=f"No read permission for directory: {path}")
590
+ if ws_paths.path_is_protected(path):
591
+ raise HTTPException(status_code=403, detail=f"Refusing to open protected system folder: {path}")
592
+
593
+ # Set as active project in both CAT and server
594
+ state_manager.set_workspace_root("default", path)
595
+ state_manager.set_workspace_root("current", path)
596
+ ws_paths.set_active_project(path)
597
+ try:
598
+ projects.record_opened(path)
599
+ except Exception as e:
600
+ print(f"projects.record_opened failed: {e}")
601
+
602
+ try:
603
+ eventbus.bus.publish(eventbus.WORKSPACE_OPENED, path=path)
604
+ except Exception:
605
+ pass
606
+
607
+ return {
608
+ "id": "current",
609
+ "workspace_id": "current",
610
+ "path": path,
611
+ "name": os.path.basename(path) or path,
612
+ }
613
+
614
+
615
+ @app.get("/api/workspace/recent")
616
+ async def recent_workspaces():
617
+ """Get full list of workspaces from projects.workspaces()."""
618
+ try:
619
+ ws_list = projects.workspaces()
620
+ for w in ws_list:
621
+ w["exists"] = os.path.isdir(w.get("path", ""))
622
+ return {"workspaces": ws_list, "recent": projects.recent()}
623
+ except Exception as e:
624
+ return {"workspaces": [], "recent": [], "error": str(e)}
625
+
626
+
627
+ @app.post("/api/workspace/recent/pin")
628
+ async def toggle_pin_recent(request: RecentPinRequest):
629
+ """Toggle pin state for a recent workspace."""
630
+ pinned = projects.toggle_pin(request.path)
631
+ return {"path": request.path, "pinned": pinned}
632
+
633
+
634
+ @app.post("/api/workspace/recent/remove")
635
+ async def remove_recent_workspace(request: RecentRemoveRequest):
636
+ """Remove workspace from recent history."""
637
+ removed = projects.remove(request.path)
638
+ return {"path": request.path, "removed": removed}
639
+
640
+
641
+ @app.post("/api/workspace/recent/clear")
642
+ async def clear_recent_workspaces():
643
+ """Clear all recent workspaces."""
644
+ cleared = projects.clear_history()
645
+ return {"cleared": cleared}
646
+
647
+
648
+ # --- Chats & Session History (Real CAT chat_store) ---
649
+
650
+ @app.get("/api/chats")
651
+ async def list_saved_chats():
652
+ """List all saved chat summaries from CAT chat_store."""
653
+ return {"chats": chat_store.get_chat_summaries()}
654
+
655
+
656
+ @app.get("/api/chats/{chat_id}")
657
+ async def get_chat_session(chat_id: str):
658
+ """Get full chat with turns."""
659
+ chat = chat_store.get_chat(chat_id)
660
+ if not chat:
661
+ raise HTTPException(status_code=404, detail="Chat session not found")
662
+ return chat
663
+
664
+
665
+ @app.post("/api/chats")
666
+ async def create_new_chat(req: ChatCreateRequest):
667
+ """Create a new empty chat session."""
668
+ active_ws = ws_paths.active_project() or ""
669
+ chat = chat_store.create_chat(
670
+ name=req.name,
671
+ workspace_root=req.project_path or active_ws,
672
+ project_path=req.project_path or active_ws,
673
+ )
674
+ return chat
675
+
676
+
677
+ @app.post("/api/chats/{chat_id}/rename")
678
+ async def rename_chat_session(chat_id: str, req: ChatRenameRequest):
679
+ """Rename a chat session."""
680
+ ok = chat_store.rename_chat(chat_id, req.name)
681
+ if not ok:
682
+ raise HTTPException(status_code=404, detail="Chat session not found")
683
+ return {"success": True, "id": chat_id, "name": req.name}
684
+
685
+
686
+ @app.delete("/api/chats/{chat_id}")
687
+ async def delete_chat_session(chat_id: str):
688
+ """Delete a chat session from disk."""
689
+ ok = chat_store.delete_chat(chat_id)
690
+ return {"success": ok}
691
+
692
+
693
+ # --- Model Context Protocol (Real CAT mcp.py) ---
694
+
695
+ @app.get("/api/mcp/servers")
696
+ async def get_mcp_servers():
697
+ """List all configured MCP servers with real live status."""
698
+ servers = mcp.load_servers()
699
+ enriched = []
700
+ for s in servers:
701
+ st = mcp.server_status(s)
702
+ rec = dict(s)
703
+ rec.update(st)
704
+ enriched.append(rec)
705
+ return {"servers": enriched}
706
+
707
+
708
+ @app.post("/api/mcp/servers")
709
+ async def add_or_update_mcp_server(req: McpServerRequest):
710
+ """Add or update an MCP server configuration."""
711
+ entry = req.dict()
712
+ sid = mcp.add_server(entry)
713
+ return {"id": sid, "success": True}
714
+
715
+
716
+ @app.delete("/api/mcp/servers/{server_id}")
717
+ async def remove_mcp_server(server_id: str):
718
+ """Remove an MCP server."""
719
+ mcp.disconnect_server(server_id)
720
+ mcp.remove_server(server_id)
721
+ return {"success": True}
722
+
723
+
724
+ @app.post("/api/mcp/servers/{server_id}/connect")
725
+ async def connect_mcp_server(server_id: str):
726
+ """Connect to an MCP server."""
727
+ servers = mcp.load_servers()
728
+ matched = next((s for s in servers if s.get("id") == server_id), None)
729
+ if not matched:
730
+ raise HTTPException(status_code=404, detail="Server not found")
731
+ ok, msg = mcp.connect_server(matched)
732
+ status = mcp.server_status(matched)
733
+ return {"success": ok, "message": msg, "status": status}
734
+
735
+
736
+ @app.post("/api/mcp/servers/{server_id}/disconnect")
737
+ async def disconnect_mcp_server(server_id: str):
738
+ """Disconnect from an MCP server."""
739
+ mcp.disconnect_server(server_id)
740
+ return {"success": True}
741
+
742
+
743
+ # --- Backup Providers (Automatic Failover Chain) ---
744
+
745
+ @app.get("/api/backup-providers")
746
+ async def get_backup_providers():
747
+ """Get the failover chain of backup providers."""
748
+ providers = pm.load_backup_providers()
749
+ return {"providers": providers, "configs": pm.backup_configs()}
750
+
751
+
752
+ DEFAULT_BACKUP_MODELS = {
753
+ "ollama": "llama3.3",
754
+ "groq": "llama-3.3-70b-versatile",
755
+ "openrouter": "openai/gpt-4o-mini",
756
+ "openai": "gpt-4o-mini",
757
+ "deepseek": "deepseek-chat",
758
+ "anthropic": "claude-3-5-sonnet-20241022",
759
+ }
760
+
761
+
762
+ @app.post("/api/backup-providers")
763
+ async def save_backup_providers_chain(req: BackupProvidersSaveRequest):
764
+ """Save the reordered/configured failover chain."""
765
+ formatted = []
766
+ for item in req.providers:
767
+ if isinstance(item, str):
768
+ p = item.lower()
769
+ m = DEFAULT_BACKUP_MODELS.get(p, "default-model")
770
+ formatted.append({
771
+ "provider": p,
772
+ "model": m,
773
+ "enabled": True,
774
+ "api_style": "ollama" if p == "ollama" else "openai",
775
+ "base_url": "http://localhost:11434" if p == "ollama" else "",
776
+ })
777
+ elif isinstance(item, dict):
778
+ entry = dict(item)
779
+ p = str(entry.get("provider", "")).lower()
780
+ if not entry.get("model"):
781
+ entry["model"] = DEFAULT_BACKUP_MODELS.get(p, "default-model")
782
+ formatted.append(entry)
783
+ ok = pm.save_backup_providers(formatted)
784
+ return {"success": ok, "providers": pm.load_backup_providers()}
785
+
786
+
787
+ # --- Themes & Styling (CAT theme.py + theme_css.py) ---
788
+
789
+ @app.get("/api/themes")
790
+ async def get_themes_info():
791
+ """Get current theme, available themes, and Textual CSS variables."""
792
+ cur = theme.get_theme()
793
+ avail = theme.available_themes()
794
+ themed_list = []
795
+ for name in avail:
796
+ resolved = theme.resolve_theme_name(name)
797
+ th_obj = theme._THEMES.get(resolved)
798
+ is_dark = th_obj.dark if th_obj else True
799
+ themed_list.append({
800
+ "name": name,
801
+ "label": theme.theme_label(name),
802
+ "active": name == cur,
803
+ "is_light": not is_dark,
804
+ })
805
+ return {
806
+ "current": cur,
807
+ "is_light": theme.is_light(),
808
+ "themes": themed_list,
809
+ "css_variables": theme_css.css_variables(),
810
+ }
811
+
812
+
813
+ @app.post("/api/themes/select")
814
+ async def select_theme(req: ThemeSelectRequest):
815
+ """Apply theme by name and return updated CSS variables."""
816
+ applied = theme.set_theme(req.theme)
817
+ return {
818
+ "theme": applied,
819
+ "css_variables": theme_css.css_variables(),
820
+ "is_light": theme.is_light(),
821
+ }
822
+
823
+
824
+ # --- Extensions Management ---
825
+
826
+ @app.post("/api/extensions/{ext_id}/install")
827
+ async def install_extension(ext_id: str):
828
+ ok = cct_ext.install(ext_id)
829
+ return {"id": ext_id, "success": ok, "state": cct_ext.get_state(ext_id)}
830
+
831
+
832
+ @app.post("/api/extensions/{ext_id}/uninstall")
833
+ async def uninstall_extension(ext_id: str):
834
+ ok = cct_ext.uninstall(ext_id)
835
+ return {"id": ext_id, "success": ok, "state": cct_ext.get_state(ext_id)}
836
+
837
+
838
+ @app.post("/api/extensions/{ext_id}/enable")
839
+ async def enable_extension(ext_id: str):
840
+ ok = cct_ext.enable(ext_id)
841
+ return {"id": ext_id, "success": ok, "state": cct_ext.get_state(ext_id)}
842
+
843
+
844
+ @app.post("/api/extensions/{ext_id}/disable")
845
+ async def disable_extension(ext_id: str):
846
+ ok = cct_ext.disable(ext_id)
847
+ return {"id": ext_id, "success": ok, "state": cct_ext.get_state(ext_id)}
848
+
849
+
850
+ # --- Users & Authentication ---
851
+
852
+ @app.get("/api/user/profile")
853
+ async def get_user_profile():
854
+ """Get full user identity, app metadata, and session status."""
855
+ ident = fomoji_auth.get_identity() or {}
856
+ auth_ok = fomoji_auth.is_authenticated()
857
+ mem_turns = 0
858
+ try:
859
+ from calc_terminal import memory
860
+ mem_turns = len(memory.get_facts())
861
+ except Exception:
862
+ pass
863
+ saved_chats = len(chat_store.get_chat_summaries())
864
+ cfg = aicore.load_config()
865
+
866
+ return {
867
+ "authenticated": auth_ok,
868
+ "user": {
869
+ "name": ident.get("name", "Local Developer"),
870
+ "email": ident.get("email", "local@cat.terminal"),
871
+ "role": ident.get("role", "developer"),
872
+ },
873
+ "app": {
874
+ "name": identity.APP_NAME,
875
+ "short_name": identity.SHORT_NAME,
876
+ "version": identity.APP_VERSION,
877
+ "creator": identity.CREATOR_NAME,
878
+ "tagline": identity.APP_TAGLINE,
879
+ "tech_stack": identity.tech_stack_sentence(cfg),
880
+ },
881
+ "active_model": cfg.get("model", "deepseek-r1"),
882
+ "active_provider": cfg.get("provider", "ollama"),
883
+ "memory_facts_count": mem_turns,
884
+ "saved_chats_count": saved_chats,
885
+ }
886
+
887
+
888
+ @app.post("/api/auth/logout")
889
+ async def perform_logout():
890
+ """Full CAT logout: revokes token, stops auth server, clears session."""
891
+ try:
892
+ fomoji_auth.logout()
893
+ except Exception as e:
894
+ print(f"Logout error: {e}")
895
+ return {"success": True, "message": "Signed out successfully."}
896
+
897
+
898
+ # --- Startup & Welcome Lifecycle (Matching Image 2) ---
899
+
900
+ @app.get("/api/startup/status")
901
+ async def get_startup_status():
902
+ """Get startup status for WelcomeModal lifecycle."""
903
+ seen = welcome_modal.has_seen_version()
904
+ highlights = [
905
+ {"icon": h[0], "name": h[1], "desc": h[2]}
906
+ for h in welcome_modal._HIGHLIGHTS
907
+ ]
908
+ return {
909
+ "version": welcome_modal._VERSION,
910
+ "has_seen": seen,
911
+ "highlights": highlights,
912
+ "tagline": "Coding \u00b7 Agents \u00b7 Intelligence \u2014 the terminal for builders.",
913
+ }
914
+
915
+
916
+ @app.post("/api/startup/dismiss")
917
+ async def dismiss_startup():
918
+ """Mark version welcome as seen."""
919
+ welcome_modal.mark_seen()
920
+ return {"success": True}
921
+
922
+
923
+ # --- Gestures & Personalization ---
924
+
925
+ @app.get("/api/gestures")
926
+ async def get_gestures():
927
+ """Get all gesture bindings from gestures manager."""
928
+ return {"gestures": gestures_mgr.list_gestures()}
929
+
930
+
931
+ @app.post("/api/gestures/toggle")
932
+ async def toggle_gesture(req: GestureToggleRequest):
933
+ """Toggle gesture enabled state."""
934
+ g = gestures_mgr.update_gesture(req.id, enabled=req.enabled)
935
+ return {"id": req.id, "enabled": req.enabled, "success": g is not None}
936
+
937
+
938
+ @app.get("/api/personalize")
939
+ async def get_personalize():
940
+ """Get personal profiles and active profile."""
941
+ data = ap.load_profiles()
942
+ return {
943
+ "active": data.get("active", ""),
944
+ "profiles": data.get("profiles", []),
945
+ "tones": ap.TONES,
946
+ }
947
+
948
+
949
+ @app.post("/api/personalize/active")
950
+ async def set_active_personalize_profile(req: ProfileActiveRequest):
951
+ """Switch active profile."""
952
+ target = req.name or req.id or ""
953
+ try:
954
+ ap.set_active(target)
955
+ return {"name": target, "id": target, "success": True}
956
+ except Exception:
957
+ return {"name": target, "id": target, "success": False}
958
+
959
+
960
+ @app.post("/api/personalize/save")
961
+ async def save_personalize_profile(req: ProfileSaveRequest):
962
+ """Save/update profile."""
963
+ prof = req.profile or req.dict(exclude_unset=True)
964
+ if "profile" in prof:
965
+ prof.pop("profile", None)
966
+ name = prof.get("name") or prof.get("id") or "Custom"
967
+ tone = prof.get("tone", "balanced")
968
+ additions = prof.get("additions") or prof.get("prompt") or ""
969
+ temp = prof.get("temperature")
970
+ try:
971
+ res = ap.add_profile(name=name, tone=tone, additions=additions, temperature=temp)
972
+ return {"profile": res, "success": True}
973
+ except Exception as e:
974
+ return {"profile": prof, "success": False, "error": str(e)}
975
+
976
+
977
+ @app.get("/api/workspace/current")
978
+ async def current_workspace():
979
+ """Get currently active workspace root and info."""
980
+ root = state_manager.get_workspace_root("current")
981
+ if not root:
982
+ return {"id": None, "path": None, "name": None}
983
+ return {
984
+ "id": "current",
985
+ "workspace_id": "current",
986
+ "path": root,
987
+ "name": os.path.basename(root) or root,
988
+ }
989
+
990
+
991
+ @app.get("/api/workspace/{workspace_id}/tree")
992
+ async def get_workspace_tree(workspace_id: str):
993
+ """Return tree structure and indexed language/file summary for workspace."""
994
+ root = state_manager.get_workspace_root(workspace_id)
995
+ if not root or not os.path.isdir(root):
996
+ raise HTTPException(status_code=404, detail="Workspace not found")
997
+
998
+ # Run scan for stats
999
+ try:
1000
+ from calc_terminal import workspace_index
1001
+ ctx = workspace_index.scan(root)
1002
+ except Exception:
1003
+ ctx = {"languages": [], "file_count": 0}
1004
+
1005
+ skip_names = {".git", ".pytest_cache", "__pycache__", "node_modules", ".venv", "venv", ".idea", ".vscode"}
1006
+
1007
+ def walk_dir(current_path: Path, rel_path: str = ""):
1008
+ children = []
1009
+ try:
1010
+ entries = sorted(list(current_path.iterdir()), key=lambda e: (not e.is_dir(), e.name.lower()))
1011
+ for entry in entries:
1012
+ if entry.name in skip_names or entry.name.startswith("."):
1013
+ continue
1014
+ entry_rel = f"{rel_path}/{entry.name}" if rel_path else entry.name
1015
+ if entry.is_dir():
1016
+ children.append({
1017
+ "name": entry.name,
1018
+ "path": entry_rel,
1019
+ "type": "directory",
1020
+ "children": walk_dir(entry, entry_rel),
1021
+ })
1022
+ elif entry.is_file():
1023
+ children.append({
1024
+ "name": entry.name,
1025
+ "path": entry_rel,
1026
+ "type": "file",
1027
+ "ext": entry.suffix.lower(),
1028
+ "size": entry.stat().st_size,
1029
+ })
1030
+ except Exception:
1031
+ pass
1032
+ return children
1033
+
1034
+ langs = ctx.get("languages", [])
1035
+ f_count = ctx.get("file_count", 0)
1036
+ summary_str = f"{', '.join(langs[:3])} · {f_count}" if langs else (f"{f_count} files" if f_count else "")
1037
+
1038
+ return {
1039
+ "name": os.path.basename(root) or root,
1040
+ "path": "",
1041
+ "full_path": root,
1042
+ "type": "directory",
1043
+ "children": walk_dir(Path(root)),
1044
+ "summary": {
1045
+ "languages": langs,
1046
+ "file_count": f_count,
1047
+ "summary_line": summary_str,
1048
+ },
1049
+ "recent": [
1050
+ {"path": p, "name": os.path.basename(p.rstrip(os.sep)) or p}
1051
+ for p in projects.recent()[:10]
1052
+ ]
1053
+ }
1054
+
1055
+
1056
+ @app.get("/api/workspace/{workspace_id}/files")
1057
+ async def list_workspace_files(workspace_id: str):
1058
+ root = state_manager.get_workspace_root(workspace_id)
1059
+ if not root:
1060
+ raise HTTPException(status_code=404, detail="Workspace not found")
1061
+
1062
+ files = []
1063
+ for entry in Path(root).rglob("*"):
1064
+ if entry.is_file():
1065
+ rel = entry.relative_to(root)
1066
+ stat = entry.stat()
1067
+ files.append({
1068
+ "path": str(rel),
1069
+ "size": stat.st_size,
1070
+ "mtime": stat.st_mtime,
1071
+ "is_binary": _is_binary_file(entry),
1072
+ })
1073
+ return {"files": files}
1074
+
1075
+
1076
+ @app.get("/api/workspace/{workspace_id}/file")
1077
+ async def read_workspace_file(workspace_id: str, path: str):
1078
+ root = state_manager.get_workspace_root(workspace_id)
1079
+ if not root:
1080
+ raise HTTPException(status_code=404, detail="Workspace not found")
1081
+
1082
+ file_path = Path(root) / path
1083
+ if not file_path.exists() or not file_path.is_file():
1084
+ raise HTTPException(status_code=404, detail="File not found")
1085
+
1086
+ # Security: ensure path is within workspace
1087
+ try:
1088
+ file_path.resolve().relative_to(Path(root).resolve())
1089
+ except ValueError:
1090
+ raise HTTPException(status_code=403, detail="Access denied")
1091
+
1092
+ if _is_binary_file(file_path):
1093
+ return FileResponse(str(file_path))
1094
+
1095
+ content = file_path.read_text(encoding="utf-8", errors="replace")
1096
+ return {"path": path, "content": content}
1097
+
1098
+
1099
+ @app.post("/api/workspace/{workspace_id}/file")
1100
+ async def write_workspace_file(workspace_id: str, request: FileWriteRequest):
1101
+ root = state_manager.get_workspace_root(workspace_id)
1102
+ if not root:
1103
+ raise HTTPException(status_code=404, detail="Workspace not found")
1104
+
1105
+ file_path = Path(root) / request.path
1106
+ try:
1107
+ file_path.resolve().relative_to(Path(root).resolve())
1108
+ except ValueError:
1109
+ raise HTTPException(status_code=403, detail="Access denied")
1110
+
1111
+ file_path.parent.mkdir(parents=True, exist_ok=True)
1112
+ file_path.write_text(request.content, encoding=request.encoding or "utf-8")
1113
+
1114
+ # Notify preview controller of file change
1115
+ ctrl = state_manager.get_preview_controller(workspace_id)
1116
+ if ctrl:
1117
+ ctrl.notify_ai_wrote(str(file_path))
1118
+
1119
+ return {"success": True, "path": request.path}
1120
+
1121
+
1122
+ @app.delete("/api/workspace/{workspace_id}/file")
1123
+ async def delete_workspace_file(workspace_id: str, path: str):
1124
+ root = state_manager.get_workspace_root(workspace_id)
1125
+ if not root:
1126
+ raise HTTPException(status_code=404, detail="Workspace not found")
1127
+
1128
+ file_path = Path(root) / path
1129
+ try:
1130
+ file_path.resolve().relative_to(Path(root).resolve())
1131
+ except ValueError:
1132
+ raise HTTPException(status_code=403, detail="Access denied")
1133
+
1134
+ if file_path.exists():
1135
+ file_path.unlink()
1136
+
1137
+ return {"success": True}
1138
+
1139
+
1140
+ # --- Command Execution ---
1141
+
1142
+ @app.post("/api/command", response_model=CommandResponse)
1143
+ async def run_command(request: CommandRequest):
1144
+ cwd = request.cwd or os.getcwd()
1145
+ env = os.environ.copy()
1146
+ if request.env:
1147
+ env.update(request.env)
1148
+
1149
+ # Run command in thread pool to avoid blocking the event loop
1150
+ loop = asyncio.get_event_loop()
1151
+ result = await loop.run_in_executor(
1152
+ None, _run_command_sync, request.cmd, cwd, env, request.timeout
1153
+ )
1154
+ return result
1155
+
1156
+
1157
+ def _run_command_sync(cmd: str, cwd: str, env: dict, timeout: float) -> CommandResponse:
1158
+ """Run a shell command synchronously (called from thread)."""
1159
+ import subprocess
1160
+ try:
1161
+ proc = subprocess.Popen(
1162
+ cmd,
1163
+ shell=True,
1164
+ cwd=cwd,
1165
+ env=env,
1166
+ stdout=subprocess.PIPE,
1167
+ stderr=subprocess.PIPE,
1168
+ stdin=subprocess.DEVNULL,
1169
+ )
1170
+ try:
1171
+ stdout, stderr = proc.communicate(timeout=timeout)
1172
+ except subprocess.TimeoutExpired:
1173
+ proc.kill()
1174
+ stdout, stderr = proc.communicate()
1175
+ return CommandResponse(
1176
+ returncode=-1,
1177
+ stdout=stdout.decode("utf-8", errors="replace"),
1178
+ stderr=stderr.decode("utf-8", errors="replace") + "\n[Timed out]",
1179
+ success=False,
1180
+ )
1181
+ return CommandResponse(
1182
+ returncode=proc.returncode or 0,
1183
+ stdout=stdout.decode("utf-8", errors="replace"),
1184
+ stderr=stderr.decode("utf-8", errors="replace"),
1185
+ success=(proc.returncode or 0) == 0,
1186
+ )
1187
+ except Exception as e:
1188
+ return CommandResponse(
1189
+ returncode=-1, stdout="", stderr=str(e), success=False,
1190
+ )
1191
+
1192
+
1193
+ # --- Preview / Live Server ---
1194
+
1195
+ @app.post("/api/preview/start")
1196
+ async def start_preview(request: PreviewStartRequest, workspace_id: Optional[str] = "current"):
1197
+ ws_id = workspace_id or "current"
1198
+ root = state_manager.get_workspace_root(ws_id)
1199
+ if not root:
1200
+ raise HTTPException(status_code=404, detail="Workspace not found")
1201
+
1202
+ file_path = os.path.join(root, request.file_path)
1203
+ if not os.path.exists(file_path):
1204
+ raise HTTPException(status_code=404, detail="File not found")
1205
+
1206
+ # Determine effective project root for preview server
1207
+ project_dir = os.path.dirname(file_path) if os.path.isfile(file_path) else root
1208
+ ctrl_key = f"{ws_id}_{project_dir}"
1209
+
1210
+ # Create or get preview controller
1211
+ if ctrl_key not in state_manager.preview_controllers:
1212
+ state_manager.preview_controllers[ctrl_key] = PreviewController(
1213
+ root=project_dir,
1214
+ on_event=lambda kind, **info: asyncio.create_task(
1215
+ _broadcast_preview_event(ws_id, kind, info)
1216
+ ),
1217
+ )
1218
+
1219
+ ctrl = state_manager.preview_controllers[ctrl_key]
1220
+ success = ctrl.start_for_file(file_path)
1221
+
1222
+ if not success:
1223
+ raise HTTPException(status_code=500, detail=f"Preview failed: {ctrl.last_error}")
1224
+
1225
+ return {
1226
+ "url": ctrl.url,
1227
+ "base_url": ctrl.base_url,
1228
+ "entry_url": ctrl.entry_url,
1229
+ }
1230
+
1231
+
1232
+ @app.post("/api/preview/stop")
1233
+ async def stop_preview(workspace_id: str):
1234
+ ctrl = state_manager.get_preview_controller(workspace_id)
1235
+ if ctrl:
1236
+ ctrl.stop_preview()
1237
+ del state_manager.preview_controllers[workspace_id]
1238
+ return {"success": True}
1239
+
1240
+
1241
+ @app.post("/api/preview/reload")
1242
+ async def reload_preview(workspace_id: str):
1243
+ ctrl = state_manager.get_preview_controller(workspace_id)
1244
+ if not ctrl:
1245
+ raise HTTPException(status_code=404, detail="Preview not running")
1246
+
1247
+ snap = ctrl.reload()
1248
+ return {"success": snap.ok if snap else False, "error": snap.error if snap and not snap.ok else None}
1249
+
1250
+
1251
+ @app.get("/api/preview/status")
1252
+ async def preview_status(workspace_id: str):
1253
+ ctrl = state_manager.get_preview_controller(workspace_id)
1254
+ if not ctrl:
1255
+ return {"running": False}
1256
+
1257
+ return {
1258
+ "running": ctrl.running,
1259
+ "url": ctrl.url,
1260
+ "base_url": ctrl.base_url,
1261
+ "server_state": ctrl.server_state.name,
1262
+ "preview_state": ctrl.preview_state.name,
1263
+ "error": ctrl.last_error,
1264
+ }
1265
+
1266
+
1267
+ # --- AI Chat ---
1268
+
1269
+ @app.post("/api/chat")
1270
+ async def chat(request: ChatRequest):
1271
+ # Set workspace if provided
1272
+ if request.project_path:
1273
+ ws_paths.set_active_project(request.project_path)
1274
+
1275
+ # Record user turn if chat_id provided
1276
+ cid = request.chat_id
1277
+ if cid:
1278
+ try:
1279
+ chat_store.add_turn(cid, "user", request.message)
1280
+ except Exception:
1281
+ pass
1282
+
1283
+ # Get AI config
1284
+ config = aicore.load_config()
1285
+
1286
+ # Process the message (run_in_executor to avoid blocking)
1287
+ try:
1288
+ loop = asyncio.get_event_loop()
1289
+ result = await loop.run_in_executor(
1290
+ None, _process_chat_message, request.message, request.mode or "build", config
1291
+ )
1292
+ if cid:
1293
+ result["chat_id"] = cid
1294
+ if result.get("response"):
1295
+ try:
1296
+ chat_store.add_turn(cid, "assistant", result["response"])
1297
+ except Exception:
1298
+ pass
1299
+ return result
1300
+ except Exception as e:
1301
+ prov = config.get("provider", "ollama").upper()
1302
+ mod = config.get("model", "deepseek-r1")
1303
+ err_res = {
1304
+ "response": f"Error communicating with AI: {e}",
1305
+ "provider": prov,
1306
+ "model": mod,
1307
+ "tokens": 0,
1308
+ "time_str": "0:01",
1309
+ "timings": {"ttfb_ms": 500, "memory_ms": 20, "loop_ms": 520},
1310
+ "calls": 1,
1311
+ "success": False,
1312
+ }
1313
+ if cid:
1314
+ err_res["chat_id"] = cid
1315
+ return err_res
1316
+
1317
+
1318
+ # --- Agent / Build / Plan ---
1319
+
1320
+ class AgentRequest(BaseModel):
1321
+ message: str
1322
+ mode: Optional[str] = "agent" # "agent", "ai", "build", "plan"
1323
+ project_path: Optional[str] = None
1324
+ max_steps: Optional[int] = 15
1325
+ chat_id: Optional[str] = None
1326
+
1327
+ @app.post("/api/agent")
1328
+ async def run_agent_endpoint(request: AgentRequest):
1329
+ """Run the CAT agent (same as CLI /agent mode)."""
1330
+ if request.project_path:
1331
+ ws_paths.set_active_project(request.project_path)
1332
+ cid = getattr(request, "chat_id", None)
1333
+ if cid:
1334
+ try:
1335
+ chat_store.add_turn(cid, "user", request.message)
1336
+ except Exception:
1337
+ pass
1338
+ loop = asyncio.get_event_loop()
1339
+ try:
1340
+ result = await loop.run_in_executor(
1341
+ None, lambda: _run_agent_sync(
1342
+ request.message, request.mode, request.max_steps
1343
+ )
1344
+ )
1345
+ if cid:
1346
+ result["chat_id"] = cid
1347
+ if result.get("response"):
1348
+ try:
1349
+ chat_store.add_turn(cid, "assistant", result["response"])
1350
+ except Exception:
1351
+ pass
1352
+ return result
1353
+ except Exception as e:
1354
+ err_res = {"response": "Agent error: " + str(e), "steps": [], "meta": {}}
1355
+ if cid:
1356
+ err_res["chat_id"] = cid
1357
+ return err_res
1358
+
1359
+
1360
+ def _run_agent_sync(message: str, mode: str, max_steps: int) -> dict:
1361
+ """Run agent synchronously (called from thread)."""
1362
+ try:
1363
+ from calc_terminal.agent import run_agent as _run_agent
1364
+ agent_mode = "agent" if mode in ("agent", "build", "plan") else "ai"
1365
+ text, steps, meta = _run_agent(
1366
+ message,
1367
+ max_steps=max_steps,
1368
+ verbose=False,
1369
+ mode=agent_mode,
1370
+ )
1371
+ # Format steps for web display
1372
+ step_list = []
1373
+ for s in steps:
1374
+ if len(s) >= 3:
1375
+ step_list.append({
1376
+ "tool": s[0],
1377
+ "args": s[1] if isinstance(s[1], dict) else {},
1378
+ "result": str(s[2])[:500],
1379
+ })
1380
+ from calc_terminal import aicore
1381
+ cfg = aicore.load_config()
1382
+ return {
1383
+ "response": text or "",
1384
+ "steps": step_list,
1385
+ "meta": meta or {},
1386
+ "provider": (cfg.get("provider") or "ollama").upper(),
1387
+ "model": cfg.get("model") or "gemma4:e4b",
1388
+ "tokens": max(15, len((text or "").split()) * 4 // 3),
1389
+ "calls": max(1, len(step_list)),
1390
+ "success": True,
1391
+ }
1392
+ except Exception as e:
1393
+ return {"response": "Agent error: " + str(e), "steps": [], "meta": {}}
1394
+
1395
+
1396
+ # --- Git Operations ---
1397
+
1398
+ class GitRequest(BaseModel):
1399
+ action: str # status, diff, add, commit, branch, checkout, log
1400
+ message: Optional[str] = None
1401
+ file: Optional[str] = None
1402
+ branch: Optional[str] = None
1403
+
1404
+ @app.post("/api/git")
1405
+ async def git_operation(request: GitRequest):
1406
+ """Execute git operations."""
1407
+ root = ws_paths.active_project() or os.getcwd()
1408
+ loop = asyncio.get_event_loop()
1409
+ try:
1410
+ result = await loop.run_in_executor(
1411
+ None, lambda: _git_op_sync(request.action, root, request.message, request.file, request.branch)
1412
+ )
1413
+ return result
1414
+ except Exception as e:
1415
+ return {"success": False, "error": str(e)}
1416
+
1417
+
1418
+ def _git_op_sync(action: str, root: str, message: str = None, file: str = None, branch: str = None) -> dict:
1419
+ """Execute a git operation synchronously."""
1420
+ import subprocess
1421
+ def run_git(args):
1422
+ proc = subprocess.Popen(
1423
+ ["git"] + args,
1424
+ cwd=root,
1425
+ stdout=subprocess.PIPE,
1426
+ stderr=subprocess.PIPE,
1427
+ )
1428
+ stdout, stderr = proc.communicate(timeout=15)
1429
+ return proc.returncode, stdout.decode("utf-8", errors="replace"), stderr.decode("utf-8", errors="replace")
1430
+
1431
+ if action == "status":
1432
+ code, out, err = run_git(["status", "--porcelain"])
1433
+ branch_code, branch_out, _ = run_git(["branch", "--show-current"])
1434
+ lines = [l for l in out.strip().split("\n") if l.strip()]
1435
+ modified = [l[3:] for l in lines if l.startswith(" M") or l.startswith("M")]
1436
+ added = [l[3:] for l in lines if l.startswith("A")]
1437
+ deleted = [l[3:] for l in lines if l.startswith("D")]
1438
+ untracked = [l[3:] for l in lines if l.startswith("??")]
1439
+ return {
1440
+ "success": True,
1441
+ "branch": branch_out.strip(),
1442
+ "modified": modified,
1443
+ "added": added,
1444
+ "deleted": deleted,
1445
+ "untracked": untracked,
1446
+ "total": len(lines),
1447
+ }
1448
+
1449
+ elif action == "diff":
1450
+ target = file or ""
1451
+ args = ["diff"]
1452
+ if target:
1453
+ args.append(target)
1454
+ code, out, err = run_git(args)
1455
+ return {"success": True, "diff": out}
1456
+
1457
+ elif action == "add":
1458
+ target = file or "."
1459
+ code, out, err = run_git(["add", target])
1460
+ return {"success": code == 0, "output": out or err}
1461
+
1462
+ elif action == "commit":
1463
+ msg = message or "Web commit"
1464
+ code, out, err = run_git(["commit", "-m", msg])
1465
+ return {"success": code == 0, "output": out or err}
1466
+
1467
+ elif action == "branch":
1468
+ code, out, err = run_git(["branch"])
1469
+ branches = [l.strip().replace("* ", "") for l in out.strip().split("\n") if l.strip()]
1470
+ return {"success": True, "branches": branches}
1471
+
1472
+ elif action == "checkout":
1473
+ br = branch or "main"
1474
+ code, out, err = run_git(["checkout", br])
1475
+ return {"success": code == 0, "output": out or err}
1476
+
1477
+ elif action == "log":
1478
+ code, out, err = run_git(["log", "--oneline", "-20"])
1479
+ return {"success": True, "log": out}
1480
+
1481
+ elif action == "pull":
1482
+ code, out, err = run_git(["pull"])
1483
+ return {"success": code == 0, "output": out or err}
1484
+
1485
+ elif action == "push":
1486
+ code, out, err = run_git(["push"])
1487
+ return {"success": code == 0, "output": out or err}
1488
+
1489
+ return {"success": False, "error": "Unknown git action: " + action}
1490
+
1491
+
1492
+ # --- File Operations (CRUD) ---
1493
+
1494
+ class FileCRUDRequest(BaseModel):
1495
+ action: str # create, rename, delete
1496
+ path: str
1497
+ new_path: Optional[str] = None
1498
+ is_dir: Optional[bool] = False
1499
+ content: Optional[str] = None
1500
+
1501
+ @app.post("/api/file")
1502
+ async def file_crud(request: FileCRUDRequest):
1503
+ """Create, rename, or delete files/folders."""
1504
+ root = ws_paths.active_project()
1505
+ if not root:
1506
+ raise HTTPException(status_code=400, detail="No workspace open")
1507
+
1508
+ target = Path(root) / request.path
1509
+ try:
1510
+ target.resolve().relative_to(Path(root).resolve())
1511
+ except ValueError:
1512
+ raise HTTPException(status_code=403, detail="Access denied")
1513
+
1514
+ try:
1515
+ if request.action == "create":
1516
+ if request.is_dir:
1517
+ target.mkdir(parents=True, exist_ok=True)
1518
+ else:
1519
+ target.parent.mkdir(parents=True, exist_ok=True)
1520
+ target.write_text(request.content or "", encoding="utf-8")
1521
+ return {"success": True, "path": str(target.relative_to(root))}
1522
+
1523
+ elif request.action == "rename":
1524
+ if not request.new_path:
1525
+ raise HTTPException(status_code=400, detail="new_path required")
1526
+ new_target = Path(root) / request.new_path
1527
+ try:
1528
+ new_target.resolve().relative_to(Path(root).resolve())
1529
+ except ValueError:
1530
+ raise HTTPException(status_code=403, detail="Access denied")
1531
+ target.rename(new_target)
1532
+ return {"success": True, "path": str(new_target.relative_to(root))}
1533
+
1534
+ elif request.action == "delete":
1535
+ if target.is_dir():
1536
+ import shutil
1537
+ shutil.rmtree(target)
1538
+ else:
1539
+ target.unlink()
1540
+ return {"success": True}
1541
+
1542
+ else:
1543
+ raise HTTPException(status_code=400, detail="Unknown action: " + request.action)
1544
+ except HTTPException:
1545
+ raise
1546
+ except Exception as e:
1547
+ raise HTTPException(status_code=500, detail=str(e))
1548
+
1549
+
1550
+ # --- Streaming AI (SSE) ---
1551
+
1552
+ @app.post("/api/chat/stream")
1553
+ async def chat_stream(request: ChatRequest):
1554
+ """Stream real token-by-token AI response using Server-Sent Events."""
1555
+ from fastapi.responses import StreamingResponse
1556
+
1557
+ async def generate():
1558
+ try:
1559
+ async for chunk in runtime.stream_chat(
1560
+ message=request.message,
1561
+ mode=request.mode or "notebook",
1562
+ project_path=request.project_path,
1563
+ ):
1564
+ yield f"data: {json.dumps(chunk)}\n\n"
1565
+ except Exception as e:
1566
+ yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
1567
+
1568
+ return StreamingResponse(
1569
+ generate(),
1570
+ media_type="text/event-stream",
1571
+ headers={
1572
+ "Cache-Control": "no-cache",
1573
+ "Connection": "keep-alive",
1574
+ "X-Accel-Buffering": "no",
1575
+ }
1576
+ )
1577
+
1578
+
1579
+ # --- AI Modes Management ---
1580
+
1581
+ @app.get("/api/modes")
1582
+ async def get_modes():
1583
+ """Return all AI modes, order, and current active mode."""
1584
+ return runtime.get_modes()
1585
+
1586
+
1587
+ @app.post("/api/modes/switch")
1588
+ async def switch_mode(request: ModeSwitchRequest):
1589
+ """Switch active AI mode."""
1590
+ res = runtime.set_mode(request.mode)
1591
+ return {"current": res, "success": True}
1592
+
1593
+
1594
+ @app.post("/api/modes/custom")
1595
+ async def create_custom_mode(request: CustomModeRequest):
1596
+ """Create a new user-defined AI mode."""
1597
+ ok, msg = runtime.create_custom_mode(
1598
+ request.key, request.label, request.icon, request.accent_hex,
1599
+ request.purpose or "", request.system_prompt or ""
1600
+ )
1601
+ if not ok:
1602
+ raise HTTPException(status_code=400, detail=msg)
1603
+ return {"success": True, "key": msg}
1604
+
1605
+
1606
+ @app.delete("/api/modes/custom/{key}")
1607
+ async def delete_custom_mode(key: str):
1608
+ """Delete a custom AI mode."""
1609
+ ok, msg = runtime.delete_custom_mode(key)
1610
+ if not ok:
1611
+ raise HTTPException(status_code=400, detail=msg)
1612
+ return {"success": True, "message": msg}
1613
+
1614
+
1615
+ # --- Providers & Models Management ---
1616
+
1617
+ @app.get("/api/providers")
1618
+ async def list_providers():
1619
+ """List all 155+ AI providers."""
1620
+ return {"providers": runtime.list_providers()}
1621
+
1622
+
1623
+ @app.get("/api/models")
1624
+ async def list_models(provider_id: str, force_refresh: bool = False):
1625
+ """Get models for a specific provider with metadata and categories."""
1626
+ return {"models": runtime.get_models_for_provider(provider_id, force_refresh=force_refresh)}
1627
+
1628
+
1629
+ @app.post("/api/models/switch")
1630
+ async def switch_model(request: ModelSwitchRequest):
1631
+ """Switch active provider and model in CAT configuration."""
1632
+ return runtime.switch_model(request.provider, request.model, request.api_key)
1633
+
1634
+
1635
+ # --- Ollama Support ---
1636
+
1637
+ @app.get("/api/ollama/status")
1638
+ async def get_ollama_status():
1639
+ """Check Ollama installation and connection status."""
1640
+ return runtime.get_ollama_status()
1641
+
1642
+
1643
+ @app.get("/api/ollama/catalog")
1644
+ async def get_ollama_catalog(query: str = ""):
1645
+ """Browse curated 250+ model catalog."""
1646
+ return {"catalog": runtime.get_ollama_catalog(query)}
1647
+
1648
+
1649
+ @app.post("/api/ollama/start")
1650
+ async def start_ollama():
1651
+ """Start local Ollama serve process."""
1652
+ ok, msg = runtime.ensure_ollama_started()
1653
+ return {"success": ok, "message": msg}
1654
+
1655
+
1656
+ # --- Commands Management ---
1657
+
1658
+ @app.get("/api/commands")
1659
+ async def get_commands():
1660
+ """Get all 103 slash commands with descriptions."""
1661
+ return {"commands": runtime.get_commands()}
1662
+
1663
+
1664
+ @app.post("/api/command/dispatch")
1665
+ async def dispatch_command(request: CommandDispatchRequest):
1666
+ """Dispatch a slash command to CAT command handlers."""
1667
+ return runtime.dispatch_command(request.command)
1668
+
1669
+
1670
+ # --- Memory & Context ---
1671
+
1672
+ @app.get("/api/memory/facts")
1673
+ async def get_memory_facts():
1674
+ """Get durable user facts and topic activity."""
1675
+ return {
1676
+ "facts": runtime.get_memory_facts(),
1677
+ "activity": runtime.get_memory_activity(),
1678
+ "topics": runtime.get_memory_topics(),
1679
+ }
1680
+
1681
+
1682
+ @app.post("/api/memory/fact")
1683
+ async def add_memory_fact(request: FactRequest):
1684
+ """Add a durable fact to CAT memory."""
1685
+ runtime.add_memory_fact(request.fact)
1686
+ return {"success": True, "facts": runtime.get_memory_facts()}
1687
+
1688
+
1689
+ @app.delete("/api/memory/fact/{index}")
1690
+ async def remove_memory_fact(index: int):
1691
+ """Remove a fact by index."""
1692
+ runtime.remove_memory_fact(index)
1693
+ return {"success": True, "facts": runtime.get_memory_facts()}
1694
+
1695
+
1696
+ # --- Customization & Extensions ---
1697
+
1698
+ @app.get("/api/customization")
1699
+ async def get_customization():
1700
+ """Get complete layout and styling customization state."""
1701
+ return runtime.get_customization()
1702
+
1703
+
1704
+ @app.post("/api/customization/layout")
1705
+ async def update_customization_layout(request: CustomizationLayoutRequest):
1706
+ """Update layout position/dock/size for a panel."""
1707
+ ok = runtime.update_layout_component(request.component_id, request.updates)
1708
+ return {"success": ok}
1709
+
1710
+
1711
+ @app.post("/api/customization/reset")
1712
+ async def reset_customization():
1713
+ """Reset customization to defaults."""
1714
+ ok = runtime.reset_customization()
1715
+ return {"success": ok}
1716
+
1717
+
1718
+ @app.get("/api/extensions")
1719
+ async def list_extensions():
1720
+ """List installable and active extensions."""
1721
+ return {"extensions": runtime.list_extensions()}
1722
+
1723
+
1724
+ @app.post("/api/extensions/toggle")
1725
+ async def toggle_extension(request: ExtensionToggleRequest):
1726
+ """Enable or disable an extension."""
1727
+ ok = runtime.toggle_extension(request.id, request.enabled)
1728
+ return {"success": ok}
1729
+
1730
+
1731
+ @app.post("/api/extensions/create")
1732
+ async def create_extension(request: ExtensionCreateRequest):
1733
+ """Create a new custom extension."""
1734
+ ok, res = cct_ext.create_extension(
1735
+ extension_id=request.id,
1736
+ name=request.name,
1737
+ description=request.description or "",
1738
+ icon=request.icon or "⚡",
1739
+ publisher=request.publisher or "Custom",
1740
+ category=request.category or "Custom",
1741
+ version=request.version or "1.0.0",
1742
+ script_code=request.script_code or "",
1743
+ )
1744
+ return {"success": ok, "message": res, "extension": res, "extensions": runtime.list_extensions()}
1745
+
1746
+
1747
+ # --- Science Engine & Notebook ---
1748
+
1749
+ @app.post("/api/science/solve")
1750
+ async def science_solve(request: ScienceSolveRequest):
1751
+ """Solve formula symbolically."""
1752
+ key = request.key or request.formula or "ideal_gas"
1753
+ raw_vals = request.values if request.values is not None else (request.params or {})
1754
+ clean_vals = {}
1755
+ for k, v in raw_vals.items():
1756
+ if v is not None and v != "":
1757
+ try:
1758
+ clean_vals[k] = float(v)
1759
+ except (ValueError, TypeError):
1760
+ clean_vals[k] = v
1761
+ return runtime.solve_formula(key, clean_vals)
1762
+
1763
+
1764
+ @app.post("/api/science/derive")
1765
+ async def science_derive(request: ScienceDeriveRequest):
1766
+ """Get step-by-step calculus derivation."""
1767
+ kw = request.keyword or request.derivation or "first"
1768
+ return runtime.get_derivation(kw)
1769
+
1770
+
1771
+ @app.post("/api/science/atom")
1772
+ async def science_atom(request: ScienceAtomRequest):
1773
+ """Get atomic simulation details."""
1774
+ return runtime.get_atom_sim(request.element)
1775
+
1776
+
1777
+ # --- Live Event Stream (SSE) ---
1778
+
1779
+ @app.get("/api/events/stream")
1780
+ async def events_stream():
1781
+ """Live Server-Sent Events stream of workspace and agent events."""
1782
+ from fastapi.responses import StreamingResponse
1783
+
1784
+ async def event_generator():
1785
+ q: asyncio.Queue = asyncio.Queue()
1786
+ loop = asyncio.get_event_loop()
1787
+
1788
+ def _sub(topic, **kwargs):
1789
+ loop.call_soon_threadsafe(
1790
+ q.put_nowait,
1791
+ {"topic": topic, "time": time.time(), "data": {k: str(v)[:150] for k, v in kwargs.items()}}
1792
+ )
1793
+
1794
+ for top in (
1795
+ eventbus.FILE_CREATED, eventbus.FILE_SAVED, eventbus.FILE_DELETED,
1796
+ eventbus.AI_RESPONSE_GENERATED, eventbus.TERMINAL_COMMAND,
1797
+ eventbus.WORKSPACE_OPENED, eventbus.MODE_SWITCHED,
1798
+ eventbus.PACKAGE_INSTALLED, eventbus.TODO_CREATED, eventbus.TODO_COMPLETED
1799
+ ):
1800
+ eventbus.bus.subscribe(top, lambda t=top, **kw: _sub(t, **kw))
1801
+
1802
+ try:
1803
+ yield f"data: {json.dumps({'topic': 'connected', 'time': time.time()})}\n\n"
1804
+ while True:
1805
+ evt = await q.get()
1806
+ yield f"data: {json.dumps(evt)}\n\n"
1807
+ except asyncio.CancelledError:
1808
+ pass
1809
+
1810
+ return StreamingResponse(
1811
+ event_generator(),
1812
+ media_type="text/event-stream",
1813
+ headers={"Cache-Control": "no-cache", "Connection": "keep-alive"}
1814
+ )
1815
+
1816
+
1817
+ # --- Server Status / Debug ---
1818
+
1819
+ @app.get("/api/status")
1820
+ async def server_status():
1821
+ """Check server status and AI config."""
1822
+ try:
1823
+ config = aicore.load_config()
1824
+ provider = config.get("provider", "none")
1825
+ model = config.get("model", "none")
1826
+ has_key = bool(config.get("api_key"))
1827
+ return {
1828
+ "status": "ok",
1829
+ "version": "0.7.9.0",
1830
+ "provider": provider,
1831
+ "model": model,
1832
+ "has_api_key": has_key,
1833
+ "workspace_count": len(state_manager.workspace_roots),
1834
+ }
1835
+ except Exception as e:
1836
+ return {"status": "error", "error": str(e)}
1837
+
1838
+
1839
+ # --- Calculator ---
1840
+
1841
+ class CalcRequest(BaseModel):
1842
+ expression: str
1843
+
1844
+ @app.post("/api/calc")
1845
+ async def calculator(request: CalcRequest):
1846
+ """Scientific calculator - safe eval with math functions."""
1847
+ import math
1848
+ import re
1849
+ expr = request.expression.strip()
1850
+ if not expr:
1851
+ raise HTTPException(status_code=400, detail="Empty expression")
1852
+ # Replace common symbols
1853
+ expr = expr.replace('^', '**')
1854
+ expr = expr.replace('×', '*')
1855
+ expr = expr.replace('÷', '/')
1856
+ # Allow only safe characters
1857
+ safe_pattern = r'^[0-9+\-*/.() pi,e,sqrt,log,log10,sin,cos,tan,asin,acos,atan,abs,ceil,floor,round,pow,min,max,radians,deg,gcd,factorial,isfinite,isnan,inf ]+$'
1858
+ # Build safe namespace
1859
+ safe_math = {
1860
+ 'pi': math.pi, 'e': math.e, 'inf': math.inf,
1861
+ 'sqrt': math.sqrt, 'log': math.log, 'log10': math.log10,
1862
+ 'sin': math.sin, 'cos': math.cos, 'tan': math.tan,
1863
+ 'asin': math.asin, 'acos': math.acos, 'atan': math.atan,
1864
+ 'abs': abs, 'ceil': math.ceil, 'floor': math.floor,
1865
+ 'round': round, 'pow': pow, 'min': min, 'max': max,
1866
+ 'radians': math.radians, 'deg': math.degrees,
1867
+ 'gcd': math.gcd, 'factorial': math.factorial,
1868
+ }
1869
+ try:
1870
+ result = eval(expr, {"__builtins__": {}}, safe_math)
1871
+ return {"expression": request.expression, "result": result}
1872
+ except Exception as e:
1873
+ raise HTTPException(status_code=400, detail=f"Calculation error: {e}")
1874
+
1875
+
1876
+ # --- Web Search ---
1877
+
1878
+ class SearchRequest(BaseModel):
1879
+ query: str
1880
+ max_results: Optional[int] = 6
1881
+
1882
+ @app.post("/api/websearch")
1883
+ async def web_search_endpoint(request: SearchRequest):
1884
+ """Search the web via DuckDuckGo."""
1885
+ loop = asyncio.get_event_loop()
1886
+ try:
1887
+ results = await loop.run_in_executor(
1888
+ None, lambda: aicore.web_search(request.query, request.max_results)
1889
+ )
1890
+ return {"results": results}
1891
+ except Exception as e:
1892
+ return {"results": [], "error": str(e)}
1893
+
1894
+
1895
+ class ResearchRequest(BaseModel):
1896
+ topic: str
1897
+ num_queries: Optional[int] = 3
1898
+
1899
+ @app.post("/api/research")
1900
+ async def deep_research_endpoint(request: ResearchRequest):
1901
+ """Deep research - multiple searches + AI synthesis."""
1902
+ loop = asyncio.get_event_loop()
1903
+ try:
1904
+ summary, sources = await loop.run_in_executor(
1905
+ None, lambda: aicore.deep_research(request.topic, request.num_queries)
1906
+ )
1907
+ return {"summary": summary, "sources": sources}
1908
+ except Exception as e:
1909
+ return {"summary": "", "sources": [], "error": str(e)}
1910
+
1911
+
1912
+ # --- Formula Library ---
1913
+
1914
+ @app.get("/api/formulas")
1915
+ async def get_formulas():
1916
+ """Get the formula library categories and formulas."""
1917
+ try:
1918
+ from calc_terminal import solver
1919
+ categories = {}
1920
+ for cat, formulas in solver.FORMULA_LIBRARY.items():
1921
+ categories[cat] = []
1922
+ for f in formulas:
1923
+ categories[cat].append({
1924
+ "name": f.get("name", ""),
1925
+ "formula": f.get("formula", ""),
1926
+ "variables": f.get("variables", {}),
1927
+ "description": f.get("description", ""),
1928
+ })
1929
+ return {"categories": categories}
1930
+ except Exception as e:
1931
+ return {"categories": {}, "error": str(e)}
1932
+
1933
+
1934
+ class SolveRequest(BaseModel):
1935
+ formula: str
1936
+ known_values: Dict[str, float]
1937
+ solve_for: str
1938
+
1939
+ @app.post("/api/solve")
1940
+ async def solve_formula(request: SolveRequest):
1941
+ """Solve a formula for an unknown variable."""
1942
+ try:
1943
+ from calc_terminal import solver as _solver
1944
+ result = _solver.solve_symbolic(request.formula, request.known_values, request.solve_for)
1945
+ return {"result": result}
1946
+ except Exception as e:
1947
+ return {"result": None, "error": str(e)}
1948
+
1949
+
1950
+ # --- Workspace Commands ---
1951
+
1952
+ @app.get("/api/workspace/info")
1953
+ async def workspace_info():
1954
+ """Get workspace info and categories."""
1955
+ try:
1956
+ from calc_terminal import workspace as _ws
1957
+ root = _ws.root_dir()
1958
+ cats = {}
1959
+ for cat in _ws.CATEGORIES:
1960
+ files = _ws.list_category(cat)
1961
+ if files:
1962
+ cats[cat] = [{"name": f["name"], "path": f["path"], "size": f["size"]} for f in files[:10]]
1963
+ return {
1964
+ "root": str(root),
1965
+ "categories": cats,
1966
+ "summary": _ws.summary(),
1967
+ }
1968
+ except Exception as e:
1969
+ return {"root": "", "categories": {}, "error": str(e)}
1970
+
1971
+
1972
+ @app.get("/api/workspace/detect")
1973
+ async def workspace_detect():
1974
+ """Auto-detect workspace from current directory."""
1975
+ try:
1976
+ from calc_terminal import workspace as _ws
1977
+ detected = _ws.detect_workspace(os.getcwd())
1978
+ if detected:
1979
+ _ws.set_active_project(detected)
1980
+ projects.record_opened(detected)
1981
+ return {"path": detected, "name": os.path.basename(detected)}
1982
+ return {"path": None}
1983
+ except Exception as e:
1984
+ return {"path": None, "error": str(e)}
1985
+
1986
+
1987
+ @app.get("/api/workspace/git")
1988
+ async def workspace_git():
1989
+ """Get git status for current workspace."""
1990
+ try:
1991
+ from calc_terminal import workspace as _ws
1992
+ root = _ws.active_project() or os.getcwd()
1993
+ branch = _ws.git_branch(root)
1994
+ changed, added, deleted = _ws.git_status_summary(root)
1995
+ return {
1996
+ "branch": branch,
1997
+ "changed": changed,
1998
+ "added": added,
1999
+ "deleted": deleted,
2000
+ }
2001
+ except Exception as e:
2002
+ return {"branch": None, "changed": 0, "added": 0, "deleted": 0}
2003
+
2004
+
2005
+ # --- Memory ---
2006
+
2007
+ @app.get("/api/memory")
2008
+ async def get_memory():
2009
+ """Get AI conversation memory stats."""
2010
+ try:
2011
+ from calc_terminal import aicore as _ac
2012
+ usage = _ac.get_session_usage()
2013
+ return {
2014
+ "prompt_tokens": usage.get("prompt_tokens", 0),
2015
+ "completion_tokens": usage.get("completion_tokens", 0),
2016
+ "total_tokens": usage.get("total_tokens", 0),
2017
+ "requests": usage.get("requests", 0),
2018
+ "context_window": usage.get("context_window", 0),
2019
+ }
2020
+ except Exception as e:
2021
+ return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "requests": 0}
2022
+
2023
+
2024
+ # --- Settings ---
2025
+
2026
+ @app.get("/api/settings")
2027
+ async def get_settings():
2028
+ """Get current settings."""
2029
+ try:
2030
+ config = aicore.load_config()
2031
+ return {
2032
+ "provider": config.get("provider", "none"),
2033
+ "model": config.get("model", "none"),
2034
+ "theme": config.get("theme", "dark"),
2035
+ "precision": config.get("precision", 3),
2036
+ "sound": config.get("sound", False),
2037
+ }
2038
+ except Exception as e:
2039
+ return {"provider": "none", "model": "none", "theme": "dark", "precision": 3}
2040
+
2041
+
2042
+ class SettingsUpdate(BaseModel):
2043
+ theme: Optional[str] = None
2044
+ precision: Optional[int] = None
2045
+ sound: Optional[bool] = None
2046
+
2047
+ @app.post("/api/settings")
2048
+ async def update_settings(request: SettingsUpdate):
2049
+ """Update settings."""
2050
+ try:
2051
+ config = aicore.load_config()
2052
+ if request.theme is not None:
2053
+ config["theme"] = request.theme
2054
+ if request.precision is not None:
2055
+ config["precision"] = request.precision
2056
+ if request.sound is not None:
2057
+ config["sound"] = request.sound
2058
+ aicore.save_config(config)
2059
+ return {"success": True}
2060
+ except Exception as e:
2061
+ return {"success": False, "error": str(e)}
2062
+
2063
+
2064
+ # --- WebSocket for Real-time Updates ---
2065
+
2066
+ @app.websocket("/ws/{workspace_id}")
2067
+ async def websocket_endpoint(websocket: WebSocket, workspace_id: str):
2068
+ await websocket.accept()
2069
+ state_manager.active_websockets.add(websocket)
2070
+
2071
+ try:
2072
+ while True:
2073
+ data = await websocket.receive_text()
2074
+ message = json.loads(data)
2075
+ await _handle_websocket_message(websocket, workspace_id, message)
2076
+ except WebSocketDisconnect:
2077
+ pass
2078
+ finally:
2079
+ state_manager.active_websockets.discard(websocket)
2080
+
2081
+
2082
+ # --- Helper Functions ---
2083
+
2084
+ def _is_binary_file(path: Path) -> bool:
2085
+ """Check if file is binary."""
2086
+ binary_extensions = {
2087
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico",
2088
+ ".woff", ".woff2", ".ttf", ".otf", ".eot",
2089
+ ".pdf", ".zip", ".gz", ".tar", ".rar",
2090
+ ".wasm", ".exe", ".dll", ".so", ".dylib",
2091
+ }
2092
+ return path.suffix.lower() in binary_extensions
2093
+
2094
+
2095
+ def _process_chat_message(message: str, mode: str, config: dict) -> dict:
2096
+ """Process a chat message through CAT's AI system (synchronous).
2097
+
2098
+ v0.7.10: Enhanced error resilience — auto-retries on transient failures,
2099
+ validates responses against error signatures, and provides clearer
2100
+ diagnostics to the user.
2101
+ """
2102
+ t0 = time.time()
2103
+ prov = config.get("provider", "ollama") if config else "ollama"
2104
+ mod = config.get("model", "deepseek-r1") if config else "deepseek-r1"
2105
+ # Only invoke multi-turn agent when explicitly in 'agent' mode, or when explicit file/workspace mutation is commanded
2106
+ is_explicit_agent_task = mode == "agent" or (mode in ("build", "plan") and bool(re.search(r"^\s*(?:create|write|delete|edit|refactor|generate)\s+(?:file|folder|dir|component|app|project)\b", message, re.I)))
2107
+ if is_explicit_agent_task:
2108
+ try:
2109
+ agent_res = _run_agent_sync(message, "agent", max_steps=4)
2110
+ if agent_res and agent_res.get("response") and not agent_res.get("response", "").startswith("Agent error:"):
2111
+ total_time = time.time() - t0
2112
+ loop_ms = max(50, int(total_time * 1000))
2113
+ mins, secs = divmod(int(total_time), 60)
2114
+ return {
2115
+ "response": agent_res["response"],
2116
+ "steps": agent_res.get("steps", []),
2117
+ "provider": prov.upper(),
2118
+ "model": mod,
2119
+ "tokens": max(12, len(agent_res["response"].split()) * 4 // 3),
2120
+ "time_str": f"{mins}:{secs:02d}",
2121
+ "duration": total_time,
2122
+ "timings": {"ttfb_ms": 300, "memory_ms": 25, "loop_ms": loop_ms},
2123
+ "calls": max(1, len(agent_res.get("steps", []))),
2124
+ "success": True,
2125
+ }
2126
+ except Exception:
2127
+ pass
2128
+
2129
+ # Standard AI chat with auto-retry on transient failures
2130
+ max_retries = 2
2131
+ last_response = ""
2132
+ last_error = ""
2133
+
2134
+ for attempt in range(max_retries + 1):
2135
+ try:
2136
+ from calc_terminal import aicore as _aicore
2137
+ from calc_terminal import ai_modes as _am
2138
+
2139
+ # Reload config on retry to pick up any changes
2140
+ if attempt > 0:
2141
+ try:
2142
+ config = _aicore.load_config()
2143
+ prov = config.get("provider", prov)
2144
+ mod = config.get("model", mod)
2145
+ except Exception:
2146
+ pass
2147
+
2148
+ system_prompt = _am.system_prompt_for(mode) if hasattr(_am, "system_prompt_for") else _aicore.DEFAULT_SYSTEM_PROMPT
2149
+ response = _aicore.query_ai(
2150
+ message,
2151
+ system_prompt=system_prompt,
2152
+ config=config,
2153
+ )
2154
+
2155
+ # Validate response — check if it's an error signature
2156
+ if response and not _aicore.is_error_response(response):
2157
+ # Good response
2158
+ total_time = time.time() - t0
2159
+ loop_ms = max(50, int(total_time * 1000))
2160
+ ttfb_ms = max(30, int(loop_ms * 0.85))
2161
+ mem_ms = max(10, int(total_time * 15))
2162
+ tokens = max(1, len((response or "").split()) * 4 // 3)
2163
+ mins, secs = divmod(int(total_time), 60)
2164
+ time_str = f"{mins}:{secs:02d}"
2165
+ return {
2166
+ "response": response,
2167
+ "provider": prov.upper(),
2168
+ "model": mod,
2169
+ "tokens": tokens,
2170
+ "time_str": time_str,
2171
+ "duration": total_time,
2172
+ "timings": {
2173
+ "ttfb_ms": ttfb_ms,
2174
+ "memory_ms": mem_ms,
2175
+ "loop_ms": loop_ms,
2176
+ },
2177
+ "calls": attempt + 1,
2178
+ "success": True,
2179
+ }
2180
+
2181
+ # Error response — save and retry if we have attempts left
2182
+ last_response = response or "No response received from AI."
2183
+ if attempt < max_retries:
2184
+ time.sleep(0.5 * (attempt + 1)) # brief backoff
2185
+ continue
2186
+
2187
+ except Exception as e:
2188
+ last_error = str(e)
2189
+ last_response = last_response or f"Error: {last_error}"
2190
+ if attempt < max_retries:
2191
+ time.sleep(0.5 * (attempt + 1))
2192
+ continue
2193
+
2194
+ # All retries exhausted — return best error info
2195
+ total_time = time.time() - t0
2196
+ mins, secs = divmod(int(total_time), 60)
2197
+ time_str = f"{mins}:{secs:02d}"
2198
+ timed_out = "timed out" in (last_response + last_error).lower() or total_time >= 25.0
2199
+ resp_text = last_response if last_response else ("(Generation timed out)" if timed_out else f"Error: {last_error}")
2200
+ return {
2201
+ "response": resp_text,
2202
+ "provider": prov.upper(),
2203
+ "model": mod,
2204
+ "tokens": max(5, int(total_time * 2)),
2205
+ "time_str": time_str,
2206
+ "duration": total_time,
2207
+ "timings": {
2208
+ "ttfb_ms": max(100, int(total_time * 1000)),
2209
+ "memory_ms": 337,
2210
+ "loop_ms": max(120, int(total_time * 1000) + 30),
2211
+ },
2212
+ "calls": max_retries + 1,
2213
+ "success": False,
2214
+ "timed_out": timed_out,
2215
+ }
2216
+
2217
+
2218
+
2219
+ async def _broadcast_preview_event(workspace_id: str, kind: str, info: dict):
2220
+ """Broadcast preview events to connected websockets."""
2221
+ message = json.dumps({"type": "preview", "kind": kind, "data": info}, default=str)
2222
+ for ws in state_manager.active_websockets.copy():
2223
+ try:
2224
+ await ws.send_text(message)
2225
+ except Exception:
2226
+ state_manager.active_websockets.discard(ws)
2227
+
2228
+
2229
+ async def _handle_websocket_message(websocket: WebSocket, workspace_id: str, message: dict):
2230
+ """Handle incoming websocket messages."""
2231
+ msg_type = message.get("type")
2232
+
2233
+ if msg_type == "preview_navigate":
2234
+ ctrl = state_manager.get_preview_controller(workspace_id)
2235
+ if ctrl:
2236
+ ctrl.navigate(message.get("url", ""))
2237
+
2238
+ elif msg_type == "preview_reload":
2239
+ ctrl = state_manager.get_preview_controller(workspace_id)
2240
+ if ctrl:
2241
+ ctrl.reload()
2242
+
2243
+ elif msg_type == "file_change":
2244
+ # Handle file changes from editor
2245
+ ctrl = state_manager.get_preview_controller(workspace_id)
2246
+ if ctrl:
2247
+ paths = message.get("paths", [])
2248
+ ctrl.on_files_changed(paths)
2249
+
2250
+
2251
+ # ============================================================
2252
+ # Vision Agent (CAT v0.8.a)
2253
+ # ============================================================
2254
+
2255
+ from calc_terminal.vision.session import VisionSessionManager, SessionState
2256
+ from calc_terminal.vision.capture import CaptureConfig, CaptureMode
2257
+ from calc_terminal.vision.context import VisionContextEngine
2258
+ from calc_terminal.vision.verify import BeforeAfterVerifier
2259
+ from calc_terminal.vision.safety import SafetyConfig, PrivacyController
2260
+ from calc_terminal.vision.provider import VisionProviderRouter
2261
+ from calc_terminal.vision.frame_pipeline import FrameMeta, ChangeLevel
2262
+ from calc_terminal.vision.annotations import AnnotationEngine, AnnotationTool, Color
2263
+
2264
+ vision_manager = VisionSessionManager()
2265
+ vision_context = VisionContextEngine()
2266
+ vision_verifier = BeforeAfterVerifier()
2267
+ vision_router = VisionProviderRouter()
2268
+
2269
+
2270
+ class VisionSessionRequest(BaseModel):
2271
+ mode: Optional[str] = "display"
2272
+ fps: Optional[float] = 1.0
2273
+ quality: Optional[int] = 85
2274
+ max_frames: Optional[int] = 500
2275
+ width: Optional[int] = None
2276
+ height: Optional[int] = None
2277
+ auto_analyze: Optional[bool] = True
2278
+ privacy_mode: Optional[bool] = True
2279
+ annotations_enabled: Optional[bool] = True
2280
+
2281
+
2282
+ class VisionFrameRequest(BaseModel):
2283
+ session_id: str
2284
+ frame_b64: str
2285
+ timestamp: Optional[float] = None
2286
+ pointer_x: Optional[float] = None
2287
+ pointer_y: Optional[float] = None
2288
+ pointer_down: Optional[bool] = False
2289
+ scroll_y: Optional[int] = 0
2290
+ active_element: Optional[str] = ""
2291
+ url: Optional[str] = ""
2292
+ title: Optional[str] = ""
2293
+
2294
+
2295
+ class VisionAnalyzeRequest(BaseModel):
2296
+ session_id: str
2297
+ prompt: Optional[str] = ""
2298
+ mode: Optional[str] = "analyze"
2299
+ error_message: Optional[str] = ""
2300
+
2301
+
2302
+ class VisionAnnotationRequest(BaseModel):
2303
+ session_id: str
2304
+ tool: str = "pen"
2305
+ points: List[List[float]] = []
2306
+ color: Optional[Dict] = None
2307
+ line_width: Optional[float] = 3.0
2308
+ text: Optional[str] = ""
2309
+ frame_index: Optional[int] = -1
2310
+
2311
+
2312
+ class VisionContextRequest(BaseModel):
2313
+ session_id: str
2314
+ file_path: Optional[str] = ""
2315
+ file_content: Optional[str] = ""
2316
+ cursor_line: Optional[int] = 0
2317
+ language: Optional[str] = ""
2318
+ project_type: Optional[str] = ""
2319
+
2320
+
2321
+ class VisionVerifyRequest(BaseModel):
2322
+ session_id: str
2323
+ fix_description: Optional[str] = ""
2324
+ code_change: Optional[str] = ""
2325
+
2326
+
2327
+ @app.post("/api/vision/session")
2328
+ async def vision_session_create(req: VisionSessionRequest):
2329
+ try:
2330
+ mode_map = {
2331
+ "display": CaptureMode.DISPLAY,
2332
+ "tab": CaptureMode.TAB,
2333
+ "window": CaptureMode.WINDOW,
2334
+ "audio_video": CaptureMode.AUDIO_VIDEO,
2335
+ }
2336
+ config = CaptureConfig(
2337
+ mode=mode_map.get(req.mode, CaptureMode.DISPLAY),
2338
+ fps=req.fps,
2339
+ quality=req.quality,
2340
+ max_frames=req.max_frames,
2341
+ width=req.width,
2342
+ height=req.height,
2343
+ auto_analyze=req.auto_analyze,
2344
+ privacy_mode=req.privacy_mode,
2345
+ annotations_enabled=req.annotations_enabled,
2346
+ )
2347
+ session = vision_manager.create_session(config)
2348
+ return {"ok": True, "session": session.to_dict()}
2349
+ except Exception as e:
2350
+ raise HTTPException(400, str(e))
2351
+
2352
+
2353
+ @app.post("/api/vision/session/{session_id}/start")
2354
+ async def vision_session_start(session_id: str):
2355
+ try:
2356
+ session = await vision_manager.start_session(session_id)
2357
+ return {"ok": True, "session": session.to_dict()}
2358
+ except KeyError:
2359
+ raise HTTPException(404, "Session not found")
2360
+ except Exception as e:
2361
+ raise HTTPException(400, str(e))
2362
+
2363
+
2364
+ @app.post("/api/vision/session/{session_id}/stop")
2365
+ async def vision_session_stop(session_id: str):
2366
+ try:
2367
+ session = await vision_manager.stop_session(session_id)
2368
+ return {"ok": True, "session": session.to_dict()}
2369
+ except KeyError:
2370
+ raise HTTPException(404, "Session not found")
2371
+ except Exception as e:
2372
+ raise HTTPException(400, str(e))
2373
+
2374
+
2375
+ @app.post("/api/vision/session/{session_id}/pause")
2376
+ async def vision_session_pause(session_id: str):
2377
+ try:
2378
+ await vision_manager.pause_session(session_id)
2379
+ session = vision_manager.get_session(session_id)
2380
+ return {"ok": True, "session": session.to_dict() if session else None}
2381
+ except Exception as e:
2382
+ raise HTTPException(400, str(e))
2383
+
2384
+
2385
+ @app.post("/api/vision/session/{session_id}/resume")
2386
+ async def vision_session_resume(session_id: str):
2387
+ try:
2388
+ await vision_manager.resume_session(session_id)
2389
+ session = vision_manager.get_session(session_id)
2390
+ return {"ok": True, "session": session.to_dict() if session else None}
2391
+ except Exception as e:
2392
+ raise HTTPException(400, str(e))
2393
+
2394
+
2395
+ @app.delete("/api/vision/session/{session_id}")
2396
+ async def vision_session_destroy(session_id: str):
2397
+ try:
2398
+ await vision_manager.destroy_session(session_id)
2399
+ return {"ok": True}
2400
+ except Exception as e:
2401
+ raise HTTPException(400, str(e))
2402
+
2403
+
2404
+ @app.get("/api/vision/sessions")
2405
+ async def vision_sessions_list():
2406
+ return {"ok": True, "sessions": vision_manager.list_sessions()}
2407
+
2408
+
2409
+ @app.get("/api/vision/session/{session_id}")
2410
+ async def vision_session_get(session_id: str):
2411
+ session = vision_manager.get_session(session_id)
2412
+ if not session:
2413
+ raise HTTPException(404, "Session not found")
2414
+ return {"ok": True, "session": session.to_dict()}
2415
+
2416
+
2417
+ @app.get("/api/vision/session/{session_id}/config")
2418
+ async def vision_capture_config(session_id: str):
2419
+ session = vision_manager.get_session(session_id)
2420
+ if not session:
2421
+ raise HTTPException(404, "Session not found")
2422
+ return {"ok": True, "config": session.capture.get_capture_config()}
2423
+
2424
+
2425
+ @app.get("/api/vision/capabilities")
2426
+ async def vision_capabilities():
2427
+ return {
2428
+ "ok": True,
2429
+ "capture": vision_manager._privacy.get_privacy_summary(),
2430
+ "providers": vision_router.get_all_capabilities(),
2431
+ }
2432
+
2433
+
2434
+ @app.post("/api/vision/frame")
2435
+ async def vision_frame_submit(req: VisionFrameRequest):
2436
+ session = vision_manager.get_session(req.session_id)
2437
+ if not session:
2438
+ raise HTTPException(404, "Session not found")
2439
+ meta = FrameMeta(
2440
+ timestamp=req.timestamp or time.time(),
2441
+ pointer_x=req.pointer_x or 0,
2442
+ pointer_y=req.pointer_y or 0,
2443
+ pointer_down=req.pointer_down or False,
2444
+ scroll_y=req.scroll_y or 0,
2445
+ active_element=req.active_element or "",
2446
+ url=req.url or "",
2447
+ title=req.title or "",
2448
+ )
2449
+ result = vision_manager.process_frame(req.session_id, req.frame_b64, meta)
2450
+ if result is None:
2451
+ return {"ok": True, "processed": False, "reason": "frame_not_selected"}
2452
+ vision_manager._privacy.record_capture()
2453
+ return {"ok": True, "processed": True, "frame": result}
2454
+
2455
+
2456
+ @app.post("/api/vision/annotate")
2457
+ async def vision_annotate(req: VisionAnnotationRequest):
2458
+ session = vision_manager.get_session(req.session_id)
2459
+ if not session:
2460
+ raise HTTPException(404, "Session not found")
2461
+ tool_map = {
2462
+ "pen": AnnotationTool.PEN,
2463
+ "arrow": AnnotationTool.ARROW,
2464
+ "rect": AnnotationTool.RECT,
2465
+ "circle": AnnotationTool.CIRCLE,
2466
+ "text": AnnotationTool.TEXT,
2467
+ "highlight": AnnotationTool.HIGHLIGHT,
2468
+ "crosshair": AnnotationTool.CROSSHAIR,
2469
+ "eraser": AnnotationTool.ERASER,
2470
+ }
2471
+ tool = tool_map.get(req.tool, AnnotationTool.PEN)
2472
+ color = Color.from_dict(req.color) if req.color else None
2473
+ points = [(p[0], p[1]) for p in req.points]
2474
+ ann = session.annotations.add_annotation(
2475
+ tool=tool, points=points, color=color,
2476
+ line_width=req.line_width, text=req.text,
2477
+ frame_index=req.frame_index,
2478
+ )
2479
+ return {"ok": True, "annotation": ann.to_dict()}
2480
+
2481
+
2482
+ @app.get("/api/vision/annotations/{session_id}")
2483
+ async def vision_annotations_list(session_id: str):
2484
+ session = vision_manager.get_session(session_id)
2485
+ if not session:
2486
+ raise HTTPException(404, "Session not found")
2487
+ return {"ok": True, "annotations": session.annotations.to_list()}
2488
+
2489
+
2490
+ @app.post("/api/vision/annotations/{session_id}/undo")
2491
+ async def vision_annotations_undo(session_id: str):
2492
+ session = vision_manager.get_session(session_id)
2493
+ if not session:
2494
+ raise HTTPException(404, "Session not found")
2495
+ ann = session.annotations.undo()
2496
+ return {"ok": True, "undone": ann.to_dict() if ann else None}
2497
+
2498
+
2499
+ @app.post("/api/vision/annotations/{session_id}/redo")
2500
+ async def vision_annotations_redo(session_id: str):
2501
+ session = vision_manager.get_session(session_id)
2502
+ if not session:
2503
+ raise HTTPException(404, "Session not found")
2504
+ ann = session.annotations.redo()
2505
+ return {"ok": True, "redone": ann.to_dict() if ann else None}
2506
+
2507
+
2508
+ @app.post("/api/vision/annotations/{session_id}/clear")
2509
+ async def vision_annotations_clear(session_id: str):
2510
+ session = vision_manager.get_session(session_id)
2511
+ if not session:
2512
+ raise HTTPException(404, "Session not found")
2513
+ session.annotations.clear()
2514
+ return {"ok": True}
2515
+
2516
+
2517
+ @app.post("/api/vision/context")
2518
+ async def vision_context_set(req: VisionContextRequest):
2519
+ session = vision_manager.get_session(req.session_id)
2520
+ if not session:
2521
+ raise HTTPException(404, "Session not found")
2522
+ vision_context.set_code_context(
2523
+ file_path=req.file_path,
2524
+ content=req.file_content,
2525
+ cursor_line=req.cursor_line,
2526
+ language=req.language,
2527
+ )
2528
+ if req.project_type:
2529
+ vision_context.set_project_context(project_type=req.project_type)
2530
+ return {"ok": True}
2531
+
2532
+
2533
+ @app.post("/api/vision/analyze")
2534
+ async def vision_analyze(req: VisionAnalyzeRequest):
2535
+ session = vision_manager.get_session(req.session_id)
2536
+ if not session:
2537
+ raise HTTPException(404, "Session not found")
2538
+ best_frame = session.pipeline.get_best_frame()
2539
+ if not best_frame:
2540
+ raise HTTPException(400, "No frames available for analysis")
2541
+ pointer_telemetry = {
2542
+ "active_element": "",
2543
+ "cursor_moving": False,
2544
+ }
2545
+ visual_ctx = vision_context.build_context(
2546
+ frame_b64=best_frame.frame_b64,
2547
+ frame_meta=best_frame.meta.to_dict(),
2548
+ annotations=session.annotations.to_list(),
2549
+ pointer_telemetry=pointer_telemetry,
2550
+ analysis_prompt=req.prompt,
2551
+ )
2552
+ try:
2553
+ from calc_terminal import aicore
2554
+ config, caps = vision_router.find_vision_capable() or (None, None)
2555
+ if config is None:
2556
+ return {
2557
+ "ok": False,
2558
+ "error": "No vision-capable model configured",
2559
+ "suggestion": "Configure a vision model via /model (e.g. gpt-4o, claude-3.5-sonnet)",
2560
+ }
2561
+ messages = visual_ctx.to_ai_messages()
2562
+ user_msg = messages[-1] if messages else {"role": "user", "content": req.prompt}
2563
+ system_msg = messages[0] if messages else {"role": "system", "content": "Analyze the screen."}
2564
+ from calc_terminal.attachments import Attachment
2565
+ att = Attachment.__new__(Attachment)
2566
+ att.id = f"att-vision-{abs(hash(best_frame.frame_b64[:64])) % 1000000}"
2567
+ att.name = "screenshot"
2568
+ att.path = ""
2569
+ att.extension = ".png"
2570
+ att.mime_type = "image/png"
2571
+ att.size = len(best_frame.frame_b64)
2572
+ att.kind = "image"
2573
+ att.content = "[screen capture]"
2574
+ att.metadata = {"inline_b64": best_frame.frame_b64}
2575
+ att.extraction_status = "ready"
2576
+ att.error = None
2577
+ att.created_at = time.time()
2578
+ att.source = "vision_agent"
2579
+ analysis = aicore.query_ai(
2580
+ user_msg.get("content", req.prompt) if isinstance(user_msg.get("content"), str) else req.prompt,
2581
+ system_prompt=system_msg.get("content", "You are CAT Vision Agent."),
2582
+ history=None,
2583
+ config=config,
2584
+ attachments=[att],
2585
+ size_class="large",
2586
+ )
2587
+ return {"ok": True, "analysis": analysis, "frame_index": best_frame.index}
2588
+ except Exception as e:
2589
+ return {"ok": False, "error": str(e)}
2590
+
2591
+
2592
+ @app.post("/api/vision/verify")
2593
+ async def vision_verify(req: VisionVerifyRequest):
2594
+ session = vision_manager.get_session(req.session_id)
2595
+ if not session:
2596
+ raise HTTPException(404, "Session not found")
2597
+ verification = vision_verifier.start_verification()
2598
+ best_frame = session.pipeline.get_best_frame()
2599
+ if best_frame:
2600
+ vision_verifier.set_before_frame(
2601
+ best_frame.frame_b64, best_frame.meta.to_dict()
2602
+ )
2603
+ vision_verifier.set_fix_applied(req.fix_description)
2604
+ return {
2605
+ "ok": True,
2606
+ "verification": verification.to_dict(),
2607
+ "message": "Before frame captured. Apply fix, then submit after frame.",
2608
+ }
2609
+
2610
+
2611
+ @app.post("/api/vision/verify/after")
2612
+ async def vision_verify_after(session_id: str = Form(...),
2613
+ frame_b64: str = Form(...)):
2614
+ vision_verifier.set_after_frame(frame_b64)
2615
+ prompt = vision_verifier.build_comparison_prompt()
2616
+ try:
2617
+ from calc_terminal import aicore
2618
+ config, caps = vision_router.find_vision_capable() or (None, None)
2619
+ if config and vision_verifier.current and vision_verifier.current.before_frame_b64:
2620
+ from calc_terminal.attachments import Attachment
2621
+ att_before = Attachment.__new__(Attachment)
2622
+ att_before.id = "att-vision-before"
2623
+ att_before.name = "before"
2624
+ att_before.path = ""
2625
+ att_before.extension = ".png"
2626
+ att_before.mime_type = "image/png"
2627
+ att_before.size = len(vision_verifier.current.before_frame_b64)
2628
+ att_before.kind = "image"
2629
+ att_before.content = "[before frame]"
2630
+ att_before.metadata = {"inline_b64": vision_verifier.current.before_frame_b64}
2631
+ att_before.extraction_status = "ready"
2632
+ att_before.error = None
2633
+ att_before.created_at = time.time()
2634
+ att_before.source = "vision_verify"
2635
+ att_after = Attachment.__new__(Attachment)
2636
+ att_after.id = "att-vision-after"
2637
+ att_after.name = "after"
2638
+ att_after.path = ""
2639
+ att_after.extension = ".png"
2640
+ att_after.mime_type = "image/png"
2641
+ att_after.size = len(frame_b64)
2642
+ att_after.kind = "image"
2643
+ att_after.content = "[after frame]"
2644
+ att_after.metadata = {"inline_b64": frame_b64}
2645
+ att_after.extraction_status = "ready"
2646
+ att_after.error = None
2647
+ att_after.created_at = time.time()
2648
+ att_after.source = "vision_verify"
2649
+ analysis = aicore.query_ai(
2650
+ prompt,
2651
+ system_prompt="You are CAT Vision verification agent. Compare before and after states.",
2652
+ history=None,
2653
+ config=config,
2654
+ attachments=[att_before, att_after],
2655
+ size_class="large",
2656
+ )
2657
+ passed = "PASS" in (analysis or "").upper()[:200]
2658
+ vision_verifier.complete(
2659
+ ai_analysis=analysis or "",
2660
+ passed=passed,
2661
+ confidence=0.8 if passed else 0.3,
2662
+ )
2663
+ return {"ok": True, "result": vision_verifier.history[-1].to_dict()}
2664
+ except Exception as e:
2665
+ vision_verifier.error(str(e))
2666
+ return {"ok": True, "result": verification_status()}
2667
+
2668
+
2669
+ def verification_status():
2670
+ if vision_verifier.current:
2671
+ return vision_verifier.current.to_dict()
2672
+ return {"status": "idle"}
2673
+
2674
+
2675
+ @app.get("/api/vision/verify/status")
2676
+ async def vision_verify_status():
2677
+ return {"ok": True, "verification": verification_status()}
2678
+
2679
+
2680
+ @app.get("/api/vision/verify/history")
2681
+ async def vision_verify_history():
2682
+ return {"ok": True, "history": vision_verifier.history}
2683
+
2684
+
2685
+ @app.post("/api/vision/verify/clear")
2686
+ async def vision_verify_clear():
2687
+ vision_verifier.clear_history()
2688
+ return {"ok": True}
2689
+
2690
+
2691
+ @app.post("/api/vision/provider/route")
2692
+ async def vision_provider_route(session_id: str = Form(...)):
2693
+ session = vision_manager.get_session(session_id)
2694
+ if not session:
2695
+ raise HTTPException(404, "Session not found")
2696
+ best = session.pipeline.get_best_frame()
2697
+ size = len(best.frame_b64) if best else 0
2698
+ result = vision_router.route_vision_request(best.frame_b64 if best else "", size)
2699
+ return {"ok": True, "route": result}
2700
+
2701
+
2702
+ @app.get("/api/vision/provider/capabilities")
2703
+ async def vision_provider_capabilities():
2704
+ return {"ok": True, "capabilities": vision_router.get_all_capabilities()}
2705
+
2706
+
2707
+ @app.get("/api/vision/privacy")
2708
+ async def vision_privacy_summary():
2709
+ return {"ok": True, "privacy": vision_manager._privacy.get_privacy_summary()}
2710
+
2711
+
2712
+ @app.get("/api/vision/privacy/log")
2713
+ async def vision_privacy_log(limit: int = 100):
2714
+ return {"ok": True, "log": vision_manager._privacy.get_access_log(limit)}
2715
+
2716
+
2717
+ class VisionCursorRequest(BaseModel):
2718
+ session_id: str
2719
+ x: float = 0.0
2720
+ y: float = 0.0
2721
+ pointer_type: Optional[str] = "mouse"
2722
+ pointer_id: Optional[int] = 0
2723
+ button: Optional[int] = 0
2724
+ pressure: Optional[float] = 0.5
2725
+ movement_x: Optional[float] = 0.0
2726
+ movement_y: Optional[float] = 0.0
2727
+ is_click: Optional[bool] = False
2728
+ is_double_click: Optional[bool] = False
2729
+ is_drag: Optional[bool] = False
2730
+ is_scroll: Optional[bool] = False
2731
+ scroll_delta: Optional[float] = 0.0
2732
+
2733
+
2734
+ @app.post("/api/vision/cursor")
2735
+ async def vision_cursor(req: VisionCursorRequest):
2736
+ session = vision_manager.get_session(req.session_id)
2737
+ if not session:
2738
+ raise HTTPException(404, "Session not found")
2739
+ try:
2740
+ session.cursor.add_raw(
2741
+ x=req.x, y=req.y,
2742
+ pointer_type=req.pointer_type or "mouse",
2743
+ pointer_id=req.pointer_id or 0,
2744
+ button=req.button or 0,
2745
+ pressure=req.pressure or 0.5,
2746
+ movement_x=req.movement_x or 0.0,
2747
+ movement_y=req.movement_y or 0.0,
2748
+ is_click=bool(req.is_click),
2749
+ is_double_click=bool(req.is_double_click),
2750
+ is_drag=bool(req.is_drag),
2751
+ is_scroll=bool(req.is_scroll),
2752
+ scroll_delta=req.scroll_delta or 0.0,
2753
+ )
2754
+ # Priority signals from cursor
2755
+ if req.is_click:
2756
+ session.priority.signal_click()
2757
+ elif req.is_drag:
2758
+ session.priority.add_signal("cursor_move")
2759
+ # live status
2760
+ return {"ok": True, "cursor": session.cursor.get_cursor_context()}
2761
+ except Exception as e:
2762
+ raise HTTPException(400, str(e))
2763
+
2764
+
2765
+ @app.get("/api/vision/cursor/{session_id}")
2766
+ async def vision_cursor_get(session_id: str):
2767
+ session = vision_manager.get_session(session_id)
2768
+ if not session:
2769
+ raise HTTPException(404, "Session not found")
2770
+ return {"ok": True, "cursor": session.cursor.get_cursor_context(), "dwells": [d.to_dict() for d in session.cursor.dwells]}
2771
+
2772
+
2773
+ @app.post("/api/vision/correlate")
2774
+ async def vision_correlate(req: VisionContextRequest):
2775
+ """Screen→Code correlation (spec 20). Honest mapping, never hallucinates."""
2776
+ session = vision_manager.get_session(req.session_id)
2777
+ if not session:
2778
+ raise HTTPException(404, "Session not found")
2779
+ try:
2780
+ from calc_terminal.vision.correlation import correlate as do_correlate
2781
+ workspace_root = None
2782
+ try:
2783
+ from calc_terminal import workspace as ws
2784
+ workspace_root = ws.root_dir() or ws.active_project()
2785
+ except Exception:
2786
+ workspace_root = None
2787
+ # preview context if preview running
2788
+ preview_ctx = None
2789
+ try:
2790
+ # try to get preview url from workspace state
2791
+ preview_ctx = {"entry": req.file_path or ""}
2792
+ except Exception:
2793
+ pass
2794
+ result = do_correlate(
2795
+ annotations=session.annotations.to_list(),
2796
+ cursor=session.cursor.get_cursor_context() if hasattr(session, 'cursor') else None,
2797
+ preview_context=preview_ctx,
2798
+ workspace_root=workspace_root,
2799
+ )
2800
+ return {"ok": True, "correlation": result}
2801
+ except Exception as e:
2802
+ return {"ok": False, "error": str(e)}
2803
+
2804
+
2805
+ @app.get("/api/vision/pipeline/summary/{session_id}")
2806
+ async def vision_pipeline_summary(session_id: str):
2807
+ session = vision_manager.get_session(session_id)
2808
+ if not session:
2809
+ raise HTTPException(404, "Session not found")
2810
+ return {"ok": True, "summary": session.pipeline.get_summary()}
2811
+
2812
+
2813
+ @app.get("/api/vision/pipeline/context/{session_id}")
2814
+ async def vision_pipeline_context(session_id: str, n: int = 5):
2815
+ session = vision_manager.get_session(session_id)
2816
+ if not session:
2817
+ raise HTTPException(404, "Session not found")
2818
+ return {"ok": True, "frames": session.pipeline.get_context_frames(n)}
2819
+
2820
+
2821
+ # ============================================================
2822
+ # Server Lifecycle & Auto-Start Utilities
2823
+ # ============================================================
2824
+
2825
+ def is_server_running(host: str = "127.0.0.1", port: int = 8765, timeout: float = 1.0) -> bool:
2826
+ """Check if Fatty CAT server is reachable."""
2827
+ import socket
2828
+ try:
2829
+ with socket.create_connection((host, port), timeout=timeout):
2830
+ return True
2831
+ except OSError:
2832
+ return False
2833
+
2834
+
2835
+ def ensure_fatty_server(timeout: float = 10.0, auto_start: bool = True) -> bool:
2836
+ """Ensure the Fatty CAT web server is running.
2837
+ If not running and auto_start is True, spawn it as a background process.
2838
+ Returns True if reachable (already or after auto-start).
2839
+ """
2840
+ if is_server_running(timeout=0.8):
2841
+ return True
2842
+ if not auto_start:
2843
+ return False
2844
+
2845
+ import subprocess
2846
+ python_exe = sys.executable or "python"
2847
+
2848
+ creationflags = 0
2849
+ if sys.platform == "win32":
2850
+ creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
2851
+
2852
+ try:
2853
+ # `python -m calc_terminal.web.server` works from ANY cwd when CAT is
2854
+ # pip-installed (and from a source checkout too), so do not force
2855
+ # cwd to the git repo root — that path does not exist on a friend's
2856
+ # computer. Inherit the caller's cwd instead.
2857
+ subprocess.Popen(
2858
+ [python_exe, "-m", "calc_terminal.web.server"],
2859
+ stdout=subprocess.DEVNULL,
2860
+ stderr=subprocess.DEVNULL,
2861
+ creationflags=creationflags,
2862
+ )
2863
+ except Exception as e:
2864
+ print(f"[FATTY CAT] Failed to auto-start server: {e}", file=sys.stderr)
2865
+ return False
2866
+
2867
+ deadline = time.time() + timeout
2868
+ while time.time() < deadline:
2869
+ if is_server_running(timeout=0.5):
2870
+ return True
2871
+ time.sleep(0.3)
2872
+ return False
2873
+
2874
+
2875
+ # ============================================================
2876
+ # Main Entry Point
2877
+ # ============================================================
2878
+
2879
+ def main():
2880
+ import uvicorn
2881
+ uvicorn.run(
2882
+ "calc_terminal.web.server:app",
2883
+ host="0.0.0.0",
2884
+ port=8765,
2885
+ reload=False,
2886
+ log_level="info",
2887
+ )
2888
+
2889
+
2890
+ if __name__ == "__main__":
2891
+ main()