shrinkray 0.0.0__py3-none-any.whl → 25.12.26__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 +130 -960
- shrinkray/cli.py +70 -0
- shrinkray/display.py +75 -0
- shrinkray/formatting.py +108 -0
- shrinkray/passes/bytes.py +217 -10
- shrinkray/passes/clangdelta.py +47 -17
- shrinkray/passes/definitions.py +84 -4
- shrinkray/passes/genericlanguages.py +61 -7
- shrinkray/passes/json.py +6 -0
- shrinkray/passes/patching.py +65 -57
- shrinkray/passes/python.py +66 -23
- shrinkray/passes/sat.py +505 -91
- shrinkray/passes/sequences.py +26 -6
- shrinkray/problem.py +206 -27
- shrinkray/process.py +49 -0
- shrinkray/reducer.py +187 -25
- shrinkray/state.py +599 -0
- shrinkray/subprocess/__init__.py +24 -0
- shrinkray/subprocess/client.py +253 -0
- shrinkray/subprocess/protocol.py +190 -0
- shrinkray/subprocess/worker.py +491 -0
- shrinkray/tui.py +915 -0
- shrinkray/ui.py +72 -0
- shrinkray/work.py +34 -6
- {shrinkray-0.0.0.dist-info → shrinkray-25.12.26.0.dist-info}/METADATA +44 -27
- shrinkray-25.12.26.0.dist-info/RECORD +33 -0
- {shrinkray-0.0.0.dist-info → shrinkray-25.12.26.0.dist-info}/WHEEL +2 -1
- shrinkray-25.12.26.0.dist-info/entry_points.txt +3 -0
- shrinkray-25.12.26.0.dist-info/top_level.txt +1 -0
- shrinkray/learning.py +0 -221
- shrinkray-0.0.0.dist-info/RECORD +0 -22
- shrinkray-0.0.0.dist-info/entry_points.txt +0 -3
- {shrinkray-0.0.0.dist-info → shrinkray-25.12.26.0.dist-info/licenses}/LICENSE +0 -0
shrinkray/tui.py
ADDED
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
"""Textual-based TUI for Shrink Ray."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from typing import Literal, Protocol
|
|
7
|
+
|
|
8
|
+
import humanize
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
from textual import work
|
|
11
|
+
from textual.app import App, ComposeResult
|
|
12
|
+
from textual.containers import Vertical, VerticalScroll
|
|
13
|
+
from textual.reactive import reactive
|
|
14
|
+
from textual.screen import ModalScreen
|
|
15
|
+
from textual.theme import Theme
|
|
16
|
+
from textual.widgets import DataTable, Footer, Header, Label, Static
|
|
17
|
+
|
|
18
|
+
from shrinkray.subprocess.client import SubprocessClient
|
|
19
|
+
from shrinkray.subprocess.protocol import PassStatsData, ProgressUpdate, Response
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
ThemeMode = Literal["auto", "dark", "light"]
|
|
23
|
+
|
|
24
|
+
# Custom themes with true white/black backgrounds
|
|
25
|
+
SHRINKRAY_LIGHT_THEME = Theme(
|
|
26
|
+
name="shrinkray-light",
|
|
27
|
+
primary="#0066cc",
|
|
28
|
+
secondary="#6c757d",
|
|
29
|
+
accent="#007acc",
|
|
30
|
+
background="#ffffff", # Pure white
|
|
31
|
+
surface="#ffffff",
|
|
32
|
+
panel="#f8f9fa",
|
|
33
|
+
dark=False,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
SHRINKRAY_DARK_THEME = Theme(
|
|
37
|
+
name="shrinkray-dark",
|
|
38
|
+
primary="#4da6ff",
|
|
39
|
+
secondary="#adb5bd",
|
|
40
|
+
accent="#4dc3ff",
|
|
41
|
+
background="#000000", # Pure black
|
|
42
|
+
surface="#000000",
|
|
43
|
+
panel="#1a1a1a",
|
|
44
|
+
dark=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def detect_terminal_theme() -> bool:
|
|
49
|
+
"""Detect if terminal is in dark mode. Returns True for dark, False for light."""
|
|
50
|
+
# Check COLORFGBG environment variable (format: "fg;bg" where higher bg = light)
|
|
51
|
+
colorfgbg = os.environ.get("COLORFGBG", "")
|
|
52
|
+
if colorfgbg:
|
|
53
|
+
try:
|
|
54
|
+
parts = colorfgbg.split(";")
|
|
55
|
+
if len(parts) >= 2:
|
|
56
|
+
bg = int(parts[-1])
|
|
57
|
+
# Background values 0-6 are typically dark, 7+ are light
|
|
58
|
+
# Common: 0=black, 15=white, 7=light gray
|
|
59
|
+
return bg < 7
|
|
60
|
+
except (ValueError, IndexError):
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
# Check for macOS Terminal.app / iTerm2 light mode indicators
|
|
64
|
+
term_program = os.environ.get("TERM_PROGRAM", "")
|
|
65
|
+
if term_program in ("Apple_Terminal", "iTerm.app"):
|
|
66
|
+
# Check if system is in light mode via defaults (macOS)
|
|
67
|
+
# This is a heuristic - AppleInterfaceStyle is absent in light mode
|
|
68
|
+
apple_interface = os.environ.get("__CFBundleIdentifier", "")
|
|
69
|
+
if not apple_interface:
|
|
70
|
+
try:
|
|
71
|
+
import subprocess
|
|
72
|
+
|
|
73
|
+
result = subprocess.run(
|
|
74
|
+
["defaults", "read", "-g", "AppleInterfaceStyle"],
|
|
75
|
+
capture_output=True,
|
|
76
|
+
text=True,
|
|
77
|
+
timeout=1,
|
|
78
|
+
)
|
|
79
|
+
# If this succeeds and returns "Dark", we're in dark mode
|
|
80
|
+
# If it fails (exit code 1), we're in light mode
|
|
81
|
+
return result.returncode == 0 and "Dark" in result.stdout
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
# Default to dark mode (textual's default)
|
|
86
|
+
return True
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ReductionClientProtocol(Protocol):
|
|
90
|
+
"""Protocol for reduction client - allows mocking for tests."""
|
|
91
|
+
|
|
92
|
+
async def start(self) -> None: ...
|
|
93
|
+
|
|
94
|
+
async def start_reduction(
|
|
95
|
+
self,
|
|
96
|
+
file_path: str,
|
|
97
|
+
test: list[str],
|
|
98
|
+
parallelism: int | None = None,
|
|
99
|
+
timeout: float = 1.0,
|
|
100
|
+
seed: int = 0,
|
|
101
|
+
input_type: str = "all",
|
|
102
|
+
in_place: bool = False,
|
|
103
|
+
formatter: str = "default",
|
|
104
|
+
volume: str = "normal",
|
|
105
|
+
no_clang_delta: bool = False,
|
|
106
|
+
clang_delta: str = "",
|
|
107
|
+
trivial_is_error: bool = True,
|
|
108
|
+
) -> Response: ...
|
|
109
|
+
async def cancel(self) -> Response: ...
|
|
110
|
+
async def disable_pass(self, pass_name: str) -> Response: ...
|
|
111
|
+
async def enable_pass(self, pass_name: str) -> Response: ...
|
|
112
|
+
async def skip_current_pass(self) -> Response: ...
|
|
113
|
+
async def close(self) -> None: ...
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def error_message(self) -> str | None: ...
|
|
117
|
+
def get_progress_updates(self) -> AsyncIterator[ProgressUpdate]: ...
|
|
118
|
+
@property
|
|
119
|
+
def is_completed(self) -> bool: ...
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class StatsDisplay(Static):
|
|
123
|
+
"""Widget to display reduction statistics."""
|
|
124
|
+
|
|
125
|
+
# Use prefixed names to avoid conflicts with textual's built-in properties
|
|
126
|
+
current_status = reactive("Starting...")
|
|
127
|
+
current_size = reactive(0)
|
|
128
|
+
original_size = reactive(0)
|
|
129
|
+
call_count = reactive(0)
|
|
130
|
+
reduction_count = reactive(0)
|
|
131
|
+
interesting_calls = reactive(0)
|
|
132
|
+
wasted_calls = reactive(0)
|
|
133
|
+
runtime = reactive(0.0)
|
|
134
|
+
parallel_workers = reactive(0)
|
|
135
|
+
average_parallelism = reactive(0.0)
|
|
136
|
+
effective_parallelism = reactive(0.0)
|
|
137
|
+
time_since_last_reduction = reactive(0.0)
|
|
138
|
+
|
|
139
|
+
def update_stats(self, update: ProgressUpdate) -> None:
|
|
140
|
+
self.current_status = update.status
|
|
141
|
+
self.current_size = update.size
|
|
142
|
+
self.original_size = update.original_size
|
|
143
|
+
self.call_count = update.calls
|
|
144
|
+
self.reduction_count = update.reductions
|
|
145
|
+
self.interesting_calls = update.interesting_calls
|
|
146
|
+
self.wasted_calls = update.wasted_calls
|
|
147
|
+
self.runtime = update.runtime
|
|
148
|
+
self.parallel_workers = update.parallel_workers
|
|
149
|
+
self.average_parallelism = update.average_parallelism
|
|
150
|
+
self.effective_parallelism = update.effective_parallelism
|
|
151
|
+
self.time_since_last_reduction = update.time_since_last_reduction
|
|
152
|
+
self.refresh(layout=True)
|
|
153
|
+
|
|
154
|
+
def render(self) -> str:
|
|
155
|
+
if self.original_size == 0:
|
|
156
|
+
return "Waiting for reduction to start..."
|
|
157
|
+
|
|
158
|
+
# Calculate stats
|
|
159
|
+
reduction_pct = (1.0 - self.current_size / self.original_size) * 100
|
|
160
|
+
deleted = self.original_size - self.current_size
|
|
161
|
+
|
|
162
|
+
# Build stats display
|
|
163
|
+
lines = []
|
|
164
|
+
|
|
165
|
+
# Size and reduction info
|
|
166
|
+
if self.reduction_count > 0 and self.runtime > 0:
|
|
167
|
+
reduction_rate = deleted / self.runtime
|
|
168
|
+
lines.append(
|
|
169
|
+
f"Current test case size: {humanize.naturalsize(self.current_size)} "
|
|
170
|
+
f"({reduction_pct:.2f}% reduction, {humanize.naturalsize(reduction_rate)} / second)"
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
lines.append(
|
|
174
|
+
f"Current test case size: {humanize.naturalsize(self.current_size)}"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Runtime
|
|
178
|
+
if self.runtime > 0:
|
|
179
|
+
runtime_delta = timedelta(seconds=self.runtime)
|
|
180
|
+
lines.append(f"Total runtime: {humanize.precisedelta(runtime_delta)}")
|
|
181
|
+
|
|
182
|
+
# Call statistics
|
|
183
|
+
if self.call_count > 0:
|
|
184
|
+
calls_per_sec = self.call_count / self.runtime if self.runtime > 0 else 0
|
|
185
|
+
interesting_pct = (self.interesting_calls / self.call_count) * 100
|
|
186
|
+
wasted_pct = (self.wasted_calls / self.call_count) * 100
|
|
187
|
+
lines.append(
|
|
188
|
+
f"Calls to interestingness test: {self.call_count} "
|
|
189
|
+
f"({calls_per_sec:.2f} calls / second, "
|
|
190
|
+
f"{interesting_pct:.2f}% interesting, "
|
|
191
|
+
f"{wasted_pct:.2f}% wasted)"
|
|
192
|
+
)
|
|
193
|
+
else:
|
|
194
|
+
lines.append("Not yet called interestingness test")
|
|
195
|
+
|
|
196
|
+
# Time since last reduction
|
|
197
|
+
if self.reduction_count > 0 and self.runtime > 0:
|
|
198
|
+
reductions_per_sec = self.reduction_count / self.runtime
|
|
199
|
+
lines.append(
|
|
200
|
+
f"Time since last reduction: {self.time_since_last_reduction:.2f}s "
|
|
201
|
+
f"({reductions_per_sec:.2f} reductions / second)"
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
lines.append("No reductions yet")
|
|
205
|
+
|
|
206
|
+
lines.append("")
|
|
207
|
+
lines.append(f"Reducer status: {self.current_status}")
|
|
208
|
+
|
|
209
|
+
# Parallelism stats - always show
|
|
210
|
+
lines.append(
|
|
211
|
+
f"Current parallel workers: {self.parallel_workers} "
|
|
212
|
+
f"(Average {self.average_parallelism:.2f}) "
|
|
213
|
+
f"(effective parallelism: {self.effective_parallelism:.2f})"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
return "\n".join(lines)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class ContentPreview(Static):
|
|
220
|
+
"""Widget to display the current test case content preview."""
|
|
221
|
+
|
|
222
|
+
preview_content = reactive("")
|
|
223
|
+
hex_mode = reactive(False)
|
|
224
|
+
_last_displayed_content: str = ""
|
|
225
|
+
_last_display_time: float = 0.0
|
|
226
|
+
_pending_content: str = ""
|
|
227
|
+
_pending_hex_mode: bool = False
|
|
228
|
+
|
|
229
|
+
def update_content(self, content: str, hex_mode: bool) -> None:
|
|
230
|
+
import time
|
|
231
|
+
|
|
232
|
+
# Store the pending content
|
|
233
|
+
self._pending_content = content
|
|
234
|
+
self._pending_hex_mode = hex_mode
|
|
235
|
+
|
|
236
|
+
# Throttle updates to once per second
|
|
237
|
+
now = time.time()
|
|
238
|
+
if now - self._last_display_time < 1.0:
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
# Update the displayed content
|
|
242
|
+
self._last_display_time = now
|
|
243
|
+
|
|
244
|
+
# Track last displayed content for diffs
|
|
245
|
+
if self.preview_content and self.preview_content != content:
|
|
246
|
+
self._last_displayed_content = str(self.preview_content)
|
|
247
|
+
|
|
248
|
+
self.preview_content = content
|
|
249
|
+
self.hex_mode = hex_mode
|
|
250
|
+
self.refresh(layout=True)
|
|
251
|
+
|
|
252
|
+
def _get_available_lines(self) -> int:
|
|
253
|
+
"""Get the number of lines available for display based on container size."""
|
|
254
|
+
try:
|
|
255
|
+
# Try to get the parent container's size (the VerticalScroll viewport)
|
|
256
|
+
parent = self.parent
|
|
257
|
+
if parent and hasattr(parent, "size"):
|
|
258
|
+
parent_size = parent.size # type: ignore[union-attr]
|
|
259
|
+
if parent_size.height > 0:
|
|
260
|
+
return max(10, parent_size.height - 2)
|
|
261
|
+
# Fall back to app screen size
|
|
262
|
+
if self.app and self.app.size.height > 0:
|
|
263
|
+
# Estimate available space (screen minus header, footer, stats, etc.)
|
|
264
|
+
return max(10, self.app.size.height - 15)
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
# Fallback based on common terminal height
|
|
268
|
+
return 30
|
|
269
|
+
|
|
270
|
+
def render(self) -> str:
|
|
271
|
+
if not self.preview_content:
|
|
272
|
+
return "Loading..."
|
|
273
|
+
|
|
274
|
+
available_lines = self._get_available_lines()
|
|
275
|
+
|
|
276
|
+
if self.hex_mode:
|
|
277
|
+
return f"[Hex mode]\n{self.preview_content}"
|
|
278
|
+
|
|
279
|
+
lines = self.preview_content.split("\n")
|
|
280
|
+
|
|
281
|
+
# For small files that fit, show full content
|
|
282
|
+
if len(lines) <= available_lines:
|
|
283
|
+
return self.preview_content
|
|
284
|
+
|
|
285
|
+
# For larger files, show diff if we have previous displayed content
|
|
286
|
+
if (
|
|
287
|
+
self._last_displayed_content
|
|
288
|
+
and self._last_displayed_content != self.preview_content
|
|
289
|
+
):
|
|
290
|
+
from difflib import unified_diff
|
|
291
|
+
|
|
292
|
+
prev_lines = self._last_displayed_content.split("\n")
|
|
293
|
+
curr_lines = self.preview_content.split("\n")
|
|
294
|
+
diff = list(unified_diff(prev_lines, curr_lines, lineterm=""))
|
|
295
|
+
if diff:
|
|
296
|
+
# Show as much diff as fits
|
|
297
|
+
return "\n".join(diff[:available_lines])
|
|
298
|
+
|
|
299
|
+
# No diff available, show truncated content
|
|
300
|
+
return (
|
|
301
|
+
"\n".join(lines[:available_lines])
|
|
302
|
+
+ f"\n\n... ({len(lines) - available_lines} more lines)"
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class HelpScreen(ModalScreen[None]):
|
|
307
|
+
"""Modal screen showing keyboard shortcuts help."""
|
|
308
|
+
|
|
309
|
+
CSS = """
|
|
310
|
+
HelpScreen {
|
|
311
|
+
align: center middle;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
HelpScreen > Vertical {
|
|
315
|
+
width: 60;
|
|
316
|
+
height: auto;
|
|
317
|
+
max-height: 80%;
|
|
318
|
+
background: $panel;
|
|
319
|
+
border: thick $primary;
|
|
320
|
+
padding: 1 2;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
HelpScreen #help-title {
|
|
324
|
+
text-align: center;
|
|
325
|
+
text-style: bold;
|
|
326
|
+
margin-bottom: 1;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
HelpScreen .help-section {
|
|
330
|
+
margin-bottom: 1;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
HelpScreen .help-key {
|
|
334
|
+
color: $accent;
|
|
335
|
+
}
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
BINDINGS = [
|
|
339
|
+
("escape,q,h", "dismiss", "Close"),
|
|
340
|
+
]
|
|
341
|
+
|
|
342
|
+
def compose(self) -> ComposeResult:
|
|
343
|
+
with Vertical():
|
|
344
|
+
yield Label("Keyboard Shortcuts", id="help-title")
|
|
345
|
+
yield Static("")
|
|
346
|
+
yield Static("[bold]Main Screen[/bold]", classes="help-section")
|
|
347
|
+
yield Static(" [green]h[/green] Show this help")
|
|
348
|
+
yield Static(" [green]p[/green] Open pass statistics")
|
|
349
|
+
yield Static(" [green]c[/green] Skip current pass")
|
|
350
|
+
yield Static(" [green]q[/green] Quit application")
|
|
351
|
+
yield Static("")
|
|
352
|
+
yield Static("[bold]Pass Statistics Screen[/bold]", classes="help-section")
|
|
353
|
+
yield Static(" [green]↑/↓[/green] Navigate passes")
|
|
354
|
+
yield Static(" [green]space[/green] Toggle pass enabled/disabled")
|
|
355
|
+
yield Static(" [green]c[/green] Skip current pass")
|
|
356
|
+
yield Static(" [green]q[/green] Close modal")
|
|
357
|
+
yield Static("")
|
|
358
|
+
yield Static("[dim]Press any key to close[/dim]")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class PassStatsScreen(ModalScreen[None]):
|
|
362
|
+
"""Modal screen showing pass statistics in a table."""
|
|
363
|
+
|
|
364
|
+
CSS = """
|
|
365
|
+
PassStatsScreen {
|
|
366
|
+
align: center middle;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
PassStatsScreen > Vertical {
|
|
370
|
+
width: 90%;
|
|
371
|
+
height: 85%;
|
|
372
|
+
background: $panel;
|
|
373
|
+
border: thick $primary;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
PassStatsScreen DataTable {
|
|
377
|
+
height: 1fr;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
PassStatsScreen #stats-footer {
|
|
381
|
+
dock: bottom;
|
|
382
|
+
height: auto;
|
|
383
|
+
padding: 1;
|
|
384
|
+
background: $panel;
|
|
385
|
+
text-align: center;
|
|
386
|
+
}
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
BINDINGS = [
|
|
390
|
+
("escape,q,p", "dismiss", "Close"),
|
|
391
|
+
("space", "toggle_disable", "Toggle Enable"),
|
|
392
|
+
("c", "skip_current", "Skip Pass"),
|
|
393
|
+
("h", "show_help", "Help"),
|
|
394
|
+
]
|
|
395
|
+
|
|
396
|
+
pass_stats: reactive[list[PassStatsData]] = reactive(list)
|
|
397
|
+
current_pass_name: reactive[str] = reactive("")
|
|
398
|
+
disabled_passes: reactive[set[str]] = reactive(set)
|
|
399
|
+
|
|
400
|
+
def __init__(self, app: "ShrinkRayApp") -> None:
|
|
401
|
+
super().__init__()
|
|
402
|
+
self._app = app
|
|
403
|
+
self.pass_stats = app._latest_pass_stats.copy()
|
|
404
|
+
self.current_pass_name = app._current_pass_name
|
|
405
|
+
self.disabled_passes = set(app._disabled_passes)
|
|
406
|
+
|
|
407
|
+
def compose(self) -> ComposeResult:
|
|
408
|
+
with Vertical():
|
|
409
|
+
yield Label(
|
|
410
|
+
"Pass Statistics - [space] toggle, [c] skip, [h] help, [q] close",
|
|
411
|
+
id="stats-header",
|
|
412
|
+
)
|
|
413
|
+
yield DataTable(id="pass-stats-table")
|
|
414
|
+
yield Static(
|
|
415
|
+
f"Showing {len(self.pass_stats)} passes in run order",
|
|
416
|
+
id="stats-footer",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def on_mount(self) -> None:
|
|
420
|
+
table = self.query_one(DataTable)
|
|
421
|
+
|
|
422
|
+
# Select entire rows, not individual cells
|
|
423
|
+
table.cursor_type = "row"
|
|
424
|
+
|
|
425
|
+
table.add_columns(
|
|
426
|
+
"Enabled",
|
|
427
|
+
"Pass Name",
|
|
428
|
+
"Runs",
|
|
429
|
+
"Bytes Deleted",
|
|
430
|
+
"Tests",
|
|
431
|
+
"Reductions",
|
|
432
|
+
"Success %",
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
self._update_table_data()
|
|
436
|
+
|
|
437
|
+
# Set up periodic refresh (every 500ms)
|
|
438
|
+
self.set_interval(0.5, self._refresh_data)
|
|
439
|
+
|
|
440
|
+
def _update_table_data(self) -> None:
|
|
441
|
+
"""Update the table with current pass stats."""
|
|
442
|
+
table = self.query_one(DataTable)
|
|
443
|
+
|
|
444
|
+
# Save cursor position and scroll position before clearing
|
|
445
|
+
saved_cursor = table.cursor_coordinate
|
|
446
|
+
saved_scroll_y = table.scroll_y
|
|
447
|
+
|
|
448
|
+
table.clear()
|
|
449
|
+
|
|
450
|
+
if not self.pass_stats:
|
|
451
|
+
table.add_row("-", "No pass data yet", "-", "-", "-", "-", "-")
|
|
452
|
+
else:
|
|
453
|
+
for ps in self.pass_stats:
|
|
454
|
+
is_current = ps.pass_name == self.current_pass_name
|
|
455
|
+
is_disabled = ps.pass_name in self.disabled_passes
|
|
456
|
+
bytes_str = humanize.naturalsize(ps.bytes_deleted, binary=True)
|
|
457
|
+
|
|
458
|
+
# Checkbox for enabled/disabled
|
|
459
|
+
if is_disabled:
|
|
460
|
+
checkbox = Text("[ ]", style="dim")
|
|
461
|
+
else:
|
|
462
|
+
checkbox = Text("[✓]", style="green")
|
|
463
|
+
|
|
464
|
+
# Determine styling: bold for current, dim for disabled
|
|
465
|
+
if is_disabled:
|
|
466
|
+
style = "dim strike"
|
|
467
|
+
elif is_current:
|
|
468
|
+
style = "bold"
|
|
469
|
+
else:
|
|
470
|
+
style = ""
|
|
471
|
+
|
|
472
|
+
# Apply styling
|
|
473
|
+
if style:
|
|
474
|
+
name = Text(ps.pass_name, style=style)
|
|
475
|
+
runs = Text(str(ps.run_count), style=style)
|
|
476
|
+
bytes_del = Text(bytes_str, style=style)
|
|
477
|
+
tests = Text(f"{ps.test_evaluations:,}", style=style)
|
|
478
|
+
reductions = Text(str(ps.successful_reductions), style=style)
|
|
479
|
+
success = Text(f"{ps.success_rate:.1f}%", style=style)
|
|
480
|
+
else:
|
|
481
|
+
name = ps.pass_name
|
|
482
|
+
runs = str(ps.run_count)
|
|
483
|
+
bytes_del = bytes_str
|
|
484
|
+
tests = f"{ps.test_evaluations:,}"
|
|
485
|
+
reductions = str(ps.successful_reductions)
|
|
486
|
+
success = f"{ps.success_rate:.1f}%"
|
|
487
|
+
|
|
488
|
+
table.add_row(checkbox, name, runs, bytes_del, tests, reductions, success)
|
|
489
|
+
|
|
490
|
+
# Restore cursor and scroll position after rebuilding
|
|
491
|
+
# Only restore if the saved position is still valid
|
|
492
|
+
row_count = table.row_count
|
|
493
|
+
if row_count > 0 and saved_cursor.row < row_count:
|
|
494
|
+
table.cursor_coordinate = saved_cursor
|
|
495
|
+
table.scroll_y = saved_scroll_y
|
|
496
|
+
|
|
497
|
+
def _refresh_data(self) -> None:
|
|
498
|
+
"""Refresh data from the app.
|
|
499
|
+
|
|
500
|
+
Note: We don't update disabled_passes from the worker because
|
|
501
|
+
the local state is the source of truth. This avoids flicker when
|
|
502
|
+
the user toggles a pass but the worker hasn't confirmed yet.
|
|
503
|
+
"""
|
|
504
|
+
new_stats = self._app._latest_pass_stats.copy()
|
|
505
|
+
new_current = self._app._current_pass_name
|
|
506
|
+
if new_stats != self.pass_stats or new_current != self.current_pass_name:
|
|
507
|
+
self.pass_stats = new_stats
|
|
508
|
+
self.current_pass_name = new_current
|
|
509
|
+
self._update_table_data()
|
|
510
|
+
# Update footer with disabled count
|
|
511
|
+
disabled_count = len(self.disabled_passes)
|
|
512
|
+
if disabled_count > 0:
|
|
513
|
+
footer_text = f"Showing {len(self.pass_stats)} passes ({disabled_count} disabled)"
|
|
514
|
+
else:
|
|
515
|
+
footer_text = f"Showing {len(self.pass_stats)} passes in run order"
|
|
516
|
+
footer = self.query_one("#stats-footer", Static)
|
|
517
|
+
footer.update(footer_text)
|
|
518
|
+
|
|
519
|
+
def _get_selected_pass_name(self) -> str | None:
|
|
520
|
+
"""Get the pass name from the currently selected row."""
|
|
521
|
+
table = self.query_one(DataTable)
|
|
522
|
+
if table.row_count == 0:
|
|
523
|
+
return None
|
|
524
|
+
cursor_row = table.cursor_coordinate.row
|
|
525
|
+
if cursor_row >= len(self.pass_stats):
|
|
526
|
+
return None
|
|
527
|
+
return self.pass_stats[cursor_row].pass_name
|
|
528
|
+
|
|
529
|
+
def action_toggle_disable(self) -> None:
|
|
530
|
+
"""Toggle the disabled state of the selected pass."""
|
|
531
|
+
pass_name = self._get_selected_pass_name()
|
|
532
|
+
if pass_name is None:
|
|
533
|
+
return
|
|
534
|
+
|
|
535
|
+
if pass_name in self.disabled_passes:
|
|
536
|
+
# Enable the pass - update UI immediately, send command in background
|
|
537
|
+
# Create new set to trigger reactive update
|
|
538
|
+
self.disabled_passes = self.disabled_passes - {pass_name}
|
|
539
|
+
self._update_table_data()
|
|
540
|
+
self._app.run_worker(self._send_enable_pass(pass_name))
|
|
541
|
+
else:
|
|
542
|
+
# Disable the pass - update UI immediately, send command in background
|
|
543
|
+
# Create new set to trigger reactive update
|
|
544
|
+
self.disabled_passes = self.disabled_passes | {pass_name}
|
|
545
|
+
self._update_table_data()
|
|
546
|
+
self._app.run_worker(self._send_disable_pass(pass_name))
|
|
547
|
+
|
|
548
|
+
async def _send_disable_pass(self, pass_name: str) -> None:
|
|
549
|
+
"""Send disable command to the subprocess (fire and forget)."""
|
|
550
|
+
if self._app._client is not None:
|
|
551
|
+
await self._app._client.disable_pass(pass_name)
|
|
552
|
+
|
|
553
|
+
async def _send_enable_pass(self, pass_name: str) -> None:
|
|
554
|
+
"""Send enable command to the subprocess (fire and forget)."""
|
|
555
|
+
if self._app._client is not None:
|
|
556
|
+
await self._app._client.enable_pass(pass_name)
|
|
557
|
+
|
|
558
|
+
def action_skip_current(self) -> None:
|
|
559
|
+
"""Skip the currently running pass."""
|
|
560
|
+
self._app.run_worker(self._skip_pass())
|
|
561
|
+
|
|
562
|
+
async def _skip_pass(self) -> None:
|
|
563
|
+
"""Skip the current pass via the client."""
|
|
564
|
+
if self._app._client is not None:
|
|
565
|
+
await self._app._client.skip_current_pass()
|
|
566
|
+
|
|
567
|
+
def action_show_help(self) -> None:
|
|
568
|
+
"""Show the help screen."""
|
|
569
|
+
self._app.push_screen(HelpScreen())
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class ShrinkRayApp(App[None]):
|
|
573
|
+
"""Textual app for Shrink Ray."""
|
|
574
|
+
|
|
575
|
+
CSS = """
|
|
576
|
+
#main-container {
|
|
577
|
+
height: 100%;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
#stats-container {
|
|
581
|
+
height: auto;
|
|
582
|
+
border: solid green;
|
|
583
|
+
padding: 1;
|
|
584
|
+
margin: 1;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
#status-label {
|
|
588
|
+
text-style: bold;
|
|
589
|
+
margin: 0 1;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
#content-container {
|
|
593
|
+
border: solid blue;
|
|
594
|
+
margin: 1;
|
|
595
|
+
height: 1fr;
|
|
596
|
+
}
|
|
597
|
+
"""
|
|
598
|
+
|
|
599
|
+
BINDINGS = [
|
|
600
|
+
("q", "quit", "Quit"),
|
|
601
|
+
("p", "show_pass_stats", "Pass Stats"),
|
|
602
|
+
("c", "skip_current_pass", "Skip Pass"),
|
|
603
|
+
("h", "show_help", "Help"),
|
|
604
|
+
]
|
|
605
|
+
|
|
606
|
+
ENABLE_COMMAND_PALETTE = False
|
|
607
|
+
|
|
608
|
+
def __init__(
|
|
609
|
+
self,
|
|
610
|
+
file_path: str,
|
|
611
|
+
test: list[str],
|
|
612
|
+
parallelism: int | None = None,
|
|
613
|
+
timeout: float = 1.0,
|
|
614
|
+
seed: int = 0,
|
|
615
|
+
input_type: str = "all",
|
|
616
|
+
in_place: bool = False,
|
|
617
|
+
formatter: str = "default",
|
|
618
|
+
volume: str = "normal",
|
|
619
|
+
no_clang_delta: bool = False,
|
|
620
|
+
clang_delta: str = "",
|
|
621
|
+
trivial_is_error: bool = True,
|
|
622
|
+
exit_on_completion: bool = True,
|
|
623
|
+
client: ReductionClientProtocol | None = None,
|
|
624
|
+
theme: ThemeMode = "auto",
|
|
625
|
+
) -> None:
|
|
626
|
+
super().__init__()
|
|
627
|
+
self._file_path = file_path
|
|
628
|
+
self._test = test
|
|
629
|
+
self._parallelism = parallelism
|
|
630
|
+
self._timeout = timeout
|
|
631
|
+
self._seed = seed
|
|
632
|
+
self._input_type = input_type
|
|
633
|
+
self._in_place = in_place
|
|
634
|
+
self._formatter = formatter
|
|
635
|
+
self._volume = volume
|
|
636
|
+
self._no_clang_delta = no_clang_delta
|
|
637
|
+
self._clang_delta = clang_delta
|
|
638
|
+
self._trivial_is_error = trivial_is_error
|
|
639
|
+
self._exit_on_completion = exit_on_completion
|
|
640
|
+
self._client: ReductionClientProtocol | None = client
|
|
641
|
+
self._owns_client = client is None
|
|
642
|
+
self._completed = False
|
|
643
|
+
self._theme = theme
|
|
644
|
+
self._latest_pass_stats: list[PassStatsData] = []
|
|
645
|
+
self._current_pass_name: str = ""
|
|
646
|
+
self._disabled_passes: list[str] = []
|
|
647
|
+
|
|
648
|
+
def compose(self) -> ComposeResult:
|
|
649
|
+
yield Header()
|
|
650
|
+
with Vertical(id="main-container"):
|
|
651
|
+
yield Label(
|
|
652
|
+
"Shrink Ray - [h] help, [p] passes, [c] skip pass, [q] quit",
|
|
653
|
+
id="status-label",
|
|
654
|
+
)
|
|
655
|
+
with Vertical(id="stats-container"):
|
|
656
|
+
yield StatsDisplay(id="stats-display")
|
|
657
|
+
with VerticalScroll(id="content-container"):
|
|
658
|
+
yield ContentPreview(id="content-preview")
|
|
659
|
+
yield Footer()
|
|
660
|
+
|
|
661
|
+
async def on_mount(self) -> None:
|
|
662
|
+
# Register and apply custom themes
|
|
663
|
+
self.register_theme(SHRINKRAY_LIGHT_THEME)
|
|
664
|
+
self.register_theme(SHRINKRAY_DARK_THEME)
|
|
665
|
+
|
|
666
|
+
if self._theme == "dark":
|
|
667
|
+
self.theme = "shrinkray-dark"
|
|
668
|
+
elif self._theme == "light":
|
|
669
|
+
self.theme = "shrinkray-light"
|
|
670
|
+
else: # auto
|
|
671
|
+
self.theme = (
|
|
672
|
+
"shrinkray-dark" if detect_terminal_theme() else "shrinkray-light"
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
self.title = "Shrink Ray"
|
|
676
|
+
self.sub_title = self._file_path
|
|
677
|
+
self.run_reduction()
|
|
678
|
+
|
|
679
|
+
@work(exclusive=True)
|
|
680
|
+
async def run_reduction(self) -> None:
|
|
681
|
+
"""Start the reduction subprocess and monitor progress."""
|
|
682
|
+
try:
|
|
683
|
+
if self._client is None:
|
|
684
|
+
# No client provided - start one and begin reduction
|
|
685
|
+
debug_mode = self._volume == "debug"
|
|
686
|
+
self._client = SubprocessClient(debug_mode=debug_mode)
|
|
687
|
+
self._owns_client = True
|
|
688
|
+
|
|
689
|
+
await self._client.start()
|
|
690
|
+
|
|
691
|
+
# Start the reduction
|
|
692
|
+
response = await self._client.start_reduction(
|
|
693
|
+
file_path=self._file_path,
|
|
694
|
+
test=self._test,
|
|
695
|
+
parallelism=self._parallelism,
|
|
696
|
+
timeout=self._timeout,
|
|
697
|
+
seed=self._seed,
|
|
698
|
+
input_type=self._input_type,
|
|
699
|
+
in_place=self._in_place,
|
|
700
|
+
formatter=self._formatter,
|
|
701
|
+
volume=self._volume,
|
|
702
|
+
no_clang_delta=self._no_clang_delta,
|
|
703
|
+
clang_delta=self._clang_delta,
|
|
704
|
+
trivial_is_error=self._trivial_is_error,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
if response.error:
|
|
708
|
+
# Exit immediately on startup error
|
|
709
|
+
self.exit(return_code=1, message=f"Error: {response.error}")
|
|
710
|
+
return
|
|
711
|
+
|
|
712
|
+
# Monitor progress (client is already started and reduction is running)
|
|
713
|
+
stats_display = self.query_one("#stats-display", StatsDisplay)
|
|
714
|
+
content_preview = self.query_one("#content-preview", ContentPreview)
|
|
715
|
+
|
|
716
|
+
async for update in self._client.get_progress_updates():
|
|
717
|
+
stats_display.update_stats(update)
|
|
718
|
+
content_preview.update_content(update.content_preview, update.hex_mode)
|
|
719
|
+
self._latest_pass_stats = update.pass_stats
|
|
720
|
+
self._current_pass_name = update.current_pass_name
|
|
721
|
+
self._disabled_passes = update.disabled_passes
|
|
722
|
+
|
|
723
|
+
# Check if all passes are disabled
|
|
724
|
+
self._check_all_passes_disabled()
|
|
725
|
+
|
|
726
|
+
if self._client.is_completed:
|
|
727
|
+
break
|
|
728
|
+
|
|
729
|
+
self._completed = True
|
|
730
|
+
|
|
731
|
+
# Check if there was an error from the worker
|
|
732
|
+
if self._client.error_message:
|
|
733
|
+
# Exit immediately on error, printing the error message
|
|
734
|
+
self.exit(return_code=1, message=f"Error: {self._client.error_message}")
|
|
735
|
+
return
|
|
736
|
+
elif self._exit_on_completion:
|
|
737
|
+
self.exit()
|
|
738
|
+
else:
|
|
739
|
+
self.update_status("Reduction completed! Press 'q' to exit.")
|
|
740
|
+
|
|
741
|
+
except Exception as e:
|
|
742
|
+
self.exit(return_code=1, message=f"Error: {e}")
|
|
743
|
+
finally:
|
|
744
|
+
if self._owns_client and self._client:
|
|
745
|
+
await self._client.close()
|
|
746
|
+
|
|
747
|
+
def _check_all_passes_disabled(self) -> None:
|
|
748
|
+
"""Check if all passes are disabled and show a message if so."""
|
|
749
|
+
if self._latest_pass_stats and self._disabled_passes:
|
|
750
|
+
all_pass_names = {ps.pass_name for ps in self._latest_pass_stats}
|
|
751
|
+
if all_pass_names and all_pass_names <= set(self._disabled_passes):
|
|
752
|
+
self.update_status(
|
|
753
|
+
"Reduction paused (all passes disabled) - " "[p] to re-enable passes"
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
def update_status(self, message: str) -> None:
|
|
757
|
+
"""Update the status label."""
|
|
758
|
+
try:
|
|
759
|
+
self.query_one("#status-label", Label).update(message)
|
|
760
|
+
except Exception:
|
|
761
|
+
pass # Widget not yet mounted
|
|
762
|
+
|
|
763
|
+
async def action_quit(self) -> None:
|
|
764
|
+
"""Quit the application with graceful cancellation."""
|
|
765
|
+
if self._client and not self._completed:
|
|
766
|
+
try:
|
|
767
|
+
await self._client.cancel()
|
|
768
|
+
except Exception:
|
|
769
|
+
pass # Process may have already exited
|
|
770
|
+
self.exit()
|
|
771
|
+
|
|
772
|
+
def action_show_pass_stats(self) -> None:
|
|
773
|
+
"""Show the pass statistics modal."""
|
|
774
|
+
self.push_screen(PassStatsScreen(self))
|
|
775
|
+
|
|
776
|
+
def action_show_help(self) -> None:
|
|
777
|
+
"""Show the help modal."""
|
|
778
|
+
self.push_screen(HelpScreen())
|
|
779
|
+
|
|
780
|
+
def action_skip_current_pass(self) -> None:
|
|
781
|
+
"""Skip the currently running pass."""
|
|
782
|
+
if self._client and not self._completed:
|
|
783
|
+
self.run_worker(self._skip_pass())
|
|
784
|
+
|
|
785
|
+
async def _skip_pass(self) -> None:
|
|
786
|
+
"""Skip the current pass via the client."""
|
|
787
|
+
if self._client is not None:
|
|
788
|
+
await self._client.skip_current_pass()
|
|
789
|
+
|
|
790
|
+
@property
|
|
791
|
+
def is_completed(self) -> bool:
|
|
792
|
+
"""Check if reduction is completed."""
|
|
793
|
+
return self._completed
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
async def _validate_initial_example(
|
|
797
|
+
file_path: str,
|
|
798
|
+
test: list[str],
|
|
799
|
+
parallelism: int | None,
|
|
800
|
+
timeout: float,
|
|
801
|
+
seed: int,
|
|
802
|
+
input_type: str,
|
|
803
|
+
in_place: bool,
|
|
804
|
+
formatter: str,
|
|
805
|
+
volume: str,
|
|
806
|
+
no_clang_delta: bool,
|
|
807
|
+
clang_delta: str,
|
|
808
|
+
trivial_is_error: bool,
|
|
809
|
+
) -> str | None:
|
|
810
|
+
"""Validate initial example before showing TUI.
|
|
811
|
+
|
|
812
|
+
Returns error_message if validation failed, None if it passed.
|
|
813
|
+
"""
|
|
814
|
+
debug_mode = volume == "debug"
|
|
815
|
+
client = SubprocessClient(debug_mode=debug_mode)
|
|
816
|
+
try:
|
|
817
|
+
await client.start()
|
|
818
|
+
|
|
819
|
+
response = await client.start_reduction(
|
|
820
|
+
file_path=file_path,
|
|
821
|
+
test=test,
|
|
822
|
+
parallelism=parallelism,
|
|
823
|
+
timeout=timeout,
|
|
824
|
+
seed=seed,
|
|
825
|
+
input_type=input_type,
|
|
826
|
+
in_place=in_place,
|
|
827
|
+
formatter=formatter,
|
|
828
|
+
volume=volume,
|
|
829
|
+
no_clang_delta=no_clang_delta,
|
|
830
|
+
clang_delta=clang_delta,
|
|
831
|
+
trivial_is_error=trivial_is_error,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
if response.error:
|
|
835
|
+
return response.error
|
|
836
|
+
|
|
837
|
+
# Validation passed - cancel this reduction since TUI will start fresh
|
|
838
|
+
await client.cancel()
|
|
839
|
+
return None
|
|
840
|
+
finally:
|
|
841
|
+
await client.close()
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def run_textual_ui(
|
|
845
|
+
file_path: str,
|
|
846
|
+
test: list[str],
|
|
847
|
+
parallelism: int | None = None,
|
|
848
|
+
timeout: float = 1.0,
|
|
849
|
+
seed: int = 0,
|
|
850
|
+
input_type: str = "all",
|
|
851
|
+
in_place: bool = False,
|
|
852
|
+
formatter: str = "default",
|
|
853
|
+
volume: str = "normal",
|
|
854
|
+
no_clang_delta: bool = False,
|
|
855
|
+
clang_delta: str = "",
|
|
856
|
+
trivial_is_error: bool = True,
|
|
857
|
+
exit_on_completion: bool = True,
|
|
858
|
+
theme: ThemeMode = "auto",
|
|
859
|
+
) -> None:
|
|
860
|
+
"""Run the textual TUI."""
|
|
861
|
+
import asyncio
|
|
862
|
+
import sys
|
|
863
|
+
|
|
864
|
+
print("Validating initial example...", flush=True)
|
|
865
|
+
|
|
866
|
+
# Validate initial example before showing TUI
|
|
867
|
+
async def validate():
|
|
868
|
+
return await _validate_initial_example(
|
|
869
|
+
file_path=file_path,
|
|
870
|
+
test=test,
|
|
871
|
+
parallelism=parallelism,
|
|
872
|
+
timeout=timeout,
|
|
873
|
+
seed=seed,
|
|
874
|
+
input_type=input_type,
|
|
875
|
+
in_place=in_place,
|
|
876
|
+
formatter=formatter,
|
|
877
|
+
volume=volume,
|
|
878
|
+
no_clang_delta=no_clang_delta,
|
|
879
|
+
clang_delta=clang_delta,
|
|
880
|
+
trivial_is_error=trivial_is_error,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
try:
|
|
884
|
+
error = asyncio.run(validate())
|
|
885
|
+
except Exception as e:
|
|
886
|
+
import traceback
|
|
887
|
+
|
|
888
|
+
traceback.print_exc()
|
|
889
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
890
|
+
sys.exit(1)
|
|
891
|
+
|
|
892
|
+
if error:
|
|
893
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
894
|
+
sys.exit(1)
|
|
895
|
+
|
|
896
|
+
# Validation passed - now show the TUI which will start a fresh client
|
|
897
|
+
app = ShrinkRayApp(
|
|
898
|
+
file_path=file_path,
|
|
899
|
+
test=test,
|
|
900
|
+
parallelism=parallelism,
|
|
901
|
+
timeout=timeout,
|
|
902
|
+
seed=seed,
|
|
903
|
+
input_type=input_type,
|
|
904
|
+
in_place=in_place,
|
|
905
|
+
formatter=formatter,
|
|
906
|
+
volume=volume,
|
|
907
|
+
no_clang_delta=no_clang_delta,
|
|
908
|
+
clang_delta=clang_delta,
|
|
909
|
+
trivial_is_error=trivial_is_error,
|
|
910
|
+
exit_on_completion=exit_on_completion,
|
|
911
|
+
theme=theme,
|
|
912
|
+
)
|
|
913
|
+
app.run()
|
|
914
|
+
if app.return_code:
|
|
915
|
+
sys.exit(app.return_code)
|