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,1034 @@
1
+ """
2
+ CCT UI — header.py: the persistent NavBar docked at the very top of the
3
+ screen (v0.7.4 "AI-native IDE navigation" redesign).
4
+
5
+ Three sections, and only three (spec's own words: "The navigation bar
6
+ must contain ONLY three sections"):
7
+
8
+ LEFT animated menu button (MenuButton) + the CCT mark (Logo)
9
+ CENTER the Permission pill (PermissionPill) — a real button, not a
10
+ form dropdown, with no triangle glyph
11
+ RIGHT the current workspace path, and nothing else — no GPU/API/
12
+ clock cluster (those used to live in `refresh_right`; that
13
+ method's contract is now "one path string", see below)
14
+
15
+ Opening the menu button no longer silently toggles the Explorer (that
16
+ surprised people who had no folder open yet, and conflated "browse the
17
+ IDE's own features" with "browse this project's files" — two different
18
+ jobs). It now opens NavPanel: a real, keyboard-navigable list of every
19
+ top-level destination in the app (Dashboard, Open Folder, Recent
20
+ Workspaces, MCP Servers, Personalize, Diff Viewer, Settings, Themes,
21
+ Extensions, Keyboard Shortcuts, Help). Only "Open Folder" reaches for
22
+ the system folder picker; everything else routes through
23
+ `events.NavAction` to CCTApp.on_nav_action, which is the one place
24
+ allowed to decide what each destination actually does — same one-way
25
+ event-flow convention every other component in ui/ already follows.
26
+
27
+ "Open Folder" and "Recent Workspaces" (the two destinations that end
28
+ in a system folder picker) KEEP the panel open when picked — the main
29
+ menu stays up after a folder is opened, per v0.7.4 — while every other
30
+ item dismisses as usual. The menu button's ✕ morph doubles as a close
31
+ control: clicking it while the panel is open closes the menu.
32
+
33
+ Explorer's own show/hide toggle still exists — it's just reached via
34
+ Ctrl+B (CCTApp.action_toggle_sidebar) or a NavPanel item, not by
35
+ re-purposing the header's menu glyph for it.
36
+ """
37
+
38
+ if __name__ == "__main__":
39
+ print("This is a library file and is not meant to be run directly.")
40
+ import sys
41
+ sys.exit(1)
42
+
43
+ import os
44
+
45
+ from .. import permissions as perm
46
+ from .events import PermissionModeChanged, NavAction
47
+
48
+ TEXTUAL_AVAILABLE = True
49
+ try:
50
+ from textual.containers import Vertical, Horizontal
51
+ from textual.screen import Screen
52
+ from textual.widgets import Static, Button
53
+ except Exception:
54
+ TEXTUAL_AVAILABLE = False
55
+
56
+ _MARK = "\u2b21" # WHITE HEXAGON — a molecule/benzene-ring silhouette,
57
+ # a cleaner and more legible brand mark at 1-cell size
58
+ # than the previous alembic glyph.
59
+
60
+ # spec v0.7 Header section: three selectable modes.
61
+ _MODE_ORDER = ("ask", "restricted", "full")
62
+
63
+ _MODE_DESCRIPTIONS = {
64
+ "ask": "Ask me before protected actions.",
65
+ "restricted": "Analyze and help, but block protected actions.",
66
+ "full": "Work automatically within my allowed workspace.",
67
+ }
68
+ _MODE_TONE = {"ask": "text", "restricted": "warning", "full": "success"}
69
+
70
+ # Permission mode icons for the selector
71
+ _MODE_ICONS = {"ask": "\U0001f510", "restricted": "\U0001f6e1", "full": "\u26a1"}
72
+
73
+ # NavPanel items: (action id, icon, label). v0.7.8.1 cleanup: "New
74
+ # Workspace" / "Save Workspace" / "Close Workspace" are gone — opening a
75
+ # folder already records it in Recent Workspaces automatically, so those
76
+ # three were redundant in a terminal IDE. The menu is now exactly:
77
+ # Open Folder / Recent Workspaces / Chats, then Integrations, then
78
+ # Settings / Account section.
79
+ # v0.7.9.10: Gestures / Vision / Personalize are Extensions — they appear
80
+ # in the main menu only when enabled via Extensions panel (see
81
+ # extensions.should_show_in_menu). This keeps the main menu uncluttered
82
+ # and lets users install/uninstall via Extensions.
83
+ _NAV_ITEMS_BASE = (
84
+ ("open_folder", "\U0001f4c1", "Open Folder"),
85
+ ("recent_workspaces", "\U0001f553", "Recent Workspaces"),
86
+ ("chats", "\U0001f4ac", "Chats"),
87
+ ("sep1", "", ""),
88
+ ("mcp_servers", "\U0001f50c", "MCP Servers"),
89
+ ("backup_providers", "\U0001f4e1", "Backup Providers"),
90
+ ("sep2", "", ""),
91
+ ("customize_ai", "🎨", "Personalize"),
92
+ ("themes", "\u25d1", "Themes"),
93
+ ("gestures", "\u270b", "Gestures"),
94
+ ("extensions", "\u25a3", "Extensions"),
95
+ ("sep3", "", ""),
96
+ ("user", "\u263a", "Users"),
97
+ ("signout", "\u238b", "Sign Out"),
98
+ )
99
+
100
+ def get_nav_items():
101
+ """Filtered NAV_ITEMS for current extensions state.
102
+
103
+ v0.7.9.12: Gestures / CAT Vision / Personalize are now grouped in the
104
+ dedicated 'Downloaded Extensions' section at the bottom of the main
105
+ menu instead of being scattered in the top list. This removes the
106
+ previous duplication where they appeared both in the top scattered
107
+ positions AND again in Downloaded (2×). Install → appears in
108
+ Downloaded as '●', disable → stays in Downloaded as '○' (still
109
+ reachable to re-enable), uninstall → removed. Other items use
110
+ should_show_in_menu filtering as before.
111
+ """
112
+ try:
113
+ from .. import extensions as _ext
114
+ # --- base items (excluding builtin extensions — they are grouped) ---
115
+ # Builtin extension actions are handled exclusively in the Downloaded
116
+ # section to avoid scattering + duplication. Compute dynamically so
117
+ # future builtins are also grouped without hard-coding.
118
+ try:
119
+ builtin_actions = {v.get("action") for v in _ext.BUILTIN_EXTENSIONS.values() if v.get("action")}
120
+ except Exception:
121
+ builtin_actions = {"gestures", "vision", "customize_ai"}
122
+ out = []
123
+ for action, icon, label in _NAV_ITEMS_BASE:
124
+ if action.startswith("sep"):
125
+ out.append((action, icon, label))
126
+ continue
127
+ if action in builtin_actions:
128
+ # grouped in Downloaded Extensions — skip scattered position
129
+ continue
130
+ if not _ext.should_show_in_menu(action):
131
+ continue
132
+ out.append((action, icon, label))
133
+
134
+ # Check customization main_menu settings (Section 14)
135
+ try:
136
+ from .. import customization as _cust
137
+ if _cust.is_active():
138
+ _mm = _cust.get_manager().get_main_menu()
139
+ _mm_vis = _mm.get("visibility", {})
140
+ _mm_order = _mm.get("order", [])
141
+ if _mm_vis:
142
+ out = [it for it in out if it[0].startswith("sep") or _mm_vis.get(it[0], True) is not False]
143
+ if _mm_order:
144
+ def _key(it):
145
+ act = it[0]
146
+ return _mm_order.index(act) if act in _mm_order else 999
147
+ seps = [it for it in out if it[0].startswith("sep")]
148
+ non_seps = [it for it in out if not it[0].startswith("sep")]
149
+ non_seps.sort(key=_key)
150
+ out = non_seps + seps
151
+ except Exception:
152
+ pass
153
+
154
+ # collapse consecutive seps and trim leading/trailing seps
155
+ filtered = []
156
+ prev_sep = True # treat start as sep to trim leading
157
+ for item in out:
158
+ is_sep = item[0].startswith("sep")
159
+ if is_sep and prev_sep:
160
+ continue
161
+ filtered.append(item)
162
+ prev_sep = is_sep
163
+ while filtered and filtered[-1][0].startswith("sep"):
164
+ filtered.pop()
165
+
166
+ # --- Downloaded Extensions section (v0.7.9.12) ---
167
+ # Grouped section at the bottom that always appears so users can
168
+ # see what is downloaded/installed and jump directly to it.
169
+ # Enabled builtins use their original action id (so nav_has checks
170
+ # still pass and clicking opens the panel directly); disabled
171
+ # builtins and all custom extensions use 'ext:<id>' with ○ hint
172
+ # (so disabled does not count as visible for should_show_in_menu
173
+ # tests but remains reachable to re-enable).
174
+ try:
175
+ installed = []
176
+ if hasattr(_ext, "list_installed"):
177
+ try:
178
+ installed = _ext.list_installed()
179
+ except Exception:
180
+ installed = []
181
+ if not installed:
182
+ try:
183
+ installed = [m for m in _ext.list_extensions() if m.get("installed")]
184
+ except Exception:
185
+ installed = []
186
+ try:
187
+ installed = sorted(installed, key=lambda m: (m.get("display_name") or m.get("name") or m.get("id", "")).lower())
188
+ except Exception:
189
+ pass
190
+
191
+ dl_extra = []
192
+ if filtered and not filtered[-1][0].startswith("sep"):
193
+ dl_extra.append(("sep_dl", "", ""))
194
+ count = len(installed)
195
+ hdr_label = f"Downloaded Extensions ({count})" if count else "Downloaded Extensions"
196
+ dl_extra.append(("__section_dl", "📦", hdr_label))
197
+ if installed:
198
+ for m in installed:
199
+ eid = m.get("id")
200
+ if not eid:
201
+ continue
202
+ icon = m.get("icon", "▣")
203
+ name = m.get("display_name") or m.get("name") or eid
204
+ enabled = bool(m.get("enabled"))
205
+ # For builtins use original action when enabled (so
206
+ # main-menu tests and direct panel open still work),
207
+ # otherwise use ext: prefix with disabled hint.
208
+ builtin_meta = _ext.BUILTIN_EXTENSIONS.get(eid) if hasattr(_ext, "BUILTIN_EXTENSIONS") else None
209
+ if builtin_meta and enabled:
210
+ action_id = builtin_meta.get("action") or f"ext:{eid}"
211
+ # no suffix needed — enabled is the normal visible state
212
+ label = name
213
+ else:
214
+ suffix = " \u25cf" if enabled else " \u25cb"
215
+ max_len = 28 - len(suffix)
216
+ if len(name) > max_len:
217
+ name = name[:max_len - 1] + "\u2026"
218
+ label = f"{name}{suffix}"
219
+ action_id = f"ext:{eid}"
220
+ dl_extra.append((action_id, icon, label))
221
+ else:
222
+ dl_extra.append(("__placeholder_dl", "", "No extensions installed"))
223
+ dl_extra.append(("__hint_dl", "", "Install from Extensions \u2192"))
224
+ filtered = filtered + dl_extra
225
+ except Exception:
226
+ pass
227
+
228
+ return tuple(filtered)
229
+ except Exception:
230
+ return _NAV_ITEMS_BASE
231
+
232
+ # Backward compat: NAV_ITEMS remains as filtered view for old importers
233
+ # that do `from header import NAV_ITEMS`. At import time extensions are
234
+ # all enabled (empty config), so this equals the filtered view anyway.
235
+ NAV_ITEMS = get_nav_items()
236
+
237
+
238
+ if TEXTUAL_AVAILABLE:
239
+
240
+ class _Clickable(Static):
241
+ """A Static that reports clicks to a callback — the same minimal
242
+ pattern footer.py's `_Badge` already uses, kept local here so
243
+ header.py doesn't need to import from footer.py for one thing."""
244
+
245
+ def __init__(self, text, widget_id, on_click=None, classes=""):
246
+ super().__init__(text, id=widget_id, classes=classes)
247
+ self._on_click = on_click
248
+
249
+ def on_mouse_down(self, event):
250
+ self.add_class("-active")
251
+
252
+ def on_mouse_up(self, event):
253
+ self.remove_class("-active")
254
+
255
+ def on_leave(self, event):
256
+ self.remove_class("-active")
257
+
258
+ def on_blur(self, event):
259
+ self.remove_class("-active")
260
+
261
+ def on_click(self, event):
262
+ self.remove_class("-active")
263
+ if self._on_click:
264
+ self._on_click()
265
+
266
+ # ---------------------------------------------------------- nav panel --
267
+ class _NavRow(Static):
268
+ """One row of NavPanel: icon + label. Hover is free via CSS
269
+ `:hover`; keyboard-driven highlight is applied explicitly via
270
+ the `selected` class from the parent screen (same split
271
+ PermissionModeMenu's `_PermMenuRow` already uses, since arrow-key
272
+ movement isn't a real mouse hover)."""
273
+
274
+ def __init__(self, action_id, icon, label, index):
275
+ super().__init__("", classes="cct-navmenu-row")
276
+ self.action_id = action_id
277
+ self._icon = icon
278
+ self._label = label
279
+ self.index = index
280
+
281
+ def on_mount(self):
282
+ self.redraw()
283
+
284
+ def redraw(self):
285
+ from . import theme_css
286
+ text_color = theme_css.current_hex("text")
287
+ self.update(f"[{text_color}]{self._icon} {self._label}[/]")
288
+
289
+ def on_click(self, event):
290
+ # v0.7.6 Patch 1: only act while the panel is still the
291
+ # active screen (see _PermMenuRow.on_click for the
292
+ # re-delivered-click explanation).
293
+ if self.app.screen is self.screen:
294
+ self.screen.on_row_picked(self.action_id)
295
+
296
+ class NavPanel(Screen):
297
+ """The structured navigation panel opened by MenuButton (spec
298
+ v0.7.4 "PRIMARY GOALS #2"). Top-left-anchored so it visually
299
+ drops down from the menu button rather than reading as a
300
+ centered dialog — same real-Screen-push-as-modal pattern this
301
+ package already uses for PermissionModeMenu, _AttachPrompt, and
302
+ _PathPrompt.
303
+
304
+ v0.7.4's "Main Menu stays open after opening a folder": the two
305
+ destinations that lead to a system folder picker ('open_folder',
306
+ 'recent_workspaces') keep the panel open and post NavAction
307
+ directly, so the user can immediately pick another destination
308
+ when the picker returns. Every other item dismisses the panel as
309
+ usual (the dismiss callback then posts NavAction).
310
+
311
+ v0.7.9.10: Gestures / Vision / Personalize are Extensions — this
312
+ panel now builds from get_nav_items() so disabled extensions are
313
+ hidden here until re-enabled via Extensions panel."""
314
+
315
+ # Destinations that keep the menu open instead of dismissing.
316
+ _KEEP_OPEN = {"open_folder", "recent_workspaces"}
317
+
318
+ CSS = """
319
+ NavPanel { align: left top; background: $app-background 0%; }
320
+ #cct-navmenu-box {
321
+ margin-top: 3; margin-left: 1;
322
+ width: 42; height: auto;
323
+ max-height: 88%;
324
+ background: $surface;
325
+ border-top: tall $surface-highlight;
326
+ border-left: tall $surface-highlight;
327
+ border-bottom: tall $surface-dark;
328
+ border-right: tall $surface-dark;
329
+ padding: 1 0;
330
+ opacity: 1; offset-y: 0;
331
+ overflow-y: auto;
332
+ scrollbar-gutter: stable;
333
+ scrollbar-size: 1 1;
334
+ scrollbar-color: $border $surface;
335
+ }
336
+ #cct-navmenu-box.open { opacity: 1; offset-y: 0; }
337
+ .cct-navmenu-row {
338
+ height: 1; padding: 0 2;
339
+ background: $surface;
340
+ color: $text;
341
+ border-left: thick $surface;
342
+ border-right: thick $surface;
343
+ }
344
+ .cct-navmenu-row:hover {
345
+ background: $surface-alt;
346
+ color: $text;
347
+ border-left: thick $accent;
348
+ border-right: thick $surface-alt;
349
+ text-style: none;
350
+ }
351
+ .cct-navmenu-row-selected {
352
+ background: $surface-highlight;
353
+ color: $text;
354
+ border-left: thick $accent;
355
+ border-right: thick $surface-highlight;
356
+ text-style: none;
357
+ }
358
+ .cct-navmenu-sep {
359
+ height: 1; background: $surface; color: $border; padding: 0 2;
360
+ border-left: thick $surface; border-right: thick $surface;
361
+ }
362
+ .cct-navmenu-section {
363
+ height: 1; color: $accent; text-style: bold; padding: 0 2;
364
+ background: $surface-alt;
365
+ border-bottom: solid $border;
366
+ border-left: thick $surface-alt; border-right: thick $surface-alt;
367
+ }
368
+ .cct-navmenu-placeholder {
369
+ height: 1; background: $surface; color: $text-muted; padding: 0 2; text-style: italic;
370
+ border-left: thick $surface; border-right: thick $surface;
371
+ }
372
+ .cct-navmenu-hint {
373
+ height: 1; background: $surface; color: $text-faint; padding: 0 2;
374
+ border-left: thick $surface; border-right: thick $surface;
375
+ }
376
+ .cct-touch-mode .cct-navmenu-row { min-height: 2; height: 2; content-align: left middle; }
377
+ .cct-touch-mode .cct-navmenu-row:hover,
378
+ .cct-touch-mode .cct-navmenu-row:focus {
379
+ background: $surface-alt; color: $text;
380
+ border-left: thick $accent; border-right: thick $surface-alt;
381
+ }
382
+ """
383
+
384
+ def __init__(self):
385
+ super().__init__()
386
+ self._selected = None # action_id of selected row
387
+ self._nav_cache = None
388
+
389
+ def _get_filtered(self):
390
+ try:
391
+ return get_nav_items()
392
+ except Exception:
393
+ return _NAV_ITEMS_BASE
394
+
395
+ def compose(self):
396
+ items = self._get_filtered()
397
+ self._nav_cache = items
398
+ # No default forced text selection on open; keyboard navigation activates it on demand
399
+ with Vertical(id="cct-navmenu-box"):
400
+ for i, (action_id, icon, label) in enumerate(items):
401
+ if action_id.startswith("sep"):
402
+ yield Static("\u2500" * 30, id=f"cct-navmenu-sep-{i}",
403
+ classes="cct-navmenu-sep")
404
+ elif action_id.startswith("__section"):
405
+ yield Static(f"{icon} {label}", id=f"cct-navmenu-section-{i}",
406
+ classes="cct-navmenu-section")
407
+ elif action_id.startswith("__placeholder") or action_id.startswith("__hint"):
408
+ cls = "cct-navmenu-placeholder" if "placeholder" in action_id else "cct-navmenu-hint"
409
+ yield Static(label, id=f"cct-navmenu-placeholder-{i}", classes=cls)
410
+ else:
411
+ yield _NavRow(action_id, icon, label, i)
412
+
413
+ def on_mount(self):
414
+ self._sync_selected_class()
415
+ try:
416
+ self.query_one("#cct-navmenu-box").add_class("open")
417
+ except Exception:
418
+ pass
419
+
420
+ def _sync_selected_class(self):
421
+ for row in self.query(_NavRow):
422
+ row.set_class(row.action_id == self._selected, "cct-navmenu-row-selected")
423
+
424
+ def on_row_picked(self, action_id):
425
+ """Shared by mouse clicks and the Enter key. Keep-open
426
+ destinations post NavAction and leave the panel up; the
427
+ rest dismiss, which triggers the usual push callback."""
428
+ if action_id in self._KEEP_OPEN:
429
+ self.post_message(NavAction(action_id))
430
+ return
431
+ self.dismiss(action_id)
432
+
433
+ def _nav_items(self):
434
+ """Return only the selectable (non-separator) filtered items."""
435
+ items = self._get_filtered()
436
+ return [(i, a, ic, l) for i, (a, ic, l) in enumerate(items)
437
+ if not a.startswith("sep") and not a.startswith("__")]
438
+
439
+ def on_key(self, event):
440
+ items = self._nav_items()
441
+ if not items:
442
+ return
443
+ cur_ids = [a for _, a, _, _ in items]
444
+ if event.key == "escape":
445
+ self.dismiss(None)
446
+ elif event.key in ("down", "tab"):
447
+ if self._selected not in cur_ids:
448
+ cur_idx = 0
449
+ else:
450
+ cur_idx = (cur_ids.index(self._selected) + 1) % len(items)
451
+ self._selected = items[cur_idx][1]
452
+ self._sync_selected_class()
453
+ elif event.key in ("up", "shift+tab"):
454
+ if self._selected not in cur_ids:
455
+ cur_idx = len(items) - 1
456
+ else:
457
+ cur_idx = (cur_ids.index(self._selected) - 1) % len(items)
458
+ self._selected = items[cur_idx][1]
459
+ self._sync_selected_class()
460
+ elif event.key == "enter":
461
+ if self._selected:
462
+ self.on_row_picked(self._selected)
463
+
464
+ def on_click(self, event):
465
+ # Click on the dimmed backdrop (outside the box) closes the
466
+ # panel without picking anything — rows handle their own
467
+ # clicks (see _NavRow.on_click). v0.7.6 Patch 1: only
468
+ # dismiss while the panel is still the active screen.
469
+ if getattr(event, "widget", None) in (self, None) and self.app.screen is self:
470
+ self.dismiss(None)
471
+
472
+ class MenuButton(_Clickable):
473
+ """Left-of-header menu button (spec: "Modern animated menu
474
+ button, replace the existing hamburger icon"). Morphs
475
+ ≡ -> ✕ while NavPanel is open, with a smooth color transition
476
+ on both states and on hover, instead of a static glyph. While
477
+ the panel is open the button is that ✕: clicking it closes the
478
+ menu (toggle behavior), so the ✕ is a real close control.
479
+
480
+ The closed-state glyph is U+2261 IDENTICAL TO (≡) — a cleaner
481
+ three-bar menu mark than the U+2630 TRIGRAM (☰) it replaces:
482
+ the trigram renders with uneven bar lengths and baseline
483
+ offsets in several common terminal fonts, which read as a
484
+ glitchy/off-center hamburger; ≡ is three uniform, crisp lines
485
+ in every font that has it (and it's far more widely covered
486
+ than the trigram block)."""
487
+
488
+ # U+2261 — clean three-bar menu mark.
489
+ _MENU_GLYPH = "\u2261"
490
+ _CLOSE_GLYPH = "\u2715"
491
+
492
+ def __init__(self, id="cct-menu-btn"):
493
+ super().__init__(self._MENU_GLYPH, widget_id=id, classes="cct-menu-btn")
494
+ self._open = False
495
+
496
+ def on_click(self, event):
497
+ # v0.7.6 Patch 1: stop the opening click from bubbling to
498
+ # the freshly pushed NavPanel's backdrop.
499
+ event.stop()
500
+ if self._open:
501
+ self.close_menu()
502
+ else:
503
+ self.open_menu()
504
+
505
+ def open_menu(self):
506
+ """Opens NavPanel — shared by the click handler and the
507
+ Ctrl+M binding, so keyboard and mouse go through one path
508
+ and the button's ≡→✕ morph stays in sync either way."""
509
+ if self._open:
510
+ return
511
+ self._open = True
512
+ self.update(self._CLOSE_GLYPH)
513
+ self.add_class("cct-menu-btn-active")
514
+ self.app.push_screen(NavPanel(), self._on_closed)
515
+
516
+ def close_menu(self):
517
+ """Closes the open NavPanel (used by the ✕-morph click and
518
+ Ctrl+M's close branch) — the panel's dismiss callback
519
+ restores the ≡ glyph and posts the picked action."""
520
+ for screen in list(self.app.screen_stack):
521
+ if isinstance(screen, NavPanel):
522
+ screen.dismiss(None)
523
+ return
524
+
525
+ def _on_closed(self, action_id):
526
+ self._open = False
527
+ self.update(self._MENU_GLYPH)
528
+ self.remove_class("cct-menu-btn-active")
529
+ if action_id:
530
+ self.post_message(NavAction(action_id))
531
+
532
+ class Logo(Static):
533
+ """The CAT mark (spec: "Redesigned logo with a cleaner, more
534
+ recognizable identity"). A single hexagon glyph + bold wordmark,
535
+ no subtitle crowding the compact one-line nav bar — the fuller
536
+ "Coding Agent Terminal" subtitle still appears once on the
537
+ Welcome Dashboard (conversation.py), not here."""
538
+
539
+ def __init__(self, id="cct-logo"):
540
+ super().__init__("", id=id, classes="cct-logo")
541
+
542
+ def on_mount(self):
543
+ self.redraw()
544
+
545
+ def redraw(self):
546
+ from . import theme_css
547
+ accent = theme_css.current_hex("accent")
548
+ self.update(f"[{accent} b]{_MARK} CAT[/]")
549
+
550
+ # ------------------------------------------------------ permission pill --
551
+ class _PermMenuRow(Static):
552
+ """One row of the permission dropdown: icon + label (colored
553
+ per-mode) on top, faint description underneath."""
554
+
555
+ def __init__(self, mode, index):
556
+ super().__init__("", classes="cct-permmenu-row")
557
+ self.mode = mode
558
+ self.index = index
559
+
560
+ def on_mount(self):
561
+ self.redraw()
562
+
563
+ def redraw(self):
564
+ from . import theme_css
565
+ color = theme_css.current_hex(_MODE_TONE[self.mode])
566
+ faint = theme_css.current_hex("text-faint")
567
+ label = perm.MODE_LABELS[self.mode]
568
+ desc = _MODE_DESCRIPTIONS[self.mode]
569
+ check = "\u25cf " if self.mode == perm.manager.mode else "\u25cb "
570
+ self.update(f"[{color} b]{check}{label}[/]\n [{faint}]{desc}[/]")
571
+
572
+ def on_click(self, event):
573
+ # v0.7.6 Patch 1: guard against a click being delivered to
574
+ # this menu after it was already dismissed (the same click
575
+ # that closed it can be re-delivered to the popped screen —
576
+ # double-dismiss then hits an empty screen stack). A row
577
+ # click only acts while the menu is still the active screen.
578
+ if self.app.screen is self.screen:
579
+ self.screen.dismiss(self.mode)
580
+
581
+ class PermissionModeMenu(Screen):
582
+ """The dropdown itself (spec: "Options: Ask Every Time /
583
+ Restricted / Full Access", each with icon/description/color,
584
+ hover + selection + keyboard-nav + open/close animation).
585
+
586
+ Real anchored popup, not a centered dialog: takes the trigger
587
+ widget (the PermissionPill) at construction time and, on mount
588
+ (and again on every terminal resize while it's open), reads
589
+ that widget's live `region` — screen-absolute coordinates,
590
+ recomputed fresh each time rather than cached — to place the
591
+ box directly under the pill via `styles.offset`. Previously
592
+ this used `align: center top` + a fixed `margin-top: 3`, which
593
+ is why the popup always rendered centered/floating in the
594
+ middle of the screen instead of next to the button, and never
595
+ adapted to resizes or the button's real position — the root
596
+ cause of the reported positioning bugs. Edge collision is
597
+ handled explicitly: the box is clamped so it never renders
598
+ past the right or bottom edge of the terminal, and flips to
599
+ open *above* the pill instead of below when there isn't enough
600
+ room underneath. `align: left top` on the Screen makes (0, 0)
601
+ the coordinate origin so the computed offset is truly
602
+ absolute, rather than fighting a centering rule."""
603
+
604
+ CSS = """
605
+ PermissionModeMenu {
606
+ align: left top;
607
+ background: $app-background 60%;
608
+ }
609
+ #cct-permmenu-box {
610
+ width: 50;
611
+ height: auto;
612
+ background: $surface;
613
+ border-top: tall $surface-highlight;
614
+ border-left: tall $surface-highlight;
615
+ border-bottom: tall $surface-dark;
616
+ border-right: tall $surface-dark;
617
+ padding: 1 1;
618
+ opacity: 1; offset-y: 0;
619
+ }
620
+ #cct-permmenu-box.open { opacity: 1; offset-y: 0; }
621
+ .cct-permmenu-title {
622
+ color: $accent;
623
+ text-style: bold;
624
+ padding: 0 1 1 1;
625
+ border-bottom: solid $border;
626
+ margin-bottom: 1;
627
+ }
628
+ .cct-permmenu-row {
629
+ height: 3;
630
+ padding: 0 1;
631
+ background: $surface;
632
+ border-left: thick $surface;
633
+ border-right: thick $surface;
634
+ }
635
+ .cct-permmenu-row:hover {
636
+ background: $surface-alt;
637
+ color: $accent;
638
+ border-left: thick $accent;
639
+ border-right: thick $surface-alt;
640
+ }
641
+ .cct-permmenu-row-selected {
642
+ background: $surface-highlight;
643
+ border-left: thick $accent;
644
+ border-right: thick $surface-highlight;
645
+ text-style: bold;
646
+ }
647
+ .cct-permmenu-row-focused {
648
+ background: $surface-alt;
649
+ border-left: thick $accent;
650
+ border-right: thick $surface-alt;
651
+ }
652
+ """
653
+
654
+ _BOX_WIDTH = 48
655
+ # Fallback estimate for the box's height before Textual has had
656
+ # a chance to lay it out for real (title row + 3 cells/mode row).
657
+ _ESTIMATED_HEIGHT = 1 + 2 + len(_MODE_ORDER) * 3
658
+
659
+ def __init__(self, anchor=None):
660
+ super().__init__()
661
+ self._selected = _MODE_ORDER.index(perm.manager.mode)
662
+ # The PermissionPill that triggered this menu, so the popup
663
+ # can anchor to its actual on-screen position instead of
664
+ # being centered. `None` is a safe fallback (falls back to
665
+ # the old centered-under-header placement) for any caller
666
+ # that doesn't have a trigger widget handy.
667
+ self._anchor = anchor
668
+
669
+ def compose(self):
670
+ with Vertical(id="cct-permmenu-box"):
671
+ yield Static("\U0001f510 Access", classes="cct-permmenu-title")
672
+ for i, mode in enumerate(_MODE_ORDER):
673
+ yield _PermMenuRow(mode, i)
674
+
675
+ def on_mount(self):
676
+ self._sync_selected_class()
677
+ self._reposition()
678
+ self.call_after_refresh(self._reposition) # box has real height now
679
+ self.call_after_refresh(lambda: self.query_one("#cct-permmenu-box").add_class("open"))
680
+
681
+ def on_resize(self, event):
682
+ """Terminal resized while the menu is open: re-read the
683
+ anchor's (possibly moved) region and re-clamp so the popup
684
+ never ends up outside the new terminal bounds."""
685
+ self._reposition()
686
+
687
+ def _reposition(self):
688
+ try:
689
+ box = self.query_one("#cct-permmenu-box")
690
+ except Exception:
691
+ return
692
+ screen_w, screen_h = self.size.width, self.size.height
693
+ if not screen_w or not screen_h:
694
+ return
695
+ box_w = self._BOX_WIDTH
696
+ box_h = box.size.height or self._ESTIMATED_HEIGHT
697
+
698
+ if self._anchor is not None:
699
+ region = self._anchor.region
700
+ x, y = region.x, region.y + region.height
701
+ else:
702
+ x, y = (screen_w - box_w) // 2, 3
703
+
704
+ # Edge collision, horizontal: never let the box run past
705
+ # the right edge (or, on a very narrow terminal, the left).
706
+ x = max(0, min(x, max(0, screen_w - box_w)))
707
+
708
+ # Edge collision, vertical: prefer opening below the
709
+ # anchor, but flip to above it when there isn't enough
710
+ # room underneath; then clamp to the terminal's height.
711
+ if self._anchor is not None and y + box_h > screen_h:
712
+ above = region.y - box_h
713
+ y = above if above >= 0 else max(0, screen_h - box_h)
714
+ y = max(0, min(y, max(0, screen_h - box_h)))
715
+
716
+ box.styles.offset = (x, y)
717
+
718
+ def _sync_selected_class(self):
719
+ for row in self.query(_PermMenuRow):
720
+ row.set_class(row.index == self._selected, "cct-permmenu-row-selected")
721
+
722
+ def on_key(self, event):
723
+ if event.key == "escape":
724
+ self.dismiss(None)
725
+ elif event.key in ("down", "tab"):
726
+ self._selected = (self._selected + 1) % len(_MODE_ORDER)
727
+ self._sync_selected_class()
728
+ elif event.key in ("up", "shift+tab"):
729
+ self._selected = (self._selected - 1) % len(_MODE_ORDER)
730
+ self._sync_selected_class()
731
+ elif event.key == "enter":
732
+ self.dismiss(_MODE_ORDER[self._selected])
733
+
734
+ def on_click(self, event):
735
+ # v0.7.6 Patch 1: only dismiss while this menu is still the
736
+ # active screen. The click that opened the menu can be
737
+ # re-delivered here after the menu already popped; a second
738
+ # dismiss would then hit an empty screen stack and raise
739
+ # ScreenStackError ("Can't pop screen").
740
+ if getattr(event, "widget", None) in (self, None) and self.app.screen is self:
741
+ self.dismiss(None)
742
+
743
+ class FullAccessConfirmScreen(Screen):
744
+ CSS = """
745
+ FullAccessConfirmScreen { align: center middle; background: $app-background 60%; }
746
+ #fullaccess-box {
747
+ width: 60; height: auto; background: $surface; border: round $warning;
748
+ padding: 1 2; align: center middle;
749
+ }
750
+ #fullaccess-title { color: $warning; text-style: bold; height: 1; }
751
+ #fullaccess-body { color: $text; height: auto; padding: 1 0; }
752
+ #fullaccess-btns { height: 3; align: center middle; }
753
+ #fullaccess-btns Button { margin: 0 1; }
754
+ """
755
+ def compose(self):
756
+ with Vertical(id="fullaccess-box"):
757
+ yield Static("Enable Full Access?", id="fullaccess-title")
758
+ yield Static(
759
+ "CAT will be able to perform permitted workspace actions\n"
760
+ "without asking each time. Scope: current CAT workspace.\n"
761
+ "You can change this at any time.",
762
+ id="fullaccess-body")
763
+ with Horizontal(id="fullaccess-btns"):
764
+ yield Button("Cancel", id="fullaccess-cancel", classes="cct-btn")
765
+ yield Button("Enable Access", id="fullaccess-ok", variant="primary", classes="cct-btn")
766
+ def on_button_pressed(self, event):
767
+ self.dismiss(event.button.id == "fullaccess-ok")
768
+ def on_key(self, event):
769
+ if event.key == "escape":
770
+ self.dismiss(False)
771
+
772
+ class PermissionPill(_Clickable):
773
+ """Center-of-header permission control (spec: "large rounded
774
+ Permission button... Remove the default dropdown triangle...
775
+ Style it as a primary action button rather than a form
776
+ widget"). No ▲/▼ glyph in either state — the pill's rounded
777
+ border + background swap on click/focus is what communicates
778
+ "this opens something", the same way a real button in Warp or
779
+ VS Code's command palette doesn't need a caret to read as
780
+ pressable."""
781
+
782
+ def __init__(self, id="cct-perm-pill"):
783
+ super().__init__("", widget_id=id, classes="cct-perm-pill")
784
+ self._open = False
785
+
786
+ def on_mount(self):
787
+ self.refresh_label()
788
+
789
+ def on_click(self, event):
790
+ # v0.7.6 Patch 1: stop the opening click from bubbling —
791
+ # otherwise the new menu screen can receive the same click
792
+ # as a backdrop hit and instantly close itself.
793
+ event.stop()
794
+ if self._open:
795
+ return
796
+ self._open = True
797
+ self.add_class("cct-perm-pill-active")
798
+ self.refresh_label()
799
+ self.app.push_screen(PermissionModeMenu(anchor=self), self._on_menu_closed)
800
+
801
+ def _on_menu_closed(self, chosen):
802
+ if not self._open:
803
+ return
804
+ self._open = False
805
+ self.remove_class("cct-perm-pill-active")
806
+ if chosen and chosen != perm.manager.mode:
807
+ if chosen == "full":
808
+ # Full Access requires confirmation
809
+ self.app.push_screen(FullAccessConfirmScreen(), lambda ok: self._apply_mode(chosen) if ok else self.refresh_label())
810
+ return
811
+ self._apply_mode(chosen)
812
+ self.refresh_label()
813
+
814
+ def _apply_mode(self, mode):
815
+ perm.manager.set_mode(mode)
816
+ self.post_message(PermissionModeChanged(mode))
817
+ self.refresh_label()
818
+ try:
819
+ from .. import workspace as _ws
820
+ ws = _ws.root_dir() if hasattr(_ws, 'root_dir') else "workspace"
821
+ scope = ws if mode == "full" else ""
822
+ note = f"Access: {perm.manager.mode_label()}" + (f" Scope: {scope}" if scope else "")
823
+ self.app._system_note(note)
824
+ except Exception:
825
+ pass
826
+
827
+ def refresh_label(self):
828
+ from . import theme_css
829
+ mode = perm.manager.mode
830
+ color = theme_css.current_hex(_MODE_TONE[mode])
831
+ self.update(f"[{color} b]{perm.manager.mode_label()}[/]")
832
+ # tooltip for current mode
833
+ try:
834
+ self.tooltip = _MODE_DESCRIPTIONS.get(mode, "")
835
+ except Exception:
836
+ pass
837
+
838
+ # Back-compat aliases: older code (and any external caller) that
839
+ # still imports the pre-redesign names keeps working unchanged.
840
+ HeaderPermissionSelector = PermissionPill
841
+ SidebarToggleGlyph = MenuButton
842
+
843
+ class BrandHeader(Vertical):
844
+ """The NavBar. Exactly one row, height 3: MenuButton + Logo
845
+ (left) / PermissionPill (center) / workspace path (right).
846
+
847
+ The old second breadcrumb row (Notebook mode · model · context)
848
+ is gone — the spec is explicit that the bar holds only three
849
+ sections and the right side shows only the workspace path.
850
+ `refresh_breadcrumb`/`refresh_right` are kept as thin methods
851
+ (rather than deleted outright) so callers elsewhere in app.py
852
+ don't need simultaneous surgery just to keep importing cleanly;
853
+ `refresh_right` now expects a single path string, not a list of
854
+ status fields.
855
+ """
856
+
857
+ def __init__(self, subtitle="Coding Agent Terminal", id="cct-brandheader"):
858
+ super().__init__(id=id)
859
+ self._subtitle = subtitle
860
+ self._workspace_path = None # None = never painted, forces first render
861
+ self._current_chat_title = "New Chat"
862
+
863
+ def _show_workspace_info(self):
864
+ try:
865
+ info_lines = [f"Path: {self._workspace_path or 'No workspace open'}"]
866
+ try:
867
+ from .. import workspace as _ws
868
+ from ..workspace_index import index_project
869
+ root = self._workspace_path
870
+ if root and root != "No workspace open" and os.path.isdir(root):
871
+ idx = index_project(root)
872
+ info_lines.append(f"Files: {idx.get('file_count', '?')}")
873
+ langs = idx.get("languages") or []
874
+ if langs:
875
+ info_lines.append("Languages: " + ", ".join(langs))
876
+ pms = idx.get("package_managers") or []
877
+ if pms:
878
+ info_lines.append("Managers: " + ", ".join(pms))
879
+ except Exception:
880
+ pass
881
+ from .nav_screens import InfoPanel
882
+ self.app.push_screen(InfoPanel("Workspace", info_lines))
883
+ except Exception:
884
+ pass
885
+
886
+ def _on_chat_title_click(self):
887
+ try:
888
+ self.post_message(NavAction("chats"))
889
+ except Exception:
890
+ pass
891
+
892
+ def set_chat_title(self, title: str):
893
+ """Updates the current chat title shown in the header pill."""
894
+ clean = (title or "New Chat").strip()
895
+ if len(clean) > 28:
896
+ clean = clean[:27] + "\u2026"
897
+ self._current_chat_title = title or "New Chat"
898
+ try:
899
+ w = self.query_one("#cct-chat-title-pill", Static)
900
+ from . import theme_css
901
+ accent = theme_css.current_hex("accent")
902
+ w.update(f"[{accent}]\U0001f4ac[/] {clean}")
903
+ try:
904
+ w.tooltip = f"Chat: {title}\nClick to view all chats"
905
+ except Exception:
906
+ pass
907
+ except Exception:
908
+ pass
909
+
910
+ def compose(self):
911
+ with Horizontal(id="cct-header-top"):
912
+ with Horizontal(id="cct-header-left"):
913
+ yield MenuButton()
914
+ yield Logo()
915
+ yield Static("", id="cct-header-spacer-left")
916
+ yield PermissionPill()
917
+ yield Static("", id="cct-header-spacer-right")
918
+ yield _Clickable("", widget_id="cct-header-right", on_click=self._show_workspace_info)
919
+
920
+ def on_mount(self):
921
+ self.refresh_brand()
922
+ self.apply_custom_header()
923
+ # Force initial workspace paint (fixes blank on launch)
924
+ try:
925
+ self.refresh_right(self._workspace_path or "No workspace open")
926
+ except Exception:
927
+ pass
928
+
929
+ def apply_custom_header(self, hdr_cfg=None):
930
+ """Apply header customization (height, visibility, logo visibility)."""
931
+ try:
932
+ from .. import customization as _cust
933
+ if not _cust.is_active():
934
+ self.display = True
935
+ self.styles.height = 4
936
+ try:
937
+ logo = self.query_one(Logo)
938
+ if logo:
939
+ logo.display = True
940
+ except Exception:
941
+ pass
942
+ return
943
+ if hdr_cfg is None:
944
+ hdr_cfg = _cust.get_manager().get_component("header") or {}
945
+ # Visibility (releases space if hidden)
946
+ vis = hdr_cfg.get("visible", True)
947
+ self.display = bool(vis)
948
+ # Height: #cct-brandheader has border-bottom: solid $border (1 row),
949
+ # so 3 rows of content (pill height: 3) requires header height: 4.
950
+ h = hdr_cfg.get("height")
951
+ if h is not None:
952
+ try:
953
+ val = int(h)
954
+ self.styles.height = max(4, min(10, val if val >= 4 else val + 1))
955
+ except Exception:
956
+ self.styles.height = 4
957
+ else:
958
+ self.styles.height = 4
959
+ # Logo visibility
960
+ logo_vis = hdr_cfg.get("logo_visible", True)
961
+ try:
962
+ logo = self.query_one(Logo)
963
+ if logo:
964
+ logo.display = bool(logo_vis)
965
+ except Exception:
966
+ pass
967
+ except Exception:
968
+ pass
969
+
970
+ def refresh_brand(self):
971
+ """Re-renders the mark/pill colors from the current theme —
972
+ content doesn't change, only the accent color it's painted
973
+ with. Called once on mount and again whenever `/theme`
974
+ toggles."""
975
+ try:
976
+ self.query_one(Logo).redraw()
977
+ except Exception:
978
+ pass
979
+ try:
980
+ self.query_one(PermissionPill).refresh_label()
981
+ except Exception:
982
+ pass
983
+ try:
984
+ self.set_chat_title(self._current_chat_title)
985
+ except Exception:
986
+ pass
987
+
988
+ def refresh_breadcrumb(self, *args, **kwargs):
989
+ """No-op retained for backward compatibility — the NavBar no
990
+ longer has a breadcrumb row (spec: only 3 sections)."""
991
+ return
992
+
993
+ def refresh_right(self, workspace_path):
994
+ """`workspace_path` is the single string CCTApp now passes
995
+ (the open folder's absolute path, or a "No workspace open"
996
+ placeholder) — the right side shows this and nothing else
997
+ (no GPU/API/clock cluster).
998
+
999
+ v0.7.6 Patch 1 (header flicker fix): CCTApp's idle
1000
+ animation calls this every 1.6s while nothing streams, and
1001
+ Static.update() with byte-identical text still marks the
1002
+ widget dirty and triggers a full repaint of that row every
1003
+ tick — which is the steady "header flickering" everyone
1004
+ reported. Guarded here: no change, no update() call, no
1005
+ repaint. The first call still renders unconditionally."""
1006
+ from . import theme_css
1007
+ new_text = workspace_path or "No workspace open"
1008
+ if new_text == self._workspace_path:
1009
+ return
1010
+ self._workspace_path = new_text
1011
+ accent = theme_css.current_hex("accent")
1012
+ faint = theme_css.current_hex("text-faint")
1013
+ # Extract just the folder name for compact display
1014
+ short_name = os.path.basename(new_text) if new_text and new_text != "No workspace open" else "No workspace"
1015
+ # Workspace indicator: 📁 prefix + short name, with full path as tooltip
1016
+ display = f"[{accent}]\U0001f4c1[/] [b]{short_name}[/b]"
1017
+ try:
1018
+ w = self.query_one("#cct-header-right", Static)
1019
+ w.update(display)
1020
+ try:
1021
+ w.tooltip = new_text
1022
+ except Exception:
1023
+ pass
1024
+ except Exception:
1025
+ pass
1026
+
1027
+ else:
1028
+ BrandHeader = None
1029
+ HeaderPermissionSelector = None
1030
+ SidebarToggleGlyph = None
1031
+ NavPanel = None
1032
+ MenuButton = None
1033
+ Logo = None
1034
+ PermissionPill = None