cct-cli 0.7.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,424 @@
1
+ """
2
+ CCT UI — attachment + smart-paste chips, living in the composer's
3
+ AttachmentBar. Renders state (what's attached, what's collapsed) and
4
+ dispatches events (AttachmentAdded/Removed, PasteCollapsed/Expanded);
5
+ StickyComposer/CCTApp decide what those events mean.
6
+ """
7
+
8
+ if __name__ == "__main__":
9
+ print("This is a library file and is not meant to be run directly.")
10
+ import sys
11
+ sys.exit(1)
12
+
13
+ import os
14
+
15
+ TEXTUAL_AVAILABLE = True
16
+ try:
17
+ from textual.containers import Horizontal
18
+ except Exception:
19
+ TEXTUAL_AVAILABLE = False
20
+
21
+ from .widgets import Chip, TEXTUAL_AVAILABLE as _WIDGETS_OK
22
+ from .events import (AttachmentAdded, AttachmentRemoved, PasteCollapsed,
23
+ PasteExpanded, AttachmentChipDoubleClicked)
24
+
25
+ try:
26
+ from .. import attachments as _core
27
+ except Exception: # pragma: no cover — import-time fallback
28
+ _core = None
29
+
30
+ TEXTUAL_AVAILABLE = TEXTUAL_AVAILABLE and _WIDGETS_OK
31
+
32
+ # Paste thresholds from the redesign brief: under these a paste inserts
33
+ # normally into the editor; at/above either one, collapse into a chip.
34
+ PASTE_COLLAPSE_THRESHOLD = 10
35
+ PASTE_WORD_THRESHOLD = 100
36
+
37
+ # Favorite folders for the attach browser (v0.7.8.1): a tiny, honest
38
+ # store — just a JSON list of folder paths the user starred in the
39
+ # Attach panel's Favorites tab.
40
+ FAVORITES_FILE = os.path.join(os.path.expanduser("~"), ".cct_attach_favorites.json")
41
+
42
+
43
+ def favorite_folders() -> list[str]:
44
+ """Starred folders for the Attach browser. Never raises."""
45
+ try:
46
+ if os.path.exists(FAVORITES_FILE):
47
+ with open(FAVORITES_FILE, "r", encoding="utf-8") as f:
48
+ import json
49
+ data = json.load(f)
50
+ if isinstance(data, list):
51
+ return [p for p in data if isinstance(p, str) and os.path.isdir(p)]
52
+ except Exception:
53
+ pass
54
+ return []
55
+
56
+
57
+ def toggle_favorite_folder(path: str) -> bool:
58
+ """Star/unstar a folder. Returns True when it is now a favorite."""
59
+ path = os.path.normpath(path)
60
+ favs = favorite_folders()
61
+ if path in favs:
62
+ favs = [p for p in favs if p != path]
63
+ added = False
64
+ else:
65
+ favs.append(path)
66
+ added = True
67
+ try:
68
+ import json
69
+ with open(FAVORITES_FILE, "w", encoding="utf-8") as f:
70
+ json.dump(favs, f, indent=2)
71
+ except Exception:
72
+ pass
73
+ return added
74
+
75
+
76
+ def list_drives() -> list[str]:
77
+ """Windows drive letters that exist (A:, C:, ...). Empty elsewhere."""
78
+ try:
79
+ import string
80
+ return [f"{c}:\\" for c in string.ascii_uppercase
81
+ if os.path.exists(f"{c}:\\")]
82
+ except Exception:
83
+ return []
84
+
85
+
86
+ def paths_from_paste(text):
87
+ """Drag & drop / paste → attachment paths (v0.7.6 Patch 1, Fix 5;
88
+ hardened v0.8.1).
89
+
90
+ Textual 8.2.8 has no OS file-drop event, but dragging a file into a
91
+ Windows terminal inserts its path into the input buffer anyway —
92
+ which arrives here as a paste. So "drag & drop" is realised as
93
+ path-paste detection, and multi-file drops are covered too:
94
+ Explorer copies/drops NUL-separated paths (`\x00`); some hosts use
95
+ CRLF or quoted single-line forms.
96
+
97
+ Returns a list of existing absolute paths when the whole paste is
98
+ nothing but paths (one per line / NUL-separated, optional quotes),
99
+ else None so the caller falls back to a normal text paste. A path
100
+ must exist on disk — a stale/typo'd path is treated as prose."""
101
+ if not text:
102
+ return None
103
+ normalized = text.replace("\x00", "\n").replace("\r\n", "\n")
104
+ paths = []
105
+ for part in normalized.split("\n"):
106
+ p = part.strip().strip('"').strip("'").strip()
107
+ if not p:
108
+ continue
109
+ # tolerate the "file:///C:/..." URI form some apps put on the clipboard
110
+ if p.lower().startswith("file:///"):
111
+ from urllib.parse import unquote, urlparse
112
+ try:
113
+ p = unquote(urlparse(p).path).lstrip("/") or p
114
+ if len(p) > 1 and p[1] != ":" and os.name == "nt":
115
+ p = "C:" + p # unlikely; keep best-effort
116
+ except Exception:
117
+ pass
118
+ if not os.path.isabs(p) or not os.path.lexists(p):
119
+ return None
120
+ paths.append(os.path.normpath(p))
121
+ return paths or None
122
+
123
+
124
+ def attachment_kind(path):
125
+ """Fix 6 (chat attachment cards): an icon + kind label for an
126
+ attachment path, chosen purely from its extension. Presentation
127
+ only — the AI-side reading/analysis (Fix 7) lives in aicore."""
128
+ ext = os.path.splitext(path)[1].lower()
129
+ if ext in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".ico"}:
130
+ return "\U0001f5bc\ufe0f", "image"
131
+ if ext == ".pdf":
132
+ return "\U0001f4d5", "pdf"
133
+ if ext in {".zip", ".rar", ".7z", ".tar", ".gz", ".bz2"}:
134
+ return "\U0001f4e6", "archive"
135
+ if ext in {".csv", ".tsv", ".xlsx", ".xls", ".json", ".xml", ".yaml", ".yml",
136
+ ".toml", ".ini", ".conf", ".env"}:
137
+ return "\U0001f4ca", "data"
138
+ if ext == ".md":
139
+ return "\U0001f4dd", "markdown"
140
+ if ext in {".py", ".js", ".ts", ".jsx", ".tsx", ".java", ".c", ".cpp", ".h", ".hpp",
141
+ ".cs", ".go", ".rs", ".rb", ".php", ".sh", ".ps1", ".bat", ".html", ".css",
142
+ ".sql", ".lua", ".r", ".swift", ".kt"}:
143
+ return "\U0001f4dc", "code"
144
+ if ext in {".mp3", ".wav", ".ogg", ".flac", ".m4a", ".aac"}:
145
+ return "\U0001f3b5", "audio"
146
+ if ext in {".mp4", ".mkv", ".avi", ".mov", ".webm", ".wmv"}:
147
+ return "\U0001f3ac", "video"
148
+ return "\U0001f4c4", "file"
149
+
150
+
151
+ def format_size(path):
152
+ try:
153
+ size = os.path.getsize(path)
154
+ except Exception:
155
+ return ""
156
+ if size < 1024:
157
+ return f"{size} B"
158
+ if size < 1024 ** 2:
159
+ return f"{size / 1024:.1f} KB"
160
+ if size < 1024 ** 3:
161
+ return f"{size / 1024 ** 2:.1f} MB"
162
+ return f"{size / 1024 ** 3:.1f} GB"
163
+
164
+
165
+ def _size_label(size):
166
+ try:
167
+ size = int(size or 0)
168
+ except Exception:
169
+ size = 0
170
+ if size < 1024:
171
+ return f"{size} B"
172
+ if size < 1024 ** 2:
173
+ return f"{size / 1024:.1f} KB"
174
+ if size < 1024 ** 3:
175
+ return f"{size / 1024 ** 2:.1f} MB"
176
+ return f"{size / 1024 ** 3:.1f} GB"
177
+
178
+
179
+ def _status_label(att):
180
+ """v0.7.8.1: the status half of an attachment chip — 'reading…'
181
+ while extraction runs, 'kind · size' when ready, the honest error
182
+ when it failed. The color lives in the chip markup, not here."""
183
+ status = getattr(att, "extraction_status", None)
184
+ if status == _core.STATUS_READING:
185
+ return "reading\u2026"
186
+ if status == _core.STATUS_READY:
187
+ label = _core.KIND_LABELS.get(getattr(att, "kind", ""), "File")
188
+ return f"{label.lower()} \u00b7 {_size_label(getattr(att, 'size', 0))}"
189
+ if status == _core.STATUS_FAILED:
190
+ return getattr(att, "error", None) or "could not be read"
191
+ return "reading\u2026"
192
+
193
+
194
+ def _status_color(att):
195
+ """Dim for pending/ready, the theme's error color for failures."""
196
+ if getattr(att, "extraction_status", None) == _core.STATUS_FAILED:
197
+ from . import theme_css
198
+ return theme_css.current_hex("error")
199
+ from . import theme_css
200
+ return theme_css.current_hex("text-faint")
201
+
202
+
203
+ def _vision_warning(att):
204
+ """CAT automatically extracts OCR, multimodal features, and metadata
205
+ for all attached images and files across all models. Returns None so no
206
+ false warnings are displayed."""
207
+ return None
208
+
209
+
210
+ def _chip_label(att):
211
+ """One chip's full markup: icon + name + status + weak-model note."""
212
+ from . import theme_css
213
+ icon, _k = attachment_kind(att.path)
214
+ name = att.name
215
+ label = (f"{icon} [{theme_css.current_hex('text')}]{name}[/]"
216
+ f"[{_status_color(att)}]\u00b7{_status_label(att)}[/]")
217
+ warn = _vision_warning(att)
218
+ if warn:
219
+ label += f"[{theme_css.current_hex('warning')}] \u26a0 {warn}[/]"
220
+ return label
221
+
222
+
223
+ if TEXTUAL_AVAILABLE:
224
+
225
+ class AttachmentBar(Horizontal):
226
+ """Row of attachment + paste chips, living inside the composer
227
+ card under the editor. Empty (height: auto collapses to 0) until
228
+ something is attached.
229
+
230
+ Holds two kinds of chips:
231
+ - file attachments (label = filename; delete removes it)
232
+ - collapsed pastes (label = "[Pasted ~N lines]"; clicking the
233
+ label re-expands the full text via PasteExpanded; delete
234
+ discards it for good)
235
+
236
+ v0.7.8.1: every attached path is normalized into an Attachment
237
+ object (calc_terminal/attachments.py) the moment it's added.
238
+ Extraction runs off the UI thread; the chip shows 'reading…'
239
+ until it resolves to 'ready' (kind · size) or 'failed' (honest
240
+ error). The Attachment objects travel with the message via
241
+ pop_attachment_objects() — the AI pipeline receives real
242
+ objects, never bare path strings.
243
+
244
+ Every state change posts the matching event instead of being
245
+ polled — StickyComposer/CCTApp react to those, they never reach
246
+ back into this bar's internals.
247
+ """
248
+
249
+ DEFAULT_CSS = """
250
+ AttachmentBar {
251
+ height: auto;
252
+ width: 100%;
253
+ padding: 0 1;
254
+ margin: 0;
255
+ display: none;
256
+ }
257
+ """
258
+
259
+ def __init__(self):
260
+ super().__init__(id="cct-chips")
261
+ self._attachments = {} # chip_id -> Attachment object
262
+ self._pastes = {} # chip_id -> full pasted text
263
+ self._next_file_id = 1
264
+ self._next_paste_id = 1
265
+ self.display = False
266
+
267
+ def _sync_visibility(self):
268
+ has_items = bool(self._attachments or self._pastes)
269
+ self.display = has_items
270
+
271
+ def on_mount(self):
272
+ self._sync_visibility()
273
+
274
+ def add_file(self, path, kind="file", source="browse"):
275
+ """Validate → normalize → chip (requirements #14-16).
276
+
277
+ Returns the new chip_id, or None when the path was rejected.
278
+ Directories are rejected with an honest reason — only real
279
+ files attach. `source` records how the file arrived
280
+ ("browse" | "drag_and_drop" | "paste" | "sidebar")."""
281
+ try:
282
+ exists = os.path.lexists(path)
283
+ is_dir = os.path.isdir(path)
284
+ except OSError:
285
+ return None
286
+ if not exists:
287
+ return None
288
+ if is_dir:
289
+ return None # directories never attach as files
290
+ chip_id = f"file-{self._next_file_id}"
291
+ self._next_file_id += 1
292
+ att = _core.Attachment(path)
293
+ att.source = source # Attachment uses __slots__; see below
294
+ if kind not in ("file",) and kind != att.kind:
295
+ att.kind = kind
296
+ att.extraction_status = _core.STATUS_READING
297
+ self._attachments[chip_id] = att
298
+ self._mount_file_chip(chip_id)
299
+ # active drop/attach feedback: flash the bar so a dropped or
300
+ # pasted batch visibly landed (never just an animation on an
301
+ # empty handler)
302
+ try:
303
+ self.add_class("cct-drop-active")
304
+ self.set_timer(0.6, lambda: self.remove_class("cct-drop-active"))
305
+ except Exception:
306
+ pass
307
+ self.post_message(AttachmentAdded(chip_id, att.name, kind=att.kind))
308
+ # Extraction off the UI thread; the chip flips to ready /
309
+ # failed when it lands (async worker, safe to await here).
310
+ self.run_worker(self._extract_worker(chip_id), group="attachments")
311
+ return chip_id
312
+
313
+ def get_attachment(self, chip_id):
314
+ return self._attachments.get(chip_id)
315
+
316
+ def _mount_file_chip(self, chip_id):
317
+ att = self._attachments.get(chip_id)
318
+ if att is None:
319
+ return
320
+ self._sync_visibility()
321
+ self.mount(Chip(chip_id, _chip_label(att),
322
+ on_delete=self._remove_file,
323
+ on_double_click=self._double_clicked))
324
+ return chip_id
325
+
326
+ def _double_clicked(self, chip_id):
327
+ att = self._attachments.get(chip_id)
328
+ if att is None:
329
+ return
330
+ self.post_message(AttachmentChipDoubleClicked(
331
+ chip_id, att.path, att.name))
332
+
333
+ def _refresh_chip(self, chip_id):
334
+ """Re-render one chip's label from its Attachment's current
335
+ state — the status chips' reading -> ready/failed flip."""
336
+ for child in list(self.children):
337
+ if isinstance(child, Chip) and child.chip_id == chip_id:
338
+ att = self._attachments.get(chip_id)
339
+ if att is None:
340
+ return
341
+ child.update_label(_chip_label(att))
342
+ return
343
+
344
+ async def _extract_worker(self, chip_id):
345
+ import asyncio
346
+ att = self._attachments.get(chip_id)
347
+ if att is None or _core is None:
348
+ return
349
+ fresh = await asyncio.to_thread(_core.AttachmentManager.create, att.path)
350
+ att.kind = fresh.kind
351
+ att.metadata = fresh.metadata
352
+ att.content = fresh.content
353
+ att.extraction_status = fresh.extraction_status
354
+ att.error = fresh.error
355
+ if chip_id in self._attachments:
356
+ self._refresh_chip(chip_id)
357
+
358
+ def add_paste(self, text, line_count, word_count=0):
359
+ chip_id = f"paste-{self._next_paste_id}"
360
+ self._next_paste_id += 1
361
+ self._pastes[chip_id] = text
362
+ if line_count > 1:
363
+ label = f"[Pasted ~{line_count} lines]"
364
+ else:
365
+ label = f"[Pasted {len(text):,} characters]"
366
+ self._sync_visibility()
367
+ self.mount(Chip(chip_id, label, on_click=self._expand_paste,
368
+ on_delete=self._remove_paste))
369
+ self.post_message(PasteCollapsed(chip_id, line_count))
370
+ return chip_id
371
+
372
+ def pop_attachment_objects(self):
373
+ """v0.7.8.1: returns every attached Attachment object (in
374
+ insertion order) and clears the bar's file chips (pastes are
375
+ left alone) — called right before a message is sent, so
376
+ attachments travel with that one message as real objects,
377
+ not bare paths. A chip still mid-extraction returns its
378
+ object anyway; the AI pipeline re-extracts if needed."""
379
+ out = list(self._attachments.values())
380
+ ids = set(self._attachments)
381
+ self._attachments = {}
382
+ for child in list(self.children):
383
+ if isinstance(child, Chip) and child.chip_id in ids:
384
+ child.remove()
385
+ self._sync_visibility()
386
+ return out
387
+
388
+ def pop_attachment_paths(self):
389
+ """Backward-compatible shim — the pre-0.7.8.1 shape (paths).
390
+ New code should use pop_attachment_objects()."""
391
+ return [a.path for a in self.pop_attachment_objects()]
392
+
393
+ def _remove_file(self, chip_id):
394
+ if chip_id not in self._attachments:
395
+ return
396
+ self._attachments.pop(chip_id, None)
397
+ for child in list(self.children):
398
+ if isinstance(child, Chip) and child.chip_id == chip_id:
399
+ child.remove()
400
+ self._sync_visibility()
401
+ self.post_message(AttachmentRemoved(chip_id))
402
+
403
+ def _expand_paste(self, chip_id):
404
+ text = self._pastes.pop(chip_id, None)
405
+ if text is None:
406
+ return
407
+ for child in list(self.children):
408
+ if isinstance(child, Chip) and child.chip_id == chip_id:
409
+ child.remove()
410
+ self._sync_visibility()
411
+ self.post_message(PasteExpanded(chip_id, text))
412
+
413
+ def _remove_paste(self, chip_id):
414
+ if chip_id not in self._pastes:
415
+ return
416
+ self._pastes.pop(chip_id, None)
417
+ for child in list(self.children):
418
+ if isinstance(child, Chip) and child.chip_id == chip_id:
419
+ child.remove()
420
+ self._sync_visibility()
421
+ self.post_message(AttachmentRemoved(chip_id))
422
+
423
+ else:
424
+ AttachmentBar = None