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/cli.py ADDED
@@ -0,0 +1,1168 @@
1
+ """
2
+ Tarang CLI - AI coding assistant with hybrid WebSocket architecture.
3
+
4
+ Just type your instructions. The orchestrator handles everything:
5
+ - Simple queries (explanations, questions)
6
+ - Complex tasks (multi-step implementations)
7
+ - Long-running jobs with phases and milestones
8
+
9
+ Usage:
10
+ tarang login # Authenticate with GitHub
11
+ tarang config --openrouter-key KEY # Set API key
12
+ tarang "explain the project" # Run instruction
13
+ tarang # Interactive mode
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import shutil
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import Optional, List, Dict
22
+
23
+ import click
24
+ from rich.prompt import Prompt
25
+
26
+ from tarang import __version__
27
+ from tarang.client import TarangAPIClient, TarangAuth
28
+ from tarang.ui import TarangConsole
29
+
30
+
31
+ # Global console instance
32
+ console: Optional[TarangConsole] = None
33
+
34
+
35
+ def get_console(verbose: bool = False) -> TarangConsole:
36
+ """Get or create console instance."""
37
+ global console
38
+ if console is None:
39
+ console = TarangConsole(verbose=verbose)
40
+ return console
41
+
42
+
43
+ @click.group(invoke_without_command=True)
44
+ @click.option("--project-dir", "-p", default=".", help="Project directory")
45
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
46
+ @click.option("--yes", "-y", is_flag=True, help="Auto-approve all operations")
47
+ @click.version_option(version=__version__, prog_name="Tarang")
48
+ @click.pass_context
49
+ def cli(ctx, project_dir: str, verbose: bool, yes: bool):
50
+ """
51
+ Tarang - AI Coding Agent.
52
+
53
+ Just type your instructions. The orchestrator handles everything:
54
+ - Simple queries (explanations, questions)
55
+ - Complex tasks (multi-step implementations)
56
+ - Long-running jobs with phases and milestones
57
+
58
+ Quick start:
59
+ tarang login # Authenticate
60
+ tarang config --openrouter-key KEY # Set API key
61
+ tarang run "explain the project" # Run instruction
62
+ tarang # Interactive mode
63
+
64
+ Examples:
65
+ tarang run "add user authentication"
66
+ tarang run "fix the login bug"
67
+ tarang run "refactor the API" -y # Auto-approve changes
68
+ """
69
+ if ctx.invoked_subcommand is None:
70
+ # Store options in context for the run function
71
+ ctx.ensure_object(dict)
72
+ ctx.obj["instruction"] = None
73
+ ctx.obj["project_dir"] = project_dir
74
+ ctx.obj["verbose"] = verbose
75
+ ctx.obj["auto_approve"] = yes
76
+ ctx.invoke(run)
77
+
78
+
79
+ @cli.command()
80
+ def login():
81
+ """
82
+ Authenticate with Tarang via GitHub.
83
+
84
+ Opens a browser window for OAuth authentication.
85
+ Your token is stored securely in ~/.tarang/config.json
86
+ """
87
+ ui = get_console()
88
+ auth = TarangAuth()
89
+
90
+ if auth.is_authenticated():
91
+ ui.print_info("Already logged in.")
92
+ if not ui.confirm("Login again?", default=False):
93
+ return
94
+
95
+ ui.print_info("Starting authentication...")
96
+
97
+ try:
98
+ asyncio.run(auth.login())
99
+ ui.print_success("Login successful!")
100
+ ui.print_info("Credentials saved to ~/.tarang/config.json")
101
+
102
+ if not auth.has_openrouter_key():
103
+ ui.console.print("\n[yellow]Next step:[/] Set your OpenRouter API key:")
104
+ ui.console.print(" [cyan]tarang config --openrouter-key YOUR_KEY[/]")
105
+
106
+ except TimeoutError:
107
+ ui.print_error("Authentication timed out. Please try again.", recoverable=False)
108
+ sys.exit(1)
109
+ except Exception as e:
110
+ ui.print_error(f"Authentication failed: {e}", recoverable=False)
111
+ sys.exit(1)
112
+
113
+
114
+ @cli.command()
115
+ @click.option("--openrouter-key", "-k", help="Set your OpenRouter API key")
116
+ @click.option("--backend-url", "-u", help="Set custom backend URL")
117
+ @click.option("--show", is_flag=True, help="Show current configuration")
118
+ def config(openrouter_key: str, backend_url: str, show: bool):
119
+ """
120
+ Configure Tarang settings.
121
+
122
+ Set your OpenRouter API key for LLM access:
123
+ tarang config --openrouter-key sk-or-...
124
+
125
+ View current config:
126
+ tarang config --show
127
+ """
128
+ ui = get_console()
129
+ auth = TarangAuth()
130
+
131
+ if show:
132
+ creds = auth.load_credentials() or {}
133
+ ui.console.print("\n[bold]Tarang Configuration[/] (~/.tarang/config.json)")
134
+ ui.console.print("─" * 50)
135
+
136
+ token_status = "[green]✓ configured[/]" if creds.get("token") else "[red]✗ not set[/]"
137
+ key_status = "[green]✓ configured[/]" if creds.get("openrouter_key") else "[red]✗ not set[/]"
138
+
139
+ ui.console.print(f"Token: {token_status}")
140
+ ui.console.print(f"OpenRouter: {key_status}")
141
+ if creds.get("backend_url"):
142
+ ui.console.print(f"Backend URL: {creds.get('backend_url')}")
143
+ ui.console.print()
144
+ return
145
+
146
+ if openrouter_key:
147
+ if not openrouter_key.startswith("sk-or-"):
148
+ ui.print_warning("OpenRouter keys usually start with 'sk-or-'")
149
+
150
+ auth.save_openrouter_key(openrouter_key)
151
+ ui.print_success("OpenRouter API key saved.")
152
+
153
+ if backend_url:
154
+ auth.save_credentials(backend_url=backend_url)
155
+ ui.print_success(f"Backend URL set to: {backend_url}")
156
+
157
+ if not openrouter_key and not backend_url:
158
+ ui.print_info("No configuration changes made. Use --help to see options.")
159
+
160
+
161
+ @cli.command()
162
+ @click.argument("instruction", required=False)
163
+ @click.option("--project-dir", "-p", default=None, help="Project directory")
164
+ @click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
165
+ @click.option("--yes", "-y", is_flag=True, help="Auto-approve all operations")
166
+ @click.pass_context
167
+ def run(ctx, instruction: str, project_dir: str, verbose: bool, yes: bool):
168
+ """
169
+ Run an instruction or start interactive mode.
170
+
171
+ Examples:
172
+ tarang run "explain the project"
173
+ tarang run "add authentication" -y
174
+ tarang run # Interactive mode
175
+ """
176
+ # Get options from parent context or use provided ones
177
+ obj = ctx.obj or {}
178
+ instruction = instruction or obj.get("instruction")
179
+ project_dir = project_dir or obj.get("project_dir", ".")
180
+ verbose = verbose or obj.get("verbose", False)
181
+ auto_approve = yes or obj.get("auto_approve", False)
182
+
183
+ ui = get_console(verbose)
184
+ auth = TarangAuth()
185
+
186
+ # Check authentication - prompt to login if needed
187
+ if not auth.is_authenticated():
188
+ ui.console.print("[yellow]Not logged in.[/]")
189
+ if ui.confirm("Login now?", default=True):
190
+ try:
191
+ asyncio.run(auth.login())
192
+ ui.print_success("Login successful!")
193
+ except Exception as e:
194
+ ui.print_error(f"Login failed: {e}", recoverable=False)
195
+ sys.exit(1)
196
+ else:
197
+ ui.print_info("Run [cyan]/login[/] when ready.")
198
+ sys.exit(0)
199
+
200
+ # Check OpenRouter key - prompt to set if needed
201
+ if not auth.has_openrouter_key():
202
+ ui.console.print("[yellow]OpenRouter API key not set.[/]")
203
+ key = Prompt.ask("[cyan]Enter your OpenRouter API key[/]", password=True)
204
+ if key and key.strip():
205
+ auth.save_openrouter_key(key.strip())
206
+ ui.print_success("API key saved!")
207
+ else:
208
+ ui.print_info("Run [cyan]tarang config --openrouter-key YOUR_KEY[/] to set later.")
209
+ sys.exit(0)
210
+
211
+ # Resolve project directory
212
+ project_path = Path(project_dir).resolve()
213
+ if not project_path.exists():
214
+ ui.print_error(f"Project directory not found: {project_dir}", recoverable=False)
215
+ sys.exit(1)
216
+
217
+ # Show banner
218
+ ui.print_banner(__version__, project_path)
219
+
220
+ # Load credentials
221
+ creds = auth.load_credentials()
222
+
223
+ # Run the SSE stream session (simpler than WebSocket)
224
+ asyncio.run(_run_stream_session(
225
+ ui=ui,
226
+ creds=creds,
227
+ project_path=project_path,
228
+ instruction=instruction,
229
+ verbose=verbose,
230
+ auto_approve=auto_approve,
231
+ ))
232
+
233
+
234
+ async def _run_hybrid_session(
235
+ ui: TarangConsole,
236
+ creds: dict,
237
+ project_path: Path,
238
+ instruction: Optional[str],
239
+ verbose: bool,
240
+ auto_approve: bool,
241
+ ):
242
+ """Run the hybrid WebSocket session."""
243
+ import signal
244
+ from tarang.ws import TarangWSClient, ToolExecutor, MessageHandlers
245
+
246
+ # Track if we're in the middle of execution
247
+ is_executing = False
248
+ cancelled = False
249
+
250
+ # Create WebSocket client
251
+ ws_client = TarangWSClient(
252
+ base_url=creds.get("backend_url"),
253
+ token=creds.get("token"),
254
+ openrouter_key=creds.get("openrouter_key"),
255
+ )
256
+
257
+ # Create tool executor
258
+ executor = ToolExecutor(project_root=str(project_path))
259
+
260
+ # Create approval callback
261
+ def on_approval(tool: str, description: str, args: dict) -> bool:
262
+ if auto_approve:
263
+ ui.console.print(f" [dim]Auto-approved[/dim]")
264
+ return True
265
+ return ui.confirm(f"Apply?", default=True)
266
+
267
+ # Create message handlers
268
+ handlers = MessageHandlers(
269
+ console=ui.console,
270
+ executor=executor,
271
+ on_approval=on_approval,
272
+ verbose=verbose,
273
+ auto_approve=auto_approve,
274
+ )
275
+
276
+ conversation_history: List[Dict[str, str]] = []
277
+
278
+ def handle_slash_command(cmd: str) -> bool:
279
+ """Handle slash commands."""
280
+ cmd = cmd.lower().strip()
281
+
282
+ if cmd in ("/help", "/h", "/?"):
283
+ ui.print_help()
284
+ return True
285
+
286
+ if cmd in ("/git", "/status"):
287
+ ui.print_git_status(project_path)
288
+ return True
289
+
290
+ if cmd in ("/commit", "/c"):
291
+ ui.git_commit(project_path)
292
+ return True
293
+
294
+ if cmd in ("/diff", "/d"):
295
+ ui.git_diff(project_path)
296
+ return True
297
+
298
+ if cmd == "/clear":
299
+ conversation_history.clear()
300
+ ui.print_success("Conversation history cleared")
301
+ return True
302
+
303
+ if cmd in ("/exit", "/quit", "/q"):
304
+ ui.print_goodbye()
305
+ sys.exit(0)
306
+
307
+ return False
308
+
309
+ async def send_cancel():
310
+ """Send cancel message to backend."""
311
+ nonlocal cancelled
312
+ if not cancelled:
313
+ cancelled = True
314
+ try:
315
+ await ws_client.cancel()
316
+ ui.console.print("\n[yellow]⏹ Cancelling...[/yellow]")
317
+ except Exception:
318
+ pass
319
+
320
+ try:
321
+ async with ws_client:
322
+ if verbose:
323
+ ui.console.print(f"[dim]Session: {ws_client.session_id}[/dim]")
324
+
325
+ ui.console.print("[dim]Type your instructions, or /help for commands[/dim]")
326
+ ui.console.print("[dim]Press Ctrl+C during execution to cancel[/dim]\n")
327
+
328
+ # Run initial instruction if provided
329
+ instr = instruction
330
+ while True:
331
+ cancelled = False
332
+
333
+ if not instr:
334
+ # Get instruction from user
335
+ try:
336
+ instr = await ui.prompt_input_async()
337
+ if not instr.strip():
338
+ continue
339
+
340
+ # Handle slash commands
341
+ if instr.startswith("/"):
342
+ if handle_slash_command(instr):
343
+ instr = None
344
+ continue
345
+
346
+ # Handle exit
347
+ if instr.lower() in ("exit", "quit", "q"):
348
+ ui.print_goodbye()
349
+ break
350
+
351
+ except (KeyboardInterrupt, EOFError):
352
+ ui.print_goodbye()
353
+ break
354
+
355
+ # Execute instruction via WebSocket
356
+ ui.console.print()
357
+ is_executing = True
358
+
359
+ try:
360
+ async for event in ws_client.execute(instr, str(project_path)):
361
+ if cancelled:
362
+ ui.console.print("[yellow]Execution cancelled[/yellow]")
363
+ break
364
+
365
+ should_continue = await handlers.handle(event, ws_client)
366
+ if not should_continue:
367
+ break
368
+
369
+ except KeyboardInterrupt:
370
+ # Ctrl+C during execution
371
+ await send_cancel()
372
+ ui.console.print()
373
+
374
+ # Show what was completed
375
+ summary = handlers.get_summary()
376
+ if summary.get("files_changed"):
377
+ ui.console.print("[dim]Files changed before cancellation:[/dim]")
378
+ for f in summary["files_changed"]:
379
+ ui.console.print(f" [dim]- {f}[/dim]")
380
+
381
+ finally:
382
+ is_executing = False
383
+
384
+ # Track conversation (even if cancelled)
385
+ summary = handlers.get_summary()
386
+ if instr:
387
+ conversation_history.append({"role": "user", "content": instr})
388
+ status = "Cancelled" if cancelled else "Done"
389
+ conversation_history.append({"role": "assistant", "content": status})
390
+
391
+ # Reset for next instruction
392
+ instr = None
393
+ handlers.state = type(handlers.state)()
394
+
395
+ except ConnectionError as e:
396
+ ui.print_error(f"Connection failed: {e}")
397
+ ui.console.print("[dim]Make sure the backend is running.[/dim]")
398
+ sys.exit(1)
399
+ except KeyboardInterrupt:
400
+ if is_executing:
401
+ ui.console.print("\n[yellow]⏹ Cancelled[/yellow]")
402
+ else:
403
+ ui.console.print()
404
+ ui.print_goodbye()
405
+ sys.exit(130)
406
+ except Exception as e:
407
+ ui.print_error(str(e), recoverable=False)
408
+ if verbose:
409
+ import traceback
410
+ traceback.print_exc()
411
+ sys.exit(1)
412
+
413
+
414
+ async def _ensure_index(ui: TarangConsole, project_path: Path, verbose: bool) -> None:
415
+ """
416
+ Smart indexing strategy:
417
+ - Small projects (<100 files): Auto-index silently
418
+ - Large projects: Prompt user
419
+ - Already indexed: Skip
420
+ """
421
+ from tarang.context import ProjectIndexer
422
+ import os
423
+
424
+ indexer = ProjectIndexer(project_path)
425
+
426
+ # Check if already indexed
427
+ if indexer.exists() and not indexer.is_stale():
428
+ if verbose:
429
+ stats = indexer.stats()
430
+ ui.console.print(f"[dim]Index ready: {stats.get('chunks', 0)} chunks, {stats.get('symbols', 0)} symbols[/dim]")
431
+ return
432
+
433
+ # Count project files quickly (without full scan)
434
+ file_count = 0
435
+ SMALL_PROJECT_THRESHOLD = 100
436
+ IGNORE_DIRS = {".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build", ".tarang"}
437
+
438
+ for root, dirs, files in os.walk(project_path):
439
+ dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
440
+ file_count += len([f for f in files if not f.startswith(".")])
441
+ if file_count > SMALL_PROJECT_THRESHOLD:
442
+ break # Large project, stop counting
443
+
444
+ is_small = file_count <= SMALL_PROJECT_THRESHOLD
445
+
446
+ if is_small:
447
+ # Auto-index silently for small projects
448
+ ui.console.print("[dim]Building code index...[/dim]")
449
+ try:
450
+ result = indexer.build(force=False)
451
+ ui.console.print(f"[dim green]✓ Indexed {result.files_indexed} files ({result.chunks_created} chunks)[/dim green]")
452
+ except Exception as e:
453
+ ui.console.print(f"[dim yellow]Index build skipped: {e}[/dim yellow]")
454
+ else:
455
+ # Prompt for large projects
456
+ ui.console.print(f"[yellow]Project has {file_count}+ files and no code index.[/yellow]")
457
+ if ui.confirm("Build code index for smarter context? (takes ~30s)", default=True):
458
+ ui.console.print("[dim]Building code index...[/dim]")
459
+ try:
460
+ result = indexer.build(force=False)
461
+ ui.console.print(f"[green]✓ Indexed {result.files_indexed} files ({result.chunks_created} chunks, {result.duration_ms}ms)[/green]")
462
+ except Exception as e:
463
+ ui.print_error(f"Index build failed: {e}")
464
+ else:
465
+ ui.console.print("[dim]Skipped. Run /index manually when ready.[/dim]")
466
+
467
+
468
+ async def _run_stream_session(
469
+ ui: TarangConsole,
470
+ creds: dict,
471
+ project_path: Path,
472
+ instruction: Optional[str],
473
+ verbose: bool,
474
+ auto_approve: bool,
475
+ ):
476
+ """
477
+ Run the SSE + REST callback session.
478
+
479
+ Flow:
480
+ 1. Collect local context (file list, relevant files)
481
+ 2. Send POST /api/execute with instruction + context
482
+ 3. Backend streams SSE events (status, tool_request, plan, change, etc.)
483
+ 4. When tool_request received, execute tool locally and POST /api/callback
484
+ 5. Backend continues streaming after receiving callback
485
+ 6. Apply file changes locally when complete
486
+
487
+ Keyboard controls:
488
+ - ESC: Cancel current execution
489
+ - SPACE: Pause and add extra instruction
490
+ """
491
+ from tarang.context_collector import collect_context, ProjectContext
492
+ from tarang.context import get_retriever, ProjectIndexer
493
+ from tarang.stream import TarangStreamClient, EventType, FileChange
494
+ from tarang.ui.keyboard import KeyboardMonitor, KeyAction, create_keyboard_hints
495
+
496
+ # =========================================================================
497
+ # Smart Indexing on Session Start
498
+ # =========================================================================
499
+ await _ensure_index(ui, project_path, verbose)
500
+
501
+ # Create keyboard monitor first (needed for callbacks)
502
+ keyboard = KeyboardMonitor(
503
+ console=ui.console,
504
+ on_status=lambda msg: ui.console.print(msg)
505
+ )
506
+
507
+ # Create stream client with keyboard callbacks for clean prompts
508
+ client = TarangStreamClient(
509
+ base_url=creds.get("backend_url"),
510
+ token=creds.get("token"),
511
+ openrouter_key=creds.get("openrouter_key"),
512
+ project_root=str(project_path),
513
+ verbose=verbose,
514
+ on_input_start=keyboard.stop, # Pause keyboard monitor
515
+ on_input_end=keyboard.start, # Resume keyboard monitor
516
+ )
517
+
518
+ # Debug: Show backend URL
519
+ if verbose:
520
+ ui.console.print(f"[dim]Backend: {client.base_url}[/dim]")
521
+
522
+ # Print instructions with matching colors
523
+ ui.print_instructions()
524
+
525
+ while True:
526
+ # Get instruction from user
527
+ if not instruction:
528
+ try:
529
+ instruction = await ui.prompt_input_async()
530
+ if not instruction.strip():
531
+ continue
532
+
533
+ # Handle slash commands
534
+ if instruction.startswith("/"):
535
+ if await _handle_slash_command(ui, instruction, project_path):
536
+ instruction = None
537
+ continue
538
+
539
+ # Handle exit
540
+ if instruction.lower() in ("exit", "quit", "q"):
541
+ ui.print_goodbye()
542
+ break
543
+
544
+ except (KeyboardInterrupt, EOFError):
545
+ ui.print_goodbye()
546
+ break
547
+
548
+ # Collect local context (for initial context in request)
549
+ ui.console.print("[dim]Collecting context...[/dim]")
550
+
551
+ # Try indexed retrieval first (BM25 + KG)
552
+ retriever = get_retriever(project_path)
553
+ if retriever and retriever.is_ready:
554
+ # Use smart retrieval
555
+ result = retriever.retrieve(instruction, hops=1, max_chunks=10)
556
+ context = ProjectContext(
557
+ cwd=str(project_path),
558
+ files=[], # Will be populated below
559
+ relevant_files=[], # Not used with indexed retrieval
560
+ )
561
+ # Attach indexed context to be sent to backend
562
+ context._indexed_context = result.to_context_dict()
563
+ if verbose:
564
+ stats = result.stats
565
+ ui.console.print(f"[dim]Retrieved {stats.get('total_chunks', 0)} chunks, {stats.get('expanded_symbols', 0)} connected symbols[/dim]")
566
+ else:
567
+ # Fall back to old context collection
568
+ context = collect_context(str(project_path), instruction)
569
+ if verbose:
570
+ ui.console.print(f"[dim]Found {len(context.files)} files, {len(context.relevant_files)} relevant[/dim]")
571
+ ui.console.print("[dim]Tip: Run /index for smarter context retrieval[/dim]")
572
+
573
+ # Stream execution with tool callbacks
574
+ ui.console.print()
575
+ changes_to_apply = []
576
+ current_phase = None
577
+ extra_instructions = [] # Queue of extra instructions from SPACE
578
+
579
+ # Initialize phase tracker for checklist display
580
+ phase_tracker = client.formatter.init_phase_tracker()
581
+
582
+ # Start keyboard monitoring
583
+ keyboard.start()
584
+
585
+ try:
586
+ async for event in client.execute(instruction, context):
587
+ # Check for keyboard actions
588
+ action = keyboard.state.consume_action()
589
+
590
+ if action == KeyAction.CANCEL:
591
+ ui.console.print("\n[yellow]⏹ Cancelling...[/yellow]")
592
+ await client.cancel()
593
+ break
594
+
595
+ elif action == KeyAction.PAUSE:
596
+ # Stop monitoring temporarily for clean input
597
+ keyboard.stop()
598
+ ui.console.print("\n[bold cyan]━━━ Paused ━━━[/bold cyan]")
599
+ try:
600
+ extra = input("[cyan]Add instruction:[/cyan] ").strip()
601
+ if extra:
602
+ extra_instructions.append(extra)
603
+ ui.console.print(f"[green]✓ Queued:[/green] {extra[:50]}...")
604
+ except (KeyboardInterrupt, EOFError):
605
+ pass
606
+ ui.console.print("[bold cyan]━━━ Resuming ━━━[/bold cyan]\n")
607
+ keyboard.start()
608
+
609
+ if event.type == EventType.STATUS:
610
+ msg = event.data.get("message", "Working...")
611
+ phase = event.data.get("phase", "")
612
+ worker = event.data.get("worker", "")
613
+ delegation = event.data.get("delegation", "")
614
+ task = event.data.get("task", "")
615
+
616
+ # Worker start/done events - update phase tracker
617
+ if worker:
618
+ if "completed" in msg.lower() or "done" in msg.lower():
619
+ phase_tracker.complete_worker(worker)
620
+ else:
621
+ phase_tracker.start_worker(worker, task)
622
+ # Delegation events
623
+ elif delegation:
624
+ client.formatter.show_delegation("agent", delegation, task)
625
+ # Phase transitions
626
+ elif phase and phase != current_phase:
627
+ current_phase = phase
628
+ phase_tracker.start_phase(phase)
629
+ elif verbose:
630
+ ui.console.print(f"[dim]{msg}[/dim]")
631
+
632
+ elif event.type == EventType.THINKING:
633
+ # Agent thinking/reasoning
634
+ msg = event.data.get("message", "Thinking...")
635
+
636
+ # Skip "Using..." tool messages - the tool result will show instead
637
+ if "Using " in msg and any(tool in msg for tool in ("read_file", "list_files", "search_files", "search_code", "get_file_info", "write_file", "edit_file", "shell")):
638
+ continue
639
+
640
+ # Extract worker name if present (e.g., "[explorer] Analyzing structure...")
641
+ if msg.startswith("[") and "]" in msg:
642
+ worker_end = msg.index("]")
643
+ worker_name = msg[1:worker_end]
644
+ action = msg[worker_end + 2:]
645
+
646
+ # Skip tool-related messages (handled by tool output)
647
+ if action.strip().startswith("Using "):
648
+ continue
649
+
650
+ if verbose:
651
+ ui.console.print(f" [dim cyan]💭 {worker_name}: {action}[/dim cyan]")
652
+ else:
653
+ # Show actual thinking, skip generic "Step N" style messages
654
+ if action and not action.startswith("Step "):
655
+ ui.console.print(f" [dim]💭 {action[:60]}{'...' if len(action) > 60 else ''}[/dim]")
656
+ else:
657
+ if verbose:
658
+ ui.console.print(f" [dim cyan]💭 {msg}[/dim cyan]")
659
+
660
+ elif event.type == EventType.TOOL_DONE:
661
+ # Tool execution completed - track in phase tracker
662
+ tool = event.data.get("tool", "")
663
+ phase_tracker.increment_tool()
664
+ if verbose:
665
+ ui.console.print(f" [dim] ✓ {tool}[/dim]")
666
+
667
+ elif event.type == EventType.PLAN:
668
+ # Strategic plan from orchestrator - renders ONCE
669
+ plan = event.data.get("plan", event.data)
670
+ phases = event.data.get("phases", [])
671
+
672
+ # Initialize phase tracker with plan (set_plan skips if already set)
673
+ if phases or plan.get("prd"):
674
+ phase_tracker.set_plan(plan)
675
+ elif phases:
676
+ # Architect's task decomposition
677
+ phase_tracker.set_worker_tasks(phases)
678
+ else:
679
+ # Legacy format - just show it
680
+ desc = event.data.get("description", "")
681
+ steps = event.data.get("steps", [])
682
+ files = event.data.get("files", [])
683
+
684
+ if desc:
685
+ ui.console.print(f"\n[bold]Plan:[/bold] {desc}")
686
+ if steps:
687
+ ui.console.print("[dim]Steps:[/dim]")
688
+ for i, step in enumerate(steps[:5], 1):
689
+ ui.console.print(f" {i}. {step}")
690
+ if files:
691
+ ui.console.print("[dim]Files to modify:[/dim]")
692
+ for f in files[:10]:
693
+ ui.console.print(f" • {f}")
694
+
695
+ elif event.type == EventType.PHASE_UPDATE:
696
+ # Phase status update (no re-render, just update state)
697
+ phase_index = event.data.get("phase_index", 0)
698
+ phase_name = event.data.get("phase_name", "")
699
+ status = event.data.get("status", "running")
700
+ phase_tracker.update_phase_status(phase_name, status, phase_index)
701
+ # Show inline status update
702
+ ui.console.print(f" [dim]↳ Phase {phase_index + 1}: {status}[/dim]")
703
+
704
+ elif event.type == EventType.WORKER_UPDATE:
705
+ # Worker status update (no re-render, just update state)
706
+ worker = event.data.get("worker", "")
707
+ task = event.data.get("task", "")
708
+ status = event.data.get("status", "running")
709
+ phase_tracker.update_worker_status(worker, task, status)
710
+ # Show inline worker update
711
+ if status == "completed":
712
+ ui.console.print(f" [green]✓ {worker}[/green]")
713
+ else:
714
+ ui.console.print(f" [dim]↳ {worker}[/dim]")
715
+ if task:
716
+ # Show task on separate line, wrap at 80 chars
717
+ task_display = task[:160] + "..." if len(task) > 160 else task
718
+ ui.console.print(f" [dim italic]{task_display}[/dim italic]")
719
+
720
+ elif event.type == EventType.PHASE_SUMMARY:
721
+ # Individual phase summary - display immediately as it completes
722
+ phase_index = event.data.get("phase_index", 0)
723
+ phase_name = event.data.get("phase_name", f"Phase {phase_index + 1}")
724
+ summary = event.data.get("summary", "")
725
+ status = event.data.get("status", "completed")
726
+ total_phases = event.data.get("total_phases", 1)
727
+
728
+ # Display phase summary in a panel
729
+ from rich.panel import Panel
730
+ from rich.markdown import Markdown
731
+
732
+ status_icon = "✓" if status == "completed" else "⚠"
733
+ status_color = "green" if status == "completed" else "yellow"
734
+
735
+ # Show summary panel
736
+ ui.console.print()
737
+ ui.console.print(Panel(
738
+ Markdown(summary),
739
+ title=f"[bold {status_color}]{status_icon} {phase_name}[/] ({phase_index + 1}/{total_phases})",
740
+ border_style=status_color,
741
+ padding=(1, 2),
742
+ ))
743
+
744
+ elif event.type == EventType.CHANGE:
745
+ change = FileChange.from_dict(event.data)
746
+ changes_to_apply.append(change)
747
+
748
+ # Show change preview
749
+ icon = "📝" if change.type == "edit" else "📄"
750
+ ui.console.print(f"\n[bold yellow]{icon} {change.type.title()}: {change.path}[/bold yellow]")
751
+ if change.description:
752
+ ui.console.print(f"[dim]{change.description}[/dim]")
753
+
754
+ if change.type == "create" and change.content:
755
+ # Show preview of new file
756
+ lines = change.content.splitlines()[:15]
757
+ preview = "\n".join(lines)
758
+ if len(change.content.splitlines()) > 15:
759
+ preview += "\n... (truncated)"
760
+ ui.console.print(f"[dim]```\n{preview}\n```[/dim]")
761
+
762
+ elif change.type == "edit" and change.search and change.replace:
763
+ # Show diff preview
764
+ search_preview = change.search[:100] + "..." if len(change.search) > 100 else change.search
765
+ replace_preview = change.replace[:100] + "..." if len(change.replace) > 100 else change.replace
766
+ ui.console.print(f"[red]- {search_preview}[/red]")
767
+ ui.console.print(f"[green]+ {replace_preview}[/green]")
768
+
769
+ elif event.type == EventType.CONTENT:
770
+ # Text response (for queries)
771
+ content = _extract_content(event.data)
772
+ ui.print_message(content, title="Answer")
773
+
774
+ elif event.type == EventType.ERROR:
775
+ msg = event.data.get("message", "Unknown error")
776
+ ui.print_error(msg)
777
+
778
+ elif event.type == EventType.COMPLETE:
779
+ duration_s = event.data.get("duration_s")
780
+ if duration_s is not None:
781
+ ui.console.print(f"[green]✓ Complete[/green]" + " " * 40 + f"[dim]{duration_s}s[/dim]")
782
+ elif verbose:
783
+ ui.console.print("[dim]✓ Complete[/dim]")
784
+
785
+ # Apply changes - stop keyboard monitor for clean prompts
786
+ keyboard.stop()
787
+
788
+ if changes_to_apply:
789
+ ui.console.print(f"\n[bold]Ready to apply {len(changes_to_apply)} change(s)[/bold]")
790
+
791
+ for change in changes_to_apply:
792
+ if not auto_approve:
793
+ if not ui.confirm(f"Apply {change.type} to {change.path}?", default=True):
794
+ ui.console.print(f"[dim]Skipped: {change.path}[/dim]")
795
+ continue
796
+
797
+ # Apply the change
798
+ success = _apply_change(project_path, change, ui)
799
+ if success:
800
+ ui.console.print(f"[green]✓[/green] Applied: {change.path}")
801
+ else:
802
+ ui.console.print(f"[red]✗[/red] Failed: {change.path}")
803
+
804
+ ui.console.print("\n[green]Done![/green]\n")
805
+ else:
806
+ ui.console.print()
807
+
808
+ except KeyboardInterrupt:
809
+ ui.console.print("\n[yellow]Cancelling...[/yellow]")
810
+ await client.cancel()
811
+ ui.console.print("[yellow]Cancelled[/yellow]")
812
+ extra_instructions.clear() # Clear queue on cancel
813
+
814
+ finally:
815
+ # Always stop keyboard monitoring
816
+ keyboard.stop()
817
+
818
+ # Process queued extra instructions or reset
819
+ if extra_instructions:
820
+ instruction = extra_instructions.pop(0)
821
+ ui.console.print(f"[cyan]→ Next queued:[/cyan] {instruction[:60]}...")
822
+ else:
823
+ instruction = None
824
+
825
+
826
+ async def _handle_slash_command(ui: TarangConsole, cmd: str, project_path: Path) -> bool:
827
+ """Handle slash commands. Returns True if handled."""
828
+ cmd = cmd.lower().strip()
829
+
830
+ if cmd in ("/help", "/h", "/?"):
831
+ ui.print_help()
832
+ return True
833
+
834
+ if cmd in ("/git", "/status"):
835
+ ui.print_git_status(project_path)
836
+ return True
837
+
838
+ if cmd in ("/commit", "/c"):
839
+ ui.git_commit(project_path)
840
+ return True
841
+
842
+ if cmd in ("/diff", "/d"):
843
+ ui.git_diff(project_path)
844
+ return True
845
+
846
+ if cmd == "/clear":
847
+ ui.console.print("[green]Ready for new instructions[/green]")
848
+ return True
849
+
850
+ if cmd == "/login":
851
+ from tarang.client import TarangAuth
852
+ auth = TarangAuth()
853
+ if auth.is_authenticated():
854
+ ui.print_info("Already logged in.")
855
+ if not ui.confirm("Login again?", default=False):
856
+ return True
857
+ ui.print_info("Starting authentication...")
858
+ try:
859
+ await auth.login()
860
+ ui.print_success("Login successful!")
861
+ except Exception as e:
862
+ ui.print_error(f"Login failed: {e}")
863
+ return True
864
+
865
+ if cmd == "/config":
866
+ from tarang.client import TarangAuth
867
+ from tarang.stream import TarangStreamClient
868
+ auth = TarangAuth()
869
+ creds = auth.load_credentials() or {}
870
+
871
+ # Show current status
872
+ ui.console.print("\n[bold]Configuration[/]")
873
+ token_status = "[green]✓[/]" if creds.get("token") else "[red]✗[/]"
874
+ key_status = "[green]✓[/]" if creds.get("openrouter_key") else "[red]✗[/]"
875
+ custom_backend = creds.get("backend_url")
876
+ backend_display = custom_backend or "[dim](default)[/dim]"
877
+ ui.console.print(f" Login: {token_status}")
878
+ ui.console.print(f" API Key: {key_status}")
879
+ ui.console.print(f" Backend: {backend_display}")
880
+
881
+ # Prompt for OpenRouter key
882
+ ui.console.print()
883
+ current_key = "(keep current)" if creds.get("openrouter_key") else ""
884
+ key = Prompt.ask("[cyan]OpenRouter API key[/]", default=current_key, password=True)
885
+ if key and key != "(keep current)":
886
+ auth.save_openrouter_key(key.strip())
887
+ ui.print_success("API key saved!")
888
+
889
+ # Prompt for backend URL
890
+ ui.console.print("[dim]Leave empty or type 'default' to use default backend[/dim]")
891
+ current_display = custom_backend or "(default)"
892
+ backend = Prompt.ask("[cyan]Backend URL[/]", default=current_display)
893
+ if backend in ("", "(default)", "default"):
894
+ if custom_backend:
895
+ # Reset to default - remove from config
896
+ auth.save_credentials(backend_url=None)
897
+ ui.print_success("Backend reset to default")
898
+ elif backend != current_display:
899
+ auth.save_credentials(backend_url=backend.strip().rstrip("/"))
900
+ ui.print_success(f"Backend set to: {backend}")
901
+
902
+ return True
903
+
904
+ if cmd.startswith("/index"):
905
+ # Parse flags
906
+ force = "--force" in cmd or "-f" in cmd
907
+ show_stats = "--stats" in cmd or "-s" in cmd
908
+
909
+ from tarang.context import ProjectIndexer
910
+
911
+ indexer = ProjectIndexer(project_path)
912
+
913
+ if show_stats:
914
+ stats = indexer.stats()
915
+ if not stats.get("indexed"):
916
+ ui.console.print("[yellow]Project not indexed.[/] Run [cyan]/index[/] to build index.")
917
+ else:
918
+ ui.console.print("\n[bold]Index Statistics[/]")
919
+ ui.console.print(f" Files: {stats['files']}")
920
+ ui.console.print(f" Chunks: {stats['chunks']}")
921
+ ui.console.print(f" Symbols: {stats['symbols']}")
922
+ ui.console.print(f" Edges: {stats['edges']}")
923
+ if stats.get("chunk_types"):
924
+ ui.console.print(f" Types: {stats['chunk_types']}")
925
+ return True
926
+
927
+ # Build or update index
928
+ ui.console.print("[dim]Indexing project...[/dim]")
929
+
930
+ try:
931
+ result = indexer.build(force=force)
932
+
933
+ ui.console.print(f" [green]✓[/] Scanned: {result.files_scanned} files")
934
+ ui.console.print(f" [green]✓[/] Indexed: {result.files_indexed} files")
935
+ ui.console.print(f" [green]✓[/] Chunks: {result.chunks_created}")
936
+ ui.console.print(f" [green]✓[/] Symbols: {result.symbols_created}")
937
+ ui.console.print(f" [green]✓[/] Edges: {result.edges_created}")
938
+ ui.console.print(f" [dim]Duration: {result.duration_ms}ms[/dim]")
939
+
940
+ if result.errors:
941
+ ui.console.print(f"\n[yellow]Warnings ({len(result.errors)}):[/]")
942
+ for err in result.errors[:5]:
943
+ ui.console.print(f" [dim]{err}[/dim]")
944
+ if len(result.errors) > 5:
945
+ ui.console.print(f" [dim]... and {len(result.errors) - 5} more[/dim]")
946
+
947
+ ui.console.print("\n[green]Index built![/] Stored in [cyan].tarang/index/[/]")
948
+
949
+ except Exception as e:
950
+ ui.print_error(f"Indexing failed: {e}")
951
+
952
+ return True
953
+
954
+ if cmd in ("/exit", "/quit", "/q"):
955
+ if ui.confirm("Exit Tarang?", default=True):
956
+ ui.print_goodbye()
957
+ sys.exit(0)
958
+ return True
959
+
960
+ return False
961
+
962
+
963
+ def _extract_content(data) -> str:
964
+ """
965
+ Extract human-readable content from event data.
966
+
967
+ Handles various formats:
968
+ - Dict with human_readable_summary
969
+ - Dict with text field
970
+ - Dict with payload.message
971
+ - String that looks like a dict
972
+ - Plain string
973
+ """
974
+ import ast
975
+ import json
976
+
977
+ # If it's a string, try to parse it as dict
978
+ if isinstance(data, str):
979
+ # Try JSON first
980
+ try:
981
+ data = json.loads(data)
982
+ except (json.JSONDecodeError, ValueError):
983
+ # Try Python literal (handles single quotes)
984
+ try:
985
+ data = ast.literal_eval(data)
986
+ except (ValueError, SyntaxError):
987
+ # It's just a plain string
988
+ return data
989
+
990
+ # Now data should be a dict
991
+ if isinstance(data, dict):
992
+ # Priority order for extraction
993
+ if "human_readable_summary" in data:
994
+ return data["human_readable_summary"]
995
+ if "text" in data:
996
+ # text might itself be a nested structure
997
+ return _extract_content(data["text"])
998
+ if "payload" in data and isinstance(data["payload"], dict):
999
+ if "message" in data["payload"]:
1000
+ return data["payload"]["message"]
1001
+ if "message" in data:
1002
+ return data["message"]
1003
+ if "content" in data:
1004
+ return data["content"]
1005
+ # Fallback - return as formatted string
1006
+ return str(data)
1007
+
1008
+ return str(data)
1009
+
1010
+
1011
+ def _apply_change(project_path: Path, change, ui: TarangConsole) -> bool:
1012
+ """Apply a file change locally."""
1013
+ from tarang.stream import FileChange
1014
+
1015
+ file_path = project_path / change.path
1016
+
1017
+ try:
1018
+ if change.type == "create":
1019
+ # Create parent directories
1020
+ file_path.parent.mkdir(parents=True, exist_ok=True)
1021
+ file_path.write_text(change.content or "", encoding="utf-8")
1022
+ return True
1023
+
1024
+ elif change.type == "edit":
1025
+ if not file_path.exists():
1026
+ ui.console.print(f"[red]File not found: {change.path}[/red]")
1027
+ return False
1028
+
1029
+ content = file_path.read_text(encoding="utf-8")
1030
+
1031
+ if change.search and change.search not in content:
1032
+ ui.console.print(f"[red]Search text not found in {change.path}[/red]")
1033
+ return False
1034
+
1035
+ new_content = content.replace(change.search, change.replace or "", 1)
1036
+ file_path.write_text(new_content, encoding="utf-8")
1037
+ return True
1038
+
1039
+ elif change.type == "delete":
1040
+ if file_path.exists():
1041
+ file_path.unlink()
1042
+ return True
1043
+
1044
+ return False
1045
+
1046
+ except Exception as e:
1047
+ ui.console.print(f"[red]Error applying change: {e}[/red]")
1048
+ return False
1049
+
1050
+
1051
+ @cli.command()
1052
+ @click.argument("query", required=True)
1053
+ def ask(query: str):
1054
+ """Quick question without code generation."""
1055
+ ui = get_console()
1056
+ auth = TarangAuth()
1057
+
1058
+ if not auth.has_openrouter_key():
1059
+ ui.print_error("OpenRouter key not set.")
1060
+ ui.console.print("Run: [cyan]tarang config --openrouter-key YOUR_KEY[/]")
1061
+ sys.exit(1)
1062
+
1063
+ creds = auth.load_credentials()
1064
+ client = TarangAPIClient(creds.get("backend_url"))
1065
+ client.openrouter_key = creds.get("openrouter_key")
1066
+
1067
+ try:
1068
+ with ui.thinking("Thinking..."):
1069
+ answer = asyncio.run(client.quick_ask(query))
1070
+ ui.print_message(answer, title="Answer")
1071
+ except Exception as e:
1072
+ ui.print_error(str(e))
1073
+ sys.exit(1)
1074
+
1075
+
1076
+ @cli.command()
1077
+ def status():
1078
+ """Show Tarang status and configuration."""
1079
+ ui = get_console()
1080
+ auth = TarangAuth()
1081
+ creds = auth.load_credentials() or {}
1082
+
1083
+ ui.console.print(f"\n[bold cyan]Tarang[/] v{__version__}")
1084
+ ui.console.print("─" * 40)
1085
+
1086
+ # Auth status
1087
+ if auth.is_authenticated():
1088
+ ui.console.print("[green]✓[/] Authentication: Logged in")
1089
+ else:
1090
+ ui.console.print("[red]✗[/] Authentication: Not logged in")
1091
+ ui.console.print(" Run: [cyan]tarang login[/]")
1092
+
1093
+ # OpenRouter key
1094
+ if auth.has_openrouter_key():
1095
+ key = creds.get("openrouter_key", "")
1096
+ ui.console.print(f"[green]✓[/] OpenRouter Key: {key[:12]}...")
1097
+ else:
1098
+ ui.console.print("[red]✗[/] OpenRouter Key: Not set")
1099
+ ui.console.print(" Run: [cyan]tarang config --openrouter-key YOUR_KEY[/]")
1100
+
1101
+ # Backend URL
1102
+ backend_url = creds.get("backend_url", TarangAPIClient.DEFAULT_BASE_URL)
1103
+ ui.console.print(f"[dim]Backend:[/] {backend_url}")
1104
+
1105
+ # Test connectivity
1106
+ ui.console.print()
1107
+ with ui.thinking("Testing connection..."):
1108
+ try:
1109
+ import httpx
1110
+ response = httpx.get(f"{backend_url}/health", timeout=5)
1111
+ if response.status_code == 200:
1112
+ ui.print_success("Backend connected")
1113
+ else:
1114
+ ui.print_warning(f"Backend status: {response.status_code}")
1115
+ except Exception as e:
1116
+ ui.print_error(f"Cannot connect: {e}")
1117
+
1118
+ ui.console.print()
1119
+
1120
+
1121
+ @cli.command()
1122
+ @click.option("--project-dir", "-p", default=".", help="Project directory")
1123
+ @click.option("--force", "-f", is_flag=True, help="Don't ask for confirmation")
1124
+ def clean(project_dir: str, force: bool):
1125
+ """Clean Tarang state from the project."""
1126
+ ui = get_console()
1127
+ project_path = Path(project_dir).resolve()
1128
+ tarang_dir = project_path / ".tarang"
1129
+ backup_dir = project_path / ".tarang_backups"
1130
+
1131
+ if not tarang_dir.exists() and not backup_dir.exists():
1132
+ ui.print_info("No Tarang state to clean.")
1133
+ return
1134
+
1135
+ if not force and not ui.confirm(f"Remove Tarang state from {project_path}?"):
1136
+ return
1137
+
1138
+ if tarang_dir.exists():
1139
+ shutil.rmtree(tarang_dir)
1140
+ ui.print_success("Removed .tarang directory")
1141
+
1142
+ if backup_dir.exists():
1143
+ shutil.rmtree(backup_dir)
1144
+ ui.print_success("Removed .tarang_backups directory")
1145
+
1146
+
1147
+ @cli.command()
1148
+ def logout():
1149
+ """Log out and clear saved credentials."""
1150
+ ui = get_console()
1151
+ auth = TarangAuth()
1152
+
1153
+ if not auth.is_authenticated():
1154
+ ui.print_info("Not logged in.")
1155
+ return
1156
+
1157
+ if ui.confirm("Clear all saved credentials?"):
1158
+ auth.clear_credentials()
1159
+ ui.print_success("Logged out. Credentials cleared.")
1160
+
1161
+
1162
+ def main():
1163
+ """Main entry point."""
1164
+ cli()
1165
+
1166
+
1167
+ if __name__ == "__main__":
1168
+ main()