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
calc_terminal/app.py ADDED
@@ -0,0 +1,3685 @@
1
+ if __name__ == '__main__':
2
+ print("This is a library file and is not meant to be run directly.")
3
+ print("Please run 'python main.py' or 'python model.py' from the project root directory.")
4
+ import sys
5
+ sys.exit(1)
6
+
7
+ import math
8
+ import os
9
+ import random
10
+ import re
11
+ import sys
12
+ import textwrap
13
+ import time
14
+
15
+ from . import theme
16
+ from . import fallback_cli
17
+ from . import solver
18
+ from . import atomsim
19
+ from . import graphs
20
+ from . import sim3d
21
+ from . import gpu3d
22
+ from . import aicore
23
+ from . import agent
24
+ from . import sound
25
+ from . import code_editor
26
+ from . import engine
27
+ from . import game
28
+ from . import easter_eggs
29
+ from . import memory
30
+ from . import pet
31
+ from . import reactionsim
32
+ from . import report
33
+ from . import tui
34
+ from . import config as cctconfig
35
+ from . import registry
36
+ from . import errors
37
+ from . import identity
38
+ from .engine import run_steps, render_notebook, render_derivation, ENGINE_STEPS, IMAGE_STEPS, DERIVE_STEPS, WIDTH
39
+ from .generators import GENERATORS, KINETICS_ORDER
40
+ from .formulas import FORMULAS
41
+ from .mathtext import compose, mathify
42
+ from .derivations import DERIVATIONS, DERIVATION_KEYWORDS
43
+
44
+ VERSION = identity.APP_VERSION
45
+ EDITION = identity.APP_NAME
46
+
47
+ LOGO = [
48
+ " ██████╗ █████╗ ████████╗",
49
+ " ██╔════╝██╔══██╗╚══██╔══╝",
50
+ " ██║ ███████║ ██║ ",
51
+ " ██║ ██╔══██║ ██║ ",
52
+ " ╚██████╗██║ ██║ ██║ ",
53
+ " ╚═════╝╚═╝ ╚═╝ ╚═╝ ",
54
+ ]
55
+
56
+ SUGGESTIONS = [
57
+ ("first order", "First Order Rate Constant", "kinetics", "first"),
58
+ ("first order half life", "First Order Half-Life", "kinetics", "halflife"),
59
+ ("first order numerical", "First Order Numerical", "kinetics", "first"),
60
+ ("zero order", "Zero Order Kinetics", "kinetics", "zero"),
61
+ ("second order", "Second Order Kinetics", "kinetics", "second"),
62
+ ("third order", "Third Order Kinetics", "kinetics", "third"),
63
+ ("half life", "Half-Life Numerical", "kinetics", "halflife"),
64
+ ("arrhenius equation", "Arrhenius Equation", "kinetics", "arrhenius"),
65
+ ("activation energy", "Activation Energy Numerical", "kinetics", "arrhenius"),
66
+ ("mole concept", "Mole Concept Numerical", "class 11", "mole"),
67
+ ("molar mass", "Molar Mass Calculation", "class 11", "mole"),
68
+ ]
69
+
70
+ # Command-suggestions shared by both in-chat loops (/ai and /agent) — the
71
+ # in-chat dropdown always offers these regardless of mode.
72
+ _CHAT_COMMANDS = [
73
+ ("/back", "Return to the home dashboard"),
74
+ ("/setup", "Reconfigure your AI provider/model"),
75
+ ("/verify", "Test the current AI connection"),
76
+ ("/memory", "View what CAT remembers about you"),
77
+ ("/workspace", "Detect, open & manage project workspaces"),
78
+ ("/packages", "Installation dashboard + install history"),
79
+ ]
80
+
81
+ # Example prompts offered inside /ai — short, conversational questions.
82
+ AI_PROMPT_SUGGESTIONS = [
83
+ ("what is a first order reaction", "Quick conceptual answer"),
84
+ ("half life of a first order reaction", "Quick numeric answer"),
85
+ ("explain the arrhenius equation simply", "Quick conceptual answer"),
86
+ ("what's the difference between molarity and molality", "Quick conceptual answer"),
87
+ ("quick mole concept refresher", "Quick conceptual answer"),
88
+ ("/agent", "Switch to the full notebook-style Chemistry Agent"),
89
+ ]
90
+ AI_SUGGESTIONS = _CHAT_COMMANDS + AI_PROMPT_SUGGESTIONS
91
+
92
+ # Example prompts offered inside /agent — longer, tool-using requests that
93
+ # want the full step-by-step notebook treatment.
94
+ AGENT_PROMPT_SUGGESTIONS = [
95
+ ("solve ideal gas law with P=1, n=2, T=300", "Symbolic solve + full notebook"),
96
+ ("derive the first order rate law step by step", "Full derivation, notebook format"),
97
+ ("plot the arrhenius graph and export it", "Plot + PNG export"),
98
+ ("plot y = sin(x)*exp(-x/5) from 0 to 20 called Damped Wave", "Custom function plot"),
99
+ ("simulate an iron atom in 3d", "Live 3D Bohr simulation"),
100
+ ("show me the 2p orbital cloud", "Live quantum orbital simulation"),
101
+ ("explain redox titration in detail, step by step", "Long, notebook-style explanation"),
102
+ ]
103
+ AGENT_SUGGESTIONS = _CHAT_COMMANDS + AGENT_PROMPT_SUGGESTIONS
104
+
105
+ # Moved to commands_data.py in v0.5.9 so the new composer's slash
106
+ # CommandPalette can import the exact same list instead of a second,
107
+ # hand-maintained copy. Nothing below this line changed behavior.
108
+ from .commands_data import COMMANDS
109
+
110
+
111
+ def _colorize_diff_lines(changes_text):
112
+ """Apply green/red ANSI to the git-style +/- diff rows of the File
113
+ changes panel (the Textual chat gets its colors from pygments' diff
114
+ lexer instead — this is the classic terminal's equivalent)."""
115
+ out = []
116
+ for ln in changes_text.split("\n"):
117
+ s = ln.lstrip()
118
+ if s and s[0] in "+-" and not s.startswith("-" * 4):
119
+ out.append((theme.green if s[0] == "+" else theme.red)(ln))
120
+ else:
121
+ out.append(ln)
122
+ return out
123
+
124
+
125
+ class App:
126
+ VERSION = VERSION
127
+ EDITION = EDITION
128
+
129
+ def __init__(self):
130
+ self.history = []
131
+ self.autosave = True
132
+ self.precision = 3
133
+ self.sound_enabled = True
134
+ self.running = True
135
+ self.game_high_score = 0
136
+ self.game_best_streak = 0
137
+ self.eggs_found = set()
138
+ sound.set_enabled(self.sound_enabled)
139
+ try:
140
+ from .terminal_identity import set_terminal_title
141
+ set_terminal_title()
142
+ except Exception:
143
+ pass
144
+ self._bind_registry_handlers()
145
+
146
+ def _bind_registry_handlers(self):
147
+ """Attach this instance's bound cmd_* methods to the shared
148
+ CommandRegistry (spec section 14) for the subset of commands
149
+ that take no inline trailing arguments — confirmed by direct
150
+ comparison against handle()'s elif chain to call the exact same
151
+ method with no extra logic in between. Commands that parse
152
+ trailing text themselves (e.g. "/bonding furan", "/derive
153
+ first order half life") are deliberately left unbound for now;
154
+ they keep going through their existing elif branch in handle().
155
+ This is real, incremental migration onto the registry — not a
156
+ parallel copy — matched one command at a time, not a blanket
157
+ rewrite of the dispatch chain."""
158
+ reg = registry.get_registry()
159
+ bindings = {
160
+ "/help": self.cmd_help,
161
+ "/calculator": self.cmd_calculator,
162
+ "/formulas": self.cmd_formulas,
163
+ "/kinetics": self.cmd_kinetics,
164
+ "/electrochemistry": self.cmd_electrochemistry,
165
+ "/solve": self.cmd_solve,
166
+ "/atomsim": self.cmd_atomsim,
167
+ "/orbitals": self.cmd_orbitals,
168
+ "/orbitalgrid": self.cmd_orbitalgrid,
169
+ "/graph": self.cmd_graph,
170
+ "/shortcuts": self.cmd_shortcuts,
171
+ "/history": self.cmd_history,
172
+ "/settings": self.cmd_settings,
173
+ "/about": self.cmd_about,
174
+ "/derive": self.cmd_derive,
175
+ # split_optional (spec section 14): zero args or one
176
+ # stripped trailing-text arg, verified 1:1 against the old
177
+ # elif chain in handle() before being added here.
178
+ "/memory": self.cmd_memory,
179
+ "/model": self.cmd_model,
180
+ "/theme": self.cmd_theme,
181
+ "/websearch": self.cmd_websearch,
182
+ "/research": self.cmd_research,
183
+ "/import": self.cmd_import,
184
+ "/react": self.cmd_react,
185
+ "/export": self.cmd_export,
186
+ "/workspace": self.cmd_workspace,
187
+ "/permissions": self.cmd_permissions,
188
+ # remaining plain no-arg commands, same verification approach
189
+ # as the first batch — checked against handle()'s elif chain
190
+ # to confirm each is a direct "self.cmd_X(); return" with
191
+ # nothing else happening in between.
192
+ "/upload": self.cmd_upload,
193
+ "/voice": self.cmd_voice,
194
+ "/sim3d": self.cmd_sim3d,
195
+ "/gpu3d": self.cmd_gpu3d,
196
+ "/ai": self.cmd_ai,
197
+ "/agent": self.cmd_agent,
198
+ "/ai-verify": self.cmd_ai_verify,
199
+ "/game": self.cmd_game,
200
+ "/codepad": self.cmd_codepad,
201
+ "/edit": self.cmd_textedit,
202
+ "/tokens": self.cmd_tokens,
203
+ "/pet": self.cmd_pet,
204
+ "/tui": self.cmd_tui,
205
+ "/composer": self.cmd_composer,
206
+ "/copycode": self.cmd_copycode,
207
+ "/free": self.cmd_free,
208
+ "/latest": self.cmd_latest,
209
+ "/provider": self.cmd_provider,
210
+ "/status": self.cmd_status,
211
+ "/refresh-models": self.cmd_refresh_models,
212
+ # /random wasn't its own method in the old elif branch (it
213
+ # called self.solve(...) inline) — wrapped here so it can be
214
+ # registry-dispatched too, with identical inner logic.
215
+ "/random": lambda: self.solve(random.choice(list(GENERATORS.keys()))),
216
+ # CAT Vision — split_optional so `/vision` and `/vision start` both work
217
+ "/vision": self.cmd_vision,
218
+ "/vscode": self.cmd_vscode,
219
+ # CAT Browser — split_optional so `/browser`, `/browse`, and `/cat` all work
220
+ "/browser": self.cmd_browser,
221
+ "/browse": self.cmd_browser,
222
+ "/cat": self.cmd_browser,
223
+ }
224
+ for name, method in bindings.items():
225
+ spec = reg.get(name)
226
+ if spec is not None:
227
+ spec.handler = method
228
+
229
+ # -------------------------------------------------------------- boot --
230
+ def boot(self):
231
+ # Defense-in-depth gate: even if cli.main was bypassed (e.g. direct
232
+ # `App().run()` from a test or old entrypoint), boot still refuses
233
+ # without Fomoji. Normal launches already passed cli's gate; this
234
+ # second check is just `status()==connected` and costs nothing.
235
+ try:
236
+ from .fomoji_auth import status as _fa_status
237
+ if _fa_status() != "connected":
238
+ # Lazy import theme for the error without breaking boot's
239
+ # normal imports if auth is broken.
240
+ try:
241
+ from . import theme as _t
242
+ _t.enable_windows_ansi()
243
+ print()
244
+ print(_t.panel([
245
+ _t.red("CAT IS LOCKED", bold=True),
246
+ _t.dim("Fomoji authentication required — boot refused."),
247
+ _t.faint("Run `cat --auth login` and try again."),
248
+ ], title="locked", color=_t.RED, width=76))
249
+ except Exception:
250
+ print("CAT IS LOCKED — Fomoji auth required.", file=sys.stderr)
251
+ raise SystemExit(1)
252
+ except SystemExit:
253
+ raise
254
+ except Exception:
255
+ pass
256
+
257
+ theme.enable_windows_ansi()
258
+ theme.load_saved_theme()
259
+ theme.clear_screen()
260
+ print()
261
+ theme.reveal_lines(LOGO, delay=0.06, color=theme.CYAN, bold=True)
262
+ print()
263
+ theme.typewriter(" CAT \u2014 Coding Agent Terminal", delay=0.012, color=theme.PURPLE, bold=True)
264
+ print(theme.center(theme.gradient(f"v{VERSION} \u00b7 {EDITION}", theme.ORANGE, theme.PURPLE, bold=True), WIDTH))
265
+ time.sleep(0.15)
266
+ print()
267
+ run_steps(["Booting calc_terminal...", "Loading Notebook Engine..."], step_time=0.28)
268
+ theme.progress_bar("Warming up engine", duration=0.5)
269
+ sound.play("startup")
270
+ print()
271
+ time.sleep(0.2)
272
+
273
+ # -------------------------------------------------------------- home --
274
+ def print_home(self):
275
+ theme.clear_screen()
276
+ print(theme.window_titlebar("calc_terminal", WIDTH))
277
+ content_w = WIDTH - 6
278
+ lines = []
279
+ for l in LOGO:
280
+ lines.append(theme.center(theme.gradient(l, theme.CYAN, theme.PURPLE, bold=True), content_w))
281
+ lines.append("")
282
+ lines.append(theme.center(theme.gradient("CAT \u2014 Coding Agent Terminal", theme.CYAN, theme.PURPLE, theme.CYAN, bold=True), content_w))
283
+ lines.append(theme.center(
284
+ theme.dim(f"v{VERSION} \u00b7 ") + theme.gradient(EDITION, theme.ORANGE, theme.PURPLE, bold=True) +
285
+ theme.dim(" \u00b7 ") + theme.green("\u25cf Ready"),
286
+ content_w))
287
+ lines.append("")
288
+ lines.append(theme.gradient_rule(content_w, theme.FAINT, theme.PURPLE, theme.FAINT))
289
+ reverse_alias = {full: short for short, full in self.SHORT_ALIASES.items()}
290
+ for cmd, desc in COMMANDS[:10]:
291
+ short = reverse_alias.get(cmd, "")
292
+ desc_w = max(1, content_w - 20 - 6)
293
+ if len(desc) > desc_w:
294
+ desc = desc[:desc_w - 1].rstrip() + "\u2026"
295
+ row = theme.cyan(cmd.ljust(20)) + theme.dim(desc.ljust(desc_w)) + theme.faint(short.rjust(4))
296
+ lines.append(row)
297
+ lines.append(theme.faint(f" \u2026 {len(COMMANDS) - 10} more \u2014 type /help for the full list"))
298
+ print(theme.panel(lines, title=f"v{VERSION} \u00b7 {EDITION}", color=theme.CYAN, width=WIDTH, title_gradient=(theme.CYAN, theme.PURPLE)))
299
+ print()
300
+ print(" " + fallback_cli.status_ribbon([
301
+ ("STATUS", "READY"), ("MODE", "NOTEBOOK"), ("ENGINE", f"VERIFIED v{VERSION}"),
302
+ ]))
303
+ print()
304
+
305
+ # -------------------------------------------------------------- loop --
306
+ def run(self):
307
+ self.boot()
308
+ self.print_home()
309
+ while self.running:
310
+ try:
311
+ model_label = None
312
+ effort_label = "ready"
313
+ try:
314
+ cfg = aicore.load_config()
315
+ if cfg.get("provider") and (cfg.get("provider") == "ollama" or cfg.get("api_key")):
316
+ model_label = f"{cfg['provider'].upper()} {cfg.get('model', '')}".strip()
317
+ usage = aicore.get_session_usage()
318
+ if usage["requests"]:
319
+ effort_label = f"{usage['total_tokens']:,} tok used"
320
+ except Exception:
321
+ model_label = None
322
+ raw = fallback_cli.fallback_input(
323
+ width=WIDTH, mode_label="NOTEBOOK", model_label=model_label,
324
+ effort_label=effort_label, suggestions=self._suggestion_items()
325
+ ).strip()
326
+ except (EOFError, KeyboardInterrupt):
327
+ self.cmd_exit()
328
+ break
329
+ if not raw:
330
+ continue
331
+ pet.mark_activity()
332
+ # Show what was typed exactly once, right here, before it's
333
+ # routed anywhere — this used to also get reprinted deeper in
334
+ # handle_question(), which was the 'double text' bug.
335
+ # v0.5.6: adaptive OpenCode-style rendering — rail panel for long
336
+ # questions, right-aligned gradient bubble for short ones.
337
+ fallback_cli.render_user(raw, tag="you", term_width=WIDTH)
338
+ try:
339
+ self.handle(raw)
340
+ except (EOFError, KeyboardInterrupt):
341
+ # A sub-menu (AI setup, code pad, editor, settings, etc.)
342
+ # was reading its own input() and the user hit Ctrl+D/
343
+ # Ctrl+C mid-flow. Previously this bubbled all the way up
344
+ # and killed the whole app with a raw traceback. Now it
345
+ # just cancels back out to the home prompt.
346
+ print()
347
+ print(theme.dim(" ✕ Cancelled — back to the main prompt."))
348
+ except Exception as exc: # pragma: no cover - defensive
349
+ # Any bug in a command handler should never nuke the
350
+ # whole session. Report it and keep the terminal alive.
351
+ print()
352
+ print(theme.red(f" ⚠ Something went wrong: {exc}"))
353
+ print(theme.dim(" The terminal is still running — try another command."))
354
+
355
+ def _suggestion_items(self):
356
+ """Flatten commands + known question topics into (trigger,
357
+ description) pairs for the live suggestion dropdown."""
358
+ items = []
359
+ reverse_alias = {full: short for short, full in self.SHORT_ALIASES.items()}
360
+ for cmd, desc in COMMANDS:
361
+ short = reverse_alias.get(cmd, "")
362
+ items.append((cmd, desc + (f" ({short})" if short else "")))
363
+ for trigger, label, category, _key in SUGGESTIONS:
364
+ items.append((trigger, f"{label} \u2014 {category}"))
365
+ return items
366
+
367
+ # ----------------------------------------------------------- routing --
368
+ # short aliases so common commands don't need to be typed in full,
369
+ # e.g. "/h" for /help, "/d first order" for /derive
370
+ #
371
+ # This used to be a dict hardcoded here. It's now derived from
372
+ # calc_terminal.registry (spec section 14's "centralized command
373
+ # registry" — one place, not duplicated) via short_alias_map().
374
+ # The mapping itself is byte-for-byte the same table, just defined
375
+ # in one place instead of two.
376
+ SHORT_ALIASES = registry.short_alias_map()
377
+
378
+ def handle(self, raw):
379
+ low = raw.lower().strip()
380
+ first_tok = low.split(" ", 1)[0]
381
+ if first_tok in self.SHORT_ALIASES:
382
+ rest = low[len(first_tok):].strip()
383
+ low = self.SHORT_ALIASES[first_tok] + (" " + rest if rest else "")
384
+ raw = low
385
+
386
+ if low in ("/exit", "/quit", "exit", "quit"):
387
+ self.cmd_exit()
388
+ return
389
+ if low in ("/clear", "/home"):
390
+ self.print_home()
391
+ return
392
+
393
+ # Registry-driven dispatch (spec section 14): for the exact-match,
394
+ # no-inline-argument commands bound in _bind_registry_handlers(),
395
+ # route through the CommandRegistry's spec.handler instead of the
396
+ # elif chain below. Commands that parse trailing text (e.g.
397
+ # "/bonding furan", "/derive first order") are NOT bound, so they
398
+ # fall straight through to their existing branch, unchanged. The
399
+ # old elif branches for the bound commands are left in place as a
400
+ # safety net — if a spec or handler is ever missing, behavior
401
+ # degrades to exactly what it was before this dispatch existed,
402
+ # never to a dead end.
403
+ spec = registry.get_registry().get(low)
404
+ if spec is not None and spec.handler is not None and low == spec.name:
405
+ spec.handler()
406
+ return
407
+
408
+ # split_optional arg-taking commands (spec section 14): the
409
+ # exact-match, zero-arg case is already covered by the block
410
+ # above (spec.handler() with no args, same as before). This
411
+ # covers "/X <trailing text>" specifically, matching the old
412
+ # `self.cmd_X(raw.split(" ", 1)[1].strip())` call exactly.
413
+ cmd_tok = low.split(" ", 1)[0]
414
+ arg_spec = registry.get_registry().get(cmd_tok)
415
+ if (arg_spec is not None and arg_spec.handler is not None
416
+ and arg_spec.arg_style == "split_optional"
417
+ and low.startswith(arg_spec.name + " ")):
418
+ arg_spec.handler(raw.split(" ", 1)[1].strip())
419
+ return
420
+
421
+ if low == "/help":
422
+ self.cmd_help()
423
+ return
424
+ if low == "/calculator":
425
+ self.cmd_calculator()
426
+ return
427
+ if low == "/formulas":
428
+ self.cmd_formulas()
429
+ return
430
+ if low == "/kinetics":
431
+ self.cmd_kinetics()
432
+ return
433
+ if low in ("/derive", "/derivation", "/derivations"):
434
+ self.cmd_derive()
435
+ return
436
+ if low.startswith(("/derive ", "/derivation ", "/derivations ")):
437
+ arg = raw.split(" ", 1)[1]
438
+ key = self.detect_derivation("derive " + arg) or None
439
+ for pattern, k in DERIVATION_KEYWORDS:
440
+ if re.search(pattern, arg.lower()):
441
+ key = k
442
+ break
443
+ if key:
444
+ self.derive(key)
445
+ else:
446
+ self.cmd_derive()
447
+ return
448
+ if low == "/electrochemistry":
449
+ self.cmd_electrochemistry()
450
+ return
451
+ if low == "/solve":
452
+ self.cmd_solve()
453
+ return
454
+ if low == "/atomsim":
455
+ self.cmd_atomsim()
456
+ return
457
+ if low == "/orbitals":
458
+ self.cmd_orbitals()
459
+ return
460
+ if low == "/orbitalgrid":
461
+ self.cmd_orbitalgrid()
462
+ return
463
+ if low == "/bonding" or low.startswith("/bonding "):
464
+ arg = raw.split(" ", 1)[1] if " " in raw else ""
465
+ self.cmd_bonding(arg)
466
+ return
467
+ if low == "/bloch" or low.startswith("/bloch "):
468
+ arg = raw.split(" ", 1)[1] if " " in raw else ""
469
+ self.cmd_bloch(arg)
470
+ return
471
+ if low == "/graph":
472
+ self.cmd_graph()
473
+ return
474
+ if low == "/shortcuts":
475
+ self.cmd_shortcuts()
476
+ return
477
+ if low == "/history":
478
+ self.cmd_history()
479
+ return
480
+ if low == "/settings":
481
+ self.cmd_settings()
482
+ return
483
+ if low == "/about":
484
+ self.cmd_about()
485
+ return
486
+ # v0.7.9: safety / auth / orchestration / integration commands
487
+ if low == "/stats":
488
+ self.cmd_stats()
489
+ return
490
+ if low == "/report" or low.startswith("/report "):
491
+ self.cmd_report(raw.split(" ", 1)[1].strip() if " " in raw else "")
492
+ return
493
+ if low == "/task" or low.startswith("/task "):
494
+ self.cmd_task(raw.split(" ", 1)[1].strip() if " " in raw else "")
495
+ return
496
+ if low == "/auth" or low.startswith("/auth "):
497
+ self.cmd_auth(raw.split(" ", 1)[1].strip() if " " in raw else "")
498
+ return
499
+ if low == "/vscode" or low.startswith("/vscode "):
500
+ self.cmd_vscode(raw.split(" ", 1)[1].strip() if " " in raw else "")
501
+ return
502
+ if low == "/colab" or low.startswith("/colab "):
503
+ self.cmd_colab(raw.split(" ", 1)[1].strip() if " " in raw else "")
504
+ return
505
+ if low == "/random":
506
+ self.solve(random.choice(list(GENERATORS.keys())))
507
+ return
508
+ if low == "/upload":
509
+ self.cmd_upload()
510
+ return
511
+ if low == "/voice":
512
+ self.cmd_voice()
513
+ return
514
+ if low == "/sim3d":
515
+ self.cmd_sim3d()
516
+ return
517
+ if low == "/gpu3d":
518
+ self.cmd_gpu3d()
519
+ return
520
+ if low == "/ai":
521
+ self.cmd_ai()
522
+ return
523
+ if low == "/agent":
524
+ self.cmd_agent()
525
+ return
526
+ if low in ("/ai-verify", "/verify"):
527
+ self.cmd_ai_verify()
528
+ return
529
+ if low == "/memory":
530
+ self.cmd_memory()
531
+ return
532
+ if low.startswith("/memory "):
533
+ self.cmd_memory(raw.split(" ", 1)[1].strip().lower())
534
+ return
535
+ if low in ("/game", "/quiz", "/play"):
536
+ self.cmd_game()
537
+ return
538
+ if low == "/codepad":
539
+ self.cmd_codepad()
540
+ return
541
+ if low == "/edit":
542
+ self.cmd_textedit()
543
+ return
544
+ if low == "/model":
545
+ self.cmd_model()
546
+ return
547
+ if low.startswith("/model "):
548
+ self.cmd_model(raw.split(" ", 1)[1].strip())
549
+ return
550
+ if low in ("/free", "/latest"):
551
+ (self.cmd_free() if low == "/free" else self.cmd_latest())
552
+ return
553
+ if low == "/provider":
554
+ self.cmd_provider()
555
+ return
556
+ if low == "/status":
557
+ self.cmd_status()
558
+ return
559
+ if low in ("/refresh-models", "/model-refresh"):
560
+ self.cmd_refresh_models()
561
+ return
562
+ if low == "/tokens":
563
+ self.cmd_tokens()
564
+ return
565
+ if low == "/theme":
566
+ self.cmd_theme()
567
+ return
568
+ if low.startswith("/theme "):
569
+ self.cmd_theme(raw.split(" ", 1)[1].strip())
570
+ return
571
+ if low.startswith("/websearch "):
572
+ self.cmd_websearch(raw.split(" ", 1)[1].strip())
573
+ return
574
+ if low == "/websearch":
575
+ self.cmd_websearch()
576
+ return
577
+ if low.startswith("/research "):
578
+ self.cmd_research(raw.split(" ", 1)[1].strip())
579
+ return
580
+ if low == "/research":
581
+ self.cmd_research()
582
+ return
583
+ if low.startswith("/import "):
584
+ self.cmd_import(raw.split(" ", 1)[1].strip())
585
+ return
586
+ if low == "/import":
587
+ self.cmd_import()
588
+ return
589
+ if low == "/pet":
590
+ self.cmd_pet()
591
+ return
592
+ if low == "/react":
593
+ self.cmd_react()
594
+ return
595
+ if low.startswith("/react "):
596
+ self.cmd_react(raw.split(" ", 1)[1].strip())
597
+ return
598
+ if low == "/export":
599
+ self.cmd_export()
600
+ return
601
+ if low.startswith("/export "):
602
+ self.cmd_export(raw.split(" ", 1)[1].strip())
603
+ return
604
+ if low == "/tui":
605
+ self.cmd_tui()
606
+ return
607
+ if low == "/composer":
608
+ self.cmd_composer()
609
+ return
610
+ if low == "/copycode":
611
+ self.cmd_copycode()
612
+ return
613
+
614
+ # ---- v0.7.7 Autonomous Development Preview commands ----
615
+ if low == "/install" or low.startswith("/install "):
616
+ self.cmd_install(raw.split(" ", 1)[1].strip() if " " in raw else "")
617
+ return
618
+ if low == "/packages" or low.startswith("/packages "):
619
+ self.cmd_packages(raw.split(" ", 1)[1].strip() if " " in raw else "")
620
+ return
621
+ if low == "/pipeline" or low.startswith("/pipeline "):
622
+ self.cmd_pipeline(raw.split(" ", 1)[1].strip() if " " in raw else "")
623
+ return
624
+ if low == "/orchestrate" or low.startswith("/orchestrate "):
625
+ self.cmd_orchestrate(raw.split(" ", 1)[1].strip() if " " in raw else "")
626
+ return
627
+
628
+ # ---- CAT Browser (full terminal browser, Fomoji-gated) --------------
629
+ if low in ("/browser", "/browse", "/cat") or low.startswith(("/browser ", "/browse ", "/cat ")):
630
+ self.cmd_browser(raw.split(" ", 1)[1].strip() if " " in raw else "")
631
+ return
632
+ if low == "/devices" or low.startswith("/devices "):
633
+ self.cmd_devices(raw.split(" ", 1)[1].strip() if " " in raw else "")
634
+ return
635
+
636
+ # secret stuff — checked before the normal question pipeline so a
637
+ # curious "who made this" or a stray "42" never falls through to
638
+ # "I couldn't match that to a supported topic yet."
639
+ if easter_eggs.check(low, self):
640
+ return
641
+
642
+ # free-text chemistry question
643
+ self.handle_question(raw)
644
+
645
+ # ------------------------------------------------------- free text ---
646
+ def detect_intent(self, text):
647
+ t = text.lower()
648
+ if re.search(r"mole|molar mass|molarity", t):
649
+ return "mole"
650
+ if "zero order" in t:
651
+ return "zero"
652
+ if "third order" in t:
653
+ return "third"
654
+ if "second order" in t:
655
+ return "second"
656
+ if re.search(r"half.?life", t):
657
+ return "halflife"
658
+ if re.search(r"arrhenius|activation energy", t):
659
+ return "arrhenius"
660
+ if "first order" in t:
661
+ return "first"
662
+ return None
663
+
664
+ def detect_derivation(self, text):
665
+ """Detect 'derive first order', 'derivation of zero order rate law',
666
+ etc. — routes to the real step-by-step calculus derivation instead
667
+ of a numeric example question."""
668
+ t = text.lower()
669
+ if not re.search(r"deriv", t):
670
+ return None
671
+ for pattern, key in DERIVATION_KEYWORDS:
672
+ if re.search(pattern, t):
673
+ return key
674
+ return None
675
+
676
+ def handle_question(self, raw):
677
+ # ---- v0.7.7 autonomous routing (spec sections 1/5/6) ----
678
+ # Install requests go straight to the package manager.
679
+ try:
680
+ from . import mode_detection
681
+ workflow = mode_detection.workflow_kind(raw)
682
+ if workflow == "install":
683
+ self.cmd_install(raw)
684
+ return
685
+ if workflow == "pipeline":
686
+ self.cmd_pipeline(raw)
687
+ return
688
+ # v0.7.8.1 mode lockdown: the selected mode is persistent —
689
+ # typing never auto-switches personas (the old silent
690
+ # re-routing bug). Workflow routing still runs: a deep
691
+ # research request goes through the Research flow, without
692
+ # changing the user's active mode.
693
+ if workflow == "research_deep":
694
+ self.cmd_research(raw)
695
+ return
696
+ except Exception:
697
+ pass
698
+
699
+ # The message is already shown once by run() right after it's
700
+ # typed — don't print it again here.
701
+ # v0.5.6: OpenCode-style cleaner thinking spinner (braille) instead
702
+ # of the old multi-line mascot indicator.
703
+ fallback_cli.thinking(label="CCT AI")
704
+
705
+ deriv_key = self.detect_derivation(raw)
706
+ if deriv_key:
707
+ self.derive(deriv_key)
708
+ return
709
+
710
+ intent = self.detect_intent(raw)
711
+ if intent:
712
+ self.solve(intent)
713
+ return
714
+
715
+ # No exact local match -> try the Chemistry Agent if configured.
716
+ # This allows the AI to handle novel topics like "third order" or
717
+ # custom plots before falling back to "Did you mean...".
718
+ config = aicore.load_config()
719
+ provider = config.get("provider")
720
+ ai_ready = provider == "ollama" or bool(config.get("api_key"))
721
+ if provider and ai_ready:
722
+ t0 = time.time()
723
+ final_text, steps, meta = agent.run_agent(raw, mode="ai")
724
+ duration = time.time() - t0
725
+ model_label = f"{provider.upper()} {config.get('model', '')}".strip()
726
+ if steps:
727
+ print(theme.dim(f" ({len(steps)} tool step(s) run \u2014 type /agent for the full notebook view)"))
728
+ # v0.5.6: adaptive OpenCode-style AI reply (bubble for short,
729
+ # borderless notebook text for long) with a `▣ tag · model · dur`
730
+ # avatar footer.
731
+ fallback_cli.render_ai(final_text, tag="CCT AI", model_label=model_label,
732
+ duration=duration, term_width=WIDTH)
733
+ if meta.get("suggest_agent"):
734
+ print(theme.orange(" \U0001F4A1 That looked like a big one \u2014 try /agent for the "
735
+ "full step-by-step notebook breakdown."))
736
+ print()
737
+ return
738
+
739
+ # ambiguous / short input -> smart suggestions
740
+ low = raw.lower()
741
+ matches = [s for s in SUGGESTIONS if low in s[0] or s[0].startswith(low)]
742
+ if not matches:
743
+ words = [w for w in low.split() if len(w) >= 4]
744
+ matches = [s for s in SUGGESTIONS if any(w in s[0] for w in words)]
745
+
746
+ if matches:
747
+ print()
748
+ print(theme.dim(" Did you mean:"))
749
+ for i, (key, label, tag, gen) in enumerate(matches[:6], 1):
750
+ print(f" {theme.cyan(str(i))} {theme.text(label)} {theme.faint('['+tag+']')}")
751
+ print(theme.faint(" Type a number to solve it, or press Enter to cancel."))
752
+ choice = input(theme.dim(" \u25b8 ")).strip()
753
+ if choice.isdigit() and 1 <= int(choice) <= min(6, len(matches)):
754
+ self.solve(matches[int(choice) - 1][3])
755
+ return
756
+
757
+ print(theme.orange("\n I couldn't match that to a supported topic yet."))
758
+ print(theme.dim(" Try /kinetics, /formulas, keywords like \"first order\", \"mole concept\","))
759
+ print(theme.dim(" or run /agent (or /ai) to configure a provider for general questions,"))
760
+ print(theme.dim(" live simulations, and custom graphs.\n"))
761
+
762
+ # ------------------------------------------------------------ solve --
763
+ def solve(self, intent, prefix=None):
764
+ print()
765
+ run_steps(ENGINE_STEPS)
766
+ nb = GENERATORS[intent]()
767
+ if prefix:
768
+ nb["question"] = f"{prefix} {nb['question']}"
769
+ print()
770
+ render_notebook(nb)
771
+ sound.play("success")
772
+ if self.autosave:
773
+ self.history.insert(0, nb)
774
+ self.history = self.history[:25]
775
+ self.post_notebook_menu(intent)
776
+
777
+ def derive(self, key):
778
+ print()
779
+ run_steps(DERIVE_STEPS, step_time=0.28)
780
+ dv = DERIVATIONS[key]()
781
+ print()
782
+ render_derivation(dv)
783
+ sound.play("success")
784
+ print()
785
+ print(theme.faint(" [Enter] continue [n] numeric example [h] home"))
786
+ choice = input(theme.dim(" \u25b8 ")).strip().lower()
787
+ if choice == "n" and key in GENERATORS:
788
+ self.solve(key)
789
+ elif choice == "h":
790
+ self.print_home()
791
+
792
+ def cmd_derive(self):
793
+ lines = [theme.purple("DERIVATIONS", bold=True), "",
794
+ theme.dim("Real step-by-step calculus derivations, not numeric examples.")]
795
+ keys = list(DERIVATIONS.keys())
796
+ for i, k in enumerate(keys, 1):
797
+ lines.append(f"{theme.cyan(str(i)+'.')} {theme.text(DERIVATIONS[k]().get('topic', k))}")
798
+ print()
799
+ print(theme.panel(lines, title="derive", color=theme.PURPLE, width=WIDTH))
800
+ choice = input(theme.dim(" \u25b8 ")).strip()
801
+ if choice.isdigit() and 1 <= int(choice) <= len(keys):
802
+ self.derive(keys[int(choice) - 1])
803
+ else:
804
+ self.print_home()
805
+
806
+ def post_notebook_menu(self, intent):
807
+ print()
808
+ print(theme.faint(" [Enter] continue [a] another [f] view formula [h] home"))
809
+ choice = input(theme.dim(" \u25b8 ")).strip().lower()
810
+ if choice == "a":
811
+ self.solve(intent)
812
+ elif choice == "f":
813
+ self.cmd_formulas()
814
+ elif choice == "h":
815
+ self.print_home()
816
+
817
+ # ------------------------------------------------------------- help --
818
+ def cmd_help(self):
819
+ lines = [theme.purple("COMMANDS", bold=True), ""]
820
+ for cmd, desc in COMMANDS:
821
+ lines.append(theme.cyan(cmd.ljust(20)) + theme.dim(desc))
822
+ lines.append("")
823
+ lines.append(theme.purple("SMART TYPING", bold=True))
824
+ lines.append(theme.dim("Type things like \"first order\", \"half life\", \"mole concept\","))
825
+ lines.append(theme.dim("and calc_terminal will detect the topic and solve a numerical."))
826
+ lines.append("")
827
+ lines.append(theme.purple("LIVE SIMULATIONS", bold=True))
828
+ lines.append(theme.dim("/atomsim — animated Bohr atom with real proton/neutron/electron counts"))
829
+ lines.append(theme.dim("/orbitals — live Monte-Carlo quantum electron-cloud (1s..3d)"))
830
+ lines.append(theme.dim("/sim3d — real-time rotatable 3D atom/electron/proton simulation"))
831
+ lines.append(theme.dim("Type /shortcuts for the full key reference inside those views."))
832
+ lines.append("")
833
+ lines.append(theme.purple("CHEMISTRY AGENTIC AI", bold=True))
834
+ lines.append(theme.dim("/agent — AI that actually solves, plots & simulates via tools,"))
835
+ lines.append(theme.dim(" not just prose (needs a provider — API key or Ollama)"))
836
+ lines.append(theme.dim("/ai-verify — test your configured provider/model connection"))
837
+ lines.append(theme.dim("Once configured, plain questions are also routed through the agent."))
838
+ lines.append("")
839
+ lines.append(theme.purple("LEARN BY PLAYING", bold=True))
840
+ lines.append(theme.dim("/game — a multiple-choice Element Quiz: symbols, names, atomic"))
841
+ lines.append(theme.dim("numbers & shell structure, with streaks, lives and a speed bonus."))
842
+ lines.append("")
843
+ lines.append(theme.purple("SOUND", bold=True))
844
+ lines.append(theme.dim("Short terminal chimes play on startup, solved results, errors, and"))
845
+ lines.append(theme.dim("simulation starts (and per agent tool call). Toggle in /settings."))
846
+ print()
847
+ print(theme.panel(lines, title="help", color=theme.PURPLE, width=WIDTH))
848
+ print()
849
+
850
+ # ------------------------------------------------------- calculator --
851
+ def cmd_calculator(self):
852
+ print()
853
+ print(theme.panel(
854
+ [theme.text("Quick scientific calculator", bold=True),
855
+ theme.dim("Supports + - * / ** () sqrt() log10() log() sin() cos() tan() pi e"),
856
+ theme.dim("Type /back to return home.")],
857
+ title="calculator", color=theme.CYAN, width=WIDTH))
858
+ allowed = re.compile(r"^[0-9+\-*/().\s,a-zA-Z_]*$")
859
+ safe_ns = {
860
+ "sqrt": math.sqrt, "log10": math.log10, "log": math.log,
861
+ "sin": math.sin, "cos": math.cos, "tan": math.tan,
862
+ "pi": math.pi, "e": math.e, "abs": abs, "round": round,
863
+ }
864
+ while True:
865
+ expr = input(theme.dim(" calc \u25b8 ")).strip()
866
+ if not expr:
867
+ continue
868
+ if expr.lower() in ("/back", "back", "/exit"):
869
+ break
870
+ try:
871
+ expr_py = expr.replace("^", "**")
872
+ if not allowed.match(expr_py):
873
+ raise ValueError("invalid characters")
874
+ result = eval(expr_py, {"__builtins__": {}}, safe_ns)
875
+ if isinstance(result, float):
876
+ result = round(result, self.precision)
877
+ print(" " + theme.green(f"= {result}", bold=True))
878
+ sound.play("success")
879
+ except Exception:
880
+ print(" " + theme.red("Error \u2014 check the expression"))
881
+ sound.play("error")
882
+ self.print_home()
883
+
884
+ # ----------------------------------------------- universal solver ----
885
+ def cmd_solve(self):
886
+ while True:
887
+ print()
888
+ lines = [theme.purple("UNIVERSAL FORMULA CALCULATOR", bold=True), ""]
889
+ cats = {}
890
+ for key, (name, cat, _, _) in solver.FORMULA_LIBRARY.items():
891
+ cats.setdefault(cat, []).append((key, name))
892
+ i = 0
893
+ index_map = []
894
+ for cat in sorted(cats):
895
+ lines.append(theme.cyan(cat, bold=True))
896
+ for key, name in cats[cat]:
897
+ i += 1
898
+ index_map.append(key)
899
+ lines.append(f" {theme.dim(str(i)+'.')} {theme.text(name)}")
900
+ lines.append("")
901
+ lines.append(theme.faint("Type a number for a library formula,"))
902
+ lines.append(theme.faint("or 'custom' to solve ANY formula you type, or /back."))
903
+ print(theme.panel(lines, title="solve", color=theme.PURPLE, width=WIDTH))
904
+ choice = input(theme.dim(" \u25b8 ")).strip().lower()
905
+ if choice in ("/back", "back", "/exit", ""):
906
+ break
907
+ if choice == "custom":
908
+ self._solve_custom()
909
+ continue
910
+ if choice.isdigit() and 1 <= int(choice) <= len(index_map):
911
+ self._solve_library(index_map[int(choice) - 1])
912
+ continue
913
+ print(theme.red(" Not a valid choice."))
914
+ self.print_home()
915
+
916
+ def _solve_library(self, key):
917
+ name, cat, formula_str, var_desc = solver.FORMULA_LIBRARY[key]
918
+ print()
919
+ lines = [theme.purple(name, bold=True) + " " + theme.faint(f"[{cat}]"), ""]
920
+ for pl in solver.pretty(formula_str).split("\n"):
921
+ lines.append(theme.text(pl, bold=True))
922
+ lines.append("")
923
+ for sym, desc in var_desc.items():
924
+ lines.append(f" {theme.cyan(sym)} = {theme.dim(desc)}")
925
+ lines.append("")
926
+ lines.append(theme.faint("Enter values for ALL BUT ONE variable (e.g. P=1 n=2 T=300),"))
927
+ lines.append(theme.faint("leave exactly one out — that's what gets solved."))
928
+ print(theme.panel(lines, title="formula", color=theme.CYAN, width=WIDTH))
929
+ raw = input(theme.dim(" values \u25b8 ")).strip()
930
+ known = self._parse_assignments(raw)
931
+ try:
932
+ solve_for, result, name2, cat2, eq = solver.solve_library_formula(key, known)
933
+ run_steps(["Substituting known values...", "Solving symbolically..."], step_time=0.22)
934
+ print()
935
+ result_lines = [theme.dim("Solving:")]
936
+ for pl in solver.pretty(eq).split("\n"):
937
+ result_lines.append(theme.text(pl))
938
+ result_lines.append("")
939
+ result_lines.append(theme.green(f"{solve_for} = {result:.6g}", bold=True))
940
+ print(theme.panel(result_lines, title="result", color=theme.GREEN, width=WIDTH))
941
+ sound.play("success")
942
+ except solver.SolveError as e:
943
+ print(theme.red(f" {e}"))
944
+ sound.play("error")
945
+ except Exception as e:
946
+ print(theme.red(f" Error: {e}"))
947
+ sound.play("error")
948
+ input(theme.faint("\n Press Enter to continue\u2026"))
949
+
950
+ def _solve_custom(self):
951
+ print()
952
+ print(theme.panel([
953
+ theme.text("Type ANY formula, e.g. P*V = n*R*T or y = m*x + c", bold=True),
954
+ theme.dim("Known constants available: R, NA, h, c, k_B, F, pi, e, Rinf, a0, me"),
955
+ ], title="custom formula", color=theme.CYAN, width=WIDTH))
956
+ formula_str = input(theme.dim(" formula \u25b8 ")).strip()
957
+ if not formula_str or "=" not in formula_str:
958
+ print(theme.red(" Need a formula containing '='."))
959
+ return
960
+ raw = input(theme.dim(" known values (e.g. m=2 x=3 c=1) \u25b8 ")).strip()
961
+ known = self._parse_assignments(raw)
962
+ solve_for = input(theme.dim(" solve for \u25b8 ")).strip()
963
+ try:
964
+ result, eq = solver.solve_formula(formula_str, known, solve_for)
965
+ run_steps(["Parsing formula...", "Solving symbolically..."], step_time=0.22)
966
+ print()
967
+ result_lines = [theme.dim("Equation:")]
968
+ for pl in solver.pretty(eq).split("\n"):
969
+ result_lines.append(theme.text(pl))
970
+ result_lines.append("")
971
+ result_lines.append(theme.green(f"{solve_for} = {result:.6g}", bold=True))
972
+ print(theme.panel(result_lines, title="result", color=theme.GREEN, width=WIDTH))
973
+ sound.play("success")
974
+ except solver.SolveError as e:
975
+ print(theme.red(f" {e}"))
976
+ sound.play("error")
977
+ except Exception as e:
978
+ print(theme.red(f" Error: {e}"))
979
+ sound.play("error")
980
+ input(theme.faint("\n Press Enter to continue\u2026"))
981
+
982
+ @staticmethod
983
+ def _parse_assignments(raw):
984
+ known = {}
985
+ for tok in raw.replace(",", " ").split():
986
+ if "=" in tok:
987
+ k, v = tok.split("=", 1)
988
+ try:
989
+ known[k.strip()] = float(v.strip())
990
+ except ValueError:
991
+ pass
992
+ return known
993
+
994
+ # ------------------------------------------------------- atom sim ----
995
+ def cmd_atomsim(self):
996
+ print()
997
+ print(theme.panel([
998
+ theme.text("Live Bohr atom & electron simulation", bold=True),
999
+ theme.dim("Enter an element symbol or atomic number (H..Kr, Z=1-36)."),
1000
+ ], title="atomsim", color=theme.CYAN, width=WIDTH))
1001
+ raw = input(theme.dim(" element \u25b8 ")).strip() or "C"
1002
+ z = atomsim.resolve_element(raw)
1003
+ if z is None:
1004
+ print(theme.red(f" Unknown element '{raw}'."))
1005
+ sound.play("error")
1006
+ return
1007
+ sound.play("sim_start")
1008
+ atomsim.atom_simulation(z)
1009
+ self.print_home()
1010
+
1011
+ def cmd_orbitals(self):
1012
+ print()
1013
+ print(theme.panel([
1014
+ theme.text("Live quantum orbital (electron cloud) simulation", bold=True),
1015
+ theme.dim("Choose an orbital: " + ", ".join(atomsim.ORBITALS.keys())),
1016
+ theme.dim("Real |psi|\u00b2 Monte-Carlo sampling — watch the cloud build up live."),
1017
+ ], title="orbitals", color=theme.PURPLE, width=WIDTH))
1018
+ raw = input(theme.dim(" orbital \u25b8 ")).strip().lower() or "1s"
1019
+ sound.play("sim_start")
1020
+ atomsim.orbital_simulation(raw)
1021
+ self.print_home()
1022
+
1023
+ def cmd_orbitalgrid(self):
1024
+ print()
1025
+ print(theme.panel([
1026
+ theme.text("Hydrogen orbital reference chart", bold=True),
1027
+ theme.dim("Exports a multi-panel PNG of |\u03c8|\u00b2 for a spread of (n,l,m) orbitals,"),
1028
+ theme.dim("the same style as the classic textbook probability-density chart."),
1029
+ ], title="orbitalgrid", color=theme.PURPLE, width=WIDTH))
1030
+ sound.play("sim_start")
1031
+ try:
1032
+ path = atomsim.snapshot_orbital_grid()
1033
+ except Exception as e:
1034
+ path = None
1035
+ if path:
1036
+ print(theme.green(f" Saved: {path}"))
1037
+ else:
1038
+ print(theme.red(" matplotlib/numpy/scipy not installed \u2014 export skipped."))
1039
+ self.print_home()
1040
+
1041
+ def cmd_bonding(self, arg=""):
1042
+ from . import scires
1043
+ print()
1044
+ names = ", ".join(sorted(scires.MOLECULES.keys()))
1045
+ raw = arg.strip().lower()
1046
+ if not raw:
1047
+ print(theme.panel([
1048
+ theme.text("Chemical bonding density map (ELF-style)", bold=True),
1049
+ theme.dim(f"Available: {names}"),
1050
+ theme.dim("Stylized illustration, not an ab-initio DFT calculation."),
1051
+ ], title="bonding", color=theme.GREEN, width=WIDTH))
1052
+ raw = input(theme.dim(" molecule \u25b8 ")).strip().lower() or "furan"
1053
+ if raw not in scires.MOLECULES:
1054
+ print(theme.red(f" Unknown molecule '{raw}'. Try: {names}"))
1055
+ self.print_home()
1056
+ return
1057
+ sound.play("sim_start")
1058
+ try:
1059
+ path = scires.render_bonding(raw)
1060
+ except Exception:
1061
+ path = None
1062
+ if path:
1063
+ print(theme.green(f" Saved: {path}"))
1064
+ else:
1065
+ print(theme.red(" matplotlib/numpy/scipy not installed \u2014 export skipped."))
1066
+ self.print_home()
1067
+
1068
+ def cmd_bloch(self, arg=""):
1069
+ from . import scires
1070
+ print()
1071
+ raw = (arg or "").strip()
1072
+ theta_deg, phi_deg = 90.0, 0.0 # default: the |+> state
1073
+ if raw:
1074
+ parts = raw.split()
1075
+ try:
1076
+ if len(parts) >= 1:
1077
+ theta_deg = float(parts[0])
1078
+ if len(parts) >= 2:
1079
+ phi_deg = float(parts[1])
1080
+ except ValueError:
1081
+ print(theme.red(f" Couldn't parse '{raw}' as 'theta phi' in degrees, "
1082
+ f"e.g. /bloch 90 0"))
1083
+ self.print_home()
1084
+ return
1085
+ else:
1086
+ print(theme.panel([
1087
+ theme.text("Bloch sphere for a single qubit", bold=True),
1088
+ theme.dim("|\u03c8\u27e9 = cos(\u03b8/2)|0\u27e9 + e^{i\u03c6}sin(\u03b8/2)|1\u27e9"),
1089
+ theme.dim("Examples: /bloch 0 0 = |0\u27e9 /bloch 90 0 = |+\u27e9 /bloch 90 90 = |+i\u27e9"),
1090
+ ], title="bloch", color=theme.CYAN, width=WIDTH))
1091
+ typed = input(theme.dim(" \u03b8 \u03c6 (degrees, blank = |+\u27e9) \u25b8 ")).strip()
1092
+ if typed:
1093
+ parts = typed.split()
1094
+ try:
1095
+ if len(parts) >= 1:
1096
+ theta_deg = float(parts[0])
1097
+ if len(parts) >= 2:
1098
+ phi_deg = float(parts[1])
1099
+ except ValueError:
1100
+ print(theme.red(f" Couldn't parse '{typed}' \u2014 using the default |+\u27e9 state."))
1101
+
1102
+ sound.play("sim_start")
1103
+
1104
+ def do_render():
1105
+ return scires.render_bloch_sphere(theta_deg, phi_deg)
1106
+
1107
+ try:
1108
+ path = do_render()
1109
+ except Exception as e:
1110
+ retried = {}
1111
+
1112
+ def _retry():
1113
+ retried["path"] = do_render()
1114
+ return retried["path"]
1115
+ ok = errors.error_card("render", e, retry=_retry, width=WIDTH)
1116
+ path = retried.get("path") if ok else None
1117
+ if path:
1118
+ print(theme.green(f" Saved: {path}"))
1119
+ elif path is None and not scires.HAVE_DEPS:
1120
+ print(theme.red(" matplotlib/numpy/scipy not installed \u2014 export skipped."))
1121
+ self.print_home()
1122
+
1123
+ # ----------------------------------------------------------- graphs --
1124
+ def cmd_workspace(self, arg=""):
1125
+ """v0.7.7 Workspace Manager — automatic detection (spec section
1126
+ 1), multi-workspace history (sections 4/5), and generated-file
1127
+ browsing (the original behavior, fully preserved):
1128
+ /workspace -> manager dashboard
1129
+ /workspace <category> -> browse generated files (original)
1130
+ /workspace detect -> re-run automatic detection
1131
+ /workspace open <path> -> open a folder as the workspace
1132
+ /workspace list -> recent + pinned + favorite workspaces
1133
+ /workspace add <path> [label] -> add to history + open
1134
+ /workspace remove <path> -> remove from history
1135
+ /workspace rename <path> <label> / <old> <new>
1136
+ /workspace pin <path> / unpin <path>
1137
+ /workspace fav <path> / unfav <path>
1138
+ /workspace last -> last opened workspace
1139
+ /workspace search <query> -> search history
1140
+ /workspace forget -> clear all history
1141
+ """
1142
+ from . import workspace as ws
1143
+ from . import projects as projs
1144
+ raw = (arg or "").strip()
1145
+
1146
+ # -- original behavior: category browsing (never removed) --------
1147
+ cat = raw.lower()
1148
+ if cat and cat in ws.CATEGORIES:
1149
+ files = ws.list_category(cat)
1150
+ lines = [theme.purple(f"WORKSPACE \u2014 {cat}", bold=True), ""]
1151
+ if not files:
1152
+ lines.append(theme.faint("Nothing here yet."))
1153
+ else:
1154
+ files.sort(key=lambda f: f["mtime"], reverse=True)
1155
+ for f in files[:20]:
1156
+ size_kb = f["size"] / 1024
1157
+ lines.append(f"{theme.cyan(f['name'])} {theme.faint(f'{size_kb:.0f} KB')}")
1158
+ print()
1159
+ print(theme.panel(lines, title="workspace", color=theme.PURPLE, width=WIDTH))
1160
+ self.print_home()
1161
+ return
1162
+
1163
+ parts = raw.split()
1164
+ sub = parts[0].lower() if parts else ""
1165
+ args_after = parts[1:] if len(parts) > 1 else []
1166
+
1167
+ # -- /workspace detect ------------------------------------------
1168
+ if sub == "detect":
1169
+ print()
1170
+ lines = [theme.purple("WORKSPACE DETECTION", bold=True), ""]
1171
+ target = ws.detect_workspace()
1172
+ if target:
1173
+ lines.append(f" Detected workspace: {theme.green(target, bold=True)}")
1174
+ branch = ws.git_branch(target)
1175
+ if branch:
1176
+ lines.append(f" Git branch: {theme.cyan(branch)}")
1177
+ else:
1178
+ lines.append(theme.faint(" No workspace detected yet \u2014 "
1179
+ "use /workspace open <path>."))
1180
+ print(theme.panel(lines, title="detect", color=theme.PURPLE, width=WIDTH))
1181
+ self.print_home()
1182
+ return
1183
+
1184
+ # -- /workspace open <path> -------------------------------------
1185
+ if sub == "open":
1186
+ path = " ".join(args_after)
1187
+ if not path:
1188
+ path = input(theme.dim(" Folder to open (full path) \u25b8 ")).strip()
1189
+ path = path.strip('"').strip()
1190
+ if not path:
1191
+ self.print_home()
1192
+ return
1193
+ expanded = os.path.expanduser(path)
1194
+ if not os.path.isdir(expanded):
1195
+ print(theme.red(f" \u2717 '{path}' is not a folder."))
1196
+ self.print_home()
1197
+ return
1198
+ projs.record_opened(expanded)
1199
+ ws.set_active_project(expanded)
1200
+ print()
1201
+ print(theme.green(f" \u2713 Opened workspace: {expanded}", bold=True))
1202
+ branch = ws.git_branch(expanded)
1203
+ if branch:
1204
+ print(theme.dim(f" Git branch: {branch}"))
1205
+ print(theme.faint(" (The Textual IDE uses /open \u2014 this sets the classic "
1206
+ "workspace and history.)"))
1207
+ self.print_home()
1208
+ return
1209
+
1210
+ # -- /workspace open-current: detect + open what's found --------
1211
+ if sub in ("open-current", "use-current", "this"):
1212
+ target = ws.detect_workspace()
1213
+ if not target:
1214
+ print(theme.orange(" No workspace detected in this directory."))
1215
+ self.print_home()
1216
+ return
1217
+ projs.record_opened(target)
1218
+ ws.set_active_project(target)
1219
+ print()
1220
+ print(theme.green(f" \u2713 Workspace set: {target}", bold=True))
1221
+ self.print_home()
1222
+ return
1223
+
1224
+ # -- /workspace list --------------------------------------------
1225
+ if sub in ("list", "history", "recent"):
1226
+ print()
1227
+ lines = [theme.purple("WORKSPACE HISTORY", bold=True), ""]
1228
+ rows = projs.workspaces()
1229
+ if not rows:
1230
+ lines.append(theme.faint(" Nothing yet \u2014 /workspace open <path> or "
1231
+ "detect a folder first."))
1232
+ else:
1233
+ for r in rows:
1234
+ marks = ("\U0001f4cc" if r.get("pinned") else "\u00b7") \
1235
+ + (" \u2b50" if r.get("favorite") else "")
1236
+ label = r.get("label") or r["path"]
1237
+ lines.append(f" {marks} {theme.cyan(label, bold=True)}")
1238
+ lines.append(f" {theme.faint(r['path'])}")
1239
+ if r.get("opened_at"):
1240
+ import datetime
1241
+ when = datetime.datetime.fromtimestamp(r["opened_at"])
1242
+ lines.append(f" {theme.faint('last opened ' + when.strftime('%Y-%m-%d %H:%M'))}")
1243
+ lines.append("")
1244
+ lines.append(theme.faint("Manage: pin/unpin, fav/unfav, rename, remove \u2014 "
1245
+ "see /workspace for usage."))
1246
+ print(theme.panel(lines, title="workspaces", color=theme.PURPLE, width=WIDTH))
1247
+ self.print_home()
1248
+ return
1249
+
1250
+ # -- /workspace last --------------------------------------------
1251
+ if sub == "last":
1252
+ target = projs.last_opened()
1253
+ print()
1254
+ if target:
1255
+ print(theme.cyan(" Last opened: ") + theme.green(target, bold=True))
1256
+ else:
1257
+ print(theme.faint(" No recorded workspaces yet."))
1258
+ self.print_home()
1259
+ return
1260
+
1261
+ # -- /workspace search <query> ----------------------------------
1262
+ if sub == "search":
1263
+ query = " ".join(args_after)
1264
+ if not query:
1265
+ query = input(theme.dim(" Search workspaces by path or label \u25b8 ")).strip()
1266
+ print()
1267
+ hits = projs.search(query)
1268
+ if not hits:
1269
+ print(theme.faint(f" No workspaces match '{query}'."))
1270
+ else:
1271
+ for h in hits:
1272
+ label = h.get("label") or h["path"]
1273
+ print(f" {theme.cyan(label)} {theme.faint(h['path'])}")
1274
+ self.print_home()
1275
+ return
1276
+
1277
+ # -- /workspace add <path> [label] ------------------------------
1278
+ if sub == "add":
1279
+ if not args_after:
1280
+ print(theme.orange(" Usage: /workspace add <path> [label]"))
1281
+ self.print_home()
1282
+ return
1283
+ path = args_after[0].strip('"')
1284
+ label = " ".join(args_after[1:]).strip() or None
1285
+ expanded = os.path.expanduser(path)
1286
+ if not os.path.isdir(expanded):
1287
+ print(theme.red(f" \u2717 '{path}' is not a folder."))
1288
+ self.print_home()
1289
+ return
1290
+ projs.record_opened(expanded)
1291
+ if label:
1292
+ projs.rename(expanded, label)
1293
+ print(theme.green(f" \u2713 Added '{label or expanded}' to workspace history."))
1294
+ self.print_home()
1295
+ return
1296
+
1297
+ # -- /workspace remove <path> -----------------------------------
1298
+ if sub in ("remove", "delete", "rm"):
1299
+ if not args_after:
1300
+ print(theme.orange(" Usage: /workspace remove <path>"))
1301
+ self.print_home()
1302
+ return
1303
+ path = " ".join(args_after).strip('"')
1304
+ removed = projs.remove(path)
1305
+ if removed:
1306
+ print(theme.green(f" \u2713 Removed '{path}' from workspace history."))
1307
+ else:
1308
+ print(theme.faint(f" '{path}' wasn't in workspace history."))
1309
+ self.print_home()
1310
+ return
1311
+
1312
+ # -- /workspace forget (clear all) ------------------------------
1313
+ if sub in ("forget", "clear"):
1314
+ projs.clear_recent()
1315
+ print(theme.green(" \u2713 Workspace history cleared."))
1316
+ self.print_home()
1317
+ return
1318
+
1319
+ # -- /workspace rename <path|old-label> <new label> -------------
1320
+ if sub == "rename":
1321
+ if len(args_after) < 2:
1322
+ print(theme.orange(" Usage: /workspace rename <path> <new label>"))
1323
+ self.print_home()
1324
+ return
1325
+ old = args_after[0].strip('"')
1326
+ new_label = " ".join(args_after[1:]).strip()
1327
+ target = None
1328
+ for r in projs.workspaces():
1329
+ if r.get("label") == old or r["path"].lower() == old.lower() \
1330
+ or os.path.basename(r["path"]).lower() == old.lower():
1331
+ target = r["path"]
1332
+ break
1333
+ if not target:
1334
+ print(theme.faint(f" '{old}' isn't in workspace history."))
1335
+ self.print_home()
1336
+ return
1337
+ projs.rename(target, new_label)
1338
+ print(theme.green(f" \u2713 Renamed to '{new_label}'."))
1339
+ self.print_home()
1340
+ return
1341
+
1342
+ # -- /workspace pin / unpin / fav / unfav -----------------------
1343
+ def _resolve_target(arg_tokens):
1344
+ path = " ".join(arg_tokens).strip('"')
1345
+ for r in projs.workspaces():
1346
+ if r.get("label") == path or r["path"].lower() == path.lower() \
1347
+ or os.path.basename(r["path"]).lower() == path.lower():
1348
+ return r["path"]
1349
+ return os.path.abspath(os.path.expanduser(path)) if path else None
1350
+
1351
+ if sub in ("pin", "unpin", "fav", "favorite", "unfav", "unfavorite"):
1352
+ target = _resolve_target(args_after)
1353
+ if not target:
1354
+ print(theme.orange(" Give a path or label from /workspace list."))
1355
+ self.print_home()
1356
+ return
1357
+ if sub in ("pin",):
1358
+ if not projs.is_pinned(target):
1359
+ projs.toggle_pin(target)
1360
+ print(theme.green(f" \U0001f4cc Pinned: {target}"))
1361
+ elif sub == "unpin":
1362
+ if projs.is_pinned(target):
1363
+ projs.toggle_pin(target)
1364
+ print(theme.green(f" \U0001f4cc Unpinned: {target}"))
1365
+ elif sub in ("fav", "favorite"):
1366
+ if not projs.is_favorite(target):
1367
+ projs.toggle_favorite(target)
1368
+ print(theme.green(f" \u2b50 Favorite: {target}"))
1369
+ else:
1370
+ if projs.is_favorite(target):
1371
+ projs.toggle_favorite(target)
1372
+ print(theme.green(f" \u2b50 Unfavorited: {target}"))
1373
+ self.print_home()
1374
+ return
1375
+
1376
+ # -- /workspace (no args): manager dashboard --------------------
1377
+ print()
1378
+ lines = [theme.purple("WORKSPACE MANAGER", bold=True), ""]
1379
+ detected = ws.detect_workspace()
1380
+ active = ws.active_project()
1381
+ if active:
1382
+ lines.append(f" \u25b8 Active: {theme.green(active, bold=True)}")
1383
+ else:
1384
+ lines.append(" \u25b8 Active: " + theme.faint("none \u2014 using the configured "
1385
+ "workspace directory"))
1386
+ if detected:
1387
+ branch = ws.git_branch(detected)
1388
+ line = f" \U0001f50d Detected: {theme.cyan(detected)}"
1389
+ if branch:
1390
+ line += theme.faint(f" ({branch})")
1391
+ lines.append(line)
1392
+ lines.append("")
1393
+ rows = projs.workspaces()
1394
+ if rows:
1395
+ lines.append(theme.dim("History (newest first; \U0001f4cc = pinned, \u2b50 = favorite):"))
1396
+ for r in rows[:8]:
1397
+ marks = ("\U0001f4cc" if r.get("pinned") else "\u00b7") \
1398
+ + (" \u2b50" if r.get("favorite") else "")
1399
+ label = r.get("label") or r["path"]
1400
+ lines.append(f" {marks} {theme.cyan(label)} {theme.faint(r['path'])}")
1401
+ lines.append("")
1402
+ lines.append(theme.dim("Commands:"))
1403
+ lines.append(theme.faint(" /workspace open <path> open a folder as the workspace"))
1404
+ lines.append(theme.faint(" /workspace list recent, pinned & favorite workspaces"))
1405
+ lines.append(theme.faint(" /workspace add/remove/rename/pin/fav/search/last"))
1406
+ lines.append(theme.faint(" /workspace <category> browse generated files (original)"))
1407
+ print(theme.panel(lines, title="workspace", color=theme.PURPLE, width=WIDTH))
1408
+
1409
+ # -- original summary of generated files, preserved -------------
1410
+ rows = ws.summary()
1411
+ lines = [theme.purple("GENERATED FILES", bold=True), ""]
1412
+ if not rows:
1413
+ lines.append(theme.faint("Nothing generated yet. Try /atomsim, /orbitals, /bonding, or /export."))
1414
+ else:
1415
+ for cat_name, count, newest, when in rows:
1416
+ lines.append(f"{theme.cyan(cat_name):<28} {count} file(s) \u00b7 newest: {newest} ({when})")
1417
+ lines.append("")
1418
+ lines.append(theme.faint("Type /workspace <category> to browse one, e.g. /workspace graphs"))
1419
+ print(theme.panel(lines, title="generated files", color=theme.PURPLE, width=WIDTH))
1420
+ print()
1421
+
1422
+ def cmd_graph(self):
1423
+ print()
1424
+ lines = [theme.purple("CHEMISTRY GRAPHS", bold=True), ""]
1425
+ for i, (key, desc) in enumerate(graphs.PRESETS, 1):
1426
+ lines.append(f"{theme.cyan(str(i)+'.')} {theme.text(desc)}")
1427
+ lines.append("")
1428
+ lines.append(theme.faint("Type a number for an animated terminal graph."))
1429
+ print(theme.panel(lines, title="graph", color=theme.CYAN, width=WIDTH))
1430
+ choice = input(theme.dim(" \u25b8 ")).strip()
1431
+ if not (choice.isdigit() and 1 <= int(choice) <= len(graphs.PRESETS)):
1432
+ self.print_home()
1433
+ return
1434
+ key, _ = graphs.PRESETS[int(choice) - 1]
1435
+ xs, ys, title, xlabel, ylabel = graphs.preset_curve(key)
1436
+ print()
1437
+ graphs.ascii_plot(xs, ys, title, xlabel, ylabel)
1438
+ export = input(theme.dim("\n Export a high-quality PNG too? [y/N] \u25b8 ")).strip().lower()
1439
+ if export == "y":
1440
+ try:
1441
+ path = graphs.export_2d(xs, ys, title, xlabel, ylabel)
1442
+ print(theme.green(f" Saved: {path}"))
1443
+ if key in ("radial_prob_1s",):
1444
+ path3d = graphs.export_3d_surface("orbital_3d")
1445
+ print(theme.green(f" Saved 3D: {path3d}"))
1446
+ except Exception as e:
1447
+ print(theme.red(f" Export failed (is matplotlib installed?): {e}"))
1448
+ input(theme.faint("\n Press Enter to continue\u2026"))
1449
+ self.print_home()
1450
+
1451
+ # -------------------------------------------------------- shortcuts --
1452
+ def cmd_shortcuts(self):
1453
+ from . import keys as _keys
1454
+ lines = [theme.purple("SHORTCUT KEYS", bold=True), "",
1455
+ theme.cyan("Inside /atomsim and /orbitals live views:", bold=True)]
1456
+ for k, desc in _keys.SHORTCUTS_HELP:
1457
+ lines.append(f" {theme.badge(k, theme.BG_INFO)} {theme.dim(desc)}")
1458
+ lines.append("")
1459
+ lines.append(theme.cyan("Quick command aliases (type instead of the full command):", bold=True))
1460
+ for short, full in self.SHORT_ALIASES.items():
1461
+ lines.append(f" {theme.badge(short, theme.BG_OK)} {theme.dim('\u2192 ' + full)}")
1462
+ lines.append("")
1463
+ lines.append(theme.cyan("Anywhere in the terminal:", bold=True))
1464
+ lines.append(f" {theme.badge('/back', theme.BG_INFO)} {theme.dim('return to the previous menu')}")
1465
+ lines.append(f" {theme.badge('/home or /clear', theme.BG_INFO)} {theme.dim('jump to the home screen')}")
1466
+ lines.append(f" {theme.badge('Ctrl+C', theme.BG_INFO)} {theme.dim('quit instantly from anywhere')}")
1467
+ lines.append(f" {theme.badge('a / f / h', theme.BG_INFO)} {theme.dim('after any solved notebook: another / formula / home')}")
1468
+ lines.append("")
1469
+ lines.append(theme.faint(f" \u2728 {len(self.eggs_found)} hidden easter egg(s) found this session \u2014 keep typing, some things aren't in any list."))
1470
+ print()
1471
+ print(theme.panel(lines, title="shortcuts", color=theme.PURPLE, width=WIDTH))
1472
+ print()
1473
+
1474
+ # --------------------------------------------------------- formulas --
1475
+ def cmd_formulas(self):
1476
+ while True:
1477
+ print()
1478
+ lines = [theme.purple("FORMULA LIBRARY", bold=True), ""]
1479
+ for i, f in enumerate(FORMULAS, 1):
1480
+ lines.append(f"{theme.cyan(str(i)+'.')} {theme.text(f['name'])} {theme.faint('['+f['tag']+']')}")
1481
+ lines.append("")
1482
+ lines.append(theme.faint("Type a number to expand, or /back to return home."))
1483
+ print(theme.panel(lines, title="formulas", color=theme.PURPLE, width=WIDTH))
1484
+ choice = input(theme.dim(" \u25b8 ")).strip().lower()
1485
+ if choice in ("/back", "back", "/exit", ""):
1486
+ break
1487
+ if choice.isdigit() and 1 <= int(choice) <= len(FORMULAS):
1488
+ self.show_formula(FORMULAS[int(choice) - 1])
1489
+ else:
1490
+ print(theme.red(" Not a valid choice."))
1491
+ self.print_home()
1492
+
1493
+ def show_formula(self, f):
1494
+ lines = [theme.purple(f["name"], bold=True) + " " + theme.faint("[" + f["tag"] + "]"), ""]
1495
+ for l in compose(f["formula"]).split("\n"):
1496
+ lines.append(theme.text(l, bold=True))
1497
+ lines.append("")
1498
+ for label, key in [("MEANING", "meaning"), ("VARIABLES", "variables"), ("UNITS", "units"),
1499
+ ("CONDITIONS", "conditions"), ("COMMON MISTAKES", "mistakes"),
1500
+ ("SHORTCUT TRICK", "trick"), ("MEMORY TRICK", "memory"), ("SOLVED EXAMPLE", "example")]:
1501
+ lines.append(theme.cyan(label, bold=True))
1502
+ for w in textwrap.wrap(f[key], width=WIDTH - 10) or [""]:
1503
+ lines.append(theme.dim(w))
1504
+ lines.append("")
1505
+ print()
1506
+ print(theme.panel(lines, title="formula", color=theme.CYAN, width=WIDTH))
1507
+ input(theme.faint(" Press Enter to continue\u2026"))
1508
+
1509
+ # --------------------------------------------------------- kinetics --
1510
+ def cmd_kinetics(self):
1511
+ labels = {
1512
+ "zero": ("Zero Order", "R = R0 - kt"),
1513
+ "first": ("First Order", "k = (2.303/t) log(R0/R)"),
1514
+ "second": ("Second Order", "1/R = 1/R0 + kt"),
1515
+ "third": ("Third Order", "1/R\u00b2 = 1/R\u2080\u00b2 + 2kt"),
1516
+ "halflife": ("Half-Life", "t1/2 = 0.693/k"),
1517
+ "arrhenius": ("Arrhenius Equation", "k = A e^(-Ea/RT)"),
1518
+ }
1519
+ lines = [theme.purple("CHEMICAL KINETICS", bold=True), ""]
1520
+ for i, key in enumerate(KINETICS_ORDER, 1):
1521
+ name, formula = labels[key]
1522
+ lines.append(f"{theme.cyan(str(i)+'.')} {theme.text(name.ljust(24))} {theme.faint(formula)}")
1523
+ lines.append("")
1524
+ lines.append(theme.faint(f"{len(KINETICS_ORDER)+1}. Random kinetics numerical"))
1525
+ print()
1526
+ print(theme.panel(lines, title="kinetics", color=theme.PURPLE, width=WIDTH))
1527
+ choice = input(theme.dim(" \u25b8 ")).strip()
1528
+ if choice.isdigit():
1529
+ idx = int(choice)
1530
+ if 1 <= idx <= len(KINETICS_ORDER):
1531
+ self.solve(KINETICS_ORDER[idx - 1])
1532
+ return
1533
+ if idx == len(KINETICS_ORDER) + 1:
1534
+ self.solve(random.choice(KINETICS_ORDER))
1535
+ return
1536
+ self.print_home()
1537
+
1538
+ # ---------------------------------------------------- electrochemistry --
1539
+ def cmd_electrochemistry(self):
1540
+ keys = [k for k, v in solver.FORMULA_LIBRARY.items() if v[1] == "Electrochemistry"]
1541
+ lines = [theme.purple("ELECTROCHEMISTRY", bold=True), ""]
1542
+ for i, key in enumerate(keys, 1):
1543
+ name, cat, formula_str, _ = solver.FORMULA_LIBRARY[key]
1544
+ lines.append(f"{theme.cyan(str(i)+'.')} {theme.text(name.ljust(28))} {theme.faint(mathify(formula_str))}")
1545
+ lines.append("")
1546
+ lines.append(theme.faint("Solved symbolically for any unknown, just like /solve."))
1547
+ print()
1548
+ print(theme.panel(lines, title="electrochemistry", color=theme.PURPLE, width=WIDTH))
1549
+ choice = input(theme.dim(" \u25b8 ")).strip()
1550
+ if choice.isdigit() and 1 <= int(choice) <= len(keys):
1551
+ self._solve_library(keys[int(choice) - 1])
1552
+ self.print_home()
1553
+
1554
+ # ----------------------------------------------------------- history --
1555
+ def cmd_history(self):
1556
+ print()
1557
+ if not self.history:
1558
+ print(theme.panel([theme.dim("No notebooks yet \u2014 solve a numerical to see it here.")],
1559
+ title="history", color=theme.PURPLE, width=WIDTH))
1560
+ print()
1561
+ return
1562
+ lines = [theme.purple("HISTORY", bold=True), ""]
1563
+ for i, nb in enumerate(self.history, 1):
1564
+ q = nb["question"]
1565
+ q = (q[:60] + "\u2026") if len(q) > 60 else q
1566
+ lines.append(f"{theme.cyan(str(i)+'.')} {theme.text(q)}")
1567
+ lines.append(" " + theme.faint(nb["topic"] + " \u00b7 " + nb["final_answer"]))
1568
+ lines.append("")
1569
+ lines.append(theme.faint("Type a number to reopen, or /back to return home."))
1570
+ print(theme.panel(lines, title="history", color=theme.PURPLE, width=WIDTH))
1571
+ choice = input(theme.dim(" \u25b8 ")).strip().lower()
1572
+ if choice.isdigit() and 1 <= int(choice) <= len(self.history):
1573
+ print()
1574
+ render_notebook(self.history[int(choice) - 1])
1575
+ input(theme.faint("\n Press Enter to continue\u2026"))
1576
+ self.print_home()
1577
+
1578
+ # ---------------------------------------------------------- settings --
1579
+ def cmd_permissions(self, arg=""):
1580
+ from . import permissions as perm
1581
+ print()
1582
+ raw = (arg or "").strip().lower()
1583
+ if raw:
1584
+ if raw not in perm.MODES:
1585
+ print(theme.red(f" Unknown mode '{raw}'. Try: {', '.join(perm.MODES)}"))
1586
+ self.print_home()
1587
+ return
1588
+ perm.manager.set_mode(raw)
1589
+ print(theme.green(f" Permission mode set to {perm.manager.mode_label()}"))
1590
+ if raw == "full":
1591
+ print(theme.orange(" \u26a0 Full Access lets the AI create, edit, rewrite, delete, "
1592
+ "execute, and install without asking. Use with care."))
1593
+ self.print_home()
1594
+ return
1595
+
1596
+ lines = [theme.purple("AI PERMISSIONS", bold=True), ""]
1597
+ for m in perm.MODES:
1598
+ marker = "\u25cf" if m == perm.manager.mode else "\u25cb"
1599
+ lines.append(f"{marker} {perm.MODE_LABELS[m]}" +
1600
+ (theme.faint(" (current)") if m == perm.manager.mode else ""))
1601
+ lines.append("")
1602
+ lines.append(theme.dim("Ask Every Time \u2014 every write/execute/shell action prompts, every time"))
1603
+ lines.append(theme.dim("Restricted \u2014 AI can read/analyze/suggest; changes need review"))
1604
+ lines.append(theme.dim("Full Access \u2014 AI acts without asking (visible warning shown)"))
1605
+ lines.append("")
1606
+ lines.append(theme.faint("Switch with: /permissions ask | restricted | full"))
1607
+ print(theme.panel(lines, title="permissions", color=theme.PURPLE, width=WIDTH))
1608
+ print()
1609
+
1610
+ # ==================================================== v0.7.7 ============
1611
+ # Autonomous Development Preview — package manager, pipeline,
1612
+ # orchestrator, and responsible device control.
1613
+ # ========================================================================
1614
+
1615
+ def _cli_perm_callback(self, key, action_label, path, reason):
1616
+ """Plain-CLI permission callback for pipeline/orchestrator runs
1617
+ (the agent tool loop and pipeline share this contract)."""
1618
+ from . import packages as pkgs
1619
+ print()
1620
+ if key == "install_packages":
1621
+ parts = action_label.replace("Install ", "", 1).strip()
1622
+ try:
1623
+ req, mgr = pkgs.resolve_request(parts or "package")
1624
+ except Exception:
1625
+ req, mgr = None, None
1626
+ if req and mgr:
1627
+ return self._cli_install_permission_prompt(req, pkgs.MANAGERS[mgr]["label"])
1628
+ lines = [theme.orange("PERMISSION REQUESTED", bold=True), "",
1629
+ f" Action: {theme.text(action_label)}",
1630
+ (f" Path: {path}" if path else ""),
1631
+ f" Reason: {reason or 'Requested by the AI.'}",
1632
+ "",
1633
+ theme.dim(" [Enter] Allow [a] Always Allow [n] Deny [x] Always Deny")]
1634
+ print(theme.panel(lines, title="permission", color=theme.ORANGE, width=WIDTH))
1635
+ choice = input(theme.dim(" \u25b8 ")).strip().lower()
1636
+ if choice in ("a", "always"):
1637
+ return "always_allow"
1638
+ if choice in ("x", "never"):
1639
+ return "always_deny"
1640
+ if choice in ("n", "no", "deny"):
1641
+ return "deny"
1642
+ return "allow"
1643
+
1644
+ def _ensure_build_mode(self):
1645
+ """v0.7.7 spec section 4: only Build Mode may install/compile/
1646
+ build. Auto-switches with a visible transition when the user
1647
+ asks from another mode (spec section 5). Returns True when we
1648
+ are (now) in Build mode."""
1649
+ from . import ai_modes
1650
+ current = ai_modes.current_mode()
1651
+ if current == "build":
1652
+ return True
1653
+ from . import mode_detection
1654
+ transition = mode_detection.transition_line(current, "build",
1655
+ "package / build responsibility")
1656
+ ai_modes.set_mode("build")
1657
+ if transition:
1658
+ print()
1659
+ print(theme.panel([theme.cyan(transition, bold=True)],
1660
+ title="mode transition", color=theme.CYAN,
1661
+ width=WIDTH))
1662
+ sound.play("notify")
1663
+ return True
1664
+
1665
+ def _cli_install_permission_prompt(self, req, manager_label):
1666
+ """Spec section 3's install dialog for the classic terminal:
1667
+ Allow Once / Always Allow / Deny / Always Deny / Cancel, with
1668
+ the Remember-choice persisted for the exact package+manager."""
1669
+ from . import packages as pkgs
1670
+ print()
1671
+ lines = [theme.orange("PACKAGE INSTALL \u2014 permission required", bold=True),
1672
+ "",
1673
+ f" Package: {theme.text(req.label(), bold=True)}",
1674
+ f" Manager: {manager_label}",
1675
+ f" System change: {theme.text('installs software on this machine', bold=True)}",
1676
+ "",
1677
+ theme.dim(" [1] Allow Once [2] Always Allow [3] Deny"),
1678
+ theme.dim(" [4] Always Deny [5] Cancel"),
1679
+ theme.faint(" Choices are remembered for this package+manager if you pick 2 or 4.")]
1680
+ print(theme.panel(lines, title="permission", color=theme.ORANGE, width=WIDTH))
1681
+ choice = input(theme.dim(" \u25b8 ")).strip().lower()
1682
+ if choice in ("1", "", "y", "yes", "allow"):
1683
+ return "allow_once"
1684
+ if choice in ("2", "a", "always"):
1685
+ pkgs.remember_decision(req.manager, req.name, "allow")
1686
+ return "always_allow"
1687
+ if choice in ("3", "n", "no", "deny", "d"):
1688
+ return "deny"
1689
+ if choice in ("4", "ad", "never"):
1690
+ pkgs.remember_decision(req.manager, req.name, "deny")
1691
+ return "always_deny"
1692
+ return "cancel"
1693
+
1694
+ def cmd_install(self, arg=""):
1695
+ """v0.7.7 Autonomous Package Manager — the full flow: parse,
1696
+ build-mode switch, research (for unknown packages), permission
1697
+ (with the temporary mode narrowing), install dashboard,
1698
+ auto-verification, summary."""
1699
+ from . import packages as pkgs
1700
+ from . import permissions as perm
1701
+ from . import package_research
1702
+ print()
1703
+ raw = (arg or "").strip()
1704
+ if not raw:
1705
+ raw = input(theme.dim(" What should I install? (e.g. requests, numpy, torch) \u25b8 ")).strip()
1706
+ if not raw:
1707
+ self.print_home()
1708
+ return
1709
+
1710
+ self._ensure_build_mode()
1711
+
1712
+ req, manager = pkgs.resolve_request(raw)
1713
+ if req is None:
1714
+ print(theme.orange(f" '{raw}' doesn't look like an install request."))
1715
+ print(theme.dim(" Try: /install requests /install numpy /install NodeJS"))
1716
+ self.print_home()
1717
+ return
1718
+
1719
+ if manager is None:
1720
+ # Spec section 2: unknown package -> research first.
1721
+ print(theme.cyan(" \U0001f50d Unknown package \u2014 researching it before installing..."))
1722
+ card = package_research.research_card(req)
1723
+ if card.get("info") or card.get("alternatives"):
1724
+ for line in package_research.render_card(req, card):
1725
+ print(line)
1726
+ if card.get("alternatives"):
1727
+ print(theme.faint(" Alternatives in the same family: "
1728
+ + ", ".join(a["name"] for a in card["alternatives"][:4])))
1729
+ print()
1730
+ print(theme.dim(" Recommendation: " + card.get("recommendation", "")))
1731
+ else:
1732
+ print(theme.orange(" Couldn't reach a registry or web search \u2014 installing "
1733
+ "on your judgment with pip (still approval-gated)."))
1734
+ manager = "pip"
1735
+ req.manager = manager
1736
+
1737
+ # Spec section 3: temporary permission narrowing.
1738
+ flow = perm.install_flow_state()
1739
+ try:
1740
+ if not flow["allow"]:
1741
+ print(theme.red(" \u2717 Installs are completely disabled in Restricted mode "
1742
+ "(v0.7.7 spec section 3)."))
1743
+ self.print_home()
1744
+ return
1745
+ remembered = pkgs.remembered_decision(manager, req.name)
1746
+ if remembered == "deny":
1747
+ print(theme.red(f" \u2717 {req.label()} was previously denied (remembered choice) "
1748
+ "\u2014 nothing installed. Use /packages forget to clear."))
1749
+ self.print_home()
1750
+ return
1751
+ decision = "allow_once"
1752
+ if remembered != "allow":
1753
+ if flow["prompt"]:
1754
+ if flow.get("restore_to"):
1755
+ print(theme.dim(" (permission mode temporarily narrowed to Ask Every Time "
1756
+ "for this install \u2014 restored afterwards)"))
1757
+ decision = self._cli_install_permission_prompt(req, pkgs.MANAGERS[manager]["label"])
1758
+ if decision in ("deny", "always_deny", "cancel"):
1759
+ perm.manager.decide(manager, decision, "Install " + req.label())
1760
+ verb = {"deny": "Denied", "always_deny": "Denied \u2014 remembered",
1761
+ "cancel": "Cancelled"}[decision]
1762
+ print(theme.orange(f" {verb} \u2014 nothing was installed."))
1763
+ self.print_home()
1764
+ return
1765
+ else:
1766
+ print(theme.dim(f" (approved before \u2014 remembered choice for {req.label()})"))
1767
+
1768
+ # Installation dashboard + auto verification.
1769
+ events = []
1770
+ result = pkgs.run_install(req, on_event=lambda ev: events.append(ev))
1771
+ print()
1772
+ for line in pkgs.dashboard_lines(events):
1773
+ print(line)
1774
+ print()
1775
+ print(theme.panel(pkgs.summarize(result, req).splitlines(),
1776
+ title="install result", color=theme.CYAN if result.get("ok") else theme.RED,
1777
+ width=WIDTH))
1778
+ pkgs.announce(result, req)
1779
+ if result.get("ok"):
1780
+ print()
1781
+ print(theme.green(" \U0001f9f9 Environment updated \u00b7 dependency conflicts "
1782
+ "checked \u00b7 verified."))
1783
+ finally:
1784
+ perm.restore_mode()
1785
+ print()
1786
+ if flow.get("restore_to"):
1787
+ print(theme.dim(f" Permission mode restored to {perm.MODE_LABELS[flow['restore_to']]}."))
1788
+ self.print_home()
1789
+
1790
+ def cmd_packages(self, arg=""):
1791
+ """v0.7.7 installation dashboard: supported managers, their
1792
+ availability, remembered package decisions, and the persistent
1793
+ install history. Subcommands:
1794
+ /packages forget <name> clears a remembered decision
1795
+ /packages history lists past installs (newest first)
1796
+ /packages history <query> searches history by package/manager
1797
+ /packages history remove <name> drops a history record
1798
+ /packages history clear empties the history"""
1799
+ from . import packages as pkgs
1800
+ raw = (arg or "").strip().lower()
1801
+
1802
+ if raw.startswith("history"):
1803
+ import datetime as _dt
1804
+ parts = raw.split()
1805
+ if len(parts) >= 3 and parts[1] == "remove":
1806
+ name = " ".join(parts[2:])
1807
+ removed = pkgs.remove_history(name)
1808
+ print(theme.green(f" Removed {removed} history record(s) for "
1809
+ f"'{name}' (installed package untouched)."))
1810
+ self.print_home()
1811
+ return
1812
+ if len(parts) >= 2 and parts[1] == "clear":
1813
+ pkgs.clear_history()
1814
+ print(theme.green(" Package history cleared."))
1815
+ self.print_home()
1816
+ return
1817
+ query = " ".join(parts[1:]) if len(parts) > 1 else ""
1818
+ records = pkgs.history_search(query)
1819
+ print()
1820
+ lines = [theme.purple("PACKAGE INSTALL HISTORY", bold=True), ""]
1821
+ if query:
1822
+ lines.append(theme.dim(f"matching '{query}':"))
1823
+ lines.append("")
1824
+ if not records:
1825
+ lines.append(theme.faint(" Nothing installed yet \u2014 ask CAT to "
1826
+ "'install <package>' and it lands here."))
1827
+ else:
1828
+ for r in records:
1829
+ status = theme.green("\u2713") if r.get("ok") else theme.red("\u2717")
1830
+ when = _dt.datetime.fromtimestamp(r.get("installed_at", 0)) \
1831
+ .strftime("%Y-%m-%d %H:%M")
1832
+ version = r.get("version") or "latest"
1833
+ extra = ""
1834
+ if r.get("updated_at") and r.get("updated_at") != r.get("installed_at"):
1835
+ extra = theme.faint(" \u00b7 updated")
1836
+ lines.append(f" {status} {theme.cyan(r.get('name', '?'))} "
1837
+ f"{theme.faint(version)} {theme.dim(r.get('manager', '') + ' \u00b7 ' + when + ' \u00b7 ' + str(r.get('env', '')))}{extra}")
1838
+ if not r.get("ok"):
1839
+ lines.append(theme.faint(f" failed \u2014 exit {r.get('exit_code', '?')}"))
1840
+ lines.append("")
1841
+ lines.append(theme.faint("Search: /packages history <query> \u00b7 Remove: "
1842
+ "/packages history remove <name> \u00b7 Clear: "
1843
+ "/packages history clear"))
1844
+ print(theme.panel(lines, title="package history", color=theme.PURPLE,
1845
+ width=WIDTH))
1846
+ self.print_home()
1847
+ return
1848
+
1849
+ if raw.startswith("forget"):
1850
+ parts = raw.split()
1851
+ if len(parts) >= 2:
1852
+ name = parts[1]
1853
+ cleared = [k for k in pkgs.list_remembered() if k[1].lower() == name.lower()]
1854
+ for manager, n, _d in cleared:
1855
+ pkgs.forget_decision(manager, n)
1856
+ print(theme.green(f" Cleared {len(cleared)} remembered decision(s) for '{name}'."))
1857
+ self.print_home()
1858
+ return
1859
+ print()
1860
+ lines = [theme.purple("AUTONOMOUS PACKAGE MANAGER", bold=True), "",
1861
+ theme.dim("Managers (supported: pip pipx uv conda npm pnpm yarn bun cargo go "
1862
+ "composer vcpkg chocolatey winget apt dnf brew):"), ""]
1863
+ for key, spec in pkgs.MANAGERS.items():
1864
+ avail = "available" if pkgs.manager_available(key) else "not found"
1865
+ mark = "\u25cf" if avail == "available" else "\u25cb"
1866
+ lines.append(f" {mark} {theme.cyan(key.ljust(12))} {theme.faint(spec['label'])} "
1867
+ f"{theme.green(avail) if avail == 'available' else theme.faint(avail)}")
1868
+ remembered = pkgs.list_remembered()
1869
+ if remembered:
1870
+ lines.append("")
1871
+ lines.append(theme.dim("Remembered install decisions (forget with /packages forget <pkg>):"))
1872
+ for manager, name, decision in remembered:
1873
+ mark = theme.green("allow") if decision == "allow" else theme.red("deny")
1874
+ lines.append(f" {theme.faint(manager + ' / ' + name + ' \u2014 ')}{mark}")
1875
+ lines.append("")
1876
+ lines.append(theme.faint("Ask CAT 'install <package>' from any mode \u2014 installs are Build-mode work."))
1877
+ print(theme.panel(lines, title="packages", color=theme.PURPLE, width=WIDTH))
1878
+ print()
1879
+ self.print_home()
1880
+
1881
+ def cmd_pipeline(self, arg=""):
1882
+ """v0.7.7 AI Execution Pipeline: Planner -> Research -> Build ->
1883
+ Testing -> Verification -> Documentation, fully visible."""
1884
+ from . import pipeline
1885
+ raw = (arg or "").strip()
1886
+ if not raw:
1887
+ print()
1888
+ print(theme.panel([theme.purple("AI EXECUTION PIPELINE", bold=True), "",
1889
+ theme.dim("Runs the full autonomous workflow for one request:"), "",
1890
+ theme.dim("Planner \u2192 Research \u2192 Implementation \u2192 Testing \u2192 "
1891
+ "Verification \u2192 Documentation \u2192 Completed"),
1892
+ "",
1893
+ theme.faint("Usage: /pipeline <your request>"),
1894
+ theme.faint('e.g. /pipeline build me a molecule viewer script')],
1895
+ title="pipeline", color=theme.PURPLE, width=WIDTH))
1896
+ print()
1897
+ self.print_home()
1898
+ return
1899
+
1900
+ print()
1901
+ print(theme.panel(pipeline.render_task_graph("planner"),
1902
+ title="task graph", color=theme.CYAN, width=WIDTH))
1903
+ print()
1904
+
1905
+ def _note(text):
1906
+ print(theme.cyan(" \u25b8 ") + text)
1907
+
1908
+ def _message(text):
1909
+ print()
1910
+ print(text)
1911
+
1912
+ def _todo(todo):
1913
+ status_mark = {"pending": theme.faint("\u25cb"),
1914
+ "running": theme.cyan("\u25b8"),
1915
+ "researching": theme.purple("\U0001f50d"),
1916
+ "coding": theme.orange("\U0001f528"),
1917
+ "testing": theme.cyan("\u2699"),
1918
+ "completed": theme.green("\u2713"),
1919
+ "skipped": theme.faint("\u2013"),
1920
+ "failed": theme.red("\u2717")}.get(todo["status"], "\u2022")
1921
+ print(f" {status_mark} {todo['text']}")
1922
+
1923
+ def _stage(stage):
1924
+ from . import pipeline as _p
1925
+ print()
1926
+ for key, label, current in _p.task_graph(stage):
1927
+ if current:
1928
+ print(theme.cyan(f" \u25b8 {label} {theme.orange('\u25cf CURRENT', bold=True)}"))
1929
+ else:
1930
+ print(theme.faint(f" {label}"))
1931
+ print()
1932
+
1933
+ run = pipeline.PipelineRun(raw, mode="agent",
1934
+ on_note=_note, on_message=_message,
1935
+ on_todo=_todo, on_stage=_stage,
1936
+ permission_callback=self._cli_perm_callback)
1937
+ run.run()
1938
+ print()
1939
+ if run.final_text:
1940
+ fallback_cli.render_ai(run.final_text, tag="CAT Pipeline",
1941
+ model_label="planner\u00b7research\u00b7build\u00b7verify",
1942
+ duration=run.duration, term_width=WIDTH)
1943
+ print()
1944
+ self.print_home()
1945
+
1946
+ def cmd_orchestrate(self, arg=""):
1947
+ """v0.7.7 Agent Orchestrator: the Coordinator monitors a roster
1948
+ of specialist agents working one request."""
1949
+ from . import orchestrator
1950
+ raw = (arg or "").strip()
1951
+ if not raw:
1952
+ print()
1953
+ print(theme.panel([theme.purple("AGENT ORCHESTRATOR", bold=True), "",
1954
+ theme.dim("A Coordinator assigns specialist agents and monitors them:"), "",
1955
+ theme.dim("Planner \u00b7 Research \u00b7 Coding \u00b7 Reviewer \u00b7 "
1956
+ "Testing \u00b7 Security \u00b7 Documentation \u00b7 Performance \u00b7 UI \u00b7 Architecture"),
1957
+ "",
1958
+ theme.faint("Usage: /orchestrate <your request>")],
1959
+ title="orchestrator", color=theme.PURPLE, width=WIDTH))
1960
+ print()
1961
+ self.print_home()
1962
+ return
1963
+
1964
+ def _agent(key, label, status, summary=""):
1965
+ if status == "start":
1966
+ print(theme.purple(f" \u2699 {label} started"))
1967
+ else:
1968
+ extra = f" \u2014 {summary}" if summary else ""
1969
+ print(theme.green(f" \u2713 {label} done{extra}"))
1970
+
1971
+ print()
1972
+ final_text, _steps, _meta, _agents = orchestrator.run_orchestrated(
1973
+ raw, mode="agent", on_agent=_agent,
1974
+ permission_callback=self._cli_perm_callback)
1975
+ print()
1976
+ fallback_cli.render_ai(final_text, tag="CAT Orchestrator",
1977
+ model_label="coordinator\u00b7team",
1978
+ duration=0.0, term_width=WIDTH)
1979
+ print()
1980
+ self.print_home()
1981
+
1982
+ def cmd_devices(self, arg=""):
1983
+ """v0.7.7 Responsible Device Control: provider roster and the
1984
+ activity log. Every device action requires explicit approval
1985
+ and is logged here."""
1986
+ from . import device_control as dc
1987
+ from . import permissions as perm
1988
+ raw = (arg or "").strip()
1989
+ if raw:
1990
+ # /devices <provider> <action> <target> — a quick approval-
1991
+ # gated way to drive an available provider.
1992
+ parts = raw.split(None, 2)
1993
+ provider_key = parts[0].lower()
1994
+ action = parts[1] if len(parts) > 1 else "run"
1995
+ target = parts[2] if len(parts) > 2 else ""
1996
+ resolved = dc.plan_action(provider_key, action, {"target": target})
1997
+ if not resolved["ok"]:
1998
+ print(theme.orange(f" \u2717 {resolved['reason']}"))
1999
+ self.print_home()
2000
+ return
2001
+ plan = resolved["plan"]
2002
+ print()
2003
+ print(theme.panel([theme.orange("DEVICE ACTION \u2014 approval required", bold=True), "",
2004
+ f" Provider: {provider_key}",
2005
+ f" Action: {action}",
2006
+ f" Target: {plan.get('target', '') or target}",
2007
+ f" Effect: {plan.get('effect', '')}",
2008
+ "",
2009
+ theme.dim(" [Enter] Allow [n] Deny")],
2010
+ title="device control", color=theme.ORANGE, width=WIDTH))
2011
+ choice = input(theme.dim(" \u25b8 ")).strip().lower()
2012
+ if choice == "n":
2013
+ dc.log_action(provider_key, action, target, "denied")
2014
+ print(theme.orange(" Denied \u2014 nothing ran."))
2015
+ self.print_home()
2016
+ return
2017
+ result = dc.execute_approved(provider_key, plan, {"target": target})
2018
+ if result.get("ok"):
2019
+ print(theme.green(f" \u2713 Approved and executed \u2014 "
2020
+ + str(result.get("note") or result.get("exit_code") or "done")))
2021
+ else:
2022
+ print(theme.orange(" \u2717 " + str(result.get("note", "execution failed"))))
2023
+ self.print_home()
2024
+ return
2025
+
2026
+ lines = [theme.purple("RESPONSIBLE DEVICE CONTROL", bold=True), "",
2027
+ theme.dim("Every action affecting a real device requires explicit approval"),
2028
+ theme.dim("and is recorded in the activity log below. Framework is extensible:"),
2029
+ theme.dim("register_provider() in calc_terminal/device_control.py."), ""]
2030
+ for p in dc.available_providers():
2031
+ mark = "\u25cf" if p["available"] else "\u25cb"
2032
+ lines.append(f" {mark} {theme.cyan(p['key'].ljust(11))} {theme.text(p['label'])} "
2033
+ + (theme.green("available") if p["available"] else theme.faint("extension point")))
2034
+ lines.append(theme.faint(" " + p["description"]))
2035
+ log = dc.activity(limit=15)
2036
+ if log:
2037
+ lines.append("")
2038
+ lines.append(theme.dim("Activity log (newest first):"))
2039
+ for entry in log:
2040
+ mark = theme.green("\u2713") if entry["decision"] == "approved" else theme.red("\u2717")
2041
+ lines.append(f" {mark} {theme.faint(entry['provider'])} / {entry['action']} "
2042
+ f"{entry['target'] or ''} \u2014 {entry['decision']}")
2043
+ else:
2044
+ lines.append("")
2045
+ lines.append(theme.faint("Activity log is empty \u2014 no device actions yet this session."))
2046
+ lines.append("")
2047
+ lines.append(theme.faint("Try: /devices terminal run 'git status' \u00b7 "
2048
+ "permission key: device_control"))
2049
+ print(theme.panel(lines, title="devices", color=theme.PURPLE, width=WIDTH))
2050
+ print()
2051
+ self.print_home()
2052
+
2053
+ def cmd_browser(self, arg=""):
2054
+ """CAT Browser — full terminal web browser (Fomoji-gated).
2055
+
2056
+ Usage:
2057
+ /browser open CAT Browser at home
2058
+ /browser https://example.com open that URL directly
2059
+ /browse <same> alias
2060
+ /browser search cats on mars -> DuckDuckGo search
2061
+ The browser gate re-checks Fomoji before any network fetch so the
2062
+ session cannot be bypassed by collaring this command directly.
2063
+ """
2064
+ # Quick gating note before the blocking browser loop takes over.
2065
+ try:
2066
+ from .fomoji_auth import status as _fa_status
2067
+ from .fomoji_auth import get_identity as _fa_ident
2068
+ from .fomoji_auth import is_skip_enabled as _fa_skip
2069
+ if not _fa_skip() and _fa_status() != "connected":
2070
+ st = _fa_status()
2071
+ print()
2072
+ print(theme.red(" CAT Browser is locked — Fomoji authentication required.", bold=True))
2073
+ print(theme.dim(f" Status: {st} · run `cat --auth login` or use /auth inside CAT"))
2074
+ print()
2075
+ return
2076
+ ident = _fa_ident()
2077
+ if ident:
2078
+ print(theme.dim(f" Browsing as {ident.get('name')} ({ident.get('fomojiId')}) — type `help` inside the browser.\n"))
2079
+ except Exception:
2080
+ pass
2081
+
2082
+ # This method is only used when CCTApp is NOT running (fallback REPL).
2083
+ # In graphical mode, /browser is handled as a native UI command that
2084
+ # pushes BrowserScreen — so this code is the fallback-CLI path only.
2085
+ # We keep it but make it launch the graphical browser when Textual is
2086
+ # available; otherwise show a message (CLI “Links (6)” presentation
2087
+ # is removed from normal flow — see browser_shell.py).
2088
+ arg = (arg or "").strip()
2089
+ start_url = None
2090
+ if arg:
2091
+ low = arg.lower()
2092
+ if low.startswith("search ") or low.startswith("s "):
2093
+ q = arg.split(None, 1)[1].strip() if " " in arg else ""
2094
+ if q:
2095
+ from .cat_browser import _search_url
2096
+ start_url = _search_url(q)
2097
+ else:
2098
+ start_url = None
2099
+ elif low.startswith("open "):
2100
+ start_url = arg.split(None, 1)[1].strip().strip('"').strip("'")
2101
+ else:
2102
+ from .cat_browser import _is_search_query, _search_url
2103
+ if " " in arg and _is_search_query(arg):
2104
+ start_url = _search_url(arg)
2105
+ else:
2106
+ start_url = arg
2107
+
2108
+ # In fallback CLI with no Textual, we cannot show the graphical browser.
2109
+ # Show a clear message instead of dumping the old CLI numbered-links UI.
2110
+ try:
2111
+ from .ui.browser_shell import TEXTUAL_AVAILABLE as _HAS_UI
2112
+ except Exception:
2113
+ _HAS_UI = False
2114
+ if not _HAS_UI:
2115
+ print()
2116
+ print(theme.panel([
2117
+ theme.text("CAT Browser — graphical mode required", bold=True),
2118
+ theme.dim("The normal browser is graphical (tab bar + address bar + viewport)."),
2119
+ theme.dim("Textual is not installed in this environment, so the"),
2120
+ theme.dim("text-based Links/numbered navigation is no longer shown in normal use."),
2121
+ theme.dim("Install the UI and use the graphical browser:"),
2122
+ theme.cyan(" pip install textual[syntax]"),
2123
+ theme.cyan(" cat browse https://example.com"),
2124
+ theme.faint("Developer debug CLI is available via `cat --browser --text` (hidden)."),
2125
+ ], title="browser", color=theme.CYAN, width=76))
2126
+ print()
2127
+ self.print_home()
2128
+ return
2129
+
2130
+ # Prefer Host embedded browser (correct architecture) — NOT text, NOT external
2131
+ try:
2132
+ from .host.launcher import can_launch_host, launch_cat_host, get_host_status
2133
+ if can_launch_host():
2134
+ launch_cat_host(start_browser_url=start_url or "about:home", start_mode="browser", block=True)
2135
+ self.print_home()
2136
+ return
2137
+ else:
2138
+ st = get_host_status()
2139
+ print()
2140
+ print(theme.panel([
2141
+ theme.text("CAT Browser — embedded engine required", bold=True),
2142
+ theme.dim(f"Reason: {st['reason']}"),
2143
+ theme.dim("Browser INSIDE CAT requires: pip install PySide6"),
2144
+ theme.dim("QWebEngineView child renders Fomoji inside CAT Host window"),
2145
+ theme.cyan(" cat --host-browser http://localhost:3000/connector.html"),
2146
+ ], title="browser", color=theme.CYAN, width=76))
2147
+ print()
2148
+ self.print_home()
2149
+ return
2150
+ except Exception as e:
2151
+ print(theme.red(f"\n Browser error: {type(e).__name__}: {e}\n"))
2152
+ finally:
2153
+ self.print_home()
2154
+
2155
+
2156
+ def cmd_settings(self):
2157
+ while True:
2158
+ cfg = cctconfig.get_config()
2159
+ lines = [theme.purple("SETTINGS", bold=True), ""]
2160
+ lines.append(f"{theme.cyan('1.')} Auto-save history .......... "
2161
+ + (theme.green("[ ENABLED ]") if self.autosave else theme.orange("[ DISABLED ]")))
2162
+ lines.append(f"{theme.cyan('2.')} Clear history ............... {theme.faint('[ READY ]')}")
2163
+ lines.append(f"{theme.cyan('3.')} Decimal precision ........... {theme.faint(str(self.precision) + ' decimals')}")
2164
+ lines.append(f"{theme.cyan('4.')} Theme ........................ "
2165
+ + theme.faint(theme.theme_label(theme.get_theme())))
2166
+ lines.append(f"{theme.cyan('5.')} Sound effects ................ "
2167
+ + (theme.green("[ ON ]") if self.sound_enabled else theme.orange("[ OFF ]")))
2168
+ lines.append(f"{theme.cyan('6.')} Animated responses ........... "
2169
+ + (theme.green("[ ON ]") if engine.ANIMATE else theme.orange("[ OFF ]")))
2170
+ lines.append(f"{theme.cyan('7.')} Animation speed .............. "
2171
+ + theme.faint(f"{cfg.animation_speed:.1f}x (config: ~/.cct_config.json)"))
2172
+ lines.append(f"{theme.cyan('8.')} Workspace directory .......... "
2173
+ + theme.faint(cfg.workspace_directory))
2174
+ lines.append("")
2175
+ lines.append(theme.faint("Type a number to toggle, or /back to return home."))
2176
+ print()
2177
+ print(theme.panel(lines, title="settings", color=theme.PURPLE, width=WIDTH))
2178
+ choice = input(theme.dim(" \u25b8 ")).strip().lower()
2179
+ if choice == "1":
2180
+ self.autosave = not self.autosave
2181
+ elif choice == "2":
2182
+ self.history = []
2183
+ print(theme.green(" History cleared."))
2184
+ elif choice == "4":
2185
+ self.cmd_theme()
2186
+ cfg.default_theme = theme.get_theme()
2187
+ cctconfig.save_config(cfg)
2188
+ elif choice == "5":
2189
+ self.sound_enabled = not self.sound_enabled
2190
+ sound.set_enabled(self.sound_enabled)
2191
+ sound.play("notify")
2192
+ elif choice == "6":
2193
+ engine.set_animation(not engine.ANIMATE)
2194
+ elif choice == "7":
2195
+ steps = [0.5, 1.0, 1.5, 2.0]
2196
+ cur = min(steps, key=lambda s: abs(s - cfg.animation_speed))
2197
+ nxt = steps[(steps.index(cur) + 1) % len(steps)]
2198
+ cfg.animation_speed = nxt
2199
+ cctconfig.save_config(cfg)
2200
+ print(theme.green(f" Animation speed set to {nxt:.1f}x."))
2201
+ elif choice == "8":
2202
+ raw = input(theme.dim(" new workspace directory \u25b8 ")).strip()
2203
+ if raw:
2204
+ cfg.workspace_directory = os.path.expanduser(raw)
2205
+ cctconfig.save_config(cfg)
2206
+ print(theme.green(f" Workspace directory set to {cfg.workspace_directory}"))
2207
+ elif choice in ("/back", "back", "/exit", ""):
2208
+ break
2209
+ else:
2210
+ continue
2211
+ self.print_home()
2212
+
2213
+ # -------------------------------------------------------------- about --
2214
+ def cmd_about(self):
2215
+ config = aicore.load_config()
2216
+ cfg = cctconfig.get_config()
2217
+ ai_line = (theme.green(f"Configured \u2014 {config['provider']} / {config['model']}")
2218
+ if config.get("provider") else theme.faint("Not configured \u2014 run /agent or /ai"))
2219
+ theme_line = theme.faint(theme.theme_label(theme.get_theme()))
2220
+ lines = [
2221
+ theme.purple(identity.APP_NAME.upper(), bold=True),
2222
+ theme.dim(f"{identity.SHORT_NAME} v{VERSION} \u00b7 Notebook Mode"),
2223
+ "",
2224
+ theme.text("A dedicated AI coding & scientific workspace styled like a modern dev terminal."),
2225
+ "",
2226
+ theme.cyan("Calculation engine", bold=True),
2227
+ theme.dim("Local numerical engine \u00b7 double-precision floating point \u00b7 sympy solver"),
2228
+ "",
2229
+ theme.cyan("Covers", bold=True),
2230
+ theme.dim("Mole Concept \u00b7 Chemical Kinetics \u00b7 Electrochemistry \u00b7 Gas Laws \u00b7"),
2231
+ theme.dim("Thermodynamics \u00b7 Equilibrium/Acid-Base \u00b7 Atomic & Quantum Structure"),
2232
+ "",
2233
+ theme.cyan("Agentic AI", bold=True),
2234
+ theme.dim("Status: ") + ai_line,
2235
+ "",
2236
+ theme.cyan("Current configuration", bold=True),
2237
+ theme.dim("Theme: ") + theme_line,
2238
+ theme.dim("Workspace: ") + theme.faint(cfg.workspace_directory),
2239
+ ]
2240
+ print()
2241
+ print(theme.panel(lines, title="about", color=theme.PURPLE, width=WIDTH))
2242
+ print()
2243
+
2244
+ # ------------------------------------------------ v0.7.9 commands --
2245
+ def cmd_stats(self):
2246
+ """Real aggregated counts only — no PII by design (spec #6)."""
2247
+ from . import auth as _auth
2248
+ from . import safety_audit as _audit
2249
+ s = _auth.stats()
2250
+ retained = len(_audit.list_events(limit=_audit.MAX_EVENTS))
2251
+ lines = [
2252
+ theme.cyan("CAT Network", bold=True),
2253
+ "",
2254
+ theme.text(f" Accounts : {s['accounts']:,}"),
2255
+ theme.text(f" Active sessions : {s['active_sessions']:,}"),
2256
+ theme.faint(f" Safety events retained on this device: {retained:,}"),
2257
+ "",
2258
+ theme.faint("Counts come from this machine's real account store."),
2259
+ theme.faint("No usernames/emails/private activity are exposed —"),
2260
+ theme.faint("aggregated numbers only, by design."),
2261
+ ]
2262
+ print()
2263
+ print(theme.panel(lines, title="stats", color=theme.CYAN, width=WIDTH))
2264
+ print()
2265
+
2266
+ def cmd_report(self, arg=""):
2267
+ """Own-account safety report: text view or PDF/txt export."""
2268
+ from . import safety_audit as _audit
2269
+ arg = (arg or "").strip()
2270
+ if arg.lower().startswith(("export", "pdf")):
2271
+ parts = arg.split(None, 1)
2272
+ out = parts[1].strip() if len(parts) > 1 else os.path.join(
2273
+ cctconfig.get_config().workspace_directory,
2274
+ time.strftime("cat_safety_report_%Y%m%d_%H%M%S.pdf"))
2275
+ path, fmt = _audit.export_pdf(out)
2276
+ if fmt == "pdf":
2277
+ print(theme.green(f"\n \u2713 PDF report written to {path}\n"))
2278
+ else:
2279
+ print(theme.yellow(
2280
+ f"\n \u26a0 reportlab isn't installed — wrote a UTF-8 text "
2281
+ f"report instead:\n {path}\n"
2282
+ f" (pip install reportlab for real PDF export)\n"))
2283
+ return
2284
+ print()
2285
+ print(_audit.render_report())
2286
+ print()
2287
+
2288
+ def cmd_task(self, arg=""):
2289
+ """Large-task execution with live progress and real states."""
2290
+ arg = (arg or "").strip()
2291
+ if not arg:
2292
+ print(theme.dim("\n Usage: /task <describe what CAT should do>\n"))
2293
+ return
2294
+ cfg = aicore.load_config()
2295
+ if not cfg.get("provider"):
2296
+ print(theme.red("\n No AI provider configured — run /model first.\n"))
2297
+ return
2298
+ from . import orchestration as orch_mod
2299
+ client = orch_mod.TaskOrchestrator()
2300
+ print()
2301
+ print(theme.cyan(
2302
+ f"\u25b8 Orchestrating task with a dynamic agent team "
2303
+ f"(max {client.max_agents})\u2026", bold=True))
2304
+
2305
+ def _activity(text):
2306
+ print(theme.dim(f" {text}"))
2307
+
2308
+ result = client.run(arg, on_activity=_activity)
2309
+ print()
2310
+ print(theme.cyan("TASK PROGRESS", bold=True))
2311
+ for slot in result.agents:
2312
+ mark = {"DONE": theme.green("\u2713"),
2313
+ "RUNNING": theme.orange("\u2192"),
2314
+ "FAILED": theme.red("\u2717"),
2315
+ "TIMEOUT": theme.red("\u23f1"),
2316
+ "CANCELLED": theme.yellow("\u229d"),
2317
+ "SKIPPED": theme.yellow("\u229d"),
2318
+ "WAITING": theme.faint("\u25cb")}.get(slot.state, "?")
2319
+ suffix = ""
2320
+ if slot.state in ("FAILED", "TIMEOUT") and slot.detail:
2321
+ suffix = theme.faint(f" ({slot.detail[:60]})")
2322
+ print(f" [{mark}] {slot.role:<11} {slot.summary}{suffix}")
2323
+ if result.changed_files:
2324
+ print()
2325
+ print(theme.cyan("FILE CHANGES (filesystem-verified)", bold=True))
2326
+ for p in result.changed_files[:20]:
2327
+ print(f" ~ {p}")
2328
+ print()
2329
+ if result.cancelled:
2330
+ print(theme.yellow(" Cancelled before completion."))
2331
+ elif result.error:
2332
+ print(theme.red(f" Finished with errors: {result.error}"))
2333
+ else:
2334
+ counts = result.progress_counts()
2335
+ print(theme.green(
2336
+ f"\u2713 Task complete \u2014 {counts.get('DONE', 0)} agent(s) done, "
2337
+ f"{counts.get('FAILED', 0) + counts.get('TIMEOUT', 0)} failed."))
2338
+ print()
2339
+
2340
+ def cmd_auth(self, arg=""):
2341
+ """Account sign up / sign in / sign out / reset / delete."""
2342
+ import getpass
2343
+ from . import auth as _auth
2344
+ parts = (arg or "").strip().split(None, 1)
2345
+ sub = parts[0].lower() if parts else ""
2346
+ try:
2347
+ if sub == "signup":
2348
+ username = input(theme.dim(" Username \u25b8 ")).strip()
2349
+ email = input(theme.dim(" Email (optional) \u25b8 ")).strip()
2350
+ pw = getpass.getpass(theme.dim(" Password \u25b8 "))
2351
+ confirm = getpass.getpass(theme.dim(" Confirm password \u25b8 "))
2352
+ if pw != confirm:
2353
+ print(theme.red("\n Passwords don't match.\n"))
2354
+ return
2355
+ info = _auth.sign_up(username, pw, email)
2356
+ print(theme.green("\n \u2713 Account created."))
2357
+ print(theme.text(" Recovery key (shown ONCE — save it now): ")
2358
+ + theme.bold(info["recovery_key"]))
2359
+ print(theme.faint(" It's the only offline way to reset your password.\n"))
2360
+ elif sub in ("signin", "login"):
2361
+ username = input(theme.dim(" Username \u25b8 ")).strip()
2362
+ pw = getpass.getpass(theme.dim(" Password \u25b8 "))
2363
+ token = _auth.sign_in(username, pw)
2364
+ self._auth_token = token
2365
+ acct = _auth.current_account(token)
2366
+ print(theme.green(
2367
+ f"\n \u2713 Signed in as {acct['username']} "
2368
+ f"(session valid for 7 days).\n"))
2369
+ elif sub in ("signout", "logout"):
2370
+ token = getattr(self, "_auth_token", None)
2371
+ if token and _auth.sign_out(token):
2372
+ self._auth_token = None
2373
+ print(theme.green("\n \u2713 Signed out.\n"))
2374
+ else:
2375
+ print(theme.yellow("\n No active session here.\n"))
2376
+ elif sub == "reset":
2377
+ username = input(theme.dim(" Username \u25b8 ")).strip()
2378
+ key = getpass.getpass(theme.dim(" Recovery key \u25b8 "))
2379
+ reset_token = _auth.request_reset(username, key)
2380
+ new = getpass.getpass(theme.dim(" New password \u25b8 "))
2381
+ confirm = getpass.getpass(theme.dim(" Confirm password \u25b8 "))
2382
+ if new != confirm:
2383
+ print(theme.red("\n Passwords don't match.\n"))
2384
+ return
2385
+ _auth.complete_reset(reset_token, new)
2386
+ print(theme.green(
2387
+ "\n \u2713 Password reset — all previous sessions were signed out.\n"))
2388
+ elif sub == "delete":
2389
+ token = getattr(self, "_auth_token", None)
2390
+ acct = _auth.current_account(token) if token else None
2391
+ if not acct:
2392
+ print(theme.red("\n Sign in first (/auth signin).\n"))
2393
+ return
2394
+ print(theme.yellow(
2395
+ f"\n This permanently deletes '{acct['username']}', "
2396
+ "its sessions and its safety events."))
2397
+ if input(theme.dim(" Type DELETE to confirm \u25b8 ")).strip() != "DELETE":
2398
+ print(theme.dim("\n Cancelled — nothing was deleted.\n"))
2399
+ return
2400
+ pw = getpass.getpass(theme.dim(" Confirm your password \u25b8 "))
2401
+ removed = _auth.delete_account(token, pw)
2402
+ self._auth_token = None
2403
+ print(theme.green(
2404
+ f"\n \u2713 Account deleted ({removed} safety event(s) removed).\n"))
2405
+ else:
2406
+ print(theme.dim(
2407
+ "\n Usage: /auth <signup|signin|signout|reset|delete>\n"
2408
+ " Passwords are stored as salted PBKDF2 hashes — never plaintext.\n"
2409
+ " Optional: set require_auth_for_ai=true in ~/.cct_config.json\n"
2410
+ " to make the AI answer only after /auth signin.\n"))
2411
+ except Exception as e:
2412
+ msg = getattr(e, "message", str(e))
2413
+ print(theme.red(f"\n \u2717 {msg}\n"))
2414
+
2415
+ def cmd_vscode(self, arg=""):
2416
+ """VS Code integration: status / open workspace|file|diff + CAT Vision commands."""
2417
+ from . import vscode_integration as vsc
2418
+ parts = (arg or "").strip().split(None, 1)
2419
+ sub = parts[0].lower() if parts else ""
2420
+ rest = parts[1].strip() if len(parts) > 1 else ""
2421
+ # Vision subcommands (spec 28)
2422
+ if sub in ("vision", "cat-vision", "cat:vision"):
2423
+ vsub = rest.split(None, 1)[0].lower() if rest else "open"
2424
+ vrest = rest.split(None, 1)[1] if " " in rest else ""
2425
+ vision_cmds = {
2426
+ "": "CAT: Open CAT Vision",
2427
+ "open": "CAT: Open CAT Vision",
2428
+ "start": "CAT: Start Vision Session",
2429
+ "stop": "CAT: Stop Vision Session",
2430
+ "analyze": "CAT: Analyze Current Screen",
2431
+ "selection": "CAT: Analyze Selected Region",
2432
+ "screenshot": "CAT: Send Screenshot to CAT",
2433
+ }
2434
+ if vsub in ("start", "stop", "analyze", "selection", "screenshot", "open", ""):
2435
+ print()
2436
+ print(theme.panel([
2437
+ theme.text(vision_cmds.get(vsub, "CAT Vision"), bold=True),
2438
+ theme.dim("Available in the CAT VS Code panel and via Command Palette (Ctrl+Shift+P)."),
2439
+ theme.dim("Active file + selected code + workspace become the Vision AI context."),
2440
+ ], title="vscode vision", color=theme.CYAN, width=WIDTH))
2441
+ print()
2442
+ return
2443
+ print(theme.yellow(f"\n Unknown /vscode vision subcommand '{vsub}'.\n"))
2444
+ return
2445
+ if sub in ("", "status"):
2446
+ st = vsc.status()
2447
+ state = theme.green("available") if st["available"] \
2448
+ else theme.red("not found")
2449
+ print(theme.dim(
2450
+ f"\n VS Code CLI: {state}"
2451
+ + (theme.faint(f" ({st['cli_path']})") if st["cli_path"] else "")))
2452
+ print(theme.faint(
2453
+ " Usage: /vscode open [path] · /vscode file <path> "
2454
+ "[line] · /vscode diff <a> <b>"))
2455
+ print(theme.faint(
2456
+ " /vscode vision [open|start|stop|analyze|selection|screenshot]\n"))
2457
+ return
2458
+ if sub == "open":
2459
+ ok, msg = (vsc.open_workspace(rest) if rest
2460
+ else vsc.open_workspace_from_current())
2461
+ elif sub == "file":
2462
+ bits = rest.split()
2463
+ line = bits[1] if len(bits) > 1 and bits[1].isdigit() else None
2464
+ ok, msg = vsc.open_file(bits[0] if bits else "", line)
2465
+ elif sub == "diff":
2466
+ bits = rest.split()
2467
+ ok, msg = (vsc.open_diff(bits[0], bits[1])
2468
+ if len(bits) >= 2
2469
+ else (False, "Usage: /vscode diff <fileA> <fileB>"))
2470
+ else:
2471
+ ok, msg = False, f"Unknown /vscode subcommand '{sub}'."
2472
+ print(theme.green(f"\n \u2713 {msg}\n") if ok
2473
+ else theme.yellow(f"\n \u26a0 {msg}\n"))
2474
+
2475
+ def cmd_colab(self, arg=""):
2476
+ """Notebook (.ipynb) tools: create / add / exec / export /
2477
+ upload-help — local-first, never touches Google credentials."""
2478
+ from . import colab_integration as col
2479
+ parts = (arg or "").strip().split(None, 2)
2480
+ sub = parts[0].lower() if parts else ""
2481
+ rest = parts[1].strip() if len(parts) > 1 else ""
2482
+ try:
2483
+ if sub in ("", "status"):
2484
+ st = col.status()
2485
+ backend = st["execution_backend"] or theme.faint("none installed")
2486
+ print(theme.cyan("\n Notebook / Colab support", bold=True))
2487
+ print(theme.dim(
2488
+ f" Local .ipynb tools : ready\n"
2489
+ f" Local execution : {backend}\n"
2490
+ f" Google permissions : none requested (by design)\n"
2491
+ f" Google passwords : never accepted\n"))
2492
+ print(theme.faint(
2493
+ " Usage:\n"
2494
+ " /colab new <title> [path.ipynb]\n"
2495
+ " /colab exec <notebook.ipynb>\n"
2496
+ " /colab script <notebook.ipynb> [out.py]\n"
2497
+ " /colab upload-help <notebook.ipynb>\n"))
2498
+ return
2499
+ if sub == "new":
2500
+ title = rest or "Untitled notebook"
2501
+ target = os.path.join(
2502
+ cctconfig.get_config().workspace_directory,
2503
+ re.sub(r"[^\w\- ]", "", title).strip().replace(" ", "_")
2504
+ + ".ipynb")
2505
+ nb = col.create_notebook(title=title,
2506
+ first_markdown="Created by CAT.")
2507
+ path = col.save_notebook(nb, target)
2508
+ print(theme.green(f"\n \u2713 Notebook created: {path}\n"))
2509
+ elif sub == "exec":
2510
+ result = col.execute_notebook(rest)
2511
+ mark = theme.green("\u2713") if result["executed"] \
2512
+ and not result["error_cells"] else theme.red("\u2717")
2513
+ print(f"\n {mark} {result['message']}")
2514
+ if result.get("output_path"):
2515
+ print(theme.dim(f" Saved outputs to {result['output_path']}"))
2516
+ print()
2517
+ elif sub in ("script", "export"):
2518
+ bits = rest.split()
2519
+ if not bits:
2520
+ print(theme.red("\n Usage: /colab script <nb.ipynb> [out.py]\n"))
2521
+ return
2522
+ nb = col.load_notebook(bits[0])
2523
+ out = bits[1] if len(bits) > 1 else \
2524
+ os.path.splitext(bits[0])[0] + ".py"
2525
+ path = col.export_script(nb, out)
2526
+ print(theme.green(f"\n \u2713 Script exported: {path}\n"))
2527
+ elif sub == "upload-help":
2528
+ print()
2529
+ print(col.colab_upload_instructions(rest))
2530
+ print()
2531
+ else:
2532
+ print(theme.yellow(f"\n Unknown /colab subcommand '{sub}'.\n"))
2533
+ except Exception as e:
2534
+ print(theme.red(f"\n \u2717 {e}\n"))
2535
+
2536
+ # ------------------------------------------------------------- upload --
2537
+ def cmd_upload(self):
2538
+ path = input(theme.dim(" Path to image/PDF (any value works in this prototype) \u25b8 ")).strip()
2539
+ print()
2540
+ run_steps(IMAGE_STEPS, step_time=0.3)
2541
+ print()
2542
+ intent = "first"
2543
+ prefix = f"(From uploaded image{': ' + path if path else ''})"
2544
+ self.solve(intent, prefix=prefix)
2545
+
2546
+ # -------------------------------------------------------------- voice --
2547
+ def cmd_voice(self):
2548
+ sys.stdout.write(" " + theme.red("\u25cf Listening"))
2549
+ sys.stdout.flush()
2550
+ for _ in range(3):
2551
+ time.sleep(0.35)
2552
+ sys.stdout.write(theme.red("."))
2553
+ sys.stdout.flush()
2554
+ print()
2555
+ run_steps(["Processing..."], step_time=0.3)
2556
+ recognized = "first order half life numerical"
2557
+ print(theme.dim(" Recognized: ") + theme.text(f"\"{recognized}\""))
2558
+ self.handle_question(recognized)
2559
+
2560
+ # --------------------------------------------------------------- game --
2561
+ def cmd_game(self):
2562
+ game.run_game(self)
2563
+ self.print_home()
2564
+
2565
+ # ------------------------------------------------------- beta features ---
2566
+ def cmd_sim3d(self):
2567
+ print()
2568
+ print(theme.panel([
2569
+ theme.badge("BETA", theme.BG_WARN) + " " + theme.text("Real-time 3D Atomic Simulation", bold=True),
2570
+ theme.dim("Real-time calculation of atom, electron, and proton simulation."),
2571
+ theme.dim("Includes 3D graphs and real-time consequence mapping."),
2572
+ "",
2573
+ theme.dim("Enter an element symbol or Z (1-36)."),
2574
+ ], title="sim3d", color=theme.CYAN, width=WIDTH))
2575
+ raw = input(theme.dim(" element \u25b8 ")).strip() or "C"
2576
+ z = atomsim.resolve_element(raw)
2577
+ if z is None:
2578
+ print(theme.red(f" Unknown element '{raw}'."))
2579
+ sound.play("error")
2580
+ return
2581
+ sound.play("sim_start")
2582
+ sim3d.atom_simulation_3d(z)
2583
+ ans = input(theme.dim(
2584
+ " Open a real GPU-rendered 3D view of this atom in your browser? [y/N] "
2585
+ )).strip().lower()
2586
+ if ans == "y":
2587
+ path, err = gpu3d.export_atom_view(z)
2588
+ if err:
2589
+ print(theme.red(f" {err}"))
2590
+ else:
2591
+ print(theme.dim(f" Opened in browser (also saved: {path})"))
2592
+ self.print_home()
2593
+
2594
+ def cmd_gpu3d(self):
2595
+ print()
2596
+ print(theme.panel([
2597
+ theme.badge("BETA", theme.BG_WARN) + " " + theme.text("GPU-Accelerated 3D (opens in browser)", bold=True),
2598
+ theme.dim("The terminal itself has no GPU access — this generates a real"),
2599
+ theme.dim("WebGL scene (three.js) and opens it in your default browser,"),
2600
+ theme.dim("rendered by your actual GPU with mouse orbit/pan/zoom."),
2601
+ "",
2602
+ theme.cyan("1", bold=True) + theme.dim(" Atom — nucleus + orbiting shell electrons"),
2603
+ theme.cyan("2", bold=True) + theme.dim(" Graph — 3D surface for z = f(x, z)"),
2604
+ theme.cyan("3", bold=True) + theme.dim(" Scale ladder — Planck length \u2192 observable universe"),
2605
+ ], title="gpu3d", color=theme.CYAN, width=WIDTH))
2606
+ choice = input(theme.dim(" choice \u25b8 ")).strip()
2607
+
2608
+ if choice == "1":
2609
+ raw = input(theme.dim(" element \u25b8 ")).strip() or "C"
2610
+ z = atomsim.resolve_element(raw)
2611
+ if z is None:
2612
+ print(theme.red(f" Unknown element '{raw}'."))
2613
+ sound.play("error")
2614
+ self.print_home()
2615
+ return
2616
+ path, err = gpu3d.export_atom_view(z)
2617
+ elif choice == "2":
2618
+ expr = input(theme.dim(
2619
+ " z = f(x, z) in JS syntax, e.g. Math.sin(Math.sqrt(x*x+z*z)) \u25b8 ")).strip()
2620
+ path, err = gpu3d.export_graph_view(expr or "Math.sin(Math.sqrt(x*x+z*z))")
2621
+ else:
2622
+ path, err = gpu3d.export_scale_view()
2623
+
2624
+ if err:
2625
+ print(theme.red(f" {err}"))
2626
+ else:
2627
+ sound.play("sim_start")
2628
+ print(theme.green(f" \u2713 Opened in browser (also saved: {path})"))
2629
+ self.print_home()
2630
+
2631
+ def cmd_ai(self):
2632
+ # NEW: Run the same agentic loop as /agent, but with AI branding
2633
+ config = aicore.load_config()
2634
+ if not config.get("provider"):
2635
+ print(theme.orange("\n No AI provider configured yet \u2014 let's set one up first.\n"))
2636
+ aicore.setup_ai()
2637
+ config = aicore.load_config()
2638
+ if not config.get("provider"):
2639
+ self.print_home()
2640
+ return
2641
+
2642
+ def print_ai_header(conf):
2643
+ theme.clear_screen()
2644
+ print(theme.panel([
2645
+ theme.badge("BETA", theme.BG_WARN) + " " + theme.purple("\u2588 CAT TERMINAL \u2588", bold=True),
2646
+ theme.dim(f"CONNECTED: {conf['provider'].upper()} \u2503 MODEL: {conf['model']}"),
2647
+ theme.dim("SPECIALIST: QUANTUM CHEMISTRY \u2503 QUICK CONVERSATIONAL ANSWERS"),
2648
+ theme.dim("Talks normal, keeps it short \u2014 remembers you across chats (/memory)."),
2649
+ theme.dim("Need a full step-by-step notebook breakdown instead? Try /agent."),
2650
+ "",
2651
+ theme.faint("Commands: /back exit \u2503 /verify test \u2503 /setup reconfigure"),
2652
+ theme.faint("/memory what's remembered"),
2653
+ ], title="ai chat", color=theme.PURPLE, width=WIDTH, title_gradient=(theme.CYAN, theme.PURPLE)))
2654
+
2655
+ print_ai_header(config)
2656
+ raw = ""
2657
+
2658
+ while True:
2659
+ try:
2660
+ raw = fallback_cli.fallback_input(
2661
+ label="AI", bg_color=theme.BG_AI, suggestions=AI_SUGGESTIONS, width=WIDTH,
2662
+ placeholder="Ask a chemistry question\u2026",
2663
+ ).strip()
2664
+ except (KeyboardInterrupt, EOFError):
2665
+ raw = ""
2666
+ break
2667
+ if not raw:
2668
+ continue
2669
+ low = raw.lower()
2670
+ if low == "/back":
2671
+ break
2672
+ if low == "/setup":
2673
+ aicore.setup_ai()
2674
+ config = aicore.load_config()
2675
+ print_ai_header(config)
2676
+ continue
2677
+ if low == "/verify":
2678
+ ok, msg, models = aicore.verify_connection()
2679
+ print((theme.green if ok else theme.red)((" \u2713 " if ok else " \u2717 ") + msg))
2680
+ if models:
2681
+ print(theme.faint(" " + ", ".join(str(m) for m in models[:8])))
2682
+ continue
2683
+ if low == "/memory":
2684
+ self.cmd_memory()
2685
+ continue
2686
+ if low == "/agent":
2687
+ break
2688
+
2689
+ fallback_cli.render_user(raw, tag="you", term_width=WIDTH)
2690
+ fallback_cli.thinking(label="CCT AI", model_label=config.get("model"))
2691
+ t0 = time.time()
2692
+ final_text, steps, meta = agent.run_agent(raw, mode="ai")
2693
+ duration = time.time() - t0
2694
+
2695
+ if aicore.is_error_response(final_text) and not steps:
2696
+ # a real provider failure, not a normal answer — show
2697
+ # the recovery card (spec section 19) instead of
2698
+ # rendering the failure text as if it were a chat reply
2699
+ retried = {}
2700
+
2701
+ def _retry_ai(_raw=raw, _model_label=None):
2702
+ ft, retry_steps, retry_meta = agent.run_agent(_raw, mode="ai")
2703
+ if aicore.is_error_response(ft) and not retry_steps:
2704
+ raise RuntimeError(ft)
2705
+ retried["text"], retried["steps"], retried["meta"] = ft, retry_steps, retry_meta
2706
+ return ft
2707
+ ok = errors.error_card(
2708
+ "ai_provider", RuntimeError(final_text), retry=_retry_ai,
2709
+ extra_actions=[
2710
+ ("Reconnect", lambda: aicore.setup_ai()),
2711
+ ("Switch Provider", lambda: self.cmd_model()),
2712
+ ], width=WIDTH)
2713
+ if ok and "text" in retried:
2714
+ # the retry succeeded — actually show that answer,
2715
+ # don't just silently swallow it
2716
+ final_text, steps, meta = retried["text"], retried["steps"], retried["meta"]
2717
+ else:
2718
+ print()
2719
+ continue
2720
+
2721
+ model_label = f"{config.get('provider','').upper()} {config.get('model', '')}".strip()
2722
+ if steps:
2723
+ print(theme.dim(f" ({len(steps)} tool step(s) run)"))
2724
+ # v0.5.6: adaptive OpenCode-style reply with avatar footer.
2725
+ fallback_cli.render_ai(final_text, tag="CCT AI", model_label=model_label,
2726
+ duration=duration, term_width=WIDTH)
2727
+ changes = meta.get("file_changes") or ""
2728
+ if changes:
2729
+ print()
2730
+ print(theme.panel(_colorize_diff_lines(changes), title="File changes",
2731
+ color=theme.GREEN, width=WIDTH))
2732
+ if meta.get("suggest_agent"):
2733
+ print(theme.orange(" \U0001F4A1 That looked like a big one \u2014 try /agent for the "
2734
+ "full step-by-step notebook breakdown."))
2735
+ print()
2736
+
2737
+ if raw.strip().lower() == "/agent":
2738
+ self.cmd_agent()
2739
+ return
2740
+ self.print_home()
2741
+
2742
+ def cmd_ai_verify(self):
2743
+ print()
2744
+ print(theme.panel([
2745
+ theme.text("Verifying AI provider connection...", bold=True),
2746
+ theme.dim("Runs a real network round trip \u2014 not just a config-file check."),
2747
+ ], title="ai-verify", color=theme.CYAN, width=WIDTH))
2748
+ run_steps(["Loading configuration...", "Contacting provider..."], step_time=0.2)
2749
+ ok, msg, models = aicore.verify_connection()
2750
+ sound.play("success" if ok else "error")
2751
+ color = theme.GREEN if ok else theme.RED
2752
+ lines = [(theme.green if ok else theme.red)(("\u2713 " if ok else "\u2717 ") + msg, bold=True)]
2753
+ if models:
2754
+ lines.append("")
2755
+ lines.append(theme.dim("Models available:"))
2756
+ for m in models[:15]:
2757
+ lines.append(" " + theme.cyan("\u2022 ") + theme.text(str(m)))
2758
+ if len(models) > 15:
2759
+ lines.append(theme.faint(f" \u2026and {len(models) - 15} more"))
2760
+ print()
2761
+ print(theme.panel(lines, title="ai-verify result", color=color, width=WIDTH))
2762
+ print()
2763
+
2764
+ def cmd_memory(self, sub=None):
2765
+ if sub in ("clear", "wipe", "reset", "forget"):
2766
+ memory.clear()
2767
+ print()
2768
+ print(theme.panel([theme.green("\u2713 Memory cleared.", bold=True),
2769
+ theme.dim("CAT no longer remembers any past turns, facts, or activity.")],
2770
+ title="memory", color=theme.CYAN, width=WIDTH))
2771
+ print()
2772
+ return
2773
+
2774
+ st = memory.stats()
2775
+ mem = memory.load()
2776
+ lines = [
2777
+ theme.purple("WHAT CAT REMEMBERS ABOUT YOU", bold=True),
2778
+ theme.dim("Shared by both /ai and /agent \u2014 persists across restarts."),
2779
+ "",
2780
+ ]
2781
+ lines.append(theme.fg("\u25cf STATS", theme.CYAN, bold=True))
2782
+ lines.append(f" {st['turns']} conversation turn(s) \u2022 {st['facts']} fact(s) \u2022 "
2783
+ f"{st['activity']} activity event(s) \u2022 {st['topics']} topic(s)")
2784
+ lines.append("")
2785
+
2786
+ facts = mem.get("facts", [])
2787
+ if facts:
2788
+ lines.append(theme.fg("\u25cf FACTS IT REMEMBERS", theme.CYAN, bold=True))
2789
+ for f in facts[-10:]:
2790
+ lines.append(" " + theme.dim("\u2022 ") + theme.text(f))
2791
+ lines.append("")
2792
+
2793
+ topics = sorted(mem.get("topics", {}).items(), key=lambda kv: kv[1], reverse=True)
2794
+ if topics:
2795
+ lines.append(theme.fg("\u25cf TOPICS YOU ASK ABOUT MOST", theme.CYAN, bold=True))
2796
+ lines.append(" " + ", ".join(f"{k} ({v})" for k, v in topics[:8]))
2797
+ lines.append("")
2798
+
2799
+ activity = mem.get("activity", [])[-8:]
2800
+ if activity:
2801
+ lines.append(theme.fg("\u25cf RECENT ACTIVITY (every movement)", theme.CYAN, bold=True))
2802
+ for a in reversed(activity):
2803
+ lines.append(" " + theme.dim("\u2022 ") + theme.text(a.get("label", a.get("type", ""))))
2804
+ lines.append("")
2805
+
2806
+ if not facts and not topics and not activity:
2807
+ lines.append(theme.dim("Nothing remembered yet \u2014 use /ai or /agent a little and it'll build up."))
2808
+ lines.append("")
2809
+
2810
+ lines.append(theme.faint("Type /memory clear to wipe all of it."))
2811
+ print()
2812
+ print(theme.panel(lines, title="memory", color=theme.CYAN, width=WIDTH))
2813
+ print()
2814
+
2815
+ # ------------------------------------------------------- agentic ai --
2816
+ def cmd_agent(self):
2817
+ config = aicore.load_config()
2818
+ if not config.get("provider"):
2819
+ print(theme.orange("\n No AI provider configured yet \u2014 let's set one up first.\n"))
2820
+ aicore.setup_ai()
2821
+ config = aicore.load_config()
2822
+ if not config.get("provider"):
2823
+ self.print_home()
2824
+ return
2825
+
2826
+ theme.clear_screen()
2827
+ print(theme.panel([
2828
+ theme.badge("BETA", theme.BG_WARN) + " " + theme.purple("\u2588 CAT AGENT \u2588", bold=True),
2829
+ theme.dim(f"CONNECTED: {config['provider'].upper()} \u2503 MODEL: {config['model']}"),
2830
+ theme.dim("This agent doesn't just talk \u2014 it can SOLVE, PLOT, and SIMULATE for real."),
2831
+ theme.dim("Answers are long-form, notebook style \u2014 and it remembers you (/memory)."),
2832
+ "",
2833
+ theme.text("Try things like:", bold=True),
2834
+ theme.dim(" \u2022 \"solve ideal gas law with P=1, n=2, T=300\""),
2835
+ theme.dim(" \u2022 \"plot the arrhenius graph and export it\""),
2836
+ theme.dim(" \u2022 \"plot y = sin(x)*exp(-x/5) from 0 to 20 called Damped Wave\""),
2837
+ theme.dim(" \u2022 \"simulate an iron atom in 3d\" / \"show me the 2p orbital cloud\""),
2838
+ "",
2839
+ theme.faint("Commands: /back exit \u2503 /verify test \u2503 /setup reconfigure"),
2840
+ theme.faint("/memory what's remembered \u2503 /team toggle multi-agent mode"),
2841
+ ], title="agent", color=theme.PURPLE, width=WIDTH))
2842
+
2843
+ team_mode = False
2844
+
2845
+ while True:
2846
+ try:
2847
+ raw = fallback_cli.fallback_input(
2848
+ label="AGENT", bg_color=theme.BG_AI, suggestions=AGENT_SUGGESTIONS, width=WIDTH,
2849
+ placeholder="Ask the agent to solve, plot, or simulate\u2026",
2850
+ ).strip()
2851
+ except (KeyboardInterrupt, EOFError):
2852
+ break
2853
+ if not raw:
2854
+ continue
2855
+ low = raw.lower()
2856
+ if low == "/back":
2857
+ break
2858
+ if low == "/setup":
2859
+ aicore.setup_ai()
2860
+ config = aicore.load_config()
2861
+ continue
2862
+ if low == "/verify":
2863
+ ok, msg, models = aicore.verify_connection()
2864
+ print((theme.green if ok else theme.red)((" \u2713 " if ok else " \u2717 ") + msg))
2865
+ if models:
2866
+ print(theme.faint(" " + ", ".join(str(m) for m in models[:8])))
2867
+ continue
2868
+ if low == "/memory":
2869
+ self.cmd_memory()
2870
+ continue
2871
+ if low == "/team":
2872
+ team_mode = not team_mode
2873
+ state = "ON — Researcher \u2192 Planner \u2192 Specialist \u2192 Programmer \u2192 Debugger \u2192 Tester (roles join only when needed)" if team_mode else "OFF — single agent"
2874
+ print(theme.dim(f" Multi-agent mode: {state}"))
2875
+ continue
2876
+
2877
+ fallback_cli.render_user(raw, tag="you", term_width=WIDTH)
2878
+ model_label = f"{config.get('provider','').upper()} {config.get('model', '')}".strip()
2879
+
2880
+ if team_mode:
2881
+ def _on_agent(role_key, role_label, status, _cfg=config):
2882
+ icon = {"researcher": "\U0001F50D", "planner": "\U0001F4CB", "specialist": "\u2699",
2883
+ "programmer": "\U0001F9F1", "debugger": "\U0001F41E",
2884
+ "tester": "\U0001F9EA"}.get(role_key, "\u25CF")
2885
+ if status == "start":
2886
+ print(theme.dim(f" {icon} {role_label} working\u2026"))
2887
+ else:
2888
+ print(theme.dim(f" \u2713 {role_label} done"))
2889
+ t0 = time.time()
2890
+ final_text, steps, meta = agent.run_multi_agent(
2891
+ raw, on_agent=_on_agent, mode="agent")
2892
+ duration = time.time() - t0
2893
+ if meta.get("active_roles"):
2894
+ print(theme.faint(" team: " + " \u2192 ".join(r.capitalize() for r in meta["active_roles"])))
2895
+ if meta.get("team_plan"):
2896
+ print(theme.faint(" plan: " + " \u2192 ".join(meta["team_plan"])))
2897
+ dnote = meta.get("debug_note")
2898
+ if dnote and dnote.get("risk") not in ("none", None):
2899
+ print(theme.orange(f" \U0001F41E debugger risk: {dnote['risk']}"))
2900
+ if meta.get("tester_ok") is False:
2901
+ print(theme.orange(f" \u26a0 tester note: {meta.get('tester_note','')}"))
2902
+ else:
2903
+ fallback_cli.thinking(label="CCT Agent", model_label=config.get("model"))
2904
+ t0 = time.time()
2905
+ final_text, steps, meta = agent.run_agent(raw, mode="agent")
2906
+ duration = time.time() - t0
2907
+
2908
+ if aicore.is_error_response(final_text) and not steps:
2909
+ retried = {}
2910
+
2911
+ def _retry_agent(_raw=raw):
2912
+ ft, retry_steps, retry_meta = agent.run_agent(_raw, mode="agent")
2913
+ if aicore.is_error_response(ft) and not retry_steps:
2914
+ raise RuntimeError(ft)
2915
+ retried["text"], retried["steps"], retried["meta"] = ft, retry_steps, retry_meta
2916
+ return ft
2917
+ ok = errors.error_card(
2918
+ "ai_provider", RuntimeError(final_text), retry=_retry_agent,
2919
+ extra_actions=[
2920
+ ("Reconnect", lambda: aicore.setup_ai()),
2921
+ ("Switch Provider", lambda: self.cmd_model()),
2922
+ ], width=WIDTH)
2923
+ if ok and "text" in retried:
2924
+ final_text, steps, meta = retried["text"], retried["steps"], retried["meta"]
2925
+ else:
2926
+ print()
2927
+ continue
2928
+
2929
+ if steps:
2930
+ print(theme.dim(f" ({len(steps)} tool step(s) run)"))
2931
+ # v0.5.6: adaptive OpenCode-style reply (the agent's answers are
2932
+ # usually long/notebook-style, so render_ai picks the borderless
2933
+ # path automatically) with the `▣ tag · model · dur` footer.
2934
+ fallback_cli.render_ai(final_text, tag="CCT Agent", model_label=model_label,
2935
+ duration=duration, term_width=WIDTH)
2936
+ changes = meta.get("file_changes") or ""
2937
+ if changes:
2938
+ print()
2939
+ print(theme.panel(_colorize_diff_lines(changes), title="File changes",
2940
+ color=theme.GREEN, width=WIDTH))
2941
+ print()
2942
+
2943
+ self.print_home()
2944
+
2945
+ # --------------------------------------------------------- code pad ---
2946
+ def cmd_codepad(self):
2947
+ code_editor.run_editor(self, mode="code")
2948
+
2949
+ def cmd_textedit(self):
2950
+ code_editor.run_editor(self, mode="text")
2951
+
2952
+ # ------------------------------------------------------- model/tokens --
2953
+ def cmd_model(self, arg=None):
2954
+ """/model — model picker with live auto-refresh, search & filters.
2955
+ Subcommands: /model refresh, /model search <query>, or switch to an
2956
+ explicitly typed model id directly (any model works, even brand-new
2957
+ ones not in any list yet)."""
2958
+ config = aicore.load_config()
2959
+ if not config.get("provider"):
2960
+ print(theme.orange("\n No AI provider configured yet — let's set one up first.\n"))
2961
+ aicore.setup_ai()
2962
+ return
2963
+
2964
+ arg = (arg or "").strip()
2965
+ low_arg = arg.lower()
2966
+ if low_arg == "refresh":
2967
+ self.cmd_refresh_models()
2968
+ return
2969
+ if low_arg == "search" or low_arg.startswith("search "):
2970
+ query = arg[6:].strip() if low_arg.startswith("search ") else ""
2971
+ self._model_picker(query=query)
2972
+ return
2973
+ if arg:
2974
+ ok, msg = aicore.switch_model(arg)
2975
+ print()
2976
+ print((theme.green if ok else theme.red)((" ✓ " if ok else " ✗ ") + msg))
2977
+ print()
2978
+ return
2979
+
2980
+ self._model_picker()
2981
+
2982
+ # ------------------------------------------------- model picker --------
2983
+ _MODEL_FILTERS = [
2984
+ ("all", "All"), ("free", "Free"), ("paid", "Paid"), ("latest", "Latest"),
2985
+ ("reasoning", "Reasoning"), ("coding", "Coding"), ("vision", "Vision"),
2986
+ ("fast", "Fast"), ("long_context", "Long Context"), ("image", "Image"),
2987
+ ("speech", "Speech"), ("embedding", "Embedding"), ("experimental", "Experimental"),
2988
+ ]
2989
+
2990
+ def _model_picker(self, filter_id="all", query="", auto_refresh=True):
2991
+ """Interactive model picker: live/cached/default resolution,
2992
+ category filters, search, FREE/PAID badges, NEW/LATEST markers.
2993
+ Auto-refreshes from the provider's API when the cache is stale."""
2994
+ from .models import manager as _mgr
2995
+ config = aicore.load_config()
2996
+ provider_id = config.get("provider")
2997
+ if not provider_id:
2998
+ return
2999
+ provider = _mgr.get_provider(provider_id) or {}
3000
+ provider_name = provider.get("name", provider_id)
3001
+ current_model = config.get("model", "")
3002
+
3003
+ print()
3004
+ run_steps(["Loading model registry...", "Checking cache..."], step_time=0.15)
3005
+
3006
+ # Auto-refresh: stale cache -> fetch live from the provider's API
3007
+ refresh_note = ""
3008
+ if auto_refresh and not _mgr.cache_is_fresh(provider_id):
3009
+ print(theme.dim(" Checking for new models..."))
3010
+ models, source = _mgr.ensure_fresh(provider_id)
3011
+ if source == _mgr.SOURCE_LIVE:
3012
+ refresh_note = " ✓ Updated successfully — new model list downloaded."
3013
+ elif source == _mgr.SOURCE_CACHED:
3014
+ refresh_note = " Using cached model list."
3015
+ else:
3016
+ refresh_note = " Using cached/default model list."
3017
+ else:
3018
+ models, source = _mgr.get_models(provider_id)
3019
+
3020
+ entries = _mgr.enrich(provider_id, models)
3021
+ new_set = _mgr.new_models(provider_id)
3022
+ active_filter = filter_id or "all"
3023
+ active_query = query or ""
3024
+
3025
+ while True:
3026
+ theme.clear_screen()
3027
+ lines = [
3028
+ theme.purple("SWITCH MODEL", bold=True),
3029
+ theme.dim(f"Provider: {provider_name} │ Current model: {current_model}"),
3030
+ theme.dim(f"Source: {source.upper()} │ "
3031
+ + (f"Last refresh: {_mgr.get_last_refresh(provider_id)}" if _mgr.get_last_refresh(provider_id) else "No live fetch yet")
3032
+ + f" │ {len(models)} model(s)"),
3033
+ ]
3034
+ if refresh_note:
3035
+ lines.append(theme.green(refresh_note))
3036
+ lines.append("")
3037
+ print(theme.panel(lines, title="model", color=theme.PURPLE, width=WIDTH))
3038
+
3039
+ shown = _mgr.apply_filter(entries, active_filter, new_set)
3040
+ if active_query:
3041
+ q = active_query.lower()
3042
+ shown = [e for e in shown if q in " ".join([
3043
+ e["id"].lower(), e.get("family", "").lower(),
3044
+ e.get("category", "").lower(),
3045
+ " ".join(e["capabilities"]),
3046
+ e.get("display_name", "").lower()])]
3047
+ shown = shown[:25]
3048
+ if not shown:
3049
+ print(theme.dim(" No models match — type /back to cancel."))
3050
+ for i, e in enumerate(shown, 1):
3051
+ badges = " ".join(e.get("badges", []))
3052
+ badges_str = f" {theme.orange(badges)}" if badges else ""
3053
+ ctx = (" " + theme.dim(f"{e.get('context_length', 0):,}")
3054
+ if e.get("context_length") else "")
3055
+ print(f" {theme.cyan(f'{i}.')} {e['id']}{badges_str}{ctx}")
3056
+ if len(_mgr.apply_filter(entries, active_filter, new_set)) > 25:
3057
+ print(theme.faint(f" … {len(entries) - 25} more — use /search <query> to narrow down."))
3058
+
3059
+ filter_row = " ".join(
3060
+ theme.cyan(f"[{f_id}]") if f_id == active_filter else theme.dim(f"[{f_id}]")
3061
+ for f_id, _ in self._MODEL_FILTERS)
3062
+ print()
3063
+ print(theme.dim(" Filters: ") + filter_row)
3064
+ print(theme.faint(" Type: number to select · /free /latest /vision … to filter · "
3065
+ "/search <query> · /model <name> to type any model · /back to cancel"))
3066
+ print()
3067
+
3068
+ choice = input(theme.dim(f" model ▸ ")).strip()
3069
+ low = choice.lower()
3070
+ if low in ("/back", "back", ""):
3071
+ return
3072
+ if low == "/refresh":
3073
+ self.cmd_refresh_models()
3074
+ return
3075
+ if low.startswith("/search") or low.startswith("/s "):
3076
+ active_query = choice.split(" ", 1)[1].strip() if " " in choice else ""
3077
+ continue
3078
+ if low.startswith("/"):
3079
+ cand = low.lstrip("/")
3080
+ for f_id, _label in self._MODEL_FILTERS:
3081
+ if cand == f_id or (f_id == "long_context" and cand in ("long", "long-context", "context")):
3082
+ active_filter = f_id
3083
+ active_query = ""
3084
+ break
3085
+ else:
3086
+ if cand == "model":
3087
+ model_name = input(theme.dim(" Type the exact model id ▸ ")).strip()
3088
+ if model_name:
3089
+ self._apply_model_switch(model_name, provider_id, current_model)
3090
+ return
3091
+ continue
3092
+ if choice.isdigit() and shown and 1 <= int(choice) <= len(shown):
3093
+ self._apply_model_switch(shown[int(choice) - 1]["id"], provider_id, current_model)
3094
+ return
3095
+ if choice:
3096
+ self._apply_model_switch(choice, provider_id, current_model)
3097
+ return
3098
+
3099
+ def _apply_model_switch(self, model_name, provider_id, old_model):
3100
+ ok, msg = aicore.switch_model(model_name)
3101
+ print()
3102
+ print((theme.green if ok else theme.red)((" ✓ " if ok else " ✗ ") + msg))
3103
+ print()
3104
+
3105
+ # ------------------------------------------------------- /free --------
3106
+ def cmd_free(self):
3107
+ """Show only free models for the current provider."""
3108
+ config = aicore.load_config()
3109
+ if not config.get("provider"):
3110
+ print(theme.orange("\n No AI provider configured yet — run /model to set one up.\n"))
3111
+ return
3112
+ self._model_picker(filter_id="free")
3113
+
3114
+ # ------------------------------------------------------- /latest ------
3115
+ def cmd_latest(self):
3116
+ """Show the newest available models for the current provider."""
3117
+ config = aicore.load_config()
3118
+ if not config.get("provider"):
3119
+ print(theme.orange("\n No AI provider configured yet — run /model to set one up.\n"))
3120
+ return
3121
+ self._model_picker(filter_id="latest")
3122
+
3123
+ # ------------------------------------------------------ /provider -----
3124
+ def cmd_provider(self):
3125
+ """Switch AI provider — the full data-driven list from providers.json."""
3126
+ from .models import manager as _mgr
3127
+ config = aicore.load_config()
3128
+ current = config.get("provider", "")
3129
+ providers = _mgr.list_providers()
3130
+
3131
+ print()
3132
+ lines = [
3133
+ theme.purple("SELECT PROVIDER", bold=True),
3134
+ theme.dim(f"Current: {current or 'none'}"),
3135
+ "",
3136
+ ]
3137
+ for i, p in enumerate(providers, 1):
3138
+ mark = "▶ " if p["id"] == current else " "
3139
+ lines.append(f"{mark}{theme.cyan(f'{i}.')} {p['name']} {theme.dim(p.get('country', ''))}")
3140
+ print(theme.panel(lines, title="provider", color=theme.PURPLE, width=WIDTH))
3141
+ print(theme.faint("Type a number to switch provider, or type a provider id directly. /back to cancel."))
3142
+ print()
3143
+
3144
+ choice = input(theme.dim(" provider ▸ ")).strip()
3145
+ if choice.lower() in ("/back", "back", ""):
3146
+ return
3147
+ if choice.isdigit() and 1 <= int(choice) <= len(providers):
3148
+ pid = providers[int(choice) - 1]["id"]
3149
+ else:
3150
+ pid = choice.lower().strip()
3151
+ target = _mgr.get_provider(pid)
3152
+ if not target:
3153
+ print(theme.red(f" ✗ Unknown provider: {pid}"))
3154
+ return
3155
+
3156
+ cfg = dict(config)
3157
+ cfg["provider"] = pid
3158
+ if target.get("api_endpoint"):
3159
+ cfg["base_url"] = target["api_endpoint"]
3160
+ cfg["api_url"] = target["api_endpoint"]
3161
+ if target.get("api_style"):
3162
+ cfg["api_style"] = target["api_style"]
3163
+ if target.get("needs_key", True) and not cfg.get("api_key"):
3164
+ key = input(theme.dim(f" API Key for {target['name']} (Enter to skip) ▸ ")).strip()
3165
+ if key:
3166
+ cfg["api_key"] = key
3167
+ fallback = [str(m) for m in target.get("fallback_models", [])]
3168
+ if fallback:
3169
+ cfg["model"] = fallback[0]
3170
+ elif cfg.get("model"):
3171
+ cfg.pop("model", None)
3172
+ from .providers import provider_manager as _pm
3173
+ _pm.save_config(cfg)
3174
+ print(theme.green(f"\n ✓ Switched provider to {target['name']}."))
3175
+ print(theme.dim(" Pick a model: "))
3176
+ self._model_picker()
3177
+
3178
+ # ------------------------------------------------------- /status ------
3179
+ def cmd_status(self):
3180
+ """Provider / model / source / last-refresh status panel."""
3181
+ from .models import manager as _mgr
3182
+ from .providers import provider_manager as _pm
3183
+ config = aicore.load_config()
3184
+ provider_id = config.get("provider")
3185
+ if not provider_id:
3186
+ print(theme.orange("\n No AI provider configured yet — run /model to set one up.\n"))
3187
+ return
3188
+ st = _pm.get_provider_status(provider_id, config.get("model", ""))
3189
+ print()
3190
+ source_label = {"live": "Live", "cached": "Cached", "default": "Default"}.get(
3191
+ st.get("model_source", ""), st.get("model_source", "—"))
3192
+ lines = [
3193
+ theme.purple("AI STATUS", bold=True),
3194
+ f" {theme.cyan('Provider:')} {st.get('provider_name', provider_id)} ({st.get('country', '')})",
3195
+ f" {theme.cyan('Current model:')} {st.get('current_model') or '(none)'}",
3196
+ f" {theme.cyan('Model source:')} {source_label}",
3197
+ f" {theme.cyan('Last refresh:')} {st.get('last_refresh') or '(never — using default list)'}",
3198
+ f" {theme.cyan('Cache age:')} " + (
3199
+ f"{st['cache_age_hours']:.1f} h" if st.get("cache_age_hours") is not None else "—"),
3200
+ f" {theme.cyan('Models visible:')} {st.get('model_count', 0):,}",
3201
+ "",
3202
+ theme.dim(" Capabilities:"),
3203
+ theme.dim(f" Listing: {'✓' if st.get('supports_model_listing') else '✗'} "
3204
+ f"Streaming: {'✓' if st.get('supports_streaming') else '✗'} "
3205
+ f"Vision: {'✓' if st.get('supports_vision') else '✗'} "
3206
+ f"Embeddings: {'✓' if st.get('supports_embeddings') else '✗'}"),
3207
+ theme.dim(f" Audio: {'✓' if st.get('supports_audio') else '✗'} "
3208
+ f"Reasoning: {'✓' if st.get('supports_reasoning') else '✗'} "
3209
+ f"OpenAI-compatible: {'✓' if st.get('openai_compatible') else '✗'} "
3210
+ f"Free models: {'✓' if st.get('free_models_available') else '✗'}"),
3211
+ "",
3212
+ theme.faint("Source is Live when fetched from the provider's API,"),
3213
+ theme.faint("Cached when reused from cache/models/, Default when offline."),
3214
+ ]
3215
+ print(theme.panel(lines, title="status", color=theme.CYAN, width=WIDTH))
3216
+ print()
3217
+
3218
+ # ------------------------------------------------- /refresh-models ----
3219
+ def cmd_refresh_models(self, arg=None):
3220
+ """Force-refresh the model list from the selected provider's API."""
3221
+ from .models import manager as _mgr
3222
+ config = aicore.load_config()
3223
+ provider_id = config.get("provider")
3224
+ if not provider_id:
3225
+ print(theme.orange("\n No AI provider configured yet — run /model to set one up.\n"))
3226
+ return
3227
+ provider = _mgr.get_provider(provider_id) or {}
3228
+ print()
3229
+ print(theme.dim(f" Refreshing model list for {provider.get('name', provider_id)} ..."))
3230
+ run_steps(["Checking for new models...", "Downloading..."], step_time=0.3)
3231
+ models, source = _mgr.refresh_models(provider_id)
3232
+ print()
3233
+ if models and source == _mgr.SOURCE_LIVE:
3234
+ print(theme.green(f" ✓ Updated successfully — {len(models)} model(s) from the provider's API."))
3235
+ cached = _mgr.get_cached(provider_id)
3236
+ if cached:
3237
+ print(theme.dim(f" Cached at: {cached[1]}"))
3238
+ print(theme.faint(" Tip: /model search <query> to narrow them down."))
3239
+ elif models and source == _mgr.SOURCE_CACHED:
3240
+ print(theme.orange(f" ⚠ Couldn't reach the API — using cached list ({len(models)} model(s))."))
3241
+ else:
3242
+ print(theme.orange(f" ⚠ Couldn't reach the API — using built-in default list ({len(models)} model(s))."))
3243
+ print()
3244
+
3245
+ def cmd_tokens(self):
3246
+ usage = aicore.get_session_usage()
3247
+ pct = 0.0
3248
+ if usage["context_window"]:
3249
+ pct = min(100.0, 100.0 * usage["total_tokens"] / usage["context_window"])
3250
+ lines = [
3251
+ theme.purple("SESSION TOKEN USAGE", bold=True),
3252
+ theme.dim(f"Model: {usage['model']}"),
3253
+ "",
3254
+ f"{theme.cyan('Prompt tokens:')} {usage['prompt_tokens']:,}",
3255
+ f"{theme.cyan('Completion tokens:')} {usage['completion_tokens']:,}",
3256
+ f"{theme.cyan('Total used:')} {usage['total_tokens']:,}",
3257
+ f"{theme.cyan('AI requests made:')} {usage['requests']:,}",
3258
+ "",
3259
+ f"{theme.dim('Estimated context window:')} {usage['context_window']:,} tokens",
3260
+ f"{theme.dim('Estimated remaining:')} {usage['remaining_estimate']:,} tokens ({100 - pct:.1f}% free)",
3261
+ "",
3262
+ theme.faint("Real usage is read from the provider's response when it reports it;"),
3263
+ theme.faint("otherwise estimated at ~4 characters/token."),
3264
+ ]
3265
+ print()
3266
+ print(theme.panel(lines, title="tokens", color=theme.CYAN, width=WIDTH))
3267
+ print()
3268
+
3269
+ # ------------------------------------------------------------- theme --
3270
+ def cmd_theme(self, arg=None):
3271
+ if not arg:
3272
+ arg = "textual-light" if not theme.is_light() else "tokyo-night"
3273
+ new_name = theme.set_theme(arg)
3274
+ print()
3275
+ print(theme.panel([
3276
+ theme.green(f"✓ Theme switched to {theme.theme_label(new_name)}.", bold=True),
3277
+ theme.dim("Applies to panels, chat bubbles, and badges across the whole app."),
3278
+ theme.dim("All themes: " + ", ".join(theme.available_themes())),
3279
+ ], title="theme", color=theme.CYAN, width=WIDTH))
3280
+ print()
3281
+
3282
+ # -------------------------------------------------------- web search --
3283
+ def cmd_websearch(self, query=None):
3284
+ if not query:
3285
+ query = input(theme.dim(" search ▸ ")).strip()
3286
+ if not query:
3287
+ return
3288
+ print()
3289
+ run_steps(["Searching the web..."], step_time=0.25)
3290
+ results = aicore.web_search(query, max_results=6)
3291
+ if not results:
3292
+ print(theme.orange(" No results (unreachable, or the query returned nothing)."))
3293
+ print()
3294
+ return
3295
+ lines = [theme.purple(f'Results for "{query}"', bold=True), ""]
3296
+ for i, r in enumerate(results, 1):
3297
+ lines.append(theme.cyan(f"{i}. {r['title']}", bold=True))
3298
+ lines.append(theme.faint(f" {r['url']}"))
3299
+ if r["snippet"]:
3300
+ lines.append(theme.dim(" " + textwrap.shorten(r["snippet"], width=WIDTH - 6)))
3301
+ lines.append("")
3302
+ print(theme.panel(lines, title="websearch", color=theme.CYAN, width=WIDTH))
3303
+
3304
+ def cmd_research(self, topic=None):
3305
+ if not topic:
3306
+ topic = input(theme.dim(" research topic ▸ ")).strip()
3307
+ if not topic:
3308
+ return
3309
+ print()
3310
+ run_steps(["Planning search angles...", "Searching the web...", "Synthesizing findings..."], step_time=0.3)
3311
+ summary, sources = aicore.deep_research(topic)
3312
+ lines = [theme.purple(f"Deep research: {topic}", bold=True), ""]
3313
+ lines.extend(theme.text(l) for l in summary.split("\n"))
3314
+ if sources:
3315
+ lines.append("")
3316
+ lines.append(theme.cyan("SOURCES", bold=True))
3317
+ for i, s in enumerate(sources, 1):
3318
+ lines.append(theme.faint(f"[{i}] {s['title']} — {s['url']}"))
3319
+ print(theme.panel(lines, title="research", color=theme.PURPLE, width=WIDTH))
3320
+ print()
3321
+
3322
+ # -------------------------------------------------------------- import --
3323
+ def cmd_pet(self):
3324
+ print()
3325
+ print(theme.panel(
3326
+ [theme.text("Your CCT mascot pet — lives right above the prompt.", bold=True), "",
3327
+ theme.faint(" Fire off questions quickly and it runs; a normal pace"),
3328
+ theme.faint(" and it walks; leave the terminal alone a while and it"),
3329
+ theme.faint(" falls asleep. Here's all three, back to back:")],
3330
+ title="pet", color=theme.CYAN, width=WIDTH))
3331
+ print()
3332
+ pet.demo(width=WIDTH)
3333
+
3334
+ # --------------------------------------------------- reaction sim --
3335
+ def cmd_react(self, arg=None):
3336
+ print()
3337
+ if not arg:
3338
+ lines = [theme.purple("REACTION SIMULATOR", bold=True), "",
3339
+ theme.text("Type an equation to balance it, e.g.:"),
3340
+ theme.cyan(" Fe + O2 -> Fe2O3"),
3341
+ theme.cyan(" C3H8 + O2 -> CO2 + H2O"),
3342
+ "",
3343
+ theme.faint("Or run a live playback:"),
3344
+ theme.dim(" /react kinetics — concentration vs time (order 0/1/2)"),
3345
+ theme.dim(" /react equilibrium — A + B ⇌ C + D approach to Keq")]
3346
+ print(theme.panel(lines, title="react", color=theme.PURPLE, width=WIDTH))
3347
+ arg = input(theme.dim(" ▸ ")).strip()
3348
+ if not arg:
3349
+ self.print_home()
3350
+ return
3351
+
3352
+ if arg.lower().startswith("kinetics"):
3353
+ self._react_kinetics()
3354
+ elif arg.lower().startswith(("equilibrium", "equil")):
3355
+ self._react_equilibrium()
3356
+ else:
3357
+ self._react_balance(arg)
3358
+ print()
3359
+
3360
+ def _react_balance(self, eq):
3361
+ try:
3362
+ balanced, coeffs, reactants, products = reactionsim.balance_equation(eq)
3363
+ except reactionsim.BalanceError as e:
3364
+ # already a purpose-built, human-readable message from
3365
+ # reactionsim itself — a recovery card would just add
3366
+ # ceremony around text that's already clear, and "Retry"
3367
+ # doesn't help without different input anyway.
3368
+ print(theme.red(f" \u2717 {e}"))
3369
+ return
3370
+ except Exception as e:
3371
+ # genuinely unexpected — the raw message here could be
3372
+ # anything from a Python internals error, so offer View
3373
+ # Details instead of dumping it inline by default.
3374
+ errors.error_card("balance", e, width=WIDTH)
3375
+ return
3376
+ lines = [theme.text("Input: " + eq, bold=False),
3377
+ theme.green("Balanced: " + balanced, bold=True), "",
3378
+ theme.faint("Coefficients: " + ", ".join(str(c) for c in coeffs))]
3379
+ print(theme.panel(lines, title="balanced equation", color=theme.GREEN, width=WIDTH))
3380
+
3381
+ def _react_kinetics(self):
3382
+ order_in = input(theme.dim(" order (0/1/2) [1] ▸ ")).strip() or "1"
3383
+ try:
3384
+ order = int(order_in)
3385
+ except ValueError:
3386
+ order = 1
3387
+ k_in = input(theme.dim(" rate constant k [0.1] ▸ ")).strip() or "0.1"
3388
+ c0_in = input(theme.dim(" initial [A]0 [1.0] ▸ ")).strip() or "1.0"
3389
+ t_in = input(theme.dim(" time span (s) [20] ▸ ")).strip() or "20"
3390
+ try:
3391
+ k, c0, t_end = float(k_in), float(c0_in), float(t_in)
3392
+ except ValueError:
3393
+ print(theme.red(" ✗ Enter numeric values."))
3394
+ return
3395
+ times, series = reactionsim.kinetics_series(order, k, c0, t_end)
3396
+ chart = reactionsim.ascii_chart({f"[A] (order {order})": series}, times, height=14, width=54)
3397
+ lines = [theme.text(l) for l in chart]
3398
+ lines.append("")
3399
+ lines.append(theme.faint(f"Integrated with RK4 · k={k} · [A]\u2080={c0} · t=0\u2192{t_end}s"))
3400
+ print(theme.panel(lines, title="kinetics playback", color=theme.CYAN, width=WIDTH))
3401
+
3402
+ def _react_equilibrium(self):
3403
+ kf_in = input(theme.dim(" forward rate kf [0.5] ▸ ")).strip() or "0.5"
3404
+ kb_in = input(theme.dim(" reverse rate kb [0.1] ▸ ")).strip() or "0.1"
3405
+ a0_in = input(theme.dim(" initial [A]0 [1.0] ▸ ")).strip() or "1.0"
3406
+ b0_in = input(theme.dim(" initial [B]0 [1.0] ▸ ")).strip() or "1.0"
3407
+ try:
3408
+ kf, kb, a0, b0 = float(kf_in), float(kb_in), float(a0_in), float(b0_in)
3409
+ except ValueError:
3410
+ print(theme.red(" ✗ Enter numeric values."))
3411
+ return
3412
+ times, A, B, C, D = reactionsim.equilibrium_series(kf, kb, a0, b0, 0.0, 0.0, 30)
3413
+ chart = reactionsim.ascii_chart({"[A]": A, "[B]": B, "[C]": C, "[D]": D}, times, height=14, width=54)
3414
+ lines = [theme.text(l) for l in chart]
3415
+ keq = (C[-1] * D[-1]) / (A[-1] * B[-1]) if A[-1] * B[-1] > 1e-9 else float("inf")
3416
+ lines.append("")
3417
+ lines.append(theme.faint(f"A + B \u21cc C + D · kf={kf}, kb={kb} · Keq \u2248 {keq:.4g} at t={times[-1]:.3g}s"))
3418
+ print(theme.panel(lines, title="equilibrium playback", color=theme.CYAN, width=WIDTH))
3419
+
3420
+ # ------------------------------------------------------------ export --
3421
+ def cmd_export(self, arg=None):
3422
+ print()
3423
+ if not self.history:
3424
+ print(theme.orange(" No notebooks solved yet this session — nothing to export."))
3425
+ print()
3426
+ return
3427
+ fmt = (arg or "").strip().lower()
3428
+ if fmt not in ("md", "markdown", "pdf"):
3429
+ print(theme.panel(
3430
+ [theme.text(f"{len(self.history)} notebook(s) ready to export."), "",
3431
+ theme.dim(" md — Markdown file"),
3432
+ theme.dim(" pdf — Formatted PDF" + ("" if report.PDF_AVAILABLE else " (reportlab not installed)"))],
3433
+ title="export", color=theme.PURPLE, width=WIDTH))
3434
+ fmt = input(theme.dim(" format [md/pdf] \u25b8 ")).strip().lower() or "md"
3435
+
3436
+ def do_export():
3437
+ out_dir = report.default_export_dir()
3438
+ stamp = time.strftime("%Y%m%d_%H%M%S")
3439
+ if fmt in ("pdf",):
3440
+ path = os.path.join(out_dir, f"cct_export_{stamp}.pdf")
3441
+ report.export_history_pdf(self.history, path)
3442
+ else:
3443
+ path = os.path.join(out_dir, f"cct_export_{stamp}.md")
3444
+ report.export_history_markdown(self.history, path)
3445
+ sound.play("success")
3446
+ print(theme.green(f" \u2713 Exported {len(self.history)} notebook(s) to {path}"))
3447
+
3448
+ try:
3449
+ do_export()
3450
+ except Exception as e:
3451
+ sound.play("error")
3452
+ errors.error_card("export", e, retry=do_export, width=WIDTH)
3453
+ print()
3454
+
3455
+ # --------------------------------------------------------------- tui --
3456
+ def cmd_tui(self):
3457
+ print()
3458
+ config = aicore.load_config()
3459
+ stats = {
3460
+ "solved": len(self.history),
3461
+ "provider": config.get("provider") or "none",
3462
+ "model": config.get("model") or "none",
3463
+ }
3464
+ ok, msg = tui.launch_dashboard(self.history, stats)
3465
+ if not ok:
3466
+ print(theme.orange(f" {msg}"))
3467
+ print()
3468
+ self.print_home()
3469
+
3470
+ print()
3471
+
3472
+ # ---------------------------------------------------------- composer --
3473
+ def cmd_composer(self):
3474
+ """Launches CCT's primary interface — the Textual chat UI in
3475
+ calc_terminal/ui/ (ConversationView + StickyComposer + streaming +
3476
+ inline permission cards). Kept as an explicit command so it's
3477
+ reachable from inside a fallback-CLI session too (e.g. a session
3478
+ that started piped but is now at a real terminal); normally
3479
+ main.py launches this UI directly at startup instead of the
3480
+ fallback loop. Separate from /tui's read-only history dashboard."""
3481
+ print()
3482
+ config = aicore.load_config()
3483
+ stats = {
3484
+ "solved": len(self.history),
3485
+ "provider": config.get("provider") or "none",
3486
+ "model": config.get("model") or "none",
3487
+ }
3488
+ from .ui.app import launch_chat_app
3489
+ ok, msg = launch_chat_app(self, self.history, stats)
3490
+ if not ok:
3491
+ print(theme.orange(f" {msg}"))
3492
+ print()
3493
+ self.print_home()
3494
+ print()
3495
+
3496
+ def cmd_copycode(self):
3497
+ block = code_editor.get_last_block()
3498
+ if not block:
3499
+ print(theme.orange(" No code block has been shown yet — ask /ai or /agent for some code first."))
3500
+ print()
3501
+ return
3502
+ ok, detail = code_editor.copy_to_clipboard(block["code"])
3503
+ n_lines = block["code"].count("\n") + 1
3504
+ lang = block["lang"] or "code"
3505
+ print()
3506
+ if ok:
3507
+ print(theme.green(f" ✓ Copied the last {lang} block ({n_lines} line(s)) to the clipboard (via {detail})."))
3508
+ else:
3509
+ saved_ok, saved_path = code_editor._save_buffer(block["code"].split("\n"), None)
3510
+ if saved_ok:
3511
+ print(theme.orange(f" Clipboard unavailable ({detail}).") + " " +
3512
+ theme.green(f"Saved instead to {saved_path} — open it to copy."))
3513
+ else:
3514
+ print(theme.red(f" ✗ Clipboard unavailable ({detail}), and saving a fallback also failed."))
3515
+ print()
3516
+
3517
+ def cmd_import(self, path=None):
3518
+ if not path:
3519
+ path = input(theme.dim(" Path to a text file, image, or .zip ▸ ")).strip()
3520
+ if not path:
3521
+ return
3522
+ path = os.path.expanduser(path)
3523
+ if not os.path.exists(path):
3524
+ print(theme.red(f" ✗ File not found: {path}"))
3525
+ return
3526
+ if path.lower().endswith(".zip"):
3527
+ self._cmd_import_zip(path)
3528
+ return
3529
+ agent.add_attachment(path)
3530
+ if aicore.is_image_file(path):
3531
+ config = aicore.load_config()
3532
+ if not config.get("provider"):
3533
+ print(theme.orange(f" ✓ Image noted: {path} (configure /ai or /model to have it analyzed)."))
3534
+ return
3535
+ print()
3536
+ run_steps(["Sending image to AI for analysis..."], step_time=0.3)
3537
+ result = aicore.query_ai_with_image(
3538
+ "Describe this image in detail; transcribe any code, chemistry, or math it contains.",
3539
+ path)
3540
+ print(theme.panel([theme.text(l) for l in result.split("\n")], title="import: image",
3541
+ color=theme.PURPLE, width=WIDTH))
3542
+ else:
3543
+ content, truncated = aicore.read_text_file_for_context(path)
3544
+ if content is None:
3545
+ print(theme.red(f" ✗ Could not read '{path}' as text."))
3546
+ return
3547
+ preview = "\n".join(content.split("\n")[:15])
3548
+ note = " (showing first 15 lines; truncated)" if truncated else " (showing first 15 lines)"
3549
+ print()
3550
+ print(theme.panel(
3551
+ [theme.green(f"✓ Imported {path}{note}", bold=True), ""] +
3552
+ [theme.text(l) for l in preview.split("\n")],
3553
+ title="import: text", color=theme.CYAN, width=WIDTH))
3554
+ print(theme.faint(" Available to /ai and /agent now via the read_attachment tool."))
3555
+ print()
3556
+
3557
+ def _cmd_import_zip(self, path):
3558
+ """Feature 6, continued: open a .zip like the OS file picker in
3559
+ the screenshot — list what's inside, extract it, and let the
3560
+ user pick one file from it to actually import (text or image),
3561
+ registering it as an attachment the AI/agent can read/see."""
3562
+ import zipfile
3563
+ try:
3564
+ with zipfile.ZipFile(path, "r") as zf:
3565
+ names = [n for n in zf.namelist() if not n.endswith("/")]
3566
+ except zipfile.BadZipFile:
3567
+ print(theme.red(f" ✗ '{path}' is not a valid zip file."))
3568
+ return
3569
+ if not names:
3570
+ print(theme.orange(" Zip archive is empty."))
3571
+ return
3572
+
3573
+ extract_dir = os.path.join(
3574
+ os.path.dirname(os.path.abspath(path)), ".cct_imports",
3575
+ os.path.splitext(os.path.basename(path))[0])
3576
+ os.makedirs(extract_dir, exist_ok=True)
3577
+ try:
3578
+ with zipfile.ZipFile(path, "r") as zf:
3579
+ zf.extractall(extract_dir)
3580
+ except Exception as e:
3581
+ print(theme.red(f" ✗ Could not extract archive: {e}"))
3582
+ return
3583
+
3584
+ agent.add_attachment(path)
3585
+ shown = names[:20]
3586
+ listing = [theme.green(f"✓ Opened {os.path.basename(path)} — {len(names)} file(s)", bold=True), ""]
3587
+ for i, n in enumerate(shown, 1):
3588
+ listing.append(theme.text(f" {i:>2}. {n}"))
3589
+ if len(names) > len(shown):
3590
+ listing.append(theme.faint(f" … {len(names) - len(shown)} more"))
3591
+ print()
3592
+ print(theme.panel(listing, title="import: zip", color=theme.CYAN, width=WIDTH))
3593
+ print(theme.faint(" Type a number to import that file too (text/image), or press Enter to skip."))
3594
+ choice = input(theme.dim(" ▸ ")).strip()
3595
+ if not choice:
3596
+ print(theme.faint(" Archive noted as an attachment; ask /agent or /ai about a file inside it by name."))
3597
+ print()
3598
+ return
3599
+ try:
3600
+ idx = int(choice) - 1
3601
+ if idx < 0 or idx >= len(shown):
3602
+ raise ValueError
3603
+ chosen_name = shown[idx]
3604
+ except ValueError:
3605
+ print(theme.red(" ✗ Not a valid selection."))
3606
+ return
3607
+ self.cmd_import(os.path.join(extract_dir, chosen_name))
3608
+
3609
+ # ----------------------------------------------- CAT Vision — spec 40 --
3610
+ def cmd_vision(self, arg=""):
3611
+ """CAT Vision — screen sharing + annotation + visual debugging.
3612
+
3613
+ /vision open Vision dashboard
3614
+ /vision start start capture (browser/PWA prompts for screen)
3615
+ /vision stop stop sharing
3616
+ /vision pause pause capture
3617
+ /vision analyze analyze annotated region
3618
+ /vision clear clear vision session data
3619
+ """
3620
+ sub = (arg or "").strip().lower()
3621
+ if not sub:
3622
+ print()
3623
+ print(theme.panel([
3624
+ theme.purple("CAT VISION", bold=True) + " " + theme.dim("Share → Annotate → Chat → Analyze → Fix → Verify"),
3625
+ "",
3626
+ theme.text("Screen sharing + annotation + AI visual debugging.", bold=True),
3627
+ theme.dim("Open CAT Vision from the main menu (eye icon) or the PWA at http://localhost:8765"),
3628
+ theme.dim("for full getDisplayMedia capture, annotation layer, and chat."),
3629
+ "",
3630
+ theme.cyan("Commands", bold=True),
3631
+ theme.dim(" /vision start Start capture (browser will prompt for screen/window/tab)"),
3632
+ theme.dim(" /vision stop Stop sharing and clean up streams"),
3633
+ theme.dim(" /vision pause Pause capture"),
3634
+ theme.dim(" /vision analyze Analyze current annotated region"),
3635
+ theme.dim(" /vision clear Clear vision session data"),
3636
+ "",
3637
+ theme.faint("Browser: getDisplayMedia() requires HTTPS/localhost + user permission."),
3638
+ theme.faint("PWA fallback: Upload Screenshot / Capture Image when screen capture unavailable."),
3639
+ ], title="vision", color=theme.CYAN, width=WIDTH))
3640
+ print()
3641
+ # also show live vision status if a session exists
3642
+ try:
3643
+ from calc_terminal.vision.session import VisionSessionManager
3644
+ mgr = VisionSessionManager()
3645
+ sessions = mgr.list_sessions()
3646
+ if sessions:
3647
+ for s in sessions[-3:]:
3648
+ print(theme.dim(f" session {s['id'][:8]} · {s['state']} · {s['frame_count']} frames"))
3649
+ except Exception:
3650
+ pass
3651
+ self.print_home()
3652
+ return
3653
+ if sub in ("start", "stop", "pause", "resume", "analyze", "clear", "screenshot"):
3654
+ print()
3655
+ print(theme.panel([
3656
+ theme.text(f"Vision: {sub}", bold=True),
3657
+ theme.dim("This control is handled by the CAT Vision panel / PWA."),
3658
+ theme.dim("Open the eye icon in the main menu, or FATTY CAT PWA at http://localhost:8765"),
3659
+ theme.dim(f"and use the {sub} controls there (browser manages screen permission)."),
3660
+ ], title="vision", color=theme.CYAN, width=WIDTH))
3661
+ print()
3662
+ self.print_home()
3663
+ return
3664
+ print(theme.orange(f"\n Unknown /vision subcommand '{sub}'. Try /vision\n"))
3665
+ self.print_home()
3666
+
3667
+ # -------------------------------------------------------------- exit --
3668
+ def cmd_exit(self):
3669
+ # v0.7.9 unified shutdown: /exit, /quit, exit, quit AND Ctrl+Q
3670
+ # all funnel through the ONE idempotent goodbye handler — never
3671
+ # a second, hand-rolled farewell panel (that duplication was
3672
+ # the double-goodbye glitch).
3673
+ try:
3674
+ from . import goodbye
3675
+ reason = "task" if self.history else "exit"
3676
+ goodbye.shutdown_session(self, reason=reason)
3677
+ except Exception:
3678
+ print()
3679
+ print(theme.panel(
3680
+ [theme.text("Session saved.", bold=True),
3681
+ theme.dim(f"{len(self.history)} notebook(s) solved this session."),
3682
+ "",
3683
+ theme.purple("Thanks for using CAT \u2014 see you next time.")],
3684
+ title="goodbye", color=theme.CYAN, width=WIDTH))
3685
+ self.running = False