tunacode-cli 0.0.41__py3-none-any.whl → 0.0.43__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.

Potentially problematic release.


This version of tunacode-cli might be problematic. Click here for more details.

tunacode/ui/panels.py CHANGED
@@ -86,7 +86,10 @@ class StreamingAgentPanel:
86
86
 
87
87
  def _create_panel(self) -> Panel:
88
88
  """Create a Rich panel with current content."""
89
- markdown_content = Markdown(self.content or "Thinking...")
89
+ # Use the UI_THINKING_MESSAGE constant instead of hardcoded text
90
+ from tunacode.constants import UI_THINKING_MESSAGE
91
+
92
+ markdown_content = Markdown(self.content or UI_THINKING_MESSAGE)
90
93
  panel_obj = Panel(
91
94
  Padding(markdown_content, (0, 1, 0, 1)),
92
95
  title=f"[bold]{self.title}[/bold]",
@@ -0,0 +1,380 @@
1
+ """Module: tunacode.ui.recursive_progress
2
+
3
+ Visual feedback and progress tracking UI for recursive task execution.
4
+ """
5
+
6
+ from datetime import datetime
7
+ from typing import Dict, List, Optional
8
+
9
+ from rich.columns import Columns
10
+ from rich.console import Console
11
+ from rich.live import Live
12
+ from rich.panel import Panel
13
+ from rich.progress import (
14
+ BarColumn,
15
+ Progress,
16
+ SpinnerColumn,
17
+ TaskID,
18
+ TextColumn,
19
+ TimeElapsedColumn,
20
+ TimeRemainingColumn,
21
+ )
22
+ from rich.table import Table
23
+ from rich.tree import Tree
24
+
25
+ from tunacode.core.recursive.budget import BudgetManager
26
+ from tunacode.core.recursive.executor import TaskNode
27
+ from tunacode.core.recursive.hierarchy import TaskHierarchy
28
+
29
+
30
+ class RecursiveProgressUI:
31
+ """UI components for visualizing recursive execution progress."""
32
+
33
+ def __init__(self, console: Optional[Console] = None):
34
+ """Initialize the progress UI.
35
+
36
+ Args:
37
+ console: Rich console instance (creates new if not provided)
38
+ """
39
+ self.console = console or Console()
40
+ self.progress = Progress(
41
+ SpinnerColumn(),
42
+ TextColumn("[progress.description]{task.description}"),
43
+ BarColumn(),
44
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
45
+ TimeElapsedColumn(),
46
+ TimeRemainingColumn(),
47
+ console=self.console,
48
+ )
49
+ self._task_progress_map: Dict[str, TaskID] = {}
50
+ self._live: Optional[Live] = None
51
+
52
+ def start_live_display(self) -> None:
53
+ """Start live display for real-time updates."""
54
+ if not self._live:
55
+ self._live = Live(
56
+ self.get_dashboard(),
57
+ console=self.console,
58
+ refresh_per_second=2,
59
+ vertical_overflow="visible",
60
+ )
61
+ self._live.start()
62
+
63
+ def stop_live_display(self) -> None:
64
+ """Stop the live display."""
65
+ if self._live:
66
+ self._live.stop()
67
+ self._live = None
68
+
69
+ def update_task_progress(
70
+ self, task_id: str, description: str, completed: int, total: int
71
+ ) -> None:
72
+ """Update progress for a specific task.
73
+
74
+ Args:
75
+ task_id: Task identifier
76
+ description: Task description
77
+ completed: Number of completed iterations
78
+ total: Total iterations
79
+ """
80
+ if task_id not in self._task_progress_map:
81
+ # Create new progress task
82
+ progress_id = self.progress.add_task(description, total=total, completed=completed)
83
+ self._task_progress_map[task_id] = progress_id
84
+ else:
85
+ # Update existing task
86
+ progress_id = self._task_progress_map[task_id]
87
+ self.progress.update(
88
+ progress_id, description=description, completed=completed, total=total
89
+ )
90
+
91
+ def display_task_hierarchy(self, hierarchy: TaskHierarchy) -> Tree:
92
+ """Create a tree visualization of task hierarchy.
93
+
94
+ Args:
95
+ hierarchy: TaskHierarchy instance
96
+
97
+ Returns:
98
+ Rich Tree object
99
+ """
100
+ tree = Tree("🎯 Task Hierarchy", guide_style="blue")
101
+
102
+ # Find root tasks
103
+ all_tasks = hierarchy._tasks
104
+ root_tasks = [task_id for task_id in all_tasks if hierarchy.get_parent(task_id) is None]
105
+
106
+ def build_tree_node(parent_node: Tree, task_id: str, depth: int = 0):
107
+ """Recursively build tree nodes."""
108
+ task = hierarchy.get_task(task_id)
109
+ if not task:
110
+ return
111
+
112
+ # Determine status icon
113
+ status = task.get("status", "pending")
114
+ icon = {"completed": "✅", "failed": "❌", "in_progress": "🔄", "pending": "⏳"}.get(
115
+ status, "❓"
116
+ )
117
+
118
+ # Create node label
119
+ title = task.get("title", "Untitled")[:50]
120
+ label = f"{icon} [{task_id}] {title}"
121
+
122
+ # Add progress if in progress
123
+ if status == "in_progress":
124
+ label += " [yellow](running)[/yellow]"
125
+
126
+ # Add node to tree
127
+ node = parent_node.add(label)
128
+
129
+ # Add children
130
+ children = hierarchy.get_children(task_id)
131
+ for child_id in children:
132
+ build_tree_node(node, child_id, depth + 1)
133
+
134
+ # Build tree from roots
135
+ for root_id in root_tasks:
136
+ build_tree_node(tree, root_id)
137
+
138
+ return tree
139
+
140
+ def display_budget_status(self, budget_manager: BudgetManager) -> Table:
141
+ """Create a table showing budget allocation and usage.
142
+
143
+ Args:
144
+ budget_manager: BudgetManager instance
145
+
146
+ Returns:
147
+ Rich Table object
148
+ """
149
+ table = Table(title="💰 Budget Status", show_header=True, header_style="bold cyan")
150
+ table.add_column("Task ID", style="dim", width=12)
151
+ table.add_column("Allocated", justify="right")
152
+ table.add_column("Used", justify="right")
153
+ table.add_column("Remaining", justify="right")
154
+ table.add_column("Utilization", justify="right")
155
+ table.add_column("Status", justify="center")
156
+
157
+ summary = budget_manager.get_budget_summary()
158
+
159
+ for task_id, details in summary["task_details"].items():
160
+ utilization = details["utilization"]
161
+
162
+ # Color code utilization
163
+ if utilization > 0.9:
164
+ util_color = "red"
165
+ status = "⚠️ High"
166
+ elif utilization > 0.7:
167
+ util_color = "yellow"
168
+ status = "📊 Normal"
169
+ else:
170
+ util_color = "green"
171
+ status = "✅ Good"
172
+
173
+ table.add_row(
174
+ task_id[:8] + "...",
175
+ str(details["allocated"]),
176
+ str(details["consumed"]),
177
+ str(details["remaining"]),
178
+ f"[{util_color}]{utilization:.1%}[/{util_color}]",
179
+ status,
180
+ )
181
+
182
+ # Add summary row
183
+ table.add_section()
184
+ table.add_row(
185
+ "TOTAL",
186
+ str(summary["allocated_budget"]),
187
+ str(summary["consumed_budget"]),
188
+ str(summary["available_budget"]),
189
+ f"{summary['utilization_rate']:.1%}",
190
+ "📈",
191
+ )
192
+
193
+ return table
194
+
195
+ def display_execution_stack(self, stack: List[str]) -> Panel:
196
+ """Display current execution stack.
197
+
198
+ Args:
199
+ stack: List of task IDs in execution order
200
+
201
+ Returns:
202
+ Rich Panel object
203
+ """
204
+ if not stack:
205
+ content = "[dim]No tasks in execution[/dim]"
206
+ else:
207
+ # Show stack with depth indicators
208
+ lines = []
209
+ for i, task_id in enumerate(stack):
210
+ indent = " " * i
211
+ arrow = "└─" if i == len(stack) - 1 else "├─"
212
+ lines.append(f"{indent}{arrow} {task_id}")
213
+ content = "\n".join(lines)
214
+
215
+ return Panel(content, title="📚 Execution Stack", border_style="green")
216
+
217
+ def display_task_details(self, task_node: TaskNode) -> Panel:
218
+ """Display detailed information about a task.
219
+
220
+ Args:
221
+ task_node: TaskNode instance
222
+
223
+ Returns:
224
+ Rich Panel object
225
+ """
226
+ details = Table(show_header=False, box=None, padding=0)
227
+ details.add_column("Property", style="bold cyan")
228
+ details.add_column("Value")
229
+
230
+ details.add_row("Title", task_node.title or "Untitled")
231
+ details.add_row("Status", f"[yellow]{task_node.status}[/yellow]")
232
+ details.add_row("Complexity", f"{task_node.complexity_score:.2f}")
233
+ details.add_row("Depth", str(task_node.depth))
234
+ details.add_row("Budget", f"{task_node.iteration_budget} iterations")
235
+
236
+ if task_node.subtasks:
237
+ details.add_row("Subtasks", f"{len(task_node.subtasks)} tasks")
238
+
239
+ if task_node.error:
240
+ details.add_row("Error", f"[red]{task_node.error}[/red]")
241
+
242
+ return Panel(details, title=f"🔍 Task Details: {task_node.id[:8]}...", border_style="blue")
243
+
244
+ def get_dashboard(
245
+ self,
246
+ hierarchy: Optional[TaskHierarchy] = None,
247
+ budget_manager: Optional[BudgetManager] = None,
248
+ current_task: Optional[TaskNode] = None,
249
+ execution_stack: Optional[List[str]] = None,
250
+ ) -> Panel:
251
+ """Create a comprehensive dashboard view.
252
+
253
+ Args:
254
+ hierarchy: Optional TaskHierarchy instance
255
+ budget_manager: Optional BudgetManager instance
256
+ current_task: Optional current TaskNode
257
+ execution_stack: Optional execution stack
258
+
259
+ Returns:
260
+ Rich Panel containing the dashboard
261
+ """
262
+ components = []
263
+
264
+ # Add progress bars
265
+ components.append(Panel(self.progress, title="⏳ Task Progress", border_style="green"))
266
+
267
+ # Add hierarchy tree if available
268
+ if hierarchy:
269
+ components.append(self.display_task_hierarchy(hierarchy))
270
+
271
+ # Add budget status if available
272
+ if budget_manager:
273
+ components.append(self.display_budget_status(budget_manager))
274
+
275
+ # Add execution stack if available
276
+ if execution_stack is not None:
277
+ components.append(self.display_execution_stack(execution_stack))
278
+
279
+ # Add current task details if available
280
+ if current_task:
281
+ components.append(self.display_task_details(current_task))
282
+
283
+ # Arrange components in columns
284
+ if len(components) > 2:
285
+ # Use columns for better layout
286
+ left_col = components[: len(components) // 2]
287
+ right_col = components[len(components) // 2 :]
288
+
289
+ content = Columns(
290
+ ["\n\n".join(str(c) for c in left_col), "\n\n".join(str(c) for c in right_col)],
291
+ equal=True,
292
+ expand=True,
293
+ )
294
+ else:
295
+ content = "\n\n".join(str(c) for c in components)
296
+
297
+ return Panel(
298
+ content,
299
+ title="🔄 Recursive Execution Dashboard",
300
+ subtitle=f"Updated: {datetime.now().strftime('%H:%M:%S')}",
301
+ border_style="bold blue",
302
+ )
303
+
304
+ async def show_completion_summary(
305
+ self,
306
+ total_tasks: int,
307
+ completed_tasks: int,
308
+ failed_tasks: int,
309
+ total_time: float,
310
+ total_iterations: int,
311
+ ) -> None:
312
+ """Display a summary when execution completes.
313
+
314
+ Args:
315
+ total_tasks: Total number of tasks
316
+ completed_tasks: Number of completed tasks
317
+ failed_tasks: Number of failed tasks
318
+ total_time: Total execution time in seconds
319
+ total_iterations: Total iterations used
320
+ """
321
+ summary = Table(title="📊 Execution Summary", show_header=True, header_style="bold green")
322
+ summary.add_column("Metric", style="cyan")
323
+ summary.add_column("Value", justify="right")
324
+
325
+ summary.add_row("Total Tasks", str(total_tasks))
326
+ summary.add_row("Completed", f"[green]{completed_tasks}[/green]")
327
+ summary.add_row("Failed", f"[red]{failed_tasks}[/red]")
328
+ summary.add_row("Success Rate", f"{(completed_tasks / total_tasks) * 100:.1f}%")
329
+ summary.add_row("Total Time", f"{total_time:.2f}s")
330
+ summary.add_row("Total Iterations", str(total_iterations))
331
+ summary.add_row("Avg Time/Task", f"{total_time / total_tasks:.2f}s")
332
+
333
+ self.console.print("\n")
334
+ self.console.print(summary)
335
+ self.console.print("\n")
336
+
337
+ # Show completion message
338
+ if failed_tasks == 0:
339
+ self.console.print("[bold green]✅ All tasks completed successfully![/bold green]")
340
+ else:
341
+ self.console.print(
342
+ f"[bold yellow]⚠️ Completed with {failed_tasks} failures[/bold yellow]"
343
+ )
344
+
345
+
346
+ # Convenience functions for integration
347
+ async def show_recursive_progress(
348
+ console: Console, message: str, task_id: Optional[str] = None, depth: int = 0
349
+ ) -> None:
350
+ """Show progress message for recursive execution.
351
+
352
+ Args:
353
+ console: Rich console instance
354
+ message: Progress message
355
+ task_id: Optional task ID
356
+ depth: Recursion depth
357
+ """
358
+ indent = " " * depth
359
+ prefix = f"[dim][{task_id[:8]}...][/dim]" if task_id else ""
360
+
361
+ console.print(f"{indent}🔄 {prefix} {message}")
362
+
363
+
364
+ async def show_task_completion(
365
+ console: Console, task_id: str, success: bool, depth: int = 0
366
+ ) -> None:
367
+ """Show task completion status.
368
+
369
+ Args:
370
+ console: Rich console instance
371
+ task_id: Task ID
372
+ success: Whether task succeeded
373
+ depth: Recursion depth
374
+ """
375
+ indent = " " * depth
376
+ icon = "✅" if success else "❌"
377
+ status = "completed" if success else "failed"
378
+ color = "green" if success else "red"
379
+
380
+ console.print(f"{indent}{icon} Task [{task_id[:8]}...] [{color}]{status}[/{color}]")
tunacode/ui/tool_ui.py CHANGED
@@ -2,6 +2,7 @@
2
2
  Tool confirmation UI components, separated from business logic.
3
3
  """
4
4
 
5
+ from rich.box import ROUNDED
5
6
  from rich.markdown import Markdown
6
7
  from rich.padding import Padding
7
8
  from rich.panel import Panel
@@ -111,9 +112,9 @@ class ToolUI:
111
112
  if request.filepath:
112
113
  await ui.usage(f"File: {request.filepath}")
113
114
 
114
- await ui.print(" 1. Yes (default)")
115
- await ui.print(" 2. Yes, and don't ask again for commands like this")
116
- await ui.print(f" 3. No, and tell {APP_NAME} what to do differently")
115
+ await ui.print(" [1] Yes (default)")
116
+ await ui.print(" [2] Yes, and don't ask again for commands like this")
117
+ await ui.print(f" [3] No, and tell {APP_NAME} what to do differently")
117
118
  resp = (
118
119
  await ui.input(
119
120
  session_key="tool_confirm",
@@ -146,10 +147,27 @@ class ToolUI:
146
147
  # Display styled confirmation panel using direct console output
147
148
  # Avoid using sync wrappers that might create event loop conflicts
148
149
  panel_obj = Panel(
149
- Padding(content, 1), title=title, title_align="left", border_style=self.colors.warning
150
+ Padding(content, (0, 1, 0, 1)),
151
+ title=title,
152
+ title_align="left",
153
+ border_style=self.colors.warning,
154
+ padding=(0, 1),
155
+ box=ROUNDED,
150
156
  )
151
157
  # Add consistent spacing above panels
152
- ui.console.print(Padding(panel_obj, (1, 0, 0, 0)))
158
+ from .constants import DEFAULT_PANEL_PADDING
159
+
160
+ ui.console.print(
161
+ Padding(
162
+ panel_obj,
163
+ (
164
+ DEFAULT_PANEL_PADDING["top"],
165
+ DEFAULT_PANEL_PADDING["right"],
166
+ DEFAULT_PANEL_PADDING["bottom"],
167
+ DEFAULT_PANEL_PADDING["left"],
168
+ ),
169
+ )
170
+ )
153
171
 
154
172
  if request.filepath:
155
173
  ui.console.print(f"File: {request.filepath}", style=self.colors.muted)
@@ -160,7 +178,7 @@ class ToolUI:
160
178
  resp = input(" Choose an option [1/2/3]: ").strip() or "1"
161
179
 
162
180
  # Add spacing after user choice for better readability
163
- print()
181
+ ui.console.print()
164
182
 
165
183
  if resp == "2":
166
184
  return ToolConfirmationResponse(approved=True, skip_future=True)
tunacode/ui/utils.py CHANGED
@@ -1,3 +1,3 @@
1
1
  from rich.console import Console as RichConsole
2
2
 
3
- console = RichConsole()
3
+ console = RichConsole(force_terminal=True, legacy_windows=False)
@@ -0,0 +1,163 @@
1
+ """Retry utilities for handling transient failures."""
2
+
3
+ import asyncio
4
+ import functools
5
+ import json
6
+ import logging
7
+ import time
8
+ from typing import Any, Callable, Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def retry_on_json_error(
14
+ max_retries: int = 10,
15
+ base_delay: float = 0.1,
16
+ max_delay: float = 5.0,
17
+ logger_name: Optional[str] = None,
18
+ ) -> Callable:
19
+ """Decorator to retry function calls that fail with JSON parsing errors.
20
+
21
+ Implements exponential backoff with configurable parameters.
22
+
23
+ Args:
24
+ max_retries: Maximum number of retry attempts (default: 10)
25
+ base_delay: Initial delay between retries in seconds (default: 0.1)
26
+ max_delay: Maximum delay between retries in seconds (default: 5.0)
27
+ logger_name: Logger name for retry logging (default: uses module logger)
28
+
29
+ Returns:
30
+ Decorated function that retries on JSONDecodeError
31
+ """
32
+ retry_logger = logging.getLogger(logger_name) if logger_name else logger
33
+
34
+ def decorator(func: Callable) -> Callable:
35
+ @functools.wraps(func)
36
+ async def async_wrapper(*args, **kwargs) -> Any:
37
+ last_exception = None
38
+
39
+ for attempt in range(max_retries + 1):
40
+ try:
41
+ return await func(*args, **kwargs)
42
+ except json.JSONDecodeError as e:
43
+ last_exception = e
44
+
45
+ if attempt == max_retries:
46
+ # Final attempt failed
47
+ retry_logger.error(f"JSON parsing failed after {max_retries} retries: {e}")
48
+ raise
49
+
50
+ # Calculate delay with exponential backoff
51
+ delay = min(base_delay * (2**attempt), max_delay)
52
+
53
+ retry_logger.warning(
54
+ f"JSON parsing error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
55
+ f"Retrying in {delay:.2f}s..."
56
+ )
57
+
58
+ await asyncio.sleep(delay)
59
+
60
+ # Should never reach here, but just in case
61
+ if last_exception:
62
+ raise last_exception
63
+
64
+ @functools.wraps(func)
65
+ def sync_wrapper(*args, **kwargs) -> Any:
66
+ last_exception = None
67
+
68
+ for attempt in range(max_retries + 1):
69
+ try:
70
+ return func(*args, **kwargs)
71
+ except json.JSONDecodeError as e:
72
+ last_exception = e
73
+
74
+ if attempt == max_retries:
75
+ # Final attempt failed
76
+ retry_logger.error(f"JSON parsing failed after {max_retries} retries: {e}")
77
+ raise
78
+
79
+ # Calculate delay with exponential backoff
80
+ delay = min(base_delay * (2**attempt), max_delay)
81
+
82
+ retry_logger.warning(
83
+ f"JSON parsing error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
84
+ f"Retrying in {delay:.2f}s..."
85
+ )
86
+
87
+ time.sleep(delay)
88
+
89
+ # Should never reach here, but just in case
90
+ if last_exception:
91
+ raise last_exception
92
+
93
+ # Return appropriate wrapper based on function type
94
+ if asyncio.iscoroutinefunction(func):
95
+ return async_wrapper
96
+ else:
97
+ return sync_wrapper
98
+
99
+ return decorator
100
+
101
+
102
+ def retry_json_parse(
103
+ json_string: str,
104
+ max_retries: int = 10,
105
+ base_delay: float = 0.1,
106
+ max_delay: float = 5.0,
107
+ ) -> Any:
108
+ """Parse JSON with automatic retry on failure.
109
+
110
+ Args:
111
+ json_string: JSON string to parse
112
+ max_retries: Maximum number of retry attempts
113
+ base_delay: Initial delay between retries in seconds
114
+ max_delay: Maximum delay between retries in seconds
115
+
116
+ Returns:
117
+ Parsed JSON object
118
+
119
+ Raises:
120
+ json.JSONDecodeError: If parsing fails after all retries
121
+ """
122
+
123
+ @retry_on_json_error(
124
+ max_retries=max_retries,
125
+ base_delay=base_delay,
126
+ max_delay=max_delay,
127
+ )
128
+ def _parse():
129
+ return json.loads(json_string)
130
+
131
+ return _parse()
132
+
133
+
134
+ async def retry_json_parse_async(
135
+ json_string: str,
136
+ max_retries: int = 10,
137
+ base_delay: float = 0.1,
138
+ max_delay: float = 5.0,
139
+ ) -> Any:
140
+ """Asynchronously parse JSON with automatic retry on failure.
141
+
142
+ Args:
143
+ json_string: JSON string to parse
144
+ max_retries: Maximum number of retry attempts
145
+ base_delay: Initial delay between retries in seconds
146
+ max_delay: Maximum delay between retries in seconds
147
+
148
+ Returns:
149
+ Parsed JSON object
150
+
151
+ Raises:
152
+ json.JSONDecodeError: If parsing fails after all retries
153
+ """
154
+
155
+ @retry_on_json_error(
156
+ max_retries=max_retries,
157
+ base_delay=base_delay,
158
+ max_delay=max_delay,
159
+ )
160
+ async def _parse():
161
+ return json.loads(json_string)
162
+
163
+ return await _parse()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tunacode-cli
3
- Version: 0.0.41
3
+ Version: 0.0.43
4
4
  Summary: Your agentic CLI developer.
5
5
  Author-email: larock22 <noreply@github.com>
6
6
  License-Expression: MIT
@@ -25,6 +25,8 @@ Requires-Dist: pydantic-ai[logfire]==0.2.6
25
25
  Requires-Dist: pygments==2.19.1
26
26
  Requires-Dist: rich==14.0.0
27
27
  Requires-Dist: tiktoken>=0.5.2
28
+ Requires-Dist: dspy-ai>=0.1.0
29
+ Requires-Dist: python-dotenv>=1.0.0
28
30
  Provides-Extra: dev
29
31
  Requires-Dist: build; extra == "dev"
30
32
  Requires-Dist: ruff; extra == "dev"