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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- 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()
|