tarang 4.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tarang/__init__.py +23 -0
- tarang/cli.py +1168 -0
- tarang/client/__init__.py +19 -0
- tarang/client/api_client.py +701 -0
- tarang/client/auth.py +178 -0
- tarang/context/__init__.py +41 -0
- tarang/context/bm25.py +218 -0
- tarang/context/chunker.py +984 -0
- tarang/context/graph.py +464 -0
- tarang/context/indexer.py +514 -0
- tarang/context/retriever.py +270 -0
- tarang/context/skeleton.py +282 -0
- tarang/context_collector.py +449 -0
- tarang/executor/__init__.py +6 -0
- tarang/executor/diff_apply.py +246 -0
- tarang/executor/linter.py +184 -0
- tarang/stream.py +1346 -0
- tarang/ui/__init__.py +7 -0
- tarang/ui/console.py +407 -0
- tarang/ui/diff_viewer.py +146 -0
- tarang/ui/formatter.py +1151 -0
- tarang/ui/keyboard.py +197 -0
- tarang/ws/__init__.py +14 -0
- tarang/ws/client.py +464 -0
- tarang/ws/executor.py +638 -0
- tarang/ws/handlers.py +590 -0
- tarang-4.4.0.dist-info/METADATA +102 -0
- tarang-4.4.0.dist-info/RECORD +31 -0
- tarang-4.4.0.dist-info/WHEEL +5 -0
- tarang-4.4.0.dist-info/entry_points.txt +2 -0
- tarang-4.4.0.dist-info/top_level.txt +1 -0
tarang/ui/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]")
|