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