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/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()
|