shrinkray 25.12.29.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
@@ -2,14 +2,18 @@
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
16
+ from rich.markup import escape as escape_markup
13
17
  from rich.text import Text
14
18
  from textual import work
15
19
  from textual.app import App, ComposeResult
@@ -17,7 +21,18 @@ from textual.containers import Horizontal, Vertical, VerticalScroll
17
21
  from textual.reactive import reactive
18
22
  from textual.screen import ModalScreen
19
23
  from textual.theme import Theme
20
- 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
+ )
21
36
  from textual_plotext import PlotextPlot
22
37
 
23
38
  from shrinkray.formatting import try_decode
@@ -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
@@ -508,8 +525,6 @@ class ContentPreview(Static):
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=""))
@@ -593,14 +608,17 @@ class OutputPreview(Static):
593
608
  return header
594
609
 
595
610
  available_lines = self._get_available_lines()
596
- 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")
597
615
 
598
616
  # Build prefix (header + newline, or empty if no header)
599
617
  prefix = f"{header}\n" if header else ""
600
618
 
601
619
  # Show tail of output (most recent lines)
602
620
  if len(lines) <= available_lines:
603
- return f"{prefix}{self.output_content}"
621
+ return f"{prefix}{escaped_content}"
604
622
 
605
623
  # Truncate from the beginning
606
624
  truncated_lines = lines[-(available_lines):]
@@ -718,13 +736,19 @@ class ExpandedBoxModal(ModalScreen[None]):
718
736
 
719
737
  def _read_file(self, file_path: str) -> str:
720
738
  """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()
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]"
728
752
 
729
753
  def compose(self) -> ComposeResult:
730
754
  with Vertical():
@@ -1041,6 +1065,447 @@ class PassStatsScreen(ModalScreen[None]):
1041
1065
  self._app.push_screen(HelpScreen())
1042
1066
 
1043
1067
 
1068
+ class HistoryExplorerModal(ModalScreen[None]):
1069
+ """Modal for browsing history reductions and also-interesting cases."""
1070
+
1071
+ CSS = """
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%;
1104
+ height: 100%;
1105
+ border-right: solid $primary;
1106
+ }
1107
+
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;
1122
+ height: auto;
1123
+ margin-top: 1;
1124
+ }
1125
+
1126
+ HistoryExplorerModal #file-content,
1127
+ HistoryExplorerModal #also-file-content {
1128
+ height: 1fr;
1129
+ border: solid $secondary;
1130
+ padding: 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
+
1044
1509
  class ShrinkRayApp(App[None]):
1045
1510
  """Textual app for Shrink Ray."""
1046
1511
 
@@ -1120,6 +1585,7 @@ class ShrinkRayApp(App[None]):
1120
1585
  ("q", "quit", "Quit"),
1121
1586
  ("p", "show_pass_stats", "Pass Stats"),
1122
1587
  ("c", "skip_current_pass", "Skip Pass"),
1588
+ ("x", "show_history", "History"),
1123
1589
  ("h", "show_help", "Help"),
1124
1590
  ("up", "focus_up", "Focus Up"),
1125
1591
  ("down", "focus_down", "Focus Down"),
@@ -1147,6 +1613,8 @@ class ShrinkRayApp(App[None]):
1147
1613
  exit_on_completion: bool = True,
1148
1614
  client: ReductionClientProtocol | None = None,
1149
1615
  theme: ThemeMode = "auto",
1616
+ history_enabled: bool = True,
1617
+ also_interesting_code: int | None = None,
1150
1618
  ) -> None:
1151
1619
  super().__init__()
1152
1620
  self._file_path = file_path
@@ -1166,9 +1634,14 @@ class ShrinkRayApp(App[None]):
1166
1634
  self._owns_client = client is None
1167
1635
  self._completed = False
1168
1636
  self._theme = theme
1637
+ self._history_enabled = history_enabled
1638
+ self._also_interesting_code = also_interesting_code
1169
1639
  self._latest_pass_stats: list[PassStatsData] = []
