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
@@ -0,0 +1,1205 @@
1
+ """
2
+ Permission Preview Screen - Visual Permission Request Display.
3
+
4
+ Shows permission requests with full context including:
5
+ - File diff previews with multiple view modes
6
+ - Multi-file navigator
7
+ - Synchronized scrolling for split view
8
+ - Command analysis
9
+ - Impact assessment
10
+ - Quick action buttons
11
+
12
+ Provides users with all information needed to make informed permission decisions.
13
+
14
+ Enhanced Features:
15
+ - Multi-file navigator with file type icons
16
+ - Split/Unified/Auto diff view modes
17
+ - Synchronized scrolling between old/new panes
18
+ - j/k navigation
19
+ - Full diff context with scrolling
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from datetime import datetime
26
+ from enum import Enum
27
+ from pathlib import Path
28
+ from typing import Any, Callable, Dict, List, Optional
29
+
30
+ from rich.console import RenderableType
31
+ from rich.panel import Panel
32
+ from rich.syntax import Syntax
33
+ from rich.table import Table
34
+ from rich.text import Text
35
+ from textual.reactive import reactive
36
+ from textual.widgets import Static, OptionList, Select
37
+ from textual.widgets.option_list import Option
38
+ from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
39
+ from textual.binding import Binding
40
+ from textual import events
41
+
42
+
43
+ class PreviewType(Enum):
44
+ """Type of preview to show."""
45
+
46
+ FILE_WRITE = "file_write"
47
+ FILE_DELETE = "file_delete"
48
+ SHELL_COMMAND = "shell_command"
49
+ NETWORK = "network"
50
+
51
+
52
+ @dataclass
53
+ class PermissionContext:
54
+ """Context for a permission request."""
55
+
56
+ request_id: str
57
+ preview_type: PreviewType
58
+ title: str
59
+ description: str
60
+
61
+ # File-related
62
+ file_path: Optional[str] = None
63
+ original_content: Optional[str] = None
64
+ new_content: Optional[str] = None
65
+
66
+ # Command-related
67
+ command: Optional[str] = None
68
+ working_dir: Optional[str] = None
69
+
70
+ # Analysis
71
+ risk_level: str = "medium" # low, medium, high, critical
72
+ risk_factors: List[str] = None
73
+ affected_files: List[str] = None
74
+
75
+ # Agent info
76
+ agent_name: str = ""
77
+ reason: str = ""
78
+
79
+ def __post_init__(self):
80
+ if self.risk_factors is None:
81
+ self.risk_factors = []
82
+ if self.affected_files is None:
83
+ self.affected_files = []
84
+
85
+
86
+ # Risk level colors and icons
87
+ RISK_STYLES = {
88
+ "low": {"color": "#22c55e", "icon": "🟢", "label": "Low Risk"},
89
+ "medium": {"color": "#eab308", "icon": "🟡", "label": "Medium Risk"},
90
+ "high": {"color": "#f97316", "icon": "🟠", "label": "High Risk"},
91
+ "critical": {"color": "#ef4444", "icon": "🔴", "label": "Critical"},
92
+ }
93
+
94
+
95
+ class PermissionPreview(Static):
96
+ """
97
+ Permission preview widget showing request details.
98
+
99
+ Displays file diffs, command analysis, and risk assessment
100
+ to help users make informed permission decisions.
101
+ """
102
+
103
+ DEFAULT_CSS = """
104
+ PermissionPreview {
105
+ height: auto;
106
+ border: solid #3f3f46;
107
+ padding: 1;
108
+ margin: 1;
109
+ }
110
+
111
+ PermissionPreview.high-risk {
112
+ border: solid #f97316;
113
+ }
114
+
115
+ PermissionPreview.critical {
116
+ border: solid #ef4444;
117
+ }
118
+ """
119
+
120
+ # Reactive state
121
+ expanded: reactive[bool] = reactive(True)
122
+
123
+ def __init__(
124
+ self,
125
+ context: PermissionContext,
126
+ on_allow: Optional[Callable[[], None]] = None,
127
+ on_deny: Optional[Callable[[], None]] = None,
128
+ on_allow_all: Optional[Callable[[], None]] = None,
129
+ **kwargs,
130
+ ):
131
+ super().__init__(**kwargs)
132
+ self.context = context
133
+ self._on_allow = on_allow
134
+ self._on_deny = on_deny
135
+ self._on_allow_all = on_allow_all
136
+
137
+ # Set risk class
138
+ if context.risk_level in ("high", "critical"):
139
+ self.add_class(context.risk_level)
140
+
141
+ def on_key(self, event: events.Key) -> None:
142
+ """Handle key events for quick actions."""
143
+ if event.key == "y":
144
+ if self._on_allow:
145
+ self._on_allow()
146
+ event.prevent_default()
147
+ elif event.key == "n":
148
+ if self._on_deny:
149
+ self._on_deny()
150
+ event.prevent_default()
151
+ elif event.key == "a":
152
+ if self._on_allow_all:
153
+ self._on_allow_all()
154
+ event.prevent_default()
155
+ elif event.key == "space":
156
+ self.expanded = not self.expanded
157
+ self.refresh()
158
+ event.prevent_default()
159
+
160
+ def _render_diff(self) -> Text:
161
+ """Render file diff preview."""
162
+ result = Text()
163
+
164
+ if not self.context.original_content and not self.context.new_content:
165
+ result.append(" No content preview available\n", style="#6b7280")
166
+ return result
167
+
168
+ original = self.context.original_content or ""
169
+ new = self.context.new_content or ""
170
+
171
+ original_lines = original.splitlines() if original else []
172
+ new_lines = new.splitlines() if new else []
173
+
174
+ # Simple diff display
175
+ if not original_lines:
176
+ # New file
177
+ result.append(" [NEW FILE]\n", style="bold #22c55e")
178
+ for i, line in enumerate(new_lines[:20]): # Limit preview
179
+ result.append(f" {i + 1:4} │ ", style="#6b7280")
180
+ result.append(f"+{line}\n", style="#22c55e")
181
+ if len(new_lines) > 20:
182
+ result.append(f" ... and {len(new_lines) - 20} more lines\n", style="#6b7280")
183
+ elif not new_lines:
184
+ # File deletion
185
+ result.append(" [FILE DELETED]\n", style="bold #ef4444")
186
+ for i, line in enumerate(original_lines[:10]):
187
+ result.append(f" {i + 1:4} │ ", style="#6b7280")
188
+ result.append(f"-{line}\n", style="#ef4444")
189
+ if len(original_lines) > 10:
190
+ result.append(f" ... and {len(original_lines) - 10} more lines\n", style="#6b7280")
191
+ else:
192
+ # Modified file - show unified diff style
193
+ result.append(" [MODIFIED]\n", style="bold #eab308")
194
+
195
+ # Very simple diff - just show changes
196
+ max_lines = max(len(original_lines), len(new_lines))
197
+ shown = 0
198
+
199
+ for i in range(min(max_lines, 30)):
200
+ orig = original_lines[i] if i < len(original_lines) else None
201
+ new = new_lines[i] if i < len(new_lines) else None
202
+
203
+ if orig == new:
204
+ if shown < 10: # Show some context
205
+ result.append(f" {i + 1:4} │ ", style="#6b7280")
206
+ result.append(f" {orig}\n", style="#a1a1aa")
207
+ shown += 1
208
+ else:
209
+ if orig:
210
+ result.append(f" {i + 1:4} │ ", style="#6b7280")
211
+ result.append(f"-{orig}\n", style="#ef4444")
212
+ if new:
213
+ result.append(f" {i + 1:4} │ ", style="#6b7280")
214
+ result.append(f"+{new}\n", style="#22c55e")
215
+ shown += 1
216
+
217
+ if max_lines > 30:
218
+ result.append(f" ... and more changes\n", style="#6b7280")
219
+
220
+ return result
221
+
222
+ def _render_command(self) -> Text:
223
+ """Render command preview."""
224
+ result = Text()
225
+
226
+ if not self.context.command:
227
+ return result
228
+
229
+ result.append(" Command:\n", style="bold #a1a1aa")
230
+ result.append(f" $ {self.context.command}\n", style="bold #e2e8f0")
231
+
232
+ if self.context.working_dir:
233
+ result.append(f" Working Directory: {self.context.working_dir}\n", style="#6b7280")
234
+
235
+ return result
236
+
237
+ def _render_risk_assessment(self) -> Text:
238
+ """Render risk assessment section."""
239
+ result = Text()
240
+
241
+ style = RISK_STYLES.get(self.context.risk_level, RISK_STYLES["medium"])
242
+
243
+ result.append("\n")
244
+ result.append(f" {style['icon']} Risk: ", style="#a1a1aa")
245
+ result.append(f"{style['label']}\n", style=f"bold {style['color']}")
246
+
247
+ if self.context.risk_factors:
248
+ result.append(" Factors:\n", style="#a1a1aa")
249
+ for factor in self.context.risk_factors:
250
+ result.append(f" • {factor}\n", style="#6b7280")
251
+
252
+ return result
253
+
254
+ def _render_actions(self) -> Text:
255
+ """Render action hints."""
256
+ result = Text()
257
+
258
+ result.append("\n")
259
+ result.append(" ─────────────────────────────────────────\n", style="#3f3f46")
260
+ result.append(" ", style="")
261
+ result.append("[y]", style="bold #22c55e")
262
+ result.append(" Allow ", style="#a1a1aa")
263
+ result.append("[n]", style="bold #ef4444")
264
+ result.append(" Deny ", style="#a1a1aa")
265
+ result.append("[a]", style="bold #3b82f6")
266
+ result.append(" Always Allow ", style="#a1a1aa")
267
+ result.append("[space]", style="bold #6b7280")
268
+ result.append(" Toggle Details\n", style="#a1a1aa")
269
+
270
+ return result
271
+
272
+ def render(self) -> RenderableType:
273
+ """Render the permission preview."""
274
+ content = Text()
275
+
276
+ style = RISK_STYLES.get(self.context.risk_level, RISK_STYLES["medium"])
277
+
278
+ # Header
279
+ content.append(f" {style['icon']} ", style="")
280
+ content.append(self.context.title, style=f"bold {style['color']}")
281
+ content.append("\n")
282
+
283
+ # Agent info
284
+ if self.context.agent_name:
285
+ content.append(f" Agent: {self.context.agent_name}\n", style="#6b7280")
286
+
287
+ # Description
288
+ if self.context.description:
289
+ content.append(f" {self.context.description}\n", style="#a1a1aa")
290
+
291
+ # File path
292
+ if self.context.file_path:
293
+ content.append(" File: ", style="#6b7280")
294
+ content.append(f"{self.context.file_path}\n", style="bold #3b82f6")
295
+
296
+ # Expanded content
297
+ if self.expanded:
298
+ content.append("\n")
299
+
300
+ if self.context.preview_type in (PreviewType.FILE_WRITE, PreviewType.FILE_DELETE):
301
+ content.append(self._render_diff())
302
+ elif self.context.preview_type == PreviewType.SHELL_COMMAND:
303
+ content.append(self._render_command())
304
+
305
+ content.append(self._render_risk_assessment())
306
+
307
+ # Actions
308
+ content.append(self._render_actions())
309
+
310
+ border_color = (
311
+ style["color"] if self.context.risk_level in ("high", "critical") else "#3f3f46"
312
+ )
313
+
314
+ return Panel(
315
+ content,
316
+ title=f"[bold {style['color']}]⚠ Permission Request[/]",
317
+ border_style=border_color,
318
+ padding=(0, 0),
319
+ )
320
+
321
+
322
+ class PermissionPreviewScreen(Container):
323
+ """
324
+ Full screen permission preview with multiple requests.
325
+
326
+ Shows a queue of pending permission requests with
327
+ batch approval options.
328
+ """
329
+
330
+ DEFAULT_CSS = """
331
+ PermissionPreviewScreen {
332
+ width: 100%;
333
+ height: 100%;
334
+ background: #0f0f0f;
335
+ padding: 1;
336
+ }
337
+
338
+ PermissionPreviewScreen .header {
339
+ height: 3;
340
+ margin-bottom: 1;
341
+ }
342
+
343
+ PermissionPreviewScreen .content {
344
+ height: 1fr;
345
+ overflow-y: auto;
346
+ }
347
+
348
+ PermissionPreviewScreen .footer {
349
+ height: 3;
350
+ margin-top: 1;
351
+ }
352
+ """
353
+
354
+ def __init__(
355
+ self,
356
+ requests: List[PermissionContext],
357
+ on_decision: Callable[[str, str], None], # (request_id, action)
358
+ **kwargs,
359
+ ):
360
+ super().__init__(**kwargs)
361
+ self.requests = requests
362
+ self._on_decision = on_decision
363
+ self._current_index = 0
364
+
365
+ def compose(self):
366
+ """Compose the screen layout."""
367
+ # Header
368
+ with Horizontal(classes="header"):
369
+ yield Static(
370
+ f"[bold #3b82f6]Permission Requests[/] ({len(self.requests)} pending)",
371
+ id="header-title",
372
+ )
373
+
374
+ # Content - current preview
375
+ with Container(classes="content"):
376
+ if self.requests:
377
+ yield PermissionPreview(
378
+ self.requests[self._current_index],
379
+ on_allow=lambda: self._handle_decision("allow"),
380
+ on_deny=lambda: self._handle_decision("deny"),
381
+ on_allow_all=lambda: self._handle_decision("allow_all"),
382
+ id="current-preview",
383
+ )
384
+
385
+ # Footer
386
+ with Horizontal(classes="footer"):
387
+ yield Static(
388
+ "[←/→] Navigate [y] Allow [n] Deny [a] Allow All [Esc] Close",
389
+ id="footer-hints",
390
+ )
391
+
392
+ def _handle_decision(self, action: str) -> None:
393
+ """Handle a permission decision."""
394
+ if not self.requests:
395
+ return
396
+
397
+ request = self.requests[self._current_index]
398
+ self._on_decision(request.request_id, action)
399
+
400
+ # Move to next request
401
+ self.requests.pop(self._current_index)
402
+
403
+ if not self.requests:
404
+ # All requests handled
405
+ self.remove()
406
+ return
407
+
408
+ # Adjust index
409
+ if self._current_index >= len(self.requests):
410
+ self._current_index = len(self.requests) - 1
411
+
412
+ self._refresh_preview()
413
+
414
+ def _refresh_preview(self) -> None:
415
+ """Refresh the current preview."""
416
+ # Remove old preview
417
+ old = self.query_one("#current-preview", PermissionPreview)
418
+ old.remove()
419
+
420
+ # Add new preview
421
+ content = self.query_one(".content", Container)
422
+ content.mount(
423
+ PermissionPreview(
424
+ self.requests[self._current_index],
425
+ on_allow=lambda: self._handle_decision("allow"),
426
+ on_deny=lambda: self._handle_decision("deny"),
427
+ on_allow_all=lambda: self._handle_decision("allow_all"),
428
+ id="current-preview",
429
+ )
430
+ )
431
+
432
+ # Update header
433
+ header = self.query_one("#header-title", Static)
434
+ header.update(f"[bold #3b82f6]Permission Requests[/] ({len(self.requests)} pending)")
435
+
436
+ def on_key(self, event: events.Key) -> None:
437
+ """Handle navigation keys."""
438
+ if event.key == "left" and self._current_index > 0:
439
+ self._current_index -= 1
440
+ self._refresh_preview()
441
+ event.prevent_default()
442
+ elif event.key == "right" and self._current_index < len(self.requests) - 1:
443
+ self._current_index += 1
444
+ self._refresh_preview()
445
+ event.prevent_default()
446
+ elif event.key == "escape":
447
+ self.remove()
448
+ event.prevent_default()
449
+
450
+
451
+ def analyze_command_risk(command: str) -> Dict[str, Any]:
452
+ """Analyze a shell command for risk factors."""
453
+ risk_factors = []
454
+ risk_level = "low"
455
+
456
+ # Dangerous patterns
457
+ dangerous_patterns = [
458
+ (r"rm\s+-rf", "Recursive forced deletion", "critical"),
459
+ (r"rm\s+-r", "Recursive deletion", "high"),
460
+ (r">\s*/dev/", "Writing to device", "critical"),
461
+ (r"mkfs", "Filesystem creation", "critical"),
462
+ (r"dd\s+", "Direct disk write", "critical"),
463
+ (r"chmod\s+777", "World-writable permissions", "high"),
464
+ (r"curl.*\|\s*(bash|sh)", "Remote code execution", "critical"),
465
+ (r"wget.*\|\s*(bash|sh)", "Remote code execution", "critical"),
466
+ (r"sudo\s+", "Elevated privileges", "high"),
467
+ (r">\s*~", "Writing to home directory", "medium"),
468
+ (r"pip\s+install", "Package installation", "medium"),
469
+ (r"npm\s+install", "Package installation", "medium"),
470
+ ]
471
+
472
+ import re
473
+
474
+ for pattern, description, level in dangerous_patterns:
475
+ if re.search(pattern, command, re.IGNORECASE):
476
+ risk_factors.append(description)
477
+ if level == "critical":
478
+ risk_level = "critical"
479
+ elif level == "high" and risk_level not in ("critical",):
480
+ risk_level = "high"
481
+ elif level == "medium" and risk_level == "low":
482
+ risk_level = "medium"
483
+
484
+ return {
485
+ "risk_level": risk_level,
486
+ "risk_factors": risk_factors,
487
+ }
488
+
489
+
490
+ def create_file_write_preview(
491
+ file_path: str,
492
+ original_content: Optional[str],
493
+ new_content: str,
494
+ agent_name: str = "",
495
+ reason: str = "",
496
+ ) -> PermissionContext:
497
+ """Create a permission context for file write."""
498
+ import hashlib
499
+
500
+ request_id = hashlib.sha256(
501
+ f"write:{file_path}:{datetime.now().isoformat()}".encode()
502
+ ).hexdigest()[:12]
503
+
504
+ risk_factors = []
505
+ risk_level = "low"
506
+
507
+ # Analyze risk
508
+ if file_path.endswith((".env", ".key", ".pem")):
509
+ risk_factors.append("Sensitive file type")
510
+ risk_level = "high"
511
+
512
+ if file_path.startswith(("/etc/", "/usr/", "/bin/", "/sbin/")):
513
+ risk_factors.append("System directory")
514
+ risk_level = "critical"
515
+
516
+ if original_content is None:
517
+ risk_factors.append("Creating new file")
518
+
519
+ return PermissionContext(
520
+ request_id=f"req-{request_id}",
521
+ preview_type=PreviewType.FILE_WRITE,
522
+ title=f"Write to {Path(file_path).name}",
523
+ description=reason or "Agent wants to modify this file",
524
+ file_path=file_path,
525
+ original_content=original_content,
526
+ new_content=new_content,
527
+ risk_level=risk_level,
528
+ risk_factors=risk_factors,
529
+ agent_name=agent_name,
530
+ reason=reason,
531
+ )
532
+
533
+
534
+ def create_command_preview(
535
+ command: str,
536
+ working_dir: str = "",
537
+ agent_name: str = "",
538
+ reason: str = "",
539
+ ) -> PermissionContext:
540
+ """Create a permission context for shell command."""
541
+ import hashlib
542
+
543
+ request_id = hashlib.sha256(f"cmd:{command}:{datetime.now().isoformat()}".encode()).hexdigest()[
544
+ :12
545
+ ]
546
+
547
+ # Analyze command risk
548
+ analysis = analyze_command_risk(command)
549
+
550
+ return PermissionContext(
551
+ request_id=f"req-{request_id}",
552
+ preview_type=PreviewType.SHELL_COMMAND,
553
+ title="Execute Shell Command",
554
+ description=reason or "Agent wants to run this command",
555
+ command=command,
556
+ working_dir=working_dir,
557
+ risk_level=analysis["risk_level"],
558
+ risk_factors=analysis["risk_factors"],
559
+ agent_name=agent_name,
560
+ reason=reason,
561
+ )
562
+
563
+
564
+ # ============================================================================
565
+ # Enhanced Permission Preview Components
566
+ # ============================================================================
567
+
568
+
569
+ class DiffViewMode(Enum):
570
+ """Diff view mode."""
571
+
572
+ UNIFIED = "unified"
573
+ SPLIT = "split"
574
+ AUTO = "auto"
575
+
576
+
577
+ # File type icons for navigator
578
+ FILE_TYPE_ICONS = {
579
+ ".py": "",
580
+ ".js": "",
581
+ ".ts": "",
582
+ ".tsx": "",
583
+ ".jsx": "",
584
+ ".html": "",
585
+ ".css": "",
586
+ ".json": "",
587
+ ".md": "",
588
+ ".yaml": "",
589
+ ".yml": "",
590
+ ".toml": "",
591
+ ".sh": "",
592
+ ".bash": "",
593
+ ".zsh": "",
594
+ ".go": "",
595
+ ".rs": "",
596
+ ".java": "",
597
+ ".rb": "",
598
+ ".php": "",
599
+ ".c": "",
600
+ ".cpp": "",
601
+ ".h": "",
602
+ ".sql": "",
603
+ ".txt": "",
604
+ ".env": "",
605
+ ".gitignore": "",
606
+ }
607
+
608
+ PREVIEW_TYPE_ICONS = {
609
+ PreviewType.FILE_WRITE: "",
610
+ PreviewType.FILE_DELETE: "",
611
+ PreviewType.SHELL_COMMAND: "",
612
+ PreviewType.NETWORK: "",
613
+ }
614
+
615
+
616
+ def get_file_icon(file_path: str) -> str:
617
+ """Get icon for a file based on extension."""
618
+ if not file_path:
619
+ return ""
620
+
621
+ path = Path(file_path)
622
+ ext = path.suffix.lower()
623
+
624
+ # Check exact filename matches first
625
+ if path.name in FILE_TYPE_ICONS:
626
+ return FILE_TYPE_ICONS[path.name]
627
+
628
+ # Check extension
629
+ return FILE_TYPE_ICONS.get(ext, "")
630
+
631
+
632
+ class PermissionNavigator(OptionList):
633
+ """
634
+ List of pending permission requests.
635
+
636
+ Shows all requests with icons and allows navigation.
637
+ """
638
+
639
+ DEFAULT_CSS = """
640
+ PermissionNavigator {
641
+ width: 25;
642
+ height: 100%;
643
+ border-right: solid #3f3f46;
644
+ background: #0f0f0f;
645
+ }
646
+
647
+ PermissionNavigator > .option-list--option {
648
+ padding: 0 1;
649
+ }
650
+
651
+ PermissionNavigator > .option-list--option-highlighted {
652
+ background: #27272a;
653
+ }
654
+ """
655
+
656
+ def __init__(
657
+ self,
658
+ requests: List[PermissionContext],
659
+ on_select: Optional[Callable[[int], None]] = None,
660
+ **kwargs,
661
+ ):
662
+ super().__init__(**kwargs)
663
+ self.requests = requests
664
+ self._on_select = on_select
665
+
666
+ def on_mount(self) -> None:
667
+ """Populate the navigator on mount."""
668
+ for i, req in enumerate(self.requests):
669
+ icon = PREVIEW_TYPE_ICONS.get(req.preview_type, "")
670
+
671
+ # Get file icon if applicable
672
+ if req.file_path:
673
+ file_icon = get_file_icon(req.file_path)
674
+ label = f"{icon} {file_icon} {Path(req.file_path).name}"
675
+ elif req.command:
676
+ label = f"{icon} {req.command[:20]}..."
677
+ else:
678
+ label = f"{icon} {req.title[:20]}"
679
+
680
+ # Add risk indicator
681
+ risk_indicators = {
682
+ "low": "[green]●[/]",
683
+ "medium": "[yellow]●[/]",
684
+ "high": "[orange1]●[/]",
685
+ "critical": "[red]●[/]",
686
+ }
687
+ risk_dot = risk_indicators.get(req.risk_level, "[white]●[/]")
688
+
689
+ self.add_option(Option(f"{risk_dot} {label}", id=str(i)))
690
+
691
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
692
+ """Handle option selection."""
693
+ if self._on_select:
694
+ index = int(str(event.option.id))
695
+ self._on_select(index)
696
+
697
+
698
+ class DiffModeSelector(Select):
699
+ """Dropdown to select diff view mode."""
700
+
701
+ DEFAULT_CSS = """
702
+ DiffModeSelector {
703
+ width: 16;
704
+ margin: 0 1;
705
+ }
706
+ """
707
+
708
+ def __init__(self, **kwargs):
709
+ super().__init__(
710
+ options=[
711
+ ("Unified", DiffViewMode.UNIFIED.value),
712
+ ("Split", DiffViewMode.SPLIT.value),
713
+ ("Auto", DiffViewMode.AUTO.value),
714
+ ],
715
+ value=DiffViewMode.UNIFIED.value,
716
+ **kwargs,
717
+ )
718
+
719
+
720
+ class DiffPane(ScrollableContainer):
721
+ """Single pane of a split diff view."""
722
+
723
+ DEFAULT_CSS = """
724
+ DiffPane {
725
+ width: 1fr;
726
+ height: 100%;
727
+ border: round #3f3f46;
728
+ padding: 0 1;
729
+ }
730
+
731
+ DiffPane.old {
732
+ border: round #ef4444;
733
+ }
734
+
735
+ DiffPane.new {
736
+ border: round #22c55e;
737
+ }
738
+ """
739
+
740
+ def __init__(
741
+ self,
742
+ content: str,
743
+ side: str = "old",
744
+ on_scroll: Optional[Callable[[int, int], None]] = None,
745
+ **kwargs,
746
+ ):
747
+ super().__init__(**kwargs)
748
+ self.content = content
749
+ self.side = side
750
+ self._on_scroll = on_scroll
751
+ self.add_class(side)
752
+
753
+ def compose(self):
754
+ """Compose the diff pane."""
755
+ lines = self.content.split("\n") if self.content else []
756
+
757
+ for i, line in enumerate(lines):
758
+ color = "#a1a1aa" if self.side == "old" else "#e4e4e7"
759
+ yield Static(f"{i + 1:4} │ {line}", classes="diff-line")
760
+
761
+
762
+ class SyncedDiffView(Horizontal):
763
+ """Side-by-side diff with synchronized scrolling."""
764
+
765
+ DEFAULT_CSS = """
766
+ SyncedDiffView {
767
+ width: 100%;
768
+ height: 100%;
769
+ }
770
+
771
+ SyncedDiffView .diff-header {
772
+ height: 2;
773
+ background: #18181b;
774
+ padding: 0 1;
775
+ }
776
+
777
+ SyncedDiffView .diff-header.old {
778
+ color: #ef4444;
779
+ }
780
+
781
+ SyncedDiffView .diff-header.new {
782
+ color: #22c55e;
783
+ }
784
+ """
785
+
786
+ def __init__(
787
+ self,
788
+ old_content: str,
789
+ new_content: str,
790
+ file_path: str = "",
791
+ **kwargs,
792
+ ):
793
+ super().__init__(**kwargs)
794
+ self.old_content = old_content or ""
795
+ self.new_content = new_content or ""
796
+ self.file_path = file_path
797
+
798
+ def compose(self):
799
+ """Compose the split diff view."""
800
+ with Vertical(classes="diff-column"):
801
+ yield Static("--- Original", classes="diff-header old")
802
+ yield DiffPane(
803
+ self.old_content,
804
+ side="old",
805
+ on_scroll=self._sync_scroll,
806
+ id="old-pane",
807
+ )
808
+
809
+ with Vertical(classes="diff-column"):
810
+ yield Static("+++ Modified", classes="diff-header new")
811
+ yield DiffPane(
812
+ self.new_content,
813
+ side="new",
814
+ on_scroll=self._sync_scroll,
815
+ id="new-pane",
816
+ )
817
+
818
+ def _sync_scroll(self, x: int, y: int) -> None:
819
+ """Sync scroll position between panes."""
820
+ # This would be called when one pane scrolls
821
+ # to update the other pane's position
822
+ pass
823
+
824
+
825
+ class UnifiedDiffView(ScrollableContainer):
826
+ """Unified diff view showing changes inline."""
827
+
828
+ DEFAULT_CSS = """
829
+ UnifiedDiffView {
830
+ width: 100%;
831
+ height: 100%;
832
+ padding: 1;
833
+ }
834
+
835
+ UnifiedDiffView .diff-add {
836
+ color: #22c55e;
837
+ background: #052e16;
838
+ }
839
+
840
+ UnifiedDiffView .diff-del {
841
+ color: #ef4444;
842
+ background: #450a0a;
843
+ }
844
+
845
+ UnifiedDiffView .diff-context {
846
+ color: #71717a;
847
+ }
848
+
849
+ UnifiedDiffView .diff-header {
850
+ color: #3b82f6;
851
+ text-style: bold;
852
+ }
853
+ """
854
+
855
+ def __init__(
856
+ self,
857
+ old_content: str,
858
+ new_content: str,
859
+ file_path: str = "",
860
+ **kwargs,
861
+ ):
862
+ super().__init__(**kwargs)
863
+ self.old_content = old_content or ""
864
+ self.new_content = new_content or ""
865
+ self.file_path = file_path
866
+
867
+ def compose(self):
868
+ """Compose the unified diff view."""
869
+ # Generate unified diff
870
+ import difflib
871
+
872
+ old_lines = self.old_content.splitlines(keepends=True)
873
+ new_lines = self.new_content.splitlines(keepends=True)
874
+
875
+ diff = difflib.unified_diff(
876
+ old_lines,
877
+ new_lines,
878
+ fromfile=f"a/{self.file_path}" if self.file_path else "a/file",
879
+ tofile=f"b/{self.file_path}" if self.file_path else "b/file",
880
+ lineterm="",
881
+ )
882
+
883
+ for line in diff:
884
+ line = line.rstrip("\n")
885
+ if line.startswith("+++") or line.startswith("---"):
886
+ yield Static(line, classes="diff-header")
887
+ elif line.startswith("@@"):
888
+ yield Static(line, classes="diff-header")
889
+ elif line.startswith("+"):
890
+ yield Static(line, classes="diff-add")
891
+ elif line.startswith("-"):
892
+ yield Static(line, classes="diff-del")
893
+ else:
894
+ yield Static(line, classes="diff-context")
895
+
896
+
897
+ class EnhancedPermissionPreviewScreen(Container):
898
+ """
899
+ Enhanced permission preview with multi-file support.
900
+
901
+ Features:
902
+ - Multi-file navigator (left sidebar)
903
+ - Diff view mode selector
904
+ - Split or unified diff view
905
+ - j/k navigation
906
+ - Action buttons
907
+ """
908
+
909
+ BINDINGS = [
910
+ Binding("y", "allow_once", "Allow", priority=True),
911
+ Binding("n", "deny", "Deny", priority=True),
912
+ Binding("a", "allow_always", "Always Allow", priority=True),
913
+ Binding("j", "next_request", "Next", priority=True),
914
+ Binding("k", "prev_request", "Previous", priority=True),
915
+ Binding("v", "toggle_diff_mode", "Toggle View", priority=True),
916
+ Binding("?", "show_help", "Help", priority=True),
917
+ Binding("escape", "cancel", "Cancel", priority=True),
918
+ ]
919
+
920
+ DEFAULT_CSS = """
921
+ EnhancedPermissionPreviewScreen {
922
+ width: 100%;
923
+ height: 100%;
924
+ background: #0f0f0f;
925
+ }
926
+
927
+ #preview-header {
928
+ height: 3;
929
+ background: #18181b;
930
+ padding: 0 1;
931
+ }
932
+
933
+ #preview-title {
934
+ color: #f59e0b;
935
+ text-style: bold;
936
+ }
937
+
938
+ #preview-body {
939
+ height: 1fr;
940
+ }
941
+
942
+ #diff-container {
943
+ width: 1fr;
944
+ }
945
+
946
+ #diff-toolbar {
947
+ height: 3;
948
+ background: #18181b;
949
+ padding: 0 1;
950
+ }
951
+
952
+ #preview-footer {
953
+ height: 4;
954
+ background: #18181b;
955
+ padding: 1;
956
+ }
957
+
958
+ #action-buttons {
959
+ align: center middle;
960
+ }
961
+
962
+ .action-btn {
963
+ margin: 0 1;
964
+ min-width: 14;
965
+ }
966
+
967
+ .allow-btn {
968
+ background: #22c55e;
969
+ }
970
+
971
+ .deny-btn {
972
+ background: #ef4444;
973
+ }
974
+
975
+ .always-btn {
976
+ background: #3b82f6;
977
+ }
978
+
979
+ #key-hints {
980
+ color: #52525b;
981
+ text-align: center;
982
+ }
983
+ """
984
+
985
+ diff_mode: reactive[DiffViewMode] = reactive(DiffViewMode.UNIFIED)
986
+ current_index: reactive[int] = reactive(0)
987
+
988
+ def __init__(
989
+ self,
990
+ requests: List[PermissionContext],
991
+ on_decision: Callable[[str, str], None],
992
+ **kwargs,
993
+ ):
994
+ super().__init__(**kwargs)
995
+ self.requests = requests
996
+ self._on_decision = on_decision
997
+
998
+ def compose(self):
999
+ """Compose the enhanced preview screen."""
1000
+ from textual.widgets import Button
1001
+
1002
+ # Header
1003
+ with Horizontal(id="preview-header"):
1004
+ yield Static(
1005
+ f" Permission Requests ({len(self.requests)} pending)",
1006
+ id="preview-title",
1007
+ )
1008
+
1009
+ # Body
1010
+ with Horizontal(id="preview-body"):
1011
+ # Navigator
1012
+ yield PermissionNavigator(
1013
+ self.requests,
1014
+ on_select=self._on_navigator_select,
1015
+ id="navigator",
1016
+ )
1017
+
1018
+ # Diff container
1019
+ with Vertical(id="diff-container"):
1020
+ # Toolbar
1021
+ with Horizontal(id="diff-toolbar"):
1022
+ yield Static("View Mode:", classes="toolbar-label")
1023
+ yield DiffModeSelector(id="diff-mode-selector")
1024
+ yield Static(self._get_request_info(), id="request-info")
1025
+
1026
+ # Diff view (will be updated based on mode)
1027
+ yield self._create_diff_view()
1028
+
1029
+ # Footer with actions
1030
+ with Vertical(id="preview-footer"):
1031
+ with Horizontal(id="action-buttons"):
1032
+ yield Button("Allow [y]", id="btn-allow", classes="action-btn allow-btn")
1033
+ yield Button("Deny [n]", id="btn-deny", classes="action-btn deny-btn")
1034
+ yield Button("Always [a]", id="btn-always", classes="action-btn always-btn")
1035
+
1036
+ yield Static(
1037
+ "[j/k] Navigate [v] Toggle View [y] Allow [n] Deny [a] Always [Esc] Cancel",
1038
+ id="key-hints",
1039
+ )
1040
+
1041
+ def _get_current_request(self) -> Optional[PermissionContext]:
1042
+ """Get the current request."""
1043
+ if 0 <= self.current_index < len(self.requests):
1044
+ return self.requests[self.current_index]
1045
+ return None
1046
+
1047
+ def _get_request_info(self) -> str:
1048
+ """Get info string for current request."""
1049
+ req = self._get_current_request()
1050
+ if not req:
1051
+ return ""
1052
+
1053
+ risk_style = RISK_STYLES.get(req.risk_level, RISK_STYLES["medium"])
1054
+ return f"{risk_style['icon']} {req.title}"
1055
+
1056
+ def _create_diff_view(self):
1057
+ """Create the appropriate diff view based on mode."""
1058
+ req = self._get_current_request()
1059
+ if not req:
1060
+ return Static("No requests")
1061
+
1062
+ old_content = req.original_content or ""
1063
+ new_content = req.new_content or ""
1064
+ file_path = req.file_path or ""
1065
+
1066
+ if self.diff_mode == DiffViewMode.SPLIT:
1067
+ return SyncedDiffView(
1068
+ old_content,
1069
+ new_content,
1070
+ file_path,
1071
+ id="diff-view",
1072
+ )
1073
+ else:
1074
+ return UnifiedDiffView(
1075
+ old_content,
1076
+ new_content,
1077
+ file_path,
1078
+ id="diff-view",
1079
+ )
1080
+
1081
+ def _on_navigator_select(self, index: int) -> None:
1082
+ """Handle navigator selection."""
1083
+ self.current_index = index
1084
+ self._refresh_diff_view()
1085
+
1086
+ def _refresh_diff_view(self) -> None:
1087
+ """Refresh the diff view."""
1088
+ # Update request info
1089
+ info = self.query_one("#request-info", Static)
1090
+ info.update(self._get_request_info())
1091
+
1092
+ # Replace diff view
1093
+ old_view = self.query_one("#diff-view")
1094
+ old_view.remove()
1095
+
1096
+ container = self.query_one("#diff-container", Vertical)
1097
+ container.mount(self._create_diff_view())
1098
+
1099
+ def watch_diff_mode(self, mode: DiffViewMode) -> None:
1100
+ """Handle diff mode change."""
1101
+ self._refresh_diff_view()
1102
+
1103
+ def on_select_changed(self, event: Select.Changed) -> None:
1104
+ """Handle diff mode selector change."""
1105
+ if event.select.id == "diff-mode-selector":
1106
+ self.diff_mode = DiffViewMode(event.value)
1107
+
1108
+ def on_button_pressed(self, event) -> None:
1109
+ """Handle button presses."""
1110
+ from textual.widgets import Button
1111
+
1112
+ if not isinstance(event.button, Button):
1113
+ return
1114
+
1115
+ if event.button.id == "btn-allow":
1116
+ self.action_allow_once()
1117
+ elif event.button.id == "btn-deny":
1118
+ self.action_deny()
1119
+ elif event.button.id == "btn-always":
1120
+ self.action_allow_always()
1121
+
1122
+ def action_allow_once(self) -> None:
1123
+ """Allow the current request once."""
1124
+ self._handle_decision("allow")
1125
+
1126
+ def action_deny(self) -> None:
1127
+ """Deny the current request."""
1128
+ self._handle_decision("deny")
1129
+
1130
+ def action_allow_always(self) -> None:
1131
+ """Always allow this type of request."""
1132
+ self._handle_decision("allow_always")
1133
+
1134
+ def action_next_request(self) -> None:
1135
+ """Go to next request."""
1136
+ if self.current_index < len(self.requests) - 1:
1137
+ self.current_index += 1
1138
+ self._refresh_diff_view()
1139
+ # Update navigator selection
1140
+ nav = self.query_one("#navigator", PermissionNavigator)
1141
+ nav.highlighted = self.current_index
1142
+
1143
+ def action_prev_request(self) -> None:
1144
+ """Go to previous request."""
1145
+ if self.current_index > 0:
1146
+ self.current_index -= 1
1147
+ self._refresh_diff_view()
1148
+ # Update navigator selection
1149
+ nav = self.query_one("#navigator", PermissionNavigator)
1150
+ nav.highlighted = self.current_index
1151
+
1152
+ def action_toggle_diff_mode(self) -> None:
1153
+ """Toggle between unified and split view."""
1154
+ if self.diff_mode == DiffViewMode.UNIFIED:
1155
+ self.diff_mode = DiffViewMode.SPLIT
1156
+ else:
1157
+ self.diff_mode = DiffViewMode.UNIFIED
1158
+
1159
+ def action_show_help(self) -> None:
1160
+ """Show help dialog."""
1161
+ # Could show a help modal here
1162
+ pass
1163
+
1164
+ def action_cancel(self) -> None:
1165
+ """Cancel and close."""
1166
+ self.remove()
1167
+
1168
+ def _handle_decision(self, action: str) -> None:
1169
+ """Handle a permission decision."""
1170
+ req = self._get_current_request()
1171
+ if not req:
1172
+ return
1173
+
1174
+ self._on_decision(req.request_id, action)
1175
+
1176
+ # Remove the request
1177
+ self.requests.pop(self.current_index)
1178
+
1179
+ if not self.requests:
1180
+ self.remove()
1181
+ return
1182
+
1183
+ # Adjust index
1184
+ if self.current_index >= len(self.requests):
1185
+ self.current_index = len(self.requests) - 1
1186
+
1187
+ # Refresh navigator
1188
+ nav = self.query_one("#navigator", PermissionNavigator)
1189
+ nav.remove()
1190
+
1191
+ body = self.query_one("#preview-body", Horizontal)
1192
+ body.mount(
1193
+ PermissionNavigator(
1194
+ self.requests,
1195
+ on_select=self._on_navigator_select,
1196
+ id="navigator",
1197
+ ),
1198
+ before=self.query_one("#diff-container"),
1199
+ )
1200
+
1201
+ self._refresh_diff_view()
1202
+
1203
+ # Update title
1204
+ title = self.query_one("#preview-title", Static)
1205
+ title.update(f" Permission Requests ({len(self.requests)} pending)")