superqode 0.1.5__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 (288) hide show
  1. superqode/__init__.py +33 -0
  2. superqode/acp/__init__.py +23 -0
  3. superqode/acp/client.py +913 -0
  4. superqode/acp/permission_screen.py +457 -0
  5. superqode/acp/types.py +480 -0
  6. superqode/acp_discovery.py +856 -0
  7. superqode/agent/__init__.py +22 -0
  8. superqode/agent/edit_strategies.py +334 -0
  9. superqode/agent/loop.py +892 -0
  10. superqode/agent/qe_report_templates.py +39 -0
  11. superqode/agent/system_prompts.py +353 -0
  12. superqode/agent_output.py +721 -0
  13. superqode/agent_stream.py +953 -0
  14. superqode/agents/__init__.py +59 -0
  15. superqode/agents/acp_registry.py +305 -0
  16. superqode/agents/client.py +249 -0
  17. superqode/agents/data/augmentcode.com.toml +51 -0
  18. superqode/agents/data/cagent.dev.toml +51 -0
  19. superqode/agents/data/claude.com.toml +60 -0
  20. superqode/agents/data/codeassistant.dev.toml +51 -0
  21. superqode/agents/data/codex.openai.com.toml +57 -0
  22. superqode/agents/data/fastagent.ai.toml +66 -0
  23. superqode/agents/data/geminicli.com.toml +77 -0
  24. superqode/agents/data/goose.block.xyz.toml +54 -0
  25. superqode/agents/data/junie.jetbrains.com.toml +56 -0
  26. superqode/agents/data/kimi.moonshot.cn.toml +57 -0
  27. superqode/agents/data/llmlingagent.dev.toml +51 -0
  28. superqode/agents/data/molt.bot.toml +49 -0
  29. superqode/agents/data/opencode.ai.toml +60 -0
  30. superqode/agents/data/stakpak.dev.toml +51 -0
  31. superqode/agents/data/vtcode.dev.toml +51 -0
  32. superqode/agents/discovery.py +266 -0
  33. superqode/agents/messaging.py +160 -0
  34. superqode/agents/persona.py +166 -0
  35. superqode/agents/registry.py +421 -0
  36. superqode/agents/schema.py +72 -0
  37. superqode/agents/unified.py +367 -0
  38. superqode/app/__init__.py +111 -0
  39. superqode/app/constants.py +314 -0
  40. superqode/app/css.py +366 -0
  41. superqode/app/models.py +118 -0
  42. superqode/app/suggester.py +125 -0
  43. superqode/app/widgets.py +1591 -0
  44. superqode/app_enhanced.py +399 -0
  45. superqode/app_main.py +17187 -0
  46. superqode/approval.py +312 -0
  47. superqode/atomic.py +296 -0
  48. superqode/commands/__init__.py +1 -0
  49. superqode/commands/acp.py +965 -0
  50. superqode/commands/agents.py +180 -0
  51. superqode/commands/auth.py +278 -0
  52. superqode/commands/config.py +374 -0
  53. superqode/commands/init.py +826 -0
  54. superqode/commands/providers.py +819 -0
  55. superqode/commands/qe.py +1145 -0
  56. superqode/commands/roles.py +380 -0
  57. superqode/commands/serve.py +172 -0
  58. superqode/commands/suggestions.py +127 -0
  59. superqode/commands/superqe.py +460 -0
  60. superqode/config/__init__.py +51 -0
  61. superqode/config/loader.py +812 -0
  62. superqode/config/schema.py +498 -0
  63. superqode/core/__init__.py +111 -0
  64. superqode/core/roles.py +281 -0
  65. superqode/danger.py +386 -0
  66. superqode/data/superqode-template.yaml +1522 -0
  67. superqode/design_system.py +1080 -0
  68. superqode/dialogs/__init__.py +6 -0
  69. superqode/dialogs/base.py +39 -0
  70. superqode/dialogs/model.py +130 -0
  71. superqode/dialogs/provider.py +870 -0
  72. superqode/diff_view.py +919 -0
  73. superqode/enterprise.py +21 -0
  74. superqode/evaluation/__init__.py +25 -0
  75. superqode/evaluation/adapters.py +93 -0
  76. superqode/evaluation/behaviors.py +89 -0
  77. superqode/evaluation/engine.py +209 -0
  78. superqode/evaluation/scenarios.py +96 -0
  79. superqode/execution/__init__.py +36 -0
  80. superqode/execution/linter.py +538 -0
  81. superqode/execution/modes.py +347 -0
  82. superqode/execution/resolver.py +283 -0
  83. superqode/execution/runner.py +642 -0
  84. superqode/file_explorer.py +811 -0
  85. superqode/file_viewer.py +471 -0
  86. superqode/flash.py +183 -0
  87. superqode/guidance/__init__.py +58 -0
  88. superqode/guidance/config.py +203 -0
  89. superqode/guidance/prompts.py +71 -0
  90. superqode/harness/__init__.py +54 -0
  91. superqode/harness/accelerator.py +291 -0
  92. superqode/harness/config.py +319 -0
  93. superqode/harness/validator.py +147 -0
  94. superqode/history.py +279 -0
  95. superqode/integrations/superopt_runner.py +124 -0
  96. superqode/logging/__init__.py +49 -0
  97. superqode/logging/adapters.py +219 -0
  98. superqode/logging/formatter.py +923 -0
  99. superqode/logging/integration.py +341 -0
  100. superqode/logging/sinks.py +170 -0
  101. superqode/logging/unified_log.py +417 -0
  102. superqode/lsp/__init__.py +26 -0
  103. superqode/lsp/client.py +544 -0
  104. superqode/main.py +1069 -0
  105. superqode/mcp/__init__.py +89 -0
  106. superqode/mcp/auth_storage.py +380 -0
  107. superqode/mcp/client.py +1236 -0
  108. superqode/mcp/config.py +319 -0
  109. superqode/mcp/integration.py +337 -0
  110. superqode/mcp/oauth.py +436 -0
  111. superqode/mcp/oauth_callback.py +385 -0
  112. superqode/mcp/types.py +290 -0
  113. superqode/memory/__init__.py +31 -0
  114. superqode/memory/feedback.py +342 -0
  115. superqode/memory/store.py +522 -0
  116. superqode/notifications.py +369 -0
  117. superqode/optimization/__init__.py +5 -0
  118. superqode/optimization/config.py +33 -0
  119. superqode/permissions/__init__.py +25 -0
  120. superqode/permissions/rules.py +488 -0
  121. superqode/plan.py +323 -0
  122. superqode/providers/__init__.py +33 -0
  123. superqode/providers/gateway/__init__.py +165 -0
  124. superqode/providers/gateway/base.py +228 -0
  125. superqode/providers/gateway/litellm_gateway.py +1170 -0
  126. superqode/providers/gateway/openresponses_gateway.py +436 -0
  127. superqode/providers/health.py +297 -0
  128. superqode/providers/huggingface/__init__.py +74 -0
  129. superqode/providers/huggingface/downloader.py +472 -0
  130. superqode/providers/huggingface/endpoints.py +442 -0
  131. superqode/providers/huggingface/hub.py +531 -0
  132. superqode/providers/huggingface/inference.py +394 -0
  133. superqode/providers/huggingface/transformers_runner.py +516 -0
  134. superqode/providers/local/__init__.py +100 -0
  135. superqode/providers/local/base.py +438 -0
  136. superqode/providers/local/discovery.py +418 -0
  137. superqode/providers/local/lmstudio.py +256 -0
  138. superqode/providers/local/mlx.py +457 -0
  139. superqode/providers/local/ollama.py +486 -0
  140. superqode/providers/local/sglang.py +268 -0
  141. superqode/providers/local/tgi.py +260 -0
  142. superqode/providers/local/tool_support.py +477 -0
  143. superqode/providers/local/vllm.py +258 -0
  144. superqode/providers/manager.py +1338 -0
  145. superqode/providers/models.py +1016 -0
  146. superqode/providers/models_dev.py +578 -0
  147. superqode/providers/openresponses/__init__.py +87 -0
  148. superqode/providers/openresponses/converters/__init__.py +17 -0
  149. superqode/providers/openresponses/converters/messages.py +343 -0
  150. superqode/providers/openresponses/converters/tools.py +268 -0
  151. superqode/providers/openresponses/schema/__init__.py +56 -0
  152. superqode/providers/openresponses/schema/models.py +585 -0
  153. superqode/providers/openresponses/streaming/__init__.py +5 -0
  154. superqode/providers/openresponses/streaming/parser.py +338 -0
  155. superqode/providers/openresponses/tools/__init__.py +21 -0
  156. superqode/providers/openresponses/tools/apply_patch.py +352 -0
  157. superqode/providers/openresponses/tools/code_interpreter.py +290 -0
  158. superqode/providers/openresponses/tools/file_search.py +333 -0
  159. superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
  160. superqode/providers/registry.py +716 -0
  161. superqode/providers/usage.py +332 -0
  162. superqode/pure_mode.py +384 -0
  163. superqode/qr/__init__.py +23 -0
  164. superqode/qr/dashboard.py +781 -0
  165. superqode/qr/generator.py +1018 -0
  166. superqode/qr/templates.py +135 -0
  167. superqode/safety/__init__.py +41 -0
  168. superqode/safety/sandbox.py +413 -0
  169. superqode/safety/warnings.py +256 -0
  170. superqode/server/__init__.py +33 -0
  171. superqode/server/lsp_server.py +775 -0
  172. superqode/server/web.py +250 -0
  173. superqode/session/__init__.py +25 -0
  174. superqode/session/persistence.py +580 -0
  175. superqode/session/sharing.py +477 -0
  176. superqode/session.py +475 -0
  177. superqode/sidebar.py +2991 -0
  178. superqode/stream_view.py +648 -0
  179. superqode/styles/__init__.py +3 -0
  180. superqode/superqe/__init__.py +184 -0
  181. superqode/superqe/acp_runner.py +1064 -0
  182. superqode/superqe/constitution/__init__.py +62 -0
  183. superqode/superqe/constitution/evaluator.py +308 -0
  184. superqode/superqe/constitution/loader.py +432 -0
  185. superqode/superqe/constitution/schema.py +250 -0
  186. superqode/superqe/events.py +591 -0
  187. superqode/superqe/frameworks/__init__.py +65 -0
  188. superqode/superqe/frameworks/base.py +234 -0
  189. superqode/superqe/frameworks/e2e.py +263 -0
  190. superqode/superqe/frameworks/executor.py +237 -0
  191. superqode/superqe/frameworks/javascript.py +409 -0
  192. superqode/superqe/frameworks/python.py +373 -0
  193. superqode/superqe/frameworks/registry.py +92 -0
  194. superqode/superqe/mcp_tools/__init__.py +47 -0
  195. superqode/superqe/mcp_tools/core_tools.py +418 -0
  196. superqode/superqe/mcp_tools/registry.py +230 -0
  197. superqode/superqe/mcp_tools/testing_tools.py +167 -0
  198. superqode/superqe/noise.py +89 -0
  199. superqode/superqe/orchestrator.py +778 -0
  200. superqode/superqe/roles.py +609 -0
  201. superqode/superqe/session.py +713 -0
  202. superqode/superqe/skills/__init__.py +57 -0
  203. superqode/superqe/skills/base.py +106 -0
  204. superqode/superqe/skills/core_skills.py +899 -0
  205. superqode/superqe/skills/registry.py +90 -0
  206. superqode/superqe/verifier.py +101 -0
  207. superqode/superqe_cli.py +76 -0
  208. superqode/tool_call.py +358 -0
  209. superqode/tools/__init__.py +93 -0
  210. superqode/tools/agent_tools.py +496 -0
  211. superqode/tools/base.py +324 -0
  212. superqode/tools/batch_tool.py +133 -0
  213. superqode/tools/diagnostics.py +311 -0
  214. superqode/tools/edit_tools.py +653 -0
  215. superqode/tools/enhanced_base.py +515 -0
  216. superqode/tools/file_tools.py +269 -0
  217. superqode/tools/file_tracking.py +45 -0
  218. superqode/tools/lsp_tools.py +610 -0
  219. superqode/tools/network_tools.py +350 -0
  220. superqode/tools/permissions.py +400 -0
  221. superqode/tools/question_tool.py +324 -0
  222. superqode/tools/search_tools.py +598 -0
  223. superqode/tools/shell_tools.py +259 -0
  224. superqode/tools/todo_tools.py +121 -0
  225. superqode/tools/validation.py +80 -0
  226. superqode/tools/web_tools.py +639 -0
  227. superqode/tui.py +1152 -0
  228. superqode/tui_integration.py +875 -0
  229. superqode/tui_widgets/__init__.py +27 -0
  230. superqode/tui_widgets/widgets/__init__.py +18 -0
  231. superqode/tui_widgets/widgets/progress.py +185 -0
  232. superqode/tui_widgets/widgets/tool_display.py +188 -0
  233. superqode/undo_manager.py +574 -0
  234. superqode/utils/__init__.py +5 -0
  235. superqode/utils/error_handling.py +323 -0
  236. superqode/utils/fuzzy.py +257 -0
  237. superqode/widgets/__init__.py +477 -0
  238. superqode/widgets/agent_collab.py +390 -0
  239. superqode/widgets/agent_store.py +936 -0
  240. superqode/widgets/agent_switcher.py +395 -0
  241. superqode/widgets/animation_manager.py +284 -0
  242. superqode/widgets/code_context.py +356 -0
  243. superqode/widgets/command_palette.py +412 -0
  244. superqode/widgets/connection_status.py +537 -0
  245. superqode/widgets/conversation_history.py +470 -0
  246. superqode/widgets/diff_indicator.py +155 -0
  247. superqode/widgets/enhanced_status_bar.py +385 -0
  248. superqode/widgets/enhanced_toast.py +476 -0
  249. superqode/widgets/file_browser.py +809 -0
  250. superqode/widgets/file_reference.py +585 -0
  251. superqode/widgets/issue_timeline.py +340 -0
  252. superqode/widgets/leader_key.py +264 -0
  253. superqode/widgets/mode_switcher.py +445 -0
  254. superqode/widgets/model_picker.py +234 -0
  255. superqode/widgets/permission_preview.py +1205 -0
  256. superqode/widgets/prompt.py +358 -0
  257. superqode/widgets/provider_connect.py +725 -0
  258. superqode/widgets/pty_shell.py +587 -0
  259. superqode/widgets/qe_dashboard.py +321 -0
  260. superqode/widgets/resizable_sidebar.py +377 -0
  261. superqode/widgets/response_changes.py +218 -0
  262. superqode/widgets/response_display.py +528 -0
  263. superqode/widgets/rich_tool_display.py +613 -0
  264. superqode/widgets/sidebar_panels.py +1180 -0
  265. superqode/widgets/slash_complete.py +356 -0
  266. superqode/widgets/split_view.py +612 -0
  267. superqode/widgets/status_bar.py +273 -0
  268. superqode/widgets/superqode_display.py +786 -0
  269. superqode/widgets/thinking_display.py +815 -0
  270. superqode/widgets/throbber.py +87 -0
  271. superqode/widgets/toast.py +206 -0
  272. superqode/widgets/unified_output.py +1073 -0
  273. superqode/workspace/__init__.py +75 -0
  274. superqode/workspace/artifacts.py +472 -0
  275. superqode/workspace/coordinator.py +353 -0
  276. superqode/workspace/diff_tracker.py +429 -0
  277. superqode/workspace/git_guard.py +373 -0
  278. superqode/workspace/git_snapshot.py +526 -0
  279. superqode/workspace/manager.py +750 -0
  280. superqode/workspace/snapshot.py +357 -0
  281. superqode/workspace/watcher.py +535 -0
  282. superqode/workspace/worktree.py +440 -0
  283. superqode-0.1.5.dist-info/METADATA +204 -0
  284. superqode-0.1.5.dist-info/RECORD +288 -0
  285. superqode-0.1.5.dist-info/WHEEL +5 -0
  286. superqode-0.1.5.dist-info/entry_points.txt +3 -0
  287. superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
  288. superqode-0.1.5.dist-info/top_level.txt +1 -0
