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/ui/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Tarang CLI UI - Rich terminal interface."""
2
+
3
+ from tarang.ui.console import TarangConsole
4
+ from tarang.ui.diff_viewer import DiffViewer
5
+ from tarang.ui.formatter import OutputFormatter
6
+
7
+ __all__ = ["TarangConsole", "DiffViewer", "OutputFormatter"]
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}")
@@ -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