up-cli 0.2.0__py3-none-any.whl → 0.5.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.
Files changed (46) hide show
  1. up/__init__.py +1 -1
  2. up/ai_cli.py +229 -0
  3. up/cli.py +54 -9
  4. up/commands/agent.py +521 -0
  5. up/commands/bisect.py +343 -0
  6. up/commands/branch.py +350 -0
  7. up/commands/init.py +195 -6
  8. up/commands/learn.py +1392 -32
  9. up/commands/memory.py +545 -0
  10. up/commands/provenance.py +267 -0
  11. up/commands/review.py +239 -0
  12. up/commands/start.py +752 -42
  13. up/commands/status.py +173 -18
  14. up/commands/sync.py +317 -0
  15. up/commands/vibe.py +304 -0
  16. up/context.py +64 -10
  17. up/core/__init__.py +69 -0
  18. up/core/checkpoint.py +479 -0
  19. up/core/provenance.py +364 -0
  20. up/core/state.py +678 -0
  21. up/events.py +512 -0
  22. up/git/__init__.py +37 -0
  23. up/git/utils.py +270 -0
  24. up/git/worktree.py +331 -0
  25. up/learn/__init__.py +155 -0
  26. up/learn/analyzer.py +227 -0
  27. up/learn/plan.py +374 -0
  28. up/learn/research.py +511 -0
  29. up/learn/utils.py +117 -0
  30. up/memory.py +1096 -0
  31. up/parallel.py +551 -0
  32. up/templates/config/__init__.py +1 -1
  33. up/templates/docs/SKILL.md +28 -0
  34. up/templates/docs/__init__.py +341 -0
  35. up/templates/docs/standards/HEADERS.md +24 -0
  36. up/templates/docs/standards/STRUCTURE.md +18 -0
  37. up/templates/docs/standards/TEMPLATES.md +19 -0
  38. up/templates/loop/__init__.py +92 -32
  39. up/ui/__init__.py +14 -0
  40. up/ui/loop_display.py +650 -0
  41. up/ui/theme.py +137 -0
  42. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/METADATA +160 -15
  43. up_cli-0.5.0.dist-info/RECORD +55 -0
  44. up_cli-0.2.0.dist-info/RECORD +0 -23
  45. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/WHEEL +0 -0
  46. {up_cli-0.2.0.dist-info → up_cli-0.5.0.dist-info}/entry_points.txt +0 -0
