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/__main__.py +48 -1
- shrinkray/history.py +446 -0
- shrinkray/state.py +247 -41
- shrinkray/subprocess/client.py +53 -4
- shrinkray/subprocess/protocol.py +8 -0
- shrinkray/subprocess/worker.py +196 -31
- shrinkray/tui.py +527 -19
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.1.1.0.dist-info}/METADATA +2 -5
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.1.1.0.dist-info}/RECORD +13 -12
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.1.1.0.dist-info}/WHEEL +0 -0
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.1.1.0.dist-info}/entry_points.txt +0 -0
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.1.1.0.dist-info}/licenses/LICENSE +0 -0
- {shrinkray-25.12.29.0.dist-info → shrinkray-26.1.1.0.dist-info}/top_level.txt +0 -0
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.
|
|
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
|
-
|
|
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}{
|
|
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
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
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
|
|
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
|
|
1867
|
+
except Exception:
|
|
1389
1868
|
traceback.print_exc()
|
|
1390
|
-
|
|
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:
|