shrinkray 25.12.28.0__py3-none-any.whl → 26.1.1.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.
shrinkray/tui.py CHANGED
@@ -1,14 +1,19 @@
1
1
  """Textual-based TUI for Shrink Ray."""
2
2
 
3
+ import math
3
4
  import os
5
+ import subprocess
6
+ import sys
4
7
  import time
5
8
  import traceback
6
9
  from collections.abc import AsyncGenerator
7
10
  from contextlib import aclosing
8
11
  from datetime import timedelta
9
- from typing import Literal, Protocol
12
+ from difflib import unified_diff
13
+ from typing import Literal, Protocol, cast
10
14
 
11
15
  import humanize
16
+ from rich.markup import escape as escape_markup
12
17
  from rich.text import Text
13
18
  from textual import work
14
19
  from textual.app import App, ComposeResult
@@ -16,8 +21,21 @@ from textual.containers import Horizontal, Vertical, VerticalScroll
16
21
  from textual.reactive import reactive
17
22
  from textual.screen import ModalScreen
18
23
  from textual.theme import Theme
19
- from textual.widgets import DataTable, Footer, Header, Label, Static
24
+ from textual.timer import Timer
25
+ from textual.widgets import (
26
+ DataTable,
27
+ Footer,
28
+ Header,
29
+ Label,
30
+ ListItem,
31
+ ListView,
32
+ Static,
33
+ TabbedContent,
34
+ TabPane,
35
+ )
36
+ from textual_plotext import PlotextPlot
20
37
 
38
+ from shrinkray.formatting import try_decode
21
39
  from shrinkray.subprocess.client import SubprocessClient
22
40
  from shrinkray.subprocess.protocol import (
23
41
  PassStatsData,
@@ -75,8 +93,6 @@ def detect_terminal_theme() -> bool:
75
93
  apple_interface = os.environ.get("__CFBundleIdentifier", "")
76
94
  if not apple_interface:
77
95
  try:
78
- import subprocess
79
-
80
96
  result = subprocess.run(
81
97
  ["defaults", "read", "-g", "AppleInterfaceStyle"],
82
98
  capture_output=True,
@@ -112,11 +128,15 @@ class ReductionClientProtocol(Protocol):
112
128
  no_clang_delta: bool = False,
113
129
  clang_delta: str = "",
114
130
  trivial_is_error: bool = True,
131
+ skip_validation: bool = False,
132
+ history_enabled: bool = True,
133
+ also_interesting_code: int | None = None,
115
134
  ) -> Response: ...
116
135
  async def cancel(self) -> Response: ...
117
136
  async def disable_pass(self, pass_name: str) -> Response: ...
118
137
  async def enable_pass(self, pass_name: str) -> Response: ...
119
138
  async def skip_current_pass(self) -> Response: ...
139
+ async def restart_from(self, reduction_number: int) -> Response: ...
120
140
  async def close(self) -> None: ...
121
141
 
122
142
  @property
@@ -223,6 +243,219 @@ class StatsDisplay(Static):
223
243
  return "\n".join(lines)
224
244
 
225
245
 
246
+ def _format_time_label(seconds: float) -> str:
247
+ """Format a time value for axis labels."""
248
+ if seconds < 60:
249
+ return f"{int(seconds)}s"
250
+ elif seconds < 3600:
251
+ minutes = int(seconds / 60)
252
+ return f"{minutes}m"
253
+ else:
254
+ hours = int(seconds / 3600)
255
+ return f"{hours}h"
256
+
257
+
258
+ def _get_time_axis_bounds(current_time: float) -> tuple[float, list[float], list[str]]:
259
+ """Get stable x-axis bounds and labeled positions.
260
+
261
+ Returns (max_time, positions, labels) where:
262
+ - max_time: the stable right boundary of the axis
263
+ - positions: numeric positions where axis labels should appear (e.g., [0, 60, 120])
264
+ - labels: formatted strings for each position (e.g., ["0s", "1m", "2m"])
265
+
266
+ The axis only rescales when current_time exceeds the current boundary.
267
+ """
268
+ if current_time <= 0:
269
+ ticks = [0.0, 10.0, 20.0, 30.0]
270
+ labels = [_format_time_label(t) for t in ticks]
271
+ return (30.0, ticks, labels)
272
+
273
+ # For the first 10 minutes, expand one minute at a time with 1-minute ticks
274
+ if current_time < 600:
275
+ # Round up to next minute
276
+ minutes = int(current_time / 60) + 1
277
+ max_time = float(minutes * 60)
278
+ interval = 60.0
279
+ else:
280
+ # After 10 minutes, use larger boundaries
281
+ # (boundary, tick_interval) - axis extends to boundary, ticks at interval
282
+ boundaries = [
283
+ (1800, 300), # 30m with 5m ticks
284
+ (3600, 600), # 1h with 10m ticks
285
+ (7200, 1200), # 2h with 20m ticks
286
+ (14400, 1800), # 4h with 30m ticks
287
+ (28800, 3600), # 8h with 1h ticks
288
+ ]
289
+
290
+ # Find the first boundary that exceeds current_time
291
+ max_time = 1800.0
292
+ interval = 300.0
293
+ for boundary, tick_interval in boundaries:
294
+ if current_time < boundary:
295
+ max_time = float(boundary)
296
+ interval = float(tick_interval)
297
+ break
298
+ else:
299
+ # Beyond 8h: extend in 4h increments with 1h ticks
300
+ hours = int(current_time / 14400) + 1
301
+ max_time = float(hours * 14400)
302
+ interval = 3600.0
303
+
304
+ # Generate ticks from 0 to max_time
305
+ ticks = []
306
+ t = 0.0
307
+ while t <= max_time:
308
+ ticks.append(t)
309
+ t += interval
310
+
311
+ labels = [_format_time_label(t) for t in ticks]
312
+ return (max_time, ticks, labels)
313
+
314
+
315
+ def _get_percentage_axis_bounds(
316
+ min_pct: float, max_pct: float
317
+ ) -> tuple[float, list[float], list[str]]:
318
+ """Get stable y-axis bounds for percentage values on log scale.
319
+
320
+ Returns (min_pct_bound, positions, labels) where:
321
+ - min_pct_bound: the stable lower boundary of the axis
322
+ - positions: log10 positions where axis labels should appear
323
+ - labels: formatted percentage strings for each position
324
+
325
+ The axis only rescales when min_pct gets close to the current lower boundary.
326
+ """
327
+ # Standard percentage boundaries (log scale friendly)
328
+ # Extended to handle very small reductions (below 0.01%)
329
+ boundaries = [
330
+ 100,
331
+ 50,
332
+ 20,
333
+ 10,
334
+ 5,
335
+ 2,
336
+ 1,
337
+ 0.5,
338
+ 0.2,
339
+ 0.1,
340
+ 0.05,
341
+ 0.02,
342
+ 0.01,
343
+ 0.005,
344
+ 0.002,
345
+ 0.001,
346
+ 0.0005,
347
+ 0.0002,
348
+ 0.0001,
349
+ ]
350
+
351
+ # Find the appropriate lower bound - use the first boundary below min_pct * 0.5
352
+ # This gives us some room before we need to rescale
353
+ lower_bound = boundaries[-1] # Default to smallest boundary
354
+ for b in boundaries:
355
+ if b < min_pct * 0.5:
356
+ lower_bound = b
357
+ break
358
+
359
+ # Find which percentage values to show as ticks (between lower_bound and 100%)
360
+ # Since boundaries always includes 100 and lower_bound <= 100, this is never empty
361
+ tick_pcts = [p for p in boundaries if p >= lower_bound and p <= 100]
362
+
363
+ # Convert to log scale
364
+ ticks = [math.log10(max(0.0001, p)) for p in tick_pcts]
365
+
366
+ # Format labels
367
+ labels = []
368
+ for p in tick_pcts:
369
+ if p >= 1:
370
+ labels.append(f"{p:.0f}%")
371
+ else:
372
+ labels.append(f"{p}%")
373
+
374
+ return (lower_bound, ticks, labels)
375
+
376
+
377
+ class SizeGraph(PlotextPlot):
378
+ """Widget to display test case size over time on a log scale."""
379
+
380
+ _size_history: list[tuple[float, int]]
381
+ _original_size: int
382
+ _current_runtime: float
383
+
384
+ def __init__(
385
+ self,
386
+ name: str | None = None,
387
+ id: str | None = None,
388
+ classes: str | None = None,
389
+ disabled: bool = False,
390
+ ) -> None:
391
+ super().__init__(name=name, id=id, classes=classes, disabled=disabled)
392
+ self._size_history = []
393
+ self._original_size = 0
394
+ self._current_runtime = 0.0
395
+
396
+ def update_graph(
397
+ self,
398
+ new_entries: list[tuple[float, int]],
399
+ original_size: int,
400
+ current_runtime: float,
401
+ ) -> None:
402
+ """Update the graph with new data."""
403
+ if new_entries:
404
+ self._size_history.extend(new_entries)
405
+ if original_size > 0:
406
+ self._original_size = original_size
407
+ self._current_runtime = current_runtime
408
+ self._setup_plot()
409
+ self.refresh()
410
+
411
+ def on_mount(self) -> None:
412
+ """Set up the plot on mount."""
413
+ self._setup_plot()
414
+
415
+ def on_resize(self) -> None:
416
+ """Redraw when resized."""
417
+ self._setup_plot()
418
+
419
+ def _setup_plot(self) -> None:
420
+ """Configure and draw the plot."""
421
+ plt = self.plt
422
+ plt.clear_figure()
423
+ plt.theme("dark")
424
+
425
+ if len(self._size_history) < 2 or self._original_size == 0:
426
+ plt.xlabel("Time")
427
+ plt.ylabel("% of original")
428
+ return
429
+
430
+ times = [t for t, _ in self._size_history]
431
+ sizes = [s for _, s in self._size_history]
432
+
433
+ # Calculate percentages of original size
434
+ percentages = [(s / self._original_size) * 100 for s in sizes]
435
+
436
+ # Use log scale for y-axis (percentages)
437
+ log_percentages = [math.log10(max(0.01, p)) for p in percentages]
438
+
439
+ plt.plot(times, log_percentages, marker="braille")
440
+
441
+ # Get stable x-axis bounds
442
+ max_time, x_ticks, x_labels = _get_time_axis_bounds(self._current_runtime)
443
+ plt.xticks(x_ticks, x_labels)
444
+ plt.xlim(0, max_time)
445
+
446
+ # Get stable y-axis bounds
447
+ min_pct = min(percentages)
448
+ lower_bound, y_ticks, y_labels = _get_percentage_axis_bounds(min_pct, 100)
449
+ plt.yticks(y_ticks, y_labels)
450
+ plt.ylim(math.log10(max(0.01, lower_bound)), math.log10(100))
451
+
452
+ plt.xlabel("Time")
453
+ plt.ylabel("% of original")
454
+
455
+ # Build to apply the plot
456
+ _ = plt.build()
457
+
458
+
226
459
  class ContentPreview(Static):
227
460
  """Widget to display the current test case content preview."""
228
461
 
@@ -292,8 +525,6 @@ class ContentPreview(Static):
292
525
  self._last_displayed_content
293
526
  and self._last_displayed_content != self.preview_content
294
527
  ):
