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.
- superqode/__init__.py +33 -0
- superqode/acp/__init__.py +23 -0
- superqode/acp/client.py +913 -0
- superqode/acp/permission_screen.py +457 -0
- superqode/acp/types.py +480 -0
- superqode/acp_discovery.py +856 -0
- superqode/agent/__init__.py +22 -0
- superqode/agent/edit_strategies.py +334 -0
- superqode/agent/loop.py +892 -0
- superqode/agent/qe_report_templates.py +39 -0
- superqode/agent/system_prompts.py +353 -0
- superqode/agent_output.py +721 -0
- superqode/agent_stream.py +953 -0
- superqode/agents/__init__.py +59 -0
- superqode/agents/acp_registry.py +305 -0
- superqode/agents/client.py +249 -0
- superqode/agents/data/augmentcode.com.toml +51 -0
- superqode/agents/data/cagent.dev.toml +51 -0
- superqode/agents/data/claude.com.toml +60 -0
- superqode/agents/data/codeassistant.dev.toml +51 -0
- superqode/agents/data/codex.openai.com.toml +57 -0
- superqode/agents/data/fastagent.ai.toml +66 -0
- superqode/agents/data/geminicli.com.toml +77 -0
- superqode/agents/data/goose.block.xyz.toml +54 -0
- superqode/agents/data/junie.jetbrains.com.toml +56 -0
- superqode/agents/data/kimi.moonshot.cn.toml +57 -0
- superqode/agents/data/llmlingagent.dev.toml +51 -0
- superqode/agents/data/molt.bot.toml +49 -0
- superqode/agents/data/opencode.ai.toml +60 -0
- superqode/agents/data/stakpak.dev.toml +51 -0
- superqode/agents/data/vtcode.dev.toml +51 -0
- superqode/agents/discovery.py +266 -0
- superqode/agents/messaging.py +160 -0
- superqode/agents/persona.py +166 -0
- superqode/agents/registry.py +421 -0
- superqode/agents/schema.py +72 -0
- superqode/agents/unified.py +367 -0
- superqode/app/__init__.py +111 -0
- superqode/app/constants.py +314 -0
- superqode/app/css.py +366 -0
- superqode/app/models.py +118 -0
- superqode/app/suggester.py +125 -0
- superqode/app/widgets.py +1591 -0
- superqode/app_enhanced.py +399 -0
- superqode/app_main.py +17187 -0
- superqode/approval.py +312 -0
- superqode/atomic.py +296 -0
- superqode/commands/__init__.py +1 -0
- superqode/commands/acp.py +965 -0
- superqode/commands/agents.py +180 -0
- superqode/commands/auth.py +278 -0
- superqode/commands/config.py +374 -0
- superqode/commands/init.py +826 -0
- superqode/commands/providers.py +819 -0
- superqode/commands/qe.py +1145 -0
- superqode/commands/roles.py +380 -0
- superqode/commands/serve.py +172 -0
- superqode/commands/suggestions.py +127 -0
- superqode/commands/superqe.py +460 -0
- superqode/config/__init__.py +51 -0
- superqode/config/loader.py +812 -0
- superqode/config/schema.py +498 -0
- superqode/core/__init__.py +111 -0
- superqode/core/roles.py +281 -0
- superqode/danger.py +386 -0
- superqode/data/superqode-template.yaml +1522 -0
- superqode/design_system.py +1080 -0
- superqode/dialogs/__init__.py +6 -0
- superqode/dialogs/base.py +39 -0
- superqode/dialogs/model.py +130 -0
- superqode/dialogs/provider.py +870 -0
- superqode/diff_view.py +919 -0
- superqode/enterprise.py +21 -0
- superqode/evaluation/__init__.py +25 -0
- superqode/evaluation/adapters.py +93 -0
- superqode/evaluation/behaviors.py +89 -0
- superqode/evaluation/engine.py +209 -0
- superqode/evaluation/scenarios.py +96 -0
- superqode/execution/__init__.py +36 -0
- superqode/execution/linter.py +538 -0
- superqode/execution/modes.py +347 -0
- superqode/execution/resolver.py +283 -0
- superqode/execution/runner.py +642 -0
- superqode/file_explorer.py +811 -0
- superqode/file_viewer.py +471 -0
- superqode/flash.py +183 -0
- superqode/guidance/__init__.py +58 -0
- superqode/guidance/config.py +203 -0
- superqode/guidance/prompts.py +71 -0
- superqode/harness/__init__.py +54 -0
- superqode/harness/accelerator.py +291 -0
- superqode/harness/config.py +319 -0
- superqode/harness/validator.py +147 -0
- superqode/history.py +279 -0
- superqode/integrations/superopt_runner.py +124 -0
- superqode/logging/__init__.py +49 -0
- superqode/logging/adapters.py +219 -0
- superqode/logging/formatter.py +923 -0
- superqode/logging/integration.py +341 -0
- superqode/logging/sinks.py +170 -0
- superqode/logging/unified_log.py +417 -0
- superqode/lsp/__init__.py +26 -0
- superqode/lsp/client.py +544 -0
- superqode/main.py +1069 -0
- superqode/mcp/__init__.py +89 -0
- superqode/mcp/auth_storage.py +380 -0
- superqode/mcp/client.py +1236 -0
- superqode/mcp/config.py +319 -0
- superqode/mcp/integration.py +337 -0
- superqode/mcp/oauth.py +436 -0
- superqode/mcp/oauth_callback.py +385 -0
- superqode/mcp/types.py +290 -0
- superqode/memory/__init__.py +31 -0
- superqode/memory/feedback.py +342 -0
- superqode/memory/store.py +522 -0
- superqode/notifications.py +369 -0
- superqode/optimization/__init__.py +5 -0
- superqode/optimization/config.py +33 -0
- superqode/permissions/__init__.py +25 -0
- superqode/permissions/rules.py +488 -0
- superqode/plan.py +323 -0
- superqode/providers/__init__.py +33 -0
- superqode/providers/gateway/__init__.py +165 -0
- superqode/providers/gateway/base.py +228 -0
- superqode/providers/gateway/litellm_gateway.py +1170 -0
- superqode/providers/gateway/openresponses_gateway.py +436 -0
- superqode/providers/health.py +297 -0
- superqode/providers/huggingface/__init__.py +74 -0
- superqode/providers/huggingface/downloader.py +472 -0
- superqode/providers/huggingface/endpoints.py +442 -0
- superqode/providers/huggingface/hub.py +531 -0
- superqode/providers/huggingface/inference.py +394 -0
- superqode/providers/huggingface/transformers_runner.py +516 -0
- superqode/providers/local/__init__.py +100 -0
- superqode/providers/local/base.py +438 -0
- superqode/providers/local/discovery.py +418 -0
- superqode/providers/local/lmstudio.py +256 -0
- superqode/providers/local/mlx.py +457 -0
- superqode/providers/local/ollama.py +486 -0
- superqode/providers/local/sglang.py +268 -0
- superqode/providers/local/tgi.py +260 -0
- superqode/providers/local/tool_support.py +477 -0
- superqode/providers/local/vllm.py +258 -0
- superqode/providers/manager.py +1338 -0
- superqode/providers/models.py +1016 -0
- superqode/providers/models_dev.py +578 -0
- superqode/providers/openresponses/__init__.py +87 -0
- superqode/providers/openresponses/converters/__init__.py +17 -0
- superqode/providers/openresponses/converters/messages.py +343 -0
- superqode/providers/openresponses/converters/tools.py +268 -0
- superqode/providers/openresponses/schema/__init__.py +56 -0
- superqode/providers/openresponses/schema/models.py +585 -0
- superqode/providers/openresponses/streaming/__init__.py +5 -0
- superqode/providers/openresponses/streaming/parser.py +338 -0
- superqode/providers/openresponses/tools/__init__.py +21 -0
- superqode/providers/openresponses/tools/apply_patch.py +352 -0
- superqode/providers/openresponses/tools/code_interpreter.py +290 -0
- superqode/providers/openresponses/tools/file_search.py +333 -0
- superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
- superqode/providers/registry.py +716 -0
- superqode/providers/usage.py +332 -0
- superqode/pure_mode.py +384 -0
- superqode/qr/__init__.py +23 -0
- superqode/qr/dashboard.py +781 -0
- superqode/qr/generator.py +1018 -0
- superqode/qr/templates.py +135 -0
- superqode/safety/__init__.py +41 -0
- superqode/safety/sandbox.py +413 -0
- superqode/safety/warnings.py +256 -0
- superqode/server/__init__.py +33 -0
- superqode/server/lsp_server.py +775 -0
- superqode/server/web.py +250 -0
- superqode/session/__init__.py +25 -0
- superqode/session/persistence.py +580 -0
- superqode/session/sharing.py +477 -0
- superqode/session.py +475 -0
- superqode/sidebar.py +2991 -0
- superqode/stream_view.py +648 -0
- superqode/styles/__init__.py +3 -0
- superqode/superqe/__init__.py +184 -0
- superqode/superqe/acp_runner.py +1064 -0
- superqode/superqe/constitution/__init__.py +62 -0
- superqode/superqe/constitution/evaluator.py +308 -0
- superqode/superqe/constitution/loader.py +432 -0
- superqode/superqe/constitution/schema.py +250 -0
- superqode/superqe/events.py +591 -0
- superqode/superqe/frameworks/__init__.py +65 -0
- superqode/superqe/frameworks/base.py +234 -0
- superqode/superqe/frameworks/e2e.py +263 -0
- superqode/superqe/frameworks/executor.py +237 -0
- superqode/superqe/frameworks/javascript.py +409 -0
- superqode/superqe/frameworks/python.py +373 -0
- superqode/superqe/frameworks/registry.py +92 -0
- superqode/superqe/mcp_tools/__init__.py +47 -0
- superqode/superqe/mcp_tools/core_tools.py +418 -0
- superqode/superqe/mcp_tools/registry.py +230 -0
- superqode/superqe/mcp_tools/testing_tools.py +167 -0
- superqode/superqe/noise.py +89 -0
- superqode/superqe/orchestrator.py +778 -0
- superqode/superqe/roles.py +609 -0
- superqode/superqe/session.py +713 -0
- superqode/superqe/skills/__init__.py +57 -0
- superqode/superqe/skills/base.py +106 -0
- superqode/superqe/skills/core_skills.py +899 -0
- superqode/superqe/skills/registry.py +90 -0
- superqode/superqe/verifier.py +101 -0
- superqode/superqe_cli.py +76 -0
- superqode/tool_call.py +358 -0
- superqode/tools/__init__.py +93 -0
- superqode/tools/agent_tools.py +496 -0
- superqode/tools/base.py +324 -0
- superqode/tools/batch_tool.py +133 -0
- superqode/tools/diagnostics.py +311 -0
- superqode/tools/edit_tools.py +653 -0
- superqode/tools/enhanced_base.py +515 -0
- superqode/tools/file_tools.py +269 -0
- superqode/tools/file_tracking.py +45 -0
- superqode/tools/lsp_tools.py +610 -0
- superqode/tools/network_tools.py +350 -0
- superqode/tools/permissions.py +400 -0
- superqode/tools/question_tool.py +324 -0
- superqode/tools/search_tools.py +598 -0
- superqode/tools/shell_tools.py +259 -0
- superqode/tools/todo_tools.py +121 -0
- superqode/tools/validation.py +80 -0
- superqode/tools/web_tools.py +639 -0
- superqode/tui.py +1152 -0
- superqode/tui_integration.py +875 -0
- superqode/tui_widgets/__init__.py +27 -0
- superqode/tui_widgets/widgets/__init__.py +18 -0
- superqode/tui_widgets/widgets/progress.py +185 -0
- superqode/tui_widgets/widgets/tool_display.py +188 -0
- superqode/undo_manager.py +574 -0
- superqode/utils/__init__.py +5 -0
- superqode/utils/error_handling.py +323 -0
- superqode/utils/fuzzy.py +257 -0
- superqode/widgets/__init__.py +477 -0
- superqode/widgets/agent_collab.py +390 -0
- superqode/widgets/agent_store.py +936 -0
- superqode/widgets/agent_switcher.py +395 -0
- superqode/widgets/animation_manager.py +284 -0
- superqode/widgets/code_context.py +356 -0
- superqode/widgets/command_palette.py +412 -0
- superqode/widgets/connection_status.py +537 -0
- superqode/widgets/conversation_history.py +470 -0
- superqode/widgets/diff_indicator.py +155 -0
- superqode/widgets/enhanced_status_bar.py +385 -0
- superqode/widgets/enhanced_toast.py +476 -0
- superqode/widgets/file_browser.py +809 -0
- superqode/widgets/file_reference.py +585 -0
- superqode/widgets/issue_timeline.py +340 -0
- superqode/widgets/leader_key.py +264 -0
- superqode/widgets/mode_switcher.py +445 -0
- superqode/widgets/model_picker.py +234 -0
- superqode/widgets/permission_preview.py +1205 -0
- superqode/widgets/prompt.py +358 -0
- superqode/widgets/provider_connect.py +725 -0
- superqode/widgets/pty_shell.py +587 -0
- superqode/widgets/qe_dashboard.py +321 -0
- superqode/widgets/resizable_sidebar.py +377 -0
- superqode/widgets/response_changes.py +218 -0
- superqode/widgets/response_display.py +528 -0
- superqode/widgets/rich_tool_display.py +613 -0
- superqode/widgets/sidebar_panels.py +1180 -0
- superqode/widgets/slash_complete.py +356 -0
- superqode/widgets/split_view.py +612 -0
- superqode/widgets/status_bar.py +273 -0
- superqode/widgets/superqode_display.py +786 -0
- superqode/widgets/thinking_display.py +815 -0
- superqode/widgets/throbber.py +87 -0
- superqode/widgets/toast.py +206 -0
- superqode/widgets/unified_output.py +1073 -0
- superqode/workspace/__init__.py +75 -0
- superqode/workspace/artifacts.py +472 -0
- superqode/workspace/coordinator.py +353 -0
- superqode/workspace/diff_tracker.py +429 -0
- superqode/workspace/git_guard.py +373 -0
- superqode/workspace/git_snapshot.py +526 -0
- superqode/workspace/manager.py +750 -0
- superqode/workspace/snapshot.py +357 -0
- superqode/workspace/watcher.py +535 -0
- superqode/workspace/worktree.py +440 -0
- superqode-0.1.5.dist-info/METADATA +204 -0
- superqode-0.1.5.dist-info/RECORD +288 -0
- superqode-0.1.5.dist-info/WHEEL +5 -0
- superqode-0.1.5.dist-info/entry_points.txt +3 -0
- superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
- 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)")
|