shrinkray 0.0.0__py3-none-any.whl → 25.12.26__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.
shrinkray/tui.py ADDED
@@ -0,0 +1,915 @@
1
+ """Textual-based TUI for Shrink Ray."""
2
+
3
+ import os
4
+ from collections.abc import AsyncIterator
5
+ from datetime import timedelta
6
+ from typing import Literal, Protocol
7
+
8
+ import humanize
9
+ from rich.text import Text
10
+ from textual import work
11
+ from textual.app import App, ComposeResult
12
+ from textual.containers import Vertical, VerticalScroll
13
+ from textual.reactive import reactive
14
+ from textual.screen import ModalScreen
15
+ from textual.theme import Theme
16
+ from textual.widgets import DataTable, Footer, Header, Label, Static
17
+
18
+ from shrinkray.subprocess.client import SubprocessClient
19
+ from shrinkray.subprocess.protocol import PassStatsData, ProgressUpdate, Response
20
+
21
+
22
+ ThemeMode = Literal["auto", "dark", "light"]
23
+
24
+ # Custom themes with true white/black backgrounds
25
+ SHRINKRAY_LIGHT_THEME = Theme(
26
+ name="shrinkray-light",
27
+ primary="#0066cc",
28
+ secondary="#6c757d",
29
+ accent="#007acc",
30
+ background="#ffffff", # Pure white
31
+ surface="#ffffff",
32
+ panel="#f8f9fa",
33
+ dark=False,
34
+ )
35
+
36
+ SHRINKRAY_DARK_THEME = Theme(
37
+ name="shrinkray-dark",
38
+ primary="#4da6ff",
39
+ secondary="#adb5bd",
40
+ accent="#4dc3ff",
41
+ background="#000000", # Pure black
42
+ surface="#000000",
43
+ panel="#1a1a1a",
44
+ dark=True,
45
+ )
46
+
47
+
48
+ def detect_terminal_theme() -> bool:
49
+ """Detect if terminal is in dark mode. Returns True for dark, False for light."""
50
+ # Check COLORFGBG environment variable (format: "fg;bg" where higher bg = light)
51
+ colorfgbg = os.environ.get("COLORFGBG", "")
52
+ if colorfgbg:
53
+ try:
54
+ parts = colorfgbg.split(";")
55
+ if len(parts) >= 2:
56
+ bg = int(parts[-1])
57
+ # Background values 0-6 are typically dark, 7+ are light
58
+ # Common: 0=black, 15=white, 7=light gray
59
+ return bg < 7
60
+ except (ValueError, IndexError):
61
+ pass
62
+
63
+ # Check for macOS Terminal.app / iTerm2 light mode indicators
64
+ term_program = os.environ.get("TERM_PROGRAM", "")
65
+ if term_program in ("Apple_Terminal", "iTerm.app"):
66
+ # Check if system is in light mode via defaults (macOS)
67
+ # This is a heuristic - AppleInterfaceStyle is absent in light mode
68
+ apple_interface = os.environ.get("__CFBundleIdentifier", "")
69
+ if not apple_interface:
70
+ try:
71
+ import subprocess
72
+
73
+ result = subprocess.run(
74
+ ["defaults", "read", "-g", "AppleInterfaceStyle"],
75
+ capture_output=True,
76
+ text=True,
77
+ timeout=1,
78
+ )
79
+ # If this succeeds and returns "Dark", we're in dark mode
80
+ # If it fails (exit code 1), we're in light mode
81
+ return result.returncode == 0 and "Dark" in result.stdout
82
+ except Exception:
83
+ pass
84
+
85
+ # Default to dark mode (textual's default)
86
+ return True
87
+
88
+
89
+ class ReductionClientProtocol(Protocol):
90
+ """Protocol for reduction client - allows mocking for tests."""
91
+
92
+ async def start(self) -> None: ...
93
+
94
+ async def start_reduction(
95
+ self,
96
+ file_path: str,
97
+ test: list[str],
98
+ parallelism: int | None = None,
99
+ timeout: float = 1.0,
100
+ seed: int = 0,
101
+ input_type: str = "all",
102
+ in_place: bool = False,
103
+ formatter: str = "default",
104
+ volume: str = "normal",
105
+ no_clang_delta: bool = False,
106
+ clang_delta: str = "",
107
+ trivial_is_error: bool = True,
108
+ ) -> Response: ...
109
+ async def cancel(self) -> Response: ...
110
+ async def disable_pass(self, pass_name: str) -> Response: ...
111
+ async def enable_pass(self, pass_name: str) -> Response: ...
112
+ async def skip_current_pass(self) -> Response: ...
113
+ async def close(self) -> None: ...
114
+
115
+ @property
116
+ def error_message(self) -> str | None: ...
117
+ def get_progress_updates(self) -> AsyncIterator[ProgressUpdate]: ...
118
+ @property
119
+ def is_completed(self) -> bool: ...
120
+
121
+
122
+ class StatsDisplay(Static):
123
+ """Widget to display reduction statistics."""
124
+
125
+ # Use prefixed names to avoid conflicts with textual's built-in properties
126
+ current_status = reactive("Starting...")
127
+ current_size = reactive(0)
128
+ original_size = reactive(0)
129
+ call_count = reactive(0)
130
+ reduction_count = reactive(0)
131
+ interesting_calls = reactive(0)
132
+ wasted_calls = reactive(0)
133
+ runtime = reactive(0.0)
134
+ parallel_workers = reactive(0)
135
+ average_parallelism = reactive(0.0)
136
+ effective_parallelism = reactive(0.0)
137
+ time_since_last_reduction = reactive(0.0)
138
+
139
+ def update_stats(self, update: ProgressUpdate) -> None:
140
+ self.current_status = update.status
141
+ self.current_size = update.size
142
+ self.original_size = update.original_size
143
+ self.call_count = update.calls
144
+ self.reduction_count = update.reductions
145
+ self.interesting_calls = update.interesting_calls
146
+ self.wasted_calls = update.wasted_calls
147
+ self.runtime = update.runtime
148
+ self.parallel_workers = update.parallel_workers
149
+ self.average_parallelism = update.average_parallelism
150
+ self.effective_parallelism = update.effective_parallelism
151
+ self.time_since_last_reduction = update.time_since_last_reduction
152
+ self.refresh(layout=True)
153
+
154
+ def render(self) -> str:
155
+ if self.original_size == 0:
156
+ return "Waiting for reduction to start..."
157
+
158
+ # Calculate stats
159
+ reduction_pct = (1.0 - self.current_size / self.original_size) * 100
160
+ deleted = self.original_size - self.current_size
161
+
162
+ # Build stats display
163
+ lines = []
164
+
165
+ # Size and reduction info
166
+ if self.reduction_count > 0 and self.runtime > 0:
167
+ reduction_rate = deleted / self.runtime
168
+ lines.append(
169
+ f"Current test case size: {humanize.naturalsize(self.current_size)} "
170
+ f"({reduction_pct:.2f}% reduction, {humanize.naturalsize(reduction_rate)} / second)"
171
+ )
172
+ else:
173
+ lines.append(
174
+ f"Current test case size: {humanize.naturalsize(self.current_size)}"
175
+ )
176
+
177
+ # Runtime
178
+ if self.runtime > 0:
179
+ runtime_delta = timedelta(seconds=self.runtime)
180
+ lines.append(f"Total runtime: {humanize.precisedelta(runtime_delta)}")
181
+
182
+ # Call statistics
183
+ if self.call_count > 0:
184
+ calls_per_sec = self.call_count / self.runtime if self.runtime > 0 else 0
185
+ interesting_pct = (self.interesting_calls / self.call_count) * 100
186
+ wasted_pct = (self.wasted_calls / self.call_count) * 100
187
+ lines.append(
188
+ f"Calls to interestingness test: {self.call_count} "
189
+ f"({calls_per_sec:.2f} calls / second, "
190
+ f"{interesting_pct:.2f}% interesting, "
191
+ f"{wasted_pct:.2f}% wasted)"
192
+ )
193
+ else:
194
+ lines.append("Not yet called interestingness test")
195
+
196
+ # Time since last reduction
197
+ if self.reduction_count > 0 and self.runtime > 0:
198
+ reductions_per_sec = self.reduction_count / self.runtime
199
+ lines.append(
200
+ f"Time since last reduction: {self.time_since_last_reduction:.2f}s "
201
+ f"({reductions_per_sec:.2f} reductions / second)"
202
+ )
203
+ else:
204
+ lines.append("No reductions yet")
205
+
206
+ lines.append("")
207
+ lines.append(f"Reducer status: {self.current_status}")
208
+
209
+ # Parallelism stats - always show
210
+ lines.append(
211
+ f"Current parallel workers: {self.parallel_workers} "
212
+ f"(Average {self.average_parallelism:.2f}) "
213
+ f"(effective parallelism: {self.effective_parallelism:.2f})"
214
+ )
215
+
216
+ return "\n".join(lines)
217
+
218
+
219
+ class ContentPreview(Static):
220
+ """Widget to display the current test case content preview."""
221
+
222
+ preview_content = reactive("")
223
+ hex_mode = reactive(False)
224
+ _last_displayed_content: str = ""
225
+ _last_display_time: float = 0.0
226
+ _pending_content: str = ""
227
+ _pending_hex_mode: bool = False
228
+
229
+ def update_content(self, content: str, hex_mode: bool) -> None:
230
+ import time
231
+
232
+ # Store the pending content
233
+ self._pending_content = content
234
+ self._pending_hex_mode = hex_mode
235
+
236
+ # Throttle updates to once per second
237
+ now = time.time()
238
+ if now - self._last_display_time < 1.0:
239
+ return
240
+
241
+ # Update the displayed content
242
+ self._last_display_time = now
243
+
244
+ # Track last displayed content for diffs
245
+ if self.preview_content and self.preview_content != content:
246
+ self._last_displayed_content = str(self.preview_content)
247
+
248
+ self.preview_content = content
249
+ self.hex_mode = hex_mode
250
+ self.refresh(layout=True)
251
+
252
+ def _get_available_lines(self) -> int:
253
+ """Get the number of lines available for display based on container size."""
254
+ try:
255
+ # Try to get the parent container's size (the VerticalScroll viewport)
256
+ parent = self.parent
257
+ if parent and hasattr(parent, "size"):
258
+ parent_size = parent.size # type: ignore[union-attr]
259
+ if parent_size.height > 0:
260
+ return max(10, parent_size.height - 2)
261
+ # Fall back to app screen size
262
+ if self.app and self.app.size.height > 0:
263
+ # Estimate available space (screen minus header, footer, stats, etc.)
264
+ return max(10, self.app.size.height - 15)
265
+ except Exception:
266
+ pass
267
+ # Fallback based on common terminal height
268
+ return 30
269
+
270
+ def render(self) -> str:
271
+ if not self.preview_content:
272
+ return "Loading..."
273
+
274
+ available_lines = self._get_available_lines()
275
+
276
+ if self.hex_mode:
277
+ return f"[Hex mode]\n{self.preview_content}"
278
+
279
+ lines = self.preview_content.split("\n")
280
+
281
+ # For small files that fit, show full content
282
+ if len(lines) <= available_lines:
283
+ return self.preview_content
284
+
285
+ # For larger files, show diff if we have previous displayed content
286
+ if (
287
+ self._last_displayed_content
288
+ and self._last_displayed_content != self.preview_content
289
+ ):
290
+ from difflib import unified_diff
291
+
292
+ prev_lines = self._last_displayed_content.split("\n")
293
+ curr_lines = self.preview_content.split("\n")
294
+ diff = list(unified_diff(prev_lines, curr_lines, lineterm=""))
295
+ if diff:
296
+ # Show as much diff as fits
297
+ return "\n".join(diff[:available_lines])
298
+
299
+ # No diff available, show truncated content
300
+ return (
301
+ "\n".join(lines[:available_lines])
302
+ + f"\n\n... ({len(lines) - available_lines} more lines)"
303
+ )
304
+
305
+
306
+ class HelpScreen(ModalScreen[None]):
307
+ """Modal screen showing keyboard shortcuts help."""
308
+
309
+ CSS = """
310
+ HelpScreen {
311
+ align: center middle;
312
+ }
313
+
314
+ HelpScreen > Vertical {
315
+ width: 60;
316
+ height: auto;
317
+ max-height: 80%;
318
+ background: $panel;
319
+ border: thick $primary;
320
+ padding: 1 2;
321
+ }
322
+
323
+ HelpScreen #help-title {
324
+ text-align: center;
325
+ text-style: bold;
326
+ margin-bottom: 1;
327
+ }
328
+
329
+ HelpScreen .help-section {
330
+ margin-bottom: 1;
331
+ }
332
+
333
+ HelpScreen .help-key {
334
+ color: $accent;
335
+ }
336
+ """
337
+
338
+ BINDINGS = [
339
+ ("escape,q,h", "dismiss", "Close"),
340
+ ]
341
+
342
+ def compose(self) -> ComposeResult:
343
+ with Vertical():
344
+ yield Label("Keyboard Shortcuts", id="help-title")
345
+ yield Static("")
346
+ yield Static("[bold]Main Screen[/bold]", classes="help-section")
347
+ yield Static(" [green]h[/green] Show this help")
348
+ yield Static(" [green]p[/green] Open pass statistics")
349
+ yield Static(" [green]c[/green] Skip current pass")
350
+ yield Static(" [green]q[/green] Quit application")
351
+ yield Static("")
352
+ yield Static("[bold]Pass Statistics Screen[/bold]", classes="help-section")
353
+ yield Static(" [green]↑/↓[/green] Navigate passes")
354
+ yield Static(" [green]space[/green] Toggle pass enabled/disabled")
355
+ yield Static(" [green]c[/green] Skip current pass")
356
+ yield Static(" [green]q[/green] Close modal")
357
+ yield Static("")
358
+ yield Static("[dim]Press any key to close[/dim]")
359
+
360
+
361
+ class PassStatsScreen(ModalScreen[None]):
362
+ """Modal screen showing pass statistics in a table."""
363
+
364
+ CSS = """
365
+ PassStatsScreen {
366
+ align: center middle;
367
+ }
368
+
369
+ PassStatsScreen > Vertical {
370
+ width: 90%;
371
+ height: 85%;
372
+ background: $panel;
373
+ border: thick $primary;
374
+ }
375
+
376
+ PassStatsScreen DataTable {
377
+ height: 1fr;
378
+ }
379
+
380
+ PassStatsScreen #stats-footer {
381
+ dock: bottom;
382
+ height: auto;
383
+ padding: 1;
384
+ background: $panel;
385
+ text-align: center;
386
+ }
387
+ """
388
+
389
+ BINDINGS = [
390
+ ("escape,q,p", "dismiss", "Close"),
391
+ ("space", "toggle_disable", "Toggle Enable"),
392
+ ("c", "skip_current", "Skip Pass"),
393
+ ("h", "show_help", "Help"),
394
+ ]
395
+
396
+ pass_stats: reactive[list[PassStatsData]] = reactive(list)
397
+ current_pass_name: reactive[str] = reactive("")
398
+ disabled_passes: reactive[set[str]] = reactive(set)
399
+
400
+ def __init__(self, app: "ShrinkRayApp") -> None:
401
+ super().__init__()
402
+ self._app = app
403
+ self.pass_stats = app._latest_pass_stats.copy()
404
+ self.current_pass_name = app._current_pass_name
405
+ self.disabled_passes = set(app._disabled_passes)
406
+
407
+ def compose(self) -> ComposeResult:
408
+ with Vertical():
409
+ yield Label(
410
+ "Pass Statistics - [space] toggle, [c] skip, [h] help, [q] close",
411
+ id="stats-header",
412
+ )
413
+ yield DataTable(id="pass-stats-table")
414
+ yield Static(
415
+ f"Showing {len(self.pass_stats)} passes in run order",
416
+ id="stats-footer",
417
+ )
418
+
419
+ def on_mount(self) -> None:
420
+ table = self.query_one(DataTable)
421
+
422
+ # Select entire rows, not individual cells
423
+ table.cursor_type = "row"
424
+
425
+ table.add_columns(
426
+ "Enabled",
427
+ "Pass Name",
428
+ "Runs",
429
+ "Bytes Deleted",
430
+ "Tests",
431
+ "Reductions",
432
+ "Success %",
433
+ )
434
+
435
+ self._update_table_data()
436
+
437
+ # Set up periodic refresh (every 500ms)
438
+ self.set_interval(0.5, self._refresh_data)
439
+
440
+ def _update_table_data(self) -> None:
441
+ """Update the table with current pass stats."""
442
+ table = self.query_one(DataTable)
443
+
444
+ # Save cursor position and scroll position before clearing
445
+ saved_cursor = table.cursor_coordinate
446
+ saved_scroll_y = table.scroll_y
447
+
448
+ table.clear()
449
+
450
+ if not self.pass_stats:
451
+ table.add_row("-", "No pass data yet", "-", "-", "-", "-", "-")
452
+ else:
453
+ for ps in self.pass_stats:
454
+ is_current = ps.pass_name == self.current_pass_name
455
+ is_disabled = ps.pass_name in self.disabled_passes
456
+ bytes_str = humanize.naturalsize(ps.bytes_deleted, binary=True)
457
+
458
+ # Checkbox for enabled/disabled
459
+ if is_disabled:
460
+ checkbox = Text("[ ]", style="dim")
461
+ else:
462
+ checkbox = Text("[✓]", style="green")
463
+
464
+ # Determine styling: bold for current, dim for disabled
465
+ if is_disabled:
466
+ style = "dim strike"
467
+ elif is_current:
468
+ style = "bold"
469
+ else:
470
+ style = ""
471
+
472
+ # Apply styling
473
+ if style:
474
+ name = Text(ps.pass_name, style=style)
475
+ runs = Text(str(ps.run_count), style=style)
476
+ bytes_del = Text(bytes_str, style=style)
477
+ tests = Text(f"{ps.test_evaluations:,}", style=style)
478
+ reductions = Text(str(ps.successful_reductions), style=style)
479
+ success = Text(f"{ps.success_rate:.1f}%", style=style)
480
+ else:
481
+ name = ps.pass_name
482
+ runs = str(ps.run_count)
483
+ bytes_del = bytes_str
484
+ tests = f"{ps.test_evaluations:,}"
485
+ reductions = str(ps.successful_reductions)
486
+ success = f"{ps.success_rate:.1f}%"
487
+
488
+ table.add_row(checkbox, name, runs, bytes_del, tests, reductions, success)
489
+
490
+ # Restore cursor and scroll position after rebuilding
491
+ # Only restore if the saved position is still valid
492
+ row_count = table.row_count
493
+ if row_count > 0 and saved_cursor.row < row_count:
494
+ table.cursor_coordinate = saved_cursor
495
+ table.scroll_y = saved_scroll_y
496
+
497
+ def _refresh_data(self) -> None:
498
+ """Refresh data from the app.
499
+
500
+ Note: We don't update disabled_passes from the worker because
501
+ the local state is the source of truth. This avoids flicker when
502
+ the user toggles a pass but the worker hasn't confirmed yet.
503
+ """
504
+ new_stats = self._app._latest_pass_stats.copy()
505
+ new_current = self._app._current_pass_name
506
+ if new_stats != self.pass_stats or new_current != self.current_pass_name:
507
+ self.pass_stats = new_stats
508
+ self.current_pass_name = new_current
509
+ self._update_table_data()
510
+ # Update footer with disabled count
511
+ disabled_count = len(self.disabled_passes)
512
+ if disabled_count > 0:
513
+ footer_text = f"Showing {len(self.pass_stats)} passes ({disabled_count} disabled)"
514
+ else:
515
+ footer_text = f"Showing {len(self.pass_stats)} passes in run order"
516
+ footer = self.query_one("#stats-footer", Static)
517
+ footer.update(footer_text)
518
+
519
+ def _get_selected_pass_name(self) -> str | None:
520
+ """Get the pass name from the currently selected row."""
521
+ table = self.query_one(DataTable)
522
+ if table.row_count == 0:
523
+ return None
524
+ cursor_row = table.cursor_coordinate.row
525
+ if cursor_row >= len(self.pass_stats):
526
+ return None
527
+ return self.pass_stats[cursor_row].pass_name
528
+
529
+ def action_toggle_disable(self) -> None:
530
+ """Toggle the disabled state of the selected pass."""
531
+ pass_name = self._get_selected_pass_name()
532
+ if pass_name is None:
533
+ return
534
+
535
+ if pass_name in self.disabled_passes:
536
+ # Enable the pass - update UI immediately, send command in background
537
+ # Create new set to trigger reactive update
538
+ self.disabled_passes = self.disabled_passes - {pass_name}
539
+ self._update_table_data()
540
+ self._app.run_worker(self._send_enable_pass(pass_name))
541
+ else:
542
+ # Disable the pass - update UI immediately, send command in background
543
+ # Create new set to trigger reactive update
544
+ self.disabled_passes = self.disabled_passes | {pass_name}
545
+ self._update_table_data()
546
+ self._app.run_worker(self._send_disable_pass(pass_name))
547
+
548
+ async def _send_disable_pass(self, pass_name: str) -> None:
549
+ """Send disable command to the subprocess (fire and forget)."""
550
+ if self._app._client is not None:
551
+ await self._app._client.disable_pass(pass_name)
552
+
553
+ async def _send_enable_pass(self, pass_name: str) -> None:
554
+ """Send enable command to the subprocess (fire and forget)."""
555
+ if self._app._client is not None:
556
+ await self._app._client.enable_pass(pass_name)
557
+
558
+ def action_skip_current(self) -> None:
559
+ """Skip the currently running pass."""
560
+ self._app.run_worker(self._skip_pass())
561
+
562
+ async def _skip_pass(self) -> None:
563
+ """Skip the current pass via the client."""
564
+ if self._app._client is not None:
565
+ await self._app._client.skip_current_pass()
566
+
567
+ def action_show_help(self) -> None:
568
+ """Show the help screen."""
569
+ self._app.push_screen(HelpScreen())
570
+
571
+
572
+ class ShrinkRayApp(App[None]):
573
+ """Textual app for Shrink Ray."""
574
+
575
+ CSS = """
576
+ #main-container {
577
+ height: 100%;
578
+ }
579
+
580
+ #stats-container {
581
+ height: auto;
582
+ border: solid green;
583
+ padding: 1;
584
+ margin: 1;
585
+ }
586
+
587
+ #status-label {
588
+ text-style: bold;
589
+ margin: 0 1;
590
+ }
591
+
592
+ #content-container {
593
+ border: solid blue;
594
+ margin: 1;
595
+ height: 1fr;
596
+ }
597
+ """
598
+
599
+ BINDINGS = [
600
+ ("q", "quit", "Quit"),
601
+ ("p", "show_pass_stats", "Pass Stats"),
602
+ ("c", "skip_current_pass", "Skip Pass"),
603
+ ("h", "show_help", "Help"),
604
+ ]
605
+
606
+ ENABLE_COMMAND_PALETTE = False
607
+
608
+ def __init__(
609
+ self,
610
+ file_path: str,
611
+ test: list[str],
612
+ parallelism: int | None = None,
613
+ timeout: float = 1.0,
614
+ seed: int = 0,
615
+ input_type: str = "all",
616
+ in_place: bool = False,
617
+ formatter: str = "default",
618
+ volume: str = "normal",
619
+ no_clang_delta: bool = False,
620
+ clang_delta: str = "",
621
+ trivial_is_error: bool = True,
622
+ exit_on_completion: bool = True,
623
+ client: ReductionClientProtocol | None = None,
624
+ theme: ThemeMode = "auto",
625
+ ) -> None:
626
+ super().__init__()
627
+ self._file_path = file_path
628
+ self._test = test
629
+ self._parallelism = parallelism
630
+ self._timeout = timeout
631
+ self._seed = seed
632
+ self._input_type = input_type
633
+ self._in_place = in_place
634
+ self._formatter = formatter
635
+ self._volume = volume
636
+ self._no_clang_delta = no_clang_delta
637
+ self._clang_delta = clang_delta
638
+ self._trivial_is_error = trivial_is_error
639
+ self._exit_on_completion = exit_on_completion
640
+ self._client: ReductionClientProtocol | None = client
641
+ self._owns_client = client is None
642
+ self._completed = False
643
+ self._theme = theme
644
+ self._latest_pass_stats: list[PassStatsData] = []
645
+ self._current_pass_name: str = ""
646
+ self._disabled_passes: list[str] = []
647
+
648
+ def compose(self) -> ComposeResult:
649
+ yield Header()
650
+ with Vertical(id="main-container"):
651
+ yield Label(
652
+ "Shrink Ray - [h] help, [p] passes, [c] skip pass, [q] quit",
653
+ id="status-label",
654
+ )
655
+ with Vertical(id="stats-container"):
656
+ yield StatsDisplay(id="stats-display")
657
+ with VerticalScroll(id="content-container"):
658
+ yield ContentPreview(id="content-preview")
659
+ yield Footer()
660
+
661
+ async def on_mount(self) -> None:
662
+ # Register and apply custom themes
663
+ self.register_theme(SHRINKRAY_LIGHT_THEME)
664
+ self.register_theme(SHRINKRAY_DARK_THEME)
665
+
666
+ if self._theme == "dark":
667
+ self.theme = "shrinkray-dark"
668
+ elif self._theme == "light":
669
+ self.theme = "shrinkray-light"
670
+ else: # auto
671
+ self.theme = (
672
+ "shrinkray-dark" if detect_terminal_theme() else "shrinkray-light"
673
+ )
674
+
675
+ self.title = "Shrink Ray"
676
+ self.sub_title = self._file_path
677
+ self.run_reduction()
678
+
679
+ @work(exclusive=True)
680
+ async def run_reduction(self) -> None:
681
+ """Start the reduction subprocess and monitor progress."""
682
+ try:
683
+ if self._client is None:
684
+ # No client provided - start one and begin reduction
685
+ debug_mode = self._volume == "debug"
686
+ self._client = SubprocessClient(debug_mode=debug_mode)
687
+ self._owns_client = True
688
+
689
+ await self._client.start()
690
+
691
+ # Start the reduction
692
+ response = await self._client.start_reduction(
693
+ file_path=self._file_path,
694
+ test=self._test,
695
+ parallelism=self._parallelism,
696
+ timeout=self._timeout,
697
+ seed=self._seed,
698
+ input_type=self._input_type,
699
+ in_place=self._in_place,
700
+ formatter=self._formatter,
701
+ volume=self._volume,
702
+ no_clang_delta=self._no_clang_delta,
703
+ clang_delta=self._clang_delta,
704
+ trivial_is_error=self._trivial_is_error,
705
+ )
706
+
707
+ if response.error:
708
+ # Exit immediately on startup error
709
+ self.exit(return_code=1, message=f"Error: {response.error}")
710
+ return
711
+
712
+ # Monitor progress (client is already started and reduction is running)
713
+ stats_display = self.query_one("#stats-display", StatsDisplay)
714
+ content_preview = self.query_one("#content-preview", ContentPreview)
715
+
716
+ async for update in self._client.get_progress_updates():
717
+ stats_display.update_stats(update)
718
+ content_preview.update_content(update.content_preview, update.hex_mode)
719
+ self._latest_pass_stats = update.pass_stats
720
+ self._current_pass_name = update.current_pass_name
721
+ self._disabled_passes = update.disabled_passes
722
+
723
+ # Check if all passes are disabled
724
+ self._check_all_passes_disabled()
725
+
726
+ if self._client.is_completed:
727
+ break
728
+
729
+ self._completed = True
730
+
731
+ # Check if there was an error from the worker
732
+ if self._client.error_message:
733
+ # Exit immediately on error, printing the error message
734
+ self.exit(return_code=1, message=f"Error: {self._client.error_message}")
735
+ return
736
+ elif self._exit_on_completion:
737
+ self.exit()
738
+ else:
739
+ self.update_status("Reduction completed! Press 'q' to exit.")
740
+
741
+ except Exception as e:
742
+ self.exit(return_code=1, message=f"Error: {e}")
743
+ finally:
744
+ if self._owns_client and self._client:
745
+ await self._client.close()
746
+
747
+ def _check_all_passes_disabled(self) -> None:
748
+ """Check if all passes are disabled and show a message if so."""
749
+ if self._latest_pass_stats and self._disabled_passes:
750
+ all_pass_names = {ps.pass_name for ps in self._latest_pass_stats}
751
+ if all_pass_names and all_pass_names <= set(self._disabled_passes):
752
+ self.update_status(
753
+ "Reduction paused (all passes disabled) - " "[p] to re-enable passes"
754
+ )
755
+
756
+ def update_status(self, message: str) -> None:
757
+ """Update the status label."""
758
+ try:
759
+ self.query_one("#status-label", Label).update(message)
760
+ except Exception:
761
+ pass # Widget not yet mounted
762
+
763
+ async def action_quit(self) -> None:
764
+ """Quit the application with graceful cancellation."""
765
+ if self._client and not self._completed:
766
+ try:
767
+ await self._client.cancel()
768
+ except Exception:
769
+ pass # Process may have already exited
770
+ self.exit()
771
+
772
+ def action_show_pass_stats(self) -> None:
773
+ """Show the pass statistics modal."""
774
+ self.push_screen(PassStatsScreen(self))
775
+
776
+ def action_show_help(self) -> None:
777
+ """Show the help modal."""
778
+ self.push_screen(HelpScreen())
779
+
780
+ def action_skip_current_pass(self) -> None:
781
+ """Skip the currently running pass."""
782
+ if self._client and not self._completed:
783
+ self.run_worker(self._skip_pass())
784
+
785
+ async def _skip_pass(self) -> None:
786
+ """Skip the current pass via the client."""
787
+ if self._client is not None:
788
+ await self._client.skip_current_pass()
789
+
790
+ @property
791
+ def is_completed(self) -> bool:
792
+ """Check if reduction is completed."""
793
+ return self._completed
794
+
795
+
796
+ async def _validate_initial_example(
797
+ file_path: str,
798
+ test: list[str],
799
+ parallelism: int | None,
800
+ timeout: float,
801
+ seed: int,
802
+ input_type: str,
803
+ in_place: bool,
804
+ formatter: str,
805
+ volume: str,
806
+ no_clang_delta: bool,
807
+ clang_delta: str,
808
+ trivial_is_error: bool,
809
+ ) -> str | None:
810
+ """Validate initial example before showing TUI.
811
+
812
+ Returns error_message if validation failed, None if it passed.
813
+ """
814
+ debug_mode = volume == "debug"
815
+ client = SubprocessClient(debug_mode=debug_mode)
816
+ try:
817
+ await client.start()
818
+
819
+ response = await client.start_reduction(
820
+ file_path=file_path,
821
+ test=test,
822
+ parallelism=parallelism,
823
+ timeout=timeout,
824
+ seed=seed,
825
+ input_type=input_type,
826
+ in_place=in_place,
827
+ formatter=formatter,
828
+ volume=volume,
829
+ no_clang_delta=no_clang_delta,
830
+ clang_delta=clang_delta,
831
+ trivial_is_error=trivial_is_error,
832
+ )
833
+
834
+ if response.error:
835
+ return response.error
836
+
837
+ # Validation passed - cancel this reduction since TUI will start fresh
838
+ await client.cancel()
839
+ return None
840
+ finally:
841
+ await client.close()
842
+
843
+
844
+ def run_textual_ui(
845
+ file_path: str,
846
+ test: list[str],
847
+ parallelism: int | None = None,
848
+ timeout: float = 1.0,
849
+ seed: int = 0,
850
+ input_type: str = "all",
851
+ in_place: bool = False,
852
+ formatter: str = "default",
853
+ volume: str = "normal",
854
+ no_clang_delta: bool = False,
855
+ clang_delta: str = "",
856
+ trivial_is_error: bool = True,
857
+ exit_on_completion: bool = True,
858
+ theme: ThemeMode = "auto",
859
+ ) -> None:
860
+ """Run the textual TUI."""
861
+ import asyncio
862
+ import sys
863
+
864
+ print("Validating initial example...", flush=True)
865
+
866
+ # Validate initial example before showing TUI
867
+ async def validate():
868
+ return await _validate_initial_example(
869
+ file_path=file_path,
870
+ test=test,
871
+ parallelism=parallelism,
872
+ timeout=timeout,
873
+ seed=seed,
874
+ input_type=input_type,
875
+ in_place=in_place,
876
+ formatter=formatter,
877
+ volume=volume,
878
+ no_clang_delta=no_clang_delta,
879
+ clang_delta=clang_delta,
880
+ trivial_is_error=trivial_is_error,
881
+ )
882
+
883
+ try:
884
+ error = asyncio.run(validate())
885
+ except Exception as e:
886
+ import traceback
887
+
888
+ traceback.print_exc()
889
+ print(f"Error: {e}", file=sys.stderr)
890
+ sys.exit(1)
891
+
892
+ if error:
893
+ print(f"Error: {error}", file=sys.stderr)
894
+ sys.exit(1)
895
+
896
+ # Validation passed - now show the TUI which will start a fresh client
897
+ app = ShrinkRayApp(
898
+ file_path=file_path,
899
+ test=test,
900
+ parallelism=parallelism,
901
+ timeout=timeout,
902
+ seed=seed,
903
+ input_type=input_type,
904
+ in_place=in_place,
905
+ formatter=formatter,
906
+ volume=volume,
907
+ no_clang_delta=no_clang_delta,
908
+ clang_delta=clang_delta,
909
+ trivial_is_error=trivial_is_error,
910
+ exit_on_completion=exit_on_completion,
911
+ theme=theme,
912
+ )
913
+ app.run()
914
+ if app.return_code:
915
+ sys.exit(app.return_code)