295
- from difflib import unified_diff
296
-
297
528
  prev_lines = self._last_displayed_content.split("\n")
298
529
  curr_lines = self.preview_content.split("\n")
299
530
  diff = list(unified_diff(prev_lines, curr_lines, lineterm=""))
@@ -313,21 +544,37 @@ class OutputPreview(Static):
313
544
 
314
545
  output_content = reactive("")
315
546
  active_test_id: reactive[int | None] = reactive(None)
547
+ last_return_code: reactive[int | None] = reactive(None)
316
548
  _last_update_time: float = 0.0
317
- _last_seen_test_id: int | None = None # Track last test ID for "completed" message
549
+ # Pending updates that haven't been applied yet (due to throttling)
550
+ _pending_content: str = ""
551
+ _pending_test_id: int | None = None
552
+ _pending_return_code: int | None = None
553
+ # Track if we've ever seen any output (once true, never show "No test output yet...")
554
+ _has_seen_output: bool = False
318
555
 
319
- def update_output(self, content: str, test_id: int | None) -> None:
320
- # Throttle updates to every 200ms
556
+ def update_output(
557
+ self, content: str, test_id: int | None, return_code: int | None = None
558
+ ) -> None:
559
+ # Only update pending content if there's actual content to show
560
+ # This prevents switching to empty output when we have previous output
561
+ if content:
562
+ self._pending_content = content
563
+ self._has_seen_output = True
564
+ self._pending_test_id = test_id
565
+ self._pending_return_code = return_code
566
+
567
+ # Throttle display updates to every 200ms
321
568
  now = time.time()
322
569
  if now - self._last_update_time < 0.2:
323
570
  return
324
571
 
325
572
  self._last_update_time = now
326
- self.output_content = content
327
- # Track the last test ID we've seen (for showing in "completed" message)
328
- if test_id is not None:
329
- self._last_seen_test_id = test_id
330
- self.active_test_id = test_id
573
+ # Only update output_content if we have new content
574
+ if self._pending_content:
575
+ self.output_content = self._pending_content
576
+ self.active_test_id = self._pending_test_id
577
+ self.last_return_code = self._pending_return_code
331
578
  self.refresh(layout=True)
332
579
 
333
580
  def _get_available_lines(self) -> int:
@@ -345,11 +592,15 @@ class OutputPreview(Static):
345
592
  return 30
346
593
 
347
594
  def render(self) -> str:
348
- # Header line
349
- if self.active_test_id is not None:
595
+ # Header line - use return_code to determine if test is running
596
+ # (return_code is None means still running, has value means completed)
597
+ if self.active_test_id is not None and self.last_return_code is None:
350
598
  header = f"[green]Test #{self.active_test_id} running...[/green]"
351
- elif self.output_content and self._last_seen_test_id is not None:
352
- header = f"[dim]Test #{self._last_seen_test_id} completed[/dim]"
599
+ elif self.active_test_id is not None:
600
+ header = f"[dim]Test #{self.active_test_id} exited with code {self.last_return_code}[/dim]"
601
+ elif self._has_seen_output or self.output_content:
602
+ # Have seen output before - show without header
603
+ header = ""
353
604
  else:
354
605
  header = "[dim]No test output yet...[/dim]"
355
606
 
@@ -357,16 +608,22 @@ class OutputPreview(Static):
357
608
  return header
358
609
 
359
610
  available_lines = self._get_available_lines()