superqode/sidebar.py ADDED
@@ -0,0 +1,2991 @@
1
+ """
2
+ SuperQode Enhanced Sidebar - Colorful File Browser with Preview
3
+
4
+ Features:
5
+ - File type icons (Python, JS, etc.)
6
+ - Gradient colored folders
7
+ - File preview on selection
8
+ - Syntax highlighted content
9
+ - File info display
10
+ - Collapsible panels (Plan, Files, Preview)
11
+ - Git status indicator
12
+ - Quick file search (Ctrl+F)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import subprocess
18
+ import asyncio
19
+ from pathlib import Path
20
+ from typing import Optional, Callable, List
21
+ from dataclasses import dataclass
22
+
23
+ from textual.app import ComposeResult
24
+ from textual.containers import Container, Vertical, Horizontal, ScrollableContainer
25
+ from textual.widgets import Static, DirectoryTree, Tree, Label, Input, Collapsible
26
+ from textual.widgets.tree import TreeNode
27
+ from textual.widgets._directory_tree import DirEntry
28
+ from textual.reactive import reactive
29
+ from textual.message import Message
30
+ from textual import on, work
31
+ from textual.binding import Binding
32
+
33
+ from rich.text import Text
34
+ from rich.syntax import Syntax
35
+ from rich.panel import Panel
36
+ from rich.box import ROUNDED
37
+
38
+
39
+ # ============================================================================
40
+ # FILE TYPE ICONS - Nerd Font style icons with colors
41
+ # ============================================================================
42
+
43
+ FILE_ICONS = {
44
+ # Python
45
+ ".py": ("🐍", "#3776ab"),
46
+ ".pyw": ("🐍", "#3776ab"),
47
+ ".pyi": ("🐍", "#3776ab"),
48
+ ".pyx": ("🐍", "#3776ab"),
49
+ ".ipynb": ("📓", "#f37626"),
50
+ # JavaScript/TypeScript
51
+ ".js": ("📜", "#f7df1e"),
52
+ ".jsx": ("⚛️", "#61dafb"),
53
+ ".ts": ("💠", "#3178c6"),
54
+ ".tsx": ("⚛️", "#61dafb"),
55
+ ".mjs": ("📜", "#f7df1e"),
56
+ ".cjs": ("📜", "#f7df1e"),
57
+ ".vue": ("💚", "#42b883"),
58
+ ".svelte": ("🔥", "#ff3e00"),
59
+ # Web
60
+ ".html": ("🌐", "#e34f26"),
61
+ ".htm": ("🌐", "#e34f26"),
62
+ ".css": ("🎨", "#1572b6"),
63
+ ".scss": ("🎨", "#cc6699"),
64
+ ".sass": ("🎨", "#cc6699"),
65
+ ".less": ("🎨", "#1d365d"),
66
+ # Data formats
67
+ ".json": ("📋", "#cbcb41"),
68
+ ".yaml": ("⚙️", "#cb171e"),
69
+ ".yml": ("⚙️", "#cb171e"),
70
+ ".toml": ("⚙️", "#9c4121"),
71
+ ".xml": ("📰", "#e37933"),
72
+ ".csv": ("📊", "#217346"),
73
+ # Shell
74
+ ".sh": ("💻", "#4eaa25"),
75
+ ".bash": ("💻", "#4eaa25"),
76
+ ".zsh": ("💻", "#4eaa25"),
77
+ ".fish": ("🐟", "#4eaa25"),
78
+ ".ps1": ("💻", "#012456"),
79
+ ".bat": ("💻", "#c1f12e"),
80
+ ".cmd": ("💻", "#c1f12e"),
81
+ # Systems languages
82
+ ".c": ("🔷", "#555555"),
83
+ ".h": ("🔷", "#555555"),
84
+ ".cpp": ("🔷", "#f34b7d"),
85
+ ".hpp": ("🔷", "#f34b7d"),
86
+ ".cc": ("🔷", "#f34b7d"),
87
+ ".cxx": ("🔷", "#f34b7d"),
88
+ ".rs": ("🦀", "#dea584"),
89
+ ".go": ("🐹", "#00add8"),
90
+ ".java": ("☕", "#b07219"),
91
+ ".kt": ("🟣", "#a97bff"),
92
+ ".kts": ("🟣", "#a97bff"),
93
+ ".scala": ("🔴", "#c22d40"),
94
+ ".swift": ("🍎", "#f05138"),
95
+ # Other languages
96
+ ".rb": ("💎", "#cc342d"),
97
+ ".php": ("🐘", "#777bb4"),
98
+ ".pl": ("🐪", "#0298c3"),
99
+ ".lua": ("🌙", "#000080"),
100
+ ".r": ("📊", "#198ce7"),
101
+ ".R": ("📊", "#198ce7"),
102
+ ".jl": ("🔮", "#9558b2"),
103
+ ".ex": ("💧", "#6e4a7e"),
104
+ ".exs": ("💧", "#6e4a7e"),
105
+ ".erl": ("📡", "#b83998"),
106
+ ".hs": ("λ", "#5e5086"),
107
+ ".ml": ("🐫", "#dc6b1f"),
108
+ ".fs": ("🔷", "#b845fc"),
109
+ ".clj": ("🟢", "#63b132"),
110
+ ".lisp": ("🟢", "#3fb68b"),
111
+ # Config files
112
+ ".ini": ("⚙️", "#6d8086"),
113
+ ".cfg": ("⚙️", "#6d8086"),
114
+ ".conf": ("⚙️", "#6d8086"),
115
+ ".env": ("🔐", "#ecd53f"),
116
+ ".gitignore": ("🚫", "#f05032"),
117
+ ".dockerignore": ("🚫", "#2496ed"),
118
+ ".editorconfig": ("⚙️", "#6d8086"),
119
+ # Documentation
120
+ ".md": ("📝", "#083fa1"),
121
+ ".markdown": ("📝", "#083fa1"),
122
+ ".rst": ("📝", "#141414"),
123
+ ".txt": ("📄", "#6d8086"),
124
+ ".log": ("📋", "#6d8086"),
125
+ ".pdf": ("📕", "#ff0000"),
126
+ # Database
127
+ ".sql": ("🗄️", "#e38c00"),
128
+ ".sqlite": ("🗄️", "#003b57"),
129
+ ".db": ("🗄️", "#003b57"),
130
+ # Build/Config
131
+ ".dockerfile": ("🐳", "#2496ed"),
132
+ ".docker": ("🐳", "#2496ed"),
133
+ # Images
134
+ ".png": ("🖼️", "#a4c639"),
135
+ ".jpg": ("🖼️", "#a4c639"),
136
+ ".jpeg": ("🖼️", "#a4c639"),
137
+ ".gif": ("🖼️", "#a4c639"),
138
+ ".svg": ("🎨", "#ffb13b"),
139
+ ".ico": ("🖼️", "#a4c639"),
140
+ ".webp": ("🖼️", "#a4c639"),
141
+ # Archives
142
+ ".zip": ("📦", "#6d8086"),
143
+ ".tar": ("📦", "#6d8086"),
144
+ ".gz": ("📦", "#6d8086"),
145
+ ".rar": ("📦", "#6d8086"),
146
+ ".7z": ("📦", "#6d8086"),
147
+ # Other
148
+ ".diff": ("📊", "#41b883"),
149
+ ".patch": ("📊", "#41b883"),
150
+ ".graphql": ("💜", "#e10098"),
151
+ ".proto": ("📡", "#6d8086"),
152
+ ".tf": ("🟣", "#844fba"),
153
+ ".hcl": ("🟣", "#844fba"),
154
+ ".lock": ("🔒", "#6d8086"),
155
+ }
156
+
157
+ # Special filenames
158
+ SPECIAL_FILES = {
159
+ "Makefile": ("🔧", "#6d8086"),
160
+ "Dockerfile": ("🐳", "#2496ed"),
161
+ "Vagrantfile": ("📦", "#1868f2"),
162
+ "Gemfile": ("💎", "#cc342d"),
163
+ "Rakefile": ("💎", "#cc342d"),
164
+ "CMakeLists.txt": ("🔧", "#064f8c"),
165
+ "package.json": ("📦", "#cb3837"),
166
+ "package-lock.json": ("🔒", "#cb3837"),
167
+ "yarn.lock": ("🔒", "#2c8ebb"),
168
+ "pnpm-lock.yaml": ("🔒", "#f9ad00"),
169
+ "requirements.txt": ("📋", "#3776ab"),
170
+ "pyproject.toml": ("🐍", "#3776ab"),
171
+ "setup.py": ("🐍", "#3776ab"),
172
+ "setup.cfg": ("🐍", "#3776ab"),
173
+ "Cargo.toml": ("🦀", "#dea584"),
174
+ "Cargo.lock": ("🔒", "#dea584"),
175
+ "go.mod": ("🐹", "#00add8"),
176
+ "go.sum": ("🔒", "#00add8"),
177
+ ".gitignore": ("🚫", "#f05032"),
178
+ ".gitattributes": ("📋", "#f05032"),
179
+ ".prettierrc": ("🎨", "#f7b93e"),
180
+ ".eslintrc": ("🔍", "#4b32c3"),
181
+ ".eslintrc.js": ("🔍", "#4b32c3"),
182
+ ".eslintrc.json": ("🔍", "#4b32c3"),
183
+ "tsconfig.json": ("💠", "#3178c6"),
184
+ "jsconfig.json": ("📜", "#f7df1e"),
185
+ "README.md": ("📖", "#083fa1"),
186
+ "LICENSE": ("📜", "#6d8086"),
187
+ "CHANGELOG.md": ("📋", "#083fa1"),
188
+ "CONTRIBUTING.md": ("🤝", "#083fa1"),
189
+ }
190
+
191
+ # Folder icons with gradient colors
192
+ FOLDER_ICONS = {
193
+ "src": ("📁", "#a855f7"),
194
+ "source": ("📁", "#a855f7"),
195
+ "lib": ("📚", "#ec4899"),
196
+ "libs": ("📚", "#ec4899"),
197
+ "test": ("🧪", "#22c55e"),
198
+ "tests": ("🧪", "#22c55e"),
199
+ "spec": ("🧪", "#22c55e"),
200
+ "specs": ("🧪", "#22c55e"),
201
+ "__tests__": ("🧪", "#22c55e"),
202
+ "docs": ("📖", "#06b6d4"),
203
+ "doc": ("📖", "#06b6d4"),
204
+ "documentation": ("📖", "#06b6d4"),
205
+ "config": ("⚙️", "#f97316"),
206
+ "configs": ("⚙️", "#f97316"),
207
+ "settings": ("⚙️", "#f97316"),
208
+ "public": ("🌐", "#3b82f6"),
209
+ "static": ("🌐", "#3b82f6"),
210
+ "assets": ("🎨", "#eab308"),
211
+ "images": ("🖼️", "#a4c639"),
212
+ "img": ("🖼️", "#a4c639"),
213
+ "icons": ("🎯", "#f43f5e"),
214
+ "styles": ("🎨", "#ec4899"),
215
+ "css": ("🎨", "#1572b6"),
216
+ "scripts": ("💻", "#4eaa25"),
217
+ "bin": ("⚡", "#f59e0b"),
218
+ "build": ("🔨", "#6d8086"),
219
+ "dist": ("📦", "#6d8086"),
220
+ "out": ("📦", "#6d8086"),
221
+ "output": ("📦", "#6d8086"),
222
+ "node_modules": ("📦", "#cb3837"),
223
+ "vendor": ("📦", "#6d8086"),
224
+ "packages": ("📦", "#6d8086"),
225
+ ".git": ("📂", "#f05032"),
226
+ ".github": ("🐙", "#181717"),
227
+ ".vscode": ("💙", "#007acc"),
228
+ ".idea": ("🧠", "#000000"),
229
+ "components": ("🧩", "#61dafb"),
230
+ "pages": ("📄", "#000000"),
231
+ "views": ("👁️", "#42b883"),
232
+ "models": ("🗃️", "#ff6b6b"),
233
+ "controllers": ("🎮", "#4ecdc4"),
234
+ "services": ("⚡", "#f7df1e"),
235
+ "utils": ("🔧", "#6d8086"),
236
+ "helpers": ("🤝", "#6d8086"),
237
+ "hooks": ("🪝", "#61dafb"),
238
+ "api": ("🔌", "#009688"),
239
+ "routes": ("🛤️", "#ff5722"),
240
+ "middleware": ("🔗", "#9c27b0"),
241
+ "migrations": ("📊", "#e38c00"),
242
+ "seeds": ("🌱", "#4caf50"),
243
+ "fixtures": ("📌", "#795548"),
244
+ "mocks": ("🎭", "#9e9e9e"),
245
+ "__pycache__": ("📦", "#3776ab"),
246
+ ".pytest_cache": ("🧪", "#22c55e"),
247
+ "venv": ("🐍", "#3776ab"),
248
+ ".venv": ("🐍", "#3776ab"),
249
+ "env": ("🔐", "#ecd53f"),
250
+ ".env": ("🔐", "#ecd53f"),
251
+ }
252
+
253
+ # Default icons
254
+ DEFAULT_FILE_ICON = ("📄", "#6d8086")
255
+ DEFAULT_FOLDER_ICON = ("📁", "#a855f7")
256
+ DEFAULT_FOLDER_OPEN_ICON = ("📂", "#ec4899")
257
+
258
+
259
+ def get_file_icon(path: Path) -> tuple[str, str]:
260
+ """Get icon and color for a file."""
261
+ name = path.name
262
+ ext = path.suffix.lower()
263
+
264
+ # Check special filenames first
265
+ if name in SPECIAL_FILES:
266
+ return SPECIAL_FILES[name]
267
+
268
+ # Check extension
269
+ if ext in FILE_ICONS:
270
+ return FILE_ICONS[ext]
271
+
272
+ return DEFAULT_FILE_ICON
273
+
274
+
275
+ def get_folder_icon(name: str, is_open: bool = False) -> tuple[str, str]:
276
+ """Get icon and color for a folder."""
277
+ name_lower = name.lower()
278
+
279
+ if name_lower in FOLDER_ICONS:
280
+ icon, color = FOLDER_ICONS[name_lower]
281
+ # Use open folder variant if expanded
282
+ if is_open and icon == "📁":
283
+ icon = "📂"
284
+ return icon, color
285
+
286
+ return DEFAULT_FOLDER_OPEN_ICON if is_open else DEFAULT_FOLDER_ICON
287
+
288
+
289
+ # ============================================================================
290
+ # LANGUAGE DETECTION FOR SYNTAX HIGHLIGHTING
291
+ # ============================================================================
292
+
293
+ LANGUAGE_MAP = {
294
+ ".py": "python",
295
+ ".pyw": "python",
296
+ ".pyi": "python",
297
+ ".js": "javascript",
298
+ ".jsx": "jsx",
299
+ ".mjs": "javascript",
300
+ ".ts": "typescript",
301
+ ".tsx": "tsx",
302
+ ".html": "html",
303
+ ".htm": "html",
304
+ ".css": "css",
305
+ ".scss": "scss",
306
+ ".sass": "sass",
307
+ ".less": "less",
308
+ ".json": "json",
309
+ ".yaml": "yaml",
310
+ ".yml": "yaml",
311
+ ".toml": "toml",
312
+ ".xml": "xml",
313
+ ".svg": "xml",
314
+ ".sh": "bash",
315
+ ".bash": "bash",
316
+ ".zsh": "bash",
317
+ ".c": "c",
318
+ ".h": "c",
319
+ ".cpp": "cpp",
320
+ ".hpp": "cpp",
321
+ ".rs": "rust",
322
+ ".go": "go",
323
+ ".java": "java",
324
+ ".rb": "ruby",
325
+ ".php": "php",
326
+ ".swift": "swift",
327
+ ".kt": "kotlin",
328
+ ".scala": "scala",
329
+ ".md": "markdown",
330
+ ".rst": "rst",
331
+ ".sql": "sql",
332
+ ".graphql": "graphql",
333
+ ".dockerfile": "dockerfile",
334
+ ".tf": "terraform",
335
+ ".hcl": "hcl",
336
+ ".vue": "vue",
337
+ ".svelte": "svelte",
338
+ }
339
+
340
+
341
+ def detect_language(path: Path) -> str:
342
+ """Detect programming language from file path."""
343
+ name = path.name.lower()
344
+ ext = path.suffix.lower()
345
+
346
+ # Special filenames
347
+ if name == "dockerfile":
348
+ return "dockerfile"
349
+ if name == "makefile":
350
+ return "makefile"
351
+ if name in ("gemfile", "rakefile", "vagrantfile"):
352
+ return "ruby"
353
+
354
+ return LANGUAGE_MAP.get(ext, "text")
355
+
356
+
357
+ # ============================================================================
358
+ # CUSTOM DIRECTORY TREE WITH ICONS
359
+ # ============================================================================
360
+
361
+
362
+ class ColorfulDirectoryTree(DirectoryTree):
363
+ """Enhanced DirectoryTree with colorful file type icons."""
364
+
365
+ BINDINGS = [
366
+ Binding("o", "open_file", "Open in view", show=True),
367
+ ]
368
+
369
+ class FileOpenRequested(Message):
370
+ """Message sent when a file should be opened in main view."""
371
+
372
+ def __init__(self, path: Path) -> None:
373
+ self.path = path
374
+ super().__init__()
375
+
376
+ def render_label(self, node: TreeNode, base_style, style) -> Text:
377
+ """Render a label with file type icon."""
378
+ path = node.data.path if node.data else None
379
+
380
+ if path is None:
381
+ return Text(str(node.label))
382
+
383
+ label = Text()
384
+
385
+ if path.is_dir():
386
+ # Folder with icon
387
+ is_open = node.is_expanded
388
+ icon, color = get_folder_icon(path.name, is_open)
389
+ label.append(f"{icon} ", style=f"bold {color}")
390
+ label.append(path.name, style=f"{color}")
391
+ else:
392
+ # File with icon
393
+ icon, color = get_file_icon(path)
394
+ label.append(f"{icon} ", style=color)
395
+ label.append(path.name, style="white")
396
+
397
+ return label
398
+
399
+ def filter_paths(self, paths):
400
+ """Filter out hidden and ignored paths."""
401
+ ignore_patterns = {
402
+ "__pycache__",
403
+ ".git",
404
+ ".svn",
405
+ ".hg",
406
+ "node_modules",
407
+ ".pytest_cache",
408
+ ".mypy_cache",
409
+ ".ruff_cache",
410
+ ".tox",
411
+ ".nox",
412
+ ".coverage",
413
+ "dist",
414
+ "build",
415
+ "*.egg-info",
416
+ ".eggs",
417
+ "venv",
418
+ ".venv",
419
+ "env",
420
+ ".env",
421
+ ".DS_Store",
422
+ "Thumbs.db",
423
+ }
424
+
425
+ for path in paths:
426
+ name = path.name
427
+ # Skip hidden files (except some config files)
428
+ if name.startswith(".") and name not in {".github", ".gitignore", ".env", ".vscode"}:
429
+ continue
430
+ # Skip ignored patterns
431
+ if name in ignore_patterns:
432
+ continue
433
+ if any(name.endswith(p.replace("*", "")) for p in ignore_patterns if "*" in p):
434
+ continue
435
+ yield path
436
+
437
+ def action_open_file(self) -> None:
438
+ """Open the selected file in main view."""
439
+ node = self.cursor_node
440
+ if node and node.data and hasattr(node.data, "path"):
441
+ path = node.data.path
442
+ if path.is_file():
443
+ self.post_message(self.FileOpenRequested(path))
444
+
445
+
446
+ # ============================================================================
447
+ # FILE PREVIEW PANEL - Scrollable with user-friendly hints
448
+ # ============================================================================
449
+
450
+
451
+ class FilePreviewScroll(ScrollableContainer):
452
+ """Scrollable container for file preview."""
453
+
454
+ DEFAULT_CSS = """
455
+ FilePreviewScroll {
456
+ height: 100%;
457
+ background: #000000;
458
+ scrollbar-size: 1 1;
459
+ }
460
+ """
461
+
462
+
463
+ class FilePreview(Container):
464
+ """Panel showing file content preview with syntax highlighting."""
465
+
466
+ DEFAULT_CSS = """
467
+ FilePreview {
468
+ height: 100%;
469
+ background: #000000;
470
+ padding: 0;
471
+ layout: vertical;
472
+ }
473
+
474
+ FilePreview #preview-header {
475
+ height: 3;
476
+ background: #000000;
477
+ border-bottom: solid #1a1a1a;
478
+ padding: 0 1;
479
+ }
480
+
481
+ FilePreview #preview-hints {
482
+ height: 2;
483
+ background: #000000;
484
+ border-top: solid #1a1a1a;
485
+ padding: 0 1;
486
+ text-align: center;
487
+ }
488
+
489
+ FilePreview #preview-content {
490
+ height: 1fr;
491
+ background: #000000;
492
+ }
493
+
494
+ FilePreview .preview-syntax {
495
+ height: auto;
496
+ padding: 1;
497
+ }
498
+ """
499
+
500
+ BINDINGS = [
501
+ Binding("escape", "close_preview", "Close", show=False),
502
+ Binding("q", "close_preview", "Close", show=False),
503
+ Binding("e", "edit_file", "Edit", show=False),
504
+ ]
505
+
506
+ current_file: reactive[Optional[Path]] = reactive(None)
507
+
508
+ class PreviewClosed(Message):
509
+ """Message sent when preview is closed."""
510
+
511
+ pass
512
+
513
+ class EditRequested(Message):
514
+ """Message sent when user wants to edit the file."""
515
+
516
+ def __init__(self, path: Path) -> None:
517
+ self.path = path
518
+ super().__init__()
519
+
520
+ def __init__(self, **kwargs):
521
+ super().__init__(**kwargs)
522
+ self._content_cache: dict[Path, str] = {}
523
+
524
+ def compose(self) -> ComposeResult:
525
+ """Compose the preview layout."""
526
+ # Header
527
+ yield Static(self._render_header(), id="preview-header")
528
+
529
+ # Scrollable content area
530
+ with FilePreviewScroll(id="preview-content"):
531
+ yield Static(self._render_content(), id="preview-syntax", classes="preview-syntax")
532
+
533
+ # User-friendly hints at bottom
534
+ yield Static(self._render_hints(), id="preview-hints")
535
+
536
+ def watch_current_file(self, path: Optional[Path]) -> None:
537
+ """Update display when file changes."""
538
+ try:
539
+ self.query_one("#preview-header", Static).update(self._render_header())
540
+ self.query_one("#preview-syntax", Static).update(self._render_content())
541
+ self.query_one("#preview-hints", Static).update(self._render_hints())
542
+ # Scroll to top when new file selected
543
+ scroll = self.query_one("#preview-content", FilePreviewScroll)
544
+ scroll.scroll_home(animate=False)
545
+ except Exception:
546
+ pass
547
+
548
+ def _render_header(self) -> Text:
549
+ """Render the header with file info."""
550
+ t = Text()
551
+
552
+ if self.current_file is None:
553
+ t.append("\n 📄 ", style="bold #a855f7")
554
+ t.append("No file selected", style="#71717a")
555
+ return t
556
+
557
+ path = self.current_file
558
+ icon, color = get_file_icon(path)
559
+
560
+ t.append(f"\n {icon} ", style=f"bold {color}")
561
+ t.append(path.name, style=f"bold white")
562
+
563
+ # File info
564
+ try:
565
+ size = path.stat().st_size
566
+ size_str = self._format_size(size)
567
+ t.append(f" [{size_str}]", style="#71717a")
568
+ except Exception:
569
+ pass
570
+
571
+ return t
572
+
573
+ def _render_hints(self) -> Text:
574
+ """Render user-friendly hints."""
575
+ t = Text()
576
+ t.append("\n", style="")
577
+
578
+ if self.current_file is not None:
579
+ # File is open - show file-specific hints
580
+ t.append("↑↓", style="bold #ec4899")
581
+ t.append(" scroll ", style="#71717a")
582
+ t.append("e", style="bold #22c55e")
583
+ t.append(" edit ", style="#71717a")
584
+ t.append("o", style="bold #06b6d4")
585
+ t.append(" open in chat ", style="#71717a")
586
+ t.append("q", style="bold #f59e0b")
587
+ t.append(" close", style="#71717a")
588
+ else:
589
+ # No file - show navigation hints
590
+ t.append("↑↓", style="bold #ec4899")
591
+ t.append(" navigate ", style="#71717a")
592
+ t.append("Enter", style="bold #ec4899")
593
+ t.append(" select ", style="#71717a")
594
+ t.append("Ctrl+B", style="bold #f59e0b")
595
+ t.append(" close sidebar", style="#71717a")
596
+
597
+ return t
598
+
599
+ def _render_content(self) -> Text | Syntax:
600
+ """Render the file content."""
601
+ if self.current_file is None:
602
+ return self._render_empty()
603
+
604
+ return self._render_file_content(self.current_file)
605
+
606
+ def _render_empty(self) -> Text:
607
+ """Render empty state."""
608
+ t = Text()
609
+ t.append("\n\n", style="")
610
+ t.append(" 👆 ", style="bold #a855f7")
611
+ t.append("Select a file from the tree\n\n", style="#71717a")
612
+ t.append(" 📁 ", style="#ec4899")
613
+ t.append("Click folders to expand\n", style="#52525b")
614
+ t.append(" 📄 ", style="#ec4899")
615
+ t.append("Click files to preview\n", style="#52525b")
616
+ return t
617
+
618
+ def _render_file_content(self, path: Path) -> Text | Syntax:
619
+ """Render file content with syntax highlighting."""
620
+ # Check if binary
621
+ if self._is_binary(path):
622
+ t = Text()
623
+ t.append("\n 🔒 ", style="bold #f59e0b")
624
+ t.append("Binary file\n\n", style="#f59e0b")
625
+ t.append(f" Size: {self._format_size(path.stat().st_size)}\n", style="#71717a")
626
+ t.append(" Cannot display binary content\n", style="#52525b")
627
+ return t
628
+
629
+ # Read content
630
+ try:
631
+ if path in self._content_cache:
632
+ text = self._content_cache[path]
633
+ else:
634
+ text = path.read_text(encoding="utf-8", errors="replace")
635
+ # Cache small files
636
+ if len(text) < 100000:
637
+ self._content_cache[path] = text
638
+
639
+ # Syntax highlight - show ALL content (scrollable)
640
+ language = detect_language(path)
641
+ syntax = Syntax(
642
+ text,
643
+ language,
644
+ theme="monokai",
645
+ line_numbers=True,
646
+ word_wrap=True,
647
+ background_color="#000000",
648
+ )
649
+
650
+ return syntax
651
+
652
+ except Exception as e:
653
+ t = Text()
654
+ t.append(f"\n ❌ ", style="bold #ef4444")
655
+ t.append("Error reading file\n\n", style="#ef4444")
656
+ t.append(f" {str(e)}\n", style="#71717a")
657
+ return t
658
+
659
+ def _is_binary(self, path: Path) -> bool:
660
+ """Check if file is binary."""
661
+ try:
662
+ with open(path, "rb") as f:
663
+ chunk = f.read(8192)
664
+ return b"\x00" in chunk
665
+ except Exception:
666
+ return False
667
+
668
+ def _format_size(self, size: int) -> str:
669
+ """Format file size."""
670
+ for unit in ["B", "KB", "MB", "GB"]:
671
+ if size < 1024:
672
+ return f"{size:.1f} {unit}" if unit != "B" else f"{size} {unit}"
673
+ size /= 1024
674
+ return f"{size:.1f} TB"
675
+
676
+ def set_file(self, path: Path) -> None:
677
+ """Set the file to preview."""
678
+ self.current_file = path
679
+
680
+ def clear(self) -> None:
681
+ """Clear the preview."""
682
+ self.current_file = None
683
+
684
+ def action_close_preview(self) -> None:
685
+ """Close the current file preview."""
686
+ if self.current_file is not None:
687
+ self.current_file = None
688
+ self.post_message(self.PreviewClosed())
689
+
690
+ def action_edit_file(self) -> None:
691
+ """Open the file in the default editor."""
692
+ if self.current_file is not None:
693
+ self.post_message(self.EditRequested(self.current_file))
694
+
695
+
696
+ # ============================================================================
697
+ # ENHANCED SIDEBAR WITH FILE BROWSER AND PREVIEW
698
+ # ============================================================================
699
+
700
+
701
+ class EnhancedSidebar(Container):
702
+ """
703
+ Enhanced sidebar with colorful file browser and preview panel.
704
+
705
+ Features:
706
+ - Colorful file type icons
707
+ - Gradient folder colors
708
+ - Scrollable file preview with syntax highlighting
709
+ - Simple keyboard shortcuts
710
+ """
711
+
712
+ DEFAULT_CSS = """
713
+ EnhancedSidebar {
714
+ width: 100%;
715
+ height: 100%;
716
+ layout: vertical;
717
+ background: #000000;
718
+ }
719
+
720
+ EnhancedSidebar #sidebar-header {
721
+ height: 3;
722
+ background: #000000;
723
+ border-bottom: solid #1a1a1a;
724
+ padding: 0 1;
725
+ }
726
+
727
+ EnhancedSidebar #sidebar-content {
728
+ height: 1fr;
729
+ layout: horizontal;
730
+ }
731
+
732
+ EnhancedSidebar #file-tree-container {
733
+ width: 1fr;
734
+ min-width: 25;
735
+ max-width: 35;
736
+ height: 100%;
737
+ background: #000000;
738
+ border-right: solid #1a1a1a;
739
+ }
740
+
741
+ EnhancedSidebar #file-tree {
742
+ height: 100%;
743
+ background: #000000;
744
+ scrollbar-size: 1 1;
745
+ }
746
+
747
+ EnhancedSidebar #preview-container {
748
+ width: 2fr;
749
+ height: 100%;
750
+ background: #000000;
751
+ }
752
+
753
+ EnhancedSidebar #file-preview {
754
+ height: 100%;
755
+ }
756
+
757
+ EnhancedSidebar .sidebar-title {
758
+ text-align: center;
759
+ color: #a855f7;
760
+ text-style: bold;
761
+ padding: 1 0;
762
+ }
763
+
764
+ EnhancedSidebar ColorfulDirectoryTree {
765
+ background: #000000;
766
+ }
767
+
768
+ EnhancedSidebar ColorfulDirectoryTree > .tree--guides {
769
+ color: #1a1a1a;
770
+ }
771
+
772
+ EnhancedSidebar ColorfulDirectoryTree > .tree--cursor {
773
+ background: #3f3f46;
774
+ color: #ec4899;
775
+ text-style: bold;
776
+ border-left: tall #a855f7;
777
+ }
778
+
779
+ EnhancedSidebar ColorfulDirectoryTree:focus > .tree--cursor {
780
+ background: #52525b;
781
+ color: #ec4899;
782
+ text-style: bold;
783
+ border-left: tall #a855f7;
784
+ }
785
+ """
786
+
787
+ class FileOpened(Message):
788
+ """Message sent when a file should be opened/viewed."""
789
+
790
+ def __init__(self, path: Path) -> None:
791
+ self.path = path
792
+ super().__init__()
793
+
794
+ def __init__(
795
+ self,
796
+ path: Path | str = ".",
797
+ name: str | None = None,
798
+ id: str | None = None,
799
+ classes: str | None = None,
800
+ ):
801
+ super().__init__(name=name, id=id, classes=classes)
802
+ self.root_path = Path(path).resolve()
803
+
804
+ def compose(self) -> ComposeResult:
805
+ """Compose the sidebar layout."""
806
+ # Header with close hint
807
+ with Container(id="sidebar-header"):
808
+ yield Static(self._render_header(), classes="sidebar-title")
809
+
810
+ # Content: Tree + Preview
811
+ with Horizontal(id="sidebar-content"):
812
+ # File tree
813
+ with Container(id="file-tree-container"):
814
+ yield ColorfulDirectoryTree(self.root_path, id="file-tree")
815
+
816
+ # Preview panel
817
+ with Container(id="preview-container"):
818
+ yield FilePreview(id="file-preview")
819
+
820
+ def _render_header(self) -> Text:
821
+ """Render the sidebar header with hints."""
822
+ t = Text()
823
+ t.append("📁 ", style="bold #ec4899")
824
+ t.append(self.root_path.name or "Files", style="bold #a855f7")
825
+ t.append(" ", style="")
826
+ t.append("Ctrl+B", style="bold #71717a")
827
+ t.append(" close", style="#52525b")
828
+ return t
829
+
830
+ @on(DirectoryTree.FileSelected)
831
+ def on_file_selected(self, event: DirectoryTree.FileSelected) -> None:
832
+ """Handle file selection - show preview."""
833
+ event.stop()
834
+ path = event.path
835
+ preview = self.query_one("#file-preview", FilePreview)
836
+ preview.set_file(path)
837
+
838
+ @on(ColorfulDirectoryTree.FileOpenRequested)
839
+ def on_file_open_requested(self, event: ColorfulDirectoryTree.FileOpenRequested) -> None:
840
+ """Handle file open request - forward to parent."""
841
+ event.stop()
842
+ self.post_message(self.FileOpened(event.path))
843
+
844
+ @on(Tree.NodeHighlighted)
845
+ def on_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
846
+ """Update preview when navigating with keyboard."""
847
+ node = event.node
848
+ if node.data and hasattr(node.data, "path"):
849
+ path = node.data.path
850
+ if path.is_file():
851
+ preview = self.query_one("#file-preview", FilePreview)
852
+ preview.set_file(path)
853
+
854
+ @on(FilePreview.EditRequested)
855
+ def on_edit_requested(self, event: FilePreview.EditRequested) -> None:
856
+ """Handle edit request - open file in default editor."""
857
+ event.stop()
858
+ import subprocess
859
+ import os
860
+ import platform
861
+
862
+ path = event.path
863
+
864
+ # Try to open in default editor
865
+ try:
866
+ system = platform.system()
867
+ if system == "Darwin": # macOS
868
+ subprocess.Popen(["open", str(path)])
869
+ elif system == "Windows":
870
+ os.startfile(str(path))
871
+ else: # Linux
872
+ # Try common editors
873
+ editor = os.environ.get("EDITOR", "xdg-open")
874
+ subprocess.Popen([editor, str(path)])
875
+ except Exception:
876
+ # Fallback: try $EDITOR or vim/nano
877
+ editor = os.environ.get("EDITOR", "nano")
878
+ try:
879
+ subprocess.Popen([editor, str(path)])
880
+ except Exception:
881
+ pass
882
+
883
+ def action_focus_tree(self) -> None:
884
+ """Focus the file tree."""
885
+ self.query_one("#file-tree", ColorfulDirectoryTree).focus()
886
+
887
+ def refresh_tree(self) -> None:
888
+ """Refresh the file tree."""
889
+ tree = self.query_one("#file-tree", ColorfulDirectoryTree)
890
+ tree.reload()
891
+
892
+
893
+ # ============================================================================
894
+ # COMPACT SIDEBAR (Tree only, no preview)
895
+ # ============================================================================
896
+
897
+
898
+ class CompactSidebar(Container):
899
+ """Compact sidebar with just the file tree."""
900
+
901
+ DEFAULT_CSS = """
902
+ CompactSidebar {
903
+ width: 32;
904
+ height: 100%;
905
+ background: #000000;
906
+ border-right: solid #1a1a1a;
907
+ padding: 1;
908
+ }
909
+
910
+ CompactSidebar #compact-header {
911
+ height: 2;
912
+ text-align: center;
913
+ }
914
+
915
+ CompactSidebar #compact-tree {
916
+ height: 1fr;
917
+ background: #000000;
918
+ }
919
+
920
+ CompactSidebar ColorfulDirectoryTree {
921
+ background: #000000;
922
+ }
923
+ """
924
+
925
+ class FileSelected(Message):
926
+ """Message sent when a file is selected."""
927
+
928
+ def __init__(self, path: Path) -> None:
929
+ self.path = path
930
+ super().__init__()
931
+
932
+ def __init__(
933
+ self,
934
+ path: Path | str = ".",
935
+ name: str | None = None,
936
+ id: str | None = None,
937
+ classes: str | None = None,
938
+ ):
939
+ super().__init__(name=name, id=id, classes=classes)
940
+ self.root_path = Path(path).resolve()
941
+
942
+ def compose(self) -> ComposeResult:
943
+ """Compose the compact sidebar."""
944
+ header = Text()
945
+ header.append("📁 ", style="bold #ec4899")
946
+ header.append("Files", style="bold #a855f7")
947
+ yield Static(header, id="compact-header")
948
+ yield ColorfulDirectoryTree(self.root_path, id="compact-tree")
949
+
950
+ @on(ColorfulDirectoryTree.FileSelected)
951
+ def on_file_selected(self, event: ColorfulDirectoryTree.FileSelected) -> None:
952
+ """Forward file selection."""
953
+ event.stop()
954
+ self.post_message(self.FileSelected(event.path))
955
+
956
+
957
+ # ============================================================================
958
+ # GIT STATUS INDICATOR
959
+ # ============================================================================
960
+
961
+
962
+ @dataclass
963
+ class GitStatusInfo:
964
+ """Git repository status information."""
965
+
966
+ branch: str = ""
967
+ modified: int = 0
968
+ staged: int = 0
969
+ untracked: int = 0
970
+ is_repo: bool = False
971
+ ahead: int = 0
972
+ behind: int = 0
973
+
974
+
975
+ def get_git_status(path: Path) -> GitStatusInfo:
976
+ """Get git status for a directory (runs in thread)."""
977
+ info = GitStatusInfo()
978
+
979
+ try:
980
+ # Check if it's a git repo
981
+ result = subprocess.run(
982
+ ["git", "rev-parse", "--is-inside-work-tree"],
983
+ cwd=path,
984
+ capture_output=True,
985
+ text=True,
986
+ timeout=5,
987
+ )
988
+ if result.returncode != 0:
989
+ return info
990
+
991
+ info.is_repo = True
992
+
993
+ # Get branch name
994
+ result = subprocess.run(
995
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
996
+ cwd=path,
997
+ capture_output=True,
998
+ text=True,
999
+ timeout=5,
1000
+ )
1001
+ if result.returncode == 0:
1002
+ info.branch = result.stdout.strip()
1003
+
1004
+ # Get status counts
1005
+ result = subprocess.run(
1006
+ ["git", "status", "--porcelain"], cwd=path, capture_output=True, text=True, timeout=5
1007
+ )
1008
+ if result.returncode == 0:
1009
+ for line in result.stdout.strip().split("\n"):
1010
+ if not line:
1011
+ continue
1012
+ status = line[:2]
1013
+ if status[0] in "MADRCU": # Staged
1014
+ info.staged += 1
1015
+ if status[1] in "MD": # Modified
1016
+ info.modified += 1
1017
+ if status == "??": # Untracked
1018
+ info.untracked += 1
1019
+
1020
+ # Get ahead/behind
1021
+ result = subprocess.run(
1022
+ ["git", "rev-list", "--left-right", "--count", f"{info.branch}...@{{u}}"],
1023
+ cwd=path,
1024
+ capture_output=True,
1025
+ text=True,
1026
+ timeout=5,
1027
+ )
1028
+ if result.returncode == 0:
1029
+ parts = result.stdout.strip().split()
1030
+ if len(parts) == 2:
1031
+ info.ahead = int(parts[0])
1032
+ info.behind = int(parts[1])
1033
+
1034
+ except Exception:
1035
+ pass
1036
+
1037
+ return info
1038
+
1039
+
1040
+ class GitStatusWidget(Static):
1041
+ """Widget showing git status in sidebar header."""
1042
+
1043
+ DEFAULT_CSS = """
1044
+ GitStatusWidget {
1045
+ height: 2;
1046
+ width: 100%;
1047
+ padding: 0 1;
1048
+ background: #0a0a0a;
1049
+ border-bottom: solid #1a1a1a;
1050
+ }
1051
+ """
1052
+
1053
+ status: reactive[GitStatusInfo] = reactive(GitStatusInfo)
1054
+ _loading: bool = True
1055
+
1056
+ def __init__(self, path: Path, **kwargs):
1057
+ super().__init__(**kwargs)
1058
+ self.root_path = path
1059
+ self._loading = True
1060
+
1061
+ def on_mount(self) -> None:
1062
+ """Start fetching git status."""
1063
+ self.refresh_status()
1064
+
1065
+ @work(thread=True)
1066
+ def refresh_status(self) -> None:
1067
+ """Fetch git status in background thread."""
1068
+ status = get_git_status(self.root_path)
1069
+ # Use app.call_from_thread to safely update from worker thread
1070
+ self.app.call_from_thread(self._update_status, status)
1071
+
1072
+ def _update_status(self, status: GitStatusInfo) -> None:
1073
+ """Update status from thread."""
1074
+ self._loading = False
1075
+ self.status = status
1076
+
1077
+ def watch_status(self, status: GitStatusInfo) -> None:
1078
+ """Update display when status changes."""
1079
+ self.refresh()
1080
+
1081
+ def render(self) -> Text:
1082
+ """Render git status line."""
1083
+ t = Text()
1084
+
1085
+ # Loading state
1086
+ if self._loading:
1087
+ t.append("\n", style="")
1088
+ t.append(" ⎇ ", style="bold #a855f7")
1089
+ t.append("Loading git status...", style="#52525b italic")
1090
+ return t
1091
+
1092
+ status = self.status
1093
+
1094
+ t.append("\n", style="")
1095
+
1096
+ if not status.is_repo:
1097
+ t.append(" 📁 ", style="#71717a")
1098
+ t.append("Not a git repository", style="#52525b")
1099
+ return t
1100
+
1101
+ # Branch icon and name
1102
+ t.append(" ⎇ ", style="bold #a855f7")
1103
+ t.append(status.branch[:20], style="bold #a855f7")
1104
+
1105
+ # Status counts with icons
1106
+ if status.staged > 0:
1107
+ t.append(f" ✓{status.staged}", style="bold #22c55e")
1108
+ if status.modified > 0:
1109
+ t.append(f" ●{status.modified}", style="bold #f97316")
1110
+ if status.untracked > 0:
1111
+ t.append(f" +{status.untracked}", style="#71717a")
1112
+
1113
+ # Ahead/behind with arrows
1114
+ if status.ahead > 0:
1115
+ t.append(f" ↑{status.ahead}", style="bold #06b6d4")
1116
+ if status.behind > 0:
1117
+ t.append(f" ↓{status.behind}", style="bold #ec4899")
1118
+
1119
+ # Show clean state if nothing to commit
1120
+ if status.staged == 0 and status.modified == 0 and status.untracked == 0:
1121
+ t.append(" ✓ clean", style="#22c55e")
1122
+
1123
+ return t
1124
+
1125
+
1126
+ # ============================================================================
1127
+ # PLAN/TASK PANEL
1128
+ # ============================================================================
1129
+
1130
+
1131
+ @dataclass
1132
+ class TaskItem:
1133
+ """A single task in the plan."""
1134
+
1135
+ content: str
1136
+ status: str = "pending" # pending, in_progress, completed
1137
+ priority: str = "medium" # low, medium, high
1138
+
1139
+
1140
+ class PlanPanel(Container):
1141
+ """Panel showing current agent plan/tasks."""
1142
+
1143
+ DEFAULT_CSS = """
1144
+ PlanPanel {
1145
+ height: auto;
1146
+ max-height: 15;
1147
+ background: #000000;
1148
+ padding: 0 1;
1149
+ }
1150
+
1151
+ PlanPanel .task-item {
1152
+ height: 1;
1153
+ padding: 0;
1154
+ }
1155
+
1156
+ PlanPanel .task-pending {
1157
+ color: #71717a;
1158
+ }
1159
+
1160
+ PlanPanel .task-in-progress {
1161
+ color: #f97316;
1162
+ }
1163
+
1164
+ PlanPanel .task-completed {
1165
+ color: #22c55e;
1166
+ }
1167
+
1168
+ PlanPanel .empty-state {
1169
+ color: #52525b;
1170
+ text-style: italic;
1171
+ padding: 1;
1172
+ }
1173
+ """
1174
+
1175
+ tasks: reactive[List[TaskItem]] = reactive(list)
1176
+
1177
+ def compose(self) -> ComposeResult:
1178
+ """Compose the plan panel."""
1179
+ yield Static(self._render_tasks(), id="plan-content")
1180
+
1181
+ def watch_tasks(self, tasks: List[TaskItem]) -> None:
1182
+ """Update when tasks change."""
1183
+ try:
1184
+ self.query_one("#plan-content", Static).update(self._render_tasks())
1185
+ except Exception:
1186
+ pass
1187
+
1188
+ def _render_tasks(self) -> Text:
1189
+ """Render task list."""
1190
+ t = Text()
1191
+
1192
+ if not self.tasks:
1193
+ t.append(" No active tasks\n", style="italic #52525b")
1194
+ t.append(" Start a conversation to see plan", style="#3f3f46")
1195
+ return t
1196
+
1197
+ for task in self.tasks[:8]: # Show max 8 tasks
1198
+ # Status icon
1199
+ if task.status == "completed":
1200
+ t.append(" ✓ ", style="bold #22c55e")
1201
+ elif task.status == "in_progress":
1202
+ t.append(" ● ", style="bold #f97316")
1203
+ else:
1204
+ t.append(" ○ ", style="#71717a")
1205
+
1206
+ # Task content (truncated)
1207
+ content = task.content[:40] + "..." if len(task.content) > 40 else task.content
1208
+
1209
+ if task.status == "completed":
1210
+ t.append(content, style="#52525b")
1211
+ elif task.status == "in_progress":
1212
+ t.append(content, style="#f97316")
1213
+ else:
1214
+ t.append(content, style="#a1a1aa")
1215
+
1216
+ t.append("\n")
1217
+
1218
+ if len(self.tasks) > 8:
1219
+ t.append(f" +{len(self.tasks) - 8} more tasks...", style="#52525b")
1220
+
1221
+ return t
1222
+
1223
+ def set_tasks(self, tasks: List[TaskItem]) -> None:
1224
+ """Update the task list."""
1225
+ self.tasks = tasks
1226
+
1227
+ def add_task(self, content: str, status: str = "pending") -> None:
1228
+ """Add a new task."""
1229
+ self.tasks = self.tasks + [TaskItem(content=content, status=status)]
1230
+
1231
+ def update_task_status(self, index: int, status: str) -> None:
1232
+ """Update a task's status."""
1233
+ if 0 <= index < len(self.tasks):
1234
+ tasks = list(self.tasks)
1235
+ tasks[index] = TaskItem(
1236
+ content=tasks[index].content, status=status, priority=tasks[index].priority
1237
+ )
1238
+ self.tasks = tasks
1239
+
1240
+
1241
+ # ============================================================================
1242
+ # FILE SEARCH
1243
+ # ============================================================================
1244
+
1245
+
1246
+ class FileSearchResults(Container):
1247
+ """Container showing file search results."""
1248
+
1249
+ DEFAULT_CSS = """
1250
+ FileSearchResults {
1251
+ height: auto;
1252
+ max-height: 12;
1253
+ background: #000000;
1254
+ padding: 0;
1255
+ display: none;
1256
+ }
1257
+
1258
+ FileSearchResults.visible {
1259
+ display: block;
1260
+ }
1261
+
1262
+ FileSearchResults .search-result {
1263
+ height: 1;
1264
+ padding: 0 1;
1265
+ }
1266
+
1267
+ FileSearchResults .search-result:hover {
1268
+ background: #1a1a1a;
1269
+ }
1270
+
1271
+ FileSearchResults .search-result.selected {
1272
+ background: #a855f720;
1273
+ }
1274
+ """
1275
+
1276
+ results: reactive[List[Path]] = reactive(list)
1277
+ selected_index: reactive[int] = reactive(0)
1278
+
1279
+ class FileSelected(Message):
1280
+ """Message when a search result is selected."""
1281
+
1282
+ def __init__(self, path: Path) -> None:
1283
+ self.path = path
1284
+ super().__init__()
1285
+
1286
+ def compose(self) -> ComposeResult:
1287
+ """Compose search results."""
1288
+ yield Static(self._render_results(), id="search-results-content")
1289
+
1290
+ def watch_results(self, results: List[Path]) -> None:
1291
+ """Update when results change."""
1292
+ self.selected_index = 0
1293
+ try:
1294
+ self.query_one("#search-results-content", Static).update(self._render_results())
1295
+ except Exception:
1296
+ pass
1297
+
1298
+ def watch_selected_index(self, index: int) -> None:
1299
+ """Update when selection changes."""
1300
+ try:
1301
+ self.query_one("#search-results-content", Static).update(self._render_results())
1302
+ except Exception:
1303
+ pass
1304
+
1305
+ def _render_results(self) -> Text:
1306
+ """Render search results."""
1307
+ t = Text()
1308
+
1309
+ if not self.results:
1310
+ t.append(" No matches found", style="italic #52525b")
1311
+ return t
1312
+
1313
+ for i, path in enumerate(self.results[:10]):
1314
+ # Selection indicator
1315
+ if i == self.selected_index:
1316
+ t.append("▸ ", style="bold #a855f7")
1317
+ else:
1318
+ t.append(" ", style="")
1319
+
1320
+ # File icon
1321
+ icon, color = get_file_icon(path)
1322
+ t.append(f"{icon} ", style=color)
1323
+
1324
+ # Path (relative, truncated)
1325
+ rel_path = str(path)[-45:] if len(str(path)) > 45 else str(path)
1326
+ if len(str(path)) > 45:
1327
+ rel_path = "..." + rel_path
1328
+
1329
+ if i == self.selected_index:
1330
+ t.append(rel_path, style="bold white")
1331
+ else:
1332
+ t.append(rel_path, style="#a1a1aa")
1333
+
1334
+ t.append("\n")
1335
+
1336
+ if len(self.results) > 10:
1337
+ t.append(f" +{len(self.results) - 10} more results", style="#52525b")
1338
+
1339
+ return t
1340
+
1341
+ def move_selection(self, delta: int) -> None:
1342
+ """Move selection up or down."""
1343
+ if self.results:
1344
+ new_index = (self.selected_index + delta) % min(len(self.results), 10)
1345
+ self.selected_index = new_index
1346
+
1347
+ def get_selected(self) -> Optional[Path]:
1348
+ """Get the selected path."""
1349
+ if self.results and 0 <= self.selected_index < len(self.results):
1350
+ return self.results[self.selected_index]
1351
+ return None
1352
+
1353
+
1354
+ class FileSearch(Container):
1355
+ """File search widget with fuzzy matching."""
1356
+
1357
+ DEFAULT_CSS = """
1358
+ FileSearch {
1359
+ height: auto;
1360
+ background: #000000;
1361
+ padding: 0;
1362
+ }
1363
+
1364
+ FileSearch #search-input {
1365
+ height: 1;
1366
+ background: #0a0a0a;
1367
+ border: none;
1368
+ padding: 0 1;
1369
+ margin: 0;
1370
+ }
1371
+
1372
+ FileSearch #search-input:focus {
1373
+ border: none;
1374
+ }
1375
+ """
1376
+
1377
+ BINDINGS = [
1378
+ Binding("escape", "close_search", "Close", show=False),
1379
+ Binding("up", "move_up", "Up", show=False),
1380
+ Binding("down", "move_down", "Down", show=False),
1381
+ Binding("enter", "select_file", "Select", show=False),
1382
+ ]
1383
+
1384
+ class FileSelected(Message):
1385
+ """Message when a file is selected from search."""
1386
+
1387
+ def __init__(self, path: Path) -> None:
1388
+ self.path = path
1389
+ super().__init__()
1390
+
1391
+ class SearchClosed(Message):
1392
+ """Message when search is closed."""
1393
+
1394
+ pass
1395
+
1396
+ def __init__(self, root_path: Path, **kwargs):
1397
+ super().__init__(**kwargs)
1398
+ self.root_path = root_path
1399
+ self._all_files: List[Path] = []
1400
+ self._files_loaded = False
1401
+
1402
+ def compose(self) -> ComposeResult:
1403
+ """Compose the search widget."""
1404
+ yield Input(placeholder="🔍 Search files...", id="search-input")
1405
+ yield FileSearchResults(id="search-results")
1406
+
1407
+ def on_mount(self) -> None:
1408
+ """Load files on mount."""
1409
+ self._load_files()
1410
+
1411
+ @work(thread=True)
1412
+ def _load_files(self) -> None:
1413
+ """Load all files in background."""
1414
+ files = []
1415
+ try:
1416
+ for path in self.root_path.rglob("*"):
1417
+ if path.is_file():
1418
+ # Skip hidden and ignored
1419
+ parts = path.parts
1420
+ if any(
1421
+ p.startswith(".") or p in {"node_modules", "__pycache__", "venv", ".venv"}
1422
+ for p in parts
1423
+ ):
1424
+ continue
1425
+ files.append(path)
1426
+ if len(files) > 5000: # Limit for performance
1427
+ break
1428
+ except Exception:
1429
+ pass
1430
+
1431
+ self._all_files = files
1432
+ self._files_loaded = True
1433
+
1434
+ @on(Input.Changed, "#search-input")
1435
+ def on_search_changed(self, event: Input.Changed) -> None:
1436
+ """Handle search input changes."""
1437
+ query = event.value.lower().strip()
1438
+ results_widget = self.query_one("#search-results", FileSearchResults)
1439
+
1440
+ if not query:
1441
+ results_widget.results = []
1442
+ results_widget.remove_class("visible")
1443
+ return
1444
+
1445
+ # Fuzzy match files
1446
+ matches = []
1447
+ for path in self._all_files:
1448
+ name = path.name.lower()
1449
+ rel_path = str(path.relative_to(self.root_path)).lower()
1450
+
1451
+ # Simple fuzzy match: all query chars appear in order
1452
+ if self._fuzzy_match(query, name) or self._fuzzy_match(query, rel_path):
1453
+ matches.append(path)
1454
+ if len(matches) >= 50:
1455
+ break
1456
+
1457
+ results_widget.results = matches
1458
+ if matches:
1459
+ results_widget.add_class("visible")
1460
+ else:
1461
+ results_widget.remove_class("visible")
1462
+
1463
+ def _fuzzy_match(self, query: str, target: str) -> bool:
1464
+ """Simple fuzzy matching."""
1465
+ query_idx = 0
1466
+ for char in target:
1467
+ if query_idx < len(query) and char == query[query_idx]:
1468
+ query_idx += 1
1469
+ return query_idx == len(query)
1470
+
1471
+ def action_close_search(self) -> None:
1472
+ """Close the search."""
1473
+ self.query_one("#search-input", Input).value = ""
1474
+ self.query_one("#search-results", FileSearchResults).results = []
1475
+ self.query_one("#search-results", FileSearchResults).remove_class("visible")
1476
+ self.post_message(self.SearchClosed())
1477
+
1478
+ def action_move_up(self) -> None:
1479
+ """Move selection up."""
1480
+ self.query_one("#search-results", FileSearchResults).move_selection(-1)
1481
+
1482
+ def action_move_down(self) -> None:
1483
+ """Move selection down."""
1484
+ self.query_one("#search-results", FileSearchResults).move_selection(1)
1485
+
1486
+ def action_select_file(self) -> None:
1487
+ """Select the current file."""
1488
+ results = self.query_one("#search-results", FileSearchResults)
1489
+ path = results.get_selected()
1490
+ if path:
1491
+ self.post_message(self.FileSelected(path))
1492
+ self.action_close_search()
1493
+
1494
+
1495
+ # ============================================================================
1496
+ # CODEBASE SEARCH (Content Search / Grep)
1497
+ # ============================================================================
1498
+
1499
+
1500
+ @dataclass
1501
+ class CodeSearchResult:
1502
+ """A single code search result."""
1503
+
1504
+ path: Path
1505
+ line_no: int
1506
+ line_content: str
1507
+ match_start: int
1508
+ match_end: int
1509
+
1510
+
1511
+ def search_codebase(root_path: Path, query: str, max_results: int = 100) -> List[CodeSearchResult]:
1512
+ """Search through file contents (grep-like)."""
1513
+ results = []
1514
+ query_lower = query.lower()
1515
+
1516
+ # File extensions to search
1517
+ code_extensions = {
1518
+ ".py",
1519
+ ".js",
1520
+ ".ts",
1521
+ ".jsx",
1522
+ ".tsx",
1523
+ ".java",
1524
+ ".c",
1525
+ ".cpp",
1526
+ ".h",
1527
+ ".hpp",
1528
+ ".go",
1529
+ ".rs",
1530
+ ".rb",
1531
+ ".php",
1532
+ ".swift",
1533
+ ".kt",
1534
+ ".scala",
1535
+ ".cs",
1536
+ ".vb",
1537
+ ".html",
1538
+ ".css",
1539
+ ".scss",
1540
+ ".sass",
1541
+ ".less",
1542
+ ".json",
1543
+ ".yaml",
1544
+ ".yml",
1545
+ ".xml",
1546
+ ".md",
1547
+ ".txt",
1548
+ ".sh",
1549
+ ".bash",
1550
+ ".zsh",
1551
+ ".fish",
1552
+ ".sql",
1553
+ ".toml",
1554
+ ".ini",
1555
+ ".cfg",
1556
+ ".conf",
1557
+ ".env",
1558
+ ".gitignore",
1559
+ ".dockerignore",
1560
+ }
1561
+
1562
+ try:
1563
+ for path in root_path.rglob("*"):
1564
+ if not path.is_file():
1565
+ continue
1566
+
1567
+ # Skip hidden and ignored directories
1568
+ parts = path.parts
1569
+ if any(
1570
+ p.startswith(".")
1571
+ and p not in {".env", ".gitignore", ".dockerignore"}
1572
+ or p in {"node_modules", "__pycache__", "venv", ".venv", "dist", "build"}
1573
+ for p in parts
1574
+ ):
1575
+ continue
1576
+
1577
+ # Only search code files
1578
+ if path.suffix.lower() not in code_extensions:
1579
+ continue
1580
+
1581
+ # Skip large files
1582
+ try:
1583
+ if path.stat().st_size > 500000: # 500KB limit
1584
+ continue
1585
+ except Exception:
1586
+ continue
1587
+
1588
+ # Search file content
1589
+ try:
1590
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
1591
+ for line_no, line in enumerate(f, 1):
1592
+ line_lower = line.lower()
1593
+ idx = line_lower.find(query_lower)
1594
+ if idx != -1:
1595
+ results.append(
1596
+ CodeSearchResult(
1597
+ path=path,
1598
+ line_no=line_no,
1599
+ line_content=line.rstrip()[:200], # Limit line length
1600
+ match_start=idx,
1601
+ match_end=idx + len(query),
1602
+ )
1603
+ )
1604
+ if len(results) >= max_results:
1605
+ return results
1606
+ except Exception:
1607
+ continue
1608
+
1609
+ except Exception:
1610
+ pass
1611
+
1612
+ return results
1613
+
1614
+
1615
+ class CodeSearchResults(Container):
1616
+ """Container showing code search results."""
1617
+
1618
+ DEFAULT_CSS = """
1619
+ CodeSearchResults {
1620
+ height: auto;
1621
+ max-height: 20;
1622
+ background: #000000;
1623
+ padding: 0;
1624
+ display: none;
1625
+ }
1626
+
1627
+ CodeSearchResults.visible {
1628
+ display: block;
1629
+ }
1630
+ """
1631
+
1632
+ results: reactive[List[CodeSearchResult]] = reactive(list)
1633
+ selected_index: reactive[int] = reactive(0)
1634
+
1635
+ class ResultSelected(Message):
1636
+ """Message when a search result is selected."""
1637
+
1638
+ def __init__(self, result: CodeSearchResult) -> None:
1639
+ self.result = result
1640
+ super().__init__()
1641
+
1642
+ def compose(self) -> ComposeResult:
1643
+ """Compose search results."""
1644
+ yield Static(self._render_results(), id="code-results-content")
1645
+
1646
+ def watch_results(self, results: List[CodeSearchResult]) -> None:
1647
+ """Update when results change."""
1648
+ self.selected_index = 0
1649
+ try:
1650
+ self.query_one("#code-results-content", Static).update(self._render_results())
1651
+ except Exception:
1652
+ pass
1653
+
1654
+ def watch_selected_index(self, index: int) -> None:
1655
+ """Update when selection changes."""
1656
+ try:
1657
+ self.query_one("#code-results-content", Static).update(self._render_results())
1658
+ except Exception:
1659
+ pass
1660
+
1661
+ def _render_results(self) -> Text:
1662
+ """Render search results."""
1663
+ t = Text()
1664
+
1665
+ if not self.results:
1666
+ t.append(" No matches found", style="italic #52525b")
1667
+ return t
1668
+
1669
+ # Group by file
1670
+ current_file = None
1671
+ display_count = 0
1672
+
1673
+ for i, result in enumerate(self.results[:30]): # Show max 30 results
1674
+ # File header
1675
+ if result.path != current_file:
1676
+ if current_file is not None:
1677
+ t.append("\n", style="")
1678
+ current_file = result.path
1679
+
1680
+ # File icon and path
1681
+ icon, color = get_file_icon(result.path)
1682
+ rel_path = (
1683
+ str(result.path)[-50:] if len(str(result.path)) > 50 else str(result.path)
1684
+ )
1685
+ if len(str(result.path)) > 50:
1686
+ rel_path = "..." + rel_path
1687
+ t.append(f" {icon} ", style=color)
1688
+ t.append(rel_path + "\n", style="bold #a1a1aa")
1689
+
1690
+ # Result line
1691
+ if i == self.selected_index:
1692
+ t.append(" ▸ ", style="bold #a855f7")
1693
+ else:
1694
+ t.append(" ", style="")
1695
+
1696
+ # Line number
1697
+ t.append(f"{result.line_no:>4}:", style="#52525b")
1698
+
1699
+ # Line content with highlight
1700
+ line = result.line_content[:80]
1701
+ if result.match_start < len(line):
1702
+ # Before match
1703
+ t.append(line[: result.match_start], style="#71717a")
1704
+ # Match (highlighted)
1705
+ match_end = min(result.match_end, len(line))
1706
+ t.append(line[result.match_start : match_end], style="bold #fbbf24 on #1a1a1a")
1707
+ # After match
1708
+ t.append(line[match_end:], style="#71717a")
1709
+ else:
1710
+ t.append(line, style="#71717a")
1711
+
1712
+ t.append("\n", style="")
1713
+ display_count += 1
1714
+
1715
+ if len(self.results) > 30:
1716
+ t.append(f"\n +{len(self.results) - 30} more results", style="#52525b")
1717
+
1718
+ return t
1719
+
1720
+ def move_selection(self, delta: int) -> None:
1721
+ """Move selection up or down."""
1722
+ if self.results:
1723
+ max_idx = min(len(self.results), 30) - 1
1724
+ new_index = max(0, min(self.selected_index + delta, max_idx))
1725
+ self.selected_index = new_index
1726
+
1727
+ def get_selected(self) -> Optional[CodeSearchResult]:
1728
+ """Get the selected result."""
1729
+ if self.results and 0 <= self.selected_index < len(self.results):
1730
+ return self.results[self.selected_index]
1731
+ return None
1732
+
1733
+
1734
+ class CodebaseSearch(Container):
1735
+ """Codebase search widget - grep through file contents."""
1736
+
1737
+ DEFAULT_CSS = """
1738
+ CodebaseSearch {
1739
+ height: auto;
1740
+ background: #000000;
1741
+ padding: 0;
1742
+ }
1743
+
1744
+ CodebaseSearch #code-search-input {
1745
+ height: 1;
1746
+ background: #0a0a0a;
1747
+ border: none;
1748
+ padding: 0 1;
1749
+ margin: 0;
1750
+ }
1751
+
1752
+ CodebaseSearch #code-search-input:focus {
1753
+ border: none;
1754
+ }
1755
+
1756
+ CodebaseSearch #search-status {
1757
+ height: 1;
1758
+ padding: 0 1;
1759
+ color: #52525b;
1760
+ }
1761
+ """
1762
+
1763
+ BINDINGS = [
1764
+ Binding("escape", "close_search", "Close", show=False),
1765
+ Binding("up", "move_up", "Up", show=False),
1766
+ Binding("down", "move_down", "Down", show=False),
1767
+ Binding("enter", "select_result", "Select", show=False),
1768
+ ]
1769
+
1770
+ _searching: bool = False
1771
+
1772
+ class ResultSelected(Message):
1773
+ """Message when a result is selected."""
1774
+
1775
+ def __init__(self, path: Path, line_no: int) -> None:
1776
+ self.path = path
1777
+ self.line_no = line_no
1778
+ super().__init__()
1779
+
1780
+ class SearchClosed(Message):
1781
+ """Message when search is closed."""
1782
+
1783
+ pass
1784
+
1785
+ def __init__(self, root_path: Path, **kwargs):
1786
+ super().__init__(**kwargs)
1787
+ self.root_path = root_path
1788
+ self._searching = False
1789
+ self._last_query = ""
1790
+
1791
+ def compose(self) -> ComposeResult:
1792
+ """Compose the search widget."""
1793
+ yield Input(placeholder="🔎 Search in files...", id="code-search-input")
1794
+ yield Static("", id="search-status")
1795
+ yield CodeSearchResults(id="code-search-results")
1796
+
1797
+ @on(Input.Changed, "#code-search-input")
1798
+ def on_search_changed(self, event: Input.Changed) -> None:
1799
+ """Handle search input changes."""
1800
+ query = event.value.strip()
1801
+
1802
+ if not query or len(query) < 2:
1803
+ self.query_one("#code-search-results", CodeSearchResults).results = []
1804
+ self.query_one("#code-search-results", CodeSearchResults).remove_class("visible")
1805
+ self.query_one("#search-status", Static).update("")
1806
+ return
1807
+
1808
+ if query != self._last_query:
1809
+ self._last_query = query
1810
+ self._do_search(query)
1811
+
1812
+ @work(thread=True)
1813
+ def _do_search(self, query: str) -> None:
1814
+ """Perform search in background."""
1815
+ self._searching = True
1816
+ self.app.call_from_thread(self._update_status, "Searching...")
1817
+
1818
+ results = search_codebase(self.root_path, query)
1819
+
1820
+ self.app.call_from_thread(self._show_results, results)
1821
+
1822
+ def _update_status(self, status: str) -> None:
1823
+ """Update status text."""
1824
+ try:
1825
+ self.query_one("#search-status", Static).update(status)
1826
+ except Exception:
1827
+ pass
1828
+
1829
+ def _show_results(self, results: List[CodeSearchResult]) -> None:
1830
+ """Show search results."""
1831
+ self._searching = False
1832
+ try:
1833
+ results_widget = self.query_one("#code-search-results", CodeSearchResults)
1834
+ results_widget.results = results
1835
+
1836
+ if results:
1837
+ results_widget.add_class("visible")
1838
+ self._update_status(f"Found {len(results)} matches")
1839
+ else:
1840
+ results_widget.remove_class("visible")
1841
+ self._update_status("No matches found")
1842
+ except Exception:
1843
+ pass
1844
+
1845
+ def action_close_search(self) -> None:
1846
+ """Close the search."""
1847
+ self.query_one("#code-search-input", Input).value = ""
1848
+ self.query_one("#code-search-results", CodeSearchResults).results = []
1849
+ self.query_one("#code-search-results", CodeSearchResults).remove_class("visible")
1850
+ self.query_one("#search-status", Static).update("")
1851
+ self._last_query = ""
1852
+ self.post_message(self.SearchClosed())
1853
+
1854
+ def action_move_up(self) -> None:
1855
+ """Move selection up."""
1856
+ self.query_one("#code-search-results", CodeSearchResults).move_selection(-1)
1857
+
1858
+ def action_move_down(self) -> None:
1859
+ """Move selection down."""
1860
+ self.query_one("#code-search-results", CodeSearchResults).move_selection(1)
1861
+
1862
+ def action_select_result(self) -> None:
1863
+ """Select the current result."""
1864
+ results = self.query_one("#code-search-results", CodeSearchResults)
1865
+ result = results.get_selected()
1866
+ if result:
1867
+ self.post_message(self.ResultSelected(result.path, result.line_no))
1868
+ self.action_close_search()
1869
+
1870
+
1871
+ # ============================================================================
1872
+ # TABBED SIDEBAR (Clean Tab-based Design)
1873
+ # ============================================================================
1874
+
1875
+
1876
+ class SidebarTabs(Container):
1877
+ """Tab bar for switching between sidebar views.
1878
+
1879
+ Tabs: Files, Code, Changes, Search, Agent, Context, Diff, History
1880
+ Uses minimal SuperQode icons instead of emojis.
1881
+ """
1882
+
1883
+ DEFAULT_CSS = """
1884
+ SidebarTabs {
1885
+ height: 2;
1886
+ width: 100%;
1887
+ layout: horizontal;
1888
+ background: #000000;
1889
+ border-bottom: solid #1a1a1a;
1890
+ overflow-x: auto;
1891
+ }
1892
+
1893
+ SidebarTabs .tab {
1894
+ width: auto;
1895
+ min-width: 4;
1896
+ height: 100%;
1897
+ content-align: center middle;
1898
+ text-align: center;
1899
+ background: #000000;
1900
+ color: #71717a;
1901
+ padding: 0 1;
1902
+ }
1903
+
1904
+ SidebarTabs .tab:hover {
1905
+ background: #0a0a0a;
1906
+ color: #a1a1aa;
1907
+ }
1908
+
1909
+ SidebarTabs .tab.active {
1910
+ background: #0a0a0a;
1911
+ color: #a855f7;
1912
+ border-bottom: solid #a855f7;
1913
+ }
1914
+ """
1915
+
1916
+ # All available tabs with SuperQode icons - Colorful symbols
1917
+ TABS = {
1918
+ "files": "📁", # Files
1919
+ "code": "◇", # Code preview
1920
+ "changes": "⟳", # Git changes
1921
+ "search": "⌕", # Search
1922
+ "agent": "◈", # Agent info
1923
+ "context": "↳", # Context
1924
+ "diff": "±", # Diff
1925
+ "history": "◇", # History
1926
+ }
1927
+
1928
+ # Tab hints for hover
1929
+ TAB_HINTS = {
1930
+ "files": "Project Files",
1931
+ "code": "Code Preview",
1932
+ "changes": "Git Changes",
1933
+ "search": "Search Files",
1934
+ "agent": "Agent Info",
1935
+ "context": "Context",
1936
+ "diff": "File Diff",
1937
+ "history": "History",
1938
+ }
1939
+
1940
+ active_tab: reactive[str] = reactive("files")
1941
+
1942
+ class TabChanged(Message):
1943
+ """Message when tab changes."""
1944
+
1945
+ def __init__(self, tab: str) -> None:
1946
+ self.tab = tab
1947
+ super().__init__()
1948
+
1949
+ def compose(self) -> ComposeResult:
1950
+ """Compose tab bar with all tabs."""
1951
+ # Primary tabs (always shown) - Colorful symbols with hints
1952
+ yield Static(self.TABS["files"], id="tab-files", classes="tab active")
1953
+ yield Static(self.TABS["code"], id="tab-code", classes="tab")
1954
+ yield Static(self.TABS["changes"], id="tab-changes", classes="tab")
1955
+ yield Static(self.TABS["search"], id="tab-search", classes="tab")
1956
+ yield Static(self.TABS["agent"], id="tab-agent", classes="tab")
1957
+ yield Static(self.TABS["context"], id="tab-context", classes="tab")
1958
+ yield Static(self.TABS["diff"], id="tab-diff", classes="tab")
1959
+ yield Static(self.TABS["history"], id="tab-history", classes="tab")
1960
+
1961
+ def on_mount(self) -> None:
1962
+ """Set tooltips after widgets are mounted."""
1963
+ for tab_name in self.TABS:
1964
+ try:
1965
+ tab_widget = self.query_one(f"#tab-{tab_name}", Static)
1966
+ hint = self.TAB_HINTS.get(tab_name, "")
1967
+ if hint:
1968
+ tab_widget.tooltip = hint
1969
+ except Exception:
1970
+ pass
1971
+
1972
+ def on_click(self, event) -> None:
1973
+ """Handle tab clicks."""
1974
+ widget_id = event.widget.id
1975
+ if widget_id and widget_id.startswith("tab-"):
1976
+ tab_name = widget_id[4:] # Remove "tab-" prefix
1977
+ if tab_name in self.TABS:
1978
+ self.active_tab = tab_name
1979
+
1980
+ def watch_active_tab(self, tab: str) -> None:
1981
+ """Update tab styles when active tab changes."""
1982
+ try:
1983
+ # Remove active class from all tabs
1984
+ for tab_name in self.TABS:
1985
+ try:
1986
+ tab_widget = self.query_one(f"#tab-{tab_name}", Static)
1987
+ tab_widget.remove_class("active")
1988
+ except Exception:
1989
+ pass
1990
+
1991
+ # Add active class to selected tab
1992
+ try:
1993
+ active_widget = self.query_one(f"#tab-{tab}", Static)
1994
+ active_widget.add_class("active")
1995
+ except Exception:
1996
+ pass
1997
+
1998
+ self.post_message(self.TabChanged(tab))
1999
+ except Exception:
2000
+ pass
2001
+
2002
+ def select_tab(self, tab: str) -> None:
2003
+ """Programmatically select a tab."""
2004
+ if tab in self.TABS:
2005
+ self.active_tab = tab
2006
+
2007
+
2008
+ # ============================================================================
2009
+ # GIT CHANGES VIEW
2010
+ # ============================================================================
2011
+
2012
+
2013
+ @dataclass
2014
+ class GitChange:
2015
+ """A single git change entry."""
2016
+
2017
+ path: str
2018
+ status: str # M=modified, A=added, D=deleted, ?=untracked, R=renamed
2019
+ staged: bool = False
2020
+
2021
+
2022
+ def get_git_changes(root_path: Path) -> List[GitChange]:
2023
+ """Get list of changed files from git."""
2024
+ changes = []
2025
+ try:
2026
+ result = subprocess.run(
2027
+ ["git", "status", "--porcelain"],
2028
+ cwd=root_path,
2029
+ capture_output=True,
2030
+ text=True,
2031
+ timeout=5,
2032
+ )
2033
+ if result.returncode == 0:
2034
+ for line in result.stdout.strip().split("\n"):
2035
+ if not line:
2036
+ continue
2037
+ status = line[:2]
2038
+ path = line[3:]
2039
+
2040
+ # Parse status
2041
+ staged = status[0] != " " and status[0] != "?"
2042
+ if status[0] in "MADRCU":
2043
+ changes.append(GitChange(path=path, status=status[0], staged=True))
2044
+ if status[1] in "MD":
2045
+ changes.append(GitChange(path=path, status=status[1], staged=False))
2046
+ if status == "??":
2047
+ changes.append(GitChange(path=path, status="?", staged=False))
2048
+ except Exception:
2049
+ pass
2050
+ return changes
2051
+
2052
+
2053
+ def get_file_diff(root_path: Path, file_path: str, staged: bool = False) -> str:
2054
+ """Get diff for a specific file."""
2055
+ try:
2056
+ cmd = ["git", "diff"]
2057
+ if staged:
2058
+ cmd.append("--cached")
2059
+ cmd.append("--")
2060
+ cmd.append(file_path)
2061
+
2062
+ result = subprocess.run(cmd, cwd=root_path, capture_output=True, text=True, timeout=10)
2063
+ if result.returncode == 0:
2064
+ return result.stdout
2065
+ except Exception:
2066
+ pass
2067
+ return ""
2068
+
2069
+
2070
+ class GitChangesPanel(Container):
2071
+ """Panel showing git changes with diffs."""
2072
+
2073
+ DEFAULT_CSS = """
2074
+ GitChangesPanel {
2075
+ height: 100%;
2076
+ width: 100%;
2077
+ background: #000000;
2078
+ layout: vertical;
2079
+ }
2080
+
2081
+ GitChangesPanel #changes-header {
2082
+ height: 2;
2083
+ background: #0a0a0a;
2084
+ border-bottom: solid #1a1a1a;
2085
+ padding: 0 1;
2086
+ }
2087
+
2088
+ GitChangesPanel #changes-list {
2089
+ height: 1fr;
2090
+ background: #000000;
2091
+ overflow-y: auto;
2092
+ scrollbar-size: 1 1;
2093
+ }
2094
+
2095
+ GitChangesPanel .change-item {
2096
+ height: 1;
2097
+ padding: 0 1;
2098
+ background: #000000;
2099
+ }
2100
+
2101
+ GitChangesPanel .change-item:hover {
2102
+ background: #0a0a0a;
2103
+ }
2104
+
2105
+ GitChangesPanel .change-item.selected {
2106
+ background: #a855f720;
2107
+ }
2108
+
2109
+ GitChangesPanel #diff-view {
2110
+ height: 1fr;
2111
+ background: #000000;
2112
+ border-top: solid #1a1a1a;
2113
+ overflow-y: auto;
2114
+ overflow-x: auto;
2115
+ scrollbar-size: 1 1;
2116
+ display: none;
2117
+ }
2118
+
2119
+ GitChangesPanel #diff-view.visible {
2120
+ display: block;
2121
+ }
2122
+
2123
+ GitChangesPanel #no-changes {
2124
+ height: 100%;
2125
+ content-align: center middle;
2126
+ text-align: center;
2127
+ color: #52525b;
2128
+ }
2129
+
2130
+ GitChangesPanel #loading {
2131
+ height: 100%;
2132
+ content-align: center middle;
2133
+ text-align: center;
2134
+ color: #71717a;
2135
+ }
2136
+ """
2137
+
2138
+ changes: reactive[List[GitChange]] = reactive(list)
2139
+ selected_index: reactive[int] = reactive(-1)
2140
+ _loading: bool = True
2141
+
2142
+ class FileSelected(Message):
2143
+ """Message when a file is selected for diff viewing."""
2144
+
2145
+ def __init__(self, path: str, staged: bool) -> None:
2146
+ self.path = path
2147
+ self.staged = staged
2148
+ super().__init__()
2149
+
2150
+ def __init__(self, root_path: Path, **kwargs):
2151
+ super().__init__(**kwargs)
2152
+ self.root_path = root_path
2153
+ self._loading = True
2154
+ self._current_diff = ""
2155
+
2156
+ def compose(self) -> ComposeResult:
2157
+ """Compose the changes panel."""
2158
+ yield Static(self._render_header(), id="changes-header")
2159
+ yield Static("Loading changes...", id="loading")
2160
+ with ScrollableContainer(id="changes-list"):
2161
+ yield Static("", id="changes-content")
2162
+ with ScrollableContainer(id="diff-view"):
2163
+ yield Static("", id="diff-content")
2164
+ yield Static("✓ No changes\nWorking tree clean", id="no-changes")
2165
+
2166
+ def on_mount(self) -> None:
2167
+ """Load changes on mount."""
2168
+ self.refresh_changes()
2169
+
2170
+ @work(thread=True)
2171
+ def refresh_changes(self) -> None:
2172
+ """Refresh git changes in background."""
2173
+ changes = get_git_changes(self.root_path)
2174
+ self.app.call_from_thread(self._update_changes, changes)
2175
+
2176
+ def _update_changes(self, changes: List[GitChange]) -> None:
2177
+ """Update changes from thread."""
2178
+ self._loading = False
2179
+ self.changes = changes
2180
+
2181
+ # Reset selection if current selection is out of bounds
2182
+ if self.selected_index >= len(self.changes):
2183
+ self.selected_index = -1
2184
+
2185
+ self._update_ui()
2186
+
2187
+ def _update_ui(self) -> None:
2188
+ """Update UI based on current state."""
2189
+ try:
2190
+ loading = self.query_one("#loading", Static)
2191
+ no_changes = self.query_one("#no-changes", Static)
2192
+ changes_list = self.query_one("#changes-list", ScrollableContainer)
2193
+ diff_view = self.query_one("#diff-view", ScrollableContainer)
2194
+
2195
+ loading.display = False
2196
+
2197
+ if not self.changes:
2198
+ no_changes.display = True
2199
+ changes_list.display = False
2200
+ # Hide diff view when no changes
2201
+ diff_view.remove_class("visible")
2202
+ self.selected_index = -1
2203
+ else:
2204
+ no_changes.display = False
2205
+ changes_list.display = True
2206
+ self.query_one("#changes-content", Static).update(self._render_changes())
2207
+
2208
+ # If we have a valid selection, ensure diff is loaded
2209
+ if 0 <= self.selected_index < len(self.changes):
2210
+ change = self.changes[self.selected_index]
2211
+ self._load_diff(change.path, change.staged)
2212
+ else:
2213
+ # Clear diff view if no valid selection
2214
+ diff_view.remove_class("visible")
2215
+ except Exception:
2216
+ pass
2217
+
2218
+ def _render_header(self) -> Text:
2219
+ """Render the header."""
2220
+ t = Text()
2221
+ t.append("\n", style="")
2222
+ t.append("📊 Git Changes", style="bold #a855f7")
2223
+ t.append(" ", style="")
2224
+ t.append("r", style="bold #52525b")
2225
+ t.append(" refresh", style="#3f3f46")
2226
+ return t
2227
+
2228
+ def _render_changes(self) -> Text:
2229
+ """Render the changes list."""
2230
+ t = Text()
2231
+
2232
+ # Group by staged/unstaged
2233
+ staged = [c for c in self.changes if c.staged]
2234
+ unstaged = [c for c in self.changes if not c.staged]
2235
+
2236
+ if staged:
2237
+ t.append(" Staged Changes\n", style="bold #22c55e")
2238
+ for i, change in enumerate(staged):
2239
+ self._render_change_item(t, change, i)
2240
+
2241
+ if unstaged:
2242
+ if staged:
2243
+ t.append("\n", style="")
2244
+ t.append(" Unstaged Changes\n", style="bold #f97316")
2245
+ for i, change in enumerate(unstaged, len(staged)):
2246
+ self._render_change_item(t, change, i)
2247
+
2248
+ return t
2249
+
2250
+ def _render_change_item(self, t: Text, change: GitChange, index: int) -> None:
2251
+ """Render a single change item."""
2252
+ # Selection indicator
2253
+ if index == self.selected_index:
2254
+ t.append(" ▸ ", style="bold #a855f7")
2255
+ else:
2256
+ t.append(" ", style="")
2257
+
2258
+ # Status icon
2259
+ if change.status == "M":
2260
+ t.append("● ", style="bold #f97316")
2261
+ elif change.status == "A":
2262
+ t.append("+ ", style="bold #22c55e")
2263
+ elif change.status == "D":
2264
+ t.append("- ", style="bold #ef4444")
2265
+ elif change.status == "?":
2266
+ t.append("? ", style="#71717a")
2267
+ elif change.status == "R":
2268
+ t.append("→ ", style="bold #06b6d4")
2269
+ else:
2270
+ t.append(" ", style="")
2271
+
2272
+ # File path - show full path (no truncation)
2273
+ path = change.path
2274
+
2275
+ if index == self.selected_index:
2276
+ t.append(path, style="bold white")
2277
+ else:
2278
+ t.append(path, style="#a1a1aa")
2279
+
2280
+ t.append("\n", style="")
2281
+
2282
+ def watch_changes(self, changes: List[GitChange]) -> None:
2283
+ """Update when changes change."""
2284
+ self._update_ui()
2285
+
2286
+ def watch_selected_index(self, index: int) -> None:
2287
+ """Update when selection changes."""
2288
+ try:
2289
+ self.query_one("#changes-content", Static).update(self._render_changes())
2290
+
2291
+ # Load diff for selected file
2292
+ if 0 <= index < len(self.changes):
2293
+ change = self.changes[index]
2294
+ self._load_diff(change.path, change.staged)
2295
+ except Exception:
2296
+ pass
2297
+
2298
+ @work(thread=True)
2299
+ def _load_diff(self, path: str, staged: bool) -> None:
2300
+ """Load diff for a file in background."""
2301
+ diff = get_file_diff(self.root_path, path, staged)
2302
+ self.app.call_from_thread(self._show_diff, diff)
2303
+
2304
+ def _show_diff(self, diff: str) -> None:
2305
+ """Show diff content."""
2306
+ try:
2307
+ diff_view = self.query_one("#diff-view", ScrollableContainer)
2308
+ diff_content = self.query_one("#diff-content", Static)
2309
+
2310
+ if diff:
2311
+ diff_view.add_class("visible")
2312
+ diff_content.update(self._render_diff(diff))
2313
+ else:
2314
+ diff_view.remove_class("visible")
2315
+ except Exception:
2316
+ pass
2317
+
2318
+ def _render_diff(self, diff: str) -> Text:
2319
+ """Render diff content with colors."""
2320
+ t = Text()
2321
+
2322
+ for line in diff.split("\n"):
2323
+ if line.startswith("+++") or line.startswith("---"):
2324
+ t.append(line + "\n", style="bold #71717a")
2325
+ elif line.startswith("@@"):
2326
+ t.append(line + "\n", style="bold #06b6d4")
2327
+ elif line.startswith("+"):
2328
+ t.append(line + "\n", style="#22c55e on #22c55e15")
2329
+ elif line.startswith("-"):
2330
+ t.append(line + "\n", style="#ef4444 on #ef444415")
2331
+ else:
2332
+ t.append(line + "\n", style="#a1a1aa")
2333
+
2334
+ return t
2335
+
2336
+ def on_click(self, event) -> None:
2337
+ """Handle clicks on change items."""
2338
+ # Calculate which item was clicked based on y position
2339
+ try:
2340
+ changes_list = self.query_one("#changes-list", ScrollableContainer)
2341
+ if changes_list in event.widget.ancestors or event.widget == changes_list:
2342
+ # Rough calculation of clicked index
2343
+ y = event.y - 3 # Offset for header
2344
+ if 0 <= y < len(self.changes) + 4: # Account for section headers
2345
+ self.selected_index = max(0, min(y - 1, len(self.changes) - 1))
2346
+ except Exception:
2347
+ pass
2348
+
2349
+ def action_refresh(self) -> None:
2350
+ """Refresh changes."""
2351
+ self._loading = True
2352
+ try:
2353
+ self.query_one("#loading", Static).display = True
2354
+ except Exception:
2355
+ pass
2356
+ self.refresh_changes()
2357
+
2358
+ def select_next(self) -> None:
2359
+ """Select next change."""
2360
+ if self.changes:
2361
+ self.selected_index = (self.selected_index + 1) % len(self.changes)
2362
+
2363
+ def select_prev(self) -> None:
2364
+ """Select previous change."""
2365
+ if self.changes:
2366
+ self.selected_index = (self.selected_index - 1) % len(self.changes)
2367
+
2368
+ def highlight_files(self, files: List[str]) -> None:
2369
+ """Highlight specified files in the changes list.
2370
+
2371
+ This method should be called after refresh_changes() completes.
2372
+ It will select the first matching file and load its diff.
2373
+ """
2374
+ # Clear previous selection
2375
+ self.selected_index = -1
2376
+
2377
+ # Find and select the first file to highlight
2378
+ for i, change in enumerate(self.changes):
2379
+ if change.path in files:
2380
+ self.selected_index = i
2381
+ # This will trigger watch_selected_index which loads the diff
2382
+ break
2383
+
2384
+ # If no match found but we have changes, select the first one
2385
+ if self.selected_index == -1 and self.changes:
2386
+ self.selected_index = 0
2387
+
2388
+
2389
+ class CollapsibleSidebar(Container):
2390
+ """
2391
+ Clean tabbed sidebar with Files, Code, and Changes views.
2392
+
2393
+ Features:
2394
+ - Git status indicator (always visible)
2395
+ - File search (Ctrl+F)
2396
+ - Tab switching: Files | Code | Changes
2397
+ - Click file to view code in sidebar
2398
+ - Git diff view for changed files
2399
+ - Dark black background
2400
+ """
2401
+
2402
+ DEFAULT_CSS = """
2403
+ CollapsibleSidebar {
2404
+ width: 100%;
2405
+ height: 100%;
2406
+ layout: vertical;
2407
+ background: #000000;
2408
+ }
2409
+
2410
+ CollapsibleSidebar #sidebar-header {
2411
+ height: auto;
2412
+ background: #000000;
2413
+ padding: 0;
2414
+ }
2415
+
2416
+ CollapsibleSidebar .sidebar-title {
2417
+ height: 2;
2418
+ padding: 0 1;
2419
+ text-align: left;
2420
+ background: #000000;
2421
+ }
2422
+
2423
+ CollapsibleSidebar #git-status {
2424
+ height: 2;
2425
+ background: #000000;
2426
+ border-bottom: solid #1a1a1a;
2427
+ }
2428
+
2429
+ CollapsibleSidebar #file-search {
2430
+ background: #000000;
2431
+ border-bottom: solid #1a1a1a;
2432
+ }
2433
+
2434
+ CollapsibleSidebar #file-search.-hidden {
2435
+ display: none;
2436
+ }
2437
+
2438
+ CollapsibleSidebar #sidebar-content {
2439
+ height: 1fr;
2440
+ background: #000000;
2441
+ }
2442
+
2443
+ CollapsibleSidebar #files-view {
2444
+ height: 100%;
2445
+ width: 100%;
2446
+ background: #000000;
2447
+ }
2448
+
2449
+ CollapsibleSidebar #files-view.-hidden {
2450
+ display: none;
2451
+ }
2452
+
2453
+ CollapsibleSidebar #code-view {
2454
+ height: 100%;
2455
+ width: 100%;
2456
+ background: #000000;
2457
+ display: none;
2458
+ }
2459
+
2460
+ CollapsibleSidebar #code-view.visible {
2461
+ display: block;
2462
+ }
2463
+
2464
+ CollapsibleSidebar #changes-view {
2465
+ height: 100%;
2466
+ width: 100%;
2467
+ background: #000000;
2468
+ display: none;
2469
+ }
2470
+
2471
+ CollapsibleSidebar #changes-view.visible {
2472
+ display: block;
2473
+ }
2474
+
2475
+ CollapsibleSidebar #search-view {
2476
+ height: 100%;
2477
+ width: 100%;
2478
+ background: #000000;
2479
+ display: none;
2480
+ }
2481
+
2482
+ CollapsibleSidebar #search-view.visible {
2483
+ display: block;
2484
+ }
2485
+
2486
+ CollapsibleSidebar #file-tree {
2487
+ height: 100%;
2488
+ background: #000000;
2489
+ scrollbar-size: 1 1;
2490
+ }
2491
+
2492
+ CollapsibleSidebar #file-preview {
2493
+ height: 100%;
2494
+ background: #000000;
2495
+ }
2496
+
2497
+ CollapsibleSidebar ColorfulDirectoryTree {
2498
+ background: #000000;
2499
+ }
2500
+
2501
+ CollapsibleSidebar ColorfulDirectoryTree > .tree--guides {
2502
+ color: #1a1a1a;
2503
+ }
2504
+
2505
+ CollapsibleSidebar ColorfulDirectoryTree > .tree--cursor {
2506
+ background: #3f3f46;
2507
+ color: #ec4899;
2508
+ text-style: bold;
2509
+ border-left: tall #a855f7;
2510
+ }
2511
+
2512
+ CollapsibleSidebar ColorfulDirectoryTree:focus > .tree--cursor {
2513
+ background: #52525b;
2514
+ color: #ec4899;
2515
+ text-style: bold;
2516
+ border-left: tall #a855f7;
2517
+ }
2518
+
2519
+ CollapsibleSidebar FilePreview {
2520
+ background: #000000;
2521
+ }
2522
+
2523
+ CollapsibleSidebar #preview-header {
2524
+ background: #000000;
2525
+ }
2526
+
2527
+ CollapsibleSidebar #preview-content {
2528
+ background: #000000;
2529
+ }
2530
+
2531
+ CollapsibleSidebar #preview-hints {
2532
+ background: #000000;
2533
+ }
2534
+
2535
+ CollapsibleSidebar GitChangesPanel {
2536
+ background: #000000;
2537
+ }
2538
+
2539
+ /* New panel views - hidden by default */
2540
+ CollapsibleSidebar #agent-view {
2541
+ height: 100%;
2542
+ width: 100%;
2543
+ background: #000000;
2544
+ display: none;
2545
+ }
2546
+
2547
+ CollapsibleSidebar #agent-view.visible {
2548
+ display: block;
2549
+ }
2550
+
2551
+ CollapsibleSidebar #context-view {
2552
+ height: 100%;
2553
+ width: 100%;
2554
+ background: #000000;
2555
+ display: none;
2556
+ }
2557
+
2558
+ CollapsibleSidebar #context-view.visible {
2559
+ display: block;
2560
+ }
2561
+
2562
+
2563
+ CollapsibleSidebar #diff-view {
2564
+ height: 100%;
2565
+ width: 100%;
2566
+ background: #000000;
2567
+ display: none;
2568
+ }
2569
+
2570
+ CollapsibleSidebar #diff-view.visible {
2571
+ display: block;
2572
+ }
2573
+
2574
+ CollapsibleSidebar #history-view {
2575
+ height: 100%;
2576
+ width: 100%;
2577
+ background: #000000;
2578
+ display: none;
2579
+ }
2580
+
2581
+ CollapsibleSidebar #history-view.visible {
2582
+ display: block;
2583
+ }
2584
+
2585
+ /* QE Dashboard View */
2586
+ CollapsibleSidebar #qe-view {
2587
+ height: 100%;
2588
+ width: 100%;
2589
+ background: #000000;
2590
+ display: none;
2591
+ layout: vertical;
2592
+ padding: 1;
2593
+ }
2594
+
2595
+ CollapsibleSidebar #qe-view.visible {
2596
+ display: block;
2597
+ }
2598
+
2599
+ CollapsibleSidebar #qe-dashboard {
2600
+ height: auto;
2601
+ width: 100%;
2602
+ }
2603
+
2604
+ CollapsibleSidebar #qe-dashboard-fallback {
2605
+ height: 100%;
2606
+ width: 100%;
2607
+ content-align: center middle;
2608
+ text-align: center;
2609
+ color: #71717a;
2610
+ }
2611
+ """
2612
+
2613
+ BINDINGS = [
2614
+ Binding("ctrl+f", "toggle_search", "Search", show=True),
2615
+ Binding("escape", "dismiss", "Close", show=False),
2616
+ Binding("f", "show_files", "Files", show=False),
2617
+ Binding("c", "show_code", "Code", show=False),
2618
+ Binding("g", "show_changes", "Changes", show=False),
2619
+ Binding("s", "show_search", "Search", show=False),
2620
+ Binding("r", "refresh_changes", "Refresh", show=False),
2621
+ Binding("a", "show_agent", "Agent", show=False),
2622
+ Binding("x", "show_context", "Context", show=False),
2623
+ Binding("d", "show_diff", "Diff", show=False),
2624
+ Binding("h", "show_history", "History", show=False),
2625
+ ]
2626
+
2627
+ current_view: reactive[str] = reactive("files")
2628
+
2629
+ # All available views
2630
+ VIEWS = ["files", "code", "changes", "search", "agent", "context", "diff", "history"]
2631
+
2632
+ class FileOpened(Message):
2633
+ """Message sent when a file should be opened/viewed."""
2634
+
2635
+ def __init__(self, path: Path) -> None:
2636
+ self.path = path
2637
+ super().__init__()
2638
+
2639
+ class Dismiss(Message):
2640
+ """Message to dismiss/close the sidebar."""
2641
+
2642
+ pass
2643
+
2644
+ def __init__(
2645
+ self,
2646
+ path: Path | str = ".",
2647
+ name: str | None = None,
2648
+ id: str | None = None,
2649
+ classes: str | None = None,
2650
+ ):
2651
+ super().__init__(name=name, id=id, classes=classes)
2652
+ self.root_path = Path(path).resolve()
2653
+ self._current_file: Optional[Path] = None
2654
+
2655
+ def compose(self) -> ComposeResult:
2656
+ """Compose the sidebar layout with all panels."""
2657
+ # Import new panels (lazy import to avoid circular deps)
2658
+ from superqode.widgets.sidebar_panels import (
2659
+ AgentPanel,
2660
+ ContextPanel,
2661
+ TerminalPanel,
2662
+ DiffPanel,
2663
+ HistoryPanel,
2664
+ )
2665
+
2666
+ # Header with title
2667
+ with Container(id="sidebar-header"):
2668
+ yield Static(self._render_title(), classes="sidebar-title")
2669
+ yield GitStatusWidget(self.root_path, id="git-status")
2670
+
2671
+ # File search (hidden by default)
2672
+ yield FileSearch(self.root_path, id="file-search", classes="-hidden")
2673
+
2674
+ # Tab bar
2675
+ yield SidebarTabs(id="sidebar-tabs")
2676
+
2677
+ # Content area
2678
+ with Container(id="sidebar-content"):
2679
+ # Files view (default)
2680
+ with Container(id="files-view"):
2681
+ yield ColorfulDirectoryTree(self.root_path, id="file-tree")
2682
+
2683
+ # Code view (hidden by default)
2684
+ with Container(id="code-view"):
2685
+ yield FilePreview(id="file-preview")
2686
+
2687
+ # Changes view (hidden by default)
2688
+ with Container(id="changes-view"):
2689
+ yield GitChangesPanel(self.root_path, id="git-changes")
2690
+
2691
+ # Search view (hidden by default)
2692
+ with Container(id="search-view"):
2693
+ yield CodebaseSearch(self.root_path, id="codebase-search")
2694
+
2695
+ # NEW: Agent panel
2696
+ with Container(id="agent-view"):
2697
+ yield AgentPanel(id="agent-panel")
2698
+
2699
+ # NEW: Context panel
2700
+ with Container(id="context-view"):
2701
+ yield ContextPanel(id="context-panel")
2702
+
2703
+ # NEW: Diff panel
2704
+ with Container(id="diff-view"):
2705
+ yield DiffPanel(id="diff-panel")
2706
+
2707
+ # NEW: History panel
2708
+ with Container(id="history-view"):
2709
+ yield HistoryPanel(id="history-panel")
2710
+
2711
+ # QE dashboard panel is not shown in OSS.
2712
+
2713
+ def _render_title(self) -> Text:
2714
+ """Render the sidebar title."""
2715
+ t = Text()
2716
+ t.append("\n", style="")
2717
+ t.append("📁 ", style="bold #ec4899")
2718
+ t.append(self.root_path.name or "Project", style="bold #a855f7")
2719
+ t.append(" ", style="")
2720
+ t.append("Ctrl+B", style="#52525b")
2721
+ t.append(" close ", style="#3f3f46")
2722
+ t.append("Ctrl+F", style="#52525b")
2723
+ t.append(" search", style="#3f3f46")
2724
+ return t
2725
+
2726
+ def watch_current_view(self, view: str) -> None:
2727
+ """Switch between all sidebar views."""
2728
+ try:
2729
+ tabs = self.query_one("#sidebar-tabs", SidebarTabs)
2730
+
2731
+ # Hide all views first
2732
+ all_views = [
2733
+ "files-view",
2734
+ "code-view",
2735
+ "changes-view",
2736
+ "search-view",
2737
+ "agent-view",
2738
+ "context-view",
2739
+ "diff-view",
2740
+ "history-view",
2741
+ ]
2742
+
2743
+ for view_id in all_views:
2744
+ try:
2745
+ v = self.query_one(f"#{view_id}", Container)
2746
+ v.add_class("-hidden")
2747
+ v.remove_class("visible")
2748
+ except Exception:
2749
+ pass
2750
+
2751
+ # Show selected view
2752
+ view_id = f"{view}-view"
2753
+ try:
2754
+ selected = self.query_one(f"#{view_id}", Container)
2755
+ selected.remove_class("-hidden")
2756
+ selected.add_class("visible")
2757
+ tabs.active_tab = view
2758
+ except Exception:
2759
+ pass
2760
+
2761
+ # View-specific actions
2762
+ if view == "changes":
2763
+ # Refresh changes when switching to changes tab
2764
+ try:
2765
+ self.query_one("#git-changes", GitChangesPanel).refresh_changes()
2766
+ except Exception:
2767
+ pass
2768
+ elif view == "search":
2769
+ # Focus the search input
2770
+ try:
2771
+ self.query_one("#codebase-search", CodebaseSearch).query_one(
2772
+ "#code-search-input", Input
2773
+ ).focus()
2774
+ except Exception:
2775
+ pass
2776
+ except Exception:
2777
+ pass
2778
+
2779
+ @on(SidebarTabs.TabChanged)
2780
+ def on_tab_changed(self, event: SidebarTabs.TabChanged) -> None:
2781
+ """Handle tab changes."""
2782
+ event.stop()
2783
+ self.current_view = event.tab
2784
+
2785
+ def action_toggle_search(self) -> None:
2786
+ """Toggle file search visibility."""
2787
+ search = self.query_one("#file-search", FileSearch)
2788
+ if search.has_class("-hidden"):
2789
+ search.remove_class("-hidden")
2790
+ search.query_one("#search-input", Input).focus()
2791
+ else:
2792
+ search.add_class("-hidden")
2793
+
2794
+ def action_dismiss(self) -> None:
2795
+ """Dismiss the sidebar."""
2796
+ self.post_message(self.Dismiss())
2797
+
2798
+ def action_show_files(self) -> None:
2799
+ """Show files view."""
2800
+ self.current_view = "files"
2801
+
2802
+ def action_show_code(self) -> None:
2803
+ """Show code view."""
2804
+ self.current_view = "code"
2805
+
2806
+ def action_show_changes(self) -> None:
2807
+ """Show changes view."""
2808
+ self.current_view = "changes"
2809
+
2810
+ def action_show_search(self) -> None:
2811
+ """Show search view."""
2812
+ self.current_view = "search"
2813
+
2814
+ def action_refresh_changes(self) -> None:
2815
+ """Refresh git changes."""
2816
+ try:
2817
+ self.query_one("#git-changes", GitChangesPanel).refresh_changes()
2818
+ self.query_one("#git-status", GitStatusWidget).refresh_status()
2819
+ except Exception:
2820
+ pass
2821
+
2822
+ def action_show_agent(self) -> None:
2823
+ """Show agent panel."""
2824
+ self.current_view = "agent"
2825
+
2826
+ def action_show_context(self) -> None:
2827
+ """Show context panel."""
2828
+ self.current_view = "context"
2829
+
2830
+ def action_show_diff(self) -> None:
2831
+ """Show diff panel."""
2832
+ self.current_view = "diff"
2833
+
2834
+ def action_show_history(self) -> None:
2835
+ """Show history panel."""
2836
+ self.current_view = "history"
2837
+
2838
+ # Panel access methods
2839
+ def get_agent_panel(self):
2840
+ """Get the agent panel widget."""
2841
+ try:
2842
+ from superqode.widgets.sidebar_panels import AgentPanel
2843
+
2844
+ return self.query_one("#agent-panel", AgentPanel)
2845
+ except Exception:
2846
+ return None
2847
+
2848
+ def get_context_panel(self):
2849
+ """Get the context panel widget."""
2850
+ try:
2851
+ from superqode.widgets.sidebar_panels import ContextPanel
2852
+
2853
+ return self.query_one("#context-panel", ContextPanel)
2854
+ except Exception:
2855
+ return None
2856
+
2857
+ def get_terminal_panel(self):
2858
+ """Get the terminal panel widget."""
2859
+ try:
2860
+ from superqode.widgets.sidebar_panels import TerminalPanel
2861
+
2862
+ return self.query_one("#terminal-panel", TerminalPanel)
2863
+ except Exception:
2864
+ return None
2865
+
2866
+ def get_diff_panel(self):
2867
+ """Get the diff panel widget."""
2868
+ try:
2869
+ from superqode.widgets.sidebar_panels import DiffPanel
2870
+
2871
+ return self.query_one("#diff-panel", DiffPanel)
2872
+ except Exception:
2873
+ return None
2874
+
2875
+ def get_history_panel(self):
2876
+ """Get the history panel widget."""
2877
+ try:
2878
+ from superqode.widgets.sidebar_panels import HistoryPanel
2879
+
2880
+ return self.query_one("#history-panel", HistoryPanel)
2881
+ except Exception:
2882
+ return None
2883
+
2884
+ @on(DirectoryTree.FileSelected)
2885
+ def on_file_selected(self, event: DirectoryTree.FileSelected) -> None:
2886
+ """Handle file selection - show in code view."""
2887
+ event.stop()
2888
+ path = event.path
2889
+ self._current_file = path
2890
+
2891
+ # Set file in preview
2892
+ preview = self.query_one("#file-preview", FilePreview)
2893
+ preview.set_file(path)
2894
+
2895
+ # Switch to code view
2896
+ self.current_view = "code"
2897
+
2898
+ @on(ColorfulDirectoryTree.FileOpenRequested)
2899
+ def on_file_open_requested(self, event: ColorfulDirectoryTree.FileOpenRequested) -> None:
2900
+ """Handle file open request - forward to parent."""
2901
+ event.stop()
2902
+ self.post_message(self.FileOpened(event.path))
2903
+
2904
+ @on(FileSearch.FileSelected)
2905
+ def on_search_file_selected(self, event: FileSearch.FileSelected) -> None:
2906
+ """Handle file selection from search."""
2907
+ event.stop()
2908
+ path = event.path
2909
+ self._current_file = path
2910
+
2911
+ # Set file in preview and switch to code view
2912
+ preview = self.query_one("#file-preview", FilePreview)
2913
+ preview.set_file(path)
2914
+ self.current_view = "code"
2915
+
2916
+ @on(FileSearch.SearchClosed)
2917
+ def on_search_closed(self, event: FileSearch.SearchClosed) -> None:
2918
+ """Handle search close - hide search widget."""
2919
+ event.stop()
2920
+ self.query_one("#file-search", FileSearch).add_class("-hidden")
2921
+ if self.current_view == "files":
2922
+ self.query_one("#file-tree", ColorfulDirectoryTree).focus()
2923
+
2924
+ @on(CodebaseSearch.ResultSelected)
2925
+ def on_codebase_search_result_selected(self, event: CodebaseSearch.ResultSelected) -> None:
2926
+ """Handle codebase search result selection - open file at line."""
2927
+ event.stop()
2928
+ path = event.path
2929
+ self._current_file = path
2930
+
2931
+ # Set file in preview and switch to code view
2932
+ preview = self.query_one("#file-preview", FilePreview)
2933
+ preview.set_file(path)
2934
+ self.current_view = "code"
2935
+
2936
+ @on(CodebaseSearch.SearchClosed)
2937
+ def on_codebase_search_closed(self, event: CodebaseSearch.SearchClosed) -> None:
2938
+ """Handle codebase search close."""
2939
+ event.stop()
2940
+ # Stay on search view but could switch to files
2941
+ pass
2942
+
2943
+ @on(FilePreview.EditRequested)
2944
+ def on_edit_requested(self, event: FilePreview.EditRequested) -> None:
2945
+ """Handle edit request - open file in default editor."""
2946
+ event.stop()
2947
+ import os
2948
+ import platform
2949
+
2950
+ path = event.path
2951
+
2952
+ try:
2953
+ system = platform.system()
2954
+ if system == "Darwin":
2955
+ subprocess.Popen(["open", str(path)])
2956
+ elif system == "Windows":
2957
+ os.startfile(str(path))
2958
+ else:
2959
+ editor = os.environ.get("EDITOR", "xdg-open")
2960
+ subprocess.Popen([editor, str(path)])
2961
+ except Exception:
2962
+ editor = os.environ.get("EDITOR", "nano")
2963
+ try:
2964
+ subprocess.Popen([editor, str(path)])
2965
+ except Exception:
2966
+ pass
2967
+
2968
+ @on(FilePreview.PreviewClosed)
2969
+ def on_preview_closed(self, event: FilePreview.PreviewClosed) -> None:
2970
+ """Handle preview close - switch back to files."""
2971
+ event.stop()
2972
+ self.current_view = "files"
2973
+
2974
+ def refresh_tree(self) -> None:
2975
+ """Refresh the file tree."""
2976
+ tree = self.query_one("#file-tree", ColorfulDirectoryTree)
2977
+ tree.reload()
2978
+
2979
+ def refresh_git_status(self) -> None:
2980
+ """Refresh git status."""
2981
+ git_widget = self.query_one("#git-status", GitStatusWidget)
2982
+ git_widget.refresh_status()
2983
+
2984
+ def set_tasks(self, tasks: List[TaskItem]) -> None:
2985
+ """Update tasks (for compatibility)."""
2986
+ pass # Plan panel removed in this version
2987
+
2988
+ def focus_tree(self) -> None:
2989
+ """Focus the file tree."""
2990
+ self.current_view = "files"
2991
+ self.query_one("#file-tree", ColorfulDirectoryTree).focus()