up/ui/loop_display.py ADDED
@@ -0,0 +1,650 @@
1
+ """Product Loop Display - Cybersecurity/AI themed dashboard.
2
+
3
+ A real-time, refreshing terminal UI for the UP product loop.
4
+ Auto-detects terminal size and adapts layout accordingly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+ import time
11
+ from collections import deque
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from enum import Enum
15
+ from typing import Optional, Callable
16
+
17
+ from rich.align import Align
18
+ from rich.console import Console, Group, RenderableType
19
+ from rich.layout import Layout
20
+ from rich.live import Live
21
+ from rich.panel import Panel
22
+ from rich.progress import (
23
+ Progress,
24
+ BarColumn,
25
+ TextColumn,
26
+ TaskProgressColumn,
27
+ TimeElapsedColumn,
28
+ SpinnerColumn,
29
+ )
30
+ from rich.style import Style
31
+ from rich.table import Table
32
+ from rich.text import Text
33
+
34
+ from up.ui.theme import CyberTheme, THEME, Symbols
35
+
36
+
37
+ class TaskStatus(Enum):
38
+ """Status of a task in the queue."""
39
+ PENDING = "pending"
40
+ IN_PROGRESS = "in_progress"
41
+ COMPLETE = "complete"
42
+ FAILED = "failed"
43
+ SKIPPED = "skipped"
44
+ ROLLED_BACK = "rolled_back"
45
+
46
+
47
+ class LoopStatus(Enum):
48
+ """Overall loop status."""
49
+ IDLE = "idle"
50
+ RUNNING = "running"
51
+ VERIFYING = "verifying"
52
+ PAUSED = "paused"
53
+ FAILED = "failed"
54
+ COMPLETE = "complete"
55
+
56
+
57
+ @dataclass
58
+ class TaskInfo:
59
+ """Information about a task."""
60
+ id: str
61
+ title: str
62
+ status: TaskStatus = TaskStatus.PENDING
63
+ priority: str = "medium"
64
+ effort: str = "medium"
65
+ phase: str = ""
66
+ description: str = ""
67
+
68
+
69
+ @dataclass
70
+ class LoopStats:
71
+ """Statistics for the loop."""
72
+ elapsed_seconds: float = 0.0
73
+ failures: int = 0
74
+ rollbacks: int = 0
75
+ completed: int = 0
76
+ total: int = 0
77
+ current_iteration: int = 0
78
+
79
+
80
+ @dataclass
81
+ class DisplayState:
82
+ """Current state of the display."""
83
+ status: LoopStatus = LoopStatus.IDLE
84
+ current_task: Optional[TaskInfo] = None
85
+ current_phase: str = "INIT"
86
+ tasks: list[TaskInfo] = field(default_factory=list)
87
+ stats: LoopStats = field(default_factory=LoopStats)
88
+ log_entries: deque = field(default_factory=lambda: deque(maxlen=6))
89
+ start_time: Optional[datetime] = None
90
+
91
+
92
+ class ProductLoopDisplay:
93
+ """Real-time product loop dashboard with cybersecurity/AI theme.
94
+
95
+ Features:
96
+ - Auto-detects terminal size (compact vs full layout)
97
+ - Real-time refresh using Rich Live
98
+ - Animated progress bars
99
+ - Status indicators with color coding
100
+ - Scrolling activity log
101
+
102
+ Usage:
103
+ display = ProductLoopDisplay()
104
+ display.start()
105
+
106
+ display.set_tasks(tasks)
107
+ display.update_task_status("C-001", TaskStatus.IN_PROGRESS)
108
+ display.log("Starting implementation...")
109
+
110
+ display.stop()
111
+ """
112
+
113
+ # Layout thresholds
114
+ COMPACT_WIDTH = 80
115
+ COMPACT_HEIGHT = 24
116
+
117
+ def __init__(self, console: Optional[Console] = None):
118
+ """Initialize the display.
119
+
120
+ Args:
121
+ console: Rich console instance (created if not provided)
122
+ """
123
+ self.console = console or Console(theme=THEME)
124
+ self.state = DisplayState()
125
+ self.live: Optional[Live] = None
126
+ self._running = False
127
+ self._spinner_frame = 0
128
+ self._last_update = time.time()
129
+
130
+ def start(self) -> None:
131
+ """Start the live display."""
132
+ if self._running:
133
+ return
134
+
135
+ self.state.start_time = datetime.now()
136
+ self.state.status = LoopStatus.RUNNING
137
+ self._running = True
138
+
139
+ # Create live display with appropriate refresh rate
140
+ self.live = Live(
141
+ self._render(),
142
+ console=self.console,
143
+ refresh_per_second=4,
144
+ transient=False,
145
+ )
146
+ self.live.start()
147
+
148
+ def stop(self) -> None:
149
+ """Stop the live display."""
150
+ if not self._running:
151
+ return
152
+
153
+ self._running = False
154
+ if self.live:
155
+ self.live.stop()
156
+ self.live = None
157
+
158
+ def update(self) -> None:
159
+ """Force update the display."""
160
+ if self.live and self._running:
161
+ self._update_elapsed()
162
+ self._spinner_frame = (self._spinner_frame + 1) % len(Symbols.SPINNER)
163
+ self.live.update(self._render())
164
+
165
+ def _update_elapsed(self) -> None:
166
+ """Update elapsed time."""
167
+ if self.state.start_time:
168
+ delta = datetime.now() - self.state.start_time
169
+ self.state.stats.elapsed_seconds = delta.total_seconds()
170
+
171
+ # ─── State Setters ───────────────────────────────────────────────────
172
+
173
+ def set_status(self, status: LoopStatus) -> None:
174
+ """Set the overall loop status."""
175
+ self.state.status = status
176
+ self.update()
177
+
178
+ def set_tasks(self, tasks: list[dict]) -> None:
179
+ """Set the task queue from PRD task dicts."""
180
+ self.state.tasks = []
181
+ for t in tasks:
182
+ task_info = TaskInfo(
183
+ id=t.get("id", ""),
184
+ title=t.get("title", ""),
185
+ priority=t.get("priority", "medium"),
186
+ effort=t.get("effort", "medium"),
187
+ phase=t.get("phase", ""),
188
+ description=t.get("description", ""),
189
+ status=TaskStatus.COMPLETE if t.get("passes") else TaskStatus.PENDING,
190
+ )
191
+ self.state.tasks.append(task_info)
192
+
193
+ self.state.stats.total = len(tasks)
194
+ self.state.stats.completed = sum(1 for t in self.state.tasks if t.status == TaskStatus.COMPLETE)
195
+ self.update()
196
+
197
+ def set_current_task(self, task_id: str, phase: str = "EXECUTE") -> None:
198
+ """Set the current task being processed."""
199
+ self.state.current_phase = phase
200
+
201
+ for task in self.state.tasks:
202
+ if task.id == task_id:
203
+ task.status = TaskStatus.IN_PROGRESS
204
+ self.state.current_task = task
205
+ break
206
+
207
+ self.update()
208
+
209
+ def update_task_status(self, task_id: str, status: TaskStatus) -> None:
210
+ """Update a task's status."""
211
+ for task in self.state.tasks:
212
+ if task.id == task_id:
213
+ task.status = status
214
+
215
+ if status == TaskStatus.COMPLETE:
216
+ self.state.stats.completed += 1
217
+ elif status == TaskStatus.FAILED:
218
+ self.state.stats.failures += 1
219
+ elif status == TaskStatus.ROLLED_BACK:
220
+ self.state.stats.rollbacks += 1
221
+ break
222
+
223
+ # Clear current task if it's the one being updated
224
+ if self.state.current_task and self.state.current_task.id == task_id:
225
+ if status in (TaskStatus.COMPLETE, TaskStatus.FAILED, TaskStatus.ROLLED_BACK):
226
+ self.state.current_task = None
227
+
228
+ self.update()
229
+
230
+ def set_phase(self, phase: str) -> None:
231
+ """Set the current phase."""
232
+ self.state.current_phase = phase
233
+ if phase == "VERIFY":
234
+ self.state.status = LoopStatus.VERIFYING
235
+ elif phase == "EXECUTE":
236
+ self.state.status = LoopStatus.RUNNING
237
+ self.update()
238
+
239
+ def increment_iteration(self) -> None:
240
+ """Increment the iteration counter."""
241
+ self.state.stats.current_iteration += 1
242
+ self.update()
243
+
244
+ def log(self, message: str, style: str = "") -> None:
245
+ """Add a log entry."""
246
+ timestamp = datetime.now().strftime("%H:%M:%S")
247
+ entry = (timestamp, message, style)
248
+ self.state.log_entries.append(entry)
249
+ self.update()
250
+
251
+ def log_success(self, message: str) -> None:
252
+ """Add a success log entry."""
253
+ self.log(f"{Symbols.COMPLETE} {message}", "task.complete")
254
+
255
+ def log_error(self, message: str) -> None:
256
+ """Add an error log entry."""
257
+ self.log(f"{Symbols.FAILED} {message}", "task.failed")
258
+
259
+ def log_warning(self, message: str) -> None:
260
+ """Add a warning log entry."""
261
+ self.log(f"⚠ {message}", "task.skipped")
262
+
263
+ # ─── Rendering ───────────────────────────────────────────────────────
264
+
265
+ def _get_terminal_size(self) -> tuple[int, int]:
266
+ """Get terminal dimensions."""
267
+ size = shutil.get_terminal_size((80, 24))
268
+ return size.columns, size.lines
269
+
270
+ def _is_compact(self) -> bool:
271
+ """Check if we should use compact layout."""
272
+ width, height = self._get_terminal_size()
273
+ return width < self.COMPACT_WIDTH or height < self.COMPACT_HEIGHT
274
+
275
+ def _render(self) -> RenderableType:
276
+ """Render the dashboard."""
277
+ if self._is_compact():
278
+ return self._render_compact()
279
+ return self._render_full()
280
+
281
+ def _render_full(self) -> Panel:
282
+ """Render full layout dashboard."""
283
+ layout = Layout()
284
+
285
+ # Main structure
286
+ layout.split_column(
287
+ Layout(name="header", size=3),
288
+ Layout(name="progress", size=3),
289
+ Layout(name="main", ratio=1),
290
+ Layout(name="log", size=10),
291
+ Layout(name="stats", size=3),
292
+ Layout(name="footer", size=1),
293
+ )
294
+
295
+ # Split main into current task and queue
296
+ layout["main"].split_row(
297
+ Layout(name="current", ratio=1),
298
+ Layout(name="queue", ratio=1),
299
+ )
300
+
301
+ # Render components
302
+ layout["header"].update(self._render_header())
303
+ layout["progress"].update(self._render_progress_bar())
304
+ layout["current"].update(self._render_current_task())
305
+ layout["queue"].update(self._render_task_queue())
306
+ layout["log"].update(self._render_log())
307
+ layout["stats"].update(self._render_stats())
308
+ layout["footer"].update(self._render_footer())
309
+
310
+ return Panel(
311
+ layout,
312
+ border_style=Style(color=CyberTheme.BORDER),
313
+ padding=0,
314
+ )
315
+
316
+ def _render_compact(self) -> Panel:
317
+ """Render compact layout for small terminals."""
318
+ parts = []
319
+
320
+ # Header with status
321
+ parts.append(self._render_compact_header())
322
+ parts.append("")
323
+
324
+ # Progress
325
+ parts.append(self._render_compact_progress())
326
+ parts.append("")
327
+
328
+ # Current task (one line)
329
+ if self.state.current_task:
330
+ task = self.state.current_task
331
+ parts.append(Text(f" Current: {task.id} {task.title[:30]}...", style="task.progress"))
332
+ parts.append(Text(f" Phase: {self.state.current_phase}", style="text.dim"))
333
+ else:
334
+ parts.append(Text(" Current: None", style="text.dim"))
335
+
336
+ parts.append("")
337
+
338
+ # Compact task list
339
+ parts.append(self._render_compact_tasks())
340
+ parts.append("")
341
+
342
+ # Stats line
343
+ stats = self.state.stats
344
+ elapsed = self._format_duration(stats.elapsed_seconds)
345
+ parts.append(Text(
346
+ f" ⏱ {elapsed} {Symbols.FAILED} {stats.failures} fails {Symbols.ROLLBACK} {stats.rollbacks} rollbacks",
347
+ style="text.dim"
348
+ ))
349
+
350
+ return Panel(
351
+ Group(*parts),
352
+ title=f"[title]UP LOOP[/]",
353
+ subtitle="[text.dim]Ctrl+C to pause[/]",
354
+ border_style=Style(color=CyberTheme.BORDER),
355
+ )
356
+
357
+ def _render_header(self) -> Panel:
358
+ """Render the header with status badge."""
359
+ status = self.state.status
360
+ spinner = Symbols.SPINNER[self._spinner_frame] if status == LoopStatus.RUNNING else ""
361
+
362
+ status_styles = {
363
+ LoopStatus.RUNNING: ("status.running", f"{spinner} RUNNING"),
364
+ LoopStatus.VERIFYING: ("status.verifying", "◉ VERIFYING"),
365
+ LoopStatus.PAUSED: ("status.paused", "◉ PAUSED"),
366
+ LoopStatus.FAILED: ("status.failed", "◉ FAILED"),
367
+ LoopStatus.COMPLETE: ("status.complete", "◉ COMPLETE"),
368
+ LoopStatus.IDLE: ("text.dim", "◉ IDLE"),
369
+ }
370
+
371
+ style, label = status_styles.get(status, ("text.dim", "◉ UNKNOWN"))
372
+
373
+ title_text = Text()
374
+ title_text.append(" UP ", style="title")
375
+ title_text.append("PRODUCT LOOP", style="secondary")
376
+ title_text.append(" " * 30)
377
+ title_text.append(label, style=style)
378
+ title_text.append(" ")
379
+
380
+ return Panel(
381
+ Align.center(title_text),
382
+ border_style=Style(color=CyberTheme.BORDER_DIM),
383
+ padding=0,
384
+ )
385
+
386
+ def _render_compact_header(self) -> Text:
387
+ """Render compact header."""
388
+ status = self.state.status
389
+ spinner = Symbols.SPINNER[self._spinner_frame] if status == LoopStatus.RUNNING else "●"
390
+
391
+ status_colors = {
392
+ LoopStatus.RUNNING: CyberTheme.STATUS_RUNNING,
393
+ LoopStatus.VERIFYING: CyberTheme.STATUS_VERIFYING,
394
+ LoopStatus.PAUSED: CyberTheme.STATUS_PAUSED,
395
+ LoopStatus.FAILED: CyberTheme.STATUS_FAILED,
396
+ LoopStatus.COMPLETE: CyberTheme.STATUS_COMPLETE,
397
+ }
398
+
399
+ color = status_colors.get(status, CyberTheme.TEXT_DIM)
400
+
401
+ header = Text()
402
+ header.append(f" {spinner} ", style=Style(color=color))
403
+ header.append("UP LOOP", style="title")
404
+ header.append(f" │ {status.value.upper()}", style=Style(color=color))
405
+
406
+ return header
407
+
408
+ def _render_progress_bar(self) -> Panel:
409
+ """Render the animated progress bar."""
410
+ stats = self.state.stats
411
+ total = stats.total or 1
412
+ completed = stats.completed
413
+ percentage = (completed / total) * 100
414
+
415
+ # Create progress bar
416
+ width = 40
417
+ filled = int(width * completed / total)
418
+
419
+ bar = Text()
420
+ bar.append(" Progress ", style="text.dim")
421
+ bar.append(Symbols.BAR_FULL * filled, style="progress.complete")
422
+ bar.append(Symbols.BAR_EMPTY * (width - filled), style="progress.remaining")
423
+ bar.append(f" {percentage:5.1f}%", style="primary")
424
+ bar.append(f" ({completed}/{total} tasks)", style="text.dim")
425
+
426
+ return Panel(
427
+ Align.center(bar),
428
+ border_style=Style(color=CyberTheme.BORDER_DIM),
429
+ padding=0,
430
+ )
431
+
432
+ def _render_compact_progress(self) -> Text:
433
+ """Render compact progress bar."""
434
+ stats = self.state.stats
435
+ total = stats.total or 1
436
+ completed = stats.completed
437
+ percentage = (completed / total) * 100
438
+
439
+ width = 25
440
+ filled = int(width * completed / total)
441
+
442
+ bar = Text()
443
+ bar.append(" ", style="text")
444
+ bar.append(Symbols.BAR_FULL * filled, style="progress.complete")
445
+ bar.append(Symbols.BAR_EMPTY * (width - filled), style="progress.remaining")
446
+ bar.append(f" {percentage:5.1f}% ({completed}/{total})", style="primary")
447
+
448
+ return bar
449
+
450
+ def _render_current_task(self) -> Panel:
451
+ """Render current task panel."""
452
+ task = self.state.current_task
453
+
454
+ if not task:
455
+ content = Text("\n No task in progress\n", style="text.dim")
456
+ return Panel(
457
+ content,
458
+ title="[title]Current Task[/]",
459
+ border_style=Style(color=CyberTheme.BORDER_DIM),
460
+ )
461
+
462
+ lines = []
463
+ lines.append("")
464
+ lines.append(Text(f" {task.id}: {task.title}", style="task.progress"))
465
+ lines.append("")
466
+ lines.append(Text(f" Priority: {task.priority} │ Effort: {task.effort} │ Phase: {task.phase}", style="text.dim"))
467
+ lines.append("")
468
+
469
+ # Current phase indicator
470
+ phase = self.state.current_phase
471
+ phase_icon = {
472
+ "INIT": "○",
473
+ "CHECKPOINT": "◐",
474
+ "EXECUTE": Symbols.SPINNER[self._spinner_frame],
475
+ "VERIFY": "◑",
476
+ "COMMIT": "◒",
477
+ }.get(phase, "○")
478
+
479
+ lines.append(Text(f" Status: {phase_icon} {phase}", style="status.running"))
480
+ lines.append("")
481
+
482
+ if task.description:
483
+ desc = task.description[:60] + "..." if len(task.description) > 60 else task.description
484
+ lines.append(Text(f" {desc}", style="text.dim"))
485
+
486
+ return Panel(
487
+ Group(*lines),
488
+ title="[title]Current Task[/]",
489
+ border_style=Style(color=CyberTheme.PRIMARY),
490
+ )
491
+
492
+ def _render_task_queue(self) -> Panel:
493
+ """Render task queue panel."""
494
+ table = Table(
495
+ show_header=False,
496
+ box=None,
497
+ padding=(0, 1),
498
+ expand=True,
499
+ )
500
+
501
+ table.add_column("Status", width=3)
502
+ table.add_column("ID", width=8)
503
+ table.add_column("Title", ratio=1)
504
+ table.add_column("State", width=12, justify="right")
505
+
506
+ status_symbols = {
507
+ TaskStatus.COMPLETE: (Symbols.COMPLETE, "task.complete"),
508
+ TaskStatus.IN_PROGRESS: (Symbols.IN_PROGRESS, "task.progress"),
509
+ TaskStatus.PENDING: (Symbols.PENDING, "task.pending"),
510
+ TaskStatus.FAILED: (Symbols.FAILED, "task.failed"),
511
+ TaskStatus.SKIPPED: (Symbols.SKIPPED, "task.skipped"),
512
+ TaskStatus.ROLLED_BACK: (Symbols.ROLLBACK, "task.skipped"),
513
+ }
514
+
515
+ for task in self.state.tasks[:8]: # Show max 8 tasks
516
+ symbol, style = status_symbols.get(task.status, (Symbols.PENDING, "task.pending"))
517
+
518
+ title = task.title[:30] + "..." if len(task.title) > 30 else task.title
519
+ state_label = task.status.value.replace("_", " ")
520
+
521
+ table.add_row(
522
+ Text(symbol, style=style),
523
+ Text(task.id, style=style),
524
+ Text(title, style=style if task.status == TaskStatus.IN_PROGRESS else "text"),
525
+ Text(state_label, style=style),
526
+ )
527
+
528
+ # Show count if more tasks
529
+ remaining = len(self.state.tasks) - 8
530
+ if remaining > 0:
531
+ table.add_row(
532
+ Text("", style="text.dim"),
533
+ Text("", style="text.dim"),
534
+ Text(f"... +{remaining} more tasks", style="text.dim"),
535
+ Text("", style="text.dim"),
536
+ )
537
+
538
+ return Panel(
539
+ table,
540
+ title="[title]Task Queue[/]",
541
+ border_style=Style(color=CyberTheme.BORDER_DIM),
542
+ )
543
+
544
+ def _render_compact_tasks(self) -> Text:
545
+ """Render compact task indicators."""
546
+ status_symbols = {
547
+ TaskStatus.COMPLETE: (Symbols.COMPLETE, CyberTheme.TASK_COMPLETE),
548
+ TaskStatus.IN_PROGRESS: (Symbols.IN_PROGRESS, CyberTheme.TASK_IN_PROGRESS),
549
+ TaskStatus.PENDING: (Symbols.PENDING, CyberTheme.TASK_PENDING),
550
+ TaskStatus.FAILED: (Symbols.FAILED, CyberTheme.TASK_FAILED),
551
+ TaskStatus.SKIPPED: (Symbols.SKIPPED, CyberTheme.TASK_SKIPPED),
552
+ TaskStatus.ROLLED_BACK: (Symbols.ROLLBACK, CyberTheme.TASK_SKIPPED),
553
+ }
554
+
555
+ text = Text(" ")
556
+ for task in self.state.tasks[:12]:
557
+ symbol, color = status_symbols.get(task.status, (Symbols.PENDING, CyberTheme.TASK_PENDING))
558
+ text.append(f"{symbol} {task.id} ", style=Style(color=color))
559
+
560
+ return text
561
+
562
+ def _render_log(self) -> Panel:
563
+ """Render activity log panel."""
564
+ lines = []
565
+
566
+ for timestamp, message, style in self.state.log_entries:
567
+ line = Text()
568
+ line.append(f" {timestamp} ", style="text.dim")
569
+ line.append(message[:60], style=style or "text")
570
+ lines.append(line)
571
+
572
+ # Pad with empty lines if needed
573
+ while len(lines) < 6:
574
+ lines.append(Text(""))
575
+
576
+ return Panel(
577
+ Group(*lines),
578
+ title="[title]Activity Log[/]",
579
+ border_style=Style(color=CyberTheme.BORDER_DIM),
580
+ )
581
+
582
+ def _render_stats(self) -> Panel:
583
+ """Render stats panel."""
584
+ stats = self.state.stats
585
+ elapsed = self._format_duration(stats.elapsed_seconds)
586
+
587
+ text = Text()
588
+ text.append(" ⏱ ", style="text.dim")
589
+ text.append(f"Elapsed: {elapsed}", style="primary")
590
+ text.append(" │ ", style="text.dim")
591
+ text.append(f"{Symbols.FAILED} ", style="task.failed")
592
+ text.append(f"Failures: {stats.failures}", style="text")
593
+ text.append(" │ ", style="text.dim")
594
+ text.append(f"{Symbols.ROLLBACK} ", style="task.skipped")
595
+ text.append(f"Rollbacks: {stats.rollbacks}", style="text")
596
+ text.append(" │ ", style="text.dim")
597
+ text.append(f"Iteration: {stats.current_iteration}", style="secondary")
598
+
599
+ return Panel(
600
+ Align.center(text),
601
+ border_style=Style(color=CyberTheme.BORDER_DIM),
602
+ padding=0,
603
+ )
604
+
605
+ def _render_footer(self) -> Text:
606
+ """Render footer."""
607
+ return Text(" Press Ctrl+C to pause │ q to quit", style="text.dim", justify="center")
608
+
609
+ def _format_duration(self, seconds: float) -> str:
610
+ """Format duration as human readable string."""
611
+ if seconds < 60:
612
+ return f"{int(seconds)}s"
613
+ elif seconds < 3600:
614
+ minutes = int(seconds // 60)
615
+ secs = int(seconds % 60)
616
+ return f"{minutes}m {secs}s"
617
+ else:
618
+ hours = int(seconds // 3600)
619
+ minutes = int((seconds % 3600) // 60)
620
+ return f"{hours}h {minutes}m"
621
+
622
+
623
+ # ─── Context Manager Support ─────────────────────────────────────────────
624
+
625
+ class ProductLoopDisplayContext:
626
+ """Context manager for the display."""
627
+
628
+ def __init__(self, display: ProductLoopDisplay):
629
+ self.display = display
630
+
631
+ def __enter__(self) -> ProductLoopDisplay:
632
+ self.display.start()
633
+ return self.display
634
+
635
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
636
+ self.display.stop()
637
+ return False
638
+
639
+
640
+ def create_display(console: Optional[Console] = None) -> ProductLoopDisplayContext:
641
+ """Create a display context manager.
642
+
643
+ Usage:
644
+ with create_display() as display:
645
+ display.set_tasks(tasks)
646
+ display.log("Starting...")
647
+ # ... do work ...
648
+ """
649
+ display = ProductLoopDisplay(console)
650
+ return ProductLoopDisplayContext(display)