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