360
- lines = self.output_content.split("\n")
611
+ # Escape the output content to prevent Rich markup interpretation
612
+ # (test output may contain characters like ^ that have special meaning)
613
+ escaped_content = escape_markup(self.output_content)
614
+ lines = escaped_content.split("\n")
615
+
616
+ # Build prefix (header + newline, or empty if no header)
617
+ prefix = f"{header}\n" if header else ""
361
618
 
362
619
  # Show tail of output (most recent lines)
363
620
  if len(lines) <= available_lines:
364
- return f"{header}\n{self.output_content}"
621
+ return f"{prefix}{escaped_content}"
365
622
 
366
623
  # Truncate from the beginning
367
624
  truncated_lines = lines[-(available_lines):]
368
625
  skipped = len(lines) - available_lines
369
- return f"{header}\n... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
626
+ return f"{prefix}... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
370
627
 
371
628
 
372
629
  class HelpScreen(ModalScreen[None]):
@@ -424,6 +681,175 @@ class HelpScreen(ModalScreen[None]):
424
681
  yield Static("[dim]Press any key to close[/dim]")
425
682
 
426
683
 
684
+ class ExpandedBoxModal(ModalScreen[None]):
685
+ """Modal screen showing an expanded view of a content box."""
686
+
687
+ CSS = """
688
+ ExpandedBoxModal {
689
+ align: center middle;
690
+ }
691
+
692
+ ExpandedBoxModal > Vertical {
693
+ width: 95%;
694
+ height: 90%;
695
+ background: $panel;
696
+ border: thick $primary;
697
+ padding: 0 1 1 1;
698
+ }
699
+
700
+ ExpandedBoxModal #expanded-title {
701
+ text-align: center;
702
+ text-style: bold;
703
+ height: auto;
704
+ width: 100%;
705
+ border-bottom: solid $primary;
706
+ padding: 0;
707
+ margin-bottom: 1;
708
+ }
709
+
710
+ ExpandedBoxModal VerticalScroll {
711
+ width: 100%;
712
+ height: 1fr;
713
+ }
714
+
715
+ ExpandedBoxModal #expanded-content {
716
+ width: 100%;
717
+ }
718
+
719
+ ExpandedBoxModal #expanded-graph {
720
+ width: 100%;
721
+ height: 1fr;
722
+ }
723
+ """
724
+
725
+ BINDINGS = [
726
+ ("escape,enter,q", "dismiss", "Close"),
727
+ ]
728
+
729
+ def __init__(
730
+ self, title: str, content_widget_id: str, file_path: str | None = None
731
+ ) -> None:
732
+ super().__init__()
733
+ self._title = title
734
+ self._content_widget_id = content_widget_id
735
+ self._file_path = file_path
736
+
737
+ def _read_file(self, file_path: str) -> str:
738
+ """Read file content, decoding as text if possible."""
739
+ if not os.path.isfile(file_path):
740
+ return "[dim]File not found[/dim]"
741
+ try:
742
+ with open(file_path, "rb") as f:
743
+ raw_content = f.read()
744
+ # Try to decode as text, fall back to hex display if binary
745
+ encoding, text = try_decode(raw_content)
746
+ if encoding is not None:
747
+ # Escape Rich markup to prevent interpretation of [ ] etc
748
+ return escape_markup(text)
749
+ return "[Binary content - hex display]\n\n" + raw_content.hex()
750
+ except OSError:
751
+ return "[red]Error reading file[/red]"
752
+
753
+ def compose(self) -> ComposeResult:
754
+ with Vertical():
755
+ yield Label(self._title, id="expanded-title")
756
+ if self._content_widget_id == "graph-container":
757
+ # For graph, create a new SizeGraph widget
758
+ yield SizeGraph(id="expanded-graph")
759
+ else:
760
+ # For other content, use a scrollable static
761
+ with VerticalScroll():
762
+ yield Static("", id="expanded-content")
763
+
764
+ def _get_graph_content(self, app: "ShrinkRayApp") -> None:
765
+ """Copy graph data from main graph to expanded graph."""
766
+ main_graphs = list(app.query("#size-graph").results(SizeGraph))
767
+ expanded_graphs = list(self.query("#expanded-graph").results(SizeGraph))
768
+ if not main_graphs or not expanded_graphs:
769
+ return
770
+ main_graph = main_graphs[0]
771
+ expanded_graph = expanded_graphs[0]
772
+ expanded_graph._size_history = main_graph._size_history.copy()
773
+ expanded_graph._original_size = main_graph._original_size
774
+ expanded_graph._current_runtime = main_graph._current_runtime
775
+ expanded_graph._setup_plot()
776
+
777
+ def _get_stats_content(self, app: "ShrinkRayApp") -> str:
778
+ """Get stats content from the stats display widget."""
779
+ stats_displays = list(app.query("#stats-display").results(StatsDisplay))
780
+ if not stats_displays:
781
+ return "Statistics not available"
782
+ return stats_displays[0].render()
783
+
784
+ def _get_file_content(self, app: "ShrinkRayApp") -> str:
785
+ """Get content from file or preview widget."""
786
+ if self._file_path:
787
+ return self._read_file(self._file_path)
788
+ content_previews = list(app.query("#content-preview").results(ContentPreview))
789
+ if not content_previews:
790
+ return "Content preview not available"
791
+ return content_previews[0].preview_content
792
+
793
+ def _get_output_content(self, app: "ShrinkRayApp") -> str:
794
+ """Get output content from the output preview widget."""
795
+ output_previews = list(app.query("#output-preview").results(OutputPreview))
796
+ if not output_previews:
797
+ return "Output not available"
798
+ output_preview = output_previews[0]
799
+
800
+ # Use pending values (most recent) rather than throttled values
801
+ raw_content = output_preview._pending_content or output_preview.output_content
802
+ test_id = (
803
+ output_preview._pending_test_id
804
+ if output_preview._pending_test_id is not None
805
+ else output_preview.active_test_id
806
+ )
807
+ return_code = (
808
+ output_preview._pending_return_code
809
+ if output_preview._pending_return_code is not None
810
+ else output_preview.last_return_code
811
+ )
812
+ has_seen_output = output_preview._has_seen_output
813
+
814
+ # Build header - return_code is None means test is still running
815
+ if test_id is not None and return_code is None:
816
+ header = f"[green]Test #{test_id} running...[/green]\n\n"
817
+ elif test_id is not None:
818
+ header = f"[dim]Test #{test_id} exited with code {return_code}[/dim]\n\n"
819
+ else:
820
+ header = ""
821
+
822
+ if raw_content:
823
+ return header + raw_content
824
+ elif has_seen_output or test_id is not None:
825
+ # We've seen output before - show header only (no "No test output" message)
826
+ return header.rstrip("\n") if header else ""
827
+ else:
828
+ return "[dim]No test output yet...[/dim]"
829
+
830
+ def on_mount(self) -> None:
831
+ """Populate content from the source widget."""
832
+ # Cast is safe because this modal is only used within ShrinkRayApp
833
+ app = cast("ShrinkRayApp", self.app)
834
+
835
+ if self._content_widget_id == "graph-container":
836
+ self._get_graph_content(app)
837
+ return
838
+
839
+ # For non-graph content, populate the static
840
+ # compose() always creates the #expanded-content widget for non-graph modals
841
+ if self._content_widget_id == "stats-container":
842
+ content = self._get_stats_content(app)
843
+ elif self._content_widget_id == "content-container":
844
+ content = self._get_file_content(app)
845
+ elif self._content_widget_id == "output-container":
846
+ content = self._get_output_content(app)
847
+ else:
848
+ content = ""
849
+
850
+ self.query_one("#expanded-content", Static).update(content)
851
+
852
+
427
853
  class PassStatsScreen(ModalScreen[None]):
428
854
  """Modal screen showing pass statistics in a table."""
429
855
 
@@ -639,19 +1065,453 @@ class PassStatsScreen(ModalScreen[None]):
639
1065
  self._app.push_screen(HelpScreen())
640
1066
 
641
1067
 
642
- class ShrinkRayApp(App[None]):
643
- """Textual app for Shrink Ray."""
1068
+ class HistoryExplorerModal(ModalScreen[None]):
1069
+ """Modal for browsing history reductions and also-interesting cases."""
644
1070
 
645
1071
  CSS = """
646
- #main-container {
1072
+ HistoryExplorerModal {
1073
+ align: center middle;
1074
+ }
1075
+
1076
+ HistoryExplorerModal > Vertical {
1077
+ width: 95%;
1078
+ height: 90%;
1079
+ background: $panel;
1080
+ border: thick $primary;
1081
+ }
1082
+
1083
+ HistoryExplorerModal #history-title {
1084
+ text-align: center;
1085
+ text-style: bold;
1086
+ height: auto;
1087
+ width: 100%;
1088
+ padding: 0 1;
1089
+ border-bottom: solid $primary;
1090
+ }
1091
+
1092
+ HistoryExplorerModal TabbedContent {
1093
+ height: 1fr;
1094
+ }
1095
+
1096
+ HistoryExplorerModal #history-content,
1097
+ HistoryExplorerModal #also-interesting-content {
1098
+ height: 1fr;
1099
+ }
1100
+
1101
+ HistoryExplorerModal #history-list-container,
1102
+ HistoryExplorerModal #also-interesting-list-container {
1103
+ width: 30%;
647
1104
  height: 100%;
1105
+ border-right: solid $primary;
648
1106
  }
649
1107
 
650
- #stats-container {
1108
+ HistoryExplorerModal ListView {
1109
+ height: 100%;
1110
+ }
1111
+
1112
+ HistoryExplorerModal #history-preview-container,
1113
+ HistoryExplorerModal #also-interesting-preview-container {
1114
+ width: 70%;
1115
+ height: 100%;
1116
+ padding: 0 1;
1117
+ }
1118
+
1119
+ HistoryExplorerModal #file-content-label,
1120
+ HistoryExplorerModal #also-file-label {
1121
+ text-style: bold;
651
1122
  height: auto;
652
- border: solid green;
1123
+ margin-top: 1;
1124
+ }
1125
+
1126
+ HistoryExplorerModal #file-content,
1127
+ HistoryExplorerModal #also-file-content {
1128
+ height: 1fr;
1129
+ border: solid $secondary;
653
1130
  padding: 1;
654
- margin: 1;
1131
+ }
1132
+
1133
+ HistoryExplorerModal #output-label,
1134
+ HistoryExplorerModal #also-output-label {
1135
+ text-style: bold;
1136
+ height: auto;
1137
+ margin-top: 1;
1138
+ }
1139
+
1140
+ HistoryExplorerModal #output-content,
1141
+ HistoryExplorerModal #also-output-content {
1142
+ height: 1fr;
1143
+ border: solid $secondary;
1144
+ padding: 1;
1145
+ }
1146
+
1147
+ HistoryExplorerModal #history-footer {
1148
+ dock: bottom;
1149
+ height: auto;
1150
+ padding: 0 1;
1151
+ text-align: center;
1152
+ border-top: solid $primary;
1153
+ }
1154
+ """
1155
+
1156
+ BINDINGS = [
1157
+ ("escape,q,x", "dismiss", "Close"),
1158
+ ("r", "restart_from_here", "Restart from here"),
1159
+ ]
1160
+
1161
+ def __init__(self, history_dir: str, target_basename: str) -> None:
1162
+ super().__init__()
1163
+ self._history_dir = history_dir
1164
+ self._target_basename = target_basename
1165
+ self._reductions_entries: list[str] = [] # List of entry paths
1166
+ self._also_interesting_entries: list[str] = []
1167
+ self._preview_timer: Timer | None = None
1168
+ self._pending_preview: tuple[str, str, str] | None = None
1169
+ self._refresh_timer: Timer | None = None
1170
+ # Track selected entry by path (more robust than by index)
1171
+ self._selected_reductions_path: str | None = None
1172
+ self._selected_also_interesting_path: str | None = None
1173
+ # Guard against updating selection during refresh (clear/append triggers
1174
+ # Highlighted events that would overwrite the saved selection path)
1175
+ self._refreshing: bool = False
1176
+
1177
+ def compose(self) -> ComposeResult:
1178
+ with Vertical():
1179
+ yield Label("History Explorer", id="history-title")
1180
+ with TabbedContent(id="history-tabs"):
1181
+ with TabPane("Reductions", id="reductions-tab"):
1182
+ with Horizontal(id="history-content"):
1183
+ with Vertical(id="history-list-container"):
1184
+ yield ListView(id="reductions-list")
1185
+ with Vertical(id="history-preview-container"):
1186
+ yield Label("File Content:", id="file-content-label")
1187
+ with VerticalScroll(id="file-content"):
1188
+ yield Static("", id="file-preview")
1189
+ yield Label("Test Output:", id="output-label")
1190
+ with VerticalScroll(id="output-content"):
1191
+ yield Static("", id="output-preview")
1192
+ with TabPane("Also-Interesting", id="also-interesting-tab"):
1193
+ with Horizontal(id="also-interesting-content"):
1194
+ with Vertical(id="also-interesting-list-container"):
1195
+ yield ListView(id="also-interesting-list")
1196
+ with Vertical(id="also-interesting-preview-container"):
1197
+ yield Label("File Content:", id="also-file-label")
1198
+ with VerticalScroll(id="also-file-content"):
1199
+ yield Static("", id="also-file-preview")
1200
+ yield Label("Test Output:", id="also-output-label")
1201
+ with VerticalScroll(id="also-output-content"):
1202
+ yield Static("", id="also-output-preview")
1203
+ yield Static(
1204
+ "↑/↓: Navigate Tab: Switch r: Restart from here Esc/q/x: Close",
1205
+ id="history-footer",
1206
+ )
1207
+
1208
+ def on_mount(self) -> None:
1209
+ """Populate the lists with history entries."""
1210
+ self._populate_list("reductions", "reductions-list")
1211
+ self._populate_list("also-interesting", "also-interesting-list")
1212
+
1213
+ # Focus the reductions list so arrow keys work immediately
1214
+ self.query_one("#reductions-list", ListView).focus()
1215
+
1216
+ # Start periodic refresh of the lists
1217
+ self._refresh_timer = self.set_interval(1.0, self._refresh_lists)
1218
+
1219
+ def on_unmount(self) -> None:
1220
+ """Clean up timers when modal is closed."""
1221
+ if self._preview_timer is not None:
1222
+ self._preview_timer.stop()
1223
+ if self._refresh_timer is not None:
1224
+ self._refresh_timer.stop()
1225
+
1226
+ def _refresh_lists(self) -> None:
1227
+ """Refresh the history lists to show new entries."""
1228
+ self._refresh_list("reductions", "reductions-list")
1229
+ self._refresh_list("also-interesting", "also-interesting-list")
1230
+
1231
+ def _refresh_list(self, subdir: str, list_id: str) -> None:
1232
+ """Refresh a single list, preserving selection.
1233
+
1234
+ This uses an incremental update strategy: only add new entries rather
1235
+ than clearing and repopulating. This preserves ListView selection
1236
+ naturally without fighting async DOM updates.
1237
+ """
1238
+ entries = self._scan_entries(subdir)
1239
+ list_view = self.query_one(f"#{list_id}", ListView)
1240
+
1241
+ # Get current entries for comparison
1242
+ if subdir == "reductions":
1243
+ old_entries = self._reductions_entries
1244
+ else:
1245
+ old_entries = self._also_interesting_entries
1246
+
1247
+ new_entries = [e[1] for e in entries]
1248
+
1249
+ # Only update if entries changed
1250
+ if new_entries == old_entries:
1251
+ return
1252
+
1253
+ # Check if this is purely additive (common case: new reductions added)
1254
+ # In this case, we can just append the new items without touching selection
1255
+ if old_entries and new_entries[: len(old_entries)] == old_entries:
1256
+ # New entries were added at the end - just append them
1257
+ new_items = entries[len(old_entries) :]
1258
+ for entry_num, _, size in new_items:
1259
+ size_str = humanize.naturalsize(size, binary=True)
1260
+ list_view.append(ListItem(Label(f"{entry_num} ({size_str})")))
1261
+
1262
+ # Update stored entries
1263
+ if subdir == "reductions":
1264
+ self._reductions_entries = new_entries
1265
+ else:
1266
+ self._also_interesting_entries = new_entries
1267
+ return
1268
+
1269
+ # Entries changed in a non-additive way (items removed or reordered).
1270
+ # This happens during restart-from-point. Do a full rebuild.
1271
+ selected_path = (
1272
+ self._selected_reductions_path
1273
+ if subdir == "reductions"
1274
+ else self._selected_also_interesting_path
1275
+ )
1276
+
1277
+ # Store new entries
1278
+ if subdir == "reductions":
1279
+ self._reductions_entries = new_entries
1280
+ else:
1281
+ self._also_interesting_entries = new_entries
1282
+
1283
+ # Find the index of the previously selected path in the new entries
1284
+ new_index: int | None = None
1285
+ if selected_path is not None and selected_path in new_entries:
1286
+ new_index = new_entries.index(selected_path)
1287
+
1288
+ # Guard against Highlighted events during clear/repopulate
1289
+ self._refreshing = True
1290
+
1291
+ # Clear and repopulate
1292
+ list_view.clear()
1293
+
1294
+ if not entries:
1295
+ list_view.append(ListItem(Label("[dim]No entries yet[/dim]")))
1296
+ self.call_after_refresh(self._finish_refresh)
1297
+ return
1298
+
1299
+ for entry_num, _, size in entries:
1300
+ size_str = humanize.naturalsize(size, binary=True)
1301
+ list_view.append(ListItem(Label(f"{entry_num} ({size_str})")))
1302
+
1303
+ # Restore selection after DOM updates complete
1304
+ if new_index is not None:
1305
+ self.call_after_refresh(self._restore_list_selection, list_view, new_index)
1306
+ else:
1307
+ self.call_after_refresh(self._finish_refresh)
1308
+
1309
+ def _finish_refresh(self) -> None:
1310
+ """Mark refresh as complete, allowing selection tracking to resume."""
1311
+ self._refreshing = False
1312
+
1313
+ def _restore_list_selection(self, list_view: ListView, index: int) -> None:
1314
+ """Restore selection to a list view after async DOM updates."""
1315
+ child_count = len(list_view.children)
1316
+ if child_count > 0:
1317
+ list_view.index = min(index, child_count - 1)
1318
+ self._refreshing = False
1319
+
1320
+ def _scan_entries(self, subdir: str) -> list[tuple[str, str, int]]:
1321
+ """Scan a history subdirectory for entries.
1322
+
1323
+ Returns list of (entry_number, entry_path, file_size) tuples, sorted by number.
1324
+ """
1325
+ entries = []
1326
+ dir_path = os.path.join(self._history_dir, subdir)
1327
+ if not os.path.isdir(dir_path):
1328
+ return entries
1329
+
1330
+ for entry_name in os.listdir(dir_path):
1331
+ entry_path = os.path.join(dir_path, entry_name)
1332
+ if os.path.isdir(entry_path):
1333
+ # Get file size
1334
+ file_path = os.path.join(entry_path, self._target_basename)
1335
+ if os.path.isfile(file_path):
1336
+ size = os.path.getsize(file_path)
1337
+ entries.append((entry_name, entry_path, size))
1338
+
1339
+ # Sort by entry number
1340
+ entries.sort(key=lambda x: x[0])
1341
+ return entries
1342
+
1343
+ def _populate_list(self, subdir: str, list_id: str) -> None:
1344
+ """Populate a ListView with entries from a history subdirectory."""
1345
+ entries = self._scan_entries(subdir)
1346
+ list_view = self.query_one(f"#{list_id}", ListView)
1347
+
1348
+ entry_paths = [e[1] for e in entries]
1349
+ if subdir == "reductions":
1350
+ self._reductions_entries = entry_paths
1351
+ else:
1352
+ self._also_interesting_entries = entry_paths
1353
+
1354
+ if not entries:
1355
+ list_view.append(ListItem(Label("[dim]No entries yet[/dim]")))
1356
+ return
1357
+
1358
+ for entry_num, _, size in entries:
1359
+ size_str = humanize.naturalsize(size, binary=True)
1360
+ # Don't use IDs - they conflict with refresh operations
1361
+ list_view.append(ListItem(Label(f"{entry_num} ({size_str})")))
1362
+
1363
+ # Select first item and track its path
1364
+ list_view.index = 0
1365
+ if subdir == "reductions":
1366
+ self._selected_reductions_path = entry_paths[0]
1367
+ else:
1368
+ self._selected_also_interesting_path = entry_paths[0]
1369
+
1370
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
1371
+ """Handle selection in a ListView."""
1372
+ list_view = event.list_view
1373
+
1374
+ # Determine which list was selected
1375
+ if list_view.id == "reductions-list":
1376
+ entries = self._reductions_entries
1377
+ file_preview_id = "file-preview"
1378
+ output_preview_id = "output-preview"
1379
+ else:
1380
+ entries = self._also_interesting_entries
1381
+ file_preview_id = "also-file-preview"
1382
+ output_preview_id = "also-output-preview"
1383
+
1384
+ # Get the selected entry path
1385
+ if not entries or list_view.index is None or list_view.index >= len(entries):
1386
+ return
1387
+
1388
+ entry_path = entries[list_view.index]
1389
+ self._update_preview(entry_path, file_preview_id, output_preview_id)
1390
+
1391
+ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
1392
+ """Handle highlighting (cursor movement) in a ListView."""
1393
+ # During refresh, clear/append trigger Highlighted events that would
1394
+ # overwrite the saved selection path. Skip updating selection tracking
1395
+ # during refresh - the selection will be restored by _restore_list_selection.
1396
+ if self._refreshing:
1397
+ return
1398
+
1399
+ list_view = event.list_view
1400
+
1401
+ # Determine which list was highlighted
1402
+ if list_view.id == "reductions-list":
1403
+ entries = self._reductions_entries
1404
+ file_preview_id = "file-preview"
1405
+ output_preview_id = "output-preview"
1406
+ else:
1407
+ entries = self._also_interesting_entries
1408
+ file_preview_id = "also-file-preview"
1409
+ output_preview_id = "also-output-preview"
1410
+
1411
+ # Get the highlighted entry path
1412
+ if not entries or list_view.index is None or list_view.index >= len(entries):
1413
+ return
1414
+
1415
+ entry_path = entries[list_view.index]
1416
+
1417
+ # Track the selected path for restoration after refresh
1418
+ if list_view.id == "reductions-list":
1419
+ self._selected_reductions_path = entry_path
1420
+ else:
1421
+ self._selected_also_interesting_path = entry_path
1422
+
1423
+ # Debounce preview updates to avoid lag when navigating quickly
1424
+ self._pending_preview = (entry_path, file_preview_id, output_preview_id)
1425
+ if self._preview_timer is not None:
1426
+ self._preview_timer.stop()
1427
+ self._preview_timer = self.set_timer(0.05, self._do_pending_preview)
1428
+
1429
+ def _do_pending_preview(self) -> None:
1430
+ """Execute the pending preview update."""
1431
+ if self._pending_preview is not None:
1432
+ entry_path, file_preview_id, output_preview_id = self._pending_preview
1433
+ self._pending_preview = None
1434
+ self._update_preview(entry_path, file_preview_id, output_preview_id)
1435
+
1436
+ def _update_preview(
1437
+ self, entry_path: str, file_preview_id: str, output_preview_id: str
1438
+ ) -> None:
1439
+ """Update the preview pane with content from the selected entry."""
1440
+ # Read file content
1441
+ file_path = os.path.join(entry_path, self._target_basename)
1442
+ file_content = self._read_file(file_path)
1443
+ self.query_one(f"#{file_preview_id}", Static).update(file_content)
1444
+
1445
+ # Read output content
1446
+ output_path = os.path.join(entry_path, f"{self._target_basename}.out")
1447
+ if os.path.isfile(output_path):
1448
+ output_content = self._read_file(output_path)
1449
+ else:
1450
+ output_content = "[dim]No output captured[/dim]"
1451
+ self.query_one(f"#{output_preview_id}", Static).update(output_content)
1452
+
1453
+ def _read_file(self, file_path: str) -> str:
1454
+ """Read file content, decoding as text if possible."""
1455
+ if not os.path.isfile(file_path):
1456
+ return "[dim]File not found[/dim]"
1457
+ try:
1458
+ with open(file_path, "rb") as f:
1459
+ raw_content = f.read()
1460
+ # Truncate large files
1461
+ max_size = 50000
1462
+ truncated = len(raw_content) > max_size
1463
+ if truncated:
1464
+ raw_content = raw_content[:max_size]
1465
+ # Try to decode as text
1466
+ encoding, text = try_decode(raw_content)
1467
+ if encoding is not None:
1468
+ # Escape Rich markup to prevent interpretation of [ ] etc
1469
+ text = escape_markup(text)
1470
+ if truncated:
1471
+ text += "\n\n[dim]... (truncated)[/dim]"
1472
+ return text
1473
+ # Binary content - hex display
1474
+ hex_display = "[Binary content - hex display]\n\n" + raw_content.hex()
1475
+ if truncated:
1476
+ hex_display += "\n\n[dim]... (truncated)[/dim]"
1477
+ return hex_display
1478
+ except OSError:
1479
+ return "[red]Error reading file[/red]"
1480
+
1481
+ def action_restart_from_here(self) -> None:
1482
+ """Restart reduction from the currently selected history point."""
1483
+ # Only works in Reductions tab
1484
+ tabs = self.query_one("#history-tabs", TabbedContent)
1485
+ if tabs.active != "reductions-tab":
1486
+ app = cast("ShrinkRayApp", self.app)
1487
+ app.notify("Restart only available in Reductions tab", severity="warning")
1488
+ return
1489
+
1490
+ # Get the selected reduction number
1491
+ list_view = self.query_one("#reductions-list", ListView)
1492
+ if list_view.index is None or list_view.index >= len(self._reductions_entries):
1493
+ app = cast("ShrinkRayApp", self.app)
1494
+ app.notify("No reduction selected", severity="warning")
1495
+ return
1496
+
1497
+ entry_path = self._reductions_entries[list_view.index]
1498
+ # Extract number from path (e.g., ".../reductions/0003" -> 3)
1499
+ reduction_number = int(os.path.basename(entry_path))
1500
+
1501
+ # Trigger restart via app
1502
+ app = cast("ShrinkRayApp", self.app)
1503
+ app._trigger_restart_from(reduction_number)
1504
+
1505
+ # Close modal
1506
+ self.dismiss()
1507
+
1508
+
1509
+ class ShrinkRayApp(App[None]):
1510
+ """Textual app for Shrink Ray."""
1511
+
1512
+ CSS = """
1513
+ #main-container {
1514
+ height: 100%;
655
1515
  }
656
1516
 
657
1517
  #status-label {
@@ -659,32 +1519,65 @@ class ShrinkRayApp(App[None]):
659
1519
  margin: 0 1;
660
1520
  }
661
1521
 
1522
+ #stats-area {
1523
+ height: 1fr;
1524
+ }
1525
+
1526
+ #stats-container {
1527
+ border: solid $primary;
1528
+ margin: 0;
1529
+ padding: 1;
1530
+ width: 1fr;
1531
+ height: 100%;
1532
+ }
1533
+
1534
+ #stats-container:focus {
1535
+ border: thick $primary;
1536
+ }
1537
+
1538
+ #graph-container {
1539
+ border: solid $primary;
1540
+ margin: 0;
1541
+ padding: 1;
1542
+ width: 1fr;
1543
+ height: 100%;
1544
+ }
1545
+
1546
+ #graph-container:focus {
1547
+ border: thick $primary;
1548
+ }
1549
+
1550
+ #size-graph {
1551
+ width: 100%;
1552
+ height: 100%;
1553
+ }
1554
+
662
1555
  #content-area {
663
1556
  height: 1fr;
664
1557
  }
665
1558
 
666
1559
  #content-container {
667
- border: solid blue;
668
- margin: 1;
1560
+ border: solid $primary;
1561
+ margin: 0;
669
1562
  padding: 1;
670
1563
  width: 1fr;
671
1564
  height: 100%;
672
1565
  }
673
1566
 
674
- #content-container:dark {
675
- border: solid lightskyblue;
1567
+ #content-container:focus {
1568
+ border: thick $primary;
676
1569
  }
677
1570
 
678
1571
  #output-container {
679
- border: solid blue;
680
- margin: 1;
1572
+ border: solid $primary;
1573
+ margin: 0;
681
1574
  padding: 1;
682
1575
  width: 1fr;
683
1576
  height: 100%;
684
1577
  }
685
1578
 
686
- #output-container:dark {
687
- border: solid lightskyblue;
1579
+ #output-container:focus {
1580
+ border: thick $primary;
688
1581
  }
689
1582
  """
