tarang 4.4.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.
- tarang/__init__.py +23 -0
- tarang/cli.py +1168 -0
- tarang/client/__init__.py +19 -0
- tarang/client/api_client.py +701 -0
- tarang/client/auth.py +178 -0
- tarang/context/__init__.py +41 -0
- tarang/context/bm25.py +218 -0
- tarang/context/chunker.py +984 -0
- tarang/context/graph.py +464 -0
- tarang/context/indexer.py +514 -0
- tarang/context/retriever.py +270 -0
- tarang/context/skeleton.py +282 -0
- tarang/context_collector.py +449 -0
- tarang/executor/__init__.py +6 -0
- tarang/executor/diff_apply.py +246 -0
- tarang/executor/linter.py +184 -0
- tarang/stream.py +1346 -0
- tarang/ui/__init__.py +7 -0
- tarang/ui/console.py +407 -0
- tarang/ui/diff_viewer.py +146 -0
- tarang/ui/formatter.py +1151 -0
- tarang/ui/keyboard.py +197 -0
- tarang/ws/__init__.py +14 -0
- tarang/ws/client.py +464 -0
- tarang/ws/executor.py +638 -0
- tarang/ws/handlers.py +590 -0
- tarang-4.4.0.dist-info/METADATA +102 -0
- tarang-4.4.0.dist-info/RECORD +31 -0
- tarang-4.4.0.dist-info/WHEEL +5 -0
- tarang-4.4.0.dist-info/entry_points.txt +2 -0
- tarang-4.4.0.dist-info/top_level.txt +1 -0
tarang/ui/__init__.py
ADDED
tarang/ui/console.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Rich console UI for Tarang CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, List, Dict, Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.markdown import Markdown
|
|
12
|
+
from rich.syntax import Syntax
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
15
|
+
from rich.prompt import Prompt, Confirm
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
from rich.rule import Rule
|
|
18
|
+
from rich.live import Live
|
|
19
|
+
from rich.layout import Layout
|
|
20
|
+
|
|
21
|
+
# Try to import prompt_toolkit for command history (up/down arrows)
|
|
22
|
+
try:
|
|
23
|
+
from prompt_toolkit import PromptSession
|
|
24
|
+
from prompt_toolkit.history import FileHistory, InMemoryHistory
|
|
25
|
+
from prompt_toolkit.styles import Style as PTStyle
|
|
26
|
+
HAS_PROMPT_TOOLKIT = True
|
|
27
|
+
except ImportError:
|
|
28
|
+
HAS_PROMPT_TOOLKIT = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TarangConsole:
|
|
32
|
+
"""Rich console for Tarang CLI with Aider-like UI."""
|
|
33
|
+
|
|
34
|
+
BANNER = """
|
|
35
|
+
[bold green]██████╗ ███████╗ ██╗ ██╗[/]
|
|
36
|
+
[bold green]██╔══██╗ ██╔════╝ ██║ ██║[/]
|
|
37
|
+
[bold green]██║ ██║ █████╗ ██║ ██║[/]
|
|
38
|
+
[bold green]██║ ██║ ██╔══╝ ╚██╗ ██╔╝[/]
|
|
39
|
+
[bold green]██████╔╝ ███████╗ ╚████╔╝ [/]
|
|
40
|
+
[bold green]╚═════╝ ╚══════╝ ╚═══╝ [/]
|
|
41
|
+
|
|
42
|
+
[bold cyan]████████╗ █████╗ ██████╗ █████╗ ███╗ ██╗ ██████╗ [/]
|
|
43
|
+
[bold cyan]╚══██╔══╝ ██╔══██╗ ██╔══██╗ ██╔══██╗ ████╗ ██║ ██╔════╝ [/]
|
|
44
|
+
[bold cyan] ██║ ███████║ ██████╔╝ ███████║ ██╔██╗ ██║ ██║ ███╗[/]
|
|
45
|
+
[bold cyan] ██║ ██╔══██║ ██╔══██╗ ██╔══██║ ██║╚██╗██║ ██║ ██║[/]
|
|
46
|
+
[bold cyan] ██║ ██║ ██║ ██║ ██║ ██║ ██║ ██║ ╚████║ ╚██████╔╝[/]
|
|
47
|
+
[bold cyan] ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═══╝ ╚═════╝ [/]
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, verbose: bool = False):
|
|
51
|
+
self.console = Console()
|
|
52
|
+
self.verbose = verbose
|
|
53
|
+
self.project_path: Optional[Path] = None
|
|
54
|
+
|
|
55
|
+
# Initialize command history for up/down arrow navigation
|
|
56
|
+
self._prompt_session = None
|
|
57
|
+
if HAS_PROMPT_TOOLKIT:
|
|
58
|
+
# Store history in ~/.tarang/history
|
|
59
|
+
history_path = Path.home() / ".tarang" / "history"
|
|
60
|
+
history_path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
try:
|
|
62
|
+
history = FileHistory(str(history_path))
|
|
63
|
+
except Exception:
|
|
64
|
+
history = InMemoryHistory()
|
|
65
|
+
|
|
66
|
+
# Style to match Rich prompt
|
|
67
|
+
style = PTStyle.from_dict({
|
|
68
|
+
'prompt': 'bold cyan',
|
|
69
|
+
})
|
|
70
|
+
self._prompt_session = PromptSession(
|
|
71
|
+
history=history,
|
|
72
|
+
style=style,
|
|
73
|
+
enable_history_search=True, # Ctrl+R for reverse search
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def print_banner(self, version: str, project_path: Path):
|
|
77
|
+
"""Print the startup banner with project info."""
|
|
78
|
+
self.project_path = project_path
|
|
79
|
+
self.console.print(self.BANNER)
|
|
80
|
+
|
|
81
|
+
# Project info bar
|
|
82
|
+
git_info = self._get_git_info(project_path)
|
|
83
|
+
info_text = f"[dim]v{version}[/] │ [bold]{project_path.name}[/]"
|
|
84
|
+
if git_info:
|
|
85
|
+
info_text += f" │ [yellow]{git_info}[/]"
|
|
86
|
+
|
|
87
|
+
self.console.print(Panel(info_text, style="blue", padding=(0, 1)))
|
|
88
|
+
|
|
89
|
+
def print_instructions(self):
|
|
90
|
+
"""Print usage instructions with matching colors."""
|
|
91
|
+
self.console.print("[green]Type your instructions[/], or [cyan]/help[/] for commands")
|
|
92
|
+
self.console.print("[bold]↑/↓[/][dim]=[/]history [bold]ESC[/][green]=[/]cancel [bold]SPACE[/][cyan]=[/]add instruction")
|
|
93
|
+
self.console.print()
|
|
94
|
+
|
|
95
|
+
def print_project_stats(self, total_files: int, total_lines: int):
|
|
96
|
+
"""Print project statistics."""
|
|
97
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
98
|
+
table.add_column(style="dim")
|
|
99
|
+
table.add_column(style="bold")
|
|
100
|
+
table.add_row("Files:", f"{total_files:,}")
|
|
101
|
+
table.add_row("Lines:", f"{total_lines:,}")
|
|
102
|
+
self.console.print(table)
|
|
103
|
+
self.console.print()
|
|
104
|
+
|
|
105
|
+
def print_help(self):
|
|
106
|
+
"""Print available commands."""
|
|
107
|
+
help_text = """
|
|
108
|
+
[bold]Commands:[/]
|
|
109
|
+
[cyan]/help[/] Show this help message
|
|
110
|
+
[cyan]/login[/] Login to Tarang
|
|
111
|
+
[cyan]/config[/] Configure API key
|
|
112
|
+
[cyan]/index[/] Build code index for better context
|
|
113
|
+
[cyan]/git[/] Show git status
|
|
114
|
+
[cyan]/files[/] List tracked files
|
|
115
|
+
[cyan]/add[/] Add files to context
|
|
116
|
+
[cyan]/drop[/] Remove files from context
|
|
117
|
+
[cyan]/clear[/] Clear conversation history
|
|
118
|
+
[cyan]/commit[/] Commit pending changes
|
|
119
|
+
[cyan]/diff[/] Show uncommitted changes
|
|
120
|
+
[cyan]/undo[/] Undo last change
|
|
121
|
+
[cyan]/exit[/] Exit Tarang
|
|
122
|
+
|
|
123
|
+
[bold]Tips:[/]
|
|
124
|
+
• Run [cyan]/index[/] to enable smart code retrieval
|
|
125
|
+
• Type your request naturally: "add a login button"
|
|
126
|
+
• Reference files: "fix the bug in src/main.py"
|
|
127
|
+
• Ask questions: "explain how auth works"
|
|
128
|
+
"""
|
|
129
|
+
self.console.print(Panel(help_text, title="[bold]Tarang Help[/]", border_style="blue"))
|
|
130
|
+
|
|
131
|
+
def print_git_status(self, project_path: Path):
|
|
132
|
+
"""Print git status in a panel."""
|
|
133
|
+
try:
|
|
134
|
+
result = subprocess.run(
|
|
135
|
+
["git", "status", "--short"],
|
|
136
|
+
cwd=project_path,
|
|
137
|
+
capture_output=True,
|
|
138
|
+
text=True,
|
|
139
|
+
timeout=5,
|
|
140
|
+
)
|
|
141
|
+
if result.returncode == 0:
|
|
142
|
+
status = result.stdout.strip() or "[dim]No changes[/]"
|
|
143
|
+
self.console.print(Panel(status, title="[bold]Git Status[/]", border_style="yellow"))
|
|
144
|
+
else:
|
|
145
|
+
self.console.print("[dim]Not a git repository[/]")
|
|
146
|
+
except Exception:
|
|
147
|
+
self.console.print("[dim]Git not available[/]")
|
|
148
|
+
|
|
149
|
+
def thinking(self, message: str = "Thinking..."):
|
|
150
|
+
"""Return a spinner context for thinking state."""
|
|
151
|
+
return self.console.status(f"[bold cyan]{message}[/]", spinner="dots")
|
|
152
|
+
|
|
153
|
+
def print_message(self, message: str, title: str = "Response"):
|
|
154
|
+
"""Print an AI response in a panel with markdown."""
|
|
155
|
+
md = Markdown(message)
|
|
156
|
+
self.console.print(Panel(md, title=f"[bold green]{title}[/]", border_style="green"))
|
|
157
|
+
|
|
158
|
+
def print_error(self, error: str, recoverable: bool = True):
|
|
159
|
+
"""Print an error message."""
|
|
160
|
+
style = "yellow" if recoverable else "red"
|
|
161
|
+
icon = "⚠" if recoverable else "✗"
|
|
162
|
+
self.console.print(f"[{style}]{icon} {error}[/{style}]")
|
|
163
|
+
|
|
164
|
+
def print_success(self, message: str):
|
|
165
|
+
"""Print a success message."""
|
|
166
|
+
self.console.print(f"[green]✓ {message}[/green]")
|
|
167
|
+
|
|
168
|
+
def print_info(self, message: str):
|
|
169
|
+
"""Print an info message."""
|
|
170
|
+
self.console.print(f"[blue]ℹ {message}[/blue]")
|
|
171
|
+
|
|
172
|
+
def print_warning(self, message: str):
|
|
173
|
+
"""Print a warning message."""
|
|
174
|
+
self.console.print(f"[yellow]⚠ {message}[/yellow]")
|
|
175
|
+
|
|
176
|
+
def print_thought(self, thought: str):
|
|
177
|
+
"""Print AI thinking/reasoning."""
|
|
178
|
+
if self.verbose:
|
|
179
|
+
self.console.print(f"[dim italic]💭 {thought[:200]}...[/dim italic]")
|
|
180
|
+
|
|
181
|
+
def prompt_input(self) -> str:
|
|
182
|
+
"""
|
|
183
|
+
Get user input with styled prompt (sync version).
|
|
184
|
+
|
|
185
|
+
Features:
|
|
186
|
+
- Up/Down arrows: Navigate command history
|
|
187
|
+
- Ctrl+R: Reverse search through history
|
|
188
|
+
- History persisted to ~/.tarang/history
|
|
189
|
+
"""
|
|
190
|
+
import asyncio
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
if self._prompt_session:
|
|
194
|
+
# Check if we're in an async context
|
|
195
|
+
try:
|
|
196
|
+
loop = asyncio.get_running_loop()
|
|
197
|
+
# We're in async context - use nest_asyncio or run in executor
|
|
198
|
+
import concurrent.futures
|
|
199
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
200
|
+
future = executor.submit(self._prompt_session.prompt, "You> ")
|
|
201
|
+
return future.result()
|
|
202
|
+
except RuntimeError:
|
|
203
|
+
# No running loop - use sync version
|
|
204
|
+
return self._prompt_session.prompt("You> ")
|
|
205
|
+
else:
|
|
206
|
+
# Fallback to Rich prompt (no history)
|
|
207
|
+
return Prompt.ask("[bold cyan]You[/]", console=self.console)
|
|
208
|
+
except (KeyboardInterrupt, EOFError):
|
|
209
|
+
return ""
|
|
210
|
+
|
|
211
|
+
async def prompt_input_async(self) -> str:
|
|
212
|
+
"""
|
|
213
|
+
Get user input with styled prompt (async version).
|
|
214
|
+
|
|
215
|
+
Use this when calling from async context.
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
if self._prompt_session:
|
|
219
|
+
return await self._prompt_session.prompt_async("You> ")
|
|
220
|
+
else:
|
|
221
|
+
# Fallback - run sync in executor
|
|
222
|
+
import asyncio
|
|
223
|
+
loop = asyncio.get_event_loop()
|
|
224
|
+
return await loop.run_in_executor(
|
|
225
|
+
None,
|
|
226
|
+
lambda: Prompt.ask("[bold cyan]You[/]", console=self.console)
|
|
227
|
+
)
|
|
228
|
+
except (KeyboardInterrupt, EOFError):
|
|
229
|
+
return ""
|
|
230
|
+
|
|
231
|
+
def confirm(self, message: str, default: bool = True) -> bool:
|
|
232
|
+
"""Ask for confirmation."""
|
|
233
|
+
return Confirm.ask(message, console=self.console, default=default)
|
|
234
|
+
|
|
235
|
+
def print_edits_preview(self, edits: List[Dict[str, Any]]) -> bool:
|
|
236
|
+
"""
|
|
237
|
+
Preview edits and ask for confirmation.
|
|
238
|
+
|
|
239
|
+
Returns True if user accepts, False otherwise.
|
|
240
|
+
"""
|
|
241
|
+
self.console.print()
|
|
242
|
+
self.console.print(Rule("[bold]Proposed Changes[/]", style="yellow"))
|
|
243
|
+
|
|
244
|
+
for edit in edits:
|
|
245
|
+
file_path = edit.get("file", "unknown")
|
|
246
|
+
description = edit.get("description", "")
|
|
247
|
+
|
|
248
|
+
# Create edit panel
|
|
249
|
+
content = Text()
|
|
250
|
+
content.append(f"📄 {file_path}\n", style="bold")
|
|
251
|
+
if description:
|
|
252
|
+
content.append(f" {description}", style="dim")
|
|
253
|
+
|
|
254
|
+
self.console.print(content)
|
|
255
|
+
|
|
256
|
+
# Show diff preview if available
|
|
257
|
+
if edit.get("search") and edit.get("replace"):
|
|
258
|
+
self._print_search_replace_diff(edit["search"], edit["replace"])
|
|
259
|
+
elif edit.get("diff"):
|
|
260
|
+
self._print_diff(edit["diff"])
|
|
261
|
+
elif edit.get("content"):
|
|
262
|
+
self.console.print(f" [dim]New file: {len(edit['content'])} chars[/dim]")
|
|
263
|
+
|
|
264
|
+
self.console.print()
|
|
265
|
+
|
|
266
|
+
self.console.print(Rule(style="yellow"))
|
|
267
|
+
return self.confirm("[yellow]Apply these changes?[/]", default=True)
|
|
268
|
+
|
|
269
|
+
def _print_search_replace_diff(self, search: str, replace: str):
|
|
270
|
+
"""Print a search/replace diff."""
|
|
271
|
+
lines = []
|
|
272
|
+
for line in search.split("\n")[:5]:
|
|
273
|
+
lines.append(f"[red]- {line}[/red]")
|
|
274
|
+
if len(search.split("\n")) > 5:
|
|
275
|
+
lines.append("[dim]...[/dim]")
|
|
276
|
+
for line in replace.split("\n")[:5]:
|
|
277
|
+
lines.append(f"[green]+ {line}[/green]")
|
|
278
|
+
if len(replace.split("\n")) > 5:
|
|
279
|
+
lines.append("[dim]...[/dim]")
|
|
280
|
+
|
|
281
|
+
for line in lines:
|
|
282
|
+
self.console.print(f" {line}")
|
|
283
|
+
|
|
284
|
+
def _print_diff(self, diff: str):
|
|
285
|
+
"""Print a unified diff with syntax highlighting."""
|
|
286
|
+
syntax = Syntax(diff[:500], "diff", theme="monokai", line_numbers=False)
|
|
287
|
+
self.console.print(syntax)
|
|
288
|
+
|
|
289
|
+
def print_edit_result(self, file_path: str, success: bool, error: Optional[str] = None):
|
|
290
|
+
"""Print the result of applying an edit."""
|
|
291
|
+
if success:
|
|
292
|
+
self.console.print(f" [green]✓[/green] {file_path}")
|
|
293
|
+
else:
|
|
294
|
+
self.console.print(f" [red]✗[/red] {file_path}: {error}")
|
|
295
|
+
|
|
296
|
+
def print_command_output(self, command: str, output: str, exit_code: int):
|
|
297
|
+
"""Print command execution output."""
|
|
298
|
+
self.console.print(f"\n[bold]$ {command}[/bold]")
|
|
299
|
+
if output:
|
|
300
|
+
self.console.print(output[:500])
|
|
301
|
+
if exit_code != 0:
|
|
302
|
+
self.console.print(f"[yellow]Exit code: {exit_code}[/yellow]")
|
|
303
|
+
|
|
304
|
+
def print_session_info(self, session_id: Optional[str], history_count: int):
|
|
305
|
+
"""Print session information."""
|
|
306
|
+
table = Table(show_header=False, box=None)
|
|
307
|
+
table.add_column(style="dim")
|
|
308
|
+
table.add_column()
|
|
309
|
+
table.add_row("Session:", session_id or "[dim]None[/dim]")
|
|
310
|
+
table.add_row("History:", f"{history_count} messages")
|
|
311
|
+
self.console.print(table)
|
|
312
|
+
|
|
313
|
+
def print_goodbye(self):
|
|
314
|
+
"""Print goodbye message."""
|
|
315
|
+
self.console.print("\n[bold cyan]👋 Goodbye![/bold cyan]\n")
|
|
316
|
+
|
|
317
|
+
def _get_git_info(self, project_path: Path) -> Optional[str]:
|
|
318
|
+
"""Get current git branch and status."""
|
|
319
|
+
try:
|
|
320
|
+
# Get branch name
|
|
321
|
+
result = subprocess.run(
|
|
322
|
+
["git", "branch", "--show-current"],
|
|
323
|
+
cwd=project_path,
|
|
324
|
+
capture_output=True,
|
|
325
|
+
text=True,
|
|
326
|
+
timeout=2,
|
|
327
|
+
)
|
|
328
|
+
if result.returncode != 0:
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
branch = result.stdout.strip()
|
|
332
|
+
|
|
333
|
+
# Get status count
|
|
334
|
+
result = subprocess.run(
|
|
335
|
+
["git", "status", "--porcelain"],
|
|
336
|
+
cwd=project_path,
|
|
337
|
+
capture_output=True,
|
|
338
|
+
text=True,
|
|
339
|
+
timeout=2,
|
|
340
|
+
)
|
|
341
|
+
changes = len([l for l in result.stdout.strip().split("\n") if l])
|
|
342
|
+
|
|
343
|
+
if changes > 0:
|
|
344
|
+
return f"⎇ {branch} ({changes} changed)"
|
|
345
|
+
return f"⎇ {branch}"
|
|
346
|
+
|
|
347
|
+
except Exception:
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
def git_commit(self, project_path: Path, message: Optional[str] = None) -> bool:
|
|
351
|
+
"""Commit changes with auto-generated or custom message."""
|
|
352
|
+
try:
|
|
353
|
+
# Check for changes
|
|
354
|
+
result = subprocess.run(
|
|
355
|
+
["git", "status", "--porcelain"],
|
|
356
|
+
cwd=project_path,
|
|
357
|
+
capture_output=True,
|
|
358
|
+
text=True,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if not result.stdout.strip():
|
|
362
|
+
self.print_info("No changes to commit")
|
|
363
|
+
return False
|
|
364
|
+
|
|
365
|
+
# Show what will be committed
|
|
366
|
+
self.print_git_status(project_path)
|
|
367
|
+
|
|
368
|
+
if not message:
|
|
369
|
+
message = Prompt.ask(
|
|
370
|
+
"[yellow]Commit message[/]",
|
|
371
|
+
default="Update via Tarang",
|
|
372
|
+
console=self.console,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if not self.confirm(f"Commit with message: '{message}'?"):
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
# Stage and commit
|
|
379
|
+
subprocess.run(["git", "add", "-A"], cwd=project_path, check=True)
|
|
380
|
+
subprocess.run(
|
|
381
|
+
["git", "commit", "-m", message],
|
|
382
|
+
cwd=project_path,
|
|
383
|
+
check=True,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
self.print_success("Changes committed")
|
|
387
|
+
return True
|
|
388
|
+
|
|
389
|
+
except subprocess.CalledProcessError as e:
|
|
390
|
+
self.print_error(f"Git error: {e}")
|
|
391
|
+
return False
|
|
392
|
+
|
|
393
|
+
def git_diff(self, project_path: Path):
|
|
394
|
+
"""Show git diff."""
|
|
395
|
+
try:
|
|
396
|
+
result = subprocess.run(
|
|
397
|
+
["git", "diff", "--color=always"],
|
|
398
|
+
cwd=project_path,
|
|
399
|
+
capture_output=True,
|
|
400
|
+
text=True,
|
|
401
|
+
)
|
|
402
|
+
if result.stdout:
|
|
403
|
+
self.console.print(result.stdout)
|
|
404
|
+
else:
|
|
405
|
+
self.print_info("No unstaged changes")
|
|
406
|
+
except Exception as e:
|
|
407
|
+
self.print_error(f"Git error: {e}")
|
tarang/ui/diff_viewer.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Diff viewer for previewing changes before applying."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, List, Tuple
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.syntax import Syntax
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DiffViewer:
|
|
16
|
+
"""View and manage diffs for file changes."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, console: Console):
|
|
19
|
+
self.console = console
|
|
20
|
+
|
|
21
|
+
def show_diff(
|
|
22
|
+
self,
|
|
23
|
+
file_path: str,
|
|
24
|
+
original: str,
|
|
25
|
+
modified: str,
|
|
26
|
+
context_lines: int = 3,
|
|
27
|
+
):
|
|
28
|
+
"""Show a diff between original and modified content."""
|
|
29
|
+
original_lines = original.splitlines(keepends=True)
|
|
30
|
+
modified_lines = modified.splitlines(keepends=True)
|
|
31
|
+
|
|
32
|
+
diff = difflib.unified_diff(
|
|
33
|
+
original_lines,
|
|
34
|
+
modified_lines,
|
|
35
|
+
fromfile=f"a/{file_path}",
|
|
36
|
+
tofile=f"b/{file_path}",
|
|
37
|
+
n=context_lines,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
diff_text = "".join(diff)
|
|
41
|
+
if diff_text:
|
|
42
|
+
syntax = Syntax(diff_text, "diff", theme="monokai", line_numbers=False)
|
|
43
|
+
self.console.print(Panel(
|
|
44
|
+
syntax,
|
|
45
|
+
title=f"[bold]{file_path}[/bold]",
|
|
46
|
+
border_style="yellow",
|
|
47
|
+
))
|
|
48
|
+
else:
|
|
49
|
+
self.console.print(f"[dim]No changes in {file_path}[/dim]")
|
|
50
|
+
|
|
51
|
+
def show_new_file(self, file_path: str, content: str, max_lines: int = 20):
|
|
52
|
+
"""Show preview of a new file."""
|
|
53
|
+
lines = content.split("\n")
|
|
54
|
+
preview = "\n".join(lines[:max_lines])
|
|
55
|
+
if len(lines) > max_lines:
|
|
56
|
+
preview += f"\n... ({len(lines) - max_lines} more lines)"
|
|
57
|
+
|
|
58
|
+
# Try to detect language from extension
|
|
59
|
+
ext = Path(file_path).suffix.lstrip(".")
|
|
60
|
+
lang_map = {
|
|
61
|
+
"py": "python",
|
|
62
|
+
"js": "javascript",
|
|
63
|
+
"ts": "typescript",
|
|
64
|
+
"tsx": "tsx",
|
|
65
|
+
"jsx": "jsx",
|
|
66
|
+
"json": "json",
|
|
67
|
+
"yaml": "yaml",
|
|
68
|
+
"yml": "yaml",
|
|
69
|
+
"md": "markdown",
|
|
70
|
+
"html": "html",
|
|
71
|
+
"css": "css",
|
|
72
|
+
"sh": "bash",
|
|
73
|
+
"rs": "rust",
|
|
74
|
+
"go": "go",
|
|
75
|
+
}
|
|
76
|
+
lang = lang_map.get(ext, "text")
|
|
77
|
+
|
|
78
|
+
syntax = Syntax(preview, lang, theme="monokai", line_numbers=True)
|
|
79
|
+
self.console.print(Panel(
|
|
80
|
+
syntax,
|
|
81
|
+
title=f"[bold green]+ {file_path}[/bold green] (new file)",
|
|
82
|
+
border_style="green",
|
|
83
|
+
))
|
|
84
|
+
|
|
85
|
+
def show_search_replace(
|
|
86
|
+
self,
|
|
87
|
+
file_path: str,
|
|
88
|
+
search: str,
|
|
89
|
+
replace: str,
|
|
90
|
+
original_content: Optional[str] = None,
|
|
91
|
+
):
|
|
92
|
+
"""Show a search/replace preview."""
|
|
93
|
+
content = Text()
|
|
94
|
+
|
|
95
|
+
# Search section (what will be removed)
|
|
96
|
+
content.append("───── Search (to be replaced) ─────\n", style="bold red")
|
|
97
|
+
for line in search.split("\n")[:10]:
|
|
98
|
+
content.append(f"- {line}\n", style="red")
|
|
99
|
+
if len(search.split("\n")) > 10:
|
|
100
|
+
content.append(f"... ({len(search.split(chr(10))) - 10} more lines)\n", style="dim")
|
|
101
|
+
|
|
102
|
+
content.append("\n")
|
|
103
|
+
|
|
104
|
+
# Replace section (what will be added)
|
|
105
|
+
content.append("───── Replace (new content) ─────\n", style="bold green")
|
|
106
|
+
for line in replace.split("\n")[:10]:
|
|
107
|
+
content.append(f"+ {line}\n", style="green")
|
|
108
|
+
if len(replace.split("\n")) > 10:
|
|
109
|
+
content.append(f"... ({len(replace.split(chr(10))) - 10} more lines)\n", style="dim")
|
|
110
|
+
|
|
111
|
+
self.console.print(Panel(
|
|
112
|
+
content,
|
|
113
|
+
title=f"[bold]{file_path}[/bold]",
|
|
114
|
+
border_style="yellow",
|
|
115
|
+
))
|
|
116
|
+
|
|
117
|
+
def create_inline_diff(
|
|
118
|
+
self,
|
|
119
|
+
original: str,
|
|
120
|
+
modified: str,
|
|
121
|
+
) -> List[Tuple[str, str]]:
|
|
122
|
+
"""
|
|
123
|
+
Create an inline diff showing changes line by line.
|
|
124
|
+
|
|
125
|
+
Returns list of (line_content, style) tuples.
|
|
126
|
+
"""
|
|
127
|
+
result = []
|
|
128
|
+
matcher = difflib.SequenceMatcher(None, original.split("\n"), modified.split("\n"))
|
|
129
|
+
|
|
130
|
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
|
131
|
+
if tag == "equal":
|
|
132
|
+
for line in original.split("\n")[i1:i2]:
|
|
133
|
+
result.append((f" {line}", "dim"))
|
|
134
|
+
elif tag == "replace":
|
|
135
|
+
for line in original.split("\n")[i1:i2]:
|
|
136
|
+
result.append((f"- {line}", "red"))
|
|
137
|
+
for line in modified.split("\n")[j1:j2]:
|
|
138
|
+
result.append((f"+ {line}", "green"))
|
|
139
|
+
elif tag == "delete":
|
|
140
|
+
for line in original.split("\n")[i1:i2]:
|
|
141
|
+
result.append((f"- {line}", "red"))
|
|
142
|
+
elif tag == "insert":
|
|
143
|
+
for line in modified.split("\n")[j1:j2]:
|
|
144
|
+
result.append((f"+ {line}", "green"))
|
|
145
|
+
|
|
146
|
+
return result
|