recursive-cleaner 0.7.1__py3-none-any.whl → 1.0.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.
@@ -0,0 +1,614 @@
1
+ """Rich TUI dashboard with Mission Control retro aesthetic."""
2
+
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from typing import Literal
6
+
7
+ # Graceful import - TUI features only available when Rich is installed
8
+ try:
9
+ from rich.box import DOUBLE
10
+ from rich.console import Console, Group
11
+ from rich.layout import Layout
12
+ from rich.live import Live
13
+ from rich.panel import Panel
14
+ from rich.progress import BarColumn, Progress, TextColumn
15
+ from rich.table import Table
16
+ from rich.text import Text
17
+
18
+ HAS_RICH = True
19
+ except ImportError:
20
+ HAS_RICH = False
21
+
22
+
23
+ # ASCII art banner - chunky block style
24
+ ASCII_BANNER = """
25
+ ██████╗ ███████╗ ██████╗██╗ ██╗██████╗ ███████╗██╗██╗ ██╗███████╗
26
+ ██╔══██╗██╔════╝██╔════╝██║ ██║██╔══██╗██╔════╝██║██║ ██║██╔════╝
27
+ ██████╔╝█████╗ ██║ ██║ ██║██████╔╝███████╗██║██║ ██║█████╗
28
+ ██╔══██╗██╔══╝ ██║ ██║ ██║██╔══██╗╚════██║██║╚██╗ ██╔╝██╔══╝
29
+ ██║ ██║███████╗╚██████╗╚██████╔╝██║ ██║███████║██║ ╚████╔╝ ███████╗
30
+ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═══╝ ╚══════╝
31
+ ██████╗██╗ ███████╗ █████╗ ███╗ ██╗███████╗██████╗
32
+ ██╔════╝██║ ██╔════╝██╔══██╗████╗ ██║██╔════╝██╔══██╗
33
+ ██║ ██║ █████╗ ███████║██╔██╗ ██║█████╗ ██████╔╝
34
+ ██║ ██║ ██╔══╝ ██╔══██║██║╚██╗██║██╔══╝ ██╔══██╗
35
+ ╚██████╗███████╗███████╗██║ ██║██║ ╚████║███████╗██║ ██║
36
+ ╚═════╝╚══════╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝
37
+ """.strip()
38
+
39
+ # Keep HEADER_TITLE for backwards compatibility with tests
40
+ HEADER_TITLE = "RECURSIVE CLEANER"
41
+
42
+
43
+ @dataclass
44
+ class FunctionInfo:
45
+ """Info about a generated cleaning function."""
46
+
47
+ name: str
48
+ docstring: str
49
+
50
+
51
+ @dataclass
52
+ class TUIState:
53
+ """Dashboard display state."""
54
+
55
+ # Header
56
+ file_path: str
57
+ total_records: int
58
+ version: str = "0.8.0"
59
+
60
+ # Progress
61
+ current_chunk: int = 0
62
+ total_chunks: int = 0
63
+ current_iteration: int = 0
64
+ max_iterations: int = 5
65
+
66
+ # LLM Status
67
+ llm_status: Literal["idle", "calling"] = "idle"
68
+
69
+ # Functions
70
+ functions: list[FunctionInfo] = field(default_factory=list)
71
+
72
+ # Latency metrics
73
+ latency_last_ms: float = 0.0
74
+ latency_avg_ms: float = 0.0
75
+ latency_total_ms: float = 0.0
76
+ llm_call_count: int = 0
77
+
78
+ # Token estimation
79
+ tokens_in: int = 0
80
+ tokens_out: int = 0
81
+
82
+ # Transmission log
83
+ last_response: str = ""
84
+
85
+
86
+ class TUIRenderer:
87
+ """
88
+ Rich-based terminal dashboard with Mission Control retro aesthetic.
89
+
90
+ Shows live updates during cleaning runs with:
91
+ - ASCII art banner header
92
+ - Mission timer and status indicator
93
+ - Progress bar and chunk/iteration counters
94
+ - List of generated functions with checkmarks
95
+ - Token estimation and latency metrics
96
+ - Transmission log showing latest LLM response
97
+ """
98
+
99
+ def __init__(self, file_path: str, total_chunks: int, total_records: int = 0):
100
+ """
101
+ Initialize TUI renderer.
102
+
103
+ Args:
104
+ file_path: Path to the data file being cleaned
105
+ total_chunks: Total number of chunks to process
106
+ total_records: Total number of records in the file
107
+ """
108
+ self._state = TUIState(
109
+ file_path=file_path,
110
+ total_chunks=total_chunks,
111
+ total_records=total_records,
112
+ )
113
+ self._start_time = time.time()
114
+ self._layout = self._make_layout() if HAS_RICH else None
115
+ self._live: "Live | None" = None
116
+ self._console = Console() if HAS_RICH else None
117
+
118
+ def _make_layout(self) -> "Layout":
119
+ """Create the dashboard layout structure.
120
+
121
+ Layout:
122
+ - header (size=5) - ASCII art banner "RECURSIVE CLEANER"
123
+ - status_bar (size=3) - MISSION | TIME | STATUS
124
+ - progress_bar (size=3) - CHUNK X/Y + progress bar
125
+ - body (size=computed) - Split horizontally, FIXED size to prevent infinite expansion
126
+ - left_panel - FUNCTIONS ACQUIRED, tokens, latency
127
+ - right_panel - Parsed transmission log
128
+
129
+ CRITICAL: Body uses fixed `size=` not `ratio=` to prevent panels from
130
+ expanding infinitely and pushing header off screen on large terminals.
131
+ Works on terminals as small as 80x24.
132
+ """
133
+ if not HAS_RICH:
134
+ return None
135
+
136
+ from rich.console import Console
137
+
138
+ console = Console()
139
+ term_height = console.height or 24 # Default to 24 if unknown
140
+
141
+ # Fixed heights for top sections
142
+ header_height = 14 # ASCII banner (12 lines + border)
143
+ status_height = 3
144
+ progress_height = 3
145
+ fixed_total = header_height + status_height + progress_height
146
+
147
+ # Body gets remaining space with a FIXED size (not ratio)
148
+ # Cap at 18 rows max to keep it tight
149
+ body_height = min(18, max(10, term_height - fixed_total - 2))
150
+
151
+ layout = Layout()
152
+ layout.split_column(
153
+ Layout(name="header", size=header_height),
154
+ Layout(name="status_bar", size=status_height),
155
+ Layout(name="progress_bar", size=progress_height),
156
+ Layout(name="body", size=body_height), # FIXED size, not ratio
157
+ )
158
+ layout["body"].split_row(
159
+ Layout(name="left_panel", ratio=1),
160
+ Layout(name="right_panel", ratio=1),
161
+ )
162
+ return layout
163
+
164
+ def start(self) -> None:
165
+ """Start the live TUI display."""
166
+ if not HAS_RICH or self._layout is None:
167
+ return
168
+
169
+ self._start_time = time.time()
170
+ self._refresh()
171
+ self._live = Live(
172
+ self._layout,
173
+ console=self._console,
174
+ refresh_per_second=2,
175
+ vertical_overflow="crop",
176
+ )
177
+ self._live.start()
178
+
179
+ def stop(self) -> None:
180
+ """Stop the live TUI display."""
181
+ if self._live:
182
+ self._live.stop()
183
+ self._live = None
184
+
185
+ def update_chunk(self, chunk_index: int, iteration: int, max_iterations: int) -> None:
186
+ """
187
+ Update progress for current chunk and iteration.
188
+
189
+ Args:
190
+ chunk_index: Current chunk index (0-based)
191
+ iteration: Current iteration within chunk (0-based)
192
+ max_iterations: Maximum iterations per chunk
193
+ """
194
+ self._state.current_chunk = chunk_index + 1 # Convert to 1-based for display
195
+ self._state.current_iteration = iteration + 1
196
+ self._state.max_iterations = max_iterations
197
+ self._refresh()
198
+
199
+ def update_llm_status(self, status: Literal["calling", "idle"]) -> None:
200
+ """
201
+ Update LLM call status.
202
+
203
+ Args:
204
+ status: "calling" when LLM is being called, "idle" otherwise
205
+ """
206
+ self._state.llm_status = status
207
+ self._refresh()
208
+
209
+ def add_function(self, name: str, docstring: str) -> None:
210
+ """
211
+ Add a newly generated function to the display.
212
+
213
+ Args:
214
+ name: Function name
215
+ docstring: Function docstring
216
+ """
217
+ self._state.functions.append(FunctionInfo(name=name, docstring=docstring))
218
+ self._refresh()
219
+
220
+ def update_metrics(
221
+ self,
222
+ quality_delta: float,
223
+ latency_last: float,
224
+ latency_avg: float,
225
+ latency_total: float,
226
+ llm_calls: int,
227
+ ) -> None:
228
+ """
229
+ Update latency metrics.
230
+
231
+ Args:
232
+ quality_delta: Quality improvement percentage (ignored, kept for compatibility)
233
+ latency_last: Last LLM call latency in ms
234
+ latency_avg: Average LLM call latency in ms
235
+ latency_total: Total LLM call time in ms
236
+ llm_calls: Total number of LLM calls
237
+ """
238
+ self._state.latency_last_ms = latency_last
239
+ self._state.latency_avg_ms = latency_avg
240
+ self._state.latency_total_ms = latency_total
241
+ self._state.llm_call_count = llm_calls
242
+ self._refresh()
243
+
244
+ def update_tokens(self, prompt: str, response: str) -> None:
245
+ """
246
+ Update token estimates.
247
+
248
+ Rough estimate: len(text) // 4
249
+
250
+ Args:
251
+ prompt: The prompt sent to the LLM
252
+ response: The response received from the LLM
253
+ """
254
+ self._state.tokens_in += len(prompt) // 4
255
+ self._state.tokens_out += len(response) // 4
256
+ self._refresh()
257
+
258
+ def update_transmission(self, response: str) -> None:
259
+ """
260
+ Update the transmission log with latest LLM response.
261
+
262
+ Args:
263
+ response: The latest LLM response text
264
+ """
265
+ self._state.last_response = response
266
+ self._refresh()
267
+
268
+ def _get_elapsed_time(self) -> str:
269
+ """Get elapsed time as MM:SS string."""
270
+ elapsed = int(time.time() - self._start_time)
271
+ minutes = elapsed // 60
272
+ seconds = elapsed % 60
273
+ return f"{minutes:02d}:{seconds:02d}"
274
+
275
+ def show_complete(self, summary: dict) -> None:
276
+ """
277
+ Show completion summary panel.
278
+
279
+ Args:
280
+ summary: Dictionary with completion stats including:
281
+ - functions_count: Number of functions generated
282
+ - chunks_processed: Number of chunks processed
283
+ - latency_total_ms: Total LLM time in ms
284
+ - llm_calls: Number of LLM calls
285
+ - output_file: Path to output file
286
+ """
287
+ if not HAS_RICH or self._layout is None:
288
+ return
289
+
290
+ # Build completion panel content
291
+ content = Table.grid(padding=(0, 2))
292
+ content.add_column(justify="left")
293
+ content.add_column(justify="left")
294
+
295
+ func_count = summary.get("functions_count", len(self._state.functions))
296
+ chunks = summary.get("chunks_processed", self._state.total_chunks)
297
+ elapsed = self._get_elapsed_time()
298
+
299
+ # Token stats
300
+ tokens_in_k = self._state.tokens_in / 1000
301
+ tokens_out_k = self._state.tokens_out / 1000
302
+
303
+ content.add_row(
304
+ Text("Functions Acquired:", style="bold"),
305
+ Text(str(func_count), style="green"),
306
+ )
307
+ content.add_row(
308
+ Text("Chunks Processed:", style="bold"),
309
+ Text(str(chunks)),
310
+ )
311
+ content.add_row(
312
+ Text("Total Time:", style="bold"),
313
+ Text(elapsed),
314
+ )
315
+ content.add_row(
316
+ Text("Tokens:", style="bold"),
317
+ Text(f"~{tokens_in_k:.1f}k in / ~{tokens_out_k:.1f}k out"),
318
+ )
319
+ content.add_row(Text(""), Text("")) # Spacer
320
+ content.add_row(
321
+ Text("Output:", style="bold"),
322
+ Text(summary.get("output_file", "cleaning_functions.py"), style="cyan"),
323
+ )
324
+
325
+ # Build the complete panel with box drawing
326
+ complete_panel = Panel(
327
+ content,
328
+ title="[bold green]MISSION COMPLETE[/bold green]",
329
+ border_style="green",
330
+ box=DOUBLE,
331
+ )
332
+
333
+ # Replace entire layout with completion panel
334
+ self._layout.split_column(
335
+ Layout(complete_panel, name="complete"),
336
+ )
337
+
338
+ if self._live:
339
+ self._live.update(self._layout)
340
+
341
+ def _refresh(self) -> None:
342
+ """Refresh all panels with current state."""
343
+ if not HAS_RICH or self._layout is None:
344
+ return
345
+
346
+ self._refresh_header()
347
+ self._refresh_status_bar()
348
+ self._refresh_progress_bar()
349
+ self._refresh_left_panel()
350
+ self._refresh_right_panel()
351
+
352
+ if self._live:
353
+ self._live.update(self._layout)
354
+
355
+ def _refresh_header(self) -> None:
356
+ """Refresh the header panel with ASCII art banner."""
357
+ if not HAS_RICH or self._layout is None:
358
+ return
359
+
360
+ banner_text = Text(ASCII_BANNER, style="bold cyan")
361
+ header_panel = Panel(
362
+ banner_text,
363
+ border_style="cyan",
364
+ box=DOUBLE,
365
+ padding=(0, 1),
366
+ )
367
+ self._layout["header"].update(header_panel)
368
+
369
+ def _refresh_status_bar(self) -> None:
370
+ """Refresh the status bar with mission info, timer, and status."""
371
+ if not HAS_RICH or self._layout is None:
372
+ return
373
+
374
+ # Truncate file path if too long
375
+ file_path = self._state.file_path
376
+ if len(file_path) > 30:
377
+ file_path = "..." + file_path[-27:]
378
+
379
+ elapsed = self._get_elapsed_time()
380
+
381
+ # Status indicator
382
+ if self._state.llm_status == "calling":
383
+ status_text = Text("ACTIVE", style="bold green")
384
+ status_indicator = "\u25cf" # Filled circle
385
+ else:
386
+ status_text = Text("IDLE", style="dim")
387
+ status_indicator = "\u25cb" # Empty circle
388
+
389
+ # Build status bar content
390
+ status_table = Table.grid(padding=(0, 2), expand=True)
391
+ status_table.add_column(justify="left", ratio=2)
392
+ status_table.add_column(justify="center", ratio=1)
393
+ status_table.add_column(justify="right", ratio=1)
394
+
395
+ mission_text = Text()
396
+ mission_text.append("MISSION: ", style="bold")
397
+ mission_text.append(file_path, style="cyan")
398
+
399
+ time_text = Text()
400
+ time_text.append("TIME: ", style="bold")
401
+ time_text.append(elapsed, style="cyan")
402
+
403
+ status_combined = Text()
404
+ status_combined.append("STATUS: ", style="bold")
405
+ status_combined.append(f"{status_indicator} ", style="green" if self._state.llm_status == "calling" else "dim")
406
+ status_combined.append_text(status_text)
407
+
408
+ status_table.add_row(mission_text, time_text, status_combined)
409
+
410
+ status_panel = Panel(
411
+ status_table,
412
+ border_style="cyan",
413
+ box=DOUBLE,
414
+ padding=(0, 1),
415
+ )
416
+ self._layout["status_bar"].update(status_panel)
417
+
418
+ def _refresh_progress_bar(self) -> None:
419
+ """Refresh the progress bar panel."""
420
+ if not HAS_RICH or self._layout is None:
421
+ return
422
+
423
+ # Calculate progress percentage
424
+ progress_pct = 0
425
+ if self._state.total_chunks > 0:
426
+ progress_pct = int((self._state.current_chunk / self._state.total_chunks) * 100)
427
+
428
+ # Build progress bar using Rich Progress
429
+ progress = Progress(
430
+ TextColumn("[bold cyan]\u25ba[/bold cyan]"),
431
+ TextColumn(f"CHUNK {self._state.current_chunk}/{self._state.total_chunks}"),
432
+ BarColumn(bar_width=30, complete_style="cyan", finished_style="green"),
433
+ TextColumn(f"{progress_pct}%"),
434
+ expand=False,
435
+ )
436
+ task = progress.add_task("", total=self._state.total_chunks, completed=self._state.current_chunk)
437
+
438
+ progress_panel = Panel(
439
+ progress,
440
+ border_style="cyan",
441
+ box=DOUBLE,
442
+ padding=(0, 1),
443
+ )
444
+ self._layout["progress_bar"].update(progress_panel)
445
+
446
+ def _refresh_left_panel(self) -> None:
447
+ """Refresh the left panel with functions list and metrics."""
448
+ if not HAS_RICH or self._layout is None:
449
+ return
450
+
451
+ func_count = len(self._state.functions)
452
+
453
+ # Build function tree
454
+ content = Table.grid(padding=(0, 0))
455
+ content.add_column()
456
+
457
+ # Show max 6 functions with tree structure
458
+ max_display = 6
459
+ display_funcs = self._state.functions[-max_display:] if func_count > max_display else self._state.functions
460
+
461
+ for i, func in enumerate(display_funcs):
462
+ func_text = Text()
463
+ # Tree-style prefix
464
+ if i == len(display_funcs) - 1:
465
+ func_text.append("\u2514\u2500 ", style="dim cyan") # Corner
466
+ else:
467
+ func_text.append("\u251c\u2500 ", style="dim cyan") # Tee
468
+
469
+ func_text.append(func.name, style="bold")
470
+ func_text.append(" \u2713", style="green") # Checkmark
471
+
472
+ content.add_row(func_text)
473
+
474
+ # Show "+N more" if truncated
475
+ if func_count > max_display:
476
+ hidden_count = func_count - max_display
477
+ content.add_row(Text(f" (+{hidden_count} more)", style="dim italic"))
478
+
479
+ # Add spacing
480
+ content.add_row(Text(""))
481
+
482
+ # Token stats
483
+ tokens_in_k = self._state.tokens_in / 1000
484
+ tokens_out_k = self._state.tokens_out / 1000
485
+ tokens_text = Text()
486
+ tokens_text.append("TOKENS: ", style="bold")
487
+ tokens_text.append(f"~{tokens_in_k:.1f}k in / ~{tokens_out_k:.1f}k out", style="dim")
488
+ content.add_row(tokens_text)
489
+
490
+ # Latency stats
491
+ latency_text = Text()
492
+ latency_text.append("LATENCY: ", style="bold")
493
+ if self._state.llm_call_count > 0:
494
+ latency_text.append(f"{self._state.latency_last_ms:.1f}s", style="cyan")
495
+ latency_text.append(f" (avg {self._state.latency_avg_ms / 1000:.1f}s)", style="dim")
496
+ else:
497
+ latency_text.append("\u2014", style="dim") # Em dash
498
+ content.add_row(latency_text)
499
+
500
+ left_panel = Panel(
501
+ content,
502
+ title=f"[bold cyan]FUNCTIONS ACQUIRED [{func_count}][/bold cyan]",
503
+ border_style="cyan",
504
+ box=DOUBLE,
505
+ )
506
+ self._layout["left_panel"].update(left_panel)
507
+
508
+ def _colorize_transmission(self, response: str) -> "Text":
509
+ """Parse LLM XML response into colorized Rich Text for transmission log.
510
+
511
+ Color scheme:
512
+ - Issues (solved): dim
513
+ - Issues (unsolved): bright_white with cycling accent (blue/magenta/cyan/yellow)
514
+ - Function names: green
515
+ - Docstrings: italic
516
+ - Status clean: green
517
+ - Status needs_more_work: yellow
518
+
519
+ Args:
520
+ response: Raw LLM response text (XML format)
521
+
522
+ Returns:
523
+ Rich Text object with colors applied.
524
+ """
525
+ import re
526
+
527
+ ISSUE_COLORS = ["blue", "magenta", "cyan", "yellow"]
528
+ text = Text()
529
+ unsolved_index = 0
530
+
531
+ try:
532
+ # Find all issues
533
+ issue_pattern = r'<issue[^>]*id="(\d+)"[^>]*solved="(true|false)"[^>]*>([^<]+)</issue>'
534
+ issues = re.findall(issue_pattern, response, re.DOTALL)
535
+
536
+ if issues:
537
+ text.append("ISSUES DETECTED:\n", style="bold cyan")
538
+ for issue_id, solved, desc in issues[:8]: # Limit to 8 issues
539
+ desc_clean = desc.strip()[:40] # Truncate description
540
+ if solved == "true":
541
+ text.append(" \u2713 ", style="green")
542
+ text.append(f"{desc_clean}\n", style="dim")
543
+ else:
544
+ accent = ISSUE_COLORS[unsolved_index % len(ISSUE_COLORS)]
545
+ text.append(" \u2717 ", style=accent)
546
+ text.append(f"{desc_clean}\n", style="bright_white")
547
+ unsolved_index += 1
548
+ if len(issues) > 8:
549
+ text.append(f" (+{len(issues) - 8} more)\n", style="dim")
550
+ text.append("\n")
551
+
552
+ # Find function being generated
553
+ name_match = re.search(r'<name>([^<]+)</name>', response)
554
+ docstring_match = re.search(r'<docstring>([^<]+)</docstring>', response, re.DOTALL)
555
+
556
+ if name_match:
557
+ text.append("GENERATING: ", style="bold cyan")
558
+ text.append(f"{name_match.group(1).strip()}\n", style="green bold")
559
+ if docstring_match:
560
+ doc = docstring_match.group(1).strip()[:60]
561
+ text.append(f' "{doc}..."\n', style="italic")
562
+ text.append("\n")
563
+
564
+ # Find chunk status
565
+ status_match = re.search(r'<chunk_status>([^<]+)</chunk_status>', response)
566
+ if status_match:
567
+ status = status_match.group(1).strip()
568
+ text.append("STATUS: ", style="bold cyan")
569
+ if status == "clean":
570
+ text.append(status.upper(), style="green bold")
571
+ else:
572
+ text.append(status.upper().replace("_", " "), style="yellow bold")
573
+
574
+ if text.plain:
575
+ return text
576
+ except Exception:
577
+ pass
578
+
579
+ # Fallback: show truncated raw response
580
+ fallback = response[:500] + "..." if len(response) > 500 else response
581
+ return Text(fallback, style="dim cyan")
582
+
583
+ def _refresh_right_panel(self) -> None:
584
+ """Refresh the right panel with colorized transmission log."""
585
+ if not HAS_RICH or self._layout is None:
586
+ return
587
+
588
+ # Get last response and colorize for display
589
+ response = self._state.last_response
590
+ if not response:
591
+ log_text = Text("(Awaiting transmission...)", style="dim cyan")
592
+ else:
593
+ log_text = self._colorize_transmission(response)
594
+
595
+ right_panel = Panel(
596
+ log_text,
597
+ title="[bold cyan]\u25c4\u25c4 TRANSMISSION LOG \u25ba\u25ba[/bold cyan]",
598
+ border_style="cyan",
599
+ box=DOUBLE,
600
+ )
601
+ self._layout["right_panel"].update(right_panel)
602
+
603
+ # Legacy method stubs for backwards compatibility
604
+ def _refresh_progress(self) -> None:
605
+ """Legacy method - calls _refresh_progress_bar."""
606
+ self._refresh_progress_bar()
607
+
608
+ def _refresh_functions(self) -> None:
609
+ """Legacy method - calls _refresh_left_panel."""
610
+ self._refresh_left_panel()
611
+
612
+ def _refresh_footer(self) -> None:
613
+ """Legacy method - no longer used but kept for compatibility."""
614
+ pass