cct-cli 0.7.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1321 @@
1
+ """
2
+ CCT fallback CLI — the plain print()/input() chat renderer, used ONLY
3
+ when the primary Textual UI (calc_terminal.ui.app.CCTApp) can't attach:
4
+ no real tty, redirected/piped stdout, an unsupported platform, or
5
+ Textual not installed. calc_terminal.app.App's main loop and its /ai
6
+ and /agent chat commands call into this module for that case.
7
+
8
+ Renders chat turns as highlighted background bubbles, a typing
9
+ indicator, and a streamed (word-by-word) response reveal — all built on
10
+ theme.py's colorama-backed background highlighting, no external chat
11
+ framework required. `fallback_input()` is the single input entry point
12
+ (see its docstring for the two call shapes it covers).
13
+ """
14
+
15
+ if __name__ == '__main__':
16
+ print("This is a library file and is not meant to be run directly.")
17
+ print("Please run 'python main.py' or 'python model.py' from the project root directory.")
18
+ import sys
19
+ sys.exit(1)
20
+
21
+ import random
22
+ import shutil
23
+ import sys
24
+ import time
25
+ import textwrap
26
+
27
+ from . import theme
28
+ from . import anim as _anim
29
+ from . import pet as _pet
30
+
31
+ try:
32
+ from prompt_toolkit import PromptSession
33
+ from prompt_toolkit.completion import Completer, Completion
34
+ from prompt_toolkit.formatted_text import ANSI, HTML
35
+ from prompt_toolkit.styles import Style as PTStyle
36
+ from prompt_toolkit.key_binding import KeyBindings
37
+ _HAS_PROMPT_TOOLKIT = True
38
+ except Exception:
39
+ _HAS_PROMPT_TOOLKIT = False
40
+
41
+ BUBBLE_W = 70
42
+
43
+ # Sentinel placed in the `below` list to mark the row that should be drawn
44
+ # with the animated rail-grow cap instead of printed as a static string.
45
+ _oc_rail_cap_obj = object()
46
+
47
+ # v0.5.6 — the home dashboard's `smart_input` delegates to the new
48
+ # OpenCode-style `opencode_input` when this is True (the default). Set to False
49
+ # to fall back to the v0.5.2 bordered box. Kept as a module flag so the choice
50
+ # is a one-line toggle, not a call-site change.
51
+ USE_OPENCODE_INPUT = True
52
+
53
+
54
+ def _wrap_block(s, width):
55
+ out = []
56
+ for para in s.split("\n"):
57
+ out.extend(textwrap.wrap(para, width=width) or [""])
58
+ return out
59
+
60
+
61
+ def _rjust_ansi(s, width):
62
+ pad = max(0, width - theme.vlen(s))
63
+ return " " * pad + s
64
+
65
+
66
+ def _ljust_ansi(s, width):
67
+ pad = max(0, width - theme.vlen(s))
68
+ return s + " " * pad
69
+
70
+
71
+ def user_message(msg, tag="you", term_width=None):
72
+ """A proper, right-aligned chat bubble for the user's message.
73
+
74
+ v0.5.6: for LONG user messages (more than one wrapped line), `run()` now
75
+ prefers `user_message_rail()` (the OpenCode left-rail panel) instead. This
76
+ short-message bubble is unchanged for back-compat and short inputs.
77
+ """
78
+ if term_width is None:
79
+ from . import engine
80
+ term_width = engine.WIDTH
81
+
82
+ print()
83
+ lines = _wrap_block(msg, BUBBLE_W - 4)
84
+ inner_w = max((len(l) for l in lines), default=0)
85
+
86
+ top = "╭" + "─" * (inner_w + 2) + "╮"
87
+ bot = "╰" + "─" * (inner_w + 2) + "╯"
88
+
89
+ print(_rjust_ansi(theme.gradient(top, theme.CYAN, theme.GREEN), term_width))
90
+ for l in lines:
91
+ row = "│ " + l.ljust(inner_w) + " │"
92
+ print(_rjust_ansi(theme.bg(row, theme.BG_USER, theme.TEXT), term_width))
93
+ print(_rjust_ansi(theme.gradient(bot, theme.CYAN, theme.GREEN), term_width))
94
+ print(_rjust_ansi(theme.dim(tag.upper()), term_width))
95
+ print()
96
+
97
+
98
+ # ---------------------------------------------------- v0.5.6 OpenCode kit --
99
+ # The new hybrid chat surface. These ADD to the functions above — nothing
100
+ # above changes signature or behavior. The renderers pick a look by message
101
+ # length so the chat reads well for both a one-liner and a full notebook:
102
+ # * short user msg -> right-aligned gradient bubble (CCT identity, unchanged)
103
+ # * long user msg -> OpenCode left-rail filled panel (`user_message_rail`)
104
+ # * short AI reply -> gradient bubble + `▣ tag · model · dur` footer
105
+ # * long AI reply -> borderless indented markdown-ish text + same footer
106
+
107
+ def _fmt_duration(seconds):
108
+ """Compact duration string for the avatar footer: '3s', '1m 4s', etc."""
109
+ s = max(0, int(seconds))
110
+ if s < 60:
111
+ return f"{s}s"
112
+ m, s = divmod(s, 60)
113
+ if m < 60:
114
+ return f"{m}m {s}s"
115
+ h, m = divmod(m, 60)
116
+ return f"{h}h {m}m"
117
+
118
+
119
+ def _ai_footer(tag, model_label, duration):
120
+ """The OpenCode-style assistant footer line: `▣ Tag · model · dur`, with
121
+ the avatar square in the primary accent, the tag in plain text, and the
122
+ model + duration dimmed. Indented to sit under the reply body."""
123
+ mark = theme.fg(theme.SQ_MARK, theme.OC_PRIMARY, bold=True)
124
+ parts = [theme.fg(tag, theme.TEXT, bold=True)]
125
+ if model_label:
126
+ parts.append(theme.fg(model_label, theme.OC_MUTED))
127
+ parts.append(theme.fg(_fmt_duration(duration), theme.OC_MUTED))
128
+ return " " + mark + " " + theme.fg(" \u00b7 ", theme.OC_MUTED).join(parts)
129
+
130
+
131
+ def thinking(label="CAT AI", model_label=None):
132
+ """The cleaner default 'thinking' indicator: OpenCode's braille spinner
133
+ (`⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` @80ms) inline with `{label} is thinking`, self-erasing.
134
+
135
+ A lighter, more modern alternative to the multi-line mascot
136
+ `typing_indicator`. Both exist; this is the v0.5.6 default.
137
+ """
138
+ print()
139
+ suffix = (" " + model_label) if model_label else ""
140
+ _anim.dots_spinner(f"{label}{suffix} is thinking", cycles=12)
141
+ # leave a faint blank so the reply that follows has breathing room
142
+ print()
143
+
144
+
145
+ def user_message_rail(msg, tag="you", term_width=None):
146
+ """OpenCode-style user message: a colored LEFT-rail filled panel (rail in
147
+ the primary accent, `OC_BG_PANEL` fill, NO right border). Used for longer
148
+ user messages; short ones keep the right-aligned bubble (`user_message`).
149
+
150
+ ┃ solve PV = nRT with n=2, T=300, P=1 (fill, no right edge)
151
+ ┃
152
+ """
153
+ if term_width is None:
154
+ from . import engine
155
+ term_width = engine.WIDTH
156
+ print()
157
+ # wrap to leave room for the rail + padding on the left
158
+ wrap_w = max(30, min(BUBBLE_W, term_width - 6))
159
+ lines = _wrap_block(msg, wrap_w)
160
+ inner_w = max((len(l) for l in lines), default=0)
161
+ rail = theme.fg(theme.HV, theme.OC_PRIMARY)
162
+ for l in lines:
163
+ row = l + " " * (inner_w - len(l))
164
+ print(" " + rail + " " + theme.bg(row, theme.OC_BG_PANEL, theme.TEXT))
165
+ # terminator row (the half-heavy up bar) + tag
166
+ print(" " + theme.rail_terminator(theme.OC_PRIMARY) + " " + theme.fg(tag.upper(), theme.OC_MUTED))
167
+ print()
168
+
169
+
170
+ def ai_message(text, tag="CAT AI", model_label=None, long=False,
171
+ speed=0.006, duration=0.0):
172
+ """The v0.5.6 adaptive AI reply renderer (full-hybrid mode).
173
+
174
+ * `long=False` (default, short replies): a PURPLE→CYAN gradient bubble with
175
+ a streamed word-by-word reveal (same feel as `ai_bubble_stream`), then a
176
+ `▣ {tag} · {model} · {dur}` avatar footer.
177
+ * `long=True` (notebook / derivation-style replies): BORDERLESS indented
178
+ text with simple markdown-ish coloring (ALL-CAPS lines → accent heading,
179
+ `code`/backtick spans → green, `**bold**` → orange), then the same footer.
180
+
181
+ `duration` is the measured reply time in seconds (callers pass it from the
182
+ agent run). If 0/unset, no duration segment is appended.
183
+
184
+ `ai_bubble_stream` is kept unchanged for back-compat; this is the preferred
185
+ renderer.
186
+ """
187
+ if not text or not text.strip():
188
+ print()
189
+ return
190
+
191
+ if long:
192
+ _ai_message_borderless(text, tag, model_label, duration)
193
+ else:
194
+ _ai_message_bubble(text, tag, model_label, duration, speed)
195
+
196
+
197
+ def _ai_message_bubble(text, tag, model_label, duration, speed):
198
+ """Short-reply path: gradient bubble + streamed reveal + avatar footer."""
199
+ lines = _wrap_block(text, BUBBLE_W - 4)
200
+ if not lines:
201
+ return
202
+ inner_w = max((len(l) for l in lines), default=0)
203
+ top = theme.gradient("\u256d" + "\u2500" * (inner_w + 2) + "\u256e", theme.PURPLE, theme.CYAN)
204
+ bot = theme.gradient("\u2570" + "\u2500" * (inner_w + 2) + "\u256f", theme.PURPLE, theme.CYAN)
205
+
206
+ print("\n " + theme.badge(tag, theme.BG_AI, fgcolor=(225, 210, 255)))
207
+ print(" " + top)
208
+ for l in lines:
209
+ # streamed reveal on the first line only; subsequent lines appear
210
+ # instantly (keeps short multi-line answers snappy without losing the
211
+ # "typing" feel on the opening line)
212
+ if l is lines[0] and sys.stdout.isatty() and speed > 0:
213
+ shown = ""
214
+ row_prefix = " " + theme.gradient("\u2502 ", theme.PURPLE, theme.CYAN)
215
+ row_suffix = theme.gradient(" \u2502", theme.PURPLE, theme.CYAN)
216
+ for word in l.split(" "):
217
+ shown = (shown + " " + word).strip()
218
+ sys.stdout.write("\r" + row_prefix +
219
+ theme.bg(_ljust_ansi(shown, inner_w), theme.BG_AI, theme.TEXT) +
220
+ row_suffix)
221
+ sys.stdout.flush()
222
+ time.sleep(speed)
223
+ row = " " + theme.gradient("\u2502 ", theme.PURPLE, theme.CYAN) + \
224
+ theme.bg(_ljust_ansi(l, inner_w), theme.BG_AI, theme.TEXT) + \
225
+ theme.gradient(" \u2502", theme.PURPLE, theme.CYAN)
226
+ sys.stdout.write("\r" + row + "\n")
227
+ print(" " + bot)
228
+ print(_ai_footer(tag, model_label, duration))
229
+ print()
230
+
231
+
232
+ _FENCE_RE = None
233
+
234
+
235
+ def _split_fences(text):
236
+ """Split AI reply text into a list of (\"prose\", text) / (\"code\", lang,
237
+ code) segments on ``` fences. Feeds both the borderless renderer (so
238
+ code blocks get their own bordered, highlighted box instead of being
239
+ flattened into plain indented lines) and the /copycode command (via
240
+ code_editor.remember_block)."""
241
+ global _FENCE_RE
242
+ if _FENCE_RE is None:
243
+ import re
244
+ _FENCE_RE = re.compile(r"```([a-zA-Z0-9_+-]*)\n(.*?)(?:```|\Z)", re.S)
245
+ segments = []
246
+ pos = 0
247
+ for m in _FENCE_RE.finditer(text):
248
+ if m.start() > pos:
249
+ segments.append(("prose", text[pos:m.start()]))
250
+ lang = m.group(1).strip().lower()
251
+ code = m.group(2).rstrip("\n")
252
+ segments.append(("code", lang, code))
253
+ pos = m.end()
254
+ if pos < len(text):
255
+ segments.append(("prose", text[pos:]))
256
+ if not segments:
257
+ segments = [("prose", text)]
258
+ return segments
259
+
260
+
261
+ def _render_code_block(lang, code, width=None):
262
+ """A real bordered, syntax-highlighted code block for AI replies —
263
+ replaces the old behavior of just flattening code into plain indented
264
+ text. Registers the block with code_editor so /copycode can grab it."""
265
+ from . import code_editor as _code_editor
266
+ _code_editor.remember_block(code, lang)
267
+ w = (width or BUBBLE_W) - 4
268
+ lines = code.split("\n")
269
+ highlighter = _code_editor.highlight_python if lang in ("", "py", "python") else theme.text
270
+ inner_w = max([len(l) for l in lines] + [10])
271
+ inner_w = min(inner_w, w)
272
+ top_label = f" {lang or 'code'} "
273
+ print(" " + theme.faint("┌─" + top_label + "─" * max(1, inner_w - len(top_label)) + "┐"))
274
+ for l in lines:
275
+ shown = l if len(l) <= inner_w else l[:inner_w - 1] + "\u2026"
276
+ print(" " + theme.faint("│ ") + highlighter(shown))
277
+ print(" " + theme.faint("└" + "─" * (inner_w + 1) + "┘"))
278
+ print(" " + theme.faint(f"{len(lines)} line(s) \u2022 /copycode to copy this block"))
279
+
280
+
281
+ def _markdown_colorize(line):
282
+ """Cheap inline markdown-ish coloring for the borderless (long) reply:
283
+ * a fully-UPPERCASE (>=3 char) line -> accent heading
284
+ * `spans in backticks` -> green
285
+ * **spans in double-asterisks** -> orange bold
286
+ Anything else is plain TEXT. Only handles simple, non-overlapping spans —
287
+ good enough for notebook-style prose; falls back to plain on no match."""
288
+ import re
289
+ if line.strip() and len(line.strip()) >= 3 and line.strip() == line.strip().upper() and any(c.isalpha() for c in line):
290
+ return theme.fg(line, theme.OC_ACCENT, bold=True)
291
+ out = line
292
+ out = re.sub(r"`([^`]+)`", lambda m: theme.fg(m.group(1), theme.GREEN, bold=True), out)
293
+ out = re.sub(r"\*\*([^*]+)\*\*", lambda m: theme.fg(m.group(1), theme.ORANGE, bold=True), out)
294
+ return out
295
+
296
+
297
+ def _ai_message_borderless(text, tag, model_label, duration):
298
+ """Long-reply path: borderless indented markdown-ish text + footer, the
299
+ OpenCode assistant-message look. No box, no fill — just clean flowing
300
+ text with a `▣ tag · model · dur` footer at the end.
301
+
302
+ v0.5.6: any ```fenced``` code block in the reply gets pulled out and
303
+ rendered as its own bordered, syntax-highlighted box (see
304
+ _render_code_block) instead of being flattened into plain indented
305
+ lines — this is what was showing raw, unhighlighted HTML/code before.
306
+ """
307
+ print()
308
+ gutter = " "
309
+ for seg in _split_fences(text):
310
+ if seg[0] == "code":
311
+ _, lang, code = seg
312
+ print()
313
+ _render_code_block(lang, code)
314
+ print()
315
+ continue
316
+ para_text = seg[1]
317
+ for para in para_text.split("\n"):
318
+ if para.strip() == "":
319
+ print()
320
+ continue
321
+ for wl in _wrap_block(para, BUBBLE_W - 4):
322
+ print(gutter + _markdown_colorize(wl))
323
+ print()
324
+ print(" " + _ai_footer(tag, model_label, duration).lstrip())
325
+ print()
326
+
327
+
328
+ def is_long_reply(text, term_width=None):
329
+ """Heuristic the chat loops use to pick bubble vs borderless for the AI
330
+ reply: more than 4 wrapped lines OR contains an ALL-CAPS section header
331
+ (a hallmark of the agent's notebook output)."""
332
+ if not text:
333
+ return False
334
+ lines = _wrap_block(text, BUBBLE_W - 4)
335
+ if len(lines) > 4:
336
+ return True
337
+ import re
338
+ for ln in text.split("\n"):
339
+ s = ln.strip()
340
+ if s and len(s) >= 3 and s == s.upper() and any(c.isalpha() for c in s):
341
+ return True
342
+ return False
343
+
344
+
345
+ def is_long_question(msg):
346
+ """Heuristic for user messages: pick the rail panel vs the bubble based on
347
+ wrapped-line count (>1 -> rail panel)."""
348
+ if not msg:
349
+ return False
350
+ return len(_wrap_block(msg, BUBBLE_W - 4)) > 1
351
+
352
+
353
+ def render_user(msg, tag="you", term_width=None):
354
+ """Adaptive user message: rail panel for long, bubble for short. This is
355
+ what `run()` calls now instead of `user_message` directly."""
356
+ if is_long_question(msg):
357
+ user_message_rail(msg, tag=tag, term_width=term_width)
358
+ else:
359
+ user_message(msg, tag=tag, term_width=term_width)
360
+
361
+
362
+ def render_ai(text, tag="CAT AI", model_label=None, duration=0.0, term_width=None):
363
+ """Adaptive AI message: borderless for long replies, bubble for short.
364
+ `term_width` accepted for API symmetry; the renderers size off BUBBLE_W."""
365
+ long = is_long_reply(text, term_width)
366
+ ai_message(text, tag=tag, model_label=model_label, long=long, duration=duration)
367
+
368
+
369
+ def typing_indicator(label="Chemistry AI", cycles=3, term_width=None):
370
+ """A multi-line, animated bot 'thinking' indicator.
371
+
372
+ v0.5.6: kept as the mascot-style alternate; the cleaner default is now
373
+ `thinking()` (OpenCode's braille spinner). Both are available.
374
+ """
375
+ bot_frames = [
376
+ [" (o.o) ", "/[_]\\", " ] [ "],
377
+ [" (o.O) ", "/[_]\\", " ] [ "],
378
+ [" (O.o) ", "/[_]\\", " ] [ "],
379
+ [" (-.-) ", "/[_]\\", " ] [ "],
380
+ ]
381
+ thinking_frames = [". ", ".. ", "..."]
382
+
383
+ print() # Start on a new line
384
+ total_frames = cycles * 4
385
+ num_lines_to_clear = len(bot_frames[0]) + 1
386
+
387
+ for i in range(total_frames):
388
+ bot_art = bot_frames[i % len(bot_frames)]
389
+ dots = thinking_frames[i % len(thinking_frames)]
390
+
391
+ if i > 0:
392
+ sys.stdout.write(f"\033[{num_lines_to_clear}A")
393
+
394
+ max_art_width = max(theme.vlen(l) for l in bot_art)
395
+ for line in bot_art:
396
+ padded_line = theme.vpad(line, max_art_width, "center")
397
+ print(" " + theme.gradient(padded_line, theme.CYAN, theme.PURPLE))
398
+
399
+ pill = theme.badge(f"{label} is thinking{dots}", theme.BG_AI, fgcolor=(225, 210, 255))
400
+ print(" " + pill + " " * 10)
401
+
402
+ sys.stdout.flush()
403
+ time.sleep(0.15)
404
+
405
+ # Erase the animation
406
+ sys.stdout.write(f"\033[{num_lines_to_clear}A")
407
+ for _ in range(num_lines_to_clear):
408
+ sys.stdout.write(" " * 60 + "\n")
409
+ sys.stdout.write(f"\033[{num_lines_to_clear}A")
410
+ sys.stdout.flush()
411
+
412
+
413
+ def ai_bubble_stream(text_block, width=BUBBLE_W, speed=0.008, tag="Chemistry AI"):
414
+ """Stream an AI response inside a bubble, next to a bot icon."""
415
+ raw_bot_art = [" (o.o) ", "/[_]\\" , " ] [ "]
416
+ max_bot_width = max(theme.vlen(l) for l in raw_bot_art)
417
+ bot_art = [
418
+ theme.gradient(theme.vpad(l, max_bot_width, "center"), theme.CYAN, theme.PURPLE)
419
+ for l in raw_bot_art
420
+ ]
421
+ bot_width = theme.vlen(bot_art[0])
422
+
423
+ lines = _wrap_block(text_block, width - 4)
424
+ if not lines: return
425
+
426
+ inner_w = max((len(l) for l in lines), default=0)
427
+ top = theme.gradient("╭" + "─" * (inner_w + 2) + "╮", theme.PURPLE, theme.CYAN)
428
+ bot = theme.gradient("╰" + "─" * (inner_w + 2) + "╯", theme.PURPLE, theme.CYAN)
429
+
430
+ print("\n " + theme.badge(tag, theme.BG_AI, fgcolor=(225, 210, 255)))
431
+ print(" " + bot_art[0] + " " + top)
432
+
433
+ for i, l in enumerate(lines):
434
+ bot_line = bot_art[i+1] if i+1 < len(bot_art) else " " * bot_width
435
+ line_prefix = " " + bot_line + " " + theme.gradient("│ ", theme.PURPLE, theme.CYAN)
436
+
437
+ sys.stdout.write(line_prefix)
438
+ shown = ""
439
+ for word in l.split(" "):
440
+ shown = (shown + " " + word).strip()
441
+ sys.stdout.write("\r" + line_prefix +
442
+ theme.bg(_ljust_ansi(shown, inner_w), theme.BG_AI) +
443
+ theme.gradient(" │", theme.PURPLE, theme.CYAN))
444
+ sys.stdout.flush()
445
+ time.sleep(speed)
446
+
447
+ sys.stdout.write("\r" + line_prefix +
448
+ theme.bg(_ljust_ansi(l, inner_w), theme.BG_AI) +
449
+ theme.gradient(" │", theme.PURPLE, theme.CYAN) + "\n")
450
+
451
+ bot_line_for_bottom = bot_art[len(lines) + 1] if len(lines) + 1 < len(bot_art) else " " * bot_width
452
+ print(" " + bot_line_for_bottom + " " + bot)
453
+
454
+ for i in range(len(lines) + 2, len(bot_art)):
455
+ print(" " + bot_art[i])
456
+ print()
457
+
458
+
459
+ def highlight_line(label, value, bgcolor=theme.BG_INFO, fgcolor=(230, 240, 255)):
460
+ """One highlighted key/value row — used for live readouts (quantum
461
+ numbers, simulation stats, etc.)."""
462
+ return theme.badge(label, bgcolor, fgcolor) + " " + theme.text(value)
463
+
464
+
465
+ TIPS = [
466
+ "\"first order half life\"", "\"mole concept\"", "\"derive second order\"",
467
+ "/solve \u2014 39 formulas, solved for any variable", "/game \u2014 learn chemistry by playing",
468
+ "/atomsim Fe \u2014 watch an iron atom spin up", "/orbitals 3d \u2014 live electron cloud",
469
+ "/agent \u2014 an AI that solves, plots & simulates", "\"arrhenius equation\"",
470
+ "/graph \u2014 animated kinetics & titration curves",
471
+ ]
472
+ _tip_cursor = [0]
473
+
474
+
475
+ def _next_tip():
476
+ i = _tip_cursor[0] % len(TIPS)
477
+ _tip_cursor[0] += 1
478
+ return TIPS[i]
479
+
480
+
481
+ def _xml_escape(s):
482
+ """prompt_toolkit's HTML() parses its input as real XML, so any
483
+ raw '&' (or '<'/'>') in dynamic text — like a TIPS entry mentioning
484
+ 'plots & simulates' — breaks it with an ExpatError. Escape before
485
+ ever putting user-facing text inside HTML(...)."""
486
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
487
+
488
+
489
+ def _live_width(fallback=78):
490
+ """Re-measure the terminal width right before drawing the input box,
491
+ instead of trusting the WIDTH constant computed once at startup.
492
+ Startup-only sizing drifts out of alignment whenever the actual
493
+ terminal/embedding pane is a different size than it was at launch
494
+ (a resized window, or a web-embedded pty that reports its size a
495
+ beat after the app starts) — this keeps the box's borders and the
496
+ hint row underneath it consistent with whatever the box was
497
+ actually drawn at."""
498
+ try:
499
+ from . import engine
500
+ return engine.term_width(fallback=fallback)
501
+ except Exception:
502
+ return fallback
503
+
504
+
505
+ def _erase_echoed_input(prefix_w, raw, width):
506
+ """Erase the terminal's own live-typed echo of `raw` so that whatever
507
+ gets printed right after (the chat bubble, the box's closing rows)
508
+ is the ONLY copy of the text left on screen.
509
+
510
+ Fixes the reported 'double text' bug: the previous version of this
511
+ function moved the cursor up `rows_used - 1` lines before clearing,
512
+ which is an off-by-one — for the single most common case, a short
513
+ one-line answer (`rows_used == 1`), that's `0` lines, i.e. no
514
+ cursor movement at all. The clear-to-end-of-screen that followed
515
+ then had nothing above it left to erase (the cursor was already on
516
+ the fresh line below, since Enter had just printed a newline), so
517
+ the raw echoed text stayed on screen — and then got printed a
518
+ second time right after it. Moving up the full `rows_used` fixes
519
+ it for every input length, not just the multi-line/pasted case this
520
+ was originally written for.
521
+ """
522
+ if not sys.stdout.isatty():
523
+ return
524
+ try:
525
+ term_cols = shutil.get_terminal_size(fallback=(width, 24)).columns
526
+ except Exception:
527
+ term_cols = width
528
+ total = prefix_w + len(raw)
529
+ rows_used = max(1, -(-total // max(1, term_cols)))
530
+ sys.stdout.write(f"\x1b[{rows_used}A")
531
+ sys.stdout.write("\r\x1b[0J")
532
+ sys.stdout.flush()
533
+
534
+
535
+ def _reflow_submitted_line(raw, row_prefix, width):
536
+ """Clear the terminal's own raw echo of the just-submitted line.
537
+
538
+ This used to also re-print the question inline (icon + word-wrapped
539
+ text) directly under the box — which, combined with the chat bubble
540
+ printed a moment later by `user_message()`, was the second half of
541
+ the 'double text' bug: the same line rendered twice, in two
542
+ different styles. Now this only erases; the single canonical
543
+ on-screen copy of what was typed is the chat bubble.
544
+ """
545
+ _erase_echoed_input(theme.vlen(row_prefix), raw, width)
546
+
547
+
548
+ def _clear_input_row_keep_below(prefix_w, raw, width, rows_below):
549
+ """Erase just the plain, unstyled terminal echo of the submitted
550
+ input line, leaving whatever is already drawn *beneath* it (the
551
+ box's meta row, bottom border, hints, tip) untouched, then park
552
+ the cursor on a clean line past all of it.
553
+
554
+ Used by `premium_input`, which (as of v0.5.5) draws the rest of
555
+ the box *before* reading input — saving the cursor position with
556
+ `\\x1b[s` right where the input line goes, printing everything
557
+ below it, then jumping back up to that saved spot with `\\x1b[u`
558
+ so the box looks fully closed (border, meta row, hints, tip) from
559
+ the moment it appears, instead of only after Enter is pressed.
560
+
561
+ That pre-draw means the old `_erase_echoed_input`'s clear-to-end-
562
+ of-screen (`\\x1b[0J`) can't be reused here — it would wipe out
563
+ the pre-drawn rows along with the echoed text. This clears only
564
+ the row(s) the echo itself occupies, and — rather than counting
565
+ relative cursor moves (the source of the off-by-one bug the old
566
+ function's docstring describes) — always moves via the saved
567
+ anchor, so it can't drift.
568
+ """
569
+ if not sys.stdout.isatty():
570
+ return
571
+ try:
572
+ term_cols = shutil.get_terminal_size(fallback=(width, 24)).columns
573
+ except Exception:
574
+ term_cols = width
575
+ total = prefix_w + len(raw)
576
+ rows_used = max(1, -(-total // max(1, term_cols)))
577
+
578
+ sys.stdout.write("\x1b[u")
579
+ for i in range(rows_used):
580
+ sys.stdout.write("\x1b[2K")
581
+ if i < rows_used - 1:
582
+ sys.stdout.write("\x1b[1B")
583
+ sys.stdout.write("\x1b[u")
584
+ sys.stdout.write(f"\x1b[{1 + rows_below}B\r")
585
+ sys.stdout.flush()
586
+
587
+
588
+ def ask(prompt_str, width=None):
589
+ """A drop-in replacement for `input(prompt_str)` for any call site
590
+ that shows the result again right after via `user_message()` (the
591
+ /agent and /ai chat loops). Reads the line normally, then erases
592
+ the terminal's own echo of it so the bubble printed next is the
593
+ only copy on screen — same fix as the main input box, for the
594
+ other two places the same 'double text' pattern showed up."""
595
+ if width is None:
596
+ width = _live_width()
597
+ raw = input(prompt_str)
598
+ _erase_echoed_input(theme.vlen(prompt_str), raw, width)
599
+ return raw
600
+
601
+
602
+ WALK_FRAMES = ["\\o/", " o<", "/o\\", ">o "]
603
+
604
+
605
+ def animate_mascot_intro(width=78, duration=0.9):
606
+ """A tiny ASCII mascot crosses the top of the chatbox before it
607
+ appears — pure single-width ASCII (no emoji) so it can never throw
608
+ off column alignment on any terminal/font.
609
+
610
+ v0.5.6: the mascot is a real living pet (see pet.py) — its state
611
+ depends on how long it's been since the user last did something:
612
+ quick back-to-back turns make it *run*, a normal pace has it
613
+ *walk*, and leaving the terminal idle for a while puts it to
614
+ *sleep* (drawn in place with a little zzz instead of crossing the
615
+ screen).
616
+ """
617
+ if not sys.stdout.isatty():
618
+ return
619
+ state = _pet.current_state()
620
+ if state == "sleep":
621
+ for _ in range(10):
622
+ line = _pet.frame_line(width, state="sleep")
623
+ sys.stdout.write("\r" + line + " " * 10)
624
+ sys.stdout.flush()
625
+ time.sleep(0.2)
626
+ sys.stdout.write("\r" + " " * (width + 10) + "\r")
627
+ sys.stdout.flush()
628
+ return
629
+ span = max(10, width - 12)
630
+ delay = 0.018 if state == "run" else 0.03
631
+ steps = max(8, min(span, int(duration / delay)))
632
+ for _ in range(steps):
633
+ line = _pet.frame_line(width, state=state)
634
+ sys.stdout.write("\r" + line + " " * 6)
635
+ sys.stdout.flush()
636
+ time.sleep(delay)
637
+ sys.stdout.write("\r" + " " * (width + 6) + "\r")
638
+ sys.stdout.flush()
639
+
640
+
641
+ def _oc_geometry(term_width):
642
+ """Box width + centering offset for the v0.5.2 chatbox.
643
+
644
+ The reference screenshot is a centered box that sits with clean,
645
+ even side margins (not edge-to-edge, not cramped). v0.5.2 tunes the
646
+ proportions so the box is the same calm width on every reasonable
647
+ terminal:
648
+
649
+ * ~72% of the terminal width — wide enough to read as a real,
650
+ substantial input object (like the reference) while keeping
651
+ generous breathing room on both sides.
652
+ * clamped to 58-76 columns so it never collapses on a thin
653
+ embedded pane or stretches too wide on a big monitor.
654
+ * centered with equal left/right margins.
655
+ """
656
+ box_w = max(58, min(76, int(term_width * 0.72)))
657
+ if box_w > term_width - 6:
658
+ box_w = max(40, term_width - 6)
659
+ left_pad = max(0, (term_width - box_w) // 2)
660
+ return box_w, left_pad
661
+
662
+
663
+ def _oc_top(box_w, left_pad):
664
+ """Top edge of the v0.5.2 box: a soft left→right navy gradient on
665
+ angular ┌─…─┐ corners — premium depth without becoming a rainbow."""
666
+ return " " * left_pad + theme.soft_border_line(
667
+ box_w, theme.ATL, theme.AH, theme.ATR,
668
+ theme.BORDER_SOFT_A, theme.BORDER_SOFT_B)
669
+
670
+
671
+ def _oc_bottom(box_w, left_pad):
672
+ """Bottom edge — same gradient, mirrored with └─…─┘ corners."""
673
+ return " " * left_pad + theme.soft_border_line(
674
+ box_w, theme.ABL, theme.AH, theme.ABR,
675
+ theme.BORDER_SOFT_B, theme.BORDER_SOFT_A)
676
+
677
+
678
+ def _oc_row_prefix(left_pad, icon):
679
+ """Prompt for the live input row: left border + icon. There's no
680
+ live right-hand border while typing — a single-line prompt can't
681
+ track how much to pad on every keystroke the way a real redrawing
682
+ TUI (which is what OpenCode itself is, under the hood) can. The
683
+ box closes with all four sides once the line is submitted, below."""
684
+ return " " * left_pad + theme.fg(theme.AV, theme.BORDER_SOFT_A) + " " + icon + " "
685
+
686
+
687
+ def _oc_meta_row(box_w, left_pad, mode_label, model_label, effort_label):
688
+ """The row under the input line, closed on both sides: mode label
689
+ in bold vivid blue (like 'Build' in the reference image), model
690
+ name dimmed next to it, effort/status word right-aligned in
691
+ amber. This is the second row of the two-row chatbox."""
692
+ inner = box_w - 2
693
+ budget = max(10, inner - 3)
694
+
695
+ left_plain = mode_label + (" " + model_label if model_label else "")
696
+ right_plain = effort_label or ""
697
+
698
+ if len(left_plain) + len(right_plain) + 1 > budget:
699
+ avail_right = max(4, budget - len(left_plain) - 1)
700
+ if len(right_plain) > avail_right:
701
+ right_plain = right_plain[:max(1, avail_right - 1)].rstrip() + "\u2026"
702
+ avail_left = max(4, budget - len(right_plain) - 1)
703
+ if len(left_plain) > avail_left:
704
+ left_plain = left_plain[:max(1, avail_left - 1)].rstrip() + "\u2026"
705
+ model_label = None
706
+
707
+ gap = max(1, budget - len(left_plain) - len(right_plain))
708
+ left_colored = theme.fg(mode_label, theme.ACCENT_VIVID, bold=True)
709
+ if model_label:
710
+ left_colored += theme.fg(" " + model_label, theme.META_DIM)
711
+ body = " " + left_colored + " " * gap + theme.fg(right_plain, theme.STATUS_AMBER, bold=True) + " "
712
+ border_l = theme.fg(theme.AV, theme.BORDER_SOFT_A)
713
+ border_r = theme.fg(theme.AV, theme.BORDER_SOFT_B)
714
+ return " " * left_pad + border_l + body + border_r
715
+
716
+
717
+ DEFAULT_HINTS = (("tab", "suggestions"), ("/help", "commands"))
718
+
719
+
720
+ def _oc_hint_row(box_w, left_pad, hints=DEFAULT_HINTS):
721
+ """Keyboard-shortcut hints, right-aligned under the box's right
722
+ edge, printed entirely outside the border — exactly where the
723
+ reference screenshot puts 'tab agents ctrl+p commands'.
724
+
725
+ Each key cap is rendered faint+bold (like a physical key) and its
726
+ label dimmed, with a wider 4-space gutter between pairs so the row
727
+ breathes instead of crowding."""
728
+ text_plain = " ".join(f"{k} {l}" for k, l in hints)
729
+ colored = " ".join(
730
+ theme.fg(k, theme.HINT_FAINT, bold=True) + " " + theme.fg(l, theme.HINT_LABEL)
731
+ for k, l in hints)
732
+ total_w = left_pad + box_w
733
+ pad = max(1, total_w - len(text_plain))
734
+ return " " * pad + colored
735
+
736
+
737
+ def _oc_tip_row(left_pad, tip_text):
738
+ """The 'try: ...' callout under the box, left-aligned under its
739
+ left edge with a colored dot — same spot and style as the
740
+ reference screenshot's '\u25cf Tip Run /share...' line."""
741
+ return " " * left_pad + theme.pulse_dot() + " " + theme.dim("try: ") + theme.faint(tip_text)
742
+
743
+
744
+ def premium_input(width=78, show_tip=True, mode_label="NOTEBOOK", model_label=None,
745
+ effort_label="engine ready", animate=True, hints=DEFAULT_HINTS,
746
+ right_hint=None):
747
+ """The v0.5.2 chatbox: a narrow, centered, angular-cornered box
748
+ with a soft navy gradient border — same shape, proportions and
749
+ 2-row layout as the reference design, but with a refined premium
750
+ feel (calmer palette, sharper corners, better-proportioned width):
751
+
752
+ ┌──────────────────────────────────────────────────┐
753
+ │ ⚗ Ask Chemistry… │
754
+ │ NOTEBOOK GEMINI-FLASH-LITE ready │
755
+ └──────────────────────────────────────────────────┘
756
+ tab suggestions /help commands
757
+ ● try: "arrhenius equation"
758
+
759
+ This is the plain-input fallback (no live suggestion dropdown);
760
+ `smart_input()` layers that on top when prompt_toolkit is
761
+ installed. `right_hint` is accepted for backwards compatibility
762
+ and used as `effort_label` if that isn't given explicitly.
763
+
764
+ v0.5.5: the meta row, bottom border, hint row and tip line are
765
+ now drawn *before* `input()` is even called — matching the
766
+ reference design, where the whole box (closed on all sides, with
767
+ hints and a tip underneath) is visible from the moment it
768
+ appears, not just after Enter is pressed. The cursor position is
769
+ saved right where the input line goes, everything below it is
770
+ printed, then the cursor jumps back up to that saved spot so
771
+ `input()` reads there with the rest of the box already on screen.
772
+ """
773
+ if right_hint and effort_label == "engine ready":
774
+ effort_label = right_hint
775
+ width = _live_width(fallback=width)
776
+ if animate:
777
+ animate_mascot_intro(width)
778
+
779
+ box_w, left_pad = _oc_geometry(width)
780
+ icon = theme.fg(theme.FLASK, theme.ACCENT_VIVID, bold=True)
781
+ row_prefix = _oc_row_prefix(left_pad, icon)
782
+
783
+ print(_oc_top(box_w, left_pad))
784
+
785
+ tip_text = _next_tip() if show_tip else None
786
+ below = [
787
+ _oc_meta_row(box_w, left_pad, mode_label, model_label, effort_label),
788
+ _oc_bottom(box_w, left_pad),
789
+ _oc_hint_row(box_w, left_pad, hints),
790
+ ]
791
+ if tip_text:
792
+ below.append(_oc_tip_row(left_pad, tip_text))
793
+
794
+ interactive = sys.stdout.isatty()
795
+ if interactive:
796
+ sys.stdout.write("\x1b[s") # anchor: exactly where the input line goes
797
+ print() # placeholder row `input()` will read on
798
+ for line in below:
799
+ print(line)
800
+ sys.stdout.write("\x1b[u") # jump back up to the anchor
801
+ sys.stdout.flush()
802
+
803
+ try:
804
+ raw = input(row_prefix)
805
+ except (EOFError, KeyboardInterrupt):
806
+ if interactive:
807
+ sys.stdout.write(f"\x1b[{1 + len(below)}B\r")
808
+ sys.stdout.flush()
809
+ else:
810
+ print(_oc_bottom(box_w, left_pad))
811
+ raise
812
+
813
+ if interactive:
814
+ _clear_input_row_keep_below(theme.vlen(row_prefix), raw, width, len(below))
815
+ else:
816
+ # No TTY (piped/non-interactive output, e.g. tests): the
817
+ # cursor tricks above are meaningless, so fall back to the
818
+ # original linear print-after-input order.
819
+ for line in below:
820
+ print(line)
821
+ return raw
822
+
823
+
824
+ if _HAS_PROMPT_TOOLKIT:
825
+ class _SuggestCompleter(Completer):
826
+ """Match-anywhere completer that powers the live suggestion
827
+ dropdown: as soon as what's typed appears anywhere inside a
828
+ known command or question, it shows up as a pickable row."""
829
+
830
+ def __init__(self, items):
831
+ self.items = items or [] # list of (trigger_text, description)
832
+
833
+ def get_completions(self, document, complete_event):
834
+ typed = document.text_before_cursor.strip().lower()
835
+ if not typed:
836
+ return
837
+ seen = set()
838
+ for trigger, desc in self.items:
839
+ if trigger in seen:
840
+ continue
841
+ if typed in trigger.lower():
842
+ seen.add(trigger)
843
+ yield Completion(
844
+ trigger,
845
+ start_position=-len(document.text_before_cursor),
846
+ display=trigger,
847
+ display_meta=desc,
848
+ )
849
+
850
+ def _animated_fill(buf, text, speed=0.012):
851
+ """Type a fully-picked suggestion into the buffer letter by
852
+ letter — the closest a terminal can get to a smooth CSS-style
853
+ fill-in animation when a suggestion is accepted with Tab."""
854
+ buf.delete_before_cursor(len(buf.text))
855
+ for ch in text:
856
+ buf.insert_text(ch)
857
+ sys.stdout.flush()
858
+ time.sleep(speed)
859
+
860
+
861
+ _PT_STYLE = None
862
+ if _HAS_PROMPT_TOOLKIT:
863
+ # v0.5.2 dropdown palette — tuned to sit inside the soft-navy box
864
+ # instead of fighting it: the selected row lifts to the same vivid
865
+ # blue as the mode word, unselected rows stay calm on the Tokyo
866
+ # Night background.
867
+ _PT_STYLE = PTStyle.from_dict({
868
+ "": "fg:#c0caf5",
869
+ "completion-menu": "bg:#16161e fg:#c0caf5",
870
+ "completion-menu.completion": "bg:#16161e fg:#9aa5d8",
871
+ "completion-menu.completion.current": "bg:#2a3866 fg:#ffffff bold",
872
+ "completion-menu.meta.completion": "bg:#16161e fg:#6a78a0 italic",
873
+ "completion-menu.meta.completion.current": "bg:#2a3866 fg:#dfe5ff",
874
+ "scrollbar.background": "bg:#16161e",
875
+ "scrollbar.button": "bg:#3a466e",
876
+ "bottom-toolbar": "bg:#12121a fg:#6e78a0",
877
+ "placeholder": "fg:#565f89",
878
+ })
879
+
880
+
881
+ # ----------------------------------------------------- opencode geometry --
882
+ # The v0.5.6 OpenCode-style input: a heavy left `┃` rail + `╹` terminator with
883
+ # a `▀` underline cap, a meta row (vivid mode word + dim model + right-aligned
884
+ # status), a hint row, and a `● Tip` line. Same proportions as `premium_input`
885
+ # (re-uses `_oc_geometry`) but with OpenCode's borderless-left-rail identity.
886
+
887
+ def _oc_rail_prefix(left_pad):
888
+ """The live input row prefix: left margin + heavy rail `┃` + 2-col gutter
889
+ + the flask icon. Mirrors `_oc_row_prefix` but with the heavy rail instead
890
+ of the angular left border."""
891
+ icon = theme.fg(theme.FLASK, theme.OC_PRIMARY, bold=True)
892
+ return " " * left_pad + theme.fg(theme.HV, theme.OC_PRIMARY) + " " + icon + " "
893
+
894
+
895
+ def _oc_rail_meta_row(box_w, left_pad, mode_label, model_label, effort_label):
896
+ """OpenCode meta row: rail + vivid mode word + dim model (left), status
897
+ right-aligned, no right border. Same budget/truncation logic as
898
+ `_oc_meta_row` so long model names still fit."""
899
+ budget = max(10, box_w - 3)
900
+ left_plain = mode_label + (" " + model_label if model_label else "")
901
+ right_plain = effort_label or ""
902
+ if len(left_plain) + len(right_plain) + 1 > budget:
903
+ avail_right = max(4, budget - len(left_plain) - 1)
904
+ if len(right_plain) > avail_right:
905
+ right_plain = right_plain[:max(1, avail_right - 1)].rstrip() + "\u2026"
906
+ avail_left = max(4, budget - len(right_plain) - 1)
907
+ if len(left_plain) > avail_left:
908
+ left_plain = left_plain[:max(1, avail_left - 1)].rstrip() + "\u2026"
909
+ model_label = None
910
+ gap = max(1, budget - len(left_plain) - len(right_plain))
911
+ left_colored = theme.fg(mode_label, theme.OC_PRIMARY, bold=True)
912
+ if model_label:
913
+ left_colored += theme.fg(" " + model_label, theme.OC_MUTED)
914
+ rail = theme.fg(theme.HV, theme.OC_PRIMARY)
915
+ body = " " + left_colored + " " * gap + theme.fg(right_plain, theme.OC_ACCENT, bold=True)
916
+ return " " * left_pad + rail + body
917
+
918
+
919
+ def _oc_rail_cap_row(box_w, left_pad):
920
+ """The `▀` underline cap + `╹` terminator: closes the input box visually
921
+ under the meta row, exactly like OpenCode's cap row."""
922
+ inner = box_w - 2
923
+ return " " * left_pad + theme.rail_terminator(theme.OC_PRIMARY) + \
924
+ theme.fg(theme.HALF_UP * inner, theme.OC_PRIMARY)
925
+
926
+
927
+ def _oc_rail_cap_animated(box_w, left_pad, color=None, delay=0.006):
928
+ """Animated version of the cap row: the `╹` terminator prints instantly,
929
+ then the `▀` half-block cap GROWS left→right across the box width (via
930
+ `anim.rail_grow`) so the input box visibly *draws shut* instead of just
931
+ popping in. Falls back to the static `_oc_rail_cap_row` when animations
932
+ are off or there's no TTY."""
933
+ color = color or theme.OC_PRIMARY
934
+ inner = box_w - 2
935
+ # print the terminator + leave the cursor at the start of the cap run
936
+ sys.stdout.write(" " * left_pad + theme.rail_terminator(color))
937
+ sys.stdout.flush()
938
+ _anim.rail_grow(inner, color=color, char=theme.HALF_UP, delay=delay)
939
+
940
+
941
+ def opencode_input(width=78, show_tip=True, mode_label="NOTEBOOK", model_label=None,
942
+ effort_label="engine ready", animate=True, suggestions=None,
943
+ hints=DEFAULT_HINTS, right_hint=None):
944
+ """The v0.5.6 OpenCode-style chatbox — the new default look for the home
945
+ dashboard (reached automatically via `smart_input` when
946
+ `USE_OPENCODE_INPUT` is True):
947
+
948
+ ┃ ⚗ Ask Chemistry…
949
+ ┃ NOTEBOOK gemini-flash-lite ready
950
+ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
951
+ tab suggestions /help commands
952
+ ● try: "arrhenius equation"
953
+
954
+ A single heavy left rail (no right/top/bottom border), a meta row, a `▀`
955
+ cap, then hints + tip outside the rail — same proportions and 2-row layout
956
+ as the reference, with OpenCode's exact row order.
957
+
958
+ Keeps the live prompt_toolkit suggestion dropdown when available (same
959
+ completer/Tab-fill/`_PT_STYLE` as `smart_input`); falls back to a plain
960
+ `input()` + the v0.5.5 pre-draw/echo-erase machinery otherwise. Signature
961
+ matches `smart_input` so it's a drop-in.
962
+ """
963
+ if right_hint and effort_label == "engine ready":
964
+ effort_label = right_hint
965
+ width = _live_width(fallback=width)
966
+ if animate:
967
+ animate_mascot_intro(width)
968
+
969
+ box_w, left_pad = _oc_geometry(width)
970
+ tip_text = _next_tip() if show_tip else None
971
+ row_prefix = _oc_rail_prefix(left_pad)
972
+
973
+ # The input-row rail (top of the box)
974
+ print(" " * left_pad + theme.fg(theme.HV, theme.OC_PRIMARY))
975
+
976
+ # Rows printed BELOW the input line — pre-drawn (v0.5.5 pattern) so the box
977
+ # looks fully closed the instant it appears. The cap row is marked with a
978
+ # sentinel so the plain-input path can grow it with an animation.
979
+ below = [
980
+ _oc_rail_meta_row(box_w, left_pad, mode_label, model_label, effort_label),
981
+ _oc_rail_cap_obj,
982
+ _oc_hint_row(box_w, left_pad, hints),
983
+ ]
984
+ if tip_text:
985
+ below.append(_oc_tip_row(left_pad, tip_text))
986
+
987
+ interactive = sys.stdout.isatty()
988
+
989
+ # ---- prompt_toolkit path: live dropdown + live-rendered below-rows ----
990
+ if _HAS_PROMPT_TOOLKIT and interactive:
991
+ completer = _SuggestCompleter(suggestions)
992
+ kb = KeyBindings()
993
+
994
+ @kb.add("tab")
995
+ def _accept(event):
996
+ buf = event.current_buffer
997
+ state = buf.complete_state
998
+ if state and state.completions:
999
+ completion = state.current_completion or state.completions[0]
1000
+ _animated_fill(buf, completion.text)
1001
+ buf.cancel_completion()
1002
+ else:
1003
+ buf.start_completion(select_first=False)
1004
+
1005
+ def _toolbar():
1006
+ lines = [
1007
+ _oc_rail_cap_row(box_w, left_pad) if line is _oc_rail_cap_obj else line
1008
+ for line in below
1009
+ ]
1010
+ return ANSI("\n".join(lines))
1011
+
1012
+ session = PromptSession(
1013
+ completer=completer,
1014
+ complete_while_typing=True,
1015
+ mouse_support=True,
1016
+ style=_PT_STYLE,
1017
+ key_bindings=kb,
1018
+ bottom_toolbar=_toolbar,
1019
+ reserve_space_for_menu=6,
1020
+ erase_when_done=True,
1021
+ )
1022
+
1023
+ label = "Ask Chemistry\u2026"
1024
+ inline_tip = _next_tip()
1025
+ content_start = theme.vlen(row_prefix) - left_pad
1026
+ budget = max(0, box_w - content_start - 1)
1027
+ tip_budget = budget - len(label) - 2
1028
+ if tip_budget < 6:
1029
+ inline_tip = ""
1030
+ elif theme.vlen(inline_tip) > tip_budget:
1031
+ inline_tip = inline_tip[:max(1, tip_budget - 1)].rstrip() + "\u2026"
1032
+ tip_span = f' <style fg="#565f89">{_xml_escape(inline_tip)}</style>' if inline_tip else ""
1033
+ placeholder = HTML(f'<style fg="#9aa5d8">{_xml_escape(label)}</style>{tip_span}')
1034
+
1035
+ try:
1036
+ raw = session.prompt(ANSI(row_prefix), placeholder=placeholder)
1037
+ except (EOFError, KeyboardInterrupt):
1038
+ raise
1039
+ # echo-erase the prompt_toolkit output, then print the canonical box
1040
+ _erase_echoed_input(theme.vlen(row_prefix), raw, width)
1041
+ return raw
1042
+
1043
+ # ---- plain input() fallback (no prompt_toolkit) -----------------------
1044
+ if interactive:
1045
+ sys.stdout.write("\x1b[s") # anchor where the input line goes
1046
+ print() # placeholder row input() reads on
1047
+ # print the below-rows, but render the cap row with a left→right
1048
+ # grow animation so the box visibly draws shut.
1049
+ for line in below:
1050
+ if line is _oc_rail_cap_obj:
1051
+ _oc_rail_cap_animated(box_w, left_pad)
1052
+ else:
1053
+ print(line)
1054
+ sys.stdout.write("\x1b[u") # jump back to the anchor
1055
+ sys.stdout.flush()
1056
+
1057
+ try:
1058
+ raw = input(row_prefix)
1059
+ except (EOFError, KeyboardInterrupt):
1060
+ if interactive:
1061
+ sys.stdout.write(f"\x1b[{1 + len(below)}B\r")
1062
+ sys.stdout.flush()
1063
+ raise
1064
+
1065
+ if interactive:
1066
+ _clear_input_row_keep_below(theme.vlen(row_prefix), raw, width, len(below))
1067
+ else:
1068
+ for line in below:
1069
+ print(_oc_rail_cap_row(box_w, left_pad) if line is _oc_rail_cap_obj else line)
1070
+ return raw
1071
+
1072
+
1073
+ def fallback_input(width=78, show_tip=True, mode_label="NOTEBOOK", model_label=None,
1074
+ effort_label="engine ready", animate=True, suggestions=None,
1075
+ hints=DEFAULT_HINTS, right_hint=None, label=None, bg_color=None,
1076
+ placeholder="Ask a question\u2026", hint=None):
1077
+ """THE single fallback-CLI input entry point (used only when the
1078
+ primary Textual UI — calc_terminal.ui.app.CCTApp — can't attach: no
1079
+ tty, piped stdout, or textual not installed).
1080
+
1081
+ Two call shapes, matching the two contexts the old chatui.py used to
1082
+ split across separate functions:
1083
+
1084
+ * `fallback_input(...)` (no `label`) — the home-dashboard box, same
1085
+ narrow soft-bordered v0.5.2 chatbox as before, with a live
1086
+ suggestion dropdown when prompt_toolkit is installed.
1087
+ * `fallback_input(label="AI", bg_color=theme.BG_INFO, ...)` — the
1088
+ /ai and /agent chat-loop prompt: a colored badge + `\u276f` arrow
1089
+ matching the chat bubbles printed around it, instead of the home
1090
+ box (reusing the home box here previously caused a reported
1091
+ mismatched-geometry bug).
1092
+
1093
+ This merges what used to be two public functions (`smart_input` /
1094
+ `chat_input`) into one entry point, so there's a single fallback
1095
+ input implementation instead of two call sites to keep in sync.
1096
+ """
1097
+ if label is not None:
1098
+ return _chat_style_input(label, bg_color or theme.BG_INFO, suggestions=suggestions,
1099
+ width=width, placeholder=placeholder, hint=hint)
1100
+ return _home_style_input(width=width, show_tip=show_tip, mode_label=mode_label,
1101
+ model_label=model_label, effort_label=effort_label,
1102
+ animate=animate, suggestions=suggestions, hints=hints,
1103
+ right_hint=right_hint)
1104
+
1105
+
1106
+ def _home_style_input(width=78, show_tip=True, mode_label="NOTEBOOK", model_label=None,
1107
+ effort_label="engine ready", animate=True, suggestions=None,
1108
+ hints=DEFAULT_HINTS, right_hint=None):
1109
+ """Everything `premium_input` does — the same narrow, centered,
1110
+ soft-bordered v0.5.2 chatbox — plus a real, live suggestion
1111
+ dropdown while typing (the meta row even stays visible live, as a
1112
+ prompt_toolkit bottom-toolbar, sitting right under the input
1113
+ exactly like the reference design shows it):
1114
+
1115
+ * dropdown appears as soon as what's typed matches a known
1116
+ command/topic
1117
+ * navigate with \u2191/\u2193, or click a row with the mouse
1118
+ (mouse-aware terminals switch the pointer to a hand/click
1119
+ cursor over it automatically once mouse reporting is on —
1120
+ that part is the terminal emulator's job, not this app's)
1121
+ * Tab fills the highlighted row in with a short animated
1122
+ letter-by-letter type-in; clicking or pressing Enter on a
1123
+ highlighted row fills it in instantly, same as any standard
1124
+ autocomplete widget
1125
+
1126
+ Needs `prompt_toolkit` (`pip install prompt_toolkit`); falls back
1127
+ to the plain `premium_input()` automatically if it isn't
1128
+ installed, so the app still runs with zero extra dependencies.
1129
+
1130
+ v0.5.6: by default delegates to the new OpenCode-style `opencode_input`
1131
+ (the heavy left-rail box) via the `USE_OPENCODE_INPUT` flag. Set that flag
1132
+ to False to get this v0.5.2 bordered box back unchanged.
1133
+ """
1134
+ if right_hint and effort_label == "engine ready":
1135
+ effort_label = right_hint
1136
+ if USE_OPENCODE_INPUT:
1137
+ return opencode_input(width=width, show_tip=show_tip, mode_label=mode_label,
1138
+ model_label=model_label, effort_label=effort_label,
1139
+ animate=animate, suggestions=suggestions, hints=hints)
1140
+ if not _HAS_PROMPT_TOOLKIT:
1141
+ return premium_input(width=width, show_tip=show_tip, mode_label=mode_label,
1142
+ model_label=model_label, effort_label=effort_label,
1143
+ animate=animate, hints=hints)
1144
+
1145
+ width = _live_width(fallback=width)
1146
+ if animate:
1147
+ animate_mascot_intro(width)
1148
+
1149
+ box_w, left_pad = _oc_geometry(width)
1150
+ icon = theme.fg(theme.FLASK, theme.ACCENT_VIVID, bold=True)
1151
+ row_prefix = _oc_row_prefix(left_pad, icon)
1152
+ print(_oc_top(box_w, left_pad))
1153
+
1154
+ completer = _SuggestCompleter(suggestions)
1155
+ kb = KeyBindings()
1156
+
1157
+ @kb.add("tab")
1158
+ def _accept(event):
1159
+ buf = event.current_buffer
1160
+ state = buf.complete_state
1161
+ if state and state.completions:
1162
+ completion = state.current_completion or state.completions[0]
1163
+ _animated_fill(buf, completion.text)
1164
+ buf.cancel_completion()
1165
+ else:
1166
+ buf.start_completion(select_first=False)
1167
+
1168
+ def _toolbar():
1169
+ # v0.5.5: the full rest of the box — bottom border, hints,
1170
+ # tip included, not just the meta row — rendered as one
1171
+ # multi-line toolbar so it's visible live, from the moment
1172
+ # the box appears, matching the reference design (previously
1173
+ # only the meta row showed here, and everything else waited
1174
+ # until Enter was pressed). prompt_toolkit owns this region
1175
+ # and redraws it itself on every keystroke, so — unlike the
1176
+ # plain-input fallback — there's no need for manual cursor
1177
+ # save/restore tricks here; it can't collide with the
1178
+ # completion dropdown's reserved space either, since that's
1179
+ # laid out above this toolbar, not over it.
1180
+ lines = [
1181
+ _oc_meta_row(box_w, left_pad, mode_label, model_label, effort_label),
1182
+ _oc_bottom(box_w, left_pad),
1183
+ _oc_hint_row(box_w, left_pad, hints),
1184
+ ]
1185
+ if show_tip:
1186
+ lines.append(_oc_tip_row(left_pad, below_tip))
1187
+ return ANSI("\n".join(lines))
1188
+
1189
+ session = PromptSession(
1190
+ completer=completer,
1191
+ complete_while_typing=True,
1192
+ mouse_support=True,
1193
+ style=_PT_STYLE,
1194
+ key_bindings=kb,
1195
+ bottom_toolbar=_toolbar,
1196
+ reserve_space_for_menu=6,
1197
+ erase_when_done=True,
1198
+ )
1199
+
1200
+ inline_tip = _next_tip()
1201
+ below_tip = _next_tip()
1202
+ # Clean two-tone placeholder: the prompt itself in a calm gray, the
1203
+ # rotating example suggestion in a fainter tone — the same quiet
1204
+ # hierarchy premium input fields use (label dim, sample fainter).
1205
+ #
1206
+ # v0.5.4 fix: trim/drop the tip so "icon + label + tip" never
1207
+ # renders wider than the box itself. Previously this text had no
1208
+ # width budget at all, so on a narrow terminal — where
1209
+ # `_oc_geometry` clamps box_w down via `term_width - 6` — a long
1210
+ # tip like '"first order half life"' printed straight past the
1211
+ # box's right edge. Because the border above/below is drawn at
1212
+ # the (narrower) box_w, that made the border look like it cut off
1213
+ # mid-word instead of framing the whole line (reported bug).
1214
+ label = "Ask Chemistry\u2026"
1215
+ content_start = theme.vlen(row_prefix) - left_pad
1216
+ budget = max(0, box_w - content_start - 1)
1217
+ tip_budget = budget - len(label) - 2
1218
+ if tip_budget < 6:
1219
+ inline_tip = ""
1220
+ elif theme.vlen(inline_tip) > tip_budget:
1221
+ inline_tip = inline_tip[:max(1, tip_budget - 1)].rstrip() + "\u2026"
1222
+
1223
+ tip_span = f' <style fg="#565f89">{_xml_escape(inline_tip)}</style>' if inline_tip else ""
1224
+ placeholder = HTML(f'<style fg="#9aa5d8">{_xml_escape(label)}</style>{tip_span}')
1225
+
1226
+ try:
1227
+ raw = session.prompt(ANSI(row_prefix), placeholder=placeholder)
1228
+ except (EOFError, KeyboardInterrupt):
1229
+ print(_oc_bottom(box_w, left_pad))
1230
+ raise
1231
+
1232
+ print(_oc_meta_row(box_w, left_pad, mode_label, model_label, effort_label))
1233
+ print(_oc_bottom(box_w, left_pad))
1234
+ print(_oc_hint_row(box_w, left_pad, hints))
1235
+ if show_tip:
1236
+ print(_oc_tip_row(left_pad, below_tip))
1237
+ return raw
1238
+
1239
+
1240
+ def _chat_style_input(label, bg_color, suggestions=None, width=None,
1241
+ placeholder="Ask a question\u2026", hint=None):
1242
+ """Suggestion-aware input for the /ai and /agent chat loops.
1243
+
1244
+ Deliberately keeps THEIR OWN look — a colored ' AI '/' AGENT ' badge
1245
+ plus a `\u276f` arrow, matching every chat bubble printed around it —
1246
+ instead of borrowing the home dashboard's bordered 'Ask Chemistry…'
1247
+ box (`smart_input`). Reusing that box here was the cause of a
1248
+ reported bug: its placeholder is hard-coded to 'Ask Chemistry…' and
1249
+ its box geometry is tuned for the home screen, so inside /ai or
1250
+ /agent it rendered a mismatched, sometimes misaligned, generic box
1251
+ instead of the distinct AI/Agent chat prompt. This gives the same
1252
+ live autocomplete dropdown (arrow keys, Tab to fill, mouse click)
1253
+ without changing that prompt's identity.
1254
+
1255
+ Falls back to plain `ask()` (no dropdown) if prompt_toolkit isn't
1256
+ installed, so the app still runs with zero extra dependencies.
1257
+ """
1258
+ if width is None:
1259
+ width = _live_width()
1260
+ prompt_str = theme.bg(f" \u2588 {label} \u2588 ", bg_color) + " \u276f "
1261
+
1262
+ if not _HAS_PROMPT_TOOLKIT:
1263
+ raw = ask(prompt_str, width=width)
1264
+ if hint:
1265
+ print(theme.faint(" " + hint))
1266
+ return raw
1267
+
1268
+ completer = _SuggestCompleter(suggestions)
1269
+ kb = KeyBindings()
1270
+
1271
+ @kb.add("tab")
1272
+ def _accept(event):
1273
+ buf = event.current_buffer
1274
+ state = buf.complete_state
1275
+ if state and state.completions:
1276
+ completion = state.current_completion or state.completions[0]
1277
+ _animated_fill(buf, completion.text)
1278
+ buf.cancel_completion()
1279
+ else:
1280
+ buf.start_completion(select_first=False)
1281
+
1282
+ session = PromptSession(
1283
+ completer=completer,
1284
+ complete_while_typing=True,
1285
+ mouse_support=True,
1286
+ style=_PT_STYLE,
1287
+ key_bindings=kb,
1288
+ reserve_space_for_menu=6,
1289
+ erase_when_done=True,
1290
+ )
1291
+ placeholder_ft = HTML(f'<style fg="#565f89">{placeholder}</style>')
1292
+
1293
+ try:
1294
+ raw = session.prompt(ANSI(prompt_str), placeholder=placeholder_ft)
1295
+ except (EOFError, KeyboardInterrupt):
1296
+ raise
1297
+ _erase_echoed_input(theme.vlen(prompt_str), raw, width)
1298
+ if hint:
1299
+ print(theme.faint(" " + hint))
1300
+ return raw
1301
+
1302
+
1303
+ def prompt_bar():
1304
+ """The premium highlighted 'Ask Chemistry…' input bar."""
1305
+ left = theme.bg(" \u276f ", theme.BG_INFO, (150, 220, 255))
1306
+ hint = theme.dim("Ask Chemistry\u2026 ")
1307
+ return left + " " + hint
1308
+
1309
+
1310
+ def status_ribbon(items):
1311
+ """A row of status key/values, e.g. mode / engine / precision.
1312
+
1313
+ v0.7.9.0 (requirement #34 — remove DECORATIVE text highlighting):
1314
+ this used to paint every chip with a filled color BACKGROUND
1315
+ (badge()), which made ordinary labels look selected/active. Now it's
1316
+ clean typography only — dim uppercase keys, normal values, separated
1317
+ by dots. Legitimate selection/focus states elsewhere are untouched."""
1318
+ chips = []
1319
+ for label, val in items:
1320
+ chips.append(theme.dim(f"{label} ".upper()) + theme.text(str(val)))
1321
+ return " \u00b7 ".join(chips)