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/formatter.py ADDED
@@ -0,0 +1,1151 @@
1
+ """
2
+ Shared output formatter for consistent tool display across CLI.
3
+
4
+ This module provides a unified interface for displaying tool execution,
5
+ approvals, results, and diffs. Used by both SSE (stream.py) and WebSocket
6
+ (ws/handlers.py) implementations.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any, Dict, Optional
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import List
17
+
18
+ from rich.console import Console
19
+ from rich.panel import Panel
20
+ from rich.syntax import Syntax
21
+ from rich.text import Text
22
+ from rich.table import Table
23
+
24
+
25
+ @dataclass
26
+ class PhaseStatus:
27
+ """Status of a single phase in the plan."""
28
+ name: str
29
+ worker: str = ""
30
+ goals: str = ""
31
+ status: str = "pending" # pending, running, completed, failed
32
+ current_step: str = "" # Current worker task being executed
33
+
34
+
35
+ @dataclass
36
+ class PhaseTracker:
37
+ """
38
+ Tracks execution progress through phases and worker steps.
39
+
40
+ Provides a live-updating checklist view of:
41
+ - PRD phases from orchestrator
42
+ - Worker steps from architect
43
+ - Tool calls within each step
44
+ """
45
+ console: Console
46
+ phases: List[PhaseStatus] = field(default_factory=list)
47
+ prd_title: str = ""
48
+ prd_requirements: List[str] = field(default_factory=list)
49
+ current_phase_index: int = 0
50
+ current_worker: str = ""
51
+ tool_count: int = 0
52
+
53
+ def set_plan(self, plan: dict) -> None:
54
+ """Initialize tracker with orchestrator's plan. Renders ONCE."""
55
+ # Skip if plan already set (prevent duplicate renders)
56
+ if self.phases:
57
+ return
58
+
59
+ prd = plan.get("prd", {})
60
+ phases = plan.get("phases", [])
61
+
62
+ self.prd_title = prd.get("title", "")
63
+ self.prd_requirements = prd.get("requirements", [])
64
+
65
+ self.phases = [
66
+ PhaseStatus(
67
+ name=p.get("name", f"Phase {i+1}"),
68
+ worker=p.get("worker", ""),
69
+ goals=p.get("goals", ""), # Store full goals
70
+ )
71
+ for i, p in enumerate(phases)
72
+ ]
73
+ self.current_phase_index = 0
74
+ self.render()
75
+
76
+ def set_worker_tasks(self, tasks: list) -> None:
77
+ """Set architect's decomposed tasks as phases."""
78
+ self.phases = []
79
+ for i, t in enumerate(tasks):
80
+ if isinstance(t, dict):
81
+ worker = t.get("worker", "coder")
82
+ goals = t.get("goals", "")
83
+ # For architect tasks, use worker name + brief goal as the name
84
+ name = f"{worker}: {goals[:50]}..." if len(goals) > 50 else f"{worker}: {goals}" if goals else f"Step {i+1}"
85
+ else:
86
+ worker = "coder"
87
+ goals = str(t)
88
+ name = f"Step {i+1}: {goals[:50]}..." if len(goals) > 50 else f"Step {i+1}: {goals}"
89
+
90
+ self.phases.append(PhaseStatus(
91
+ name=name,
92
+ worker=worker,
93
+ goals="", # Don't show goals separately for architect tasks
94
+ ))
95
+ self.current_phase_index = 0
96
+ self.render()
97
+
98
+ def start_phase(self, phase_name: str) -> None:
99
+ """Mark a phase as running (with render)."""
100
+ self.update_phase_status(phase_name, "running")
101
+ self.render()
102
+
103
+ def start_worker(self, worker: str, task: str = "") -> None:
104
+ """Mark current phase's worker as active (with render)."""
105
+ self.update_worker_status(worker, task, "running")
106
+ self.render()
107
+
108
+ def complete_worker(self, worker: str) -> None:
109
+ """Mark worker complete and advance to next phase (with render)."""
110
+ self.update_worker_status(worker, "", "completed")
111
+ self.render()
112
+
113
+ def update_phase_status(self, phase_name: str, status: str, phase_index: int = -1) -> None:
114
+ """Update phase status WITHOUT rendering. Use for incremental updates."""
115
+ if phase_index >= 0 and phase_index < len(self.phases):
116
+ self.phases[phase_index].status = status
117
+ self.current_phase_index = phase_index
118
+ else:
119
+ # Find matching phase by name or use current index
120
+ for i, p in enumerate(self.phases):
121
+ if p.name == phase_name or i == self.current_phase_index:
122
+ p.status = status
123
+ self.current_phase_index = i
124
+ break
125
+
126
+ def update_worker_status(self, worker: str, task: str = "", status: str = "running") -> None:
127
+ """Update worker status WITHOUT rendering. Use for incremental updates."""
128
+ if status == "completed":
129
+ if self.phases and self.current_phase_index < len(self.phases):
130
+ self.phases[self.current_phase_index].status = "completed"
131
+ self.phases[self.current_phase_index].current_step = ""
132
+ self.current_phase_index += 1
133
+ self.current_worker = ""
134
+ self.tool_count = 0
135
+ else:
136
+ self.current_worker = worker
137
+ self.tool_count = 0
138
+ if self.phases and self.current_phase_index < len(self.phases):
139
+ self.phases[self.current_phase_index].current_step = f"{worker}: {task[:40]}..." if task else worker
140
+ self.phases[self.current_phase_index].status = "running"
141
+
142
+ def increment_tool(self) -> None:
143
+ """Track tool call within current step."""
144
+ self.tool_count += 1
145
+
146
+ def render(self) -> None:
147
+ """Render the current checklist state."""
148
+ if not self.phases:
149
+ return
150
+
151
+ # Clear previous output and render fresh
152
+ lines = []
153
+
154
+ # Title
155
+ if self.prd_title:
156
+ lines.append(f"[bold blue]📋 {self.prd_title}[/bold blue]")
157
+ lines.append("")
158
+
159
+ # Phase checklist
160
+ for i, phase in enumerate(self.phases):
161
+ # Status icon
162
+ if phase.status == "completed":
163
+ icon = "[green]✓[/green]"
164
+ style = "dim"
165
+ elif phase.status == "running":
166
+ icon = "[yellow]▶[/yellow]"
167
+ style = "bold"
168
+ elif phase.status == "failed":
169
+ icon = "[red]✗[/red]"
170
+ style = "red"
171
+ else:
172
+ icon = "[dim]○[/dim]"
173
+ style = "dim"
174
+
175
+ # Phase line - show full name for orchestrator phases (when PRD exists)
176
+ worker_badge = f"[cyan]{phase.worker}[/cyan]" if phase.worker else ""
177
+
178
+ # For orchestrator phases (has PRD title), show full name
179
+ # For architect phases (no PRD), show shorter name
180
+ if self.prd_title:
181
+ name_display = phase.name # Full name for orchestrator
182
+ else:
183
+ name_display = phase.name if len(phase.name) <= 40 else phase.name[:37] + "..."
184
+
185
+ line = f" {icon} [{style}]{name_display}[/{style}]"
186
+ if worker_badge:
187
+ line += f" {worker_badge}"
188
+
189
+ lines.append(line)
190
+
191
+ # Show goals for orchestrator phases (when PRD exists and goals present)
192
+ if self.prd_title and phase.goals and phase.status != "completed":
193
+ # Wrap goals at ~70 chars for readability
194
+ goals_display = phase.goals[:120] + "..." if len(phase.goals) > 120 else phase.goals
195
+ lines.append(f" [dim italic]{goals_display}[/dim italic]")
196
+
197
+ # Current step (if running)
198
+ if phase.status == "running" and phase.current_step:
199
+ tool_info = f" ({self.tool_count} tools)" if self.tool_count > 0 else ""
200
+ lines.append(f" [dim]→ {phase.current_step}{tool_info}[/dim]")
201
+
202
+ # Progress summary
203
+ completed = sum(1 for p in self.phases if p.status == "completed")
204
+ total = len(self.phases)
205
+ lines.append("")
206
+ lines.append(f" [dim]Progress: {completed}/{total} phases[/dim]")
207
+
208
+ # Print all lines
209
+ self.console.print("\n".join(lines))
210
+
211
+
212
+ class OutputFormatter:
213
+ """
214
+ Unified output formatter for Tarang CLI.
215
+
216
+ Provides consistent, rich terminal output for:
217
+ - Tool execution previews and results
218
+ - Approval requests with syntax highlighting
219
+ - Diff displays for file changes
220
+ - Shell command output
221
+ - Search results
222
+
223
+ Usage:
224
+ formatter = OutputFormatter(console)
225
+ formatter.show_tool_request("write_file", args, require_approval=True)
226
+ formatter.show_tool_result("write_file", args, result)
227
+ """
228
+
229
+ # Language detection by file extension
230
+ LANG_MAP = {
231
+ ".py": "python",
232
+ ".js": "javascript",
233
+ ".ts": "typescript",
234
+ ".tsx": "tsx",
235
+ ".jsx": "jsx",
236
+ ".json": "json",
237
+ ".yaml": "yaml",
238
+ ".yml": "yaml",
239
+ ".md": "markdown",
240
+ ".html": "html",
241
+ ".css": "css",
242
+ ".scss": "scss",
243
+ ".sh": "bash",
244
+ ".bash": "bash",
245
+ ".zsh": "bash",
246
+ ".rs": "rust",
247
+ ".go": "go",
248
+ ".java": "java",
249
+ ".c": "c",
250
+ ".cpp": "cpp",
251
+ ".h": "c",
252
+ ".hpp": "cpp",
253
+ ".rb": "ruby",
254
+ ".php": "php",
255
+ ".sql": "sql",
256
+ ".toml": "toml",
257
+ ".xml": "xml",
258
+ ".vue": "vue",
259
+ ".svelte": "svelte",
260
+ }
261
+
262
+ # Tool icons
263
+ TOOL_ICONS = {
264
+ "read_file": "📖",
265
+ "write_file": "📝",
266
+ "edit_file": "✏️",
267
+ "delete_file": "🗑️",
268
+ "shell": "💻",
269
+ "list_files": "📂",
270
+ "search_files": "🔍",
271
+ "search_code": "🔎",
272
+ "get_file_info": "ℹ️",
273
+ "validate_file": "✅",
274
+ "validate_build": "🔨",
275
+ }
276
+
277
+ # Tool colors
278
+ TOOL_COLORS = {
279
+ "read_file": "blue",
280
+ "write_file": "green",
281
+ "edit_file": "cyan",
282
+ "delete_file": "red",
283
+ "shell": "yellow",
284
+ "list_files": "blue",
285
+ "search_files": "magenta",
286
+ "search_code": "magenta",
287
+ "get_file_info": "blue",
288
+ "validate_file": "green",
289
+ "validate_build": "yellow",
290
+ }
291
+
292
+ def __init__(self, console: Optional[Console] = None, verbose: bool = False, compact: bool = True):
293
+ """
294
+ Initialize the formatter.
295
+
296
+ Args:
297
+ console: Rich Console instance. Created if not provided.
298
+ verbose: Show detailed output for all operations.
299
+ compact: Use compact single-line output for tools (default True).
300
+ """
301
+ self.console = console or Console()
302
+ self.verbose = verbose
303
+ self.compact = compact
304
+ # Store pending tool requests for compact mode (to merge request + result)
305
+ self._pending_tool: Optional[Dict[str, Any]] = None
306
+ # Phase tracker for checklist display
307
+ self.phase_tracker: Optional[PhaseTracker] = None
308
+
309
+ def init_phase_tracker(self) -> PhaseTracker:
310
+ """Initialize and return a phase tracker for this session."""
311
+ self.phase_tracker = PhaseTracker(console=self.console)
312
+ return self.phase_tracker
313
+
314
+ def _get_language(self, file_path: str) -> str:
315
+ """Detect language from file extension."""
316
+ _, ext = os.path.splitext(file_path)
317
+ return self.LANG_MAP.get(ext.lower(), "text")
318
+
319
+ def _get_icon(self, tool: str) -> str:
320
+ """Get icon for tool."""
321
+ return self.TOOL_ICONS.get(tool, "•")
322
+
323
+ def _get_color(self, tool: str) -> str:
324
+ """Get color for tool."""
325
+ return self.TOOL_COLORS.get(tool, "white")
326
+
327
+ # =========================================================================
328
+ # Tool Progress Indicators
329
+ # =========================================================================
330
+
331
+ # Descriptive action messages for tools (max 10 chars for alignment)
332
+ TOOL_ACTIONS = {
333
+ "read_file": "Read",
334
+ "read_files": "Batch", # Batch read
335
+ "write_file": "Write",
336
+ "edit_file": "Edit",
337
+ "delete_file": "Delete",
338
+ "list_files": "List",
339
+ "search_files": "Search",
340
+ "search_code": "Index",
341
+ "get_file_info": "Check",
342
+ "shell": "Run",
343
+ "validate_file": "Validate",
344
+ "validate_build": "Build",
345
+ }
346
+
347
+ def show_tool_progress(self, tool: str, args: Dict[str, Any]) -> None:
348
+ """
349
+ Show tool execution in progress.
350
+
351
+ In compact mode, we skip this and show the action in the result line instead.
352
+ This avoids duplicate lines and keeps output clean.
353
+ """
354
+ # In compact mode, we integrate action text into result line
355
+ # So no progress display needed - result will show "Read file.py (24 lines)"
356
+ if self.compact:
357
+ return
358
+
359
+ # Non-compact mode shows full progress
360
+ icon = self._get_icon(tool)
361
+ action = self.TOOL_ACTIONS.get(tool, "Running")
362
+
363
+ # Build target description
364
+ if tool == "read_file":
365
+ target = args.get("file_path", "")
366
+ target = target if len(target) <= 40 else "..." + target[-37:]
367
+ elif tool == "list_files":
368
+ target = args.get("path", ".")
369
+ elif tool in ("search_files", "search_code"):
370
+ target = f"'{args.get('pattern', args.get('query', ''))[:25]}'"
371
+ elif tool == "shell":
372
+ cmd = args.get("command", "")[:35].replace("\n", " ")
373
+ target = cmd
374
+ elif tool in ("write_file", "edit_file", "delete_file", "get_file_info"):
375
+ target = args.get("file_path", "")
376
+ target = target if len(target) <= 40 else "..." + target[-37:]
377
+ else:
378
+ target = ""
379
+
380
+ self.console.print(f" [dim]{icon} {action} {target}...[/dim]")
381
+
382
+ # =========================================================================
383
+ # Tool Request Display (Before Execution)
384
+ # =========================================================================
385
+
386
+ def show_tool_request(
387
+ self,
388
+ tool: str,
389
+ args: Dict[str, Any],
390
+ require_approval: bool = False,
391
+ description: str = "",
392
+ ) -> None:
393
+ """
394
+ Display a tool request before execution.
395
+
396
+ In compact mode, read-only tools are deferred to show_tool_result for single-line output.
397
+ Write operations that require approval still show full previews.
398
+
399
+ Args:
400
+ tool: Tool name (e.g., "write_file", "shell")
401
+ args: Tool arguments
402
+ require_approval: Whether this tool needs user approval
403
+ description: Optional description of what the tool will do
404
+ """
405
+ icon = self._get_icon(tool)
406
+ color = self._get_color(tool)
407
+
408
+ # In compact mode, defer read-only tools to show_tool_result
409
+ if self.compact and tool in ("read_file", "list_files", "search_files", "search_code", "get_file_info"):
410
+ self._pending_tool = {"tool": tool, "args": args, "description": description}
411
+ return
412
+
413
+ # Write operations always show full preview (need user to see what's changing)
414
+ if tool == "write_file":
415
+ self._show_write_file_request(args, description)
416
+ elif tool == "edit_file":
417
+ self._show_edit_file_request(args, description)
418
+ elif tool == "delete_file":
419
+ self._show_delete_file_request(args, description)
420
+ elif tool == "shell":
421
+ self._show_shell_request(args, description)
422
+ elif tool == "read_file":
423
+ file_path = args.get("file_path", "...")
424
+ self.console.print(f" [{color}]{icon} read_file:[/{color}] {file_path}")
425
+ elif tool == "list_files":
426
+ path = args.get("path", ".")
427
+ pattern = args.get("pattern", "")
428
+ display = f"{path}" + (f" ({pattern})" if pattern else "")
429
+ self.console.print(f" [{color}]{icon} list_files:[/{color}] {display}")
430
+ elif tool == "search_files":
431
+ pattern = args.get("pattern", "...")
432
+ self.console.print(f" [{color}]{icon} search_files:[/{color}] {pattern}")
433
+ else:
434
+ # Generic display
435
+ self.console.print(f" [{color}]{icon} {tool}[/{color}]")
436
+
437
+ def _show_write_file_request(self, args: Dict[str, Any], description: str) -> None:
438
+ """Display write_file request with syntax-highlighted preview."""
439
+ file_path = args.get("file_path", "")
440
+ content = args.get("content", "")
441
+ language = self._get_language(file_path)
442
+ lines = content.split("\n")
443
+
444
+ self.console.print(f"[bold green]╭─ 📝 Create: {file_path}[/bold green]")
445
+ if description:
446
+ self.console.print(f"[bold green]│[/bold green] [dim]{description}[/dim]")
447
+
448
+ # Show syntax-highlighted preview (max 20 lines)
449
+ preview_lines = lines[:20]
450
+ preview = "\n".join(preview_lines)
451
+
452
+ try:
453
+ syntax = Syntax(
454
+ preview,
455
+ language,
456
+ theme="monokai",
457
+ line_numbers=True,
458
+ word_wrap=True,
459
+ )
460
+ self.console.print(Panel(
461
+ syntax,
462
+ border_style="green",
463
+ title="[green]+ New File[/green]",
464
+ subtitle=f"[dim]{len(lines)} lines[/dim]" if len(lines) > 20 else None,
465
+ ))
466
+ except Exception:
467
+ # Fallback to simple display
468
+ for line in preview_lines[:10]:
469
+ self.console.print(f"[bold green]│[/bold green] [green]+ {line}[/green]")
470
+ if len(lines) > 10:
471
+ self.console.print(f"[bold green]│[/bold green] [dim]... ({len(lines)} lines total)[/dim]")
472
+
473
+ if len(lines) > 20:
474
+ self.console.print(f"[bold green]╰─[/bold green] [dim]... and {len(lines) - 20} more lines[/dim]")
475
+ else:
476
+ self.console.print("[bold green]╰─[/bold green]")
477
+
478
+ def _show_edit_file_request(self, args: Dict[str, Any], description: str) -> None:
479
+ """Display edit_file request with diff preview."""
480
+ file_path = args.get("file_path", "")
481
+ search = args.get("search", "")
482
+ replace = args.get("replace", "")
483
+
484
+ search_lines = search.split("\n")
485
+ replace_lines = replace.split("\n")
486
+
487
+ self.console.print(f"[bold cyan]╭─ ✏️ Edit: {file_path}[/bold cyan]")
488
+ if description:
489
+ self.console.print(f"[bold cyan]│[/bold cyan] [dim]{description}[/dim]")
490
+
491
+ # Show removal (red)
492
+ if search_lines:
493
+ self.console.print("[bold cyan]│[/bold cyan]")
494
+ self.console.print("[bold cyan]│[/bold cyan] [red]Remove:[/red]")
495
+ for line in search_lines[:10]:
496
+ self.console.print(f"[bold cyan]│[/bold cyan] [red]- {line}[/red]")
497
+ if len(search_lines) > 10:
498
+ self.console.print(f"[bold cyan]│[/bold cyan] [dim]... ({len(search_lines)} lines total)[/dim]")
499
+
500
+ # Show addition (green)
501
+ if replace_lines:
502
+ self.console.print("[bold cyan]│[/bold cyan]")
503
+ self.console.print("[bold cyan]│[/bold cyan] [green]Add:[/green]")
504
+ for line in replace_lines[:10]:
505
+ self.console.print(f"[bold cyan]│[/bold cyan] [green]+ {line}[/green]")
506
+ if len(replace_lines) > 10:
507
+ self.console.print(f"[bold cyan]│[/bold cyan] [dim]... ({len(replace_lines)} lines total)[/dim]")
508
+
509
+ self.console.print("[bold cyan]╰─[/bold cyan]")
510
+
511
+ def _show_delete_file_request(self, args: Dict[str, Any], description: str) -> None:
512
+ """Display delete_file request with warning."""
513
+ file_path = args.get("file_path", "")
514
+
515
+ self.console.print(f"[bold red]╭─ 🗑️ Delete: {file_path}[/bold red]")
516
+ if description:
517
+ self.console.print(f"[bold red]│[/bold red] [dim]{description}[/dim]")
518
+ self.console.print("[bold red]╰─ This action cannot be undone![/bold red]")
519
+
520
+ def _show_shell_request(self, args: Dict[str, Any], description: str) -> None:
521
+ """Display shell command request with syntax highlighting."""
522
+ command = args.get("command", "")
523
+ cwd = args.get("cwd", "")
524
+ timeout = args.get("timeout", 60)
525
+
526
+ self.console.print(f"[bold yellow]╭─ 💻 Shell Command[/bold yellow]")
527
+ if description:
528
+ self.console.print(f"[bold yellow]│[/bold yellow] [dim]{description}[/dim]")
529
+
530
+ try:
531
+ syntax = Syntax(command, "bash", theme="monokai")
532
+ self.console.print(Panel(
533
+ syntax,
534
+ border_style="yellow",
535
+ title="[yellow]$ Command[/yellow]",
536
+ ))
537
+ except Exception:
538
+ self.console.print(f"[bold yellow]│[/bold yellow] $ {command}")
539
+
540
+ if cwd:
541
+ self.console.print(f"[bold yellow]│[/bold yellow] [dim]Directory: {cwd}[/dim]")
542
+ self.console.print(f"[bold yellow]╰─[/bold yellow] [dim]Timeout: {timeout}s[/dim]")
543
+
544
+ # =========================================================================
545
+ # Tool Result Display (After Execution)
546
+ # =========================================================================
547
+
548
+ def show_tool_result(
549
+ self,
550
+ tool: str,
551
+ args: Dict[str, Any],
552
+ result: Dict[str, Any],
553
+ duration_s: Optional[float] = None,
554
+ ) -> None:
555
+ """
556
+ Display the result of a tool execution.
557
+
558
+ In compact mode, shows a single-line summary combining request + result.
559
+
560
+ Args:
561
+ tool: Tool name
562
+ args: Original tool arguments
563
+ result: Tool execution result
564
+ duration_s: Execution time in seconds (for right-aligned stats)
565
+ """
566
+ icon = self._get_icon(tool)
567
+ color = self._get_color(tool)
568
+
569
+ # Clear pending tool
570
+ self._pending_tool = None
571
+
572
+ # Stats for right side
573
+ stats = f"[dim]{duration_s}s[/dim]" if duration_s is not None else ""
574
+
575
+ if "error" in result:
576
+ left = f" [red]✗ {tool}: {result['error'][:50]}[/red]"
577
+ if stats:
578
+ self.console.print(f"{left:<{self.LINE_WIDTH}} {stats}")
579
+ else:
580
+ self.console.print(left)
581
+ return
582
+
583
+ # Compact mode: single-line output for read-only tools
584
+ if self.compact and tool in ("read_file", "read_files", "list_files", "search_files", "search_code", "get_file_info"):
585
+ self._show_compact_result(tool, args, result, duration_s)
586
+ return
587
+
588
+ if tool == "read_file":
589
+ self._show_read_file_result(args, result)
590
+ elif tool == "write_file":
591
+ self._show_write_file_result(args, result, duration_s)
592
+ elif tool == "edit_file":
593
+ self._show_edit_file_result(args, result, duration_s)
594
+ elif tool == "delete_file":
595
+ self._show_delete_file_result(args, result)
596
+ elif tool == "shell":
597
+ self._show_shell_result(args, result, duration_s)
598
+ elif tool == "list_files":
599
+ self._show_list_files_result(args, result)
600
+ elif tool == "search_files":
601
+ self._show_search_files_result(args, result)
602
+ else:
603
+ # Generic success
604
+ if result.get("success"):
605
+ left = f" [{color}]✓ {tool}: OK[/{color}]"
606
+ if duration_s is not None:
607
+ self.console.print(f"{left:<{self.LINE_WIDTH}} [dim]{duration_s}s[/dim]")
608
+ else:
609
+ self.console.print(left)
610
+ else:
611
+ self.console.print(f" [dim]{tool}: completed[/dim]")
612
+
613
+ # Width for action column alignment (longest: "Validate" = 8)
614
+ ACTION_WIDTH = 8
615
+ # Width for left part of line (for right-aligned stats)
616
+ LINE_WIDTH = 55
617
+
618
+ def _show_compact_result(
619
+ self,
620
+ tool: str,
621
+ args: Dict[str, Any],
622
+ result: Dict[str, Any],
623
+ duration_s: Optional[float] = None,
624
+ ) -> None:
625
+ """Show compact single-line result for read-only tools with aligned columns."""
626
+ icon = self._get_icon(tool)
627
+ color = self._get_color(tool)
628
+ action = self.TOOL_ACTIONS.get(tool, "Done")
629
+ # Pad action to fixed width for alignment
630
+ action_padded = action.ljust(self.ACTION_WIDTH)
631
+ # Stats on the right (duration, future: tokens, etc.)
632
+ stats = f"[dim]{duration_s}s[/dim]" if duration_s is not None else ""
633
+
634
+ if tool == "read_file":
635
+ file_path = args.get("file_path", "")
636
+ lines = result.get("lines", 0)
637
+ # Truncate long paths
638
+ display_path = file_path if len(file_path) <= 35 else "..." + file_path[-32:]
639
+ left = f" [{color}]✓ {icon} {action_padded}[/{color}] {display_path} [dim]({lines} lines)[/dim]"
640
+
641
+ elif tool == "read_files":
642
+ # Batch read - show count and total lines
643
+ file_paths = args.get("file_paths", [])
644
+ successful = result.get("successful", 0)
645
+ total_lines = result.get("total_lines", 0)
646
+ left = f" [{color}]✓ 📚 {action_padded}[/{color}] {successful} files [dim]({total_lines} lines)[/dim]"
647
+
648
+ elif tool == "list_files":
649
+ path = args.get("path", ".")
650
+ if len(path) > 30:
651
+ path = "..." + path[-27:]
652
+ count = result.get("count", len(result.get("files", [])))
653
+ left = f" [{color}]✓ {icon} {action_padded}[/{color}] {path} [dim]({count} files)[/dim]"
654
+
655
+ elif tool == "search_files":
656
+ pattern = args.get("pattern", "")[:25]
657
+ count = result.get("count", len(result.get("matches", [])))
658
+ left = f" [{color}]✓ {icon} {action_padded}[/{color}] '{pattern}' [dim]({count} matches)[/dim]"
659
+
660
+ elif tool == "search_code":
661
+ query = args.get("query", "")[:25]
662
+ chunks = len(result.get("chunks", []))
663
+ left = f" [{color}]✓ {icon} {action_padded}[/{color}] '{query}' [dim]({chunks} chunks)[/dim]"
664
+
665
+ elif tool == "get_file_info":
666
+ file_path = args.get("file_path", "")
667
+ exists = "exists" if result.get("exists") else "not found"
668
+ display_path = file_path if len(file_path) <= 35 else "..." + file_path[-32:]
669
+ left = f" [{color}]✓ {icon} {action_padded}[/{color}] {display_path} [dim]({exists})[/dim]"
670
+
671
+ else:
672
+ action = self.TOOL_ACTIONS.get(tool, tool)
673
+ action_padded = action.ljust(self.ACTION_WIDTH)
674
+ left = f" [{color}]✓ {icon} {action_padded}[/{color}]"
675
+
676
+ # Print with stats right-aligned
677
+ if stats:
678
+ # Use Rich's Text for proper alignment with markup
679
+ self.console.print(f"{left:<{self.LINE_WIDTH}} {stats}")
680
+ else:
681
+ self.console.print(left)
682
+
683
+ def _show_read_file_result(self, args: Dict[str, Any], result: Dict[str, Any]) -> None:
684
+ """Display read_file result with line count."""
685
+ file_path = args.get("file_path", "")
686
+ content = result.get("content", "")
687
+ lines = content.count("\n") + 1 if content else 0
688
+ chars = len(content)
689
+
690
+ self.console.print(f" [blue]✓ read_file:[/blue] {file_path}")
691
+ self.console.print(f" [dim]Read {lines} lines ({chars:,} chars)[/dim]")
692
+
693
+ # Show preview in verbose mode
694
+ if self.verbose and content:
695
+ preview_lines = content.split("\n")[:5]
696
+ for line in preview_lines:
697
+ truncated = line[:80] + "..." if len(line) > 80 else line
698
+ self.console.print(f" [dim]│ {truncated}[/dim]")
699
+ if lines > 5:
700
+ self.console.print(f" [dim]│ ... ({lines - 5} more lines)[/dim]")
701
+
702
+ def _show_write_file_result(self, args: Dict[str, Any], result: Dict[str, Any], duration_s: Optional[float] = None) -> None:
703
+ """Display write_file result with summary."""
704
+ file_path = args.get("file_path", "")
705
+ content = args.get("content", "")
706
+ lines = content.count("\n") + 1 if content else 0
707
+ display_path = file_path if len(file_path) <= 40 else "..." + file_path[-37:]
708
+ stats = f"[dim]{duration_s}s[/dim]" if duration_s is not None else ""
709
+
710
+ if result.get("success"):
711
+ if self.compact:
712
+ left = f" [green]✓ 📝[/green] {display_path} [dim]({lines} lines)[/dim]"
713
+ if stats:
714
+ self.console.print(f"{left:<{self.LINE_WIDTH}} {stats}")
715
+ else:
716
+ self.console.print(left)
717
+ else:
718
+ self.console.print(f" [green]✓ write_file:[/green] {file_path}")
719
+ self.console.print(f" [dim]Created {lines} lines[/dim]")
720
+ else:
721
+ self.console.print(f" [red]✗ write_file:[/red] {file_path} - FAILED")
722
+
723
+ def _show_edit_file_result(self, args: Dict[str, Any], result: Dict[str, Any], duration_s: Optional[float] = None) -> None:
724
+ """Display edit_file result with replacement count."""
725
+ file_path = args.get("file_path", "")
726
+ replacements = result.get("replacements", 1)
727
+ display_path = file_path if len(file_path) <= 40 else "..." + file_path[-37:]
728
+ stats = f"[dim]{duration_s}s[/dim]" if duration_s is not None else ""
729
+
730
+ if result.get("success"):
731
+ if self.compact:
732
+ left = f" [cyan]✓ ✏️[/cyan] {display_path} [dim]({replacements} edit{'s' if replacements > 1 else ''})[/dim]"
733
+ if stats:
734
+ self.console.print(f"{left:<{self.LINE_WIDTH}} {stats}")
735
+ else:
736
+ self.console.print(left)
737
+ else:
738
+ self.console.print(f" [cyan]✓ edit_file:[/cyan] {file_path}")
739
+ self.console.print(f" [dim]{replacements} replacement(s) made[/dim]")
740
+ else:
741
+ self.console.print(f" [red]✗ edit_file:[/red] {file_path} - FAILED")
742
+
743
+ def _show_delete_file_result(self, args: Dict[str, Any], result: Dict[str, Any]) -> None:
744
+ """Display delete_file result."""
745
+ file_path = args.get("file_path", "")
746
+ display_path = file_path if len(file_path) <= 40 else "..." + file_path[-37:]
747
+
748
+ if result.get("success"):
749
+ if self.compact:
750
+ self.console.print(f" [red]✓ 🗑️[/red] {display_path} [dim](deleted)[/dim]")
751
+ else:
752
+ self.console.print(f" [red]✓ delete_file:[/red] {file_path} [dim](deleted)[/dim]")
753
+ else:
754
+ self.console.print(f" [red]✗ delete_file:[/red] {file_path} - FAILED")
755
+
756
+ def _show_shell_result(self, args: Dict[str, Any], result: Dict[str, Any], duration_s: Optional[float] = None) -> None:
757
+ """Display shell command result with output."""
758
+ command = args.get("command", "")
759
+ exit_code = result.get("exit_code", -1)
760
+ stdout = result.get("stdout", "")
761
+ stderr = result.get("stderr", "")
762
+ stats = f"[dim]{duration_s}s[/dim]" if duration_s is not None else ""
763
+
764
+ # Compact command preview (first 35 chars to leave room for stats)
765
+ cmd_preview = command[:35] + "..." if len(command) > 35 else command
766
+ cmd_preview = cmd_preview.replace("\n", " ")
767
+
768
+ # Status line
769
+ if exit_code == 0:
770
+ if self.compact:
771
+ left = f" [green]✓ 💻[/green] {cmd_preview} [dim](exit 0)[/dim]"
772
+ if stats:
773
+ self.console.print(f"{left:<{self.LINE_WIDTH}} {stats}")
774
+ else:
775
+ self.console.print(left)
776
+ else:
777
+ self.console.print(f" [green]✓ shell:[/green] exit {exit_code}")
778
+ else:
779
+ if self.compact:
780
+ left = f" [yellow]⚠ 💻[/yellow] {cmd_preview} [dim](exit {exit_code})[/dim]"
781
+ if stats:
782
+ self.console.print(f"{left:<{self.LINE_WIDTH}} {stats}")
783
+ else:
784
+ self.console.print(left)
785
+ else:
786
+ self.console.print(f" [yellow]⚠ shell:[/yellow] exit {exit_code}")
787
+
788
+ # Show stdout (up to 15 lines, or 5 in compact mode)
789
+ max_lines = 5 if self.compact else 15
790
+ if stdout:
791
+ stdout_lines = stdout.strip().split("\n")
792
+ if self.compact and len(stdout_lines) <= 3:
793
+ # Very short output - show inline
794
+ for line in stdout_lines:
795
+ self.console.print(f" [dim]{line[:80]}[/dim]")
796
+ else:
797
+ self.console.print(Panel(
798
+ "\n".join(stdout_lines[:max_lines]),
799
+ border_style="dim",
800
+ title="[dim]stdout[/dim]",
801
+ subtitle=f"[dim]{len(stdout_lines)} lines[/dim]" if len(stdout_lines) > max_lines else None,
802
+ ))
803
+ if len(stdout_lines) > max_lines:
804
+ self.console.print(f" [dim]... ({len(stdout_lines) - max_lines} more lines)[/dim]")
805
+
806
+ # Show stderr if present
807
+ if stderr:
808
+ stderr_lines = stderr.strip().split("\n")
809
+ self.console.print(Panel(
810
+ "\n".join(stderr_lines[:10]),
811
+ border_style="red",
812
+ title="[red]stderr[/red]",
813
+ ))
814
+
815
+ def _show_list_files_result(self, args: Dict[str, Any], result: Dict[str, Any]) -> None:
816
+ """Display list_files result with file count."""
817
+ files = result.get("files", [])
818
+ path = args.get("path", ".")
819
+
820
+ self.console.print(f" [blue]✓ list_files:[/blue] {path}")
821
+ self.console.print(f" [dim]Found {len(files)} files[/dim]")
822
+
823
+ # Show first few files in verbose mode
824
+ if self.verbose and files:
825
+ for f in files[:10]:
826
+ self.console.print(f" [dim]• {f}[/dim]")
827
+ if len(files) > 10:
828
+ self.console.print(f" [dim]... and {len(files) - 10} more[/dim]")
829
+
830
+ def _show_search_files_result(self, args: Dict[str, Any], result: Dict[str, Any]) -> None:
831
+ """Display search_files result with matches."""
832
+ pattern = args.get("pattern", "")
833
+ matches = result.get("matches", [])
834
+ total = result.get("total_matches", len(matches))
835
+
836
+ self.console.print(f" [magenta]✓ search_files:[/magenta] '{pattern}'")
837
+ self.console.print(f" [dim]Found {total} matches[/dim]")
838
+
839
+ # Show matches in verbose mode
840
+ if self.verbose and matches:
841
+ for match in matches[:5]:
842
+ file_path = match.get("file", "")
843
+ line_num = match.get("line", 0)
844
+ text = match.get("text", "")[:60]
845
+ self.console.print(f" [dim]{file_path}:{line_num}: {text}[/dim]")
846
+ if len(matches) > 5:
847
+ self.console.print(f" [dim]... and {len(matches) - 5} more matches[/dim]")
848
+
849
+ # =========================================================================
850
+ # Approval UI
851
+ # =========================================================================
852
+
853
+ def show_approval_prompt(
854
+ self,
855
+ tool: str,
856
+ args: Dict[str, Any],
857
+ options: str = "Y/n/a(ll)/t(ool)/v(iew)",
858
+ ) -> str:
859
+ """
860
+ Show approval prompt and get user response.
861
+
862
+ Args:
863
+ tool: Tool name
864
+ args: Tool arguments
865
+ options: Options to display
866
+
867
+ Returns:
868
+ User's response (lowercase, stripped)
869
+ """
870
+ self.console.print(f" [yellow]Approve? [{options}]:[/yellow] ", end="")
871
+ try:
872
+ response = input().strip().lower()
873
+ return response
874
+ except (EOFError, KeyboardInterrupt):
875
+ return "n"
876
+
877
+ def show_approval_status(self, status: str, detail: str = "") -> None:
878
+ """
879
+ Show approval status message.
880
+
881
+ Args:
882
+ status: Status type ("approved", "approved_all", "approved_tool", "skipped", "cancelled")
883
+ detail: Additional detail (e.g., tool name for approved_tool)
884
+ """
885
+ if status == "approved":
886
+ self.console.print(" [green]✓ Approved[/green]")
887
+ elif status == "approved_all":
888
+ self.console.print(" [green]✓ Approved all for session[/green]")
889
+ elif status == "approved_tool":
890
+ self.console.print(f" [green]✓ Approved all '{detail}' for session[/green]")
891
+ elif status == "auto_approved":
892
+ self.console.print(" [dim green]✓ Auto-approved[/dim green]")
893
+ elif status == "skipped":
894
+ self.console.print(" [yellow]⊘ Skipped by user[/yellow]")
895
+ elif status == "cancelled":
896
+ self.console.print(" [yellow]⊘ Cancelled[/yellow]")
897
+
898
+ def show_view_content(self, tool: str, args: Dict[str, Any]) -> None:
899
+ """
900
+ Show full content when user requests to view before approval.
901
+
902
+ Args:
903
+ tool: Tool name
904
+ args: Tool arguments
905
+ """
906
+ if tool == "write_file":
907
+ content = args.get("content", "")
908
+ file_path = args.get("file_path", "")
909
+ language = self._get_language(file_path)
910
+
911
+ try:
912
+ syntax = Syntax(content, language, theme="monokai", line_numbers=True)
913
+ self.console.print(Panel(
914
+ syntax,
915
+ title=f"[bold]{file_path}[/bold]",
916
+ border_style="blue",
917
+ ))
918
+ except Exception:
919
+ self.console.print(f"\n--- Content for {file_path} ---")
920
+ self.console.print(content)
921
+ self.console.print("--- End ---\n")
922
+
923
+ elif tool == "edit_file":
924
+ file_path = args.get("file_path", "")
925
+ search = args.get("search", "")
926
+ replace = args.get("replace", "")
927
+
928
+ content = Text()
929
+ content.append("─── Search (to be replaced) ───\n", style="bold red")
930
+ content.append(search + "\n", style="red")
931
+ content.append("\n─── Replace (new content) ───\n", style="bold green")
932
+ content.append(replace, style="green")
933
+
934
+ self.console.print(Panel(
935
+ content,
936
+ title=f"[bold]{file_path}[/bold]",
937
+ border_style="yellow",
938
+ ))
939
+
940
+ elif tool == "shell":
941
+ command = args.get("command", "")
942
+ try:
943
+ syntax = Syntax(command, "bash", theme="monokai")
944
+ self.console.print(Panel(syntax, title="[yellow]Command[/yellow]", border_style="yellow"))
945
+ except Exception:
946
+ self.console.print(f"\n $ {command}\n")
947
+
948
+ # =========================================================================
949
+ # Status & Progress
950
+ # =========================================================================
951
+
952
+ def show_status(self, message: str, style: str = "dim") -> None:
953
+ """Show a status message."""
954
+ self.console.print(f" [{style}]{message}[/{style}]")
955
+
956
+ def show_phase(self, phase: str, message: str = "") -> None:
957
+ """Show a phase transition."""
958
+ phase_icons = {
959
+ "explore": "🔍",
960
+ "plan": "📋",
961
+ "implement": "⚡",
962
+ "generate": "✨",
963
+ "review": "🔎",
964
+ "complete": "✅",
965
+ }
966
+ icon = phase_icons.get(phase, "•")
967
+ display = f"{icon} {phase.title()}"
968
+ if message:
969
+ display += f": {message}"
970
+ self.console.print(f"[cyan]{display}[/cyan]")
971
+
972
+ # =========================================================================
973
+ # Orchestrator Phase & Task Tracking
974
+ # =========================================================================
975
+
976
+ def show_strategic_plan(self, plan: Dict[str, Any]) -> None:
977
+ """
978
+ Display the orchestrator's strategic plan with PRD and phases.
979
+
980
+ Args:
981
+ plan: Plan dict containing 'prd' and 'phases'
982
+ """
983
+ prd = plan.get("prd", {})
984
+ phases = plan.get("phases", [])
985
+
986
+ # PRD Header
987
+ if prd:
988
+ title = prd.get("title", "Project")
989
+ self.console.print()
990
+ self.console.print(f"[bold blue]╭─────────────────────────────────────────────────────╮[/bold blue]")
991
+ self.console.print(f"[bold blue]│[/bold blue] 📋 [bold]{title}[/bold]")
992
+ self.console.print(f"[bold blue]╰─────────────────────────────────────────────────────╯[/bold blue]")
993
+
994
+ # Requirements
995
+ requirements = prd.get("requirements", [])
996
+ if requirements:
997
+ self.console.print(f" [dim]Requirements:[/dim]")
998
+ for req in requirements[:5]:
999
+ self.console.print(f" [dim]• {req[:60]}{'...' if len(req) > 60 else ''}[/dim]")
1000
+
1001
+ # Phases overview
1002
+ if phases:
1003
+ self.console.print()
1004
+ self.console.print(f"[bold cyan] 📊 Execution Plan ({len(phases)} phases):[/bold cyan]")
1005
+
1006
+ for i, phase in enumerate(phases, 1):
1007
+ name = phase.get("name", f"Phase {i}")
1008
+ worker = phase.get("worker", "architect")
1009
+ goals = phase.get("goals", "")[:50]
1010
+
1011
+ # Phase status indicator
1012
+ status_icon = "○" # pending
1013
+ color = "dim"
1014
+
1015
+ self.console.print(f" [{color}]{status_icon} {name}[/{color}]")
1016
+ if goals:
1017
+ self.console.print(f" [{color}]→ {worker}: {goals}{'...' if len(phase.get('goals', '')) > 50 else ''}[/{color}]")
1018
+
1019
+ self.console.print()
1020
+
1021
+ def show_phase_start(self, phase_name: str, phase_index: int = 0, total_phases: int = 0) -> None:
1022
+ """
1023
+ Display when a phase starts executing.
1024
+
1025
+ Args:
1026
+ phase_name: Name of the phase
1027
+ phase_index: Current phase number (1-based)
1028
+ total_phases: Total number of phases
1029
+ """
1030
+ progress = f"[{phase_index}/{total_phases}]" if total_phases > 0 else ""
1031
+ self.console.print()
1032
+ self.console.print(f"[bold cyan]▶ {progress} {phase_name}[/bold cyan]")
1033
+ self.console.print(f"[cyan]{'─' * 50}[/cyan]")
1034
+
1035
+ def show_worker_start(self, worker: str, task: str = "") -> None:
1036
+ """
1037
+ Display when a worker starts.
1038
+
1039
+ Args:
1040
+ worker: Worker name (e.g., "architect", "explorer", "coder")
1041
+ task: Task description
1042
+ """
1043
+ worker_icons = {
1044
+ "orchestrator": "🎯",
1045
+ "architect": "📐",
1046
+ "explorer": "🔍",
1047
+ "coder": "💻",
1048
+ }
1049
+ icon = worker_icons.get(worker.lower(), "•")
1050
+ self.console.print(f" [yellow]{icon} {worker}[/yellow]", end="")
1051
+ if task:
1052
+ # Truncate long tasks
1053
+ display_task = task[:60] + "..." if len(task) > 60 else task
1054
+ self.console.print(f" [dim]→ {display_task}[/dim]")
1055
+ else:
1056
+ self.console.print()
1057
+
1058
+ def show_worker_done(self, worker: str, success: bool = True) -> None:
1059
+ """
1060
+ Display when a worker completes.
1061
+
1062
+ Args:
1063
+ worker: Worker name
1064
+ success: Whether it completed successfully
1065
+ """
1066
+ if success:
1067
+ self.console.print(f" [green]✓ {worker} done[/green]")
1068
+ else:
1069
+ self.console.print(f" [red]✗ {worker} failed[/red]")
1070
+
1071
+ def show_task_decomposition(self, tasks: list) -> None:
1072
+ """
1073
+ Display architect's task decomposition.
1074
+
1075
+ Args:
1076
+ tasks: List of tasks from architect
1077
+ """
1078
+ if not tasks:
1079
+ return
1080
+
1081
+ self.console.print()
1082
+ self.console.print(f" [bold magenta]📋 Task Breakdown ({len(tasks)} tasks):[/bold magenta]")
1083
+
1084
+ for i, task in enumerate(tasks, 1):
1085
+ if isinstance(task, dict):
1086
+ worker = task.get("worker", "coder")
1087
+ goals = task.get("goals", "")[:55]
1088
+ worker_icon = "🔍" if worker == "explorer" else "💻"
1089
+ self.console.print(f" [dim]{i}. {worker_icon} {worker}:[/dim] {goals}{'...' if len(task.get('goals', '')) > 55 else ''}")
1090
+ else:
1091
+ self.console.print(f" [dim]{i}. {str(task)[:60]}[/dim]")
1092
+ self.console.print()
1093
+
1094
+ def show_delegation(self, from_agent: str, to_agent: str, task: str = "") -> None:
1095
+ """
1096
+ Display delegation between agents.
1097
+
1098
+ Args:
1099
+ from_agent: Delegating agent
1100
+ to_agent: Target agent
1101
+ task: Task being delegated
1102
+ """
1103
+ self.console.print(f" [dim]↳ {from_agent} → {to_agent}[/dim]")
1104
+ if task:
1105
+ # Show full task on separate line(s) for better readability
1106
+ # Wrap at 80 chars per line, max 3 lines
1107
+ max_line_len = 80
1108
+ max_lines = 3
1109
+ lines = []
1110
+ remaining = task
1111
+ while remaining and len(lines) < max_lines:
1112
+ if len(remaining) <= max_line_len:
1113
+ lines.append(remaining)
1114
+ remaining = ""
1115
+ else:
1116
+ # Find break point (space near max_line_len)
1117
+ break_at = remaining.rfind(" ", 0, max_line_len)
1118
+ if break_at == -1:
1119
+ break_at = max_line_len
1120
+ lines.append(remaining[:break_at])
1121
+ remaining = remaining[break_at:].lstrip()
1122
+
1123
+ if remaining:
1124
+ lines[-1] = lines[-1][:max_line_len - 3] + "..."
1125
+
1126
+ for line in lines:
1127
+ self.console.print(f" [dim italic]{line}[/dim italic]")
1128
+
1129
+ def show_thinking(self, message: str) -> None:
1130
+ """Show thinking/reasoning indicator."""
1131
+ self.console.print(f" [dim cyan]💭 {message}[/dim cyan]")
1132
+
1133
+ def show_error(self, message: str, recoverable: bool = True) -> None:
1134
+ """Show an error message."""
1135
+ style = "yellow" if recoverable else "red"
1136
+ icon = "⚠" if recoverable else "✗"
1137
+ self.console.print(f"[{style}]{icon} {message}[/{style}]")
1138
+
1139
+ def show_success(self, message: str) -> None:
1140
+ """Show a success message."""
1141
+ self.console.print(f"[green]✓ {message}[/green]")
1142
+
1143
+ def show_callback_status(self, success: bool, error: str = "") -> None:
1144
+ """Show callback status (for SSE flow). Silent in compact mode unless error."""
1145
+ if self.compact and success:
1146
+ # In compact mode, success is implied by the checkmark - no need to confirm
1147
+ return
1148
+ if success:
1149
+ self.console.print(" [dim green]↳ callback OK[/dim green]")
1150
+ else:
1151
+ self.console.print(f" [red]↳ callback failed: {error}[/red]")