1170
1640
  self._current_pass_name: str = ""
1171
1641
  self._disabled_passes: list[str] = []
1642
+ # History explorer state
1643
+ self._history_dir: str | None = None
1644
+ self._target_basename: str = ""
1172
1645
 
1173
1646
  # Box IDs in navigation order: [top-left, top-right, bottom-left, bottom-right]
1174
1647
  _BOX_IDS = [
@@ -1182,7 +1655,7 @@ class ShrinkRayApp(App[None]):
1182
1655
  yield Header()
1183
1656
  with Vertical(id="main-container"):
1184
1657
  yield Label(
1185
- "Shrink Ray - [h] help, [p] passes, [c] skip pass, [q] quit",
1658
+ "Shrink Ray - [h] help, [p] passes, [x] history, [c] skip, [q] quit",
1186
1659
  id="status-label",
1187
1660
  markup=False,
1188
1661
  )
@@ -1324,6 +1797,8 @@ class ShrinkRayApp(App[None]):
1324
1797
  clang_delta=self._clang_delta,
1325
1798
  trivial_is_error=self._trivial_is_error,
1326
1799
  skip_validation=True,
1800
+ history_enabled=self._history_enabled,
1801
+ also_interesting_code=self._also_interesting_code,
1327
1802
  )
1328
1803
 
1329
1804
  if response.error:
@@ -1363,6 +1838,10 @@ class ShrinkRayApp(App[None]):
1363
1838
  self._latest_pass_stats = update.pass_stats
1364
1839
  self._current_pass_name = update.current_pass_name
1365
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
1366
1845
 
1367
1846
  # Check if all passes are disabled
1368
1847
  self._check_all_passes_disabled()
@@ -1385,9 +1864,10 @@ class ShrinkRayApp(App[None]):
1385
1864
  else:
1386
1865
  self.update_status("Reduction completed! Press 'q' to exit.")
1387
1866
 
1388
- except Exception as e:
1867
+ except Exception:
1389
1868
  traceback.print_exc()
1390
- 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()}")
1391
1871
  finally:
1392
1872
  if self._owns_client and self._client:
1393
1873
  await self._client.close()
@@ -1460,6 +1940,13 @@ class ShrinkRayApp(App[None]):
1460
1940
  """Show the help modal."""
1461
1941
  self.push_screen(HelpScreen())
1462
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
+
1463
1950
  def action_skip_current_pass(self) -> None:
1464
1951
  """Skip the currently running pass."""
1465
1952
  if self._client and not self._completed:
@@ -1470,6 +1957,25 @@ class ShrinkRayApp(App[None]):
1470
1957
  if self._client is not None:
1471
1958
  await self._client.skip_current_pass()
1472
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
+
1473
1979
  @property
1474
1980
  def is_completed(self) -> bool:
1475
1981
  """Check if reduction is completed."""
@@ -1491,14 +1997,14 @@ def run_textual_ui(
1491
1997
  trivial_is_error: bool = True,
1492
1998
  exit_on_completion: bool = True,
1493
1999
  theme: ThemeMode = "auto",
2000
+ history_enabled: bool = True,
2001
+ also_interesting_code: int | None = None,
1494
2002
  ) -> None:
1495
2003
  """Run the textual TUI.
1496
2004
 
1497
2005
  Note: Validation must be done before calling this function.
1498
2006
  The caller (main()) is responsible for running run_validation() first.
1499
2007
  """
1500
- import sys
1501
-
1502
2008
  # Start the TUI app - validation has already been done by main()
1503
2009
  app = ShrinkRayApp(
1504
2010
  file_path=file_path,
@@ -1515,6 +2021,8 @@ def run_textual_ui(
1515
2021
  trivial_is_error=trivial_is_error,
1516
2022
  exit_on_completion=exit_on_completion,
1517
2023
  theme=theme,
2024
+ history_enabled=history_enabled,
2025
+ also_interesting_code=also_interesting_code,
1518
2026
  )
1519
2027
  app.run()
1520
2028
  if app.return_code: