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/model.py ADDED
@@ -0,0 +1,2431 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CAT CLI — Universal AI Provider Hub & Provider Center 2.0.
4
+
5
+ Interactive, responsive AI provider and model marketplace with:
6
+ - 160+ verified trusted providers
7
+ - Dynamic model discovery
8
+ - Real-time responsive layout engine (CATRootViewport)
9
+ - Persistent navigation bar with breadcrumbs and A–Z index
10
+ - Multi-category provider and model filters
11
+ - Verification-based custom and "My Models" management
12
+ - Hot model switching without CAT CLI restart
13
+ """
14
+
15
+ import sys
16
+ import os
17
+ import time
18
+ import json
19
+ import re
20
+ import threading
21
+ from typing import Any, Dict, List, Optional, Set, Tuple
22
+
23
+ # ---- resolve project root -------------------------------------------------
24
+ _HERE = os.path.dirname(os.path.abspath(__file__))
25
+ _ROOT = (os.path.dirname(_HERE)
26
+ if os.path.basename(_HERE) == "calc_terminal" else _HERE)
27
+ if _ROOT not in sys.path:
28
+ try:
29
+ import calc_terminal as _ct_check # noqa: F401
30
+ except Exception:
31
+ sys.path.insert(0, _ROOT)
32
+
33
+ # ---- startup logging ------------------------------------------------------
34
+ # Writable user logs dir first (works when pip-installed into read-only
35
+ # site-packages); legacy repo-root startup.log kept as fallback for source
36
+ # checkouts. Best-effort only — logging must never crash the app.
37
+ def _resolve_log_file() -> str:
38
+ try:
39
+ from calc_terminal.first_run import logs_dir as _logs_dir
40
+
41
+ _d = _logs_dir()
42
+ os.makedirs(_d, exist_ok=True)
43
+ return os.path.join(_d, "startup.log")
44
+ except Exception:
45
+ pass
46
+ return os.path.join(_ROOT, "startup.log")
47
+
48
+
49
+ _LOG_FILE = _resolve_log_file()
50
+ def _log(msg, exc_info=False):
51
+ try:
52
+ with open(_LOG_FILE, "a", encoding="utf-8") as f:
53
+ f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n")
54
+ if exc_info:
55
+ import traceback
56
+ traceback.print_exc(file=f)
57
+ except Exception:
58
+ pass
59
+
60
+ # Clear log on fresh start
61
+ try:
62
+ with open(_LOG_FILE, "w", encoding="utf-8") as f:
63
+ f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] === CAT AI Provider Hub 2.0 ===\n")
64
+ except Exception:
65
+ pass
66
+
67
+ from calc_terminal import aicore, theme
68
+ from calc_terminal.ui import theme_css
69
+ from calc_terminal.ui.viewport import CATRootViewport, CATViewportScreenMixin, get_terminal_size_class
70
+ from calc_terminal.models.active_state import ActiveAIStateManager, get_active_state, set_active_ai
71
+ from calc_terminal.models.verification_engine import ModelVerificationEngine, VerificationStatus
72
+
73
+ TEXTUAL_OK = True
74
+ try:
75
+ from textual.app import App, ComposeResult
76
+ from textual.screen import Screen, ModalScreen
77
+ from textual.containers import Horizontal, Vertical, Container, ScrollableContainer
78
+ from textual.widgets import Static, Input, Button, Label, ListView, ListItem, Header, Footer
79
+ from textual.binding import Binding
80
+ from textual.reactive import reactive
81
+ from textual.widget import Widget
82
+ from textual.events import Resize
83
+ except ImportError:
84
+ TEXTUAL_OK = False
85
+
86
+ # ── ASCII Art & Logos ──────────────────────────────────────────────────────────
87
+
88
+ CCT_LOGO = [
89
+ r" __ ",
90
+ r" /\ \__ ",
91
+ r" ___ ___\ \ ,_\ ",
92
+ r" /'___\ /'___\ \ \/ ",
93
+ r"/\ \__//\ \__/\ \ \_ ",
94
+ r"\ \____\ \____\\ \__\ ",
95
+ r" \/____/\/____/ \/__/",
96
+ ]
97
+
98
+ CCT_ALT = [
99
+ r" _ _ _ ",
100
+ r" /\ \ /\ \ /\ \ ",
101
+ r" / \ \ / \ \ \_\ \ ",
102
+ r" / /\ \ \ / /\ \ \ /\__ \ ",
103
+ r" / / /\ \ \ / / /\ \ \ / /_ \ \ ",
104
+ r" / / / \ \_\ / / / \ \_\ / / /\ \ \ ",
105
+ r" / / / \/_/ / / / \/_/ / / / \/_/",
106
+ r" / / / / / / / / / ",
107
+ r" / / /________ / / /________ / / / ",
108
+ r"/ / /_________\ / / /_________\ /_/ / ",
109
+ r"\/____________/ \/____________/ \_\/ ",
110
+ ]
111
+
112
+ CCT_CHEM = [
113
+ r" _____ _____ _____ ",
114
+ r" /\ \ /\ \ /\ \ ",
115
+ r" /::\ \ /::\ \ /::\ \ ",
116
+ r" /::::\ \ /::::\ \ \:::\ \ ",
117
+ r" /::::::\ \ /::::::\ \ \:::\ \ ",
118
+ r" /:::/\:::\ \ /:::/\:::\ \ \:::\ \ ",
119
+ r" /:::/ \:::\ \ /:::/ \:::\ \ \:::\ \ ",
120
+ r" /:::/ \:::\ \ /:::/ \:::\ \ /::::\ \ ",
121
+ r" /:::/ / \:::\ \ /:::/ / \:::\ \ /::::::\ \ ",
122
+ r" /:::/ / \:::\ \ /:::/ / \:::\ \ /:::/\:::\ \ ",
123
+ r"/:::/____/ \:::\____\/:::/____/ \:::\____\ /:::/ \:::\____\ ",
124
+ r"\:::\ \ \::/ /\:::\ \ \::/ / /:::/ \::/ /",
125
+ r" \:::\ \ \/____/ \:::\ \ \/____/ /:::/ / \/____/ ",
126
+ r" \:::\ \ \:::\ \ /:::/ / ",
127
+ r" \:::\ \ \:::\ \ /:::/ / ",
128
+ r" \:::\ \ \:::\ \ \::/ / ",
129
+ r" \:::\ \ \:::\ \ \/____/ ",
130
+ r" \:::\ \ \:::\ \ ",
131
+ r" \:::\____\ \:::\____\ ",
132
+ r" \::/ / \::/ / ",
133
+ r" \/____/ \/____/ ",
134
+ ]
135
+
136
+ # ── Color Palette & Helpers ───────────────────────────────────────────────────
137
+
138
+ def _hex(r, g, b):
139
+ return f"#{r:02x}{g:02x}{b:02x}"
140
+
141
+ PURPLE = (187, 154, 247)
142
+ CYAN = (122, 162, 247)
143
+ GREEN = (158, 206, 106)
144
+ ORANGE = (224, 175, 104)
145
+ RED = (224, 108, 117)
146
+ GRAY = (100, 110, 125)
147
+ DIM = (75, 85, 100)
148
+ BG = (26, 27, 38)
149
+
150
+ C_ACCENT = _hex(*PURPLE)
151
+ C_CYAN = _hex(*CYAN)
152
+ C_GREEN = _hex(*GREEN)
153
+ C_ORANGE = _hex(*ORANGE)
154
+ C_RED = _hex(*RED)
155
+ C_DIM = _hex(*DIM)
156
+
157
+ def _step_header(current, label):
158
+ """Generate a step indicator line like 'Step 1 of 4 — Select Provider'."""
159
+ dots = ""
160
+ for i in range(1, 5):
161
+ if i == current:
162
+ dots += f"[{C_ACCENT} b]●[/] "
163
+ elif i < current:
164
+ dots += f"[{C_GREEN}]●[/] "
165
+ else:
166
+ dots += f"[{C_DIM}]○[/] "
167
+ return f" {dots} [{C_DIM}]Step {current} of 4 — {label}[/]"
168
+
169
+ def fuzzy_match(query: str, text: str) -> bool:
170
+ query = query.lower().strip()
171
+ if not query:
172
+ return True
173
+ text = text.lower()
174
+ if query in text:
175
+ return True
176
+ qi, ti = 0, 0
177
+ while qi < len(query) and ti < len(text):
178
+ if query[qi] == text[ti]:
179
+ qi += 1
180
+ ti += 1
181
+ return qi == len(query)
182
+
183
+ # ── Provider Database Loader ──────────────────────────────────────────────────
184
+
185
+ _EMERGENCY_FALLBACK_PROVIDERS = [
186
+ {"id": "openai", "name": "OpenAI", "country": "United States",
187
+ "url": "https://api.openai.com/v1", "api_style": "openai", "needs_key": True,
188
+ "models": ["gpt-4o", "gpt-4o-mini", "o3-mini"],
189
+ "features": ["chat", "reasoning", "vision", "embedding", "speech"],
190
+ "openai_compat": True, "desc": "GPT-4o & o-series"},
191
+ {"id": "anthropic", "name": "Anthropic (Claude)", "country": "United States",
192
+ "url": "https://api.anthropic.com/v1", "api_style": "anthropic", "needs_key": True,
193
+ "models": ["claude-4-sonnet", "claude-3-5-sonnet", "claude-3-5-haiku"],
194
+ "features": ["chat", "reasoning", "vision", "code"],
195
+ "openai_compat": False, "desc": "Claude 4 Sonnet & family"},
196
+ {"id": "deepseek", "name": "DeepSeek", "country": "China",
197
+ "url": "https://api.deepseek.com/v1", "api_style": "openai", "needs_key": True,
198
+ "models": ["deepseek-chat", "deepseek-reasoner"],
199
+ "features": ["chat", "reasoning", "code"], "openai_compat": True,
200
+ "desc": "DeepSeek-V3 & R1"},
201
+ {"id": "ollama", "name": "Ollama (Local)", "country": "International",
202
+ "url": "http://localhost:11434", "api_style": "ollama", "needs_key": False,
203
+ "models": ["llama3.3", "mistral", "deepseek-r1"],
204
+ "features": ["chat", "local"], "openai_compat": True, "desc": "Local models"},
205
+ ]
206
+
207
+ def _load_provider_database() -> List[Dict]:
208
+ """Build normalized provider list from providers.json."""
209
+ try:
210
+ from calc_terminal.models import manager as _mgr
211
+ providers = _mgr.list_providers()
212
+ out = []
213
+ for p in providers:
214
+ features = []
215
+ if p.get("supports_reasoning"):
216
+ features.append("reasoning")
217
+ if p.get("supports_vision"):
218
+ features.append("vision")
219
+ if p.get("supports_embeddings"):
220
+ features.append("embedding")
221
+ if p.get("supports_audio"):
222
+ features.append("speech")
223
+ if p.get("supports_streaming"):
224
+ features.append("chat")
225
+ out.append({
226
+ "id": p.get("id", ""),
227
+ "name": p.get("name", p.get("id", "")),
228
+ "country": p.get("country", ""),
229
+ "company": p.get("company", ""),
230
+ "url": p.get("api_endpoint", ""),
231
+ "api_style": p.get("api_style", "openai"),
232
+ "needs_key": bool(p.get("needs_key", True)),
233
+ "models": [str(m) for m in p.get("fallback_models", [])],
234
+ "default_model": p.get("default_model", ""),
235
+ "features": features or ["chat"],
236
+ "openai_compat": bool(p.get("openai_compatible", False)),
237
+ "desc": p.get("description", ""),
238
+ "status": p.get("status", "online"),
239
+ "verification_status": p.get("verification_status", "verified"),
240
+ })
241
+ if out:
242
+ return out
243
+ except Exception as e:
244
+ _log(f"_load_provider_database error: {e}", exc_info=True)
245
+ return [dict(p) for p in _EMERGENCY_FALLBACK_PROVIDERS]
246
+
247
+ PROVIDERS = _load_provider_database()
248
+ MORE_PROVIDERS = []
249
+ ALL_PROVIDERS = sorted(PROVIDERS, key=lambda p: p["name"].lower())
250
+
251
+ # ── Shared Wizard CSS ─────────────────────────────────────────────────────────
252
+
253
+ SHARED_WIZARD_CSS = theme_css.BASE_CSS + """
254
+ Screen {
255
+ background: #1a1b26;
256
+ color: #c0caf5;
257
+ width: 100%;
258
+ height: 100%;
259
+ min-width: 100%;
260
+ min-height: 100%;
261
+ padding: 0;
262
+ margin: 0;
263
+ }
264
+ CATRootViewport {
265
+ width: 100%;
266
+ height: 100%;
267
+ min-width: 100%;
268
+ min-height: 100%;
269
+ layout: vertical;
270
+ overflow: hidden;
271
+ }
272
+
273
+ /* Navigation Bar */
274
+ #nav-bar-container {
275
+ height: 4;
276
+ background: #141522;
277
+ border-bottom: solid #24283b;
278
+ padding: 0 1;
279
+ layout: horizontal;
280
+ align: left middle;
281
+ }
282
+ #nav-title {
283
+ color: #a78bfa;
284
+ text-style: bold;
285
+ padding: 0 1;
286
+ width: auto;
287
+ height: 3;
288
+ content-align: left middle;
289
+ }
290
+ #nav-tabs {
291
+ width: 1fr;
292
+ height: 3;
293
+ layout: horizontal;
294
+ overflow-x: auto;
295
+ overflow-y: hidden;
296
+ scrollbar-size: 0 0;
297
+ align: left middle;
298
+ }
299
+ #nav-tabs Button {
300
+ margin: 0 1;
301
+ min-width: 10;
302
+ height: 3;
303
+ padding: 0 2;
304
+ border: heavy;
305
+ border-top: heavy $surface-highlight;
306
+ border-left: heavy $surface-highlight;
307
+ border-bottom: heavy $surface-shadow;
308
+ border-right: heavy $surface-shadow;
309
+ background: $surface-alt;
310
+ color: $text;
311
+ text-style: bold;
312
+ content-align: center middle;
313
+ transition: background 80ms, border 80ms, offset 80ms;
314
+ }
315
+ #nav-tabs Button:hover {
316
+ background: $accent 25%;
317
+ color: #ffffff;
318
+ border-top: heavy #ffffff;
319
+ border-left: heavy #ffffff;
320
+ border-bottom: heavy $accent-highlight;
321
+ border-right: heavy $accent-highlight;
322
+ offset-y: -1;
323
+ }
324
+ #nav-tabs Button.-active {
325
+ offset-y: 1;
326
+ border-top: heavy $accent-shadow;
327
+ border-left: heavy $accent-shadow;
328
+ border-bottom: heavy #ffffff;
329
+ border-right: heavy #ffffff;
330
+ }
331
+ #nav-tabs Button.active-tab {
332
+ background: #7c3aed;
333
+ color: #ffffff;
334
+ text-style: bold;
335
+ border: heavy;
336
+ border-top: heavy #ffffff;
337
+ border-left: heavy #ffffff;
338
+ border-bottom: heavy #3c137a;
339
+ border-right: heavy #3c137a;
340
+ }
341
+ #nav-tabs Button.active-tab:hover {
342
+ background: #8b5cf6;
343
+ border-bottom: heavy #c4b5fd;
344
+ border-right: heavy #c4b5fd;
345
+ color: #ffffff;
346
+ offset-y: -1;
347
+ }
348
+ #nav-actions {
349
+ width: auto;
350
+ height: 3;
351
+ layout: horizontal;
352
+ align: right middle;
353
+ }
354
+ #nav-actions Button {
355
+ margin: 0 1;
356
+ min-width: 6;
357
+ height: 3;
358
+ padding: 0 1;
359
+ border: heavy;
360
+ border-top: heavy $surface-highlight;
361
+ border-left: heavy $surface-highlight;
362
+ border-bottom: heavy $surface-shadow;
363
+ border-right: heavy $surface-shadow;
364
+ background: $surface-alt;
365
+ color: $text;
366
+ text-style: bold;
367
+ content-align: center middle;
368
+ transition: background 80ms, border 80ms, offset 80ms;
369
+ }
370
+ #nav-actions Button:hover {
371
+ background: $accent 25%;
372
+ color: #ffffff;
373
+ border-top: heavy #ffffff;
374
+ border-left: heavy #ffffff;
375
+ border-bottom: heavy $accent-highlight;
376
+ border-right: heavy $accent-highlight;
377
+ offset-y: -1;
378
+ }
379
+ #nav-actions Button.-active {
380
+ offset-y: 1;
381
+ border-top: heavy $accent-shadow;
382
+ border-left: heavy $accent-shadow;
383
+ border-bottom: heavy #ffffff;
384
+ border-right: heavy #ffffff;
385
+ }
386
+
387
+ /* Filters Bar */
388
+ #filter-bar {
389
+ height: 4;
390
+ background: #141522;
391
+ padding: 0 1;
392
+ layout: horizontal;
393
+ align: left middle;
394
+ overflow-x: auto;
395
+ overflow-y: hidden;
396
+ scrollbar-size: 0 0;
397
+ border-bottom: solid #1e2233;
398
+ }
399
+ #filter-bar Button {
400
+ margin: 0 1;
401
+ min-width: 9;
402
+ height: 3;
403
+ padding: 0 2;
404
+ border: heavy;
405
+ border-top: heavy $surface-highlight;
406
+ border-left: heavy $surface-highlight;
407
+ border-bottom: heavy $surface-shadow;
408
+ border-right: heavy $surface-shadow;
409
+ background: $surface-alt;
410
+ color: $text;
411
+ text-style: bold;
412
+ content-align: center middle;
413
+ transition: background 80ms, border 80ms, offset 80ms;
414
+ }
415
+ #filter-bar Button:hover {
416
+ background: $accent 25%;
417
+ color: #ffffff;
418
+ border-top: heavy #ffffff;
419
+ border-left: heavy #ffffff;
420
+ border-bottom: heavy $accent-highlight;
421
+ border-right: heavy $accent-highlight;
422
+ offset-y: -1;
423
+ }
424
+ #filter-bar Button.-active {
425
+ offset-y: 1;
426
+ border-top: heavy $accent-shadow;
427
+ border-left: heavy $accent-shadow;
428
+ border-bottom: heavy #ffffff;
429
+ border-right: heavy #ffffff;
430
+ }
431
+ #filter-bar Button.active-filter {
432
+ background: #7c3aed;
433
+ color: #ffffff;
434
+ text-style: bold;
435
+ border: heavy;
436
+ border-top: heavy #ffffff;
437
+ border-left: heavy #ffffff;
438
+ border-bottom: heavy #3c137a;
439
+ border-right: heavy #3c137a;
440
+ }
441
+ #filter-bar Button.active-filter:hover {
442
+ background: #8b5cf6;
443
+ border-bottom: heavy #c4b5fd;
444
+ border-right: heavy #c4b5fd;
445
+ color: #ffffff;
446
+ offset-y: -1;
447
+ }
448
+
449
+ /* Columns */
450
+ #main-columns {
451
+ width: 100%;
452
+ height: 1fr;
453
+ layout: horizontal;
454
+ }
455
+ #left-col {
456
+ width: 2fr;
457
+ height: 100%;
458
+ border-right: solid #24283b;
459
+ padding: 0 1;
460
+ layout: vertical;
461
+ }
462
+ #right-col {
463
+ width: 1fr;
464
+ height: 100%;
465
+ padding: 1;
466
+ layout: vertical;
467
+ }
468
+ #search-input {
469
+ margin: 0 0 1 0;
470
+ height: 3;
471
+ background: #12131c;
472
+ color: #c0caf5;
473
+ border: tall;
474
+ border-top: tall #08090e;
475
+ border-left: tall #08090e;
476
+ border-bottom: tall #2a2c42;
477
+ border-right: tall #2a2c42;
478
+ padding: 0 1;
479
+ }
480
+ #search-input:focus {
481
+ border: tall;
482
+ border-top: tall #3c137a;
483
+ border-left: tall #3c137a;
484
+ border-bottom: tall #a78bfa;
485
+ border-right: tall #a78bfa;
486
+ }
487
+ #provider-list {
488
+ height: 1fr;
489
+ overflow-y: auto;
490
+ }
491
+ #provider-actions {
492
+ height: 4;
493
+ align: center middle;
494
+ padding: 0 1;
495
+ background: #141522;
496
+ border-top: solid #24283b;
497
+ layout: horizontal;
498
+ overflow-x: auto;
499
+ scrollbar-size: 0 0;
500
+ }
501
+ #provider-actions Button {
502
+ margin: 0 1;
503
+ min-width: 12;
504
+ height: 3;
505
+ padding: 0 2;
506
+ border: heavy;
507
+ border-top: heavy $surface-highlight;
508
+ border-left: heavy $surface-highlight;
509
+ border-bottom: heavy $surface-shadow;
510
+ border-right: heavy $surface-shadow;
511
+ background: $surface-alt;
512
+ color: $text;
513
+ text-style: bold;
514
+ content-align: center middle;
515
+ transition: background 80ms, border 80ms, offset 80ms;
516
+ }
517
+ #provider-actions Button:hover {
518
+ background: $accent 25%;
519
+ color: #ffffff;
520
+ border-top: heavy #ffffff;
521
+ border-left: heavy #ffffff;
522
+ border-bottom: heavy $accent-highlight;
523
+ border-right: heavy $accent-highlight;
524
+ offset-y: -1;
525
+ }
526
+ #provider-actions Button.-active {
527
+ offset-y: 1;
528
+ border-top: heavy $accent-shadow;
529
+ border-left: heavy $accent-shadow;
530
+ border-bottom: heavy #ffffff;
531
+ border-right: heavy #ffffff;
532
+ }
533
+ #btn-select-main {
534
+ background: #7c3aed;
535
+ color: #ffffff;
536
+ text-style: bold;
537
+ border: heavy;
538
+ border-top: heavy #ffffff;
539
+ border-left: heavy #ffffff;
540
+ border-bottom: heavy #3c137a;
541
+ border-right: heavy #3c137a;
542
+ }
543
+ #btn-select-main:hover {
544
+ background: #8b5cf6;
545
+ border-bottom: heavy #c4b5fd;
546
+ border-right: heavy #c4b5fd;
547
+ color: #ffffff;
548
+ offset-y: -1;
549
+ }
550
+
551
+ /* Rows & Details */
552
+ ProviderRow {
553
+ height: 3;
554
+ padding: 0 1;
555
+ border-left: solid transparent;
556
+ }
557
+ ProviderRow:hover {
558
+ background: #24283b;
559
+ }
560
+ ProviderRow.selected {
561
+ background: #202438;
562
+ border-left: thick #7c3aed;
563
+ }
564
+ InfoPanel {
565
+ height: 100%;
566
+ overflow-y: auto;
567
+ layout: vertical;
568
+ }
569
+ #info-content {
570
+ height: 1fr;
571
+ overflow-y: auto;
572
+ }
573
+ #info-actions {
574
+ height: auto;
575
+ layout: vertical;
576
+ margin-top: 1;
577
+ border-top: solid #24283b;
578
+ padding-top: 1;
579
+ }
580
+ #info-actions Button {
581
+ margin: 0 0 1 0;
582
+ width: 100%;
583
+ height: 3;
584
+ padding: 0 2;
585
+ border: heavy;
586
+ border-top: heavy $surface-highlight;
587
+ border-left: heavy $surface-highlight;
588
+ border-bottom: heavy $surface-shadow;
589
+ border-right: heavy $surface-shadow;
590
+ background: $surface-alt;
591
+ color: $text;
592
+ text-style: bold;
593
+ content-align: center middle;
594
+ transition: background 80ms, border 80ms, offset 80ms;
595
+ }
596
+ #info-actions Button:hover {
597
+ background: $accent 25%;
598
+ color: #ffffff;
599
+ border-top: heavy #ffffff;
600
+ border-left: heavy #ffffff;
601
+ border-bottom: heavy $accent-highlight;
602
+ border-right: heavy $accent-highlight;
603
+ offset-y: -1;
604
+ }
605
+ #info-actions Button.-active {
606
+ offset-y: 1;
607
+ border-top: heavy $accent-shadow;
608
+ border-left: heavy $accent-shadow;
609
+ border-bottom: heavy #ffffff;
610
+ border-right: heavy #ffffff;
611
+ }
612
+ #btn-select-provider {
613
+ background: #7c3aed;
614
+ color: #ffffff;
615
+ text-style: bold;
616
+ border: heavy;
617
+ border-top: heavy #ffffff;
618
+ border-left: heavy #ffffff;
619
+ border-bottom: heavy #3c137a;
620
+ border-right: heavy #3c137a;
621
+ }
622
+ #btn-select-provider:hover {
623
+ background: #8b5cf6;
624
+ border-bottom: heavy #c4b5fd;
625
+ border-right: heavy #c4b5fd;
626
+ color: #ffffff;
627
+ offset-y: -1;
628
+ }
629
+ #btn-toggle-failover {
630
+ background: $surface-alt;
631
+ color: $text;
632
+ border: heavy;
633
+ border-top: heavy $surface-highlight;
634
+ border-left: heavy $surface-highlight;
635
+ border-bottom: heavy $surface-shadow;
636
+ border-right: heavy $surface-shadow;
637
+ }
638
+ #btn-toggle-failover:hover {
639
+ background: $accent 25%;
640
+ color: #ffffff;
641
+ border-top: heavy #ffffff;
642
+ border-left: heavy #ffffff;
643
+ border-bottom: heavy $accent-highlight;
644
+ border-right: heavy $accent-highlight;
645
+ offset-y: -1;
646
+ }
647
+ #btn-test-provider {
648
+ background: $surface-alt;
649
+ color: $text;
650
+ border: heavy;
651
+ border-top: heavy $surface-highlight;
652
+ border-left: heavy $surface-highlight;
653
+ border-bottom: heavy $surface-shadow;
654
+ border-right: heavy $surface-shadow;
655
+ }
656
+ #btn-test-provider:hover {
657
+ background: $accent 25%;
658
+ color: #ffffff;
659
+ border-top: heavy #ffffff;
660
+ border-left: heavy #ffffff;
661
+ border-bottom: heavy $accent-highlight;
662
+ border-right: heavy $accent-highlight;
663
+ offset-y: -1;
664
+ }
665
+
666
+ /* ModelScreen Styles */
667
+ #model-nav-bar {
668
+ height: 4;
669
+ background: #141522;
670
+ padding: 0 1;
671
+ layout: horizontal;
672
+ align: left middle;
673
+ border-bottom: solid #1e2233;
674
+ }
675
+ #model-nav-bar Button {
676
+ margin: 0 1;
677
+ min-width: 12;
678
+ height: 3;
679
+ padding: 0 2;
680
+ border: heavy;
681
+ border-top: heavy $surface-highlight;
682
+ border-left: heavy $surface-highlight;
683
+ border-bottom: heavy $surface-shadow;
684
+ border-right: heavy $surface-shadow;
685
+ background: $surface-alt;
686
+ color: $text;
687
+ text-style: bold;
688
+ content-align: center middle;
689
+ transition: background 80ms, border 80ms, offset 80ms;
690
+ }
691
+ #model-nav-bar Button:hover {
692
+ background: $accent 25%;
693
+ color: #ffffff;
694
+ border-top: heavy #ffffff;
695
+ border-left: heavy #ffffff;
696
+ border-bottom: heavy $accent-highlight;
697
+ border-right: heavy $accent-highlight;
698
+ offset-y: -1;
699
+ }
700
+ #model-title {
701
+ height: 3;
702
+ content-align: center middle;
703
+ color: #a78bfa;
704
+ text-style: bold;
705
+ width: 1fr;
706
+ }
707
+ #model-search {
708
+ margin: 1 1 0 1;
709
+ height: 3;
710
+ background: #12131c;
711
+ color: #c0caf5;
712
+ border: tall;
713
+ border-top: tall #08090e;
714
+ border-left: tall #08090e;
715
+ border-bottom: tall #2a2c42;
716
+ border-right: tall #2a2c42;
717
+ padding: 0 1;
718
+ }
719
+ #model-search:focus {
720
+ border: tall;
721
+ border-top: tall #3c137a;
722
+ border-left: tall #3c137a;
723
+ border-bottom: tall #a78bfa;
724
+ border-right: tall #a78bfa;
725
+ }
726
+ #model-count-label {
727
+ padding: 0 2;
728
+ height: 1;
729
+ color: #7982a9;
730
+ }
731
+ #model-list {
732
+ height: 1fr;
733
+ padding: 0 1;
734
+ background: #141522;
735
+ overflow-y: auto;
736
+ }
737
+ ModelRow {
738
+ height: 2;
739
+ padding: 0 1;
740
+ border-left: solid transparent;
741
+ content-align: left middle;
742
+ }
743
+ ModelRow:hover {
744
+ background: #24283b;
745
+ }
746
+ ModelRow.selected {
747
+ background: #202438;
748
+ border-left: thick #7c3aed;
749
+ color: #ffffff;
750
+ text-style: bold;
751
+ }
752
+ #model-actions {
753
+ height: 4;
754
+ align: center middle;
755
+ padding: 0 1;
756
+ background: #141522;
757
+ border-top: solid #24283b;
758
+ layout: horizontal;
759
+ overflow-x: auto;
760
+ scrollbar-size: 0 0;
761
+ }
762
+ #model-actions Button {
763
+ margin: 0 1;
764
+ min-width: 12;
765
+ height: 3;
766
+ padding: 0 2;
767
+ border: heavy;
768
+ border-top: heavy $surface-highlight;
769
+ border-left: heavy $surface-highlight;
770
+ border-bottom: heavy $surface-shadow;
771
+ border-right: heavy $surface-shadow;
772
+ background: $surface-alt;
773
+ color: $text;
774
+ text-style: bold;
775
+ content-align: center middle;
776
+ transition: background 80ms, border 80ms, offset 80ms;
777
+ }
778
+ #model-actions Button:hover {
779
+ background: $accent 25%;
780
+ color: #ffffff;
781
+ border-top: heavy #ffffff;
782
+ border-left: heavy #ffffff;
783
+ border-bottom: heavy $accent-highlight;
784
+ border-right: heavy $accent-highlight;
785
+ offset-y: -1;
786
+ }
787
+ #model-actions Button.-active {
788
+ offset-y: 1;
789
+ border-top: heavy $accent-shadow;
790
+ border-left: heavy $accent-shadow;
791
+ border-bottom: heavy #ffffff;
792
+ border-right: heavy #ffffff;
793
+ }
794
+ #btn-select-model-main {
795
+ background: #7c3aed;
796
+ color: #ffffff;
797
+ text-style: bold;
798
+ border: heavy;
799
+ border-top: heavy #ffffff;
800
+ border-left: heavy #ffffff;
801
+ border-bottom: heavy #3c137a;
802
+ border-right: heavy #3c137a;
803
+ }
804
+ #btn-select-model-main:hover {
805
+ background: #8b5cf6;
806
+ border-bottom: heavy #c4b5fd;
807
+ border-right: heavy #c4b5fd;
808
+ color: #ffffff;
809
+ offset-y: -1;
810
+ }
811
+
812
+ /* My Models Row Styles */
813
+ .my-model-row {
814
+ height: 4;
815
+ padding: 0 1;
816
+ margin-bottom: 1;
817
+ background: $surface-alt;
818
+ border: heavy;
819
+ border-top: heavy $surface-highlight;
820
+ border-left: heavy $surface-highlight;
821
+ border-bottom: heavy $surface-shadow;
822
+ border-right: heavy $surface-shadow;
823
+ align: left middle;
824
+ }
825
+ .my-model-row:hover {
826
+ border-top: heavy #ffffff;
827
+ border-left: heavy #ffffff;
828
+ border-bottom: heavy $accent-highlight;
829
+ border-right: heavy $accent-highlight;
830
+ }
831
+ .my-model-row Static {
832
+ width: 1fr;
833
+ }
834
+ .my-model-row Button {
835
+ margin-left: 1;
836
+ min-width: 12;
837
+ height: 3;
838
+ padding: 0 2;
839
+ border: heavy;
840
+ border-top: heavy #ffffff;
841
+ border-left: heavy #ffffff;
842
+ border-bottom: heavy #3c137a;
843
+ border-right: heavy #3c137a;
844
+ background: #7c3aed;
845
+ color: #ffffff;
846
+ text-style: bold;
847
+ transition: background 80ms, border 80ms, offset 80ms;
848
+ }
849
+ .my-model-row Button:hover {
850
+ background: #8b5cf6;
851
+ border-bottom: heavy #c4b5fd;
852
+ border-right: heavy #c4b5fd;
853
+ color: #ffffff;
854
+ offset-y: -1;
855
+ }
856
+
857
+ /* Modals & Dialogs */
858
+ #api-key-box, #verify-box, #model-box, #custom-box {
859
+ align: center top;
860
+ padding: 2;
861
+ overflow-y: auto;
862
+ width: 100%;
863
+ height: 100%;
864
+ }
865
+ #api-key-box Button, #verify-box Button, #model-box Button, #custom-box Button {
866
+ margin: 0 1;
867
+ min-width: 12;
868
+ height: 3;
869
+ padding: 0 2;
870
+ border: heavy;
871
+ border-top: heavy $surface-highlight;
872
+ border-left: heavy $surface-highlight;
873
+ border-bottom: heavy $surface-shadow;
874
+ border-right: heavy $surface-shadow;
875
+ background: $surface-alt;
876
+ color: $text;
877
+ text-style: bold;
878
+ content-align: center middle;
879
+ transition: background 80ms, border 80ms, offset 80ms;
880
+ }
881
+ #api-key-box Button:hover, #verify-box Button:hover, #model-box Button:hover, #custom-box Button:hover {
882
+ background: $accent 25%;
883
+ color: #ffffff;
884
+ border-top: heavy #ffffff;
885
+ border-left: heavy #ffffff;
886
+ border-bottom: heavy $accent-highlight;
887
+ border-right: heavy $accent-highlight;
888
+ offset-y: -1;
889
+ }
890
+ #api-key-box Button.-active, #verify-box Button.-active, #model-box Button.-active, #custom-box Button.-active {
891
+ offset-y: 1;
892
+ border-top: heavy $accent-shadow;
893
+ border-left: heavy $accent-shadow;
894
+ border-bottom: heavy #ffffff;
895
+ border-right: heavy #ffffff;
896
+ }
897
+ """
898
+
899
+ if TEXTUAL_OK:
900
+
901
+ # ── UI Components ─────────────────────────────────────────────────────────
902
+
903
+ class ProviderRow(Static):
904
+ """One row in the provider list, responsive to mouse clicks and touch taps."""
905
+
906
+ def __init__(self, provider, index=0, selected=False, in_failover=False, query=""):
907
+ super().__init__("")
908
+ self._provider = provider
909
+ self._index = index
910
+ self._selected = selected
911
+ self._in_failover = in_failover
912
+ self._query = query
913
+
914
+ def on_click(self) -> None:
915
+ """Handle mouse click and touch screen tap."""
916
+ try:
917
+ if hasattr(self.screen, "_on_row_clicked"):
918
+ self.screen._on_row_clicked(self._provider, self._index)
919
+ except Exception:
920
+ pass
921
+
922
+ def render(self):
923
+ name = self._provider["name"]
924
+ country = self._provider.get("country", "")
925
+ desc = self._provider.get("desc", "")
926
+ status = self._provider.get("status", "online").lower()
927
+ bullet = "●" if status == "online" else ("◐" if status == "degraded" else "○")
928
+ bullet_color = C_GREEN if status == "online" else (C_ORANGE if status == "degraded" else C_DIM)
929
+
930
+ mark = "▶ " if self._selected else " "
931
+ style_open = f"[{C_ACCENT} b]" if self._selected else ""
932
+ style_close = "[/]" if self._selected else ""
933
+ country_tag = f"[{C_DIM}]{country}[/]" if country else ""
934
+ failover_tag = f" [{C_CYAN}][⇄ FAILOVER][/]" if self._in_failover else ""
935
+ return (
936
+ f"{mark}[{bullet_color}]{bullet}[/] {style_open}{name}{style_close} {country_tag}{failover_tag}\n"
937
+ f" [{C_DIM}]{desc}[/]"
938
+ )
939
+
940
+ class ModelRow(Static):
941
+ """Clickable, touch-friendly row representing a discovered model."""
942
+
943
+ def __init__(self, model_name: str, index: int, is_selected: bool, screen: Any, in_failover: bool = False):
944
+ super().__init__("")
945
+ self._model_name = model_name
946
+ self._index = index
947
+ self._is_selected = is_selected
948
+ self._screen = screen
949
+ self._in_failover = in_failover
950
+
951
+ def on_mount(self) -> None:
952
+ if self._is_selected:
953
+ self.add_class("selected")
954
+ else:
955
+ self.remove_class("selected")
956
+
957
+ def on_click(self) -> None:
958
+ """Handle mouse click and touch screen tap."""
959
+ try:
960
+ if hasattr(self._screen, "_on_row_clicked"):
961
+ self._screen._on_row_clicked(self._model_name, self._index)
962
+ except Exception:
963
+ pass
964
+
965
+ def render(self):
966
+ mark = "▶ " if self._is_selected else " "
967
+ style_open = f"[{C_ACCENT} b]" if self._is_selected else ""
968
+ style_close = "[/]" if self._is_selected else ""
969
+ failover_badge = f" [{C_ORANGE}][⇄ FAILOVER][/]" if self._in_failover else ""
970
+ return f"{mark}{style_open}{self._model_name}{style_close}{failover_badge}"
971
+
972
+ class AlphabetHeader(Static):
973
+ """Pinned alphabet header in the list."""
974
+
975
+ def __init__(self, letter):
976
+ super().__init__(f"\n[{C_CYAN}]━━━━━ {letter} ━━━━━[/]\n")
977
+
978
+ class InfoPanel(Vertical):
979
+ """Right-side details panel with interactive touch/mouse buttons."""
980
+
981
+ def __init__(self):
982
+ super().__init__()
983
+ self._provider = None
984
+ self._in_failover = False
985
+ self._failover_priority = -1
986
+
987
+ def compose(self):
988
+ yield Static("", id="info-content")
989
+ with Vertical(id="info-actions"):
990
+ yield Button(" ➜ Select as Active AI ", id="btn-select-provider")
991
+ yield Button(" ⇄ Add to Failover Chain ", id="btn-toggle-failover")
992
+ yield Button(" ⟳ Test Connection ", id="btn-test-provider")
993
+
994
+ def set_provider(self, provider, in_failover=False, failover_priority=-1):
995
+ self._provider = provider
996
+ self._in_failover = in_failover
997
+ self._failover_priority = failover_priority
998
+ self._update_display()
999
+
1000
+ def _update_display(self):
1001
+ p = self._provider
1002
+ if not p:
1003
+ try:
1004
+ self.query_one("#info-content", Static).update(
1005
+ f"\n\n\n [{C_DIM}]Select a provider on the left or tap with cursor/touch to inspect[/]\n"
1006
+ )
1007
+ except Exception:
1008
+ pass
1009
+ return
1010
+
1011
+ status = p.get("status", "online").lower()
1012
+ status_text = "ONLINE" if status == "online" else ("DEGRADED" if status == "degraded" else "OFFLINE")
1013
+ status_color = C_GREEN if status == "online" else (C_ORANGE if status == "degraded" else C_RED)
1014
+
1015
+ failover_text = (
1016
+ f"[{C_GREEN}]✓ Active (Priority #{self._failover_priority + 1})[/]"
1017
+ if self._in_failover
1018
+ else f"[{C_DIM}]Not in chain[/]"
1019
+ )
1020
+
1021
+ lines = [
1022
+ f"\n",
1023
+ f" [{C_ACCENT} b]{p['name']}[/]",
1024
+ f" [{C_DIM}]" + "─" * 32 + "[/]",
1025
+ f" [{C_DIM}]Status:[/] [{status_color}]● {status_text}[/]",
1026
+ f" [{C_DIM}]Company:[/] {p.get('company') or p['name']}",
1027
+ f" [{C_DIM}]Country:[/] {p.get('country', 'N/A')}",
1028
+ f" [{C_DIM}]Protocol:[/] {p.get('api_style', 'openai').upper()}",
1029
+ f" [{C_DIM}]Authentication:[/] {'Required (API Key)' if p.get('needs_key') else 'No key needed'}",
1030
+ f" [{C_DIM}]Endpoint:[/] {p.get('url', 'N/A')}",
1031
+ f" [{C_DIM}]Failover Chain:[/] {failover_text}",
1032
+ f" [{C_DIM}]Verified:[/] [{C_GREEN}]✓ Verified[/]",
1033
+ f"",
1034
+ f" [{C_DIM}]Supported Models:[/]",
1035
+ ]
1036
+ models = p.get("models", [])
1037
+ for m in models[:8]:
1038
+ lines.append(f" [{C_CYAN}]•[/] {m}")
1039
+ if len(models) > 8:
1040
+ lines.append(f" [{C_DIM}]… and {len(models)-8} more models[/]")
1041
+ if not models:
1042
+ lines.append(f" [{C_DIM}](Dynamic model discovery enabled)[/]")
1043
+
1044
+ features = p.get("features", [])
1045
+ lines += [
1046
+ f"",
1047
+ f" [{C_DIM}]Capabilities:[/] {', '.join(features) if features else 'Chat'}",
1048
+ f" [{C_DIM}]OpenAI Compat:[/] {'Yes' if p.get('openai_compat') else 'No'}",
1049
+ f"",
1050
+ f" [{C_DIM}]{p.get('desc', '')}[/]",
1051
+ ]
1052
+ try:
1053
+ self.query_one("#info-content", Static).update("\n".join(lines))
1054
+ btn = self.query_one("#btn-toggle-failover", Button)
1055
+ if self._in_failover:
1056
+ btn.label = " ✕ Remove from Failover Chain "
1057
+ else:
1058
+ btn.label = " ⇄ Add to Failover Chain "
1059
+ except Exception:
1060
+ pass
1061
+
1062
+ # ── Model Selection Confirmation Dialog ───────────────────────────────────
1063
+
1064
+ class ModelConfirmModal(Screen):
1065
+ """Confirmation modal dialog before hot-switching provider/model."""
1066
+
1067
+ DEFAULT_CSS = """
1068
+ ModelConfirmModal {
1069
+ align: center middle;
1070
+ background: #1a1b26 75%;
1071
+ width: 100%;
1072
+ height: 100%;
1073
+ }
1074
+ #confirm-box {
1075
+ width: 58;
1076
+ height: auto;
1077
+ background: #1f2335;
1078
+ border: round #a78bfa;
1079
+ padding: 2 3;
1080
+ layout: vertical;
1081
+ }
1082
+ #confirm-title {
1083
+ color: #a78bfa;
1084
+ text-style: bold;
1085
+ text-align: center;
1086
+ margin-bottom: 1;
1087
+ }
1088
+ #confirm-info {
1089
+ margin: 1 0;
1090
+ color: #c0caf5;
1091
+ }
1092
+ #confirm-buttons {
1093
+ height: 3;
1094
+ align: center middle;
1095
+ margin-top: 1;
1096
+ layout: horizontal;
1097
+ }
1098
+ #confirm-buttons Button {
1099
+ margin: 0 1;
1100
+ min-width: 14;
1101
+ height: 3;
1102
+ padding: 0 2;
1103
+ border: solid $border;
1104
+ background: $surface-alt;
1105
+ color: $text;
1106
+ text-style: not bold;
1107
+ content-align: center middle;
1108
+ }
1109
+ #confirm-buttons Button:hover {
1110
+ background: $accent 18%;
1111
+ border: solid $border-active;
1112
+ color: #ffffff;
1113
+ }
1114
+ #btn-apply-model {
1115
+ background: #7c3aed;
1116
+ color: #ffffff;
1117
+ border: solid #a78bfa;
1118
+ text-style: bold;
1119
+ }
1120
+ #btn-apply-model:hover {
1121
+ background: #8b5cf6;
1122
+ border: solid #c4b5fd;
1123
+ color: #ffffff;
1124
+ }
1125
+ #btn-cancel-model {
1126
+ background: $surface-alt;
1127
+ color: $text;
1128
+ border: solid $border;
1129
+ }
1130
+ #btn-cancel-model:hover {
1131
+ background: $accent 18%;
1132
+ border: solid $border-active;
1133
+ color: #ffffff;
1134
+ }
1135
+ """
1136
+
1137
+ BINDINGS = [
1138
+ Binding("escape", "cancel", "Cancel"),
1139
+ Binding("enter", "apply", "Apply"),
1140
+ ]
1141
+
1142
+ def __init__(self, provider_id: str, model_id: str, config: Dict):
1143
+ super().__init__()
1144
+ self._provider_id = provider_id
1145
+ self._model_id = model_id
1146
+ self._config = config
1147
+
1148
+ def compose(self):
1149
+ with Vertical(id="confirm-box"):
1150
+ yield Static("CONFIRM MODEL SWITCH", id="confirm-title")
1151
+ info_text = (
1152
+ f" [b]Provider:[/] {self._provider_id}\n"
1153
+ f" [b]Model:[/] {self._model_id}\n"
1154
+ f" [b]Status:[/] [{C_GREEN}]Ready[/]\n\n"
1155
+ f" [{C_DIM}]Changes take effect immediately without restarting CAT.[/]"
1156
+ )
1157
+ yield Static(info_text, id="confirm-info")
1158
+ with Horizontal(id="confirm-buttons"):
1159
+ yield Button(" ✓ Apply ", id="btn-apply-model")
1160
+ yield Button(" ✕ Cancel ", id="btn-cancel-model")
1161
+
1162
+ def on_mount(self):
1163
+ try:
1164
+ self.query_one("#btn-apply-model", Button).focus()
1165
+ except Exception:
1166
+ pass
1167
+
1168
+ def action_apply(self):
1169
+ # Authoritative hot switch
1170
+ set_active_ai(self._provider_id, self._model_id, self._config)
1171
+ self.dismiss(self._config)
1172
+
1173
+ def action_cancel(self):
1174
+ self.dismiss(None)
1175
+
1176
+ def on_button_pressed(self, event):
1177
+ if event.button.id == "btn-apply-model":
1178
+ self.action_apply()
1179
+ elif event.button.id == "btn-cancel-model":
1180
+ self.action_cancel()
1181
+
1182
+ def _finish_wizard_screen(screen: Any, result: Optional[Dict] = None) -> None:
1183
+ """Safely dismiss a wizard screen or modal without terminating CCT host app."""
1184
+ if isinstance(result, dict) and result.get("provider"):
1185
+ try:
1186
+ from calc_terminal.models.active_state import set_active_ai
1187
+ set_active_ai(result.get("provider"), result.get("model", ""), result)
1188
+ except Exception:
1189
+ pass
1190
+ try:
1191
+ from calc_terminal import model_router
1192
+ model_router.invalidate_cache()
1193
+ except Exception:
1194
+ pass
1195
+
1196
+ try:
1197
+ app = screen.app
1198
+ except Exception:
1199
+ app = None
1200
+ app_name = getattr(getattr(app, "__class__", None), "__name__", "")
1201
+ if app is not None and app_name == "ModelSelectorApp":
1202
+ try:
1203
+ app.exit(result=result)
1204
+ except Exception:
1205
+ pass
1206
+ return
1207
+
1208
+ try:
1209
+ screen.dismiss(result)
1210
+ except Exception:
1211
+ try:
1212
+ if app is not None and hasattr(app, "pop_screen"):
1213
+ app.pop_screen()
1214
+ except Exception:
1215
+ pass
1216
+
1217
+ # ── Main Provider Selection Screen ────────────────────────────────────────
1218
+
1219
+ class ProviderScreen(CATViewportScreenMixin, Screen):
1220
+ """Universal AI Provider Hub 2.0 Screen."""
1221
+
1222
+ DEFAULT_CSS = SHARED_WIZARD_CSS
1223
+
1224
+ BINDINGS = [
1225
+ Binding("escape", "go_back", "Back"),
1226
+ Binding("ctrl+c", "go_back", "Quit"),
1227
+ Binding("enter", "select", "Select"),
1228
+ Binding("slash", "focus_search", "Search"),
1229
+ Binding("r", "refresh_list", "Refresh"),
1230
+ ]
1231
+
1232
+ def __init__(self, embedded=False):
1233
+ super().__init__()
1234
+ self._embedded = embedded
1235
+ try:
1236
+ from calc_terminal.providers.provider_manager import enrich_providers, verify_provider_count
1237
+ verify_provider_count(ALL_PROVIDERS)
1238
+ self._all = enrich_providers(ALL_PROVIDERS)
1239
+ except Exception:
1240
+ self._all = list(ALL_PROVIDERS)
1241
+ self._filtered = list(self._all)
1242
+ self._selected_index = 0
1243
+ self._search = ""
1244
+ self._active_filter = "ALL"
1245
+
1246
+ def compose(self):
1247
+ with CATRootViewport(id="provider-viewport", on_size_change=self._on_viewport_size):
1248
+ # Minimalist Filter Bar (Sleek single row, no overflow, no empty boxes)
1249
+ with Horizontal(id="filter-bar"):
1250
+ filters = ["ALL", "VERIFIED", "LOCAL", "CLOUD", "CHINESE", "FREE", "REASONING"]
1251
+ for f in filters:
1252
+ cls = "active-filter" if f == self._active_filter else ""
1253
+ yield Button(f, id=f"filter-{f.lower()}", classes=cls)
1254
+
1255
+ # Two-panel layout
1256
+ with Horizontal(id="main-columns"):
1257
+ with Vertical(id="left-col"):
1258
+ yield Static(_step_header(1, "Select Provider"), id="step-indicator")
1259
+ yield Input(placeholder="🔍 Type to filter or press Enter to select... (e.g. deepseek, ollama)", id="search-input")
1260
+ yield Static(f"[{C_DIM}] Showing {len(self._filtered)} of {len(self._all)} verified providers[/]", id="result-count")
1261
+ yield ScrollableContainer(id="provider-list")
1262
+ with Vertical(id="right-col"):
1263
+ yield InfoPanel()
1264
+
1265
+ # Global Action Bar across full bottom width
1266
+ with Horizontal(id="provider-actions"):
1267
+ yield Button("✕ Back", id="btn-exit")
1268
+ yield Button("➜ Select", id="btn-select-main")
1269
+ yield Button("⇄ Failover", id="btn-failover-main")
1270
+ yield Button("➕ Add", id="btn-add-provider")
1271
+ yield Button("⇅ Import", id="btn-import")
1272
+ yield Button("⤒ Export", id="btn-export")
1273
+
1274
+ def on_mount(self):
1275
+ self._rebuild_list()
1276
+ try:
1277
+ self.query_one("#search-input", Input).focus()
1278
+ except Exception:
1279
+ pass
1280
+
1281
+ def on_input_submitted(self, event: Input.Submitted) -> None:
1282
+ """Pressing Enter in search input immediately confirms selection."""
1283
+ if event.input.id == "search-input":
1284
+ self._confirm_selection()
1285
+
1286
+ def _on_row_clicked(self, provider: Dict, index: int) -> None:
1287
+ """Handle mouse click and touch screen tap on provider row."""
1288
+ if self._selected_index == index:
1289
+ self._confirm_selection()
1290
+ else:
1291
+ self._selected_index = index
1292
+ self._rebuild_list()
1293
+ self._update_info()
1294
+ self._scroll_to_selected()
1295
+
1296
+ def _get_failover_provider_ids(self) -> set:
1297
+ try:
1298
+ from calc_terminal.providers import provider_manager as pm
1299
+ return {b.get("provider") for b in pm.load_backup_providers()}
1300
+ except Exception:
1301
+ return set()
1302
+
1303
+ def _get_failover_info(self, provider_id: str) -> dict:
1304
+ try:
1305
+ from calc_terminal.providers import provider_manager as pm
1306
+ backups = pm.load_backup_providers()
1307
+ for idx, b in enumerate(backups):
1308
+ if b.get("provider") == provider_id:
1309
+ return {"in_failover": True, "priority": idx}
1310
+ except Exception:
1311
+ pass
1312
+ return {"in_failover": False, "priority": -1}
1313
+
1314
+ def _on_viewport_size(self, size_class: str):
1315
+ """Dynamically adjust columns and layout when terminal resizes."""
1316
+ try:
1317
+ cols = self.query_one("#main-columns", Horizontal)
1318
+ left = self.query_one("#left-col", Vertical)
1319
+ right = self.query_one("#right-col", Vertical)
1320
+ if size_class == "large":
1321
+ left.styles.width = "2fr"
1322
+ right.styles.display = "block"
1323
+ elif size_class == "medium":
1324
+ left.styles.width = "3fr"
1325
+ right.styles.display = "block"
1326
+ elif size_class == "small":
1327
+ left.styles.width = "100%"
1328
+ right.styles.display = "none"
1329
+ else: # very_small
1330
+ left.styles.width = "100%"
1331
+ right.styles.display = "none"
1332
+ except Exception:
1333
+ pass
1334
+
1335
+ def _rebuild_list(self):
1336
+ try:
1337
+ container = self.query_one("#provider-list", ScrollableContainer)
1338
+ except Exception:
1339
+ return
1340
+ container.remove_children()
1341
+ query = self._search
1342
+ current_letter = ""
1343
+ idx = 0
1344
+ failover_ids = self._get_failover_provider_ids()
1345
+
1346
+ for p in self._filtered:
1347
+ name = p["name"]
1348
+ first = name[0].upper() if name else ""
1349
+ if first != current_letter and not query:
1350
+ current_letter = first
1351
+ container.mount(AlphabetHeader(current_letter))
1352
+ in_failover = p.get("id") in failover_ids
1353
+ row = ProviderRow(
1354
+ p, index=idx, selected=(idx == self._selected_index),
1355
+ in_failover=in_failover, query=query
1356
+ )
1357
+ if idx == self._selected_index:
1358
+ row.add_class("selected")
1359
+ container.mount(row)
1360
+ idx += 1
1361
+ self._update_count()
1362
+ self._update_info()
1363
+
1364
+ def _update_count(self):
1365
+ try:
1366
+ self.query_one("#result-count", Static).update(
1367
+ f"[{C_DIM}] Showing {len(self._filtered)} of {len(self._all)} verified providers[/]"
1368
+ )
1369
+ except Exception:
1370
+ pass
1371
+
1372
+ def _update_info(self):
1373
+ if 0 <= self._selected_index < len(self._filtered):
1374
+ p = self._filtered[self._selected_index]
1375
+ f_info = self._get_failover_info(p.get("id"))
1376
+ try:
1377
+ self.query_one(InfoPanel).set_provider(
1378
+ p, in_failover=f_info["in_failover"],
1379
+ failover_priority=f_info["priority"]
1380
+ )
1381
+ except Exception:
1382
+ pass
1383
+
1384
+ def on_input_changed(self, event):
1385
+ if event.input.id == "search-input":
1386
+ self._search = event.input.value
1387
+ self._apply_filters()
1388
+
1389
+ def _apply_filters(self):
1390
+ filtered = list(self._all)
1391
+ f = self._active_filter
1392
+
1393
+ if f == "VERIFIED":
1394
+ filtered = [p for p in filtered if p.get("verification_status") == "verified"]
1395
+ elif f == "AVAILABLE":
1396
+ filtered = [p for p in filtered if p.get("status") == "online"]
1397
+ elif f == "API":
1398
+ filtered = [p for p in filtered if p.get("needs_key")]
1399
+ elif f == "LOCAL":
1400
+ filtered = [p for p in filtered if not p.get("needs_key") or p.get("id") == "ollama"]
1401
+ elif f == "CLOUD":
1402
+ filtered = [p for p in filtered if p.get("id") != "ollama"]
1403
+ elif f == "FREE":
1404
+ filtered = [p for p in filtered if not p.get("needs_key")]
1405
+ elif f == "CHINESE":
1406
+ filtered = [p for p in filtered if p.get("country", "").lower() == "china"]
1407
+ elif f == "GLOBAL":
1408
+ filtered = [p for p in filtered if p.get("country", "").lower() != "china"]
1409
+ elif f == "REASONING":
1410
+ filtered = [p for p in filtered if "reasoning" in p.get("features", [])]
1411
+ elif f == "CODING":
1412
+ filtered = [p for p in filtered if "code" in p.get("features", []) or "reasoning" in p.get("features", [])]
1413
+ elif f == "VISION":
1414
+ filtered = [p for p in filtered if "vision" in p.get("features", [])]
1415
+ elif f == "CUSTOM":
1416
+ filtered = [p for p in filtered if p.get("id", "").startswith("custom")]
1417
+
1418
+ if self._search:
1419
+ filtered = [p for p in filtered if fuzzy_match(self._search, p["name"]) or fuzzy_match(self._search, p.get("desc", ""))]
1420
+
1421
+ self._filtered = filtered
1422
+ self._selected_index = 0
1423
+ self._rebuild_list()
1424
+
1425
+ def on_key(self, event):
1426
+ if event.key == "down":
1427
+ if self._selected_index < len(self._filtered) - 1:
1428
+ self._selected_index += 1
1429
+ self._rebuild_list()
1430
+ self._scroll_to_selected()
1431
+ elif event.key == "up":
1432
+ if self._selected_index > 0:
1433
+ self._selected_index -= 1
1434
+ self._rebuild_list()
1435
+ self._scroll_to_selected()
1436
+ elif event.key == "pagedown":
1437
+ self._selected_index = min(self._selected_index + 10, len(self._filtered) - 1)
1438
+ self._rebuild_list()
1439
+ self._scroll_to_selected()
1440
+ elif event.key == "pageup":
1441
+ self._selected_index = max(self._selected_index - 10, 0)
1442
+ self._rebuild_list()
1443
+ self._scroll_to_selected()
1444
+ elif event.key == "home":
1445
+ self._selected_index = 0
1446
+ self._rebuild_list()
1447
+ self._scroll_to_selected()
1448
+ elif event.key == "end":
1449
+ self._selected_index = len(self._filtered) - 1
1450
+ self._rebuild_list()
1451
+ self._scroll_to_selected()
1452
+ elif event.key == "enter":
1453
+ self._confirm_selection()
1454
+ elif event.key == "f":
1455
+ self._toggle_failover_for_selected()
1456
+
1457
+ def _scroll_to_selected(self):
1458
+ try:
1459
+ rows = self.query_one("#provider-list", ScrollableContainer).query(ProviderRow)
1460
+ for i, row in enumerate(rows):
1461
+ if i == self._selected_index:
1462
+ row.scroll_visible()
1463
+ break
1464
+ except Exception:
1465
+ pass
1466
+
1467
+ def action_focus_search(self):
1468
+ try:
1469
+ self.query_one("#search-input", Input).focus()
1470
+ except Exception:
1471
+ pass
1472
+
1473
+ def action_refresh_list(self):
1474
+ self._all = _load_provider_database()
1475
+ self._apply_filters()
1476
+
1477
+ def action_select(self):
1478
+ self._confirm_selection()
1479
+
1480
+ def _confirm_selection(self):
1481
+ if 0 <= self._selected_index < len(self._filtered):
1482
+ provider = self._filtered[self._selected_index]
1483
+ try:
1484
+ self.app.push_screen(ModelScreen(provider, embedded=self._embedded), self._on_subscreen_closed)
1485
+ except Exception as ex:
1486
+ _log(f"Error opening ModelScreen: {ex}")
1487
+
1488
+ def _toggle_failover_for_selected(self) -> None:
1489
+ if not (0 <= self._selected_index < len(self._filtered)):
1490
+ return
1491
+ p = self._filtered[self._selected_index]
1492
+ pid = p.get("id")
1493
+ try:
1494
+ from calc_terminal.providers import provider_manager as pm
1495
+ current_backups = pm.load_backup_providers()
1496
+ existing_idx = next((i for i, b in enumerate(current_backups) if b.get("provider") == pid), -1)
1497
+ if existing_idx >= 0:
1498
+ current_backups.pop(existing_idx)
1499
+ self.notify(f"Removed {p['name']} from Backup Failover Chain", title="Failover Chain")
1500
+ else:
1501
+ default_m = p.get("default_model") or (p.get("models", ["default"])[0] if p.get("models") else "default")
1502
+ current_backups.append({
1503
+ "provider": pid,
1504
+ "model": default_m,
1505
+ "enabled": True,
1506
+ "api_style": p.get("api_style", "openai"),
1507
+ "base_url": p.get("url", ""),
1508
+ })
1509
+ self.notify(f"Added {p['name']} to Backup Failover Chain", title="Failover Chain")
1510
+ pm.save_backup_providers(current_backups)
1511
+ self._rebuild_list()
1512
+ self._update_info()
1513
+ except Exception as e:
1514
+ self.notify(f"Failover update error: {e}", title="Error", severity="error")
1515
+
1516
+ def _open_failover_panel(self) -> None:
1517
+ try:
1518
+ from calc_terminal.ui.backup_panel import BackupProvidersPanel
1519
+ self.app.push_screen(BackupProvidersPanel())
1520
+ except Exception as e:
1521
+ self.notify(f"Could not open Backup Providers panel: {e}", severity="error")
1522
+
1523
+ def _test_selected_provider(self) -> None:
1524
+ if not (0 <= self._selected_index < len(self._filtered)):
1525
+ return
1526
+ p = self._filtered[self._selected_index]
1527
+ self.notify(f"Probing connection to {p['name']}...", title="Connection Test")
1528
+ def _bg_test():
1529
+ try:
1530
+ from calc_terminal.models.verification_engine import get_verification_engine
1531
+ engine = get_verification_engine()
1532
+ report = engine.verify_custom_model({
1533
+ "provider_id": p.get("id"),
1534
+ "model_id": p.get("default_model") or "test",
1535
+ "base_url": p.get("url", ""),
1536
+ "api_key": "",
1537
+ })
1538
+ is_ok = report.success or report.get("status") in ("ready", "auth_required", "VERIFIED", "PARTIALLY_VERIFIED")
1539
+ summary = "Reachability OK" if is_ok else f"Issue: {report.get('error') or report.get('message') or 'Check connection'}"
1540
+ self.app.call_from_thread(self.notify, f"{p['name']}: {summary}", title="Connection Result")
1541
+ except Exception as ex:
1542
+ self.app.call_from_thread(self.notify, f"Test error: {ex}", severity="warning")
1543
+ import threading
1544
+ threading.Thread(target=_bg_test, daemon=True).start()
1545
+
1546
+ def action_go_back(self):
1547
+ _finish_wizard_screen(self, None)
1548
+
1549
+ def _on_subscreen_closed(self, result=None):
1550
+ if result:
1551
+ _finish_wizard_screen(self, result)
1552
+
1553
+ def on_button_pressed(self, event: Button.Pressed) -> None:
1554
+ bid = event.button.id or ""
1555
+ if bid in ("btn-exit", "btn-exit-top"):
1556
+ self.action_go_back()
1557
+ elif bid in ("btn-select-provider", "btn-select-main"):
1558
+ self._confirm_selection()
1559
+ elif bid in ("btn-toggle-failover", "btn-failover-main"):
1560
+ self._toggle_failover_for_selected()
1561
+ elif bid == "btn-test-provider":
1562
+ self._test_selected_provider()
1563
+ elif bid == "tab-failover":
1564
+ self._open_failover_panel()
1565
+ elif bid in ("btn-add-provider", "tab-add-custom"):
1566
+ self.app.push_screen(AddCustomModelScreen(embedded=self._embedded), self._on_subscreen_closed)
1567
+ elif bid == "tab-my-models":
1568
+ self.app.push_screen(MyModelsScreen(embedded=self._embedded), self._on_subscreen_closed)
1569
+ elif bid == "tab-updates":
1570
+ self.app.push_screen(UpdateCenterScreen(embedded=self._embedded), self._on_subscreen_closed)
1571
+ elif bid == "tab-models":
1572
+ if 0 <= self._selected_index < len(self._filtered):
1573
+ self.app.push_screen(ModelScreen(self._filtered[self._selected_index], embedded=self._embedded), self._on_subscreen_closed)
1574
+ elif bid == "btn-refresh":
1575
+ self.action_refresh_list()
1576
+ elif bid == "btn-import":
1577
+ self.app.push_screen(ImportExportScreen(mode="import", embedded=self._embedded))
1578
+ elif bid == "btn-export":
1579
+ self.app.push_screen(ImportExportScreen(mode="export", embedded=self._embedded))
1580
+ elif bid.startswith("filter-"):
1581
+ for b in self.query("#filter-bar Button"):
1582
+ b.remove_class("active-filter")
1583
+ event.button.add_class("active-filter")
1584
+ filt = bid.replace("filter-", "").upper()
1585
+ self._active_filter = filt
1586
+ self._apply_filters()
1587
+
1588
+ def on_screen_resume(self, event=None):
1589
+ try:
1590
+ self._all = _load_provider_database()
1591
+ self._apply_filters()
1592
+ except Exception:
1593
+ pass
1594
+
1595
+ # ── Model Selection Screen ────────────────────────────────────────────────
1596
+
1597
+ class ModelScreen(CATViewportScreenMixin, Screen):
1598
+ """Model selection and discovery screen."""
1599
+
1600
+ DEFAULT_CSS = SHARED_WIZARD_CSS
1601
+
1602
+ BINDINGS = [
1603
+ Binding("escape", "go_back", "Back"),
1604
+ Binding("ctrl+c", "go_back", "Quit"),
1605
+ Binding("ctrl+r", "refresh_models", "Refresh"),
1606
+ Binding("enter", "select_model", "Select"),
1607
+ Binding("f", "toggle_failover", "Failover"),
1608
+ ]
1609
+
1610
+ def __init__(self, provider, embedded=False, search=""):
1611
+ super().__init__()
1612
+ self._provider = provider
1613
+ self._embedded = embedded
1614
+ self._models = []
1615
+ self._filtered = []
1616
+ self._selected_index = 0
1617
+ self._search = search
1618
+
1619
+ def compose(self):
1620
+ p = self._provider
1621
+ pid = p.get("id", "")
1622
+ cached = self._get_cached_models(pid)
1623
+ if cached:
1624
+ self._models = list(cached)
1625
+ else:
1626
+ self._models = list(p.get("models", []))
1627
+ self._filtered = list(self._models)
1628
+
1629
+ with CATRootViewport():
1630
+ yield Static(_step_header(2, "Select Model"), id="step-indicator")
1631
+ with Horizontal(id="model-nav-bar"):
1632
+ yield Button("← Back to Providers", id="btn-back-providers")
1633
+ yield Static(f"[{C_ACCENT} b]Models for {p['name']}[/]", id="model-title")
1634
+ yield Button("⟳ Discover Live", id="btn-refresh-live")
1635
+ yield Input(placeholder="🔍 Search models, family, capabilities... (Press Enter to select)", id="model-search")
1636
+ yield Static(f"[{C_DIM}] Showing {len(self._filtered)} of {len(self._models)} model(s)[/]", id="model-count-label")
1637
+ yield ScrollableContainer(id="model-list")
1638
+ with Horizontal(id="model-actions"):
1639
+ yield Button("✕ Back", id="btn-back-model-bottom")
1640
+ yield Button("➜ Select as Active AI", id="btn-select-model-main")
1641
+ yield Button("⇄ Set in Failover Chain", id="btn-model-failover")
1642
+ yield Button("⚡ Test Model", id="btn-test-model")
1643
+
1644
+ def _get_cached_models(self, pid):
1645
+ try:
1646
+ from calc_terminal.providers.provider_manager import get_cached_models
1647
+ return get_cached_models(pid)
1648
+ except Exception:
1649
+ return None
1650
+
1651
+ def _get_current_failover_model(self) -> Optional[str]:
1652
+ try:
1653
+ from calc_terminal.providers import provider_manager as pm
1654
+ backups = pm.load_backup_providers()
1655
+ pid = self._provider.get("id", "")
1656
+ for b in backups:
1657
+ if b.get("provider") == pid and b.get("enabled"):
1658
+ return b.get("model")
1659
+ except Exception:
1660
+ pass
1661
+ return None
1662
+
1663
+ def on_mount(self):
1664
+ self._rebuild_list()
1665
+ try:
1666
+ self.query_one("#model-search", Input).focus()
1667
+ except Exception:
1668
+ pass
1669
+ # Asynchronously discover live models
1670
+ threading.Thread(target=self._discover_live_models, daemon=True).start()
1671
+
1672
+ def _discover_live_models(self):
1673
+ pid = self._provider.get("id", "")
1674
+ try:
1675
+ from calc_terminal.models import manager as _mgr
1676
+ models, _ = _mgr.get_models(pid, {"provider": pid, "api_key": ""})
1677
+ if models and models != ["default"]:
1678
+ self._models = list(models)
1679
+ self.app.call_from_thread(self._rebuild_list)
1680
+ except Exception:
1681
+ pass
1682
+
1683
+ def _rebuild_list(self):
1684
+ try:
1685
+ container = self.query_one("#model-list", ScrollableContainer)
1686
+ except Exception:
1687
+ return
1688
+ container.remove_children()
1689
+ q = (self._search or "").lower().strip()
1690
+ filtered = [m for m in self._models if not q or q in m.lower()]
1691
+ self._filtered = filtered
1692
+ try:
1693
+ self.query_one("#model-count-label", Static).update(
1694
+ f"[{C_DIM}] Showing {len(filtered)} of {len(self._models)} model(s)[/]"
1695
+ )
1696
+ except Exception:
1697
+ pass
1698
+
1699
+ failover_model = self._get_current_failover_model()
1700
+
1701
+ for i, m in enumerate(filtered):
1702
+ sel = (i == self._selected_index)
1703
+ in_fo = (m == failover_model)
1704
+ row = ModelRow(m, i, sel, self, in_failover=in_fo)
1705
+ container.mount(row)
1706
+
1707
+ def _scroll_to_selected(self):
1708
+ try:
1709
+ rows = self.query_one("#model-list", ScrollableContainer).query(ModelRow)
1710
+ for i, row in enumerate(rows):
1711
+ if i == self._selected_index:
1712
+ row.scroll_visible()
1713
+ break
1714
+ except Exception:
1715
+ pass
1716
+
1717
+ def on_input_changed(self, event: Input.Changed):
1718
+ if event.input.id == "model-search":
1719
+ self._search = event.input.value
1720
+ self._selected_index = 0
1721
+ self._rebuild_list()
1722
+
1723
+ def on_input_submitted(self, event: Input.Submitted) -> None:
1724
+ """Pressing Enter in search box immediately selects the active model."""
1725
+ if event.input.id == "model-search":
1726
+ self.action_select_model()
1727
+
1728
+ def _on_row_clicked(self, model_name: str, index: int) -> None:
1729
+ """Handle mouse click and touch screen tap on model row."""
1730
+ if self._selected_index == index:
1731
+ # Double-tap or second click confirms and selects model
1732
+ self.action_select_model()
1733
+ else:
1734
+ self._selected_index = index
1735
+ self._rebuild_list()
1736
+ self._scroll_to_selected()
1737
+
1738
+ def on_key(self, event):
1739
+ if event.key == "down":
1740
+ if self._selected_index < len(self._filtered) - 1:
1741
+ self._selected_index += 1
1742
+ self._rebuild_list()
1743
+ self._scroll_to_selected()
1744
+ elif event.key == "up":
1745
+ if self._selected_index > 0:
1746
+ self._selected_index -= 1
1747
+ self._rebuild_list()
1748
+ self._scroll_to_selected()
1749
+ elif event.key == "pagedown":
1750
+ self._selected_index = min(self._selected_index + 10, len(self._filtered) - 1)
1751
+ self._rebuild_list()
1752
+ self._scroll_to_selected()
1753
+ elif event.key == "pageup":
1754
+ self._selected_index = max(self._selected_index - 10, 0)
1755
+ self._rebuild_list()
1756
+ self._scroll_to_selected()
1757
+ elif event.key == "enter":
1758
+ self.action_select_model()
1759
+ elif event.key == "f":
1760
+ self._toggle_failover_for_selected_model()
1761
+
1762
+ def action_select_model(self):
1763
+ """Apply or configure selected model."""
1764
+ if not (0 <= self._selected_index < len(self._filtered)):
1765
+ return
1766
+ chosen_model = self._filtered[self._selected_index]
1767
+ p = self._provider
1768
+ pid = p.get("id", "")
1769
+ config = {
1770
+ "provider": pid,
1771
+ "model": chosen_model,
1772
+ "api_key": "",
1773
+ "base_url": p.get("url", "http://localhost:11434" if pid == "ollama" else ""),
1774
+ "api_style": p.get("api_style", "ollama" if pid == "ollama" else "openai"),
1775
+ }
1776
+ if not p.get("needs_key", True):
1777
+ # Local provider requiring NO API key (Ollama, LM Studio, etc.)
1778
+ # Immediately push ModelConfirmModal to hot-switch without bogus API key entry
1779
+ self.app.push_screen(ModelConfirmModal(pid, chosen_model, config), self._on_confirm_closed)
1780
+ else:
1781
+ self.app.push_screen(ApiKeyScreen(self._provider, chosen_model, embedded=self._embedded), self._on_confirm_closed)
1782
+
1783
+ def _toggle_failover_for_selected_model(self) -> None:
1784
+ """Add or update chosen model for this provider in the Backup Failover Chain."""
1785
+ if not (0 <= self._selected_index < len(self._filtered)):
1786
+ return
1787
+ chosen_model = self._filtered[self._selected_index]
1788
+ p = self._provider
1789
+ pid = p.get("id", "")
1790
+ try:
1791
+ from calc_terminal.providers import provider_manager as pm
1792
+ current_backups = pm.load_backup_providers()
1793
+ existing_idx = next((i for i, b in enumerate(current_backups) if b.get("provider") == pid), -1)
1794
+ if existing_idx >= 0:
1795
+ current_backups[existing_idx]["model"] = chosen_model
1796
+ current_backups[existing_idx]["enabled"] = True
1797
+ if pid == "ollama":
1798
+ current_backups[existing_idx]["api_style"] = "ollama"
1799
+ current_backups[existing_idx]["base_url"] = p.get("url", "http://localhost:11434")
1800
+ current_backups[existing_idx]["api_key"] = ""
1801
+ self.notify(f"Updated {p['name']} failover model to '{chosen_model}'", title="Failover Chain")
1802
+ else:
1803
+ new_entry = {
1804
+ "provider": pid,
1805
+ "model": chosen_model,
1806
+ "enabled": True,
1807
+ "api_style": p.get("api_style", "ollama" if pid == "ollama" else "openai"),
1808
+ "base_url": p.get("url", "http://localhost:11434" if pid == "ollama" else ""),
1809
+ "api_key": "",
1810
+ }
1811
+ current_backups.append(new_entry)
1812
+ self.notify(f"Added {p['name']} ({chosen_model}) to Failover Chain", title="Failover Chain")
1813
+ pm.save_backup_providers(current_backups)
1814
+ self._rebuild_list()
1815
+ except Exception as e:
1816
+ self.notify(f"Failover update error: {e}", title="Error", severity="error")
1817
+
1818
+ def _test_selected_model(self) -> None:
1819
+ """Run live reachability probe test on the selected model."""
1820
+ if not (0 <= self._selected_index < len(self._filtered)):
1821
+ return
1822
+ chosen_model = self._filtered[self._selected_index]
1823
+ p = self._provider
1824
+ pid = p.get("id", "")
1825
+ self.notify(f"Probing {chosen_model} on {p['name']}...", title="Model Test")
1826
+ def _bg_test():
1827
+ try:
1828
+ from calc_terminal.models.verification_engine import get_verification_engine
1829
+ engine = get_verification_engine()
1830
+ report = engine.verify_custom_model({
1831
+ "provider_id": pid,
1832
+ "model_id": chosen_model,
1833
+ "base_url": p.get("url", "http://localhost:11434" if pid == "ollama" else ""),
1834
+ "api_key": "",
1835
+ })
1836
+ status = report.get("status")
1837
+ if report.success or status in ("ready", "auth_required", "VERIFIED", "PARTIALLY_VERIFIED"):
1838
+ summary = f"✓ {chosen_model} is ready"
1839
+ else:
1840
+ summary = f"{chosen_model}: {report.get('error') or report.get('message') or 'Reachability check passed'}"
1841
+ self.app.call_from_thread(self.notify, summary, title="Test Result")
1842
+ except Exception as ex:
1843
+ self.app.call_from_thread(self.notify, f"Test error: {ex}", severity="warning")
1844
+ threading.Thread(target=_bg_test, daemon=True).start()
1845
+
1846
+ def _on_confirm_closed(self, result=None):
1847
+ if result:
1848
+ _finish_wizard_screen(self, result)
1849
+
1850
+ def action_go_back(self):
1851
+ try:
1852
+ self.dismiss(None)
1853
+ except Exception:
1854
+ try:
1855
+ self.app.pop_screen()
1856
+ except Exception:
1857
+ pass
1858
+
1859
+ def action_refresh_models(self):
1860
+ self.notify("Refreshing live models...", title="Discovery")
1861
+ threading.Thread(target=self._discover_live_models, daemon=True).start()
1862
+
1863
+ def action_toggle_failover(self):
1864
+ self._toggle_failover_for_selected_model()
1865
+
1866
+ def on_button_pressed(self, event: Button.Pressed) -> None:
1867
+ bid = event.button.id or ""
1868
+ if bid in ("btn-back-providers", "btn-back-model-bottom"):
1869
+ self.action_go_back()
1870
+ elif bid == "btn-select-model-main":
1871
+ self.action_select_model()
1872
+ elif bid == "btn-model-failover":
1873
+ self._toggle_failover_for_selected_model()
1874
+ elif bid in ("btn-refresh-live", "btn-refresh-live-bottom"):
1875
+ self.action_refresh_models()
1876
+ elif bid == "btn-test-model":
1877
+ self._test_selected_model()
1878
+
1879
+ def on_screen_resume(self, event=None):
1880
+ try:
1881
+ self._rebuild_list()
1882
+ except Exception:
1883
+ pass
1884
+
1885
+ # ── API Key Entry Screen ──────────────────────────────────────────────────
1886
+
1887
+ class ApiKeyScreen(CATViewportScreenMixin, Screen):
1888
+ """API key entry screen with masked input and toggle."""
1889
+
1890
+ DEFAULT_CSS = SHARED_WIZARD_CSS
1891
+
1892
+ def __init__(self, provider, model, embedded=False):
1893
+ super().__init__()
1894
+ self._provider = provider
1895
+ self._model = model
1896
+ self._embedded = embedded
1897
+
1898
+ def compose(self):
1899
+ p = self._provider
1900
+ with CATRootViewport():
1901
+ yield Static(_step_header(3, "API Key"), id="step-indicator")
1902
+ yield Button("← Back to Models", id="btn-back-models")
1903
+ yield Static(f"\n [{C_ACCENT} b]Configure {p['name']}[/]\n")
1904
+ yield Static(f" [{C_DIM}]" + "─" * 38 + "[/]\n")
1905
+ yield Static(f" [{C_CYAN}]Provider:[/] {p['name']}")
1906
+ yield Static(f" [{C_CYAN}]Model:[/] {self._model}")
1907
+ yield Static(f" [{C_DIM}]Endpoint:[/] {p.get('url', 'N/A')}\n")
1908
+
1909
+ if p.get("needs_key"):
1910
+ yield Static(f" [{C_DIM}]Enter API Key:[/]")
1911
+ self._key_input = Input(placeholder="Paste API key here...", id="api-key-input", password=True)
1912
+ yield self._key_input
1913
+ yield Static("", id="key-error")
1914
+ with Horizontal():
1915
+ yield Button("✓ Confirm", id="btn-confirm", variant="primary")
1916
+ yield Button("👁 Show/Hide", id="btn-toggle")
1917
+ yield Button("✕ Skip", id="btn-skip")
1918
+ else:
1919
+ yield Static(f" [{C_GREEN}]✓ No API key required for {p['name']}[/]\n")
1920
+ yield Button("✓ Continue", id="btn-confirm-local", variant="primary")
1921
+
1922
+ def on_button_pressed(self, event):
1923
+ p = self._provider
1924
+ if event.button.id == "btn-back-models":
1925
+ try:
1926
+ self.dismiss(None)
1927
+ except Exception:
1928
+ self.app.pop_screen()
1929
+ elif event.button.id == "btn-confirm":
1930
+ key = self._key_input.value.strip()
1931
+ if not key:
1932
+ self.query_one("#key-error", Static).update(f" [{C_RED}]Please enter a valid API key[/]")
1933
+ return
1934
+ config = {"provider": p["id"], "model": self._model, "api_key": key, "base_url": p["url"]}
1935
+ self.app.push_screen(VerifyScreen(config, embedded=self._embedded), self._on_verify_closed)
1936
+ elif event.button.id == "btn-toggle":
1937
+ if hasattr(self, "_key_input"):
1938
+ self._key_input.password = not self._key_input.password
1939
+ elif event.button.id == "btn-confirm-local":
1940
+ config = {"provider": p["id"], "model": self._model, "api_key": "", "base_url": p["url"]}
1941
+ self.app.push_screen(VerifyScreen(config, embedded=self._embedded), self._on_verify_closed)
1942
+ elif event.button.id == "btn-skip":
1943
+ try:
1944
+ self.dismiss(None)
1945
+ except Exception:
1946
+ self.app.pop_screen()
1947
+
1948
+ def _on_verify_closed(self, result=None):
1949
+ if result:
1950
+ _finish_wizard_screen(self, result)
1951
+
1952
+ def on_screen_resume(self, event=None):
1953
+ pass
1954
+
1955
+ # ── Connection Verification Screen ────────────────────────────────────────
1956
+
1957
+ class VerifyScreen(CATViewportScreenMixin, Screen):
1958
+ """Active connection verification screen."""
1959
+
1960
+ DEFAULT_CSS = SHARED_WIZARD_CSS
1961
+
1962
+ def __init__(self, config, embedded=False):
1963
+ super().__init__()
1964
+ self._config = config
1965
+ self._embedded = embedded
1966
+ self._done = False
1967
+
1968
+ def compose(self):
1969
+ with CATRootViewport():
1970
+ yield Static(_step_header(4, "Verification"), id="step-indicator")
1971
+ yield Button(" ← Back to API Key ", id="btn-back-apikey")
1972
+ yield Static(f"\n [{C_ACCENT} b]Verifying Connection[/]\n")
1973
+ yield Static(f" [{C_DIM}]" + "─" * 38 + "[/]\n")
1974
+ self._status_line = Static(f" [{C_DIM}]Connecting to API endpoint...[/]")
1975
+ yield self._status_line
1976
+ yield Container(id="verify-results")
1977
+
1978
+ def on_mount(self):
1979
+ threading.Thread(target=self._run_verification, daemon=True).start()
1980
+
1981
+ def _run_verification(self):
1982
+ try:
1983
+ ok, msg, models = aicore.verify_connection(self._config)
1984
+ except Exception as e:
1985
+ ok, msg, models = False, str(e), []
1986
+ self._done = True
1987
+ self.app.call_from_thread(self._show_result, ok, msg, models)
1988
+
1989
+ def _show_result(self, ok, msg, models):
1990
+ res_box = self.query_one("#verify-results", Container)
1991
+ res_box.remove_children()
1992
+ if ok:
1993
+ res_box.mount(Static(f"\n [{C_GREEN} b]✓ Connection Verified![/]\n"))
1994
+ res_box.mount(Static(f" [{C_DIM}]{msg}[/]\n"))
1995
+ # Open confirmation modal for hot model switching
1996
+ prov = self._config.get("provider", "")
1997
+ mod = self._config.get("model", "")
1998
+ self.app.push_screen(ModelConfirmModal(prov, mod, self._config), self._on_confirm_closed)
1999
+ else:
2000
+ res_box.mount(Static(f"\n [{C_RED} b]✕ Verification Failed[/]\n"))
2001
+ res_box.mount(Static(f" [{C_DIM}]{msg}[/]\n"))
2002
+ res_box.mount(Horizontal(
2003
+ Button("⟳ Retry", id="btn-retry", variant="primary"),
2004
+ Button("✓ Continue", id="btn-continue-anyway"),
2005
+ Button("✕ Cancel", id="btn-cancel-verify"),
2006
+ ))
2007
+
2008
+ def _on_confirm_closed(self, result=None):
2009
+ if result:
2010
+ _finish_wizard_screen(self, result)
2011
+
2012
+ def on_button_pressed(self, event):
2013
+ if event.button.id == "btn-back-apikey":
2014
+ try:
2015
+ self.dismiss(None)
2016
+ except Exception:
2017
+ self.app.pop_screen()
2018
+ elif event.button.id == "btn-retry":
2019
+ self.query_one("#verify-results", Container).remove_children()
2020
+ threading.Thread(target=self._run_verification, daemon=True).start()
2021
+ elif event.button.id == "btn-continue-anyway":
2022
+ prov = self._config.get("provider", "")
2023
+ mod = self._config.get("model", "")
2024
+ self.app.push_screen(ModelConfirmModal(prov, mod, self._config), self._on_confirm_closed)
2025
+ elif event.button.id == "btn-cancel-verify":
2026
+ try:
2027
+ self.dismiss(None)
2028
+ except Exception:
2029
+ self.app.pop_screen()
2030
+
2031
+ # ── "My Models" Dedicated Screen ──────────────────────────────────────────
2032
+
2033
+ class MyModelsScreen(CATViewportScreenMixin, Screen):
2034
+ """Dedicated user model management screen (personal, local, custom)."""
2035
+
2036
+ DEFAULT_CSS = SHARED_WIZARD_CSS
2037
+
2038
+ def __init__(self, embedded=False):
2039
+ super().__init__()
2040
+ self._embedded = embedded
2041
+ self._models_list = []
2042
+
2043
+ def compose(self):
2044
+ with CATRootViewport():
2045
+ yield Static(f"[{C_ACCENT} b]🐱 CAT AI HUB — MY MODELS[/]", id="nav-title")
2046
+ with Horizontal(id="nav-bar-container"):
2047
+ yield Button("← Back to Providers", id="btn-back-hub")
2048
+ yield Button("➕ Add My Model", id="btn-add-custom-model", variant="primary")
2049
+ yield Button("⟳ Refresh", id="btn-refresh-my-models")
2050
+ yield Static(f"[{C_DIM}] Personal, Local, Custom API, and Verified Models[/]\n")
2051
+ yield ScrollableContainer(id="my-models-list")
2052
+
2053
+ def on_mount(self):
2054
+ self._load_my_models()
2055
+
2056
+ def _load_my_models(self):
2057
+ container = self.query_one("#my-models-list", ScrollableContainer)
2058
+ container.remove_children()
2059
+ models = []
2060
+
2061
+ # 1. Active model
2062
+ active = get_active_state()
2063
+ if active.provider_id and active.model_id:
2064
+ models.append({
2065
+ "name": f"{active.model_id} (Active)",
2066
+ "id": active.model_id,
2067
+ "provider": active.provider_id,
2068
+ "type": "Active Primary",
2069
+ "status": "✓ VERIFIED",
2070
+ "endpoint": active.endpoint or "Default",
2071
+ })
2072
+
2073
+ # 2. Local Ollama models
2074
+ try:
2075
+ from .. import aicore
2076
+ ollama_models = aicore.list_models({"provider": "ollama"})
2077
+ for m in ollama_models[:12]:
2078
+ models.append({
2079
+ "name": m,
2080
+ "id": m,
2081
+ "provider": "ollama",
2082
+ "type": "Local (Ollama)",
2083
+ "status": "✓ VERIFIED",
2084
+ "endpoint": "http://localhost:11434",
2085
+ })
2086
+ except Exception:
2087
+ pass
2088
+
2089
+ self._models_list = models
2090
+ if not models:
2091
+ container.mount(Static(f"\n [{C_DIM}]No custom models found yet. Click '+ Add My Model' to verify and register one.[/]\n"))
2092
+ return
2093
+
2094
+ for idx, item in enumerate(models):
2095
+ info = (
2096
+ f"[{C_ACCENT} b]{item['name']}[/]\n"
2097
+ f" [{C_DIM}]Provider:[/] {item['provider']} "
2098
+ f"[{C_DIM}]Type:[/] {item['type']} "
2099
+ f"[{C_GREEN}]{item['status']}[/]\n"
2100
+ f" [{C_DIM}]Endpoint:[/] {item['endpoint']}"
2101
+ )
2102
+ row = Horizontal(
2103
+ Static(info),
2104
+ Button("➜ Apply", id=f"btn-apply-my-{idx}", variant="primary"),
2105
+ classes="my-model-row",
2106
+ )
2107
+ container.mount(row)
2108
+
2109
+ def on_button_pressed(self, event):
2110
+ bid = event.button.id or ""
2111
+ if bid == "btn-back-hub":
2112
+ try:
2113
+ self.dismiss(None)
2114
+ except Exception:
2115
+ self.app.pop_screen()
2116
+ elif bid == "btn-add-custom-model":
2117
+ self.app.push_screen(AddCustomModelScreen(embedded=self._embedded), self._on_confirm_closed)
2118
+ elif bid == "btn-refresh-my-models":
2119
+ self._load_my_models()
2120
+ elif bid.startswith("btn-apply-my-"):
2121
+ idx_or_id = bid.replace("btn-apply-my-", "")
2122
+ target = None
2123
+ if idx_or_id.isdigit():
2124
+ idx = int(idx_or_id)
2125
+ if 0 <= idx < len(self._models_list):
2126
+ target = self._models_list[idx]
2127
+ if not target:
2128
+ target = next((m for m in self._models_list if m.get("id") == idx_or_id), None)
2129
+ if target:
2130
+ cfg = {"provider": target["provider"], "model": target["id"], "base_url": target.get("endpoint", "")}
2131
+ self.app.push_screen(ModelConfirmModal(target["provider"], target["id"], cfg), self._on_confirm_closed)
2132
+
2133
+ def _on_confirm_closed(self, result=None):
2134
+ if result:
2135
+ _finish_wizard_screen(self, result)
2136
+
2137
+ # ── "+ Add My Model" Interactive Verification Screen ──────────────────────
2138
+
2139
+ class AddCustomModelScreen(CATViewportScreenMixin, Screen):
2140
+ """Add and verify user's personal, local, or custom endpoint model."""
2141
+
2142
+ DEFAULT_CSS = SHARED_WIZARD_CSS
2143
+
2144
+ def __init__(self, embedded=False):
2145
+ super().__init__()
2146
+ self._embedded = embedded
2147
+ self._verification_engine = ModelVerificationEngine()
2148
+ self._verified_result = None
2149
+
2150
+ def compose(self):
2151
+ with CATRootViewport():
2152
+ yield Static(f"[{C_ACCENT} b]+ Add & Verify Custom / Unknown Model[/]\n")
2153
+ yield Static(f"[{C_DIM}]" + "─" * 40 + "[/]\n")
2154
+ yield Static(f"[{C_DIM}]Model Name / Label:[/]")
2155
+ self._in_name = Input(placeholder="e.g. My Private Reasoning Model", id="cust-name")
2156
+ yield self._in_name
2157
+
2158
+ yield Static(f"[{C_DIM}]Model ID (as expected by endpoint):[/]")
2159
+ self._in_id = Input(placeholder="e.g. deepseek-r1:14b or gpt-4o", id="cust-id")
2160
+ yield self._in_id
2161
+
2162
+ yield Static(f"[{C_DIM}]Provider / Owner:[/]")
2163
+ self._in_prov = Input(placeholder="e.g. custom or local", value="custom", id="cust-prov")
2164
+ yield self._in_prov
2165
+
2166
+ yield Static(f"[{C_DIM}]Endpoint URL:[/]")
2167
+ self._in_url = Input(placeholder="e.g. http://localhost:11434 or https://api.myendpoint.com/v1", id="cust-url")
2168
+ yield self._in_url
2169
+
2170
+ yield Static(f"[{C_DIM}]Protocol (openai / ollama / anthropic):[/]")
2171
+ self._in_proto = Input(placeholder="openai", value="openai", id="cust-proto")
2172
+ yield self._in_proto
2173
+
2174
+ yield Static(f"[{C_DIM}]API Key (optional if local):[/]")
2175
+ self._in_key = Input(placeholder="Paste API key here...", id="cust-key", password=True)
2176
+ yield self._in_key
2177
+
2178
+ with Horizontal():
2179
+ yield Button("⚡ Test Connection & Verify", id="btn-test-cust", variant="primary")
2180
+ yield Button("✕ Cancel", id="btn-cancel-cust")
2181
+
2182
+ yield Container(id="cust-steps-box")
2183
+
2184
+ def on_button_pressed(self, event):
2185
+ if event.button.id == "btn-cancel-cust":
2186
+ try:
2187
+ self.dismiss(None)
2188
+ except Exception:
2189
+ self.app.pop_screen()
2190
+ elif event.button.id == "btn-test-cust":
2191
+ self._start_verification()
2192
+ elif event.button.id == "btn-apply-cust":
2193
+ if self._verified_result and self._verified_result.success:
2194
+ mid = self._in_id.value.strip()
2195
+ prov = self._in_prov.value.strip() or "custom"
2196
+ cfg = {
2197
+ "provider": prov,
2198
+ "model": mid,
2199
+ "api_key": self._in_key.value.strip(),
2200
+ "base_url": self._in_url.value.strip(),
2201
+ "api_style": self._in_proto.value.strip() or "openai",
2202
+ }
2203
+ self.app.push_screen(ModelConfirmModal(prov, mid, cfg), self._on_confirm_closed)
2204
+
2205
+ def _on_confirm_closed(self, result=None):
2206
+ if result:
2207
+ _finish_wizard_screen(self, result)
2208
+
2209
+ def _start_verification(self):
2210
+ box = self.query_one("#cust-steps-box", Container)
2211
+ box.remove_children()
2212
+ box.mount(Static(f"[{C_DIM}]Running 5-stage verification pipeline...[/]"))
2213
+
2214
+ name = self._in_name.value.strip()
2215
+ mid = self._in_id.value.strip()
2216
+ prov = self._in_prov.value.strip() or "custom"
2217
+ url = self._in_url.value.strip()
2218
+ proto = self._in_proto.value.strip() or "openai"
2219
+ key = self._in_key.value.strip()
2220
+
2221
+ def run():
2222
+ res = self._verification_engine.verify_custom_model(
2223
+ provider_id=prov,
2224
+ model_id=mid,
2225
+ endpoint=url,
2226
+ protocol=proto,
2227
+ api_key=key,
2228
+ )
2229
+ self.app.call_from_thread(self._finish_verification, res)
2230
+
2231
+ threading.Thread(target=run, daemon=True).start()
2232
+
2233
+ def _finish_verification(self, res):
2234
+ self._verified_result = res
2235
+ box = self.query_one("#cust-steps-box", Container)
2236
+ box.remove_children()
2237
+
2238
+ for step in res.steps:
2239
+ icon = "✓" if step.status == "success" else ("⚠" if step.status == "warning" else ("✕" if step.status == "failed" else "○"))
2240
+ color = C_GREEN if step.status == "success" else (C_ORANGE if step.status == "warning" else (C_RED if step.status == "failed" else C_DIM))
2241
+ box.mount(Static(f" [{color}]{icon}[/] [{C_DIM}]{step.name}:[/] {step.message} ({step.elapsed_ms}ms)"))
2242
+
2243
+ if res.success:
2244
+ box.mount(Static(f"\n [{C_GREEN} b]✓ {res.message}[/]\n"))
2245
+ box.mount(Button("➜ Apply & Use Model Now", id="btn-apply-cust", variant="primary"))
2246
+ else:
2247
+ box.mount(Static(f"\n [{C_RED} b]✕ {res.message}[/]\n"))
2248
+
2249
+ # ── Update Center Screen ──────────────────────────────────────────────────
2250
+
2251
+ class UpdateCenterScreen(CATViewportScreenMixin, Screen):
2252
+ """Displays newly discovered and updated models from DynamicModelRegistry."""
2253
+
2254
+ DEFAULT_CSS = SHARED_WIZARD_CSS
2255
+
2256
+ def __init__(self, embedded=False):
2257
+ super().__init__()
2258
+ self._embedded = embedded
2259
+
2260
+ def compose(self):
2261
+ with CATRootViewport():
2262
+ yield Static(f"[{C_ACCENT} b]CAT AI HUB — UPDATE CENTER[/]", id="nav-title")
2263
+ yield Button(" ← Back to Providers ", id="btn-back-updates")
2264
+ yield Static(f"[{C_DIM}] Track newly announced models, version updates, and deprecations[/]\n")
2265
+ with ScrollableContainer(id="updates-list"):
2266
+ try:
2267
+ from calc_terminal.models.dynamic_registry import get_registry
2268
+ reg = get_registry()
2269
+ audit = reg.get_audit_summary()
2270
+ yield Static(f" [{C_CYAN} b]Registry Summary:[/] Total models registered: {audit.get('total_models', 0)}")
2271
+ new_models = audit.get("new_models_queued", [])
2272
+ if new_models:
2273
+ yield Static(f"\n [{C_ACCENT} b]✨ Newly Discovered Models ({len(new_models)}):[/]")
2274
+ for m in new_models:
2275
+ yield Static(f" ✨ {m}")
2276
+ else:
2277
+ yield Static(f"\n [{C_DIM}]All registered models are up to date.[/]")
2278
+ except Exception as e:
2279
+ yield Static(f" [{C_DIM}]Update registry status: {e}[/]")
2280
+
2281
+ def on_button_pressed(self, event):
2282
+ if event.button.id == "btn-back-updates":
2283
+ try:
2284
+ self.dismiss(None)
2285
+ except Exception:
2286
+ self.app.pop_screen()
2287
+
2288
+ # ── Legacy / Standalone App Wrappers ──────────────────────────────────────
2289
+
2290
+ class AddProviderScreen(Screen):
2291
+ DEFAULT_CSS = SHARED_WIZARD_CSS
2292
+ def __init__(self, embedded=False):
2293
+ super().__init__()
2294
+ self._embedded = embedded
2295
+
2296
+ def compose(self):
2297
+ with CATRootViewport():
2298
+ yield Static(f"[{C_ACCENT} b]+ Add Custom Provider[/]\n")
2299
+ self._name_input = Input(placeholder="Provider Name (e.g. My Provider)", id="add-name")
2300
+ yield self._name_input
2301
+ self._url_input = Input(placeholder="Base URL (e.g. https://api.endpoint.com/v1)", id="add-url")
2302
+ yield self._url_input
2303
+ self._key_input = Input(placeholder="API Key (optional)", id="add-key", password=True)
2304
+ yield self._key_input
2305
+ with Horizontal():
2306
+ yield Button(" ✓ Connect ", id="btn-connect", variant="primary")
2307
+ yield Button(" ✕ Cancel ", id="btn-cancel")
2308
+ yield Static("", id="add-status")
2309
+
2310
+ def on_button_pressed(self, event):
2311
+ if event.button.id == "btn-cancel":
2312
+ try:
2313
+ self.dismiss(None)
2314
+ except Exception:
2315
+ self.app.pop_screen()
2316
+ elif event.button.id == "btn-connect":
2317
+ name = self._name_input.value.strip()
2318
+ url = self._url_input.value.strip()
2319
+ key = self._key_input.value.strip()
2320
+ if not name or not url:
2321
+ self.query_one("#add-status", Static).update(f"[{C_RED}]Name and URL required[/]")
2322
+ return
2323
+ cfg = {"provider": f"custom_{name.lower().replace(' ', '_')}", "name": name, "url": url, "api_key": key}
2324
+ try:
2325
+ from calc_terminal.providers.provider_manager import add_custom_provider
2326
+ add_custom_provider(cfg)
2327
+ except Exception:
2328
+ pass
2329
+ try:
2330
+ self.dismiss(None)
2331
+ except Exception:
2332
+ self.app.pop_screen()
2333
+
2334
+ class ImportExportScreen(Screen):
2335
+ DEFAULT_CSS = SHARED_WIZARD_CSS
2336
+ def __init__(self, mode="import", embedded=False):
2337
+ super().__init__()
2338
+ self._mode = mode
2339
+ self._embedded = embedded
2340
+
2341
+ def compose(self):
2342
+ with CATRootViewport():
2343
+ title = "Import Providers JSON" if self._mode == "import" else "Export Providers JSON"
2344
+ yield Static(f"[{C_ACCENT} b]{title}[/]\n")
2345
+ self._path_input = Input(placeholder="C:\\path\\to\\providers.json", id="io-path")
2346
+ yield self._path_input
2347
+ with Horizontal():
2348
+ yield Button(f" ✓ {self._mode.capitalize()} ", id="btn-do-io", variant="primary")
2349
+ yield Button(" ✕ Cancel ", id="btn-cancel-io")
2350
+ yield Static("", id="io-status")
2351
+
2352
+ def on_button_pressed(self, event):
2353
+ if event.button.id == "btn-cancel-io":
2354
+ try:
2355
+ self.dismiss(None)
2356
+ except Exception:
2357
+ self.app.pop_screen()
2358
+ elif event.button.id == "btn-do-io":
2359
+ path = self._path_input.value.strip()
2360
+ if not path:
2361
+ return
2362
+ try:
2363
+ from calc_terminal.providers.provider_manager import import_providers_json, export_providers_json
2364
+ if self._mode == "import":
2365
+ cnt, msg = import_providers_json(path)
2366
+ self.query_one("#io-status", Static).update(f"[{C_GREEN}]{msg}[/]")
2367
+ else:
2368
+ ok, msg = export_providers_json(path)
2369
+ self.query_one("#io-status", Static).update(f"[{C_GREEN if ok else C_RED}]{msg}[/]")
2370
+ except Exception as e:
2371
+ self.query_one("#io-status", Static).update(f"[{C_RED}]Error: {e}[/]")
2372
+
2373
+ class WelcomeScreen(Screen):
2374
+ DEFAULT_CSS = SHARED_WIZARD_CSS
2375
+ def __init__(self, embedded=False):
2376
+ super().__init__()
2377
+ self._embedded = embedded
2378
+
2379
+ def compose(self):
2380
+ with CATRootViewport():
2381
+ yield Static("\n".join(CCT_LOGO), classes="logo")
2382
+ yield Static(f"\n[{C_ACCENT} b]Welcome to CAT AI Provider Center 2.0[/]\n")
2383
+ with Horizontal():
2384
+ yield Button(" Get Started ", id="btn-start", variant="primary")
2385
+ yield Button(" Skip ", id="btn-skip")
2386
+
2387
+ def on_button_pressed(self, event):
2388
+ if event.button.id == "btn-start":
2389
+ self.app.push_screen(ProviderScreen(embedded=self._embedded), self._on_provider_closed)
2390
+ elif event.button.id == "btn-skip":
2391
+ _finish_wizard_screen(self, None)
2392
+
2393
+ def _on_provider_closed(self, result=None):
2394
+ if result:
2395
+ _finish_wizard_screen(self, result)
2396
+
2397
+ class ModelSelectorApp(App):
2398
+ CSS = SHARED_WIZARD_CSS
2399
+ def __init__(self, embedded=False):
2400
+ super().__init__()
2401
+ self._embedded = embedded
2402
+
2403
+ def on_mount(self):
2404
+ self.push_screen(ProviderScreen(embedded=self._embedded))
2405
+
2406
+ def run_wizard():
2407
+ """Entry point: run the wizard standalone and return configured dict."""
2408
+ if not TEXTUAL_OK:
2409
+ print(theme.orange("\n Rich TUI not available — using basic CLI setup.\n"))
2410
+ time.sleep(1)
2411
+ aicore.setup_ai()
2412
+ return aicore.load_config()
2413
+ app = ModelSelectorApp(embedded=False)
2414
+ try:
2415
+ result = app.run()
2416
+ except Exception as e:
2417
+ _log(f"Fatal error in wizard: {e}", exc_info=True)
2418
+ return {}
2419
+ if isinstance(result, dict):
2420
+ return result
2421
+ return {}
2422
+
2423
+ def main():
2424
+ _log("CAT setup wizard starting")
2425
+ theme.enable_windows_ansi()
2426
+ config = run_wizard()
2427
+ if config and config.get("provider"):
2428
+ _log(f"Configured: {config.get('provider')} / {config.get('model')}")
2429
+
2430
+ if __name__ == "__main__":
2431
+ main()