shrinkray 25.12.29.0__py3-none-any.whl → 26.2.4.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
@@ -2,11 +2,14 @@
2
2
 
3
3
  import math
4
4
  import os
5
+ import subprocess
6
+ import sys
5
7
  import time
6
8
  import traceback
7
9
  from collections.abc import AsyncGenerator
8
10
  from contextlib import aclosing
9
11
  from datetime import timedelta
12
+ from difflib import unified_diff
10
13
  from typing import Literal, Protocol, cast
11
14
 
12
15
  import humanize
@@ -17,7 +20,18 @@ from textual.containers import Horizontal, Vertical, VerticalScroll
17
20
  from textual.reactive import reactive
18
21
  from textual.screen import ModalScreen
19
22
  from textual.theme import Theme
20
- from textual.widgets import DataTable, Footer, Header, Label, Static
23
+ from textual.timer import Timer
24
+ from textual.widgets import (
25
+ DataTable,
26
+ Footer,
27
+ Header,
28
+ Label,
29
+ ListItem,
30
+ ListView,
31
+ Static,
32
+ TabbedContent,
33
+ TabPane,
34
+ )
21
35
  from textual_plotext import PlotextPlot
22
36
 
23
37
  from shrinkray.formatting import try_decode
@@ -31,6 +45,7 @@ from shrinkray.subprocess.protocol import (
31
45
 
32
46
  ThemeMode = Literal["auto", "dark", "light"]
33
47
 
48
+
34
49
  # Custom themes with true white/black backgrounds
35
50
  SHRINKRAY_LIGHT_THEME = Theme(
36
51
  name="shrinkray-light",
@@ -78,8 +93,6 @@ def detect_terminal_theme() -> bool:
78
93
  apple_interface = os.environ.get("__CFBundleIdentifier", "")
79
94
  if not apple_interface:
80
95
  try:
81
- import subprocess
82
-
83
96
  result = subprocess.run(
84
97
  ["defaults", "read", "-g", "AppleInterfaceStyle"],
85
98
  capture_output=True,
@@ -115,11 +128,15 @@ class ReductionClientProtocol(Protocol):
115
128
  no_clang_delta: bool = False,
116
129
  clang_delta: str = "",
117
130
  trivial_is_error: bool = True,
131
+ skip_validation: bool = False,
132
+ history_enabled: bool = True,
133
+ also_interesting_code: int | None = None,
118
134
  ) -> Response: ...
119
135
  async def cancel(self) -> Response: ...
120
136
  async def disable_pass(self, pass_name: str) -> Response: ...
121
137
  async def enable_pass(self, pass_name: str) -> Response: ...
122
138
  async def skip_current_pass(self) -> Response: ...
139
+ async def restart_from(self, reduction_number: int) -> Response: ...
123
140
  async def close(self) -> None: ...
124
141
 
125
142
  @property
@@ -488,37 +505,35 @@ class ContentPreview(Static):
488
505
  # Fallback based on common terminal height
489
506
  return 30
490
507
 
491
- def render(self) -> str:
508
+ def render(self) -> Text:
492
509
  if not self.preview_content:
493
- return "Loading..."
510
+ return Text("Loading...")
494
511
 
495
512
  available_lines = self._get_available_lines()
496
513
 
497
514
  if self.hex_mode:
498
- return f"[Hex mode]\n{self.preview_content}"
515
+ return Text(f"[Hex mode]\n{self.preview_content}")
499
516
 
500
517
  lines = self.preview_content.split("\n")
501
518
 
502
519
  # For small files that fit, show full content
503
520
  if len(lines) <= available_lines:
504
- return self.preview_content
521
+ return Text(self.preview_content)
505
522
 
506
523
  # For larger files, show diff if we have previous displayed content
507
524
  if (
508
525
  self._last_displayed_content
509
526
  and self._last_displayed_content != self.preview_content
510
527
  ):
511
- from difflib import unified_diff
512
-
513
528
  prev_lines = self._last_displayed_content.split("\n")
514
529
  curr_lines = self.preview_content.split("\n")
515
530
  diff = list(unified_diff(prev_lines, curr_lines, lineterm=""))
516
531
  if diff:
517
532
  # Show as much diff as fits
518
- return "\n".join(diff[:available_lines])
533
+ return Text("\n".join(diff[:available_lines]))
519
534
 
520
535
  # No diff available, show truncated content
521
- return (
536
+ return Text(
522
537
  "\n".join(lines[:available_lines])
523
538
  + f"\n\n... ({len(lines) - available_lines} more lines)"
524
539
  )
@@ -576,36 +591,46 @@ class OutputPreview(Static):
576
591
  pass
577
592
  return 30
578
593
 
579
- def render(self) -> str:
594
+ def render(self) -> Text:
580
595
  # Header line - use return_code to determine if test is running
581
596
  # (return_code is None means still running, has value means completed)
582
597
  if self.active_test_id is not None and self.last_return_code is None:
583
- header = f"[green]Test #{self.active_test_id} running...[/green]"
598
+ header_text = f"Test #{self.active_test_id} running..."
599
+ header_style = "green"
584
600
  elif self.active_test_id is not None:
585
- header = f"[dim]Test #{self.active_test_id} exited with code {self.last_return_code}[/dim]"
601
+ header_text = (
602
+ f"Test #{self.active_test_id} exited with code {self.last_return_code}"
603
+ )
604
+ header_style = "dim"
586
605
  elif self._has_seen_output or self.output_content:
587
606
  # Have seen output before - show without header
588
- header = ""
607
+ header_text = ""
608
+ header_style = ""
589
609
  else:
590
- header = "[dim]No test output yet...[/dim]"
610
+ header_text = "No test output yet..."
611
+ header_style = "dim"
591
612
 
592
613
  if not self.output_content:
593
- return header
614
+ return Text(header_text, style=header_style)
594
615
 
595
616
  available_lines = self._get_available_lines()
596
617
  lines = self.output_content.split("\n")
597
618
 
598
- # Build prefix (header + newline, or empty if no header)
599
- prefix = f"{header}\n" if header else ""
600
-
601
619
  # Show tail of output (most recent lines)
602
- if len(lines) <= available_lines:
603
- return f"{prefix}{self.output_content}"
620
+ if len(lines) > available_lines:
621
+ truncated_lines = lines[-(available_lines):]
622
+ skipped = len(lines) - available_lines
623
+ content = f"... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
624
+ else:
625
+ content = self.output_content
604
626
 
605
- # Truncate from the beginning
606
- truncated_lines = lines[-(available_lines):]
607
- skipped = len(lines) - available_lines
608
- return f"{prefix}... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
627
+ # Build result with styled header and unstyled content
628
+ result = Text()
629
+ if header_text:
630
+ result.append(header_text, style=header_style)
631
+ result.append("\n")
632
+ result.append(content)
633
+ return result
609
634
 
610
635
 
611
636
  class HelpScreen(ModalScreen[None]):
@@ -716,15 +741,20 @@ class ExpandedBoxModal(ModalScreen[None]):
716
741
  self._content_widget_id = content_widget_id
717
742
  self._file_path = file_path
718
743
 
719
- def _read_file(self, file_path: str) -> str:
744
+ def _read_file(self, file_path: str) -> Text:
720
745
  """Read file content, decoding as text if possible."""
721
- with open(file_path, "rb") as f:
722
- raw_content = f.read()
723
- # Try to decode as text, fall back to hex display if binary
724
- encoding, text = try_decode(raw_content)
725
- if encoding is not None:
726
- return text
727
- return "[Binary content - hex display]\n\n" + raw_content.hex()
746
+ if not os.path.isfile(file_path):
747
+ return Text("File not found", style="dim")
748
+ try:
749
+ with open(file_path, "rb") as f:
750
+ raw_content = f.read()
751
+ # Try to decode as text, fall back to hex display if binary
752
+ encoding, text = try_decode(raw_content)
753
+ if encoding is not None:
754
+ return Text(text)
755
+ return Text("Binary content - hex display\n\n" + raw_content.hex())
756
+ except OSError:
757
+ return Text("Error reading file", style="red")
728
758
 
729
759
  def compose(self) -> ComposeResult:
730
760
  with Vertical():
@@ -757,16 +787,16 @@ class ExpandedBoxModal(ModalScreen[None]):
757
787
  return "Statistics not available"
758
788
  return stats_displays[0].render()
759
789
 
760
- def _get_file_content(self, app: "ShrinkRayApp") -> str:
790
+ def _get_file_content(self, app: "ShrinkRayApp") -> str | Text:
761
791
  """Get content from file or preview widget."""
762
792
  if self._file_path:
763
793
  return self._read_file(self._file_path)
764
794
  content_previews = list(app.query("#content-preview").results(ContentPreview))
765
795
  if not content_previews:
766
796
  return "Content preview not available"
767
- return content_previews[0].preview_content
797
+ return Text(content_previews[0].preview_content)
768
798
 
769
- def _get_output_content(self, app: "ShrinkRayApp") -> str:
799
+ def _get_output_content(self, app: "ShrinkRayApp") -> str | Text:
770
800
  """Get output content from the output preview widget."""
771
801
  output_previews = list(app.query("#output-preview").results(OutputPreview))
772
802
  if not output_previews:
@@ -787,21 +817,29 @@ class ExpandedBoxModal(ModalScreen[None]):
787
817
  )
788
818
  has_seen_output = output_preview._has_seen_output
789
819
 
790
- # Build header - return_code is None means test is still running
820
+ # Build header with styling
791
821
  if test_id is not None and return_code is None:
792
- header = f"[green]Test #{test_id} running...[/green]\n\n"
822
+ header_text = f"Test #{test_id} running..."
823
+ header_style = "green"
793
824
  elif test_id is not None:
794
- header = f"[dim]Test #{test_id} exited with code {return_code}[/dim]\n\n"
825
+ header_text = f"Test #{test_id} exited with code {return_code}"
826
+ header_style = "dim"
795
827
  else:
796
- header = ""
828
+ header_text = ""
829
+ header_style = ""
797
830
 
798
831
  if raw_content:
799
- return header + raw_content
832
+ result = Text()
833
+ if header_text:
834
+ result.append(header_text, style=header_style)
835
+ result.append("\n\n")
836
+ result.append(raw_content)
837
+ return result
800
838
  elif has_seen_output or test_id is not None:
801
839
  # We've seen output before - show header only (no "No test output" message)
802
- return header.rstrip("\n") if header else ""
840
+ return Text(header_text, style=header_style)
803
841
  else:
804
- return "[dim]No test output yet...[/dim]"
842
+ return Text("No test output yet...", style="dim")
805
843
 
806
844
  def on_mount(self) -> None:
807
845
  """Populate content from the source widget."""
@@ -1041,6 +1079,446 @@ class PassStatsScreen(ModalScreen[None]):
1041
1079
  self._app.push_screen(HelpScreen())
1042
1080
 
1043
1081
 
1082
+ class HistoryExplorerModal(ModalScreen[None]):
1083
+ """Modal for browsing history reductions and also-interesting cases."""
1084
+
1085
+ CSS = """
1086
+ HistoryExplorerModal {
1087
+ align: center middle;
1088
+ }
1089
+
1090
+ HistoryExplorerModal > Vertical {
1091
+ width: 95%;
1092
+ height: 90%;
1093
+ background: $panel;
1094
+ border: thick $primary;
1095
+ }
1096
+
1097
+ HistoryExplorerModal #history-title {
1098
+ text-align: center;
1099
+ text-style: bold;
1100
+ height: auto;
1101
+ width: 100%;
1102
+ padding: 0 1;
1103
+ border-bottom: solid $primary;
1104
+ }
1105
+
1106
+ HistoryExplorerModal TabbedContent {
1107
+ height: 1fr;
1108
+ }
1109
+
1110
+ HistoryExplorerModal #history-content,
1111
+ HistoryExplorerModal #also-interesting-content {
1112
+ height: 1fr;
1113
+ }
1114
+
1115
+ HistoryExplorerModal #history-list-container,
1116
+ HistoryExplorerModal #also-interesting-list-container {
1117
+ width: 30%;
1118
+ height: 100%;
1119
+ border-right: solid $primary;
1120
+ }
1121
+
1122
+ HistoryExplorerModal ListView {
1123
+ height: 100%;
1124
+ }
1125
+
1126
+ HistoryExplorerModal #history-preview-container,
1127
+ HistoryExplorerModal #also-interesting-preview-container {
1128
+ width: 70%;
1129
+ height: 100%;
1130
+ padding: 0 1;
1131
+ }
1132
+
1133
+ HistoryExplorerModal #file-content-label,
1134
+ HistoryExplorerModal #also-file-label {
1135
+ text-style: bold;
1136
+ height: auto;
1137
+ margin-top: 1;
1138
+ }
1139
+
1140
+ HistoryExplorerModal #file-content,
1141
+ HistoryExplorerModal #also-file-content {
1142
+ height: 1fr;
1143
+ border: solid $secondary;
1144
+ padding: 1;
1145
+ }
1146
+
1147
+ HistoryExplorerModal #output-label,
1148
+ HistoryExplorerModal #also-output-label {
1149
+ text-style: bold;
1150
+ height: auto;
1151
+ margin-top: 1;
1152
+ }
1153
+
1154
+ HistoryExplorerModal #output-content,
1155
+ HistoryExplorerModal #also-output-content {
1156
+ height: 1fr;
1157
+ border: solid $secondary;
1158
+ padding: 1;
1159
+ }
1160
+
1161
+ HistoryExplorerModal #history-footer {
1162
+ dock: bottom;
1163
+ height: auto;
1164
+ padding: 0 1;
1165
+ text-align: center;
1166
+ border-top: solid $primary;
1167
+ }
1168
+ """
1169
+
1170
+ BINDINGS = [
1171
+ ("escape,q,x", "dismiss", "Close"),
1172
+ ("r", "restart_from_here", "Restart from here"),
1173
+ ]
1174
+
1175
+ def __init__(self, history_dir: str, target_basename: str) -> None:
1176
+ super().__init__()
1177
+ self._history_dir = history_dir
1178
+ self._target_basename = target_basename
1179
+ self._reductions_entries: list[str] = [] # List of entry paths
1180
+ self._also_interesting_entries: list[str] = []
1181
+ self._preview_timer: Timer | None = None
1182
+ self._pending_preview: tuple[str, str, str] | None = None
1183
+ self._refresh_timer: Timer | None = None
1184
+ # Track selected entry by path (more robust than by index)
1185
+ self._selected_reductions_path: str | None = None
1186
+ self._selected_also_interesting_path: str | None = None
1187
+ # Guard against updating selection during refresh (clear/append triggers
1188
+ # Highlighted events that would overwrite the saved selection path)
1189
+ self._refreshing: bool = False
1190
+
1191
+ def compose(self) -> ComposeResult:
1192
+ with Vertical():
1193
+ yield Label("History Explorer", id="history-title")
1194
+ with TabbedContent(id="history-tabs"):
1195
+ with TabPane("Reductions", id="reductions-tab"):
1196
+ with Horizontal(id="history-content"):
1197
+ with Vertical(id="history-list-container"):
1198
+ yield ListView(id="reductions-list")
1199
+ with Vertical(id="history-preview-container"):
1200
+ yield Label("File Content:", id="file-content-label")
1201
+ with VerticalScroll(id="file-content"):
1202
+ yield Static("", id="file-preview")
1203
+ yield Label("Test Output:", id="output-label")
1204
+ with VerticalScroll(id="output-content"):
1205
+ yield Static("", id="output-preview")
1206
+ with TabPane("Also-Interesting", id="also-interesting-tab"):
1207
+ with Horizontal(id="also-interesting-content"):
1208
+ with Vertical(id="also-interesting-list-container"):
1209
+ yield ListView(id="also-interesting-list")
1210
+ with Vertical(id="also-interesting-preview-container"):
1211
+ yield Label("File Content:", id="also-file-label")
1212
+ with VerticalScroll(id="also-file-content"):
1213
+ yield Static("", id="also-file-preview")
1214
+ yield Label("Test Output:", id="also-output-label")
1215
+ with VerticalScroll(id="also-output-content"):
1216
+ yield Static("", id="also-output-preview")
1217
+ yield Static(
1218
+ "↑/↓: Navigate Tab: Switch r: Restart from here Esc/q/x: Close",
1219
+ id="history-footer",
1220
+ )
1221
+
1222
+ def on_mount(self) -> None:
1223
+ """Populate the lists with history entries."""
1224
+ self._populate_list("reductions", "reductions-list")
1225
+ self._populate_list("also-interesting", "also-interesting-list")
1226
+
1227
+ # Focus the reductions list so arrow keys work immediately
1228
+ self.query_one("#reductions-list", ListView).focus()
1229
+
1230
+ # Start periodic refresh of the lists
1231
+ self._refresh_timer = self.set_interval(1.0, self._refresh_lists)
1232
+
1233
+ def on_unmount(self) -> None:
1234
+ """Clean up timers when modal is closed."""
1235
+ if self._preview_timer is not None:
1236
+ self._preview_timer.stop()
1237
+ if self._refresh_timer is not None:
1238
+ self._refresh_timer.stop()
1239
+
1240
+ def _refresh_lists(self) -> None:
1241
+ """Refresh the history lists to show new entries."""
1242
+ self._refresh_list("reductions", "reductions-list")
1243
+ self._refresh_list("also-interesting", "also-interesting-list")
1244
+
1245
+ def _refresh_list(self, subdir: str, list_id: str) -> None:
1246
+ """Refresh a single list, preserving selection.
1247
+
1248
+ This uses an incremental update strategy: only add new entries rather
1249
+ than clearing and repopulating. This preserves ListView selection
1250
+ naturally without fighting async DOM updates.
1251
+ """
1252
+ entries = self._scan_entries(subdir)
1253
+ list_view = self.query_one(f"#{list_id}", ListView)
1254
+
1255
+ # Get current entries for comparison
1256
+ if subdir == "reductions":
1257
+ old_entries = self._reductions_entries
1258
+ else:
1259
+ old_entries = self._also_interesting_entries
1260
+
1261
+ new_entries = [e[1] for e in entries]
1262
+
1263
+ # Only update if entries changed
1264
+ if new_entries == old_entries:
1265
+ return
1266
+
1267
+ # Check if this is purely additive (common case: new reductions added)
1268
+ # In this case, we can just append the new items without touching selection
1269
+ if old_entries and new_entries[: len(old_entries)] == old_entries:
1270
+ # New entries were added at the end - just append them
1271
+ new_items = entries[len(old_entries) :]
1272
+ for entry_num, _, size in new_items:
1273
+ size_str = humanize.naturalsize(size, binary=True)
1274
+ list_view.append(ListItem(Label(f"{entry_num} ({size_str})")))
1275
+
1276
+ # Update stored entries
1277
+ if subdir == "reductions":
1278
+ self._reductions_entries = new_entries
1279
+ else:
1280
+ self._also_interesting_entries = new_entries
1281
+ return
1282
+
1283
+ # Entries changed in a non-additive way (items removed or reordered).
1284
+ # This happens during restart-from-point. Do a full rebuild.
1285
+ selected_path = (
1286
+ self._selected_reductions_path
1287
+ if subdir == "reductions"
1288
+ else self._selected_also_interesting_path
1289
+ )
1290
+
1291
+ # Store new entries
1292
+ if subdir == "reductions":
1293
+ self._reductions_entries = new_entries
1294
+ else:
1295
+ self._also_interesting_entries = new_entries
1296
+
1297
+ # Find the index of the previously selected path in the new entries
1298
+ new_index: int | None = None
1299
+ if selected_path is not None and selected_path in new_entries:
1300
+ new_index = new_entries.index(selected_path)
1301
+
1302
+ # Guard against Highlighted events during clear/repopulate
1303
+ self._refreshing = True
1304
+
1305
+ # Clear and repopulate
1306
+ list_view.clear()
1307
+
1308
+ if not entries:
1309
+ list_view.append(ListItem(Label("[dim]No entries yet[/dim]")))
1310
+ self.call_after_refresh(self._finish_refresh)
1311
+ return
1312
+
1313
+ for entry_num, _, size in entries:
1314
+ size_str = humanize.naturalsize(size, binary=True)
1315
+ list_view.append(ListItem(Label(f"{entry_num} ({size_str})")))
1316
+
1317
+ # Restore selection after DOM updates complete
1318
+ if new_index is not None:
1319
+ self.call_after_refresh(self._restore_list_selection, list_view, new_index)
1320
+ else:
1321
+ self.call_after_refresh(self._finish_refresh)
1322
+
1323
+ def _finish_refresh(self) -> None:
1324
+ """Mark refresh as complete, allowing selection tracking to resume."""
1325
+ self._refreshing = False
1326
+
1327
+ def _restore_list_selection(self, list_view: ListView, index: int) -> None:
1328
+ """Restore selection to a list view after async DOM updates."""
1329
+ child_count = len(list_view.children)
1330
+ if child_count > 0:
1331
+ list_view.index = min(index, child_count - 1)
1332
+ self._refreshing = False
1333
+
1334
+ def _scan_entries(self, subdir: str) -> list[tuple[str, str, int]]:
1335
+ """Scan a history subdirectory for entries.
1336
+
1337
+ Returns list of (entry_number, entry_path, file_size) tuples, sorted by number.
1338
+ """
1339
+ entries = []
1340
+ dir_path = os.path.join(self._history_dir, subdir)
1341
+ if not os.path.isdir(dir_path):
1342
+ return entries
1343
+
1344
+ for entry_name in os.listdir(dir_path):
1345
+ entry_path = os.path.join(dir_path, entry_name)
1346
+ if os.path.isdir(entry_path):
1347
+ # Get file size
1348
+ file_path = os.path.join(entry_path, self._target_basename)
1349
+ if os.path.isfile(file_path):
1350
+ size = os.path.getsize(file_path)
1351
+ entries.append((entry_name, entry_path, size))
1352
+
1353
+ # Sort by entry number
1354
+ entries.sort(key=lambda x: x[0])
1355
+ return entries
1356
+
1357
+ def _populate_list(self, subdir: str, list_id: str) -> None:
1358
+ """Populate a ListView with entries from a history subdirectory."""
1359
+ entries = self._scan_entries(subdir)
1360
+ list_view = self.query_one(f"#{list_id}", ListView)
1361
+
1362
+ entry_paths = [e[1] for e in entries]
1363
+ if subdir == "reductions":
1364
+ self._reductions_entries = entry_paths
1365
+ else:
1366
+ self._also_interesting_entries = entry_paths
1367
+
1368
+ if not entries:
1369
+ list_view.append(ListItem(Label("[dim]No entries yet[/dim]")))
1370
+ return
1371
+
1372
+ for entry_num, _, size in entries:
1373
+ size_str = humanize.naturalsize(size, binary=True)
1374
+ # Don't use IDs - they conflict with refresh operations
1375
+ list_view.append(ListItem(Label(f"{entry_num} ({size_str})")))
1376
+
1377
+ # Select first item and track its path
1378
+ list_view.index = 0
1379
+ if subdir == "reductions":
1380
+ self._selected_reductions_path = entry_paths[0]
1381
+ else:
1382
+ self._selected_also_interesting_path = entry_paths[0]
1383
+
1384
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
1385
+ """Handle selection in a ListView."""
1386
+ list_view = event.list_view
1387
+
1388
+ # Determine which list was selected
1389
+ if list_view.id == "reductions-list":
1390
+ entries = self._reductions_entries
1391
+ file_preview_id = "file-preview"
1392
+ output_preview_id = "output-preview"
1393
+ else:
1394
+ entries = self._also_interesting_entries
1395
+ file_preview_id = "also-file-preview"
1396
+ output_preview_id = "also-output-preview"
1397
+
1398
+ # Get the selected entry path
1399
+ if not entries or list_view.index is None or list_view.index >= len(entries):
1400
+ return
1401
+
1402
+ entry_path = entries[list_view.index]
1403
+ self._update_preview(entry_path, file_preview_id, output_preview_id)
1404
+
1405
+ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
1406
+ """Handle highlighting (cursor movement) in a ListView."""
1407
+ # During refresh, clear/append trigger Highlighted events that would
1408
+ # overwrite the saved selection path. Skip updating selection tracking
1409
+ # during refresh - the selection will be restored by _restore_list_selection.
1410
+ if self._refreshing:
1411
+ return
1412
+
1413
+ list_view = event.list_view
1414
+
1415
+ # Determine which list was highlighted
1416
+ if list_view.id == "reductions-list":
1417
+ entries = self._reductions_entries
1418
+ file_preview_id = "file-preview"
1419
+ output_preview_id = "output-preview"
1420
+ else:
1421
+ entries = self._also_interesting_entries
1422
+ file_preview_id = "also-file-preview"
1423
+ output_preview_id = "also-output-preview"
1424
+
1425
+ # Get the highlighted entry path
1426
+ if not entries or list_view.index is None or list_view.index >= len(entries):
1427
+ return
1428
+
1429
+ entry_path = entries[list_view.index]
1430
+
1431
+ # Track the selected path for restoration after refresh
1432
+ if list_view.id == "reductions-list":
1433
+ self._selected_reductions_path = entry_path
1434
+ else:
1435
+ self._selected_also_interesting_path = entry_path
1436
+
1437
+ # Debounce preview updates to avoid lag when navigating quickly
1438
+ self._pending_preview = (entry_path, file_preview_id, output_preview_id)
1439
+ if self._preview_timer is not None:
1440
+ self._preview_timer.stop()
1441
+ self._preview_timer = self.set_timer(0.05, self._do_pending_preview)
1442
+
1443
+ def _do_pending_preview(self) -> None:
1444
+ """Execute the pending preview update."""
1445
+ if self._pending_preview is not None:
1446
+ entry_path, file_preview_id, output_preview_id = self._pending_preview
1447
+ self._pending_preview = None
1448
+ self._update_preview(entry_path, file_preview_id, output_preview_id)
1449
+
1450
+ def _update_preview(
1451
+ self, entry_path: str, file_preview_id: str, output_preview_id: str
1452
+ ) -> None:
1453
+ """Update the preview pane with content from the selected entry."""
1454
+ # Read file content
1455
+ file_path = os.path.join(entry_path, self._target_basename)
1456
+ file_content = self._read_file(file_path)
1457
+ self.query_one(f"#{file_preview_id}", Static).update(file_content)
1458
+
1459
+ # Read output content
1460
+ output_path = os.path.join(entry_path, f"{self._target_basename}.out")
1461
+ if os.path.isfile(output_path):
1462
+ output_content = self._read_file(output_path)
1463
+ else:
1464
+ output_content = Text("No output captured", style="dim")
1465
+ self.query_one(f"#{output_preview_id}", Static).update(output_content)
1466
+
1467
+ def _read_file(self, file_path: str) -> Text:
1468
+ """Read file content, decoding as text if possible."""
1469
+ if not os.path.isfile(file_path):
1470
+ return Text("File not found", style="dim")
1471
+ try:
1472
+ with open(file_path, "rb") as f:
1473
+ raw_content = f.read()
1474
+ # Truncate large files
1475
+ max_size = 50000
1476
+ truncated = len(raw_content) > max_size
1477
+ if truncated:
1478
+ raw_content = raw_content[:max_size]
1479
+ # Try to decode as text
1480
+ encoding, text = try_decode(raw_content)
1481
+ if encoding is not None:
1482
+ result = Text(text)
1483
+ if truncated:
1484
+ result.append("\n\n... (truncated)", style="dim")
1485
+ return result
1486
+ # Binary content - hex display
1487
+ result = Text("Binary content - hex display\n\n" + raw_content.hex())
1488
+ if truncated:
1489
+ result.append("\n\n... (truncated)", style="dim")
1490
+ return result
1491
+ except OSError:
1492
+ return Text("Error reading file", style="red")
1493
+
1494
+ def action_restart_from_here(self) -> None:
1495
+ """Restart reduction from the currently selected history point."""
1496
+ # Only works in Reductions tab
1497
+ tabs = self.query_one("#history-tabs", TabbedContent)
1498
+ if tabs.active != "reductions-tab":
1499
+ app = cast("ShrinkRayApp", self.app)
1500
+ app.notify("Restart only available in Reductions tab", severity="warning")
1501
+ return
1502
+
1503
+ # Get the selected reduction number
1504
+ list_view = self.query_one("#reductions-list", ListView)
1505
+ if list_view.index is None or list_view.index >= len(self._reductions_entries):
1506
+ app = cast("ShrinkRayApp", self.app)
1507
+ app.notify("No reduction selected", severity="warning")
1508
+ return
1509
+
1510
+ entry_path = self._reductions_entries[list_view.index]
1511
+ # Extract number from path (e.g., ".../reductions/0003" -> 3)
1512
+ reduction_number = int(os.path.basename(entry_path))
1513
+
1514
+ # Trigger restart via app
1515
+ app = cast("ShrinkRayApp", self.app)
1516
+ app._trigger_restart_from(reduction_number)
1517
+
1518
+ # Close modal
1519
+ self.dismiss()
1520
+
1521
+
1044
1522
  class ShrinkRayApp(App[None]):
1045
1523
  """Textual app for Shrink Ray."""
1046
1524
 
@@ -1120,6 +1598,7 @@ class ShrinkRayApp(App[None]):
1120
1598
  ("q", "quit", "Quit"),
1121
1599
  ("p", "show_pass_stats", "Pass Stats"),
1122
1600
  ("c", "skip_current_pass", "Skip Pass"),
1601
+ ("x", "show_history", "History"),
1123
1602
  ("h", "show_help", "Help"),
1124
1603
  ("up", "focus_up", "Focus Up"),
1125
1604
  ("down", "focus_down", "Focus Down"),
@@ -1147,6 +1626,8 @@ class ShrinkRayApp(App[None]):
1147
1626
  exit_on_completion: bool = True,
1148
1627
  client: ReductionClientProtocol | None = None,
1149
1628
  theme: ThemeMode = "auto",
1629
+ history_enabled: bool = True,
1630
+ also_interesting_code: int | None = None,
1150
1631
  ) -> None:
1151
1632
  super().__init__()
1152
1633
  self._file_path = file_path
@@ -1166,9 +1647,14 @@ class ShrinkRayApp(App[None]):
1166
1647
  self._owns_client = client is None
1167
1648
  self._completed = False
1168
1649
  self._theme = theme
1650
+ self._history_enabled = history_enabled
1651
+ self._also_interesting_code = also_interesting_code
1169
1652
  self._latest_pass_stats: list[PassStatsData] = []
1170
1653
  self._current_pass_name: str = ""
1171
1654
  self._disabled_passes: list[str] = []
1655
+ # History explorer state
1656
+ self._history_dir: str | None = None
1657
+ self._target_basename: str = ""
1172
1658
 
1173
1659
  # Box IDs in navigation order: [top-left, top-right, bottom-left, bottom-right]
1174
1660
  _BOX_IDS = [
@@ -1182,7 +1668,7 @@ class ShrinkRayApp(App[None]):
1182
1668
  yield Header()
1183
1669
  with Vertical(id="main-container"):
1184
1670
  yield Label(
1185
- "Shrink Ray - [h] help, [p] passes, [c] skip pass, [q] quit",
1671
+ "Shrink Ray - [h] help, [p] passes, [x] history, [c] skip, [q] quit",
1186
1672
  id="status-label",
1187
1673
  markup=False,
1188
1674
  )
@@ -1324,6 +1810,8 @@ class ShrinkRayApp(App[None]):
1324
1810
  clang_delta=self._clang_delta,
1325
1811
  trivial_is_error=self._trivial_is_error,
1326
1812
  skip_validation=True,
1813
+ history_enabled=self._history_enabled,
1814
+ also_interesting_code=self._also_interesting_code,
1327
1815
  )
1328
1816
 
1329
1817
  if response.error:
@@ -1363,6 +1851,10 @@ class ShrinkRayApp(App[None]):
1363
1851
  self._latest_pass_stats = update.pass_stats
1364
1852
  self._current_pass_name = update.current_pass_name
1365
1853
  self._disabled_passes = update.disabled_passes
1854
+ # Update history info for history explorer
1855
+ if update.history_dir is not None:
1856
+ self._history_dir = update.history_dir
1857
+ self._target_basename = update.target_basename
1366
1858
 
1367
1859
  # Check if all passes are disabled
1368
1860
  self._check_all_passes_disabled()
@@ -1385,9 +1877,10 @@ class ShrinkRayApp(App[None]):
1385
1877
  else:
1386
1878
  self.update_status("Reduction completed! Press 'q' to exit.")
1387
1879
 
1388
- except Exception as e:
1880
+ except Exception:
1389
1881
  traceback.print_exc()
1390
- self.exit(return_code=1, message=f"Error: {e}")
1882
+ # Include full traceback in error message in case stderr isn't visible
1883
+ self.exit(return_code=1, message=f"Error:\n{traceback.format_exc()}")
1391
1884
  finally:
1392
1885
  if self._owns_client and self._client:
1393
1886
  await self._client.close()
@@ -1460,6 +1953,13 @@ class ShrinkRayApp(App[None]):
1460
1953
  """Show the help modal."""
1461
1954
  self.push_screen(HelpScreen())
1462
1955
 
1956
+ def action_show_history(self) -> None:
1957
+ """Show the history explorer modal."""
1958
+ if self._history_dir is None:
1959
+ self.notify("History not available", severity="warning")
1960
+ return
1961
+ self.push_screen(HistoryExplorerModal(self._history_dir, self._target_basename))
1962
+
1463
1963
  def action_skip_current_pass(self) -> None:
1464
1964
  """Skip the currently running pass."""
1465
1965
  if self._client and not self._completed:
@@ -1470,6 +1970,25 @@ class ShrinkRayApp(App[None]):
1470
1970
  if self._client is not None:
1471
1971
  await self._client.skip_current_pass()
1472
1972
 
1973
+ def _trigger_restart_from(self, reduction_number: int) -> None:
1974
+ """Trigger restart from a specific reduction point."""
1975
+ if self._client and not self._completed:
1976
+ self.run_worker(self._do_restart_from(reduction_number))
1977
+
1978
+ async def _do_restart_from(self, reduction_number: int) -> None:
1979
+ """Execute restart command."""
1980
+ if self._client is None:
1981
+ self.notify("No client available", severity="error")
1982
+ return
1983
+ response = await self._client.restart_from(reduction_number)
1984
+ if response.error:
1985
+ self.notify(f"Restart failed: {response.error}", severity="error")
1986
+ else:
1987
+ self.notify(
1988
+ f"Restarted from reduction {reduction_number:04d}",
1989
+ severity="information",
1990
+ )
1991
+
1473
1992
  @property
1474
1993
  def is_completed(self) -> bool:
1475
1994
  """Check if reduction is completed."""
@@ -1491,14 +2010,14 @@ def run_textual_ui(
1491
2010
  trivial_is_error: bool = True,
1492
2011
  exit_on_completion: bool = True,
1493
2012
  theme: ThemeMode = "auto",
2013
+ history_enabled: bool = True,
2014
+ also_interesting_code: int | None = None,
1494
2015
  ) -> None:
1495
2016
  """Run the textual TUI.
1496
2017
 
1497
2018
  Note: Validation must be done before calling this function.
1498
2019
  The caller (main()) is responsible for running run_validation() first.
1499
2020
  """
1500
- import sys
1501
-
1502
2021
  # Start the TUI app - validation has already been done by main()
1503
2022
  app = ShrinkRayApp(
1504
2023
  file_path=file_path,
@@ -1515,6 +2034,8 @@ def run_textual_ui(
1515
2034
  trivial_is_error=trivial_is_error,
1516
2035
  exit_on_completion=exit_on_completion,
1517
2036
  theme=theme,
2037
+ history_enabled=history_enabled,
2038
+ also_interesting_code=also_interesting_code,
1518
2039
  )
1519
2040
  app.run()
1520
2041
  if app.return_code: