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/theme.py ADDED
@@ -0,0 +1,1107 @@
1
+ """
2
+ Tokyo Night color theme + low-level terminal rendering helpers for
3
+ Chemistry Calc Terminal (CCT).
4
+
5
+ No third-party dependencies required. Uses 24-bit ANSI escape codes,
6
+ which are supported by modern Windows Terminal, macOS Terminal, and
7
+ virtually all Linux terminal emulators.
8
+ """
9
+
10
+ import os
11
+ import re
12
+ import sys
13
+ import json
14
+ import time
15
+ import unicodedata
16
+
17
+ # ---------------------------------------------------------------- colors --
18
+ CYAN = (125, 207, 255) # accent
19
+ PURPLE = (195, 160, 255) # secondary accent
20
+ GREEN = (139, 224, 139) # success
21
+ ORANGE = (240, 180, 95) # warning
22
+ RED = (255, 127, 154) # error
23
+ TEXT = (232, 235, 250) # primary text (near white)
24
+ DIM = (135, 140, 175) # secondary text
25
+ FAINT = (90, 94, 128) # faint / border text
26
+
27
+ RESET = "\033[0m"
28
+ BOLD = "\033[1m"
29
+
30
+ ANSI_RE = re.compile(r"\033\[[0-9;]*m")
31
+
32
+
33
+ def enable_windows_ansi():
34
+ """Best-effort: enable ANSI escapes on legacy Windows terminals."""
35
+ try:
36
+ import colorama
37
+ colorama.init()
38
+ except ImportError:
39
+ pass
40
+
41
+
42
+ def rgb(r, g, b):
43
+ return f"\033[38;2;{r};{g};{b}m"
44
+
45
+
46
+ def bgrgb(r, g, b):
47
+ return f"\033[48;2;{r};{g};{b}m"
48
+
49
+
50
+ def fg(text, color, bold=False):
51
+ code = rgb(*color)
52
+ b = BOLD if bold else ""
53
+ return f"{b}{code}{text}{RESET}"
54
+
55
+
56
+ def cyan(t, bold=False): return fg(t, CYAN, bold)
57
+ def purple(t, bold=False): return fg(t, PURPLE, bold)
58
+ def green(t, bold=False): return fg(t, GREEN, bold)
59
+ def orange(t, bold=False): return fg(t, ORANGE, bold)
60
+ def red(t, bold=False): return fg(t, RED, bold)
61
+ def text(t, bold=False): return fg(t, TEXT, bold)
62
+ def dim(t, bold=False): return fg(t, DIM, bold)
63
+ def faint(t, bold=False): return fg(t, FAINT, bold)
64
+
65
+
66
+ # ------------------------------------------------------- premium bg / highlight --
67
+ # Background-highlighted "badges" for a premium terminal look. colorama is
68
+ # used (when present) so legacy Windows cmd.exe also renders background
69
+ # colors correctly; on modern terminals we emit real 24-bit ANSI directly.
70
+ try:
71
+ import colorama # noqa: F401
72
+ _HAS_COLORAMA = True
73
+ except ImportError:
74
+ _HAS_COLORAMA = False
75
+
76
+
77
+ def bg(text_str, bgcolor, fgcolor=TEXT, bold=True):
78
+ """Render text with a solid background highlight, e.g. a chat bubble
79
+ fill or a status badge."""
80
+ b = BOLD if bold else ""
81
+ return f"{b}{bgrgb(*bgcolor)}{rgb(*fgcolor)}{text_str}{RESET}"
82
+
83
+
84
+ def badge(label, bgcolor, fgcolor=(20, 20, 30)):
85
+ """A small pill-style highlighted badge, e.g. [ ONLINE ]."""
86
+ return bg(f" {label} ", bgcolor, fgcolor, bold=True)
87
+
88
+
89
+ # Soft background tints used for chat bubbles / highlighted panels.
90
+ BG_USER = (40, 62, 92) # muted blue — user message bubble
91
+ BG_AI = (48, 40, 74) # muted violet — AI response bubble
92
+ BG_OK = (33, 74, 55) # muted green — success highlight
93
+ BG_WARN = (79, 63, 30) # muted amber — warning highlight
94
+ BG_INFO = (33, 58, 79) # muted cyan — info highlight
95
+
96
+ # v0.5.1 "OpenCode-style" chatbox: a flat (non-gradient), single-hue
97
+ # border in a muted navy-blue, with a brighter vivid blue reserved for
98
+ # the one highlighted word in the meta row (mirrors "Build" in the
99
+ # reference screenshot) — a calmer, more premium look than the
100
+ # rainbow-gradient box used elsewhere in the app.
101
+ ACCENT_BLUE = (122, 162, 247) # vivid — the one highlighted word
102
+ BORDER_BLUE = (61, 71, 112) # muted — the box outline itself
103
+
104
+ # v0.5.2 refined chatbox palette — a touch brighter and more deliberately
105
+ # tuned than v0.5.1 so the box reads as a single calm object against the
106
+ # Tokyo Night background, exactly like the reference screenshot: a soft
107
+ # navy outline, a faint inner tint, and one reserved vivid accent for the
108
+ # mode word. Two border stops feed an almost-imperceptible horizontal
109
+ # gradient (premium tools do this — it adds depth without becoming a
110
+ # rainbow).
111
+ BORDER_SOFT_A = (74, 86, 130) # left edge — slightly lifted navy
112
+ BORDER_SOFT_B = (60, 70, 110) # right edge — settles back down
113
+ ACCENT_VIVID = (130, 170, 255) # the one bold mode word (e.g. "NOTEBOOK")
114
+ STATUS_AMBER = (230, 175, 110) # right-aligned status word
115
+ META_DIM = (110, 120, 160) # dimmed model name next to the mode word
116
+ HINT_FAINT = (96, 100, 134) # keyboard-shortcut key caps
117
+ HINT_LABEL = (130, 138, 175) # keyboard-shortcut label text
118
+
119
+
120
+ # Emoji/symbol code-point ranges that render as DOUBLE-WIDTH glyphs in
121
+ # virtually every terminal/monospace font in practice, even though
122
+ # Unicode itself classifies many of them as "Ambiguous" or "Neutral"
123
+ # width (so a naive East-Asian-Width check alone misses them). The
124
+ # flask icon (\u2697) used in the input box is one of these — treating
125
+ # it as 1 column instead of 2 was the cause of the reported "chat box
126
+ # not aligned" bug: every border/hint-row padding calculation that
127
+ # followed the icon was off by one column.
128
+ _WIDE_RANGES = (
129
+ (0x2600, 0x27BF), # misc symbols + dingbats (includes \u2697 flask)
130
+ (0x1F300, 0x1FAFF), # emoji blocks (symbols, pictographs, supplemental)
131
+ )
132
+
133
+
134
+ def _char_width(ch):
135
+ o = ord(ch)
136
+ for lo, hi in _WIDE_RANGES:
137
+ if lo <= o <= hi:
138
+ return 2
139
+ eaw = unicodedata.east_asian_width(ch)
140
+ return 2 if eaw in ("W", "F") else 1
141
+
142
+
143
+ def vlen(s):
144
+ """Visible column-width of a string: strips ANSI escape codes and
145
+ counts double-width glyphs (emoji, CJK, symbol icons) as 2 columns
146
+ instead of 1, matching how terminals actually render them."""
147
+ return sum(_char_width(ch) for ch in ANSI_RE.sub("", s))
148
+
149
+
150
+ def vtruncate(s, width):
151
+ """Truncate a (possibly ANSI-colored) string to at most `width` visible
152
+ columns, preserving any escape codes and appending an ellipsis if it
153
+ had to cut. Used by panel() so a line that's a few characters too long
154
+ for the box gets shortened instead of overflowing and pushing every
155
+ border on that row (and everything printed after it) out of
156
+ alignment — the root cause of the reported box-alignment bug."""
157
+ if vlen(s) <= width:
158
+ return s
159
+ if width <= 0:
160
+ return ""
161
+ out = []
162
+ used = 0
163
+ i = 0
164
+ n = len(s)
165
+ budget = max(0, width - 1) # reserve 1 column for the ellipsis
166
+ while i < n:
167
+ m = ANSI_RE.match(s, i)
168
+ if m:
169
+ out.append(m.group(0))
170
+ i = m.end()
171
+ continue
172
+ ch = s[i]
173
+ w = _char_width(ch)
174
+ if used + w > budget:
175
+ break
176
+ out.append(ch)
177
+ used += w
178
+ i += 1
179
+ out.append("\u2026")
180
+ return "".join(out)
181
+
182
+
183
+ def vpad(s, width, align="left"):
184
+ """Pad a (possibly colored) string to `width` visible columns."""
185
+ pad = max(0, width - vlen(s))
186
+ if align == "left":
187
+ return s + " " * pad
188
+ if align == "right":
189
+ return " " * pad + s
190
+ left = pad // 2
191
+ right = pad - left
192
+ return " " * left + s + " " * right
193
+
194
+
195
+ def clear_screen():
196
+ print("\033[2J\033[H", end="")
197
+
198
+
199
+ def cursor_home():
200
+ sys.stdout.write("\033[H")
201
+
202
+
203
+ def hide_cursor():
204
+ sys.stdout.write("\033[?25l")
205
+ sys.stdout.flush()
206
+
207
+
208
+ def show_cursor():
209
+ sys.stdout.write("\033[?25h")
210
+ sys.stdout.flush()
211
+
212
+
213
+ def typewriter(s, delay=0.006, color=None, bold=False):
214
+ """Print text one character at a time."""
215
+ for ch in s:
216
+ out = fg(ch, color, bold) if color else ch
217
+ sys.stdout.write(out)
218
+ sys.stdout.flush()
219
+ time.sleep(delay)
220
+ print()
221
+
222
+
223
+ def progress_bar(label, duration=0.6, width=32, color=CYAN):
224
+ """An animated fill-in progress bar, e.g. for boot/loading sequences."""
225
+ steps = 24
226
+ for i in range(steps + 1):
227
+ filled = int(width * i / steps)
228
+ bar = "\u2588" * filled + "\u2591" * (width - filled)
229
+ pct = int(100 * i / steps)
230
+ sys.stdout.write(f"\r {dim(label)} {fg(bar, color)} {text(str(pct) + '%')}")
231
+ sys.stdout.flush()
232
+ time.sleep(duration / steps)
233
+ sys.stdout.write("\n")
234
+
235
+
236
+ def reveal_lines(lines, delay=0.05, color=None, bold=False):
237
+ """Print a block of lines one at a time (used for the ASCII logo)."""
238
+ for line in lines:
239
+ out = fg(line, color, bold) if color else line
240
+ print(out)
241
+ time.sleep(delay)
242
+
243
+
244
+ # ------------------------------------------------------------- box panel --
245
+ TL, TR, BL, BR, H, V = "╭", "╮", "╰", "╯", "─", "│"
246
+
247
+
248
+ def panel(lines, title=None, color=CYAN, width=78, pad_x=2, title_gradient=None):
249
+ """Render a rounded box panel around a list of (plain or ANSI) lines.
250
+ If `title_gradient` is a tuple of 2+ colors, the title text itself is
251
+ rendered as a smooth gradient instead of the flat `color`."""
252
+ inner = width - 2
253
+ out = []
254
+
255
+ if title:
256
+ label = f" {title.upper()} " if title_gradient else f" {title} "
257
+ t = gradient(label, *title_gradient, bold=True) if title_gradient else label
258
+ left = (inner - vlen(t)) // 2
259
+ right = inner - vlen(t) - left
260
+ top = fg(TL + H * left, color) + t + fg(H * right + TR, color)
261
+ out.append(top)
262
+ else:
263
+ top = TL + H * inner + TR
264
+ out.append(fg(top, color))
265
+
266
+ for ln in lines:
267
+ avail = inner - pad_x * 2
268
+ content = vpad(vtruncate(ln, avail), avail)
269
+ out.append(fg(V, color) + " " * pad_x + content + " " * pad_x + fg(V, color))
270
+
271
+ bottom = BL + H * inner + BR
272
+ out.append(fg(bottom, color))
273
+ return "\n".join(out)
274
+
275
+
276
+ def window_titlebar(title, width=78):
277
+ """A macOS-style window chrome strip: red/yellow/green dots on the
278
+ left, the title centered, in its own closed rounded box — printed
279
+ just above the main content panel, mimicking a native app window."""
280
+ dots = fg("\u25cf", (237, 106, 94)) + " " + fg("\u25cf", (245, 191, 79)) + " " + fg("\u25cf", (97, 194, 91))
281
+ inner = width - 2
282
+ top = fg(TL + H * inner + TR, FAINT)
283
+ bot = fg(BL + H * inner + BR, FAINT)
284
+ dots_cell = " " + dots
285
+ title_cell = fg(title, TEXT, bold=True)
286
+ row_plain = vpad(dots_cell, (inner - vlen(title_cell)) // 2) if vlen(title_cell) < inner else dots_cell
287
+ left_w = (inner - vlen(title_cell)) // 2
288
+ row = vpad(dots_cell, left_w) + title_cell
289
+ row = vpad(row, inner)
290
+ mid = fg(V, FAINT) + row + fg(V, FAINT)
291
+ return "\n".join([top, mid, bot])
292
+
293
+
294
+ def input_box(hint="Ask Chemistry\u2026", width=78, left_hint="enter send", right_hint=""):
295
+ """A bordered chat input box like a modern TUI, e.g.:
296
+ ╭──────────────────────────────╮
297
+ │ › ▌ │
298
+ ╰──────────────────────────────╯
299
+ enter send engine
300
+ """
301
+ inner = width - 2
302
+ top = fg(TL + H * inner + TR, CYAN)
303
+ row = fg(V, CYAN) + " " + fg("\u203a", CYAN, bold=True) + " " + fg("\u258c", CYAN) + \
304
+ " " * (inner - 7) + fg(V, CYAN)
305
+ bot = fg(BL + H * inner + BR, CYAN)
306
+ hint_row = dim(left_hint) + " " * max(1, width - vlen(left_hint) - vlen(right_hint)) + dim(right_hint)
307
+ return "\n".join([top, row, bot, hint_row])
308
+
309
+
310
+ def hr(width=78, color=FAINT, ch="─"):
311
+ return fg(ch * width, color)
312
+
313
+
314
+ def center(s, width=78):
315
+ return vpad(s, width, "center")
316
+
317
+
318
+ # ---------------------------------------------------------- gradients --
319
+ # Lightweight character-by-character 24-bit color interpolation — gives
320
+ # a "premium" shimmering look to borders, titles and rules without any
321
+ # external dependency (still just plain ANSI escape codes).
322
+
323
+ def lerp(c1, c2, t):
324
+ return tuple(int(c1[i] + (c2[i] - c1[i]) * t) for i in range(3))
325
+
326
+
327
+ def _stops(t, stops):
328
+ """t in [0,1] across N-1 segments defined by `stops` (list of colors)."""
329
+ if len(stops) == 1:
330
+ return stops[0]
331
+ seg = 1.0 / (len(stops) - 1)
332
+ i = min(int(t / seg), len(stops) - 2)
333
+ local_t = (t - i * seg) / seg
334
+ return lerp(stops[i], stops[i + 1], local_t)
335
+
336
+
337
+ def gradient(s, *colors, bold=False):
338
+ """Render `s` with a smooth color gradient across its visible
339
+ characters. Pass 2+ colors, e.g. gradient(txt, CYAN, PURPLE) or
340
+ gradient(txt, CYAN, PURPLE, CYAN) for a shimmering round-trip."""
341
+ if not colors:
342
+ colors = (CYAN, PURPLE)
343
+ n = max(1, len(s) - 1)
344
+ b = BOLD if bold else ""
345
+ out = []
346
+ for i, ch in enumerate(s):
347
+ if ch == " ":
348
+ out.append(" ")
349
+ continue
350
+ c = _stops(i / n, colors)
351
+ out.append(f"{b}{rgb(*c)}{ch}")
352
+ return "".join(out) + RESET
353
+
354
+
355
+ def gradient_rule(width, *colors, ch="─"):
356
+ return gradient(ch * width, *colors)
357
+
358
+
359
+ # A calm "alive" pulse color used for status dots / engine indicators —
360
+ # alternates gently so the same screen looks a little different each
361
+ # time it's redrawn, without any real animation loop needed.
362
+ def pulse_color(base_a=GREEN, base_b=CYAN):
363
+ return base_a if int(time.time() * 2) % 2 == 0 else base_b
364
+
365
+
366
+ def pulse_dot(base_a=GREEN, base_b=CYAN):
367
+ return fg("\u25cf", pulse_color(base_a, base_b), bold=True)
368
+
369
+
370
+ # ---------------------------------------------------- premium input box --
371
+ FLASK = "\u2697" # ⚗
372
+
373
+ # v0.5.2 angular box-drawing characters — the reference screenshot uses
374
+ # sharp 90-degree corners (┌┐└┘), not rounded ones (╭╮╰╯). Keeping these
375
+ # as named constants means a single edit point if a future design wants
376
+ # rounded corners back.
377
+ ATL, ATR, ABL, ABR, AH, AV = "┌", "┐", "└", "┘", "─", "│"
378
+
379
+
380
+ def soft_border_line(width, left_ch, mid_ch, right_ch,
381
+ c1=BORDER_SOFT_A, c2=BORDER_SOFT_B):
382
+ """A horizontal box edge drawn with a very gentle left→right color
383
+ interpolation between two navy stops. It's intentionally subtle (the
384
+ two stops are close in value) so the border reads as a single calm
385
+ object rather than a rainbow — the same trick premium terminal tools
386
+ use to add depth to a flat outline."""
387
+ inner = width - 2
388
+ out = []
389
+ out.append(f"{rgb(*c1)}{left_ch}")
390
+ for i in range(inner):
391
+ t = i / max(1, inner - 1)
392
+ r, g, b = lerp(c1, c2, t)
393
+ out.append(f"{rgb(r, g, b)}{mid_ch}")
394
+ out.append(f"{rgb(*c2)}{right_ch}{RESET}")
395
+ return "".join(out)
396
+
397
+
398
+ def premium_input_frame(width=78, top_colors=(CYAN, PURPLE, CYAN)):
399
+ """Top border + prompt-row prefix for the redesigned gradient chatbox.
400
+ Returns (top_line, row_prefix) — caller prints top_line, then reads
401
+ input() using row_prefix so the cursor lands inside the box."""
402
+ inner = width - 2
403
+ top = gradient(TL + H * inner + TR, *top_colors)
404
+ icon = gradient(FLASK, *top_colors[:2], bold=True)
405
+ row_prefix = fg(V, top_colors[0]) + " " + icon + " "
406
+ return top, row_prefix
407
+
408
+
409
+ def premium_input_close(width=78, bottom_colors=(PURPLE, CYAN), left_hint="\u21b5 send",
410
+ mid_hint="/help commands", right_hint="engine ready"):
411
+ inner = width - 2
412
+ bot = gradient(BL + H * inner + BR, *bottom_colors)
413
+ dot = pulse_dot()
414
+ fixed = 12 + vlen(left_hint) + vlen(mid_hint) + vlen(right_hint)
415
+ if width - fixed < 1:
416
+ # Too narrow to fit left + mid + right hints with real padding —
417
+ # drop the middle hint rather than let the padding collapse to a
418
+ # hardcoded minimum of 1, which used to make the right_hint /
419
+ # status dot crowd into (and visually misalign with) the rest of
420
+ # the row on narrower terminals.
421
+ fixed = 8 + vlen(left_hint) + vlen(right_hint)
422
+ pad = max(1, width - fixed)
423
+ hint = " " + faint(left_hint) + " " * pad + dot + " " + dim(right_hint)
424
+ else:
425
+ pad = width - fixed
426
+ hint = (" " + faint(left_hint) + faint(" \u00b7 ") + faint(mid_hint) +
427
+ " " * pad + dot + " " + dim(right_hint))
428
+ return bot, hint
429
+
430
+
431
+ # ----------------------------------------------------- OpenCode-style kit --
432
+ # Characters + palette for the v0.5.6 OpenCode-inspired chat surface.
433
+ # These are ADDITIVE — nothing above changes. OpenCode's signature look is a
434
+ # single heavy left rail (no right border) terminating in a half-block, with a
435
+ # `▀` underline cap below; an avatar square `▣` marks the assistant; a `●` dot
436
+ # prefixes tips. The OC_* palette is OpenCode's own Tokyo-Night mapping, kept
437
+ # separate from CCT's existing CYAN/PURPLE constants so both identities coexist.
438
+
439
+ HV = "\u2503" # ┃ heavy vertical — the OpenCode input rail
440
+ HALF_UP = "\u2580" # ▀ upper half block — the underline cap
441
+ HALF_DN = "\u2584" # ▄ lower half block — fills for load bars
442
+ SQ_MARK = "\u25a3" # ▣ assistant avatar / role marker
443
+ DOT = "\u25cf" # ● tip / status dot
444
+ SWEEP = "\u259e" # ▞ the scanline-clear sweep glyph
445
+
446
+ # OpenCode's Tokyo-Night semantic roles (additive — CCT's own palette above is
447
+ # untouched). Used by the new rail input + hybrid message renderers.
448
+ OC_BG_PANEL = (30, 32, 48) # message panel fill
449
+ OC_BG_ELEMENT = (34, 36, 54) # input box fill / hover
450
+ OC_BORDER = (115, 122, 162) # default rail/border
451
+ OC_BORDER_ACTIVE = (144, 153, 178) # active (mode-coloured) rail
452
+ OC_PRIMARY = (130, 170, 255) # the vivid mode word (Build / NOTEBOOK)
453
+ OC_SECONDARY = (192, 153, 255) # secondary accent (purple)
454
+ OC_ACCENT = (255, 150, 108) # orange accent / tip
455
+ OC_MUTED = (130, 139, 184) # muted text / key caps
456
+
457
+
458
+ def heavy_rail(width, color=OC_PRIMARY, terminator="\u2579"):
459
+ """OpenCode's signature LEFT rail: a vertical `┃` run followed by the
460
+ terminator char. Returns ONE colored prefix string (the caller prints it
461
+ once per row of the input box). `terminator` defaults to `╹` (U+2575) —
462
+ the half-heavy up bar OpenCode uses so the rail curls off at the bottom."""
463
+ return fg(HV, color) + (" " if width <= 0 else "")
464
+
465
+
466
+ def rail_terminator(color=OC_PRIMARY, terminator="\u2579"):
467
+ """The single char that ends the rail at its bottom. Kept separate so the
468
+ input renderer can swap it in for the last row without recomputing width."""
469
+ return fg(terminator, color)
470
+
471
+
472
+ def underline_cap(width, color=OC_PRIMARY):
473
+ """The `▀` bottom cap row that visually closes the input box under the
474
+ meta row, exactly like OpenCode's cap. Full-width, no side borders."""
475
+ return fg(HALF_UP * max(1, width), color)
476
+
477
+
478
+ # ------------------------------------------------------------- theme switch --
479
+ # v0.5.6: a real light/dark toggle. Every color name below is a plain module
480
+ # global that every other file reads at call time via theme.CYAN, theme.BG_AI,
481
+ # etc. — so set_theme() just reassigns those globals in place and every panel,
482
+ # badge, and chat bubble anywhere in the app picks up the new palette
483
+ # immediately, with zero changes needed anywhere else.
484
+ #
485
+ # v0.7.9.5 THEME SYSTEM OVERHAUL: the two-entry dict grew into a normalized
486
+ # registry of 22 full palettes built on ONE Theme schema (the semantic field
487
+ # set every UI surface consumes). Each Theme is compiled down to the same
488
+ # 30 legacy module globals the rest of this file has always exported, so
489
+ # classic-terminal panels, badges and bubbles keep working unchanged while
490
+ # the whole palette catalog becomes data-driven:
491
+ #
492
+ # Theme(name, label, dark, background, surface, surface_hover, text,
493
+ # text_muted, text_faint, border, border_active, accent,
494
+ # accent_alt, accent_secondary, input_background,
495
+ # selection_background, selection_text, success, warning, error)
496
+ #
497
+ # "dark" and "light" survive as compatibility aliases (they resolve to
498
+ # tokyo-night / github-light), so saved configs and /theme dark|light keep
499
+ # working byte-for-byte.
500
+
501
+ import threading
502
+ from dataclasses import dataclass as _dc_dataclass
503
+
504
+
505
+ def _hx(h):
506
+ """'#rrggbb' -> (r, g, b)."""
507
+ h = (h or "").lstrip("#")
508
+ if len(h) != 6:
509
+ return (128, 128, 128)
510
+ try:
511
+ return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
512
+ except ValueError:
513
+ return (128, 128, 128)
514
+
515
+
516
+ @_dc_dataclass(frozen=True)
517
+ class Theme:
518
+ """Normalized theme schema — every UI surface (Textual CSS bridge,
519
+ classic-terminal panels, editor palettes) reads ONLY these fields."""
520
+ name: str
521
+ label: str
522
+ dark: bool = True
523
+ background: tuple = (26, 27, 38)
524
+ surface: tuple = (30, 32, 48)
525
+ surface_hover: tuple = (34, 36, 54)
526
+ text: tuple = (232, 235, 250)
527
+ text_muted: tuple = (135, 140, 175)
528
+ text_faint: tuple = (90, 94, 128)
529
+ border: tuple = (115, 122, 162)
530
+ border_active: tuple = (144, 153, 178)
531
+ accent: tuple = (130, 170, 255)
532
+ accent_alt: tuple = (125, 207, 255)
533
+ accent_secondary: tuple = (192, 153, 255)
534
+ input_background: tuple = (34, 36, 54)
535
+ selection_background: tuple = (49, 52, 75)
536
+ selection_text: tuple = (232, 235, 250)
537
+ success: tuple = (139, 224, 139)
538
+ warning: tuple = (240, 180, 95)
539
+ error: tuple = (255, 127, 154)
540
+
541
+ def hex(self, attr):
542
+ """'#rrggbb' for one of this theme's color fields."""
543
+ v = getattr(self, attr)
544
+ return "#{:02x}{:02x}{:02x}".format(*v)
545
+
546
+
547
+ # ------------------------------------------------------------ WCAG helpers --
548
+ def _lum(c):
549
+ def chan(v):
550
+ v = v / 255.0
551
+ return v / 12.92 if v <= 0.03928 else ((v + 0.055) / 1.055) ** 2.4
552
+ r, g, b = c
553
+ return 0.2126 * chan(r) + 0.7152 * chan(g) + 0.0722 * chan(b)
554
+
555
+
556
+ def contrast_ratio(a, b):
557
+ """WCAG contrast ratio between two RGB tuples (>=1.0)."""
558
+ la, lb = _lum(a), _lum(b)
559
+ hi, lo = max(la, lb), min(la, lb)
560
+ return (hi + 0.05) / (lo + 0.05)
561
+
562
+
563
+ def fit_contrast(color, bg, minimum=3.0):
564
+ """Nudge `color` toward black/white until it reaches `minimum`
565
+ contrast against `bg`. Themes are authored correct already; this is
566
+ the safety net that guarantees no shipped theme can ship an
567
+ invisible accent (requirement #17)."""
568
+ if contrast_ratio(color, bg) >= minimum:
569
+ return color
570
+ target_dark = _lum(bg) > 0.35 # light background → darken the color
571
+ for step in range(1, 21):
572
+ t = step * 0.06
573
+ other = (0, 0, 0) if target_dark else (255, 255, 255)
574
+ cand = blend(color, other, t)
575
+ if contrast_ratio(cand, bg) >= minimum:
576
+ return cand
577
+ return (0, 0, 0) if target_dark else (255, 255, 255)
578
+
579
+
580
+ # ------------------------------------------------------------ the registry --
581
+ def T(name, label, dark, bg, surf, hover, text, muted, faint, border,
582
+ border_active, accent, alt, secondary, input_bg, sel_bg, sel_text,
583
+ success, warning, error):
584
+ return Theme(
585
+ name=name, label=label, dark=dark,
586
+ background=_hx(bg), surface=_hx(surf), surface_hover=_hx(hover),
587
+ text=_hx(text), text_muted=_hx(muted), text_faint=_hx(faint),
588
+ border=_hx(border), border_active=_hx(border_active),
589
+ accent=_hx(accent), accent_alt=_hx(alt), accent_secondary=_hx(secondary),
590
+ input_background=_hx(input_bg),
591
+ selection_background=_hx(sel_bg), selection_text=_hx(sel_text),
592
+ success=_hx(success), warning=_hx(warning), error=_hx(error))
593
+
594
+
595
+ _THEME_LIST = [
596
+ # --- default (kept visually identical to the legacy "dark") ---
597
+ T("tokyo-night", "\U0001f311 Tokyo Night", True,
598
+ "#1a1b26", "#1e2030", "#222436", "#e8ebfa", "#878caf", "#5a5e80",
599
+ "#737aa2", "#9099b2", "#82aaff", "#7dcfff", "#c09aff",
600
+ "#222436", "#31344b", "#e8ebfa",
601
+ "#8be08b", "#f0b45f", "#ff7f9a"),
602
+ # --- the requested catalog ---
603
+ T("ansi-dark", "\u25ab ANSI Dark", True,
604
+ "#000000", "#101418", "#1a2026", "#d4d4d4", "#9a9a9a", "#6e6e6e",
605
+ "#3a3f44", "#5fafd7", "#5fafd7", "#5fafd7", "#ad7fa8",
606
+ "#161b20", "#264f78", "#ffffff",
607
+ "#8ae234", "#c4a000", "#ff5f5f"),
608
+ T("ansi-light", "\u25ab ANSI Light", False,
609
+ "#ffffff", "#f4f4f4", "#e8e8e8", "#171717", "#555555", "#767676",
610
+ "#c0c0c0", "#005faf", "#005faf", "#005faf", "#5c5099",
611
+ "#fbfbfb", "#b3d4fc", "#101010",
612
+ "#3f7a05", "#8f5700", "#c0392b"),
613
+ T("atom-one-dark", "\u2699 Atom One Dark", True,
614
+ "#282c34", "#2f343d", "#3a404b", "#d7dae0", "#828997", "#5c6370",
615
+ "#3e4451", "#61afef", "#61afef", "#56b6c2", "#c678dd",
616
+ "#2c313a", "#3e4451", "#d7dae0",
617
+ "#98c379", "#e5c07b", "#e06c75"),
618
+ T("atom-one-light", "\u2699 Atom One Light", False,
619
+ "#fafafa", "#ffffff", "#ececec", "#383a42", "#696c77", "#a0a1a7",
620
+ "#dcdfe4", "#4078f2", "#4078f2", "#0184bc", "#a626a4",
621
+ "#f0f0f0", "#bfd7ff", "#1b1f27",
622
+ "#50a14f", "#c18401", "#e45649"),
623
+ T("catppuccin-frappe", "\U0001f63b Catppuccin Frapp\u00e9", True,
624
+ "#303446", "#292c3c", "#414559", "#c6d0f5", "#a5adce", "#737994",
625
+ "#414559", "#8caaee", "#8caaee", "#99d1db", "#babbf1",
626
+ "#292c3c", "#4c517d", "#e6ebff",
627
+ "#a6d189", "#e5c890", "#e78284"),
628
+ T("catppuccin-latte", "\U0001f63b Catppuccin Latte", False,
629
+ "#eff1f5", "#e6e9ef", "#ccd0da", "#4c4f69", "#6c6f85", "#8c8fa1",
630
+ "#bcc0cc", "#1e66f5", "#1e66f5", "#04a5e5", "#8839ef",
631
+ "#e6e9ef", "#aecbff", "#1b2130",
632
+ "#40a02b", "#df8e1d", "#d20f39"),
633
+ T("catppuccin-macchiato", "\U0001f63b Catppuccin Macchiato", True,
634
+ "#24273a", "#1e2030", "#363a4f", "#cad3f5", "#a5adcb", "#6e738d",
635
+ "#363a4f", "#8aadf4", "#8aadf4", "#91d7e3", "#b7bdf8",
636
+ "#1e2030", "#454a63", "#e8ecff",
637
+ "#a6da95", "#eed49f", "#ed8796"),
638
+ T("catppuccin-mocha", "\U0001f63b Catppuccin Mocha", True,
639
+ "#1e1e2e", "#181825", "#313244", "#cdd6f4", "#a6adc8", "#6c7086",
640
+ "#313244", "#89b4fa", "#89b4fa", "#89dceb", "#cba6f7",
641
+ "#181825", "#45475a", "#dde3ff",
642
+ "#a6e3a1", "#f9e2af", "#f38ba8"),
643
+ T("dracula", "\U0001f408 Dracula", True,
644
+ "#282a36", "#21222c", "#343746", "#f8f8f2", "#8b93b8", "#6272a4",
645
+ "#44475a", "#bd93f9", "#bd93f9", "#8be9fd", "#ff79c6",
646
+ "#21222c", "#44475a", "#f8f8f2",
647
+ "#50fa7b", "#ffb86c", "#ff5555"),
648
+ T("flexoki", "\u25fd Flexoki Dark", True,
649
+ "#100f0f", "#1c1b1a", "#282726", "#cecdc3", "#a8a49c", "#878479",
650
+ "#333130", "#da702c", "#da702c", "#3aa99f", "#8b7ec8",
651
+ "#1c1b1a", "#3d3c38", "#fffcf0",
652
+ "#879a39", "#d0a215", "#d14d41"),
653
+ T("gruvbox", "\u25a4 Gruvbox Dark", True,
654
+ "#282828", "#32302f", "#3c3836", "#ebdbb2", "#bdae93", "#928374",
655
+ "#504945", "#fe8013", "#fe8013", "#83a598", "#d3869b",
656
+ "#32302f", "#504945", "#fbf1c7",
657
+ "#b8bb26", "#fabd2f", "#fb4934"),
658
+ T("monokai", "\u2590 Monokai", True,
659
+ "#272822", "#2d2e27", "#3e3d32", "#f8f8f2", "#a6a28d", "#75715e",
660
+ "#49483e", "#a6e22e", "#a6e22e", "#66d9ef", "#ae81ff",
661
+ "#1e1f1c", "#49483e", "#f8f8f2",
662
+ "#a6e22e", "#e6db74", "#f92672"),
663
+ T("nord", "\u2744 Nord", True,
664
+ "#2e3440", "#3b4252", "#434c5e", "#eceff4", "#c0c8d4", "#7b88a1",
665
+ "#434c5e", "#88c0d0", "#88c0d0", "#81a1c1", "#b48ead",
666
+ "#3b4252", "#434c5e", "#eceff4",
667
+ "#a3be8c", "#ebcb8b", "#bf616a"),
668
+ T("rose-pine", "\u2b50 Rose Pine", True,
669
+ "#191724", "#1f1d2e", "#26233a", "#e0def4", "#908caa", "#6e6a86",
670
+ "#403d52", "#c4a7e7", "#c4a7e7", "#9ccfd8", "#ebbcba",
671
+ "#1f1d2e", "#403d52", "#e0def4",
672
+ "#9ccfd8", "#f6c177", "#eb6f92"),
673
+ T("rose-pine-dawn", "\u2b50 Rose Pine Dawn", False,
674
+ "#faf4ed", "#fffaf3", "#f2e9e1", "#575279", "#797593", "#9893a5",
675
+ "#dfdad9", "#286983", "#286983", "#56949f", "#907aa9",
676
+ "#fffaf3", "#ecd9cf", "#3d2f42",
677
+ "#56949f", "#ea9d34", "#b4637a"),
678
+ T("rose-pine-moon", "\u2b50 Rose Pine Moon", True,
679
+ "#232136", "#2a273f", "#393552", "#e0def4", "#908caa", "#6e6a86",
680
+ "#56526e", "#c4a7e7", "#c4a7e7", "#9ccfd8", "#ebbcba",
681
+ "#2a273f", "#393552", "#e0def4",
682
+ "#9ccfd8", "#f6c177", "#eb6f92"),
683
+ T("solarized-dark", "\u2600 Solarized Dark", True,
684
+ "#002b36", "#073642", "#0f4c59", "#93a1a1", "#839496", "#586e75",
685
+ "#1a4b57", "#268bd2", "#268bd2", "#2aa198", "#6c71c4",
686
+ "#073642", "#124b59", "#eee8d5",
687
+ "#859900", "#b58900", "#dc322f"),
688
+ T("solarized-light", "\u2600 Solarized Light", False,
689
+ "#fdf6e3", "#eee8d5", "#e4ddca", "#586e75", "#657b83", "#93a1a1",
690
+ "#dcd6c6", "#268bd2", "#268bd2", "#2aa198", "#6c71c4",
691
+ "#eee8d5", "#e2dcc8", "#073642",
692
+ "#859900", "#b58900", "#dc322f"),
693
+ T("textual-dark", "\u25aa Textual Dark", True,
694
+ "#141414", "#1e1e1e", "#2b2b2b", "#e8e8e8", "#a5a5a5", "#6f6f6f",
695
+ "#3b3b3b", "#4a9eff", "#4a9eff", "#63d0e8", "#b085eb",
696
+ "#1e1e1e", "#2b4c6f", "#e8e8e8",
697
+ "#4ebf22", "#ffcf24", "#e05656"),
698
+ T("textual-light", "\u25aa Textual Light", False,
699
+ "#f5f5f7", "#ffffff", "#e6e6e9", "#1c1c1e", "#606066", "#8e8e93",
700
+ "#d2d2d7", "#0067c0", "#0067c0", "#8244c4", "#00769e",
701
+ "#ffffff", "#b9d7f8", "#18202b",
702
+ "#1a7f37", "#9a6700", "#d12f2f"),
703
+ # --- legacy light kept as a first-class entry (alias "light") so
704
+ # existing installs look exactly as before ---
705
+ T("github-light", "\u2600 GitHub Light", False,
706
+ "#ffffff", "#ffffff", "#f0f3f7", "#181c22", "#4a525c", "#6e7781",
707
+ "#bac2cc", "#0969da", "#0969da", "#0969da", "#8250df",
708
+ "#f0f3f7", "#b9d7f8", "#18202b",
709
+ "#107c2d", "#925800", "#cf222e"),
710
+ ]
711
+
712
+ _THEMES = {t.name: t for t in _THEME_LIST}
713
+
714
+ # Compatibility aliases: saved configs / /theme dark | light keep working.
715
+ _THEME_ALIASES = {"dark": "tokyo-night", "light": "github-light"}
716
+
717
+ DEFAULT_THEME_NAME = "tokyo-night"
718
+
719
+ # Real terminal background paint map (best-effort — OSC 11), derived from
720
+ # every registered theme so any theme switch paints the right backdrop.
721
+ _TERMINAL_BG = {t.name: t.background for t in _THEME_LIST}
722
+
723
+ CURRENT_THEME_NAME = DEFAULT_THEME_NAME
724
+ _THEME_FILE = os.path.join(os.path.expanduser("~"), ".cct_theme.json")
725
+
726
+
727
+ def resolve_theme_name(name):
728
+ """Map any accepted identifier (canonical name, alias, case/space
729
+ variant) to a registered canonical name; unknown names fall back to
730
+ the current theme rather than crashing."""
731
+ key = str(name).strip().lower().replace(" ", "-").replace("_", "-")
732
+ key = _THEME_ALIASES.get(key, key)
733
+ if key == "light-theme":
734
+ key = "github-light"
735
+ if key in _THEMES:
736
+ return key
737
+ if key.startswith("light"):
738
+ return _THEME_ALIASES["light"]
739
+ if key.startswith("dark"):
740
+ return _THEME_ALIASES["dark"]
741
+ return CURRENT_THEME_NAME if CURRENT_THEME_NAME in _THEMES else DEFAULT_THEME_NAME
742
+
743
+
744
+ def get_theme_obj(name=None):
745
+ """The normalized Theme object for `name` (or the active theme)."""
746
+ resolved = resolve_theme_name(name if name is not None else CURRENT_THEME_NAME)
747
+ return _THEMES.get(resolved) or _THEMES[DEFAULT_THEME_NAME]
748
+
749
+
750
+ # ------------------------------------------------- legacy var compilation --
751
+ _THEME_COLOR_VARS = [
752
+ "CYAN", "PURPLE", "GREEN", "ORANGE", "RED", "TEXT", "DIM", "FAINT",
753
+ "BG_USER", "BG_AI", "BG_OK", "BG_WARN", "BG_INFO",
754
+ "ACCENT_BLUE", "BORDER_BLUE", "BORDER_SOFT_A", "BORDER_SOFT_B", "ACCENT_VIVID",
755
+ "STATUS_AMBER", "META_DIM", "HINT_FAINT", "HINT_LABEL",
756
+ "OC_BG_PANEL", "OC_BG_ELEMENT", "OC_BORDER", "OC_BORDER_ACTIVE",
757
+ "OC_PRIMARY", "OC_SECONDARY", "OC_ACCENT", "OC_MUTED",
758
+ ]
759
+
760
+
761
+ _COMPILED_CACHE = {}
762
+
763
+
764
+ def compile_theme_vars(t):
765
+ """Compile a normalized Theme into the 30 legacy module-global colors
766
+ every print()-based panel reads (theme.CYAN, theme.BG_AI, ...). The
767
+ two pre-existing themes get hand-tuned overrides so their look stays
768
+ byte-for-byte what it was before the overhaul."""
769
+ if t.name in _COMPILED_CACHE:
770
+ return dict(_COMPILED_CACHE[t.name])
771
+ dark = t.dark
772
+ bg = t.background
773
+
774
+ bubble_user = blend(bg, t.accent_alt, 0.30 if dark else 0.14)
775
+ bubble_ai = blend(bg, t.accent_secondary, 0.30 if dark else 0.10)
776
+ bubble_ok = blend(bg, t.success, 0.32 if dark else 0.16)
777
+ bubble_warn = blend(bg, t.warning, 0.32 if dark else 0.20)
778
+ bubble_info = blend(bg, t.accent_alt, 0.22 if dark else 0.12)
779
+
780
+ soft_a = blend(t.border, t.text, 0.22)
781
+ soft_b = blend(t.border, bg, 0.30)
782
+ vivid = blend(t.accent_alt, (255, 255, 255), 0.15) if dark else t.accent_alt
783
+ status_amber = fit_contrast(t.warning, bg, 3.0)
784
+
785
+ return {
786
+ "CYAN": fit_contrast(t.accent_alt, bg, 3.0),
787
+ "PURPLE": fit_contrast(t.accent_secondary, bg, 3.0),
788
+ "GREEN": fit_contrast(t.success, bg, 3.0),
789
+ "ORANGE": status_amber,
790
+ "RED": fit_contrast(t.error, bg, 3.0),
791
+ "TEXT": t.text,
792
+ "DIM": fit_contrast(t.text_muted, bg, 2.5),
793
+ "FAINT": t.text_faint,
794
+ "BG_USER": bubble_user, "BG_AI": bubble_ai, "BG_OK": bubble_ok,
795
+ "BG_WARN": bubble_warn, "BG_INFO": bubble_info,
796
+ "ACCENT_BLUE": t.accent_alt,
797
+ "BORDER_BLUE": t.border,
798
+ "BORDER_SOFT_A": soft_a, "BORDER_SOFT_B": soft_b,
799
+ "ACCENT_VIVID": vivid,
800
+ "STATUS_AMBER": status_amber,
801
+ "META_DIM": t.text_muted,
802
+ "HINT_FAINT": t.text_faint,
803
+ "HINT_LABEL": t.text_muted,
804
+ "OC_BG_PANEL": t.surface,
805
+ "OC_BG_ELEMENT": t.input_background,
806
+ "OC_BORDER": t.border,
807
+ "OC_BORDER_ACTIVE": fit_contrast(t.border_active, t.surface, 2.0),
808
+ "OC_PRIMARY": fit_contrast(t.accent, t.surface, 3.0),
809
+ "OC_SECONDARY": fit_contrast(t.accent_secondary, t.surface, 3.0),
810
+ "OC_ACCENT": status_amber,
811
+ "OC_MUTED": t.text_muted,
812
+ }
813
+ _COMPILED_CACHE[t.name] = dict(res)
814
+ return res
815
+
816
+
817
+ # Hand-tuned legacy tables for the two themes that existed before the
818
+ # overhaul — applied verbatim so existing users see zero visual drift.
819
+ _LEGACY_OVERRIDES = {
820
+ "tokyo-night": {
821
+ "CYAN": (125, 207, 255), "PURPLE": (195, 160, 255), "GREEN": (139, 224, 139),
822
+ "ORANGE": (240, 180, 95), "RED": (255, 127, 154), "TEXT": (232, 235, 250),
823
+ "DIM": (135, 140, 175), "FAINT": (90, 94, 128),
824
+ "BG_USER": (40, 62, 92), "BG_AI": (48, 40, 74), "BG_OK": (33, 74, 55),
825
+ "BG_WARN": (79, 63, 30), "BG_INFO": (33, 58, 79),
826
+ "ACCENT_BLUE": (122, 162, 247), "BORDER_BLUE": (61, 71, 112),
827
+ "BORDER_SOFT_A": (74, 86, 130), "BORDER_SOFT_B": (60, 70, 110),
828
+ "ACCENT_VIVID": (130, 170, 255), "STATUS_AMBER": (230, 175, 110),
829
+ "META_DIM": (110, 120, 160), "HINT_FAINT": (96, 100, 134),
830
+ "HINT_LABEL": (130, 138, 175),
831
+ "OC_BG_PANEL": (30, 32, 48), "OC_BG_ELEMENT": (34, 36, 54),
832
+ "OC_BORDER": (115, 122, 162), "OC_BORDER_ACTIVE": (144, 153, 178),
833
+ "OC_PRIMARY": (130, 170, 255), "OC_SECONDARY": (192, 153, 255),
834
+ "OC_ACCENT": (255, 150, 108), "OC_MUTED": (130, 139, 184),
835
+ },
836
+ "github-light": {
837
+ "CYAN": (9, 105, 218), "PURPLE": (130, 80, 223), "GREEN": (16, 124, 45),
838
+ "ORANGE": (146, 88, 0), "RED": (207, 34, 46), "TEXT": (24, 28, 34),
839
+ "DIM": (74, 82, 92), "FAINT": (110, 119, 129),
840
+ "BG_USER": (215, 243, 255), "BG_AI": (247, 236, 255), "BG_OK": (212, 250, 221),
841
+ "BG_WARN": (255, 246, 186), "BG_INFO": (204, 238, 255),
842
+ "ACCENT_BLUE": (9, 105, 218), "BORDER_BLUE": (176, 192, 208),
843
+ "BORDER_SOFT_A": (196, 204, 213), "BORDER_SOFT_B": (214, 221, 228),
844
+ "ACCENT_VIVID": (9, 105, 218), "STATUS_AMBER": (133, 79, 0),
845
+ "META_DIM": (74, 82, 92), "HINT_FAINT": (110, 119, 129), "HINT_LABEL": (74, 82, 92),
846
+ "OC_BG_PANEL": (255, 255, 255), "OC_BG_ELEMENT": (240, 243, 247),
847
+ "OC_BORDER": (186, 194, 204), "OC_BORDER_ACTIVE": (9, 105, 218),
848
+ "OC_PRIMARY": (9, 105, 218), "OC_SECONDARY": (130, 80, 223),
849
+ "OC_ACCENT": (176, 72, 0), "OC_MUTED": (74, 82, 92),
850
+ },
851
+ }
852
+
853
+ # GitHub-Light syntax palette for the code pad (feature 1) — deliberately
854
+ # fixed regardless of the app's own dark/light theme, since that's what was
855
+ # asked for ("own syntax with light github theme").
856
+ SYNTAX_GITHUB_LIGHT = {
857
+ "keyword": (207, 34, 46), "string": (10, 48, 105), "comment": (106, 115, 125),
858
+ "number": (0, 92, 197), "function": (130, 80, 223), "builtin": (149, 56, 0),
859
+ "operator": (36, 41, 47), "plain": (36, 41, 47),
860
+ }
861
+
862
+
863
+ def _paint_terminal_bg(name):
864
+ try:
865
+ r, g, b = _TERMINAL_BG[name]
866
+ sys.stdout.write(f"\033]11;#{r:02x}{g:02x}{b:02x}\033\\")
867
+ sys.stdout.flush()
868
+ except Exception:
869
+ pass
870
+
871
+
872
+ def set_theme(name, persist=True, paint_bg=True):
873
+ """Apply a theme by name (canonical id or legacy alias). Compiles the
874
+ normalized Theme into this module's color globals, repaints the
875
+ terminal backdrop, and — unless `persist=False` (hover previews!) —
876
+ saves the choice to BOTH stores (~/.cct_theme.json and the central
877
+ config's default_theme) so it survives restarts.
878
+
879
+ Returns the canonical theme name actually applied."""
880
+ global CURRENT_THEME_NAME
881
+ resolved = resolve_theme_name(name)
882
+ t = _THEMES[resolved]
883
+ compiled = compile_theme_vars(t)
884
+ compiled.update(_LEGACY_OVERRIDES.get(resolved, {}))
885
+ globals().update({k: v for k, v in compiled.items() if k in _THEME_COLOR_VARS})
886
+ CURRENT_THEME_NAME = resolved
887
+ try:
888
+ from .ui import theme_css
889
+ theme_css.invalidate_css_cache()
890
+ except Exception:
891
+ pass
892
+ if paint_bg:
893
+ _paint_terminal_bg(resolved)
894
+ if persist:
895
+ try:
896
+ with open(_THEME_FILE, "w", encoding="utf-8") as f:
897
+ json.dump({"theme": resolved}, f)
898
+ except Exception:
899
+ pass
900
+ try:
901
+ from . import config as _config
902
+ cfg = _config.get_config()
903
+ if cfg.default_theme != resolved:
904
+ cfg.default_theme = resolved
905
+ _config.save_config(cfg)
906
+ except Exception:
907
+ pass
908
+ return resolved
909
+
910
+
911
+ def get_theme():
912
+ return CURRENT_THEME_NAME
913
+
914
+
915
+ def is_light():
916
+ """True when the active theme is light — components that need
917
+ theme-sensitive styling (editor syntax palettes, markdown code
918
+ themes, selection colors) read this instead of sniffing theme
919
+ names themselves."""
920
+ try:
921
+ return not _THEMES[CURRENT_THEME_NAME].dark
922
+ except KeyError:
923
+ return False
924
+
925
+
926
+ def available_themes():
927
+ """THE single authoritative list of canonical theme names. Every
928
+ picker in the app — the main-menu Themes entry, Settings Center,
929
+ and the /theme command — renders from this list, so no interface
930
+ can ever drift from what set_theme() actually supports."""
931
+ return [t.name for t in _THEME_LIST]
932
+
933
+
934
+ def theme_names():
935
+ """Alias kept for symmetry with label lookups; returns the same
936
+ authoritative list as available_themes()."""
937
+ return available_themes()
938
+
939
+
940
+ def theme_label(name):
941
+ """Human label for a theme name (every entry the pickers show comes
942
+ from here so labels stay consistent too)."""
943
+ t = _THEMES.get(resolve_theme_name(name))
944
+ return t.label if t else str(name)
945
+
946
+
947
+ def blend(c1, c2, t):
948
+ """Linear RGB blend between two color tuples — used to build the
949
+ per-mode gradients (v0.7.8, FEATURE 3) without any extra deps."""
950
+ return tuple(int(round(a + (b - a) * t)) for a, b in zip(c1, c2))
951
+
952
+
953
+ # ------------------------------------------------------- color roles --
954
+ # v0.7.9.0 light-mode audit: one semantic role -> palette-attribute map
955
+ # shared by EVERY component that paints accent-colored text outside CSS
956
+ # (thinking-stage spinners, the command palette icons, completion
957
+ # summaries). Roles resolve against the CURRENT theme's globals at call
958
+ # time, so dark keeps its Tokyo Night values and light automatically
959
+ # gets its darker, WCAG-readable counterparts — no hardcoded hex can
960
+ # leak a dark-mode color onto a white surface anymore.
961
+ _COLOR_ROLES = {
962
+ "blue": ("ACCENT_BLUE", "CYAN"),
963
+ "cyan": ("CYAN",),
964
+ "purple": ("PURPLE",),
965
+ "amber": ("STATUS_AMBER", "ORANGE"),
966
+ "green": ("GREEN",),
967
+ "red": ("RED",),
968
+ "gray": ("FAINT", "DIM"),
969
+ "muted": ("META_DIM", "DIM"),
970
+ }
971
+
972
+
973
+ def role_hex(role, fallback="#888888"):
974
+ """Hex string for a semantic color role in the CURRENT theme.
975
+ Unknown roles return `fallback`; never raises."""
976
+ for attr in _COLOR_ROLES.get(str(role), ()):
977
+ rgb = globals().get(attr)
978
+ if isinstance(rgb, tuple) and len(rgb) == 3:
979
+ try:
980
+ return "#{:02x}{:02x}{:02x}".format(*rgb)
981
+ except Exception:
982
+ continue
983
+ return fallback
984
+
985
+
986
+ def load_saved_theme():
987
+ """Restore the user's last theme choice — call once at boot.
988
+
989
+ Priority: an explicit per-session choice already saved to
990
+ _THEME_FILE wins (unchanged behavior). If nothing's been saved
991
+ yet, fall back to the centralized config's default_theme
992
+ (calc_terminal/config.py). v0.7.9.5: ANY registered theme name is
993
+ honored now — the old loader only acted on the literal value
994
+ "light", which silently dropped every other palette."""
995
+ try:
996
+ if os.path.exists(_THEME_FILE):
997
+ with open(_THEME_FILE, encoding="utf-8") as f:
998
+ saved = json.load(f).get("theme", DEFAULT_THEME_NAME)
999
+ else:
1000
+ from . import config as _config
1001
+ saved = _config.get_config().default_theme
1002
+ resolved = resolve_theme_name(saved)
1003
+ # Only re-apply when it differs from the import-time default so a
1004
+ # plain boot with no saved choice doesn't repaint anything.
1005
+ if os.path.exists(_THEME_FILE) or str(saved or "").strip().lower() not in (
1006
+ "", "dark", "tokyo-night", DEFAULT_THEME_NAME):
1007
+ set_theme(resolved, persist=False, paint_bg=True)
1008
+ except Exception:
1009
+ pass
1010
+
1011
+
1012
+ # ------------------------------------------------------- terminal title --
1013
+ # v0.7.9.4: ONE centralized terminal/tab-title owner (requirement: CAT
1014
+ # must identify itself in the terminal instead of showing "PowerShell").
1015
+ # Uses the OSC-0 title sequence every supported terminal understands —
1016
+ # Windows Terminal, PowerShell 7 console, classic conhost, most *nix
1017
+ # emulators — and degrades to a silent no-op on dumb/piped output so no
1018
+ # raw escape characters ever print.
1019
+ _APP_TITLE_BASE = "CAT CLI"
1020
+
1021
+ _title_saved = False
1022
+
1023
+
1024
+ def _title_ok():
1025
+ try:
1026
+ if os.environ.get("TERM", "") == "dumb":
1027
+ return False
1028
+ return bool(getattr(sys.stdout, "isatty", lambda: False)())
1029
+ except Exception:
1030
+ return False
1031
+
1032
+
1033
+ def set_terminal_title(title):
1034
+ """Set the terminal/window/tab title. Centralized — call this (or
1035
+ set_terminal_title_state) rather than emitting OSC sequences anywhere
1036
+ else. Never raises; no-op when output isn't a real terminal."""
1037
+ global _title_saved
1038
+ if not _title_ok():
1039
+ return False
1040
+ clean = "".join(ch for ch in str(title)
1041
+ if ch.isprintable() and ch not in "\x07\x1b")[:80]
1042
+ try:
1043
+ # xterm title-stack push once, before the first custom title, so
1044
+ # restore_terminal_title() can bring the user's shell label back.
1045
+ # Terminals without stacking support ignore it silently.
1046
+ if not _title_saved:
1047
+ try:
1048
+ sys.stdout.write("\x1b[22t")
1049
+ except Exception:
1050
+ pass
1051
+ _title_saved = True
1052
+ osc_seq = f"\x1b]0;{clean}\x07"
1053
+ try:
1054
+ sys.stdout.write(osc_seq)
1055
+ sys.stdout.flush()
1056
+ except UnicodeEncodeError:
1057
+ buf = getattr(sys.stdout, "buffer", None)
1058
+ if buf is not None:
1059
+ buf.write(osc_seq.encode("utf-8"))
1060
+ buf.flush()
1061
+
1062
+ # Also set Win32 console title and icon if on Windows
1063
+ if sys.platform == "win32":
1064
+ try:
1065
+ import ctypes
1066
+ ctypes.windll.kernel32.SetConsoleTitleW(clean)
1067
+ from . import terminal_identity
1068
+ terminal_identity.apply_console_icon()
1069
+ except Exception:
1070
+ pass
1071
+
1072
+ return True
1073
+ except Exception:
1074
+ return False
1075
+
1076
+
1077
+ def set_terminal_title_state(state):
1078
+ """Standardized CAT CLI title states:
1079
+ starting | ready | running | exiting."""
1080
+ mapping = {
1081
+ "starting": "CAT CLI — Starting",
1082
+ "ready": "CAT CLI — Ready",
1083
+ "running": "CAT CLI — Running",
1084
+ "exiting": "CAT CLI — Exiting",
1085
+ }
1086
+ suffix = mapping.get(str(state).lower())
1087
+ if suffix:
1088
+ return set_terminal_title(suffix)
1089
+ return set_terminal_title(str(state))
1090
+
1091
+
1092
+ def restore_terminal_title():
1093
+ """Give the shell its identity back after CAT exits (xterm stack
1094
+ pop; terminals that never pushed just keep whatever they had — no
1095
+ garbage is printed either way)."""
1096
+ global _title_saved
1097
+ if not _title_saved or not _title_ok():
1098
+ _title_saved = False
1099
+ return False
1100
+ try:
1101
+ sys.stdout.write("\x1b[23t")
1102
+ sys.stdout.flush()
1103
+ _title_saved = False
1104
+ return True
1105
+ except Exception:
1106
+ _title_saved = False
1107
+ return False