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,1285 @@
1
+ """
2
+ CAT UI — sidebar.py (spec v0.7 project structure + Sidebar section).
3
+
4
+ Explorer: Open Folder, the opened folder's file tree, Pinned/Recent
5
+ Projects, and a drag & drop hint area.
6
+
7
+ v0.7.9.0: the Explorer is a LIVE VIEW of the real filesystem — the
8
+ fs watcher (calc_terminal/fs_watcher.py) plus explicit agent tool
9
+ events push refresh_tree(changed_paths) the moment anything on disk
10
+ changes (create/delete/rename/move/modify), and the divider next to
11
+ this panel is a real drag handle (ui/resizers.py).
12
+ """
13
+
14
+ if __name__ == "__main__":
15
+ print("This is a library file and is not meant to be run directly.")
16
+ import sys
17
+ sys.exit(1)
18
+
19
+ import os
20
+
21
+ from .. import projects
22
+ from .events import FolderOpened, FileOpenRequested, SidebarToggled
23
+
24
+ TEXTUAL_AVAILABLE = True
25
+ try:
26
+ from textual.containers import Vertical, Horizontal, ScrollableContainer
27
+ from textual.widgets import Static, DirectoryTree, Button, Input
28
+ from textual.reactive import reactive
29
+ from textual.screen import Screen
30
+ from textual.binding import Binding
31
+ except Exception:
32
+ TEXTUAL_AVAILABLE = False
33
+
34
+ DEFAULT_WIDTH = 32
35
+ COLLAPSED_WIDTH = 0
36
+
37
+ # File extensions the spec's Drag & Drop section lists — used only to
38
+ # pick an icon glyph in the tree; DirectoryTree still shows every file,
39
+ # this doesn't filter anything out.
40
+ _ICONS = {
41
+ ".py": "\U0001f40d", ".cpp": "\u2699", ".c": "\u2699", ".java": "\u2615",
42
+ ".html": "\U0001f310", ".css": "\U0001f3a8", ".js": "\U0001f4dc",
43
+ ".json": "{}", ".yaml": "\U0001f4c4", ".yml": "\U0001f4c4", ".toml": "\U0001f4c4",
44
+ ".txt": "\U0001f4c4", ".md": "\U0001f4dd", ".pdf": "\U0001f4d5",
45
+ ".docx": "\U0001f4d8", ".pptx": "\U0001f4d1", ".xlsx": "\U0001f4ca", ".csv": "\U0001f4ca",
46
+ ".png": "\U0001f5bc", ".jpg": "\U0001f5bc", ".jpeg": "\U0001f5bc",
47
+ ".gif": "\U0001f5bc", ".svg": "\U0001f5bc",
48
+ ".zip": "\U0001f5c3", ".7z": "\U0001f5c3",
49
+ ".xyz": "\u269b", ".pdb": "\u269b", ".mol": "\u269b", ".cif": "\u269b",
50
+ }
51
+
52
+
53
+ def icon_for(path):
54
+ return _ICONS.get(os.path.splitext(path)[1].lower(), "\U0001f4c4")
55
+
56
+
57
+ if TEXTUAL_AVAILABLE:
58
+
59
+ class _ProjectRow(Horizontal):
60
+ """One clickable line in Pinned/Recent Projects — opens that
61
+ folder on click. When `on_delete` is given (Recent Projects
62
+ only), a small ✕ is appended to the right of the row; clicking
63
+ it removes just that entry from the history and re-renders the
64
+ block. Without a delete handler this renders exactly as the
65
+ old single-Static row did (same text, same classes, same
66
+ hover/click behaviour) — the standalone Recent Workspaces
67
+ picker screens keep using that form."""
68
+
69
+ def __init__(self, path, widget_id=None, on_delete=None, prefix="\U0001f4c1"):
70
+ label = os.path.basename(path.rstrip(os.sep)) or path
71
+ self.path = path
72
+ self._on_delete = on_delete
73
+ self._label = Static(f" {prefix} {label}", id=widget_id,
74
+ classes="cct-sidebar-project")
75
+ super().__init__(classes="cct-sidebar-project-row")
76
+ self.tooltip = path
77
+
78
+ def compose(self):
79
+ yield self._label
80
+ if self._on_delete is not None:
81
+ yield Static("\u2715", classes="cct-sidebar-project-del")
82
+
83
+ def on_mount(self):
84
+ if self._on_delete is not None:
85
+ self._label.styles.width = "auto"
86
+
87
+ def on_click(self, event):
88
+ if event.widget and "del" in getattr(event.widget, "classes", set()):
89
+ return
90
+ self.post_message(FolderOpened(self.path))
91
+
92
+ class _ConfirmClearRecent(Screen):
93
+ """Confirmation dialog for the 'Clear Recent Projects' minor
94
+ feature. Same real-Screen-push pattern as _AttachPrompt/
95
+ _PathPrompt (ui/app.py) and PermissionModeMenu (ui/header.py) —
96
+ this package never uses floating popup windows, only a
97
+ centered/anchored Screen push, which behaves like a modal
98
+ (blocks input to everything beneath it) without being one."""
99
+
100
+ CSS = """
101
+ _ConfirmClearRecent { align: center middle; background: $app-background 60%; }
102
+ #cct-clearrecent-box {
103
+ width: 56; height: auto; background: $surface;
104
+ border: tall $border-active $border; padding: 1 2;
105
+ }
106
+ #cct-clearrecent-title { text-style: bold; padding-bottom: 1; }
107
+ #cct-clearrecent-msg { color: $text-muted; padding-bottom: 1; }
108
+ #cct-clearrecent-btns { height: 3; align-horizontal: right; }
109
+ #cct-clearrecent-btns Button { margin-left: 1; border: tall $border-active $border; }
110
+ """
111
+
112
+ def compose(self):
113
+ with Vertical(id="cct-clearrecent-box"):
114
+ yield Static("Clear Recent Workspaces?", id="cct-clearrecent-title")
115
+ yield Static(
116
+ "This will remove all recent project entries from the history.\n"
117
+ "Your project folders and files will NOT be deleted.",
118
+ id="cct-clearrecent-msg")
119
+ with Horizontal(id="cct-clearrecent-btns"):
120
+ yield Button("Cancel", id="cct-clearrecent-cancel")
121
+ yield Button("Clear", id="cct-clearrecent-clear", variant="error")
122
+
123
+ def on_button_pressed(self, event):
124
+ self.dismiss(event.button.id == "cct-clearrecent-clear")
125
+
126
+ def on_key(self, event):
127
+ if event.key == "escape":
128
+ self.dismiss(False)
129
+
130
+ class _ClearRecentRow(Static):
131
+ """'Clear Recent Projects' link, shown at the bottom of the
132
+ Recent Projects section only when that list isn't already
133
+ empty. Opens _ConfirmClearRecent; on confirm, clears only the
134
+ recent-projects JSON store (projects.clear_recent()) and asks
135
+ the owning Explorer to redraw the Pinned/Recent block — it
136
+ never touches 'pinned', the currently open folder, or any file
137
+ on disk."""
138
+
139
+ def __init__(self, on_cleared):
140
+ super().__init__(" \U0001f5d1 Clear Recent Workspaces",
141
+ classes="cct-sidebar-clear-recent")
142
+ self._on_cleared = on_cleared
143
+
144
+ def on_click(self, event):
145
+ self.app.push_screen(_ConfirmClearRecent(), self._handle_result)
146
+
147
+ def _handle_result(self, confirmed):
148
+ if not confirmed:
149
+ return
150
+ projects.clear_recent()
151
+ self._on_cleared()
152
+
153
+ class _RecentWsRow(Horizontal):
154
+ """One workspace row in the Recent Workspaces picker
155
+ (v0.7.8.1 redesign): a clickable name label, a 📌 pin toggle and
156
+ a ✕ remove button. Pinned workspaces sort first and show a
157
+ filled pin; clicking the label picks the folder."""
158
+
159
+ def __init__(self, path, pinned=False, selected=False, on_click=None,
160
+ on_toggle_pin=None, on_remove=None, search_hit=False):
161
+ super().__init__(classes="cct-rws-row" + (" cct-rws-row-sel" if selected else ""))
162
+ self.path = path
163
+ self._pinned = pinned
164
+ self._on_click = on_click
165
+ self._on_toggle_pin = on_toggle_pin
166
+ self._on_remove = on_remove
167
+ label = os.path.basename(path.rstrip(os.sep)) or path
168
+ pin_mark = "\U0001f4cc " if pinned else " "
169
+ self._label = Static(f"{pin_mark}[b]{label}[/]", classes="cct-rws-label")
170
+ self._label.tooltip = path
171
+ self._search_hit = search_hit
172
+
173
+ def compose(self):
174
+ yield self._label
175
+ yield Static(self.path, classes="cct-rws-path")
176
+ pin_classes = "cct-rws-pin" + (" cct-rws-pinned" if self._pinned else "")
177
+ yield Static("\U0001f4cc", classes=pin_classes)
178
+ yield Static("\u2715", classes="cct-rws-del")
179
+
180
+ def set_selected(self, selected):
181
+ self.set_class(selected, "cct-rws-row-sel")
182
+
183
+ def on_click(self, event):
184
+ w_classes = getattr(event.widget, "classes", set())
185
+ if "cct-rws-pin" in w_classes:
186
+ event.stop()
187
+ if self._on_toggle_pin:
188
+ self._on_toggle_pin(self.path)
189
+ return
190
+ if "cct-rws-del" in w_classes:
191
+ event.stop()
192
+ if self._on_remove:
193
+ self._on_remove(self.path)
194
+ return
195
+ if self._on_click:
196
+ self._on_click(self.path)
197
+
198
+ class RecentWorkspacesScreen(Screen):
199
+ """Standalone "Recent Workspaces" picker (v0.7.8.1 redesign —
200
+ the primary workspace-management surface). Reachable from the
201
+ header's menu even before any folder is open. Supports:
202
+
203
+ - live search (filters by name or path)
204
+ - pin / unpin (pinned sort first and stick)
205
+ - remove a single entry
206
+ - clear the whole history (confirm dialog)
207
+ - up/down + Enter keyboard navigation
208
+ - Esc closes, opening a row dismisses with that path
209
+
210
+ Selecting a row dismisses with that path; CCTApp opens it the
211
+ same way FolderOpened already does."""
212
+
213
+ CSS = """
214
+ RecentWorkspacesScreen { align: center middle; background: $app-background 60%; }
215
+ #cct-recentws-box {
216
+ width: 84; height: auto; max-height: 88%;
217
+ background: $surface; border: tall $border-active $border;
218
+ padding: 0 0 1 0;
219
+ opacity: 0; offset-y: 1;
220
+ transition: opacity 120ms, offset 150ms;
221
+ }
222
+ #cct-recentws-box.open { opacity: 1; offset-y: 0; }
223
+ #cct-recentws-titlebar {
224
+ height: 3; padding: 1 2 0 2;
225
+ border-bottom: solid $border;
226
+ }
227
+ #cct-recentws-title { text-style: bold; width: 1fr; }
228
+ #cct-recentws-close {
229
+ width: 3; min-width: 3; height: 1;
230
+ color: $text-faint; content-align: center middle;
231
+ }
232
+ #cct-recentws-close:hover { color: $error; }
233
+ #cct-recentws-search { margin: 1 2 0 2; }
234
+ #cct-recentws-count { color: $text-faint; height: 1; padding: 0 2; margin-top: 1; }
235
+ #cct-recentws-list {
236
+ height: auto; max-height: 12; min-height: 2; overflow-y: auto;
237
+ margin: 0 1; padding: 0 1; scrollbar-gutter: stable;
238
+ scrollbar-size: 1 1; scrollbar-color: $border $surface;
239
+ }
240
+ #cct-recentws-empty { color: $text-faint; padding: 2 1; }
241
+ .cct-rws-row {
242
+ height: 1; min-height: 1; max-height: 1;
243
+ border-left: thick transparent;
244
+ padding: 0 1; align-vertical: middle;
245
+ transition: background 80ms, color 80ms;
246
+ }
247
+ .cct-rws-row:hover { background: $surface-alt; color: $text; }
248
+ .cct-rws-row:hover .cct-rws-label { color: $accent; }
249
+ .cct-rws-row:hover .cct-rws-path { color: $text; }
250
+ .cct-rws-row-sel {
251
+ background: $accent 18%;
252
+ border-left: thick $accent;
253
+ text-style: bold;
254
+ }
255
+ .cct-rws-row-sel .cct-rws-label { color: $accent; text-style: bold; }
256
+ .cct-rws-row-sel .cct-rws-path { color: $text; }
257
+ .cct-rws-label {
258
+ width: 24; min-width: 20; max-width: 26;
259
+ text-wrap: nowrap; text-overflow: ellipsis; overflow-x: hidden;
260
+ height: 1; padding: 0 1 0 0; color: $text;
261
+ }
262
+ .cct-rws-path {
263
+ width: 1fr; color: $text-faint;
264
+ text-wrap: nowrap; text-overflow: ellipsis; overflow-x: hidden;
265
+ height: 1; padding: 0 1;
266
+ }
267
+ .cct-rws-pin {
268
+ width: 3; min-width: 3; height: 1;
269
+ color: $text-faint; content-align: center middle;
270
+ }
271
+ .cct-rws-pin:hover { color: $accent; }
272
+ .cct-rws-pinned { color: $warning; }
273
+ .cct-rws-del {
274
+ width: 3; min-width: 3; height: 1;
275
+ color: $text-faint; content-align: center middle;
276
+ }
277
+ .cct-rws-del:hover { color: $error; }
278
+ #cct-recentws-btns {
279
+ height: 4; padding: 0 2;
280
+ margin-top: 1;
281
+ border-top: solid $border;
282
+ align-horizontal: right;
283
+ align-vertical: middle;
284
+ }
285
+ #cct-recentws-btns Button {
286
+ margin-left: 1;
287
+ min-width: 14;
288
+ height: 3;
289
+ border: tall $border-active $border;
290
+ }
291
+ .cct-btn-spacer { width: 1fr; }
292
+ #cct-recentws-hint { color: $text-faint; height: 1; margin-top: 1; padding: 0 2; text-align: center; }
293
+ """
294
+
295
+ BINDINGS = [
296
+ Binding("escape", "cancel", "Cancel"),
297
+ Binding("down", "move_down_sel", "Next"),
298
+ Binding("up", "move_up_sel", "Previous"),
299
+ ]
300
+
301
+ def __init__(self):
302
+ super().__init__()
303
+ self._entries = [] # list of workspace dicts from projects.workspaces()
304
+ self._selected = 0
305
+
306
+ def compose(self):
307
+ with Vertical(id="cct-recentws-box"):
308
+ with Horizontal(id="cct-recentws-titlebar"):
309
+ yield Static("\U0001f553 Recent Workspaces", id="cct-recentws-title")
310
+ yield Static("\u2715", id="cct-recentws-close")
311
+ yield Input(placeholder="Search workspaces\u2026", id="cct-recentws-search")
312
+ yield Static("", id="cct-recentws-count")
313
+ with ScrollableContainer(id="cct-recentws-list"):
314
+ self._render_rows()
315
+ with Horizontal(id="cct-recentws-btns"):
316
+ yield Button("\U0001f5d1 Clear Recent", id="cct-recentws-clear")
317
+ yield Static("", classes="cct-btn-spacer")
318
+ yield Button("Cancel", id="cct-recentws-cancel")
319
+ yield Button(" Open ", id="cct-recentws-open", variant="primary")
320
+ yield Static("Enter to open \u00b7 Up/Down to select \u00b7 Esc to close",
321
+ id="cct-recentws-hint")
322
+
323
+ # --------------------------------------------------------- state --
324
+ def _refresh(self):
325
+ self._render_rows()
326
+ self._update_count()
327
+
328
+ def _render_rows(self):
329
+ try:
330
+ scroll = self.query_one("#cct-recentws-list", ScrollableContainer)
331
+ except Exception:
332
+ return
333
+ query = ""
334
+ try:
335
+ query = self.query_one("#cct-recentws-search", Input).value.strip().lower()
336
+ except Exception:
337
+ pass
338
+ scroll.remove_children()
339
+ all_ws = projects.search(query)
340
+ # v0.7.8.1: pinned workspaces always sort first.
341
+ self._entries = sorted(all_ws, key=lambda w: (not w["pinned"], -w.get("opened_at", 0)))
342
+ if not self._entries:
343
+ scroll.mount(Static(
344
+ "No recent workspaces yet \u2014 open a folder to start.",
345
+ id="cct-recentws-empty"))
346
+ self._selected = 0
347
+ return
348
+ self._selected = min(self._selected, len(self._entries) - 1)
349
+ for i, w in enumerate(self._entries):
350
+ row = _RecentWsRow(
351
+ w["path"], pinned=bool(w.get("pinned")), selected=(i == self._selected),
352
+ on_click=self._pick, on_toggle_pin=self._toggle_pin, on_remove=self._remove)
353
+ scroll.mount(row)
354
+
355
+ def _update_count(self):
356
+ try:
357
+ n = len(self._entries)
358
+ pinned_n = sum(1 for w in self._entries if w.get("pinned"))
359
+ parts = [f"{n} workspace{'s' if n != 1 else ''}"]
360
+ if pinned_n:
361
+ parts.append(f"{pinned_n} pinned")
362
+ self.query_one("#cct-recentws-count", Static).update(
363
+ " \u00b7 ".join(parts))
364
+ except Exception:
365
+ pass
366
+
367
+ # ------------------------------------------------------ actions --
368
+ def _pick(self, path):
369
+ self.dismiss(path)
370
+
371
+ def _toggle_pin(self, path):
372
+ projects.toggle_pin(path)
373
+ self._refresh()
374
+
375
+ def _remove(self, path):
376
+ projects.remove_recent(path)
377
+ self._refresh()
378
+
379
+ def _clear(self):
380
+ def _on_confirmed(confirmed):
381
+ if confirmed:
382
+ projects.clear_recent()
383
+ self._refresh()
384
+ self.app.push_screen(_ConfirmClearRecent(), _on_confirmed)
385
+
386
+ # --------------------------------------------------------- events --
387
+ def on_mount(self):
388
+ self.call_after_refresh(lambda: self.query_one("#cct-recentws-box").add_class("open"))
389
+ self.call_after_refresh(self._refresh)
390
+ try:
391
+ self.query_one("#cct-recentws-search", Input).focus()
392
+ except Exception:
393
+ pass
394
+
395
+ def on_input_changed(self, event):
396
+ if event.input.id == "cct-recentws-search":
397
+ self._selected = 0
398
+ self._refresh()
399
+
400
+ def on_button_pressed(self, event):
401
+ eid = event.button.id
402
+ if eid in ("cct-recentws-cancel", "cct-recentws-close"):
403
+ self.dismiss(None)
404
+ elif eid == "cct-recentws-clear":
405
+ self._clear()
406
+ elif eid == "cct-recentws-open":
407
+ if self._entries:
408
+ self._pick(self._entries[self._selected]["path"])
409
+
410
+ def action_cancel(self):
411
+ self.dismiss(None)
412
+
413
+ def action_move_down_sel(self):
414
+ if self._entries:
415
+ self._selected = min(self._selected + 1, len(self._entries) - 1)
416
+ self._refresh()
417
+
418
+ def action_move_up_sel(self):
419
+ if self._entries:
420
+ self._selected = max(self._selected - 1, 0)
421
+ self._refresh()
422
+
423
+ def on_key(self, event):
424
+ if event.key == "enter" and self._entries and self.app.screen is self:
425
+ self._pick(self._entries[self._selected]["path"])
426
+
427
+ def on_click(self, event):
428
+ if getattr(event.widget, "id", None) == "cct-recentws-close":
429
+ self.dismiss(None)
430
+ return
431
+ if getattr(event, "widget", None) in (self, None):
432
+ self.dismiss(None)
433
+
434
+ class OpenWorkspaceScreen(Screen):
435
+ """Modern compact workspace picker dialog.
436
+
437
+ Replaces the old _PathPrompt with a VS Code/Cursor-inspired
438
+ dialog. Supports manual path entry, native folder browsing
439
+ (tkinter), recent workspaces, path validation, and keyboard
440
+ shortcuts. Compact ~68-col layout with smooth animations."""
441
+
442
+ CSS = """
443
+ OpenWorkspaceScreen { align: center middle; background: $app-background 70%; }
444
+ #ows-box {
445
+ width: 70; height: auto; max-height: 36;
446
+ background: $surface; border: tall $border-active $border; padding: 0;
447
+ opacity: 0; offset-y: 1;
448
+ transition: opacity 150ms, offset 180ms;
449
+ }
450
+ #ows-box.open { opacity: 1; offset-y: 0; }
451
+ #ows-titlebar {
452
+ height: 3; padding: 1 2 0 2;
453
+ border-bottom: solid $border; margin-bottom: 0;
454
+ }
455
+ #ows-title { text-style: bold; width: 1fr; }
456
+ #ows-close-btn {
457
+ width: 3; min-width: 3;
458
+ content-align: center middle;
459
+ color: $text-faint;
460
+ transition: color 80ms;
461
+ }
462
+ #ows-close-btn:hover { color: $error; }
463
+ #ows-body { padding: 1 2 1 2; }
464
+ #ows-path-label { color: $text-faint; padding-bottom: 0; height: 1; }
465
+ #ows-path-input { margin: 0 0 0 0; }
466
+ #ows-hint { height: 1; padding: 0 0 1 0; }
467
+ #ows-hint.ows-error { color: $error; }
468
+ #ows-hint.ows-success { color: $success; }
469
+ #ows-button-row { height: 3; align: center middle; margin-top: 0; }
470
+ #ows-button-row Button { margin: 0 1; }
471
+ #ows-recent-header {
472
+ color: $text-faint; padding-top: 1; padding-bottom: 0;
473
+ border-top: solid $border; margin-top: 1;
474
+ }
475
+ #ows-recent-body { height: auto; max-height: 10; overflow-y: auto; }
476
+ .ows-recent-row {
477
+ height: 1; color: $text-muted; padding: 0 1;
478
+ border-left: solid transparent;
479
+ transition: background 80ms, color 80ms;
480
+ }
481
+ .ows-recent-row:hover { color: $text; border-left: solid $border-active; }
482
+ #ows-dropzone {
483
+ height: 3; margin-top: 1;
484
+ border: dashed $border; color: $text-faint;
485
+ content-align: center middle;
486
+ }
487
+ """
488
+
489
+ BINDINGS = [
490
+ Binding("escape", "cancel", "Cancel"),
491
+ Binding("ctrl+v", "paste_path", "Paste"),
492
+ Binding("ctrl+l", "focus_path", "Focus path"),
493
+ Binding("ctrl+o", "browse", "Browse"),
494
+ ]
495
+
496
+ def __init__(self):
497
+ super().__init__()
498
+ self._valid_path = None
499
+
500
+ def compose(self):
501
+ with Vertical(id="ows-box"):
502
+ with Horizontal(id="ows-titlebar"):
503
+ yield Static("\U0001f4c2 Open Workspace", id="ows-title")
504
+ yield Button("\u2715", id="ows-close-btn", classes="cct-ctrl")
505
+ with Vertical(id="ows-body"):
506
+ yield Static("Workspace Folder", id="ows-path-label")
507
+ yield Input(placeholder="C:\\Users\\... or /home/user/project", id="ows-path-input")
508
+ yield Static("", id="ows-hint")
509
+ with Horizontal(id="ows-button-row"):
510
+ yield Button("Browse...", id="ows-browse-btn")
511
+ yield Button(" Open ", id="ows-open-btn", variant="primary")
512
+ yield Button("Cancel", id="ows-cancel-btn")
513
+ yield Static("Recent Workspaces", id="ows-recent-header")
514
+ with Vertical(id="ows-recent-body"):
515
+ rows = projects.recent()
516
+ if not rows:
517
+ yield Static(" No recent workspaces yet.", classes="ows-hint", id="ows-recent-empty")
518
+ else:
519
+ for p in rows[:10]:
520
+ yield _ProjectRow(p)
521
+ yield Static("\U0001f4c1 Drop folder here", id="ows-dropzone")
522
+
523
+ def on_mount(self):
524
+ self.call_after_refresh(
525
+ lambda: self.query_one("#ows-box").add_class("open"))
526
+ self.query_one("#ows-path-input", Input).focus()
527
+
528
+ def on_input_submitted(self, event):
529
+ if event.input.id == "ows-path-input":
530
+ self._do_open()
531
+
532
+ def on_button_pressed(self, event):
533
+ eid = event.button.id
534
+ if eid == "ows-browse-btn":
535
+ self._do_browse()
536
+ elif eid == "ows-open-btn":
537
+ self._do_open()
538
+ elif eid in ("ows-cancel-btn", "ows-close-btn"):
539
+ self.dismiss(None)
540
+
541
+ def on_input_changed(self, event):
542
+ if event.input.id == "ows-path-input":
543
+ self._validate()
544
+
545
+ def on_folder_opened(self, event):
546
+ """Intercept _ProjectRow's FolderOpened events and dismiss."""
547
+ event.stop()
548
+ path = os.path.abspath(os.path.expanduser(event.path))
549
+ if os.path.isdir(path):
550
+ self.dismiss(path)
551
+
552
+ def on_click(self, event):
553
+ # ✕ in titlebar may emit click on inner label, not the Button itself — walk parents
554
+ w = getattr(event, "widget", None)
555
+ cur = w
556
+ while cur is not None:
557
+ if getattr(cur, "id", None) == "ows-close-btn":
558
+ self.dismiss(None)
559
+ return
560
+ cur = getattr(cur, "parent", None)
561
+ if w in (self, None):
562
+ self.dismiss(None)
563
+
564
+ def action_cancel(self):
565
+ self.dismiss(None)
566
+
567
+ def action_paste_path(self):
568
+ clipped = self._get_clipboard()
569
+ if clipped:
570
+ inp = self.query_one("#ows-path-input", Input)
571
+ inp.value = clipped
572
+ self._validate()
573
+
574
+ def action_focus_path(self):
575
+ self.query_one("#ows-path-input", Input).focus()
576
+
577
+ def action_browse(self):
578
+ self._do_browse()
579
+
580
+ def _get_clipboard(self):
581
+ try:
582
+ import tkinter as tk
583
+ root = tk.Tk()
584
+ root.withdraw()
585
+ root.attributes('-topmost', True)
586
+ try:
587
+ return root.clipboard_get()
588
+ except tk.TclError:
589
+ return ""
590
+ finally:
591
+ root.destroy()
592
+ except Exception:
593
+ return ""
594
+
595
+ def _do_browse(self):
596
+ import tkinter as tk
597
+ from tkinter import filedialog
598
+ root = tk.Tk()
599
+ root.withdraw()
600
+ root.attributes('-topmost', True)
601
+ try:
602
+ folder = filedialog.askdirectory(title="Select Workspace Folder")
603
+ finally:
604
+ root.destroy()
605
+ if folder:
606
+ inp = self.query_one("#ows-path-input", Input)
607
+ inp.value = folder
608
+ self._validate()
609
+
610
+ def _validate(self):
611
+ hint = self.query_one("#ows-hint", Static)
612
+ raw = self.query_one("#ows-path-input", Input).value.strip()
613
+ if not raw:
614
+ hint.update("")
615
+ self._valid_path = None
616
+ return
617
+ expanded = os.path.abspath(os.path.expanduser(raw))
618
+ if not os.path.exists(expanded):
619
+ hint.update("\u26a0 Path does not exist")
620
+ self._valid_path = None
621
+ return
622
+ if not os.path.isdir(expanded):
623
+ hint.update("\u26a0 Not a directory")
624
+ self._valid_path = None
625
+ return
626
+ if not os.access(expanded, os.R_OK):
627
+ hint.update("\u26a0 No read permission")
628
+ self._valid_path = None
629
+ return
630
+ name = os.path.basename(expanded) or expanded
631
+ hint.update(f"\u2713 {name} \u2014 folder is valid")
632
+ self._valid_path = expanded
633
+
634
+ def _do_open(self):
635
+ self._validate()
636
+ if self._valid_path:
637
+ self.dismiss(self._valid_path)
638
+ else:
639
+ hint = self.query_one("#ows-hint", Static)
640
+ hint.update("\u26a0 Please enter a valid folder path")
641
+
642
+ class WorkspaceHeader(Vertical):
643
+ """v0.7.7 Project Header (spec section 9): the top strip of the
644
+ Folder Panel, above the file tree, showing the workspace name,
645
+ git branch, indexed languages/files, and session memory/context
646
+ — the facts the footer and permission pill don't already show
647
+ (model/mode/permission stay in their existing single widgets;
648
+ nothing here duplicates them).
649
+
650
+ Rendered by CCTApp: `set_workspace(path)` for the identity row,
651
+ `set_context(ctx)` with the workspace_index result for the
652
+ detail row. Textual markup only — no nested widgets beyond the
653
+ two Static lines.
654
+
655
+ Deliberately class-only (no fixed widget ids) — the Explorer
656
+ re-creates this widget whenever a folder opens, and Textual's
657
+ remove() lands through the message pump (not instantly), so a
658
+ fixed id would collide with the previous instance's pending
659
+ removal (the exact DuplicateIds race documented in
660
+ Explorer._render_projects). Classes tolerate the brief overlap.
661
+ """
662
+
663
+ def __init__(self):
664
+ super().__init__(classes="cct-workspace-header")
665
+ self._path = None
666
+ self._ctx = None
667
+
668
+ def compose(self):
669
+ yield Static("\U0001f4c2 No workspace open", classes="cct-wshead-name")
670
+ yield Static("", classes="cct-wshead-detail")
671
+
672
+ def set_workspace(self, path):
673
+ self._path = path
674
+ self._rerender()
675
+
676
+ def set_context(self, ctx):
677
+ self._ctx = ctx
678
+ self._rerender()
679
+
680
+ def _rerender(self):
681
+ try:
682
+ if self._path:
683
+ name = os.path.basename(self._path.rstrip(os.sep)) or self._path
684
+ branch = None
685
+ if self._ctx:
686
+ branch = (self._ctx.get("git") or {}).get("branch")
687
+ suffix = f" \u00b7 \U0001f33f {branch}" if branch else ""
688
+ self.query_one(".cct-wshead-name", Static).update(
689
+ f"[bold]\U0001f4c1 {name}[/bold]{suffix}")
690
+ else:
691
+ self.query_one(".cct-wshead-name", Static).update(
692
+ "\U0001f4c2 No workspace open")
693
+ detail = self._detail_line()
694
+ self.query_one(".cct-wshead-detail", Static).update(detail)
695
+ except Exception:
696
+ pass
697
+
698
+ def _detail_line(self):
699
+ if not self._path:
700
+ return "[dim]Open a folder to index it for the AI.[/dim]"
701
+ from .. import workspace_index
702
+ ctx = self._ctx
703
+ if not ctx or not ctx.get("ok"):
704
+ return "[dim]\U0001f504 indexing workspace\u2026[/dim]"
705
+ bits = []
706
+ if ctx["languages"]:
707
+ bits.append(", ".join(ctx["languages"][:3]))
708
+ bits.append(f"{ctx['file_count']} files")
709
+ if ctx["dependencies"]:
710
+ bits.append(f"{len(ctx['dependencies'])} deps")
711
+ if ctx["package_managers"]:
712
+ bits.append("/".join(ctx["package_managers"][:3]))
713
+ line = " \u00b7 ".join(bits)
714
+ try:
715
+ from .. import memory as _mem
716
+ mem = _mem.stats()
717
+ if isinstance(mem, dict) and mem.get("turns") is not None:
718
+ line += f" \u00b7 \U0001f4be {mem['turns']} mem"
719
+ except Exception:
720
+ pass
721
+ try:
722
+ from .. import aicore as _ai
723
+ usage = _ai.get_session_usage()
724
+ if isinstance(usage, dict) and usage.get("total_tokens"):
725
+ window = usage.get("context_window") or 128000
726
+ pct = int(100 * min(1.0, usage["total_tokens"] / max(1, window)))
727
+ line += f" \u00b7 \u2699 ctx {pct}%"
728
+ except Exception:
729
+ pass
730
+ return f"[dim]{line}[/dim]"
731
+
732
+ class _CollapsedTreeRow(Static):
733
+ """The multi-workspace 'collapsed' state (spec section 4): when
734
+ a workspace's tree was collapsed, opening it shows this row
735
+ instead of mounting the DirectoryTree. Clicking expands the
736
+ tree in place and clears the collapsed flag."""
737
+
738
+ def __init__(self, path, on_expand):
739
+ self._path = path
740
+ self._on_expand = on_expand
741
+ super().__init__(" \u25b8 (tree collapsed \u2014 click to expand)",
742
+ classes="cct-sidebar-collapsed-row")
743
+
744
+ def on_click(self, event):
745
+ if self._on_expand:
746
+ self._on_expand(self._path)
747
+
748
+ class Explorer(Vertical):
749
+ """The Explorer sidebar (spec v0.7 Sidebar section). Sections,
750
+ top to bottom: Open Folder button, the open folder's file tree
751
+ (once one is open), Pinned Projects, Recent Projects, and a
752
+ Drag & Drop hint area.
753
+
754
+ `width` is a reactive so `set_width()`/collapse can animate via
755
+ CSS transition (see _COMPONENT_CSS's `transition: width`) rather
756
+ than snapping instantly.
757
+ """
758
+
759
+ width = reactive(DEFAULT_WIDTH)
760
+
761
+ def __init__(self, id="cct-sidebar"):
762
+ super().__init__(id=id)
763
+ self._workspace_root = None
764
+ self._projects_container = None
765
+ self._header = None
766
+ self._tree_mounted = False
767
+ # Gesture tracking for file triple-click / long-press
768
+ self._last_file_click_time = 0
769
+ self._last_file_click_path = None
770
+ self._file_click_count = 0
771
+
772
+ def compose(self):
773
+ # Title row carries a dedicated collapse/expand toggle
774
+ # (spec v0.7.4: the File Explorer behaves like a real IDE
775
+ # sidebar — visible chevron, open/close at any time). The
776
+ # chevron flips ▼/▶ with the panel's width state.
777
+ with Horizontal(id="cct-sidebar-titlebar"):
778
+ yield Static("[b]Explorer[/b]", id="cct-sidebar-title")
779
+ yield Button("\u25c2", id="cct-sidebar-collapse-btn",
780
+ classes="cct-ctrl cct-sidebar-collapse-btn", tooltip="Collapse Sidebar (Ctrl+B)")
781
+ yield Button("📁 Open Folder", id="cct-open-folder-btn", classes="cct-sidebar-open-btn")
782
+ with ScrollableContainer(id="cct-sidebar-body"):
783
+ yield Static("No folder open.", id="cct-sidebar-empty")
784
+
785
+ def on_mount(self):
786
+ self._render_projects()
787
+
788
+ def _show_explorer_menu(self):
789
+ items = [
790
+ ("open_folder", "📁 Open Folder..."),
791
+ ("refresh", "🔄 Refresh Explorer"),
792
+ ("collapse_all", "📂 Collapse Folders"),
793
+ ]
794
+ if self._workspace_root:
795
+ items.append(("close_workspace", "✕ Close Workspace"))
796
+ try:
797
+ from .context_menu import MessageContextMenu
798
+ def on_dismiss(action):
799
+ if action == "open_folder":
800
+ self.post_message(FileOpenRequested("__open_folder_prompt__"))
801
+ elif action == "refresh":
802
+ self.refresh_tree()
803
+ self._render_projects()
804
+ elif action == "collapse_all":
805
+ if self._tree_mounted and hasattr(self, "_tree") and self._tree:
806
+ try:
807
+ self._tree.root.collapse_all()
808
+ except Exception:
809
+ pass
810
+ elif action == "close_workspace":
811
+ try:
812
+ self.close_workspace()
813
+ except Exception:
814
+ pass
815
+
816
+ menu = MessageContextMenu(items, origin=(2, 4))
817
+ self.app.push_screen(menu, callback=on_dismiss)
818
+ except Exception:
819
+ self.refresh_tree()
820
+ self._render_projects()
821
+
822
+ def close_workspace(self):
823
+ """Closes the current open workspace folder and restores
824
+ the sidebar to its default empty state with recent projects."""
825
+ self._workspace_root = None
826
+ self._tree_mounted = False
827
+ old = self._header
828
+ self._header = None
829
+ if old is not None:
830
+ try:
831
+ old.remove()
832
+ except Exception:
833
+ pass
834
+ try:
835
+ body = self.query_one("#cct-sidebar-body", ScrollableContainer)
836
+ for child in list(body.children):
837
+ child.remove()
838
+ body.mount(Static("No folder open.", id="cct-sidebar-empty"))
839
+ self._render_projects(body)
840
+ except Exception:
841
+ pass
842
+ try:
843
+ shell = self.app.query_one("#cct-workspace")
844
+ if shell:
845
+ shell.workspace_root = None
846
+ shell.sync_resizer()
847
+ shell._relayout()
848
+ except Exception:
849
+ pass
850
+ try:
851
+ from .. import workspace as ws_paths
852
+ old_ws = getattr(self.app, "_workspace_root", None)
853
+ ws_paths.set_active_project(None)
854
+ if hasattr(self.app, "_handle_workspace_chat_switch"):
855
+ self.app._handle_workspace_chat_switch(old_ws, None)
856
+ if hasattr(self.app, "_workspace_root"):
857
+ self.app._workspace_root = None
858
+ if hasattr(self.app, "_current_workspace"):
859
+ self.app._current_workspace = None
860
+ if hasattr(self.app, "_refresh_header_breadcrumb"):
861
+ self.app._refresh_header_breadcrumb()
862
+ if hasattr(self.app, "_refresh_header_right"):
863
+ self.app._refresh_header_right()
864
+ except Exception:
865
+ pass
866
+ try:
867
+ self.post_message(WorkspaceChanged(None))
868
+ except Exception:
869
+ pass
870
+
871
+ def on_button_pressed(self, event):
872
+ if event.button.id == "cct-open-folder-btn":
873
+ self.post_message(FileOpenRequested("__open_folder_prompt__"))
874
+ elif event.button.id == "cct-sidebar-collapse-btn":
875
+ self.toggle()
876
+ self.post_message(SidebarToggled(self.width > 0))
877
+ elif event.button.id == "cct-sidebar-menu-btn":
878
+ self._show_explorer_menu()
879
+
880
+ # ---------------------------------------------------- workspace --
881
+ def open_folder(self, path):
882
+ """Mounts a real DirectoryTree rooted at `path`, replacing
883
+ whatever the sidebar body currently shows. Also mounts the
884
+ v0.7.7 Project Header (spec section 9) above the tree, and
885
+ honors the workspace's remembered collapse state (spec
886
+ section 4) — a collapsed workspace shows a single expand
887
+ row instead of the tree."""
888
+ path = os.path.abspath(os.path.expanduser(path))
889
+ if not os.path.isdir(path):
890
+ return False
891
+ if path == self._workspace_root and self._tree_mounted:
892
+ return True
893
+ self._workspace_root = path
894
+ projects.record_opened(path)
895
+ try:
896
+ body = self.query_one("#cct-sidebar-body", ScrollableContainer)
897
+ except Exception:
898
+ return True
899
+ for child in list(body.children):
900
+ child.remove()
901
+ self._mount_header(path, body)
902
+ if projects.is_collapsed(path):
903
+ body.mount(_CollapsedTreeRow(path, self._expand_collapsed))
904
+ self._tree_mounted = False
905
+ else:
906
+ body.mount(DirectoryTree(path, classes="cct-filetree"))
907
+ self._tree_mounted = True
908
+ self._render_projects(body)
909
+ return True
910
+
911
+ def _mount_header(self, path, body):
912
+ """(Re)creates the Project Header. Same object-reference
913
+ pattern as _render_projects: the old instance's remove()
914
+ is scheduled through the message pump, so the new header is
915
+ class-only (no fixed id) to survive the brief overlap."""
916
+ old = self._header
917
+ self._header = None
918
+ if old is not None:
919
+ try:
920
+ old.remove()
921
+ except Exception:
922
+ pass
923
+ header = WorkspaceHeader()
924
+ header.set_workspace(path)
925
+ self._header = header
926
+ body.mount(header)
927
+
928
+ def _expand_collapsed(self, path):
929
+ """Clicking the collapsed-tree row: clear the flag, remount
930
+ the real tree in place."""
931
+ projects.set_collapsed(path, False)
932
+ self._tree_mounted = False
933
+ self.open_folder(path)
934
+
935
+ def toggle_collapsed(self, path=None):
936
+ """Collapses/expands the current workspace's tree (spec
937
+ section 4's per-workspace collapse). The state is persisted
938
+ in the workspace history store, so re-opening this folder
939
+ later respects it."""
940
+ path = path or self._workspace_root
941
+ if not path:
942
+ return
943
+ body = self.query_one("#cct-sidebar-body", ScrollableContainer)
944
+ if self._tree_mounted:
945
+ projects.set_collapsed(path, True)
946
+ for child in list(body.children):
947
+ child.remove()
948
+ self._mount_header(path, body)
949
+ body.mount(_CollapsedTreeRow(path, self._expand_collapsed))
950
+ self._tree_mounted = False
951
+ self._render_projects(body)
952
+ else:
953
+ projects.set_collapsed(path, False)
954
+ self._tree_mounted = False
955
+ self.open_folder(path)
956
+
957
+ def set_workspace_header(self, ctx):
958
+ """v0.7.7: CCTApp hands the workspace_index result here
959
+ once indexing finishes; the Project Header's detail line
960
+ re-renders with real languages/files/deps/git data."""
961
+ if self._header is not None:
962
+ self._header.set_context(ctx)
963
+
964
+ def refresh_tree(self, changed_paths=None):
965
+ """Live-view refresh (v0.7.9.0): keeps the mounted
966
+ DirectoryTree in sync with the real filesystem. Called by
967
+ the fs-watcher bridge, explicit agent-tool notifications,
968
+ and post-turn diffs.
969
+
970
+ Uses DirectoryTree.reload(), whose own subtree-reload
971
+ machinery preserves every expanded folder and the
972
+ highlighted node, and — importantly — safely discards any
973
+ in-flight internal directory-load workers before rescanning
974
+ (a bare node-level reload can race them and repaint stale
975
+ contents). `changed_paths` is accepted for API symmetry;
976
+ the Editor pane's open-file reload is handled by
977
+ WorkspaceShell.refresh_explorer. No-op if no folder open."""
978
+ del changed_paths # editor refresh handled by the shell
979
+ if self._workspace_root is None:
980
+ return
981
+ try:
982
+ body = self.query_one("#cct-sidebar-body", ScrollableContainer)
983
+ trees = body.query(DirectoryTree)
984
+ if trees:
985
+ trees.first().reload()
986
+ except Exception:
987
+ pass
988
+
989
+ @staticmethod
990
+ def _affected_dirs(changed_paths):
991
+ dirs = set()
992
+ for p in changed_paths or []:
993
+ p = os.path.abspath(str(p))
994
+ dirs.add(p)
995
+ parent = os.path.dirname(p)
996
+ if parent:
997
+ dirs.add(parent)
998
+ return dirs
999
+
1000
+ @staticmethod
1001
+ def _walk_loaded_nodes(tree):
1002
+ """Yields every already-loaded TreeNode (depth-first). Only
1003
+ loaded children are walked — collapsed branches don't need
1004
+ refreshing since their contents are re-read on expand."""
1005
+ stack = [tree.root]
1006
+ seen = 0
1007
+ while stack and seen < 4096:
1008
+ node = stack.pop()
1009
+ seen += 1
1010
+ yield node
1011
+ try:
1012
+ stack.extend(node.children)
1013
+ except Exception:
1014
+ pass
1015
+
1016
+ def close_folder(self):
1017
+ self._workspace_root = None
1018
+ self._header = None
1019
+ self._tree_mounted = False
1020
+ body = self.query_one("#cct-sidebar-body", ScrollableContainer)
1021
+ for child in list(body.children):
1022
+ child.remove()
1023
+ body.mount(Static("No folder open.", id="cct-sidebar-empty"))
1024
+ self._render_projects(body)
1025
+
1026
+ def _render_projects(self, body=None):
1027
+ """(Re)draws Pinned Projects, Recent Projects, the Clear
1028
+ Recent Projects link, and the drag & drop hint, inside a
1029
+ dedicated container (class 'cct-sidebar-projects'). Kept
1030
+ separate from the folder-name header / DirectoryTree that
1031
+ open_folder() mounts directly into `body` (both used to
1032
+ share the plain 'cct-sidebar-section' class, which meant
1033
+ there was no way to redraw just this block without also
1034
+ tearing down whichever folder is currently open) — this is
1035
+ what lets Clear Recent Projects call this again in place
1036
+ without touching the open project.
1037
+
1038
+ The container is tracked by direct object reference
1039
+ (self._projects_container), NOT a fixed widget `id` re-used
1040
+ on every redraw. Widget.remove() in Textual is scheduled
1041
+ through the message pump, not instant — mounting a new
1042
+ widget with the same fixed id immediately after calling
1043
+ .remove() on the old one (as this used to do) can race
1044
+ that scheduled removal and raise DuplicateIds, which is
1045
+ exactly the crash this fixes: open_folder() clears `body`
1046
+ (scheduling removal of the previous container along with
1047
+ everything else) and then calls this method again in the
1048
+ same synchronous call stack, before that removal has
1049
+ actually landed."""
1050
+ body = body or self.query_one("#cct-sidebar-body", ScrollableContainer)
1051
+ old = self._projects_container
1052
+ self._projects_container = None
1053
+ if old is not None:
1054
+ try:
1055
+ old.remove()
1056
+ except Exception:
1057
+ # Already gone (e.g. open_folder()/close_folder()
1058
+ # already wiped `body`, including this widget, right
1059
+ # before calling us) — nothing left to clean up.
1060
+ pass
1061
+ container = Vertical(classes="cct-sidebar-projects")
1062
+ self._projects_container = container
1063
+ body.mount(container)
1064
+ # v0.7.7 multi-workspace (spec section 4): one strip of every
1065
+ # known workspace (pinned + favorite + recent, newest first).
1066
+ # Clicking a row switches the Explorer to that folder in
1067
+ # place — the Folder Panel never closes when you browse
1068
+ # multiple workspaces (spec section 3).
1069
+ seen_paths = set()
1070
+ workspaces = projects.workspaces()[:6]
1071
+ if workspaces:
1072
+ container.mount(Static("[b]Workspaces[/b]",
1073
+ classes="cct-sidebar-project-header"))
1074
+ for w in workspaces:
1075
+ p = w["path"]
1076
+ seen_paths.add(os.path.abspath(p))
1077
+ marks = ("\U0001f4cc" if w.get("pinned") else "") \
1078
+ + ("\u2b50" if w.get("favorite") else "")
1079
+ prefix = (marks + " ") if marks else "\U0001f4c2"
1080
+ container.mount(_ProjectRow(
1081
+ p, prefix=prefix,
1082
+ on_delete=lambda path=p: self._remove_workspace(path)))
1083
+ pinned = [p for p in projects.pinned() if os.path.abspath(p) not in seen_paths]
1084
+ for p in pinned:
1085
+ seen_paths.add(os.path.abspath(p))
1086
+ recent = [p for p in projects.recent() if os.path.abspath(p) not in seen_paths][:6]
1087
+ if pinned:
1088
+ container.mount(Static("[b]Pinned Workspaces[/b]", classes="cct-sidebar-project-header"))
1089
+ for p in pinned:
1090
+ container.mount(_ProjectRow(p))
1091
+ if recent:
1092
+ container.mount(Static("[b]Recent Workspaces[/b]", classes="cct-sidebar-project-header"))
1093
+ for p in recent:
1094
+ # Per-project delete (v0.7.6 Patch 1): the ✕ on a
1095
+ # recent row removes ONLY that entry from the store,
1096
+ # then this block re-renders itself in place — same
1097
+ # in-place pattern _ClearRecentRow already uses, so
1098
+ # it never touches the open folder or 'pinned'.
1099
+ container.mount(_ProjectRow(
1100
+ p, on_delete=lambda path=p: self._delete_recent(path)))
1101
+ container.mount(_ClearRecentRow(self._render_projects))
1102
+ if not self._workspace_root:
1103
+ container.mount(Static(
1104
+ "[dim]Drag & drop files here, or into Chat/Editor,\n"
1105
+ "to attach them for the AI.[/dim]", classes="cct-sidebar-dropzone"))
1106
+
1107
+ def _remove_workspace(self, path):
1108
+ """Removes a workspace from the history strip (✕ on a
1109
+ Workspaces row) — history only, never files on disk, and
1110
+ never closes the panel."""
1111
+ projects.remove(path)
1112
+ self._render_projects()
1113
+
1114
+ def _delete_recent(self, path):
1115
+ projects.remove_recent(path)
1116
+ self._render_projects()
1117
+
1118
+ # ------------------------------------------------------ open tree --
1119
+ def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected):
1120
+ # Gesture detection: triple-click file → quick actions
1121
+ import time as _t
1122
+ path = str(event.path)
1123
+ now = _t.time()
1124
+ if path == self._last_file_click_path and now - self._last_file_click_time < 0.4:
1125
+ self._file_click_count += 1
1126
+ else:
1127
+ self._file_click_count = 1
1128
+ self._last_file_click_time = now
1129
+ self._last_file_click_path = path
1130
+ if self._file_click_count == 3:
1131
+ self._file_click_count = 0
1132
+ try:
1133
+ from ..gestures.manager import handle_gesture
1134
+ ctx = {"path": path}
1135
+ if handle_gesture("triple_click", "file", app=getattr(self, "app", None), context=ctx):
1136
+ return
1137
+ except Exception:
1138
+ pass
1139
+ # Fallback quick actions note
1140
+ try:
1141
+ self.app._system_note(f"Quick actions for: {path}")
1142
+ except Exception:
1143
+ pass
1144
+ return
1145
+ if self._file_click_count == 2:
1146
+ # Double-click file: check for reveal gesture (long_press fallback)
1147
+ try:
1148
+ from ..gestures.manager import handle_gesture
1149
+ ctx = {"path": path}
1150
+ # Try long_press as reveal
1151
+ if handle_gesture("long_press", "file", app=getattr(self, "app", None), context=ctx):
1152
+ self._file_click_count = 0
1153
+ return
1154
+ except Exception:
1155
+ pass
1156
+ self.post_message(FileOpenRequested(path))
1157
+
1158
+ def on_click(self, event):
1159
+ """Gesture on file → reveal actions (spec 23): long-press fallback via right-click."""
1160
+ try:
1161
+ # Right-click (button 3) on a file row triggers reveal gesture
1162
+ if getattr(event, "button", 1) == 3:
1163
+ widget = getattr(event, "widget", None)
1164
+ path = getattr(widget, "path", None) or getattr(self, "_last_file_click_path", "")
1165
+ if path:
1166
+ try:
1167
+ from ..gestures.manager import handle_gesture
1168
+ if handle_gesture("long_press", "file", app=getattr(self, "app", None), context={"path": path}):
1169
+ event.stop()
1170
+ return
1171
+ except Exception:
1172
+ pass
1173
+ # Fallback to file context menu
1174
+ origin = getattr(event, "screen_offset", None)
1175
+ origin_xy = (origin.x, origin.y) if origin else (event.x, event.y)
1176
+ self._show_touch_context_menu(path, origin_xy)
1177
+ except Exception:
1178
+ pass
1179
+
1180
+ def on_touch_long_press(self, widget, screen_pos):
1181
+ """Touch long-press on file sidebar opens context actions."""
1182
+ path = getattr(widget, "path", None) or getattr(self, "_last_file_click_path", "")
1183
+ if not path:
1184
+ try:
1185
+ tree = self.query_one(DirectoryTree)
1186
+ if tree.cursor_node and getattr(tree.cursor_node, "data", None):
1187
+ path = str(tree.cursor_node.data.path)
1188
+ except Exception:
1189
+ pass
1190
+ path = path or self._workspace_root or ""
1191
+ if path:
1192
+ self._show_touch_context_menu(path, screen_pos)
1193
+
1194
+ def _show_touch_context_menu(self, path, origin):
1195
+ try:
1196
+ from .context_menu import MessageContextMenu, FILE_MENU_ITEMS
1197
+ def _on_result(action):
1198
+ if not action:
1199
+ return
1200
+ self._handle_file_action(action, path)
1201
+ self.app.push_screen(MessageContextMenu(FILE_MENU_ITEMS, origin=origin), _on_result)
1202
+ except Exception:
1203
+ pass
1204
+
1205
+ def _handle_file_action(self, action, path):
1206
+ if action == "open":
1207
+ if os.path.isfile(path):
1208
+ self.post_message(FileOpenRequested(path))
1209
+ elif os.path.isdir(path):
1210
+ self.open_folder(path)
1211
+ elif action == "copy_path":
1212
+ try:
1213
+ from .. import code_editor
1214
+ code_editor.copy_to_clipboard(path)
1215
+ self.app._system_note(f"Copied: {path}")
1216
+ except Exception:
1217
+ pass
1218
+ elif action == "delete":
1219
+ try:
1220
+ if os.path.isfile(path):
1221
+ os.remove(path)
1222
+ self.refresh_tree()
1223
+ self.app._system_note(f"Deleted: {os.path.basename(path)}")
1224
+ except Exception as e:
1225
+ self.app._system_note(f"Delete failed: {e}")
1226
+ elif action == "rename":
1227
+ self.app._system_note(f"Selected for rename: {os.path.basename(path)}")
1228
+ elif action in ("new_file", "new_folder"):
1229
+ target_dir = path if os.path.isdir(path) else os.path.dirname(path)
1230
+ self.app._system_note(f"Create in: {target_dir}")
1231
+
1232
+
1233
+ # -------------------------------------------------- collapse/resize --
1234
+ def set_width(self, value):
1235
+ previous = self.width
1236
+ self.width = max(COLLAPSED_WIDTH, value)
1237
+ self.styles.width = self.width
1238
+ # At 0 width the panel would still paint its 1px right
1239
+ # border, so the collapsed class drops it — the sidebar
1240
+ # fully disappears instead of leaving a visible sliver.
1241
+ self.set_class(self.width == 0, "cct-sidebar-collapsed")
1242
+ # Keep the title-bar chevron honest: ▼ when open (click
1243
+ # collapses), ▶ when collapsed (click expands again).
1244
+ if self.is_mounted:
1245
+ try:
1246
+ btn = self.query_one("#cct-sidebar-collapse-btn", Button)
1247
+ btn.label = "\u25c2" if self.width > 0 else "\u25b8"
1248
+ except Exception:
1249
+ pass
1250
+ # Announce visibility flips once, so the shell can keep
1251
+ # its resize divider in lockstep (and CCTApp persists
1252
+ # the state). Crossing detection avoids double-posting
1253
+ # on every drag frame.
1254
+ if (previous > 0) != (self.width > 0):
1255
+ self.post_message(SidebarToggled(self.width > 0))
1256
+
1257
+ def _tree_mounted_or_root(self):
1258
+ return self._workspace_root is not None or self._tree_mounted
1259
+
1260
+ def toggle(self):
1261
+ if self.width > 0:
1262
+ self._restore_width = self.width
1263
+ self.set_width(COLLAPSED_WIDTH)
1264
+ else:
1265
+ self.set_width(getattr(self, "_restore_width", DEFAULT_WIDTH))
1266
+
1267
+ def focus_tree(self):
1268
+ """Give the open folder's DirectoryTree keyboard focus after
1269
+ the sidebar is reopened (spec v0.7.4: "focus or reopen the
1270
+ existing explorer"), so it feels like a focused panel, not a
1271
+ static pane. No-op when no folder is open."""
1272
+ if self._workspace_root is None:
1273
+ return
1274
+ try:
1275
+ body = self.query_one("#cct-sidebar-body", ScrollableContainer)
1276
+ trees = body.query(DirectoryTree)
1277
+ if trees:
1278
+ trees.first().focus()
1279
+ except Exception:
1280
+ pass
1281
+
1282
+ else:
1283
+ Explorer = None
1284
+ RecentWorkspacesScreen = None
1285
+ OpenWorkspaceScreen = None