omnimancer-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- omnimancer/__init__.py +42 -0
- omnimancer/__main__.py +9 -0
- omnimancer/cli/__init__.py +8 -0
- omnimancer/cli/agent_manager.py +826 -0
- omnimancer/cli/agent_wizard.py +944 -0
- omnimancer/cli/approval_formatter.py +734 -0
- omnimancer/cli/approval_integration.py +734 -0
- omnimancer/cli/approval_prompt.py +1286 -0
- omnimancer/cli/batch_approval_display.py +617 -0
- omnimancer/cli/batch_approval_filters.py +729 -0
- omnimancer/cli/batch_operation_monitor.py +775 -0
- omnimancer/cli/commands/performance_cmd.py +624 -0
- omnimancer/cli/commands.py +757 -0
- omnimancer/cli/completion_handler.py +281 -0
- omnimancer/cli/config_setup_wizard.py +783 -0
- omnimancer/cli/dynamic_commands.py +371 -0
- omnimancer/cli/handlers/__init__.py +18 -0
- omnimancer/cli/handlers/agent_handler.py +865 -0
- omnimancer/cli/handlers/agent_persona_handler.py +1048 -0
- omnimancer/cli/handlers/custom_agent_handler.py +352 -0
- omnimancer/cli/handlers/permissions_handler.py +1609 -0
- omnimancer/cli/interface.py +6695 -0
- omnimancer/cli/performance_dashboard.py +612 -0
- omnimancer/core/__init__.py +34 -0
- omnimancer/core/agent/__init__.py +256 -0
- omnimancer/core/agent/agent_config.py +715 -0
- omnimancer/core/agent/agent_switcher.py +668 -0
- omnimancer/core/agent/approval_context.py +460 -0
- omnimancer/core/agent/approval_dialog.py +902 -0
- omnimancer/core/agent/approval_interface.py +628 -0
- omnimancer/core/agent/approval_manager.py +760 -0
- omnimancer/core/agent/cli_integration.py +470 -0
- omnimancer/core/agent/config.py +845 -0
- omnimancer/core/agent/custom_agent_integration.py +654 -0
- omnimancer/core/agent/diff_renderer.py +706 -0
- omnimancer/core/agent/file_content_display.py +503 -0
- omnimancer/core/agent/file_modification_workflow.py +658 -0
- omnimancer/core/agent/file_system_manager.py +1790 -0
- omnimancer/core/agent/input_handler.py +614 -0
- omnimancer/core/agent/metrics_collector.py +771 -0
- omnimancer/core/agent/optimization_engine.py +729 -0
- omnimancer/core/agent/performance_monitor.py +717 -0
- omnimancer/core/agent/persona.py +870 -0
- omnimancer/core/agent/persona_validator.py +672 -0
- omnimancer/core/agent/program_executor.py +748 -0
- omnimancer/core/agent/proposed_changes_integration.py +663 -0
- omnimancer/core/agent/read_before_write_errors.py +492 -0
- omnimancer/core/agent/read_before_write_ui.py +647 -0
- omnimancer/core/agent/rich_renderer.py +763 -0
- omnimancer/core/agent/status_config.py +238 -0
- omnimancer/core/agent/status_core.py +446 -0
- omnimancer/core/agent/status_integrated_engine.py +540 -0
- omnimancer/core/agent/status_manager.py +878 -0
- omnimancer/core/agent/status_ui_components.py +535 -0
- omnimancer/core/agent/template_applicator.py +548 -0
- omnimancer/core/agent/terminal_status_listener.py +394 -0
- omnimancer/core/agent/token_tracker.py +679 -0
- omnimancer/core/agent/types.py +60 -0
- omnimancer/core/agent/web_client.py +745 -0
- omnimancer/core/agent/workflow_orchestrator.py +826 -0
- omnimancer/core/agent_engine.py +1625 -0
- omnimancer/core/agent_mode_manager.py +706 -0
- omnimancer/core/agent_progress_ui.py +545 -0
- omnimancer/core/chat_manager.py +141 -0
- omnimancer/core/config_manager.py +2199 -0
- omnimancer/core/config_migration.py +772 -0
- omnimancer/core/config_provider.py +1974 -0
- omnimancer/core/config_security_validator.py +393 -0
- omnimancer/core/config_validator.py +768 -0
- omnimancer/core/conversation_manager.py +352 -0
- omnimancer/core/engine.py +787 -0
- omnimancer/core/enhanced_input.py +523 -0
- omnimancer/core/env_loader.py +83 -0
- omnimancer/core/fallback_manager.py +568 -0
- omnimancer/core/health_monitor.py +462 -0
- omnimancer/core/history_manager.py +462 -0
- omnimancer/core/mcp_integration_layer.py +878 -0
- omnimancer/core/models.py +1889 -0
- omnimancer/core/provider_capabilities.py +403 -0
- omnimancer/core/provider_initializer.py +450 -0
- omnimancer/core/provider_registry.py +432 -0
- omnimancer/core/security/__init__.py +16 -0
- omnimancer/core/security/approval_workflow.py +406 -0
- omnimancer/core/security/audit_logger.py +511 -0
- omnimancer/core/security/permission_controller.py +511 -0
- omnimancer/core/security/sandbox_manager.py +379 -0
- omnimancer/core/security/security_manager.py +530 -0
- omnimancer/core/setup_wizard.py +345 -0
- omnimancer/core/setup_wizard_core.py +449 -0
- omnimancer/core/setup_wizard_guide_generator.py +376 -0
- omnimancer/core/setup_wizard_provider_setup.py +454 -0
- omnimancer/core/setup_wizard_ui.py +317 -0
- omnimancer/core/setup_wizard_validation.py +371 -0
- omnimancer/core/signal_handler.py +174 -0
- omnimancer/mcp/__init__.py +11 -0
- omnimancer/mcp/client.py +725 -0
- omnimancer/mcp/manager.py +649 -0
- omnimancer/providers/__init__.py +24 -0
- omnimancer/providers/azure.py +608 -0
- omnimancer/providers/base.py +262 -0
- omnimancer/providers/bedrock.py +711 -0
- omnimancer/providers/claude.py +426 -0
- omnimancer/providers/claude_code.py +406 -0
- omnimancer/providers/cohere.py +412 -0
- omnimancer/providers/factory.py +988 -0
- omnimancer/providers/gemini.py +708 -0
- omnimancer/providers/mistral.py +554 -0
- omnimancer/providers/ollama.py +510 -0
- omnimancer/providers/openai.py +362 -0
- omnimancer/providers/openrouter.py +709 -0
- omnimancer/providers/perplexity.py +476 -0
- omnimancer/providers/vertex.py +617 -0
- omnimancer/providers/xai.py +484 -0
- omnimancer/ui/__init__.py +6 -0
- omnimancer/ui/cancellation_handler.py +235 -0
- omnimancer/ui/progress_indicator.py +318 -0
- omnimancer/utils/__init__.py +22 -0
- omnimancer/utils/error_handler.py +589 -0
- omnimancer/utils/errors.py +667 -0
- omnimancer/utils/retry.py +238 -0
- omnimancer_cli-0.1.0.dist-info/METADATA +413 -0
- omnimancer_cli-0.1.0.dist-info/RECORD +126 -0
- omnimancer_cli-0.1.0.dist-info/WHEEL +5 -0
- omnimancer_cli-0.1.0.dist-info/entry_points.txt +4 -0
- omnimancer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- omnimancer_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhanced Diff Display and Code Highlighting for Omnimancer Approval Interface.
|
|
3
|
+
|
|
4
|
+
This module provides sophisticated diff visualization with syntax highlighting,
|
|
5
|
+
side-by-side comparisons, and enhanced formatting for code changes in the
|
|
6
|
+
approval workflow.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import difflib
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Dict, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
from rich.box import ROUNDED, SIMPLE
|
|
17
|
+
from rich.console import Console, RenderableType
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.rule import Rule
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
from rich.text import Text
|
|
22
|
+
from rich.tree import Tree
|
|
23
|
+
|
|
24
|
+
from .rich_renderer import RichTextRenderer, create_renderer
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DiffType(Enum):
|
|
28
|
+
"""Types of diff display formats."""
|
|
29
|
+
|
|
30
|
+
UNIFIED = "unified"
|
|
31
|
+
SIDE_BY_SIDE = "side_by_side"
|
|
32
|
+
INLINE = "inline"
|
|
33
|
+
CONTEXT = "context"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FileChangeType(Enum):
|
|
37
|
+
"""Types of file changes."""
|
|
38
|
+
|
|
39
|
+
ADDED = "added"
|
|
40
|
+
MODIFIED = "modified"
|
|
41
|
+
DELETED = "deleted"
|
|
42
|
+
RENAMED = "renamed"
|
|
43
|
+
COPIED = "copied"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class FileChange:
|
|
48
|
+
"""Represents a single file change in a diff."""
|
|
49
|
+
|
|
50
|
+
file_path: str
|
|
51
|
+
change_type: FileChangeType
|
|
52
|
+
old_path: Optional[str] = None
|
|
53
|
+
new_path: Optional[str] = None
|
|
54
|
+
old_content: Optional[str] = None
|
|
55
|
+
new_content: Optional[str] = None
|
|
56
|
+
diff_text: Optional[str] = None
|
|
57
|
+
language: Optional[str] = None
|
|
58
|
+
lines_added: int = 0
|
|
59
|
+
lines_removed: int = 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class DiffChunk:
|
|
64
|
+
"""Represents a chunk of changes within a file."""
|
|
65
|
+
|
|
66
|
+
old_start: int
|
|
67
|
+
old_count: int
|
|
68
|
+
new_start: int
|
|
69
|
+
new_count: int
|
|
70
|
+
context_before: List[str]
|
|
71
|
+
changes: List[Tuple[str, str]] # (change_type, line_content)
|
|
72
|
+
context_after: List[str]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class EnhancedDiffRenderer:
|
|
76
|
+
"""
|
|
77
|
+
Enhanced diff renderer with syntax highlighting and multiple display formats.
|
|
78
|
+
|
|
79
|
+
Provides sophisticated diff visualization capabilities for the approval interface,
|
|
80
|
+
including side-by-side comparisons, syntax highlighting, and file tree views.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
renderer: Optional[RichTextRenderer] = None,
|
|
86
|
+
console: Optional[Console] = None,
|
|
87
|
+
):
|
|
88
|
+
"""
|
|
89
|
+
Initialize the enhanced diff renderer.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
renderer: Rich text renderer instance
|
|
93
|
+
console: Rich console instance
|
|
94
|
+
"""
|
|
95
|
+
self.renderer = renderer or create_renderer()
|
|
96
|
+
self.console = console or self.renderer.console
|
|
97
|
+
|
|
98
|
+
# Diff rendering configuration
|
|
99
|
+
self.max_line_length = 120
|
|
100
|
+
self.context_lines = 3
|
|
101
|
+
self.show_line_numbers = True
|
|
102
|
+
self.highlight_syntax = True
|
|
103
|
+
self.word_level_diff = True
|
|
104
|
+
|
|
105
|
+
# Language detection patterns
|
|
106
|
+
self.language_patterns = {
|
|
107
|
+
r"\.py$": "python",
|
|
108
|
+
r"\.js$": "javascript",
|
|
109
|
+
r"\.ts$": "typescript",
|
|
110
|
+
r"\.jsx$": "jsx",
|
|
111
|
+
r"\.tsx$": "tsx",
|
|
112
|
+
r"\.json$": "json",
|
|
113
|
+
r"\.yaml$|\.yml$": "yaml",
|
|
114
|
+
r"\.md$": "markdown",
|
|
115
|
+
r"\.sh$": "bash",
|
|
116
|
+
r"\.sql$": "sql",
|
|
117
|
+
r"\.css$": "css",
|
|
118
|
+
r"\.html$": "html",
|
|
119
|
+
r"\.xml$": "xml",
|
|
120
|
+
r"\.go$": "go",
|
|
121
|
+
r"\.rs$": "rust",
|
|
122
|
+
r"\.java$": "java",
|
|
123
|
+
r"\.cpp$|\.cc$|\.cxx$": "cpp",
|
|
124
|
+
r"\.c$": "c",
|
|
125
|
+
r"\.h$": "c",
|
|
126
|
+
r"\.php$": "php",
|
|
127
|
+
r"\.rb$": "ruby",
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
def detect_language(self, file_path: str) -> Optional[str]:
|
|
131
|
+
"""
|
|
132
|
+
Detect programming language from file path.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
file_path: Path to the file
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Detected language or None
|
|
139
|
+
"""
|
|
140
|
+
for pattern, language in self.language_patterns.items():
|
|
141
|
+
if re.search(pattern, file_path, re.IGNORECASE):
|
|
142
|
+
return language
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
def parse_unified_diff(self, diff_text: str) -> List[FileChange]:
|
|
146
|
+
"""
|
|
147
|
+
Parse unified diff format into structured file changes.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
diff_text: Raw unified diff text
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
List of parsed file changes
|
|
154
|
+
"""
|
|
155
|
+
file_changes = []
|
|
156
|
+
current_change = None
|
|
157
|
+
|
|
158
|
+
lines = diff_text.split("\n")
|
|
159
|
+
i = 0
|
|
160
|
+
|
|
161
|
+
while i < len(lines):
|
|
162
|
+
line = lines[i]
|
|
163
|
+
|
|
164
|
+
# File header detection
|
|
165
|
+
if line.startswith("diff --git"):
|
|
166
|
+
if current_change:
|
|
167
|
+
file_changes.append(current_change)
|
|
168
|
+
|
|
169
|
+
# Parse git diff header
|
|
170
|
+
parts = line.split()
|
|
171
|
+
if len(parts) >= 4:
|
|
172
|
+
old_file = parts[2][2:] # Remove 'a/' prefix
|
|
173
|
+
new_file = parts[3][2:] # Remove 'b/' prefix
|
|
174
|
+
|
|
175
|
+
current_change = FileChange(
|
|
176
|
+
file_path=new_file,
|
|
177
|
+
change_type=FileChangeType.MODIFIED,
|
|
178
|
+
old_path=old_file,
|
|
179
|
+
new_path=new_file,
|
|
180
|
+
language=self.detect_language(new_file),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
elif line.startswith("new file mode"):
|
|
184
|
+
if current_change:
|
|
185
|
+
current_change.change_type = FileChangeType.ADDED
|
|
186
|
+
|
|
187
|
+
elif line.startswith("deleted file mode"):
|
|
188
|
+
if current_change:
|
|
189
|
+
current_change.change_type = FileChangeType.DELETED
|
|
190
|
+
|
|
191
|
+
elif line.startswith("rename from"):
|
|
192
|
+
if current_change:
|
|
193
|
+
current_change.change_type = FileChangeType.RENAMED
|
|
194
|
+
current_change.old_path = line[12:] # Remove 'rename from '
|
|
195
|
+
|
|
196
|
+
elif line.startswith("rename to"):
|
|
197
|
+
if current_change:
|
|
198
|
+
current_change.new_path = line[10:] # Remove 'rename to '
|
|
199
|
+
current_change.file_path = current_change.new_path
|
|
200
|
+
|
|
201
|
+
elif line.startswith("@@"):
|
|
202
|
+
# Hunk header - extract line numbers and changes
|
|
203
|
+
if current_change:
|
|
204
|
+
# Collect diff content for this hunk
|
|
205
|
+
diff_lines = [line]
|
|
206
|
+
i += 1
|
|
207
|
+
|
|
208
|
+
while (
|
|
209
|
+
i < len(lines)
|
|
210
|
+
and not lines[i].startswith("@@")
|
|
211
|
+
and not lines[i].startswith("diff")
|
|
212
|
+
):
|
|
213
|
+
diff_line = lines[i]
|
|
214
|
+
diff_lines.append(diff_line)
|
|
215
|
+
|
|
216
|
+
# Count additions and deletions
|
|
217
|
+
if diff_line.startswith("+") and not diff_line.startswith(
|
|
218
|
+
"+++"
|
|
219
|
+
):
|
|
220
|
+
current_change.lines_added += 1
|
|
221
|
+
elif diff_line.startswith("-") and not diff_line.startswith(
|
|
222
|
+
"---"
|
|
223
|
+
):
|
|
224
|
+
current_change.lines_removed += 1
|
|
225
|
+
|
|
226
|
+
i += 1
|
|
227
|
+
|
|
228
|
+
# Store diff text for this file
|
|
229
|
+
if current_change.diff_text is None:
|
|
230
|
+
current_change.diff_text = "\n".join(diff_lines)
|
|
231
|
+
else:
|
|
232
|
+
current_change.diff_text += "\n" + "\n".join(diff_lines)
|
|
233
|
+
|
|
234
|
+
i -= 1 # Back up one line since we'll increment at end of loop
|
|
235
|
+
|
|
236
|
+
i += 1
|
|
237
|
+
|
|
238
|
+
# Add the last file change
|
|
239
|
+
if current_change:
|
|
240
|
+
file_changes.append(current_change)
|
|
241
|
+
|
|
242
|
+
return file_changes
|
|
243
|
+
|
|
244
|
+
def render_file_tree(self, file_changes: List[FileChange]) -> Tree:
|
|
245
|
+
"""
|
|
246
|
+
Render a tree view of all changed files.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
file_changes: List of file changes to display
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Rich Tree object with file changes
|
|
253
|
+
"""
|
|
254
|
+
tree = Tree("📁 Changed Files", style="bold blue")
|
|
255
|
+
|
|
256
|
+
# Group files by directory
|
|
257
|
+
file_groups: Dict[str, List[FileChange]] = {}
|
|
258
|
+
|
|
259
|
+
for change in file_changes:
|
|
260
|
+
directory = str(Path(change.file_path).parent)
|
|
261
|
+
if directory == ".":
|
|
262
|
+
directory = "root"
|
|
263
|
+
|
|
264
|
+
if directory not in file_groups:
|
|
265
|
+
file_groups[directory] = []
|
|
266
|
+
file_groups[directory].append(change)
|
|
267
|
+
|
|
268
|
+
# Add each directory and its files
|
|
269
|
+
for directory, changes in sorted(file_groups.items()):
|
|
270
|
+
if len(file_groups) > 1:
|
|
271
|
+
dir_node = tree.add(f"📂 {directory}", style="cyan")
|
|
272
|
+
else:
|
|
273
|
+
dir_node = tree
|
|
274
|
+
|
|
275
|
+
for change in sorted(changes, key=lambda x: x.file_path):
|
|
276
|
+
# Choose icon based on change type
|
|
277
|
+
icon_map = {
|
|
278
|
+
FileChangeType.ADDED: "🆕",
|
|
279
|
+
FileChangeType.MODIFIED: "📝",
|
|
280
|
+
FileChangeType.DELETED: "🗑️",
|
|
281
|
+
FileChangeType.RENAMED: "🔄",
|
|
282
|
+
FileChangeType.COPIED: "📋",
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
icon = icon_map.get(change.change_type, "📄")
|
|
286
|
+
|
|
287
|
+
# Color based on change type
|
|
288
|
+
color_map = {
|
|
289
|
+
FileChangeType.ADDED: "green",
|
|
290
|
+
FileChangeType.MODIFIED: "yellow",
|
|
291
|
+
FileChangeType.DELETED: "red",
|
|
292
|
+
FileChangeType.RENAMED: "blue",
|
|
293
|
+
FileChangeType.COPIED: "cyan",
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
color = color_map.get(change.change_type, "white")
|
|
297
|
+
|
|
298
|
+
# File name with stats
|
|
299
|
+
file_name = Path(change.file_path).name
|
|
300
|
+
stats = (
|
|
301
|
+
f"(+{change.lines_added}/-{change.lines_removed})"
|
|
302
|
+
if change.lines_added or change.lines_removed
|
|
303
|
+
else ""
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
dir_node.add(f"{icon} {file_name} {stats}", style=color)
|
|
307
|
+
|
|
308
|
+
return tree
|
|
309
|
+
|
|
310
|
+
def render_unified_diff(self, file_change: FileChange) -> Panel:
|
|
311
|
+
"""
|
|
312
|
+
Render a unified diff view for a single file.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
file_change: File change to render
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Rich Panel containing the diff
|
|
319
|
+
"""
|
|
320
|
+
if not file_change.diff_text:
|
|
321
|
+
return Panel("No changes to display", title=file_change.file_path)
|
|
322
|
+
|
|
323
|
+
# Apply syntax highlighting and diff coloring
|
|
324
|
+
diff_content = self.renderer.apply_diff_highlighting(
|
|
325
|
+
file_change.diff_text, file_type=file_change.language
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Create title with change type and stats
|
|
329
|
+
change_type_colors = {
|
|
330
|
+
FileChangeType.ADDED: "green",
|
|
331
|
+
FileChangeType.MODIFIED: "yellow",
|
|
332
|
+
FileChangeType.DELETED: "red",
|
|
333
|
+
FileChangeType.RENAMED: "blue",
|
|
334
|
+
FileChangeType.COPIED: "cyan",
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
title_color = change_type_colors.get(file_change.change_type, "white")
|
|
338
|
+
stats = f"(+{file_change.lines_added}/-{file_change.lines_removed})"
|
|
339
|
+
|
|
340
|
+
title = Text()
|
|
341
|
+
title.append(file_change.file_path, style=f"bold {title_color}")
|
|
342
|
+
title.append(f" {stats}", style="dim")
|
|
343
|
+
|
|
344
|
+
return Panel(diff_content, title=title, border_style=title_color, expand=False)
|
|
345
|
+
|
|
346
|
+
def render_side_by_side_diff(self, file_change: FileChange) -> Panel:
|
|
347
|
+
"""
|
|
348
|
+
Render a side-by-side diff view for a single file.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
file_change: File change to render
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Rich Panel containing the side-by-side diff
|
|
355
|
+
"""
|
|
356
|
+
if not file_change.old_content or not file_change.new_content:
|
|
357
|
+
return self.render_unified_diff(file_change)
|
|
358
|
+
|
|
359
|
+
# Split content into lines
|
|
360
|
+
old_lines = file_change.old_content.split("\n")
|
|
361
|
+
new_lines = file_change.new_content.split("\n")
|
|
362
|
+
|
|
363
|
+
# Create side-by-side comparison
|
|
364
|
+
table = Table(
|
|
365
|
+
show_header=True,
|
|
366
|
+
header_style="bold",
|
|
367
|
+
box=SIMPLE,
|
|
368
|
+
expand=True,
|
|
369
|
+
min_width=self.renderer.get_terminal_width() - 4,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
table.add_column("Old", style="red", width=50)
|
|
373
|
+
table.add_column("New", style="green", width=50)
|
|
374
|
+
|
|
375
|
+
# Use difflib for intelligent line matching
|
|
376
|
+
differ = difflib.SequenceMatcher(None, old_lines, new_lines)
|
|
377
|
+
|
|
378
|
+
for tag, i1, i2, j1, j2 in differ.get_opcodes():
|
|
379
|
+
if tag == "equal":
|
|
380
|
+
# Lines are identical
|
|
381
|
+
for i in range(i2 - i1):
|
|
382
|
+
old_line = old_lines[i1 + i] if i1 + i < len(old_lines) else ""
|
|
383
|
+
new_line = new_lines[j1 + i] if j1 + i < len(new_lines) else ""
|
|
384
|
+
|
|
385
|
+
table.add_row(
|
|
386
|
+
Text(old_line, style="dim"),
|
|
387
|
+
Text(new_line, style="dim"),
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
elif tag == "delete":
|
|
391
|
+
# Lines deleted from old
|
|
392
|
+
for i in range(i1, i2):
|
|
393
|
+
table.add_row(
|
|
394
|
+
Text(old_lines[i], style="red"), Text("", style="dim")
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
elif tag == "insert":
|
|
398
|
+
# Lines added to new
|
|
399
|
+
for j in range(j1, j2):
|
|
400
|
+
table.add_row(
|
|
401
|
+
Text("", style="dim"),
|
|
402
|
+
Text(new_lines[j], style="green"),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
elif tag == "replace":
|
|
406
|
+
# Lines changed
|
|
407
|
+
old_chunk = old_lines[i1:i2]
|
|
408
|
+
new_chunk = new_lines[j1:j2]
|
|
409
|
+
|
|
410
|
+
# Pad shorter chunk with empty lines
|
|
411
|
+
max_len = max(len(old_chunk), len(new_chunk))
|
|
412
|
+
|
|
413
|
+
for i in range(max_len):
|
|
414
|
+
old_line = old_chunk[i] if i < len(old_chunk) else ""
|
|
415
|
+
new_line = new_chunk[i] if i < len(new_chunk) else ""
|
|
416
|
+
|
|
417
|
+
table.add_row(
|
|
418
|
+
Text(old_line, style="red" if old_line else "dim"),
|
|
419
|
+
Text(new_line, style="green" if new_line else "dim"),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Create title
|
|
423
|
+
title = Text()
|
|
424
|
+
title.append(file_change.file_path, style="bold yellow")
|
|
425
|
+
title.append(
|
|
426
|
+
f" (+{file_change.lines_added}/-{file_change.lines_removed})",
|
|
427
|
+
style="dim",
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
return Panel(table, title=title, border_style="yellow", expand=False)
|
|
431
|
+
|
|
432
|
+
def render_word_diff(self, old_line: str, new_line: str) -> Tuple[Text, Text]:
|
|
433
|
+
"""
|
|
434
|
+
Generate word-level diff highlighting for two lines.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
old_line: Original line content
|
|
438
|
+
new_line: New line content
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Tuple of (old_formatted, new_formatted) Text objects
|
|
442
|
+
"""
|
|
443
|
+
if not self.word_level_diff:
|
|
444
|
+
return Text(old_line), Text(new_line)
|
|
445
|
+
|
|
446
|
+
# Split into words for better diffing
|
|
447
|
+
old_words = re.findall(r"\S+|\s+", old_line)
|
|
448
|
+
new_words = re.findall(r"\S+|\s+", new_line)
|
|
449
|
+
|
|
450
|
+
differ = difflib.SequenceMatcher(None, old_words, new_words)
|
|
451
|
+
|
|
452
|
+
old_formatted = Text()
|
|
453
|
+
new_formatted = Text()
|
|
454
|
+
|
|
455
|
+
for tag, i1, i2, j1, j2 in differ.get_opcodes():
|
|
456
|
+
if tag == "equal":
|
|
457
|
+
# Words are identical
|
|
458
|
+
for i in range(i2 - i1):
|
|
459
|
+
word = old_words[i1 + i]
|
|
460
|
+
old_formatted.append(word)
|
|
461
|
+
new_formatted.append(word)
|
|
462
|
+
|
|
463
|
+
elif tag == "delete":
|
|
464
|
+
# Words deleted from old
|
|
465
|
+
deleted_text = "".join(old_words[i1:i2])
|
|
466
|
+
old_formatted.append(deleted_text, style="bold red on white")
|
|
467
|
+
|
|
468
|
+
elif tag == "insert":
|
|
469
|
+
# Words added to new
|
|
470
|
+
added_text = "".join(new_words[j1:j2])
|
|
471
|
+
new_formatted.append(added_text, style="bold green on white")
|
|
472
|
+
|
|
473
|
+
elif tag == "replace":
|
|
474
|
+
# Words changed
|
|
475
|
+
old_text = "".join(old_words[i1:i2])
|
|
476
|
+
new_text = "".join(new_words[j1:j2])
|
|
477
|
+
|
|
478
|
+
old_formatted.append(old_text, style="bold red on white")
|
|
479
|
+
new_formatted.append(new_text, style="bold green on white")
|
|
480
|
+
|
|
481
|
+
return old_formatted, new_formatted
|
|
482
|
+
|
|
483
|
+
def render_diff_summary(self, file_changes: List[FileChange]) -> Table:
|
|
484
|
+
"""
|
|
485
|
+
Render a summary table of all file changes.
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
file_changes: List of file changes
|
|
489
|
+
|
|
490
|
+
Returns:
|
|
491
|
+
Rich Table with summary information
|
|
492
|
+
"""
|
|
493
|
+
table = Table(
|
|
494
|
+
title="📊 Diff Summary",
|
|
495
|
+
show_header=True,
|
|
496
|
+
header_style="bold cyan",
|
|
497
|
+
box=ROUNDED,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
table.add_column("File", style="white", width=40)
|
|
501
|
+
table.add_column("Change", style="yellow", justify="center")
|
|
502
|
+
table.add_column("Added", style="green", justify="right")
|
|
503
|
+
table.add_column("Removed", style="red", justify="right")
|
|
504
|
+
table.add_column("Language", style="blue", justify="center")
|
|
505
|
+
|
|
506
|
+
total_added = 0
|
|
507
|
+
total_removed = 0
|
|
508
|
+
|
|
509
|
+
for change in file_changes:
|
|
510
|
+
# Change type with icon
|
|
511
|
+
change_icons = {
|
|
512
|
+
FileChangeType.ADDED: "🆕 Added",
|
|
513
|
+
FileChangeType.MODIFIED: "📝 Modified",
|
|
514
|
+
FileChangeType.DELETED: "🗑️ Deleted",
|
|
515
|
+
FileChangeType.RENAMED: "🔄 Renamed",
|
|
516
|
+
FileChangeType.COPIED: "📋 Copied",
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
change_text = change_icons.get(change.change_type, "❓ Unknown")
|
|
520
|
+
|
|
521
|
+
table.add_row(
|
|
522
|
+
change.file_path,
|
|
523
|
+
change_text,
|
|
524
|
+
str(change.lines_added),
|
|
525
|
+
str(change.lines_removed),
|
|
526
|
+
change.language or "—",
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
total_added += change.lines_added
|
|
530
|
+
total_removed += change.lines_removed
|
|
531
|
+
|
|
532
|
+
# Add totals row
|
|
533
|
+
table.add_row("—" * 20, "—" * 10, "—" * 5, "—" * 5, "—" * 8, style="dim")
|
|
534
|
+
|
|
535
|
+
table.add_row(
|
|
536
|
+
Text("TOTALS", style="bold"),
|
|
537
|
+
f"{len(file_changes)} files",
|
|
538
|
+
Text(str(total_added), style="bold green"),
|
|
539
|
+
Text(str(total_removed), style="bold red"),
|
|
540
|
+
"—",
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
return table
|
|
544
|
+
|
|
545
|
+
def render_diff_set(
|
|
546
|
+
self,
|
|
547
|
+
diff_text: str,
|
|
548
|
+
diff_type: DiffType = DiffType.UNIFIED,
|
|
549
|
+
show_summary: bool = True,
|
|
550
|
+
show_file_tree: bool = True,
|
|
551
|
+
) -> List[RenderableType]:
|
|
552
|
+
"""
|
|
553
|
+
Render a complete diff set with multiple files.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
diff_text: Raw diff text (unified diff format)
|
|
557
|
+
diff_type: Type of diff display to use
|
|
558
|
+
show_summary: Whether to show summary table
|
|
559
|
+
show_file_tree: Whether to show file tree
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
List of Rich renderables
|
|
563
|
+
"""
|
|
564
|
+
# Parse the diff
|
|
565
|
+
file_changes = self.parse_unified_diff(diff_text)
|
|
566
|
+
|
|
567
|
+
if not file_changes:
|
|
568
|
+
return [Panel("No changes detected", style="yellow")]
|
|
569
|
+
|
|
570
|
+
renderables = []
|
|
571
|
+
|
|
572
|
+
# Add summary table
|
|
573
|
+
if show_summary:
|
|
574
|
+
renderables.append(self.render_diff_summary(file_changes))
|
|
575
|
+
renderables.append(Rule(style="dim"))
|
|
576
|
+
|
|
577
|
+
# Add file tree
|
|
578
|
+
if show_file_tree:
|
|
579
|
+
renderables.append(self.render_file_tree(file_changes))
|
|
580
|
+
renderables.append(Rule(style="dim"))
|
|
581
|
+
|
|
582
|
+
# Render each file's diff
|
|
583
|
+
for change in file_changes:
|
|
584
|
+
if diff_type == DiffType.SIDE_BY_SIDE:
|
|
585
|
+
renderables.append(self.render_side_by_side_diff(change))
|
|
586
|
+
else: # Default to unified
|
|
587
|
+
renderables.append(self.render_unified_diff(change))
|
|
588
|
+
|
|
589
|
+
renderables.append(Rule(style="dim"))
|
|
590
|
+
|
|
591
|
+
return renderables
|
|
592
|
+
|
|
593
|
+
def render_file_content_comparison(
|
|
594
|
+
self,
|
|
595
|
+
old_content: str,
|
|
596
|
+
new_content: str,
|
|
597
|
+
file_path: str,
|
|
598
|
+
language: Optional[str] = None,
|
|
599
|
+
) -> Panel:
|
|
600
|
+
"""
|
|
601
|
+
Render a comparison between two complete file contents.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
old_content: Original file content
|
|
605
|
+
new_content: New file content
|
|
606
|
+
file_path: Path to the file
|
|
607
|
+
language: Programming language for syntax highlighting
|
|
608
|
+
|
|
609
|
+
Returns:
|
|
610
|
+
Rich Panel with file comparison
|
|
611
|
+
"""
|
|
612
|
+
# Auto-detect language if not provided
|
|
613
|
+
if not language:
|
|
614
|
+
language = self.detect_language(file_path)
|
|
615
|
+
|
|
616
|
+
# Create unified diff
|
|
617
|
+
diff_lines = list(
|
|
618
|
+
difflib.unified_diff(
|
|
619
|
+
old_content.split("\n"),
|
|
620
|
+
new_content.split("\n"),
|
|
621
|
+
fromfile=f"a/{file_path}",
|
|
622
|
+
tofile=f"b/{file_path}",
|
|
623
|
+
lineterm="",
|
|
624
|
+
)
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
diff_text = "\n".join(diff_lines)
|
|
628
|
+
|
|
629
|
+
# Apply syntax highlighting
|
|
630
|
+
highlighted_diff = self.renderer.apply_diff_highlighting(
|
|
631
|
+
diff_text, file_type=language
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
return Panel(
|
|
635
|
+
highlighted_diff,
|
|
636
|
+
title=f"📄 {file_path}",
|
|
637
|
+
border_style="blue",
|
|
638
|
+
expand=False,
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
# Utility functions for easy integration
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def create_diff_renderer(
|
|
646
|
+
renderer: Optional[RichTextRenderer] = None,
|
|
647
|
+
) -> EnhancedDiffRenderer:
|
|
648
|
+
"""Create an enhanced diff renderer with default configuration."""
|
|
649
|
+
return EnhancedDiffRenderer(renderer=renderer)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def render_git_diff(
|
|
653
|
+
diff_output: str,
|
|
654
|
+
diff_type: DiffType = DiffType.UNIFIED,
|
|
655
|
+
console: Optional[Console] = None,
|
|
656
|
+
) -> None:
|
|
657
|
+
"""
|
|
658
|
+
Quick function to render git diff output.
|
|
659
|
+
|
|
660
|
+
Args:
|
|
661
|
+
diff_output: Raw git diff output
|
|
662
|
+
diff_type: Type of diff display
|
|
663
|
+
console: Rich console for output
|
|
664
|
+
"""
|
|
665
|
+
renderer = create_diff_renderer()
|
|
666
|
+
console = console or renderer.console
|
|
667
|
+
|
|
668
|
+
renderables = renderer.render_diff_set(diff_output, diff_type)
|
|
669
|
+
|
|
670
|
+
for renderable in renderables:
|
|
671
|
+
console.print(renderable)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def compare_files(
|
|
675
|
+
old_file: str,
|
|
676
|
+
new_file: str,
|
|
677
|
+
file_path: str,
|
|
678
|
+
console: Optional[Console] = None,
|
|
679
|
+
) -> None:
|
|
680
|
+
"""
|
|
681
|
+
Compare two file contents and display the diff.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
old_file: Path to old file or content string
|
|
685
|
+
new_file: Path to new file or content string
|
|
686
|
+
file_path: Display path for the file
|
|
687
|
+
console: Rich console for output
|
|
688
|
+
"""
|
|
689
|
+
renderer = create_diff_renderer()
|
|
690
|
+
console = console or renderer.console
|
|
691
|
+
|
|
692
|
+
# Read file contents if they're paths
|
|
693
|
+
old_content = old_file
|
|
694
|
+
new_content = new_file
|
|
695
|
+
|
|
696
|
+
if Path(old_file).exists():
|
|
697
|
+
old_content = Path(old_file).read_text()
|
|
698
|
+
|
|
699
|
+
if Path(new_file).exists():
|
|
700
|
+
new_content = Path(new_file).read_text()
|
|
701
|
+
|
|
702
|
+
comparison = renderer.render_file_content_comparison(
|
|
703
|
+
old_content, new_content, file_path
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
console.print(comparison)
|