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