690
1583
 
@@ -692,7 +1585,13 @@ class ShrinkRayApp(App[None]):
692
1585
  ("q", "quit", "Quit"),
693
1586
  ("p", "show_pass_stats", "Pass Stats"),
694
1587
  ("c", "skip_current_pass", "Skip Pass"),
1588
+ ("x", "show_history", "History"),
695
1589
  ("h", "show_help", "Help"),
1590
+ ("up", "focus_up", "Focus Up"),
1591
+ ("down", "focus_down", "Focus Down"),
1592
+ ("left", "focus_left", "Focus Left"),
1593
+ ("right", "focus_right", "Focus Right"),
1594
+ ("enter", "expand_box", "Expand"),
696
1595
  ]
697
1596
 
698
1597
  ENABLE_COMMAND_PALETTE = False
@@ -714,6 +1613,8 @@ class ShrinkRayApp(App[None]):
714
1613
  exit_on_completion: bool = True,
715
1614
  client: ReductionClientProtocol | None = None,
716
1615
  theme: ThemeMode = "auto",
1616
+ history_enabled: bool = True,
1617
+ also_interesting_code: int | None = None,
717
1618
  ) -> None:
718
1619
  super().__init__()
719
1620
  self._file_path = file_path
@@ -733,26 +1634,48 @@ class ShrinkRayApp(App[None]):
733
1634
  self._owns_client = client is None
734
1635
  self._completed = False
735
1636
  self._theme = theme
1637
+ self._history_enabled = history_enabled
1638
+ self._also_interesting_code = also_interesting_code
736
1639
  self._latest_pass_stats: list[PassStatsData] = []
737
1640
  self._current_pass_name: str = ""
738
1641
  self._disabled_passes: list[str] = []
1642
+ # History explorer state
1643
+ self._history_dir: str | None = None
1644
+ self._target_basename: str = ""
1645
+
1646
+ # Box IDs in navigation order: [top-left, top-right, bottom-left, bottom-right]
1647
+ _BOX_IDS = [
1648
+ "stats-container",
1649
+ "graph-container",
1650
+ "content-container",
1651
+ "output-container",
1652
+ ]
739
1653
 
740
1654
  def compose(self) -> ComposeResult:
741
1655
  yield Header()
742
1656
  with Vertical(id="main-container"):
743
1657
  yield Label(
744
- "Shrink Ray - [h] help, [p] passes, [c] skip pass, [q] quit",
1658
+ "Shrink Ray - [h] help, [p] passes, [x] history, [c] skip, [q] quit",
745
1659
  id="status-label",
746
1660
  markup=False,
747
1661
  )
748
- with Vertical(id="stats-container"):
749
- yield StatsDisplay(id="stats-display")
1662
+ with Horizontal(id="stats-area"):
1663
+ with VerticalScroll(id="stats-container") as stats_scroll:
1664
+ stats_scroll.border_title = "Statistics"
1665
+ stats_scroll.can_focus = True
1666
+ yield StatsDisplay(id="stats-display")
1667
+ with Vertical(id="graph-container") as graph_container:
1668
+ graph_container.border_title = "Size Over Time"
1669
+ graph_container.can_focus = True
1670
+ yield SizeGraph(id="size-graph")
750
1671
  with Horizontal(id="content-area"):
751
1672
  with VerticalScroll(id="content-container") as content_scroll:
752
1673
  content_scroll.border_title = "Recent Reductions"
1674
+ content_scroll.can_focus = True
753
1675
  yield ContentPreview(id="content-preview")
754
1676
  with VerticalScroll(id="output-container") as output_scroll:
755
1677
  output_scroll.border_title = "Test Output"
1678
+ output_scroll.can_focus = True
756
1679
  yield OutputPreview(id="output-preview")
757
1680
  yield Footer()
758
1681
 
@@ -772,8 +1695,81 @@ class ShrinkRayApp(App[None]):
772
1695
 
773
1696
  self.title = "Shrink Ray"
774
1697
  self.sub_title = self._file_path
1698
+
1699
+ # Set initial focus to first box
1700
+ self.query_one("#stats-container").focus()
1701
+
775
1702
  self.run_reduction()
776
1703
 
1704
+ def _get_focused_box_index(self) -> int:
1705
+ """Get the index of the currently focused box, or 0 if none."""
1706
+ for i, box_id in enumerate(self._BOX_IDS):
1707
+ boxes = list(self.query(f"#{box_id}"))
1708
+ if boxes and boxes[0].has_focus:
1709
+ return i
1710
+ return 0
1711
+
1712
+ def _focus_box(self, index: int) -> None:
1713
+ """Focus the box at the given index (with wrapping)."""
1714
+ index = index % len(self._BOX_IDS)
1715
+ box_id = self._BOX_IDS[index]
1716
+ self.query_one(f"#{box_id}").focus()
1717
+
1718
+ def action_focus_up(self) -> None:
1719
+ """Move focus to the box above."""
1720
+ current = self._get_focused_box_index()
1721
+ # Grid is 2x2: top row is 0,1; bottom row is 2,3
1722
+ # Moving up: 2->0, 3->1, 0->2, 1->3 (wraps)
1723
+ if current >= 2:
1724
+ self._focus_box(current - 2)
1725
+ else:
1726
+ self._focus_box(current + 2)
1727
+
1728
+ def action_focus_down(self) -> None:
1729
+ """Move focus to the box below."""
1730
+ current = self._get_focused_box_index()
1731
+ # Moving down: 0->2, 1->3, 2->0, 3->1 (wraps)
1732
+ if current < 2:
1733
+ self._focus_box(current + 2)
1734
+ else:
1735
+ self._focus_box(current - 2)
1736
+
1737
+ def action_focus_left(self) -> None:
1738
+ """Move focus to the box on the left."""
1739
+ current = self._get_focused_box_index()
1740
+ # Moving left within row: 0->1, 1->0, 2->3, 3->2 (wraps within row)
1741
+ if current % 2 == 0:
1742
+ self._focus_box(current + 1)
1743
+ else:
1744
+ self._focus_box(current - 1)
1745
+
1746
+ def action_focus_right(self) -> None:
1747
+ """Move focus to the box on the right."""
1748
+ current = self._get_focused_box_index()
1749
+ # Moving right within row: 0->1, 1->0, 2->3, 3->2 (wraps within row)
1750
+ if current % 2 == 0:
1751
+ self._focus_box(current + 1)
1752
+ else:
1753
+ self._focus_box(current - 1)
1754
+
1755
+ def action_expand_box(self) -> None:
1756
+ """Expand the currently focused box to a modal."""
1757
+ current = self._get_focused_box_index()
1758
+ box_id = self._BOX_IDS[current]
1759
+
1760
+ # Get the title from the container's border_title
1761
+ titles = {
1762
+ "stats-container": "Statistics",
1763
+ "graph-container": "Size Over Time",
1764
+ "content-container": "Current Test Case",
1765
+ "output-container": "Test Output",
1766
+ }
1767
+ title = titles.get(box_id, "Details")
1768
+
1769
+ # Pass file_path for content-container to enable full file reading
1770
+ file_path = self._file_path if box_id == "content-container" else None
1771
+ self.push_screen(ExpandedBoxModal(title, box_id, file_path=file_path))
1772
+
777
1773
  @work(exclusive=True)
778
1774
  async def run_reduction(self) -> None:
779
1775
  """Start the reduction subprocess and monitor progress."""
@@ -801,6 +1797,8 @@ class ShrinkRayApp(App[None]):
801
1797
  clang_delta=self._clang_delta,
802
1798
  trivial_is_error=self._trivial_is_error,
803
1799
  skip_validation=True,
1800
+ history_enabled=self._history_enabled,
1801
+ also_interesting_code=self._also_interesting_code,
804
1802
  )
805
1803
 
806
1804
  if response.error:
@@ -812,6 +1810,7 @@ class ShrinkRayApp(App[None]):
812
1810
  stats_display = self.query_one("#stats-display", StatsDisplay)
813
1811
  content_preview = self.query_one("#content-preview", ContentPreview)
814
1812
  output_preview = self.query_one("#output-preview", OutputPreview)
1813
+ size_graph = self.query_one("#size-graph", SizeGraph)
815
1814
 
816
1815
  async with aclosing(self._client.get_progress_updates()) as updates:
817
1816
  async for update in updates:
@@ -820,11 +1819,29 @@ class ShrinkRayApp(App[None]):
820
1819
  update.content_preview, update.hex_mode
821
1820
  )
822
1821
  output_preview.update_output(
823
- update.test_output_preview, update.active_test_id
1822
+ update.test_output_preview,
1823
+ update.active_test_id,
1824
+ update.last_test_return_code,
1825
+ )
1826
+ size_graph.update_graph(
1827
+ update.new_size_history,
1828
+ update.original_size,
1829
+ update.runtime,
824
1830
  )
1831
+ # Also update expanded modals if they exist
1832
+ self._update_expanded_graph(
1833
+ update.new_size_history,
1834
+ update.original_size,
1835
+ update.runtime,
1836
+ )
1837
+ self._update_expanded_stats()
825
1838
  self._latest_pass_stats = update.pass_stats
826
1839
  self._current_pass_name = update.current_pass_name
827
1840
  self._disabled_passes = update.disabled_passes
1841
+ # Update history info for history explorer
1842
+ if update.history_dir is not None:
1843
+ self._history_dir = update.history_dir
1844
+ self._target_basename = update.target_basename
828
1845
 
829
1846
  # Check if all passes are disabled
830
1847
  self._check_all_passes_disabled()
@@ -847,9 +1864,10 @@ class ShrinkRayApp(App[None]):
847
1864
  else:
848
1865
  self.update_status("Reduction completed! Press 'q' to exit.")
849
1866
 
850
- except Exception as e:
1867
+ except Exception:
851
1868
  traceback.print_exc()
852
- self.exit(return_code=1, message=f"Error: {e}")
1869
+ # Include full traceback in error message in case stderr isn't visible
1870
+ self.exit(return_code=1, message=f"Error:\n{traceback.format_exc()}")
853
1871
  finally:
854
1872
  if self._owns_client and self._client:
855
1873
  await self._client.close()
@@ -870,6 +1888,41 @@ class ShrinkRayApp(App[None]):
870
1888
  except Exception:
871
1889
  pass # Widget not yet mounted
872
1890
 
1891
+ def _update_expanded_graph(
1892
+ self,
1893
+ new_entries: list[tuple[float, int]],
1894
+ original_size: int,
1895
+ current_runtime: float,
1896
+ ) -> None:
1897
+ """Update the expanded graph if it exists in a modal screen."""
1898
+ # Check if there's an ExpandedBoxModal for the graph on the screen stack
1899
+ for screen in self.screen_stack:
1900
+ if isinstance(screen, ExpandedBoxModal):
1901
+ if screen._content_widget_id == "graph-container":
1902
+ expanded_graphs = list(
1903
+ screen.query("#expanded-graph").results(SizeGraph)
1904
+ )
1905
+ if expanded_graphs:
1906
+ expanded_graphs[0].update_graph(
1907
+ new_entries, original_size, current_runtime
1908
+ )
1909
+ break
1910
+
1911
+ def _update_expanded_stats(self) -> None:
1912
+ """Update the expanded stats if it exists in a modal screen."""
1913
+ for screen in self.screen_stack:
1914
+ if isinstance(screen, ExpandedBoxModal):
1915
+ if screen._content_widget_id == "stats-container":
1916
+ stats_displays = list(
1917
+ self.query("#stats-display").results(StatsDisplay)
1918
+ )
1919
+ expanded_contents = list(
1920
+ screen.query("#expanded-content").results(Static)
1921
+ )
1922
+ if stats_displays and expanded_contents:
1923
+ expanded_contents[0].update(stats_displays[0].render())
1924
+ break
1925
+
873
1926
  async def action_quit(self) -> None:
874
1927
  """Quit the application with graceful cancellation."""
875
1928
  if self._client and not self._completed:
@@ -887,6 +1940,13 @@ class ShrinkRayApp(App[None]):
887
1940
  """Show the help modal."""
888
1941
  self.push_screen(HelpScreen())
889
1942
 
1943
+ def action_show_history(self) -> None:
1944
+ """Show the history explorer modal."""
1945
+ if self._history_dir is None:
1946
+ self.notify("History not available", severity="warning")
1947
+ return
1948
+ self.push_screen(HistoryExplorerModal(self._history_dir, self._target_basename))
1949
+
890
1950
  def action_skip_current_pass(self) -> None:
891
1951
  """Skip the currently running pass."""
892
1952
  if self._client and not self._completed:
@@ -897,6 +1957,25 @@ class ShrinkRayApp(App[None]):
897
1957
  if self._client is not None:
898
1958
  await self._client.skip_current_pass()
899
1959
 
1960
+ def _trigger_restart_from(self, reduction_number: int) -> None:
1961
+ """Trigger restart from a specific reduction point."""
1962
+ if self._client and not self._completed:
1963
+ self.run_worker(self._do_restart_from(reduction_number))
1964
+
1965
+ async def _do_restart_from(self, reduction_number: int) -> None:
1966
+ """Execute restart command."""
1967
+ if self._client is None:
1968
+ self.notify("No client available", severity="error")
1969
+ return
1970
+ response = await self._client.restart_from(reduction_number)
1971
+ if response.error:
1972
+ self.notify(f"Restart failed: {response.error}", severity="error")
1973
+ else:
1974
+ self.notify(
1975
+ f"Restarted from reduction {reduction_number:04d}",
1976
+ severity="information",
1977
+ )
1978
+
900
1979
  @property
901
1980
  def is_completed(self) -> bool:
902
1981
  """Check if reduction is completed."""
@@ -918,14 +1997,14 @@ def run_textual_ui(
918
1997
  trivial_is_error: bool = True,
919
1998
  exit_on_completion: bool = True,
920
1999
  theme: ThemeMode = "auto",
2000
+ history_enabled: bool = True,
2001
+ also_interesting_code: int | None = None,
921
2002
  ) -> None:
922
2003
  """Run the textual TUI.
923
2004
 
924
2005
  Note: Validation must be done before calling this function.
925
2006
  The caller (main()) is responsible for running run_validation() first.
926
2007
  """
927
- import sys
928
-
929
2008
  # Start the TUI app - validation has already been done by main()
930
2009
  app = ShrinkRayApp(
931
2010
  file_path=file_path,
@@ -942,6 +2021,8 @@ def run_textual_ui(
942
2021
  trivial_is_error=trivial_is_error,
943
2022
  exit_on_completion=exit_on_completion,
944
2023
  theme=theme,
2024
+ history_enabled=history_enabled,
2025
+ also_interesting_code=also_interesting_code,
945
2026
  )
946
2027
  app.run()
947
2028
  if app.return_code: