shrinkray 25.12.28.0__py3-none-any.whl → 25.12.29.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/state.py CHANGED
@@ -57,7 +57,7 @@ def compute_dynamic_timeout(runtime: float) -> float:
57
57
 
58
58
 
59
59
  @define
60
- class TestOutputManager:
60
+ class OutputCaptureManager:
61
61
  """Manages temporary files for test output capture.
62
62
 
63
63
  Allocates unique files for each test's stdout/stderr output,
@@ -74,7 +74,8 @@ class TestOutputManager:
74
74
 
75
75
  _sequence: int = 0
76
76
  _active_outputs: dict[int, str] = {}
77
- _completed_outputs: deque[tuple[int, str, float]] = deque()
77
+ # Completed outputs: (test_id, file_path, completion_time, return_code)
78
+ _completed_outputs: deque[tuple[int, str, float, int]] = deque()
78
79
 
79
80
  def __attrs_post_init__(self) -> None:
80
81
  # Initialize mutable defaults
@@ -89,11 +90,13 @@ class TestOutputManager:
89
90
  self._active_outputs[test_id] = file_path
90
91
  return test_id, file_path
91
92
 
92
- def mark_completed(self, test_id: int) -> None:
93
+ def mark_completed(self, test_id: int, return_code: int = 0) -> None:
93
94
  """Mark a test as completed and move to completed queue."""
94
95
  if test_id in self._active_outputs:
95
96
  file_path = self._active_outputs.pop(test_id)
96
- self._completed_outputs.append((test_id, file_path, time.time()))
97
+ self._completed_outputs.append(
98
+ (test_id, file_path, time.time(), return_code)
99
+ )
97
100
  self._cleanup_old_files()
98
101
 
99
102
  def _cleanup_old_files(self) -> None:
@@ -104,65 +107,55 @@ class TestOutputManager:
104
107
  self._completed_outputs
105
108
  and now - self._completed_outputs[0][2] > self.max_age_seconds
106
109
  ):
107
- _, file_path, _ = self._completed_outputs.popleft()
110
+ _, file_path, _, _ = self._completed_outputs.popleft()
108
111
  self._safe_delete(file_path)
109
112
  # Remove excess files beyond max_files
110
113
  while len(self._completed_outputs) > self.max_files:
111
- _, file_path, _ = self._completed_outputs.popleft()
114
+ _, file_path, _, _ = self._completed_outputs.popleft()
112
115
  self._safe_delete(file_path)
113
116
 
114
- def _should_show_completed(self) -> tuple[int, str] | None:
115
- """Check if we should show a completed test's output.
116
-
117
- Note: This method is only called when there are no active tests.
118
- It returns the completed test info if within the display window
119
- (min_display_seconds + grace_period).
120
- """
121
- if not self._completed_outputs:
122
- return None
123
- test_id, file_path, completion_time = self._completed_outputs[-1]
124
- elapsed = time.time() - completion_time
117
+ @staticmethod
118
+ def _file_has_content(path: str) -> bool:
119
+ """Check if a file exists and has non-zero size."""
120
+ try:
121
+ return os.path.getsize(path) > 0
122
+ except OSError:
123
+ return False
125
124
 
126
- # Show completed test during the full display window
127
- if elapsed < self.min_display_seconds + self.grace_period_seconds:
128
- return test_id, file_path
125
+ def get_current_output(self) -> tuple[str | None, int | None, int | None]:
126
+ """Get the current output to display.
129
127
 
130
- return None
128
+ Returns (file_path, test_id, return_code) where:
129
+ - file_path: path to the output file to display
130
+ - test_id: the test ID (for display in header)
131
+ - return_code: the return code (None if test is still running)
131
132
 
132
- def get_current_output_path(self) -> str | None:
133
- """Get the most relevant output file path.
134
-
135
- Active tests always take priority. If no active test, shows
136
- recently completed test output for min_display_seconds, plus
137
- an additional grace_period if no new test has started.
133
+ Active tests take priority only if they have produced output.
134
+ Otherwise, shows recently completed test output for min_display_seconds,
135
+ plus an additional grace_period if no new test has started.
138
136
  """
139
- # Active tests always take priority
137
+ # Active tests take priority only if they have content
140
138
  if self._active_outputs:
141
139
  max_id = max(self._active_outputs.keys())
142
- return self._active_outputs[max_id]
143
- # Then check for recently completed test that should stay visible
144
- recent = self._should_show_completed()
145
- if recent is not None:
146
- return recent[1]
147
- # Fall back to most recent completed (even if past display window)
140
+ active_path = self._active_outputs[max_id]
141
+ if self._file_has_content(active_path):
142
+ # Active test with output - no return code yet
143
+ return active_path, max_id, None
144
+ # Active test has no output yet - fall through to show previous output
145
+
146
+ # Check for recently completed test that should stay visible,
147
+ # or fall back to most recent completed (even if past display window)
148
148
  if self._completed_outputs:
149
- return self._completed_outputs[-1][1]
150
- return None
151
-
152
- def get_active_test_id(self) -> int | None:
153
- """Get the currently running test ID, if any.
149
+ test_id, file_path, _, return_code = self._completed_outputs[-1]
150
+ return file_path, test_id, return_code
154
151
 
155
- Returns the active test ID if one is running, None otherwise.
156
- """
157
- if self._active_outputs:
158
- return max(self._active_outputs.keys())
159
- return None
152
+ return None, None, None
160
153
 
161
154
  def cleanup_all(self) -> None:
162
155
  """Clean up all output files (called on shutdown)."""
163
156
  for file_path in self._active_outputs.values():
164
157
  self._safe_delete(file_path)
165
- for _, file_path, _ in self._completed_outputs:
158
+ for _, file_path, _, _ in self._completed_outputs:
166
159
  self._safe_delete(file_path)
167
160
  self._active_outputs.clear()
168
161
  self._completed_outputs.clear()
@@ -209,7 +202,7 @@ class ShrinkRayState[TestCase](ABC):
209
202
  _last_debug_output: str = ""
210
203
 
211
204
  # Optional output manager for capturing test output (TUI mode)
212
- output_manager: TestOutputManager | None = None
205
+ output_manager: OutputCaptureManager | None = None
213
206
 
214
207
  def __attrs_post_init__(self):
215
208
  self.is_interesting_limiter = trio.CapacityLimiter(max(self.parallelism, 1))
@@ -298,6 +291,7 @@ class ShrinkRayState[TestCase](ABC):
298
291
  # Determine output handling
299
292
  test_id: int | None = None
300
293
  output_file_handle = None
294
+ exit_code: int | None = None # Track for output manager
301
295
 
302
296
  if self.output_manager is not None:
303
297
  # Capture output to a file for TUI display
@@ -366,6 +360,7 @@ class ShrinkRayState[TestCase](ABC):
366
360
 
367
361
  result: int | None = sp.returncode
368
362
  assert result is not None
363
+ exit_code = result
369
364
 
370
365
  return result
371
366
  finally:
@@ -373,7 +368,7 @@ class ShrinkRayState[TestCase](ABC):
373
368
  if output_file_handle is not None:
374
369
  output_file_handle.close()
375
370
  if test_id is not None and self.output_manager is not None:
376
- self.output_manager.mark_completed(test_id)
371
+ self.output_manager.mark_completed(test_id, exit_code or 0)
377
372
 
378
373
  async def run_for_exit_code(self, test_case: TestCase, debug: bool = False) -> int:
379
374
  # Lazy import
@@ -57,8 +57,13 @@ class ProgressUpdate:
57
57
  disabled_passes: list[str] = field(default_factory=list)
58
58
  # Test output preview (last 4KB of current/recent test output)
59
59
  test_output_preview: str = ""
60
- # Currently running test ID (None if no test running)
60
+ # Test ID of the output being displayed (None if no output yet)
61
61
  active_test_id: int | None = None
62
+ # Return code of the displayed test (None if test is still running)
63
+ last_test_return_code: int | None = None
64
+ # New size history entries since last update: list of (runtime_seconds, size)
65
+ # Client should accumulate these over time
66
+ new_size_history: list[tuple[float, int]] = field(default_factory=list)
62
67
 
63
68
 
64
69
  @dataclass
@@ -120,6 +125,8 @@ def serialize(msg: Request | Response | ProgressUpdate) -> str:
120
125
  "disabled_passes": msg.disabled_passes,
121
126
  "test_output_preview": msg.test_output_preview,
122
127
  "active_test_id": msg.active_test_id,
128
+ "last_test_return_code": msg.last_test_return_code,
129
+ "new_size_history": msg.new_size_history,
123
130
  },
124
131
  }
125
132
  else:
@@ -169,6 +176,8 @@ def deserialize(line: str) -> Request | Response | ProgressUpdate:
169
176
  disabled_passes=d.get("disabled_passes", []),
170
177
  test_output_preview=d.get("test_output_preview", ""),
171
178
  active_test_id=d.get("active_test_id"),
179
+ last_test_return_code=d.get("last_test_return_code"),
180
+ new_size_history=[tuple(x) for x in d.get("new_size_history", [])],
172
181
  )
173
182
 
174
183
  # Check for response (has "result" or "error" field)
@@ -58,6 +58,11 @@ class ReducerWorker:
58
58
  self._output_stream = output_stream
59
59
  # Output directory for test output capture (cleaned up on shutdown)
60
60
  self._output_dir: str | None = None
61
+ # Size history for graphing: list of (runtime_seconds, size) tuples
62
+ self._size_history: list[tuple[float, int]] = []
63
+ self._last_sent_history_index: int = 0
64
+ self._last_recorded_size: int = 0
65
+ self._last_history_time: float = 0.0
61
66
 
62
67
  async def emit(self, msg: Response | ProgressUpdate) -> None:
63
68
  """Write a message to the output stream."""
@@ -157,9 +162,9 @@ class ReducerWorker:
157
162
  find_clang_delta,
158
163
  )
159
164
  from shrinkray.state import (
165
+ OutputCaptureManager,
160
166
  ShrinkRayDirectoryState,
161
167
  ShrinkRayStateSingleFile,
162
- TestOutputManager,
163
168
  )
164
169
  from shrinkray.work import Volume
165
170
 
@@ -213,7 +218,7 @@ class ReducerWorker:
213
218
 
214
219
  # Create output manager for test output capture (always enabled for TUI)
215
220
  self._output_dir = tempfile.mkdtemp(prefix="shrinkray-output-")
216
- self.state.output_manager = TestOutputManager(output_dir=self._output_dir)
221
+ self.state.output_manager = OutputCaptureManager(output_dir=self._output_dir)
217
222
 
218
223
  self.problem = self.state.problem
219
224
  self.reducer = self.state.reducer
@@ -304,17 +309,23 @@ class ReducerWorker:
304
309
  return Response(id=request_id, result={"status": "skipped"})
305
310
  return Response(id=request_id, error="Reducer does not support pass control")
306
311
 
307
- def _get_test_output_preview(self) -> tuple[str, int | None]:
308
- """Get preview of current test output and active test ID."""
312
+ def _get_test_output_preview(self) -> tuple[str, int | None, int | None]:
313
+ """Get preview of current test output, test ID, and return code.
314
+
315
+ Returns (content, test_id, return_code) where:
316
+ - content: the last 4KB of the output file
317
+ - test_id: the test ID being displayed
318
+ - return_code: None if test is still running, otherwise the exit code
319
+ """
309
320
  if self.state is None or self.state.output_manager is None:
310
- return "", None
321
+ return "", None, None
311
322
 
312
- manager = self.state.output_manager
313
- active_test_id = manager.get_active_test_id()
314
- output_path = manager.get_current_output_path()
323
+ output_path, test_id, return_code = (
324
+ self.state.output_manager.get_current_output()
325
+ )
315
326
 
316
327
  if output_path is None:
317
- return "", active_test_id
328
+ return "", None, None
318
329
 
319
330
  # Read last 4KB of file
320
331
  try:
@@ -326,9 +337,13 @@ class ReducerWorker:
326
337
  else:
327
338
  f.seek(0)
328
339
  data = f.read()
329
- return data.decode("utf-8", errors="replace"), active_test_id
340
+ return (
341
+ data.decode("utf-8", errors="replace"),
342
+ test_id,
343
+ return_code,
344
+ )
330
345
  except OSError:
331
- return "", active_test_id
346
+ return "", test_id, return_code
332
347
 
333
348
  def _get_content_preview(self) -> tuple[str, bool]:
334
349
  """Get a preview of the current test case content."""
@@ -379,6 +394,29 @@ class ReducerWorker:
379
394
  return None
380
395
 
381
396
  stats = self.problem.stats
397
+ runtime = time.time() - stats.start_time
398
+ current_size = stats.current_test_case_size
399
+
400
+ # Record size history when size changes or periodically
401
+ # Use 200ms interval for first 5 minutes, then 1s (ticks are at 1-minute intervals)
402
+ history_interval = 1.0 if runtime >= 300 else 0.2
403
+
404
+ if not self._size_history:
405
+ # First sample: record initial size at time 0
406
+ self._size_history.append((0.0, stats.initial_test_case_size))
407
+ self._last_recorded_size = stats.initial_test_case_size
408
+ self._last_history_time = 0.0
409
+
410
+ if current_size != self._last_recorded_size:
411
+ # Size changed - always record
412
+ self._size_history.append((runtime, current_size))
413
+ self._last_recorded_size = current_size
414
+ self._last_history_time = runtime
415
+ elif runtime - self._last_history_time >= history_interval:
416
+ # No size change but interval passed - record periodic update
417
+ self._size_history.append((runtime, current_size))
418
+ self._last_history_time = runtime
419
+
382
420
  content_preview, hex_mode = self._get_content_preview()
383
421
 
384
422
  # Get parallel workers count and track average
@@ -433,7 +471,13 @@ class ReducerWorker:
433
471
  disabled_passes = []
434
472
 
435
473
  # Get test output preview
436
- test_output_preview, active_test_id = self._get_test_output_preview()
474
+ test_output_preview, active_test_id, last_return_code = (
475
+ self._get_test_output_preview()
476
+ )
477
+
478
+ # Get new size history entries since last update
479
+ new_entries = self._size_history[self._last_sent_history_index :]
480
+ self._last_sent_history_index = len(self._size_history)
437
481
 
438
482
  return ProgressUpdate(
439
483
  status=self.reducer.status if self.reducer else "",
@@ -443,7 +487,7 @@ class ReducerWorker:
443
487
  reductions=stats.reductions,
444
488
  interesting_calls=stats.interesting_calls,
445
489
  wasted_calls=stats.wasted_interesting_calls,
446
- runtime=time.time() - stats.start_time,
490
+ runtime=runtime,
447
491
  parallel_workers=parallel_workers,
448
492
  average_parallelism=average_parallelism,
449
493
  effective_parallelism=effective_parallelism,
@@ -455,6 +499,8 @@ class ReducerWorker:
455
499
  disabled_passes=disabled_passes,
456
500
  test_output_preview=test_output_preview,
457
501
  active_test_id=active_test_id,
502
+ last_test_return_code=last_return_code,
503
+ new_size_history=new_entries,
458
504
  )
459
505
 
460
506
  async def emit_progress_updates(self) -> None:
shrinkray/tui.py CHANGED
@@ -1,12 +1,13 @@
1
1
  """Textual-based TUI for Shrink Ray."""
2
2
 
3
+ import math
3
4
  import os
4
5
  import time
5
6
  import traceback
6
7
  from collections.abc import AsyncGenerator
7
8
  from contextlib import aclosing
8
9
  from datetime import timedelta
9
- from typing import Literal, Protocol
10
+ from typing import Literal, Protocol, cast
10
11
 
11
12
  import humanize
12
13
  from rich.text import Text
@@ -17,7 +18,9 @@ from textual.reactive import reactive
17
18
  from textual.screen import ModalScreen
18
19
  from textual.theme import Theme
19
20
  from textual.widgets import DataTable, Footer, Header, Label, Static
21
+ from textual_plotext import PlotextPlot
20
22
 
23
+ from shrinkray.formatting import try_decode
21
24
  from shrinkray.subprocess.client import SubprocessClient
22
25
  from shrinkray.subprocess.protocol import (
23
26
  PassStatsData,
@@ -223,6 +226,219 @@ class StatsDisplay(Static):
223
226
  return "\n".join(lines)
224
227
 
225
228
 
229
+ def _format_time_label(seconds: float) -> str:
230
+ """Format a time value for axis labels."""
231
+ if seconds < 60:
232
+ return f"{int(seconds)}s"
233
+ elif seconds < 3600:
234
+ minutes = int(seconds / 60)
235
+ return f"{minutes}m"
236
+ else:
237
+ hours = int(seconds / 3600)
238
+ return f"{hours}h"
239
+
240
+
241
+ def _get_time_axis_bounds(current_time: float) -> tuple[float, list[float], list[str]]:
242
+ """Get stable x-axis bounds and labeled positions.
243
+
244
+ Returns (max_time, positions, labels) where:
245
+ - max_time: the stable right boundary of the axis
246
+ - positions: numeric positions where axis labels should appear (e.g., [0, 60, 120])
247
+ - labels: formatted strings for each position (e.g., ["0s", "1m", "2m"])
248
+
249
+ The axis only rescales when current_time exceeds the current boundary.
250
+ """
251
+ if current_time <= 0:
252
+ ticks = [0.0, 10.0, 20.0, 30.0]
253
+ labels = [_format_time_label(t) for t in ticks]
254
+ return (30.0, ticks, labels)
255
+
256
+ # For the first 10 minutes, expand one minute at a time with 1-minute ticks
257
+ if current_time < 600:
258
+ # Round up to next minute
259
+ minutes = int(current_time / 60) + 1
260
+ max_time = float(minutes * 60)
261
+ interval = 60.0
262
+ else:
263
+ # After 10 minutes, use larger boundaries
264
+ # (boundary, tick_interval) - axis extends to boundary, ticks at interval
265
+ boundaries = [
266
+ (1800, 300), # 30m with 5m ticks
267
+ (3600, 600), # 1h with 10m ticks
268
+ (7200, 1200), # 2h with 20m ticks
269
+ (14400, 1800), # 4h with 30m ticks
270
+ (28800, 3600), # 8h with 1h ticks
271
+ ]
272
+
273
+ # Find the first boundary that exceeds current_time
274
+ max_time = 1800.0
275
+ interval = 300.0
276
+ for boundary, tick_interval in boundaries:
277
+ if current_time < boundary:
278
+ max_time = float(boundary)
279
+ interval = float(tick_interval)
280
+ break
281
+ else:
282
+ # Beyond 8h: extend in 4h increments with 1h ticks
283
+ hours = int(current_time / 14400) + 1
284
+ max_time = float(hours * 14400)
285
+ interval = 3600.0
286
+
287
+ # Generate ticks from 0 to max_time
288
+ ticks = []
289
+ t = 0.0
290
+ while t <= max_time:
291
+ ticks.append(t)
292
+ t += interval
293
+
294
+ labels = [_format_time_label(t) for t in ticks]
295
+ return (max_time, ticks, labels)
296
+
297
+
298
+ def _get_percentage_axis_bounds(
299
+ min_pct: float, max_pct: float
300
+ ) -> tuple[float, list[float], list[str]]:
301
+ """Get stable y-axis bounds for percentage values on log scale.
302
+
303
+ Returns (min_pct_bound, positions, labels) where:
304
+ - min_pct_bound: the stable lower boundary of the axis
305
+ - positions: log10 positions where axis labels should appear
306
+ - labels: formatted percentage strings for each position
307
+
308
+ The axis only rescales when min_pct gets close to the current lower boundary.
309
+ """
310
+ # Standard percentage boundaries (log scale friendly)
311
+ # Extended to handle very small reductions (below 0.01%)
312
+ boundaries = [
313
+ 100,
314
+ 50,
315
+ 20,
316
+ 10,
317
+ 5,
318
+ 2,
319
+ 1,
320
+ 0.5,
321
+ 0.2,
322
+ 0.1,
323
+ 0.05,
324
+ 0.02,
325
+ 0.01,
326
+ 0.005,
327
+ 0.002,
328
+ 0.001,
329
+ 0.0005,
330
+ 0.0002,
331
+ 0.0001,
332
+ ]
333
+
334
+ # Find the appropriate lower bound - use the first boundary below min_pct * 0.5
335
+ # This gives us some room before we need to rescale
336
+ lower_bound = boundaries[-1] # Default to smallest boundary
337
+ for b in boundaries:
338
+ if b < min_pct * 0.5:
339
+ lower_bound = b
340
+ break
341
+
342
+ # Find which percentage values to show as ticks (between lower_bound and 100%)
343
+ # Since boundaries always includes 100 and lower_bound <= 100, this is never empty
344
+ tick_pcts = [p for p in boundaries if p >= lower_bound and p <= 100]
345
+
346
+ # Convert to log scale
347
+ ticks = [math.log10(max(0.0001, p)) for p in tick_pcts]
348
+
349
+ # Format labels
350
+ labels = []
351
+ for p in tick_pcts:
352
+ if p >= 1:
353
+ labels.append(f"{p:.0f}%")
354
+ else:
355
+ labels.append(f"{p}%")
356
+
357
+ return (lower_bound, ticks, labels)
358
+
359
+
360
+ class SizeGraph(PlotextPlot):
361
+ """Widget to display test case size over time on a log scale."""
362
+
363
+ _size_history: list[tuple[float, int]]
364
+ _original_size: int
365
+ _current_runtime: float
366
+
367
+ def __init__(
368
+ self,
369
+ name: str | None = None,
370
+ id: str | None = None,
371
+ classes: str | None = None,
372
+ disabled: bool = False,
373
+ ) -> None:
374
+ super().__init__(name=name, id=id, classes=classes, disabled=disabled)
375
+ self._size_history = []
376
+ self._original_size = 0
377
+ self._current_runtime = 0.0
378
+
379
+ def update_graph(
380
+ self,
381
+ new_entries: list[tuple[float, int]],
382
+ original_size: int,
383
+ current_runtime: float,
384
+ ) -> None:
385
+ """Update the graph with new data."""
386
+ if new_entries:
387
+ self._size_history.extend(new_entries)
388
+ if original_size > 0:
389
+ self._original_size = original_size
390
+ self._current_runtime = current_runtime
391
+ self._setup_plot()
392
+ self.refresh()
393
+
394
+ def on_mount(self) -> None:
395
+ """Set up the plot on mount."""
396
+ self._setup_plot()
397
+
398
+ def on_resize(self) -> None:
399
+ """Redraw when resized."""
400
+ self._setup_plot()
401
+
402
+ def _setup_plot(self) -> None:
403
+ """Configure and draw the plot."""
404
+ plt = self.plt
405
+ plt.clear_figure()
406
+ plt.theme("dark")
407
+
408
+ if len(self._size_history) < 2 or self._original_size == 0:
409
+ plt.xlabel("Time")
410
+ plt.ylabel("% of original")
411
+ return
412
+
413
+ times = [t for t, _ in self._size_history]
414
+ sizes = [s for _, s in self._size_history]
415
+
416
+ # Calculate percentages of original size
417
+ percentages = [(s / self._original_size) * 100 for s in sizes]
418
+
419
+ # Use log scale for y-axis (percentages)
420
+ log_percentages = [math.log10(max(0.01, p)) for p in percentages]
421
+
422
+ plt.plot(times, log_percentages, marker="braille")
423
+
424
+ # Get stable x-axis bounds
425
+ max_time, x_ticks, x_labels = _get_time_axis_bounds(self._current_runtime)
426
+ plt.xticks(x_ticks, x_labels)
427
+ plt.xlim(0, max_time)
428
+
429
+ # Get stable y-axis bounds
430
+ min_pct = min(percentages)
431
+ lower_bound, y_ticks, y_labels = _get_percentage_axis_bounds(min_pct, 100)
432
+ plt.yticks(y_ticks, y_labels)
433
+ plt.ylim(math.log10(max(0.01, lower_bound)), math.log10(100))
434
+
435
+ plt.xlabel("Time")
436
+ plt.ylabel("% of original")
437
+
438
+ # Build to apply the plot
439
+ _ = plt.build()
440
+
441
+
226
442
  class ContentPreview(Static):
227
443
  """Widget to display the current test case content preview."""
228
444
 
@@ -313,21 +529,37 @@ class OutputPreview(Static):
313
529
 
314
530
  output_content = reactive("")
315
531
  active_test_id: reactive[int | None] = reactive(None)
532
+ last_return_code: reactive[int | None] = reactive(None)
316
533
  _last_update_time: float = 0.0
317
- _last_seen_test_id: int | None = None # Track last test ID for "completed" message
534
+ # Pending updates that haven't been applied yet (due to throttling)
535
+ _pending_content: str = ""
536
+ _pending_test_id: int | None = None
537
+ _pending_return_code: int | None = None
538
+ # Track if we've ever seen any output (once true, never show "No test output yet...")
539
+ _has_seen_output: bool = False
318
540
 
319
- def update_output(self, content: str, test_id: int | None) -> None:
320
- # Throttle updates to every 200ms
541
+ def update_output(
542
+ self, content: str, test_id: int | None, return_code: int | None = None
543
+ ) -> None:
544
+ # Only update pending content if there's actual content to show
545
+ # This prevents switching to empty output when we have previous output
546
+ if content:
547
+ self._pending_content = content
548
+ self._has_seen_output = True
549
+ self._pending_test_id = test_id
550
+ self._pending_return_code = return_code
551
+
552
+ # Throttle display updates to every 200ms
321
553
  now = time.time()
322
554
  if now - self._last_update_time < 0.2:
323
555
  return
324
556
 
325
557
  self._last_update_time = now
326
- self.output_content = content
327
- # Track the last test ID we've seen (for showing in "completed" message)
328
- if test_id is not None:
329
- self._last_seen_test_id = test_id
330
- self.active_test_id = test_id
558
+ # Only update output_content if we have new content
559
+ if self._pending_content:
560
+ self.output_content = self._pending_content
561
+ self.active_test_id = self._pending_test_id
562
+ self.last_return_code = self._pending_return_code
331
563
  self.refresh(layout=True)
332
564
 
333
565
  def _get_available_lines(self) -> int:
@@ -345,11 +577,15 @@ class OutputPreview(Static):
345
577
  return 30
346
578
 
347
579
  def render(self) -> str:
348
- # Header line
349
- if self.active_test_id is not None:
580
+ # Header line - use return_code to determine if test is running
581
+ # (return_code is None means still running, has value means completed)
582
+ if self.active_test_id is not None and self.last_return_code is None:
350
583
  header = f"[green]Test #{self.active_test_id} running...[/green]"
351
- elif self.output_content and self._last_seen_test_id is not None:
352
- header = f"[dim]Test #{self._last_seen_test_id} completed[/dim]"
584
+ elif self.active_test_id is not None:
585
+ header = f"[dim]Test #{self.active_test_id} exited with code {self.last_return_code}[/dim]"
586
+ elif self._has_seen_output or self.output_content:
587
+ # Have seen output before - show without header
588
+ header = ""
353
589
  else:
354
590
  header = "[dim]No test output yet...[/dim]"
355
591
 
@@ -359,14 +595,17 @@ class OutputPreview(Static):
359
595
  available_lines = self._get_available_lines()
360
596
  lines = self.output_content.split("\n")
361
597
 
598
+ # Build prefix (header + newline, or empty if no header)
599
+ prefix = f"{header}\n" if header else ""
600
+
362
601
  # Show tail of output (most recent lines)
363
602
  if len(lines) <= available_lines:
364
- return f"{header}\n{self.output_content}"
603
+ return f"{prefix}{self.output_content}"
365
604
 
366
605
  # Truncate from the beginning
367
606
  truncated_lines = lines[-(available_lines):]
368
607
  skipped = len(lines) - available_lines
369
- return f"{header}\n... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
608
+ return f"{prefix}... ({skipped} earlier lines)\n" + "\n".join(truncated_lines)
370
609
 
371
610
 
372
611
  class HelpScreen(ModalScreen[None]):
@@ -424,6 +663,169 @@ class HelpScreen(ModalScreen[None]):
424
663
  yield Static("[dim]Press any key to close[/dim]")
425
664
 
426
665
 
666
+ class ExpandedBoxModal(ModalScreen[None]):
667
+ """Modal screen showing an expanded view of a content box."""
668
+
669
+ CSS = """
670
+ ExpandedBoxModal {
671
+ align: center middle;
672
+ }
673
+
674
+ ExpandedBoxModal > Vertical {
675
+ width: 95%;
676
+ height: 90%;
677
+ background: $panel;
678
+ border: thick $primary;
679
+ padding: 0 1 1 1;
680
+ }
681
+
682
+ ExpandedBoxModal #expanded-title {
683
+ text-align: center;
684
+ text-style: bold;
685
+ height: auto;
686
+ width: 100%;
687
+ border-bottom: solid $primary;
688
+ padding: 0;
689
+ margin-bottom: 1;
690
+ }
691
+
692
+ ExpandedBoxModal VerticalScroll {
693
+ width: 100%;
694
+ height: 1fr;
695
+ }
696
+
697
+ ExpandedBoxModal #expanded-content {
698
+ width: 100%;
699
+ }
700
+
701
+ ExpandedBoxModal #expanded-graph {
702
+ width: 100%;
703
+ height: 1fr;
704
+ }
705
+ """
706
+
707
+ BINDINGS = [
708
+ ("escape,enter,q", "dismiss", "Close"),
709
+ ]
710
+
711
+ def __init__(
712
+ self, title: str, content_widget_id: str, file_path: str | None = None
713
+ ) -> None:
714
+ super().__init__()
715
+ self._title = title
716
+ self._content_widget_id = content_widget_id
717
+ self._file_path = file_path
718
+
719
+ def _read_file(self, file_path: str) -> str:
720
+ """Read file content, decoding as text if possible."""
721
+ with open(file_path, "rb") as f:
722
+ raw_content = f.read()
723
+ # Try to decode as text, fall back to hex display if binary
724
+ encoding, text = try_decode(raw_content)
725
+ if encoding is not None:
726
+ return text
727
+ return "[Binary content - hex display]\n\n" + raw_content.hex()
728
+
729
+ def compose(self) -> ComposeResult:
730
+ with Vertical():
731
+ yield Label(self._title, id="expanded-title")
732
+ if self._content_widget_id == "graph-container":
733
+ # For graph, create a new SizeGraph widget
734
+ yield SizeGraph(id="expanded-graph")
735
+ else:
736
+ # For other content, use a scrollable static
737
+ with VerticalScroll():
738
+ yield Static("", id="expanded-content")
739
+
740
+ def _get_graph_content(self, app: "ShrinkRayApp") -> None:
741
+ """Copy graph data from main graph to expanded graph."""
742
+ main_graphs = list(app.query("#size-graph").results(SizeGraph))
743
+ expanded_graphs = list(self.query("#expanded-graph").results(SizeGraph))
744
+ if not main_graphs or not expanded_graphs:
745
+ return
746
+ main_graph = main_graphs[0]
747
+ expanded_graph = expanded_graphs[0]
748
+ expanded_graph._size_history = main_graph._size_history.copy()
749
+ expanded_graph._original_size = main_graph._original_size
750
+ expanded_graph._current_runtime = main_graph._current_runtime
751
+ expanded_graph._setup_plot()
752
+
753
+ def _get_stats_content(self, app: "ShrinkRayApp") -> str:
754
+ """Get stats content from the stats display widget."""
755
+ stats_displays = list(app.query("#stats-display").results(StatsDisplay))
756
+ if not stats_displays:
757
+ return "Statistics not available"
758
+ return stats_displays[0].render()
759
+
760
+ def _get_file_content(self, app: "ShrinkRayApp") -> str:
761
+ """Get content from file or preview widget."""
762
+ if self._file_path:
763
+ return self._read_file(self._file_path)
764
+ content_previews = list(app.query("#content-preview").results(ContentPreview))
765
+ if not content_previews:
766
+ return "Content preview not available"
767
+ return content_previews[0].preview_content
768
+
769
+ def _get_output_content(self, app: "ShrinkRayApp") -> str:
770
+ """Get output content from the output preview widget."""
771
+ output_previews = list(app.query("#output-preview").results(OutputPreview))
772
+ if not output_previews:
773
+ return "Output not available"
774
+ output_preview = output_previews[0]
775
+
776
+ # Use pending values (most recent) rather than throttled values
777
+ raw_content = output_preview._pending_content or output_preview.output_content
778
+ test_id = (
779
+ output_preview._pending_test_id
780
+ if output_preview._pending_test_id is not None
781
+ else output_preview.active_test_id
782
+ )
783
+ return_code = (
784
+ output_preview._pending_return_code
785
+ if output_preview._pending_return_code is not None
786
+ else output_preview.last_return_code
787
+ )
788
+ has_seen_output = output_preview._has_seen_output
789
+
790
+ # Build header - return_code is None means test is still running
791
+ if test_id is not None and return_code is None:
792
+ header = f"[green]Test #{test_id} running...[/green]\n\n"
793
+ elif test_id is not None:
794
+ header = f"[dim]Test #{test_id} exited with code {return_code}[/dim]\n\n"
795
+ else:
796
+ header = ""
797
+
798
+ if raw_content:
799
+ return header + raw_content
800
+ elif has_seen_output or test_id is not None:
801
+ # We've seen output before - show header only (no "No test output" message)
802
+ return header.rstrip("\n") if header else ""
803
+ else:
804
+ return "[dim]No test output yet...[/dim]"
805
+
806
+ def on_mount(self) -> None:
807
+ """Populate content from the source widget."""
808
+ # Cast is safe because this modal is only used within ShrinkRayApp
809
+ app = cast("ShrinkRayApp", self.app)
810
+
811
+ if self._content_widget_id == "graph-container":
812
+ self._get_graph_content(app)
813
+ return
814
+
815
+ # For non-graph content, populate the static
816
+ # compose() always creates the #expanded-content widget for non-graph modals
817
+ if self._content_widget_id == "stats-container":
818
+ content = self._get_stats_content(app)
819
+ elif self._content_widget_id == "content-container":
820
+ content = self._get_file_content(app)
821
+ elif self._content_widget_id == "output-container":
822
+ content = self._get_output_content(app)
823
+ else:
824
+ content = ""
825
+
826
+ self.query_one("#expanded-content", Static).update(content)
827
+
828
+
427
829
  class PassStatsScreen(ModalScreen[None]):
428
830
  """Modal screen showing pass statistics in a table."""
429
831
 
@@ -647,16 +1049,42 @@ class ShrinkRayApp(App[None]):
647
1049
  height: 100%;
648
1050
  }
649
1051
 
1052
+ #status-label {
1053
+ text-style: bold;
1054
+ margin: 0 1;
1055
+ }
1056
+
1057
+ #stats-area {
1058
+ height: 1fr;
1059
+ }
1060
+
650
1061
  #stats-container {
651
- height: auto;
652
- border: solid green;
1062
+ border: solid $primary;
1063
+ margin: 0;
1064
+ padding: 1;
1065
+ width: 1fr;
1066
+ height: 100%;
1067
+ }
1068
+
1069
+ #stats-container:focus {
1070
+ border: thick $primary;
1071
+ }
1072
+
1073
+ #graph-container {
1074
+ border: solid $primary;
1075
+ margin: 0;
653
1076
  padding: 1;
654
- margin: 1;
1077
+ width: 1fr;
1078
+ height: 100%;
655
1079
  }
656
1080
 
657
- #status-label {
658
- text-style: bold;
659
- margin: 0 1;
1081
+ #graph-container:focus {
1082
+ border: thick $primary;
1083
+ }
1084
+
1085
+ #size-graph {
1086
+ width: 100%;
1087
+ height: 100%;
660
1088
  }
661
1089
 
662
1090
  #content-area {
@@ -664,27 +1092,27 @@ class ShrinkRayApp(App[None]):
664
1092
  }
665
1093
 
666
1094
  #content-container {
667
- border: solid blue;
668
- margin: 1;
1095
+ border: solid $primary;
1096
+ margin: 0;
669
1097
  padding: 1;
670
1098
  width: 1fr;
671
1099
  height: 100%;
672
1100
  }
673
1101
 
674
- #content-container:dark {
675
- border: solid lightskyblue;
1102
+ #content-container:focus {
1103
+ border: thick $primary;
676
1104
  }
677
1105
 
678
1106
  #output-container {
679
- border: solid blue;
680
- margin: 1;
1107
+ border: solid $primary;
1108
+ margin: 0;
681
1109
  padding: 1;
682
1110
  width: 1fr;
683
1111
  height: 100%;
684
1112
  }
685
1113
 
686
- #output-container:dark {
687
- border: solid lightskyblue;
1114
+ #output-container:focus {
1115
+ border: thick $primary;
688
1116
  }
689
1117
  """
690
1118
 
@@ -693,6 +1121,11 @@ class ShrinkRayApp(App[None]):
693
1121
  ("p", "show_pass_stats", "Pass Stats"),
694
1122
  ("c", "skip_current_pass", "Skip Pass"),
695
1123
  ("h", "show_help", "Help"),
1124
+ ("up", "focus_up", "Focus Up"),
1125
+ ("down", "focus_down", "Focus Down"),
1126
+ ("left", "focus_left", "Focus Left"),
1127
+ ("right", "focus_right", "Focus Right"),
1128
+ ("enter", "expand_box", "Expand"),
696
1129
  ]
697
1130
 
698
1131
  ENABLE_COMMAND_PALETTE = False
@@ -737,6 +1170,14 @@ class ShrinkRayApp(App[None]):
737
1170
  self._current_pass_name: str = ""
738
1171
  self._disabled_passes: list[str] = []
739
1172
 
1173
+ # Box IDs in navigation order: [top-left, top-right, bottom-left, bottom-right]
1174
+ _BOX_IDS = [
1175
+ "stats-container",
1176
+ "graph-container",
1177
+ "content-container",
1178
+ "output-container",
1179
+ ]
1180
+
740
1181
  def compose(self) -> ComposeResult:
741
1182
  yield Header()
742
1183
  with Vertical(id="main-container"):
@@ -745,14 +1186,23 @@ class ShrinkRayApp(App[None]):
745
1186
  id="status-label",
746
1187
  markup=False,
747
1188
  )
748
- with Vertical(id="stats-container"):
749
- yield StatsDisplay(id="stats-display")
1189
+ with Horizontal(id="stats-area"):
1190
+ with VerticalScroll(id="stats-container") as stats_scroll:
1191
+ stats_scroll.border_title = "Statistics"
1192
+ stats_scroll.can_focus = True
1193
+ yield StatsDisplay(id="stats-display")
1194
+ with Vertical(id="graph-container") as graph_container:
1195
+ graph_container.border_title = "Size Over Time"
1196
+ graph_container.can_focus = True
1197
+ yield SizeGraph(id="size-graph")
750
1198
  with Horizontal(id="content-area"):
751
1199
  with VerticalScroll(id="content-container") as content_scroll:
752
1200
  content_scroll.border_title = "Recent Reductions"
1201
+ content_scroll.can_focus = True
753
1202
  yield ContentPreview(id="content-preview")
754
1203
  with VerticalScroll(id="output-container") as output_scroll:
755
1204
  output_scroll.border_title = "Test Output"
1205
+ output_scroll.can_focus = True
756
1206
  yield OutputPreview(id="output-preview")
757
1207
  yield Footer()
758
1208
 
@@ -772,8 +1222,81 @@ class ShrinkRayApp(App[None]):
772
1222
 
773
1223
  self.title = "Shrink Ray"
774
1224
  self.sub_title = self._file_path
1225
+
1226
+ # Set initial focus to first box
1227
+ self.query_one("#stats-container").focus()
1228
+
775
1229
  self.run_reduction()
776
1230
 
1231
+ def _get_focused_box_index(self) -> int:
1232
+ """Get the index of the currently focused box, or 0 if none."""
1233
+ for i, box_id in enumerate(self._BOX_IDS):
1234
+ boxes = list(self.query(f"#{box_id}"))
1235
+ if boxes and boxes[0].has_focus:
1236
+ return i
1237
+ return 0
1238
+
1239
+ def _focus_box(self, index: int) -> None:
1240
+ """Focus the box at the given index (with wrapping)."""
1241
+ index = index % len(self._BOX_IDS)
1242
+ box_id = self._BOX_IDS[index]
1243
+ self.query_one(f"#{box_id}").focus()
1244
+
1245
+ def action_focus_up(self) -> None:
1246
+ """Move focus to the box above."""
1247
+ current = self._get_focused_box_index()
1248
+ # Grid is 2x2: top row is 0,1; bottom row is 2,3
1249
+ # Moving up: 2->0, 3->1, 0->2, 1->3 (wraps)
1250
+ if current >= 2:
1251
+ self._focus_box(current - 2)
1252
+ else:
1253
+ self._focus_box(current + 2)
1254
+
1255
+ def action_focus_down(self) -> None:
1256
+ """Move focus to the box below."""
1257
+ current = self._get_focused_box_index()
1258
+ # Moving down: 0->2, 1->3, 2->0, 3->1 (wraps)
1259
+ if current < 2:
1260
+ self._focus_box(current + 2)
1261
+ else:
1262
+ self._focus_box(current - 2)
1263
+
1264
+ def action_focus_left(self) -> None:
1265
+ """Move focus to the box on the left."""
1266
+ current = self._get_focused_box_index()
1267
+ # Moving left within row: 0->1, 1->0, 2->3, 3->2 (wraps within row)
1268
+ if current % 2 == 0:
1269
+ self._focus_box(current + 1)
1270
+ else:
1271
+ self._focus_box(current - 1)
1272
+
1273
+ def action_focus_right(self) -> None:
1274
+ """Move focus to the box on the right."""
1275
+ current = self._get_focused_box_index()
1276
+ # Moving right within row: 0->1, 1->0, 2->3, 3->2 (wraps within row)
1277
+ if current % 2 == 0:
1278
+ self._focus_box(current + 1)
1279
+ else:
1280
+ self._focus_box(current - 1)
1281
+
1282
+ def action_expand_box(self) -> None:
1283
+ """Expand the currently focused box to a modal."""
1284
+ current = self._get_focused_box_index()
1285
+ box_id = self._BOX_IDS[current]
1286
+
1287
+ # Get the title from the container's border_title
1288
+ titles = {
1289
+ "stats-container": "Statistics",
1290
+ "graph-container": "Size Over Time",
1291
+ "content-container": "Current Test Case",
1292
+ "output-container": "Test Output",
1293
+ }
1294
+ title = titles.get(box_id, "Details")
1295
+
1296
+ # Pass file_path for content-container to enable full file reading
1297
+ file_path = self._file_path if box_id == "content-container" else None
1298
+ self.push_screen(ExpandedBoxModal(title, box_id, file_path=file_path))
1299
+
777
1300
  @work(exclusive=True)
778
1301
  async def run_reduction(self) -> None:
779
1302
  """Start the reduction subprocess and monitor progress."""
@@ -812,6 +1335,7 @@ class ShrinkRayApp(App[None]):
812
1335
  stats_display = self.query_one("#stats-display", StatsDisplay)
813
1336
  content_preview = self.query_one("#content-preview", ContentPreview)
814
1337
  output_preview = self.query_one("#output-preview", OutputPreview)
1338
+ size_graph = self.query_one("#size-graph", SizeGraph)
815
1339
 
816
1340
  async with aclosing(self._client.get_progress_updates()) as updates:
817
1341
  async for update in updates:
@@ -820,8 +1344,22 @@ class ShrinkRayApp(App[None]):
820
1344
  update.content_preview, update.hex_mode
821
1345
  )
822
1346
  output_preview.update_output(
823
- update.test_output_preview, update.active_test_id
1347
+ update.test_output_preview,
1348
+ update.active_test_id,
1349
+ update.last_test_return_code,
824
1350
  )
1351
+ size_graph.update_graph(
1352
+ update.new_size_history,
1353
+ update.original_size,
1354
+ update.runtime,
1355
+ )
1356
+ # Also update expanded modals if they exist
1357
+ self._update_expanded_graph(
1358
+ update.new_size_history,
1359
+ update.original_size,
1360
+ update.runtime,
1361
+ )
1362
+ self._update_expanded_stats()
825
1363
  self._latest_pass_stats = update.pass_stats
826
1364
  self._current_pass_name = update.current_pass_name
827
1365
  self._disabled_passes = update.disabled_passes
@@ -870,6 +1408,41 @@ class ShrinkRayApp(App[None]):
870
1408
  except Exception:
871
1409
  pass # Widget not yet mounted
872
1410
 
1411
+ def _update_expanded_graph(
1412
+ self,
1413
+ new_entries: list[tuple[float, int]],
1414
+ original_size: int,
1415
+ current_runtime: float,
1416
+ ) -> None:
1417
+ """Update the expanded graph if it exists in a modal screen."""
1418
+ # Check if there's an ExpandedBoxModal for the graph on the screen stack
1419
+ for screen in self.screen_stack:
1420
+ if isinstance(screen, ExpandedBoxModal):
1421
+ if screen._content_widget_id == "graph-container":
1422
+ expanded_graphs = list(
1423
+ screen.query("#expanded-graph").results(SizeGraph)
1424
+ )
1425
+ if expanded_graphs:
1426
+ expanded_graphs[0].update_graph(
1427
+ new_entries, original_size, current_runtime
1428
+ )
1429
+ break
1430
+
1431
+ def _update_expanded_stats(self) -> None:
1432
+ """Update the expanded stats if it exists in a modal screen."""
1433
+ for screen in self.screen_stack:
1434
+ if isinstance(screen, ExpandedBoxModal):
1435
+ if screen._content_widget_id == "stats-container":
1436
+ stats_displays = list(
1437
+ self.query("#stats-display").results(StatsDisplay)
1438
+ )
1439
+ expanded_contents = list(
1440
+ screen.query("#expanded-content").results(Static)
1441
+ )
1442
+ if stats_displays and expanded_contents:
1443
+ expanded_contents[0].update(stats_displays[0].render())
1444
+ break
1445
+
873
1446
  async def action_quit(self) -> None:
874
1447
  """Quit the application with graceful cancellation."""
875
1448
  if self._client and not self._completed:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: shrinkray
3
- Version: 25.12.28.0
3
+ Version: 25.12.29.0
4
4
  Summary: Shrink Ray
5
5
  Author-email: "David R. MacIver" <david@drmaciver.com>
6
6
  License: MIT
@@ -16,6 +16,7 @@ Requires-Dist: click>=8.0.1
16
16
  Requires-Dist: chardet>=5.2.0
17
17
  Requires-Dist: trio>=0.28.0
18
18
  Requires-Dist: textual>=0.47.0
19
+ Requires-Dist: textual-plotext>=0.2.0
19
20
  Requires-Dist: humanize>=4.9.0
20
21
  Requires-Dist: libcst>=1.1.0
21
22
  Requires-Dist: exceptiongroup>=1.2.0
@@ -28,6 +29,8 @@ Requires-Dist: hypothesmith>=0.3.1; extra == "dev"
28
29
  Requires-Dist: pytest>=8.0.0; extra == "dev"
29
30
  Requires-Dist: pytest-trio>=0.8.0; extra == "dev"
30
31
  Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
32
+ Requires-Dist: pytest-xdist>=3.5.0; extra == "dev"
33
+ Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
31
34
  Requires-Dist: syrupy>=5.0.0; extra == "dev"
32
35
  Requires-Dist: jinja2>=3.0.0; extra == "dev"
33
36
  Requires-Dist: coverage[toml]>=7.4.0; extra == "dev"
@@ -78,7 +81,12 @@ shrinkray (or any other test-case reducer) then systematically tries smaller and
78
81
 
79
82
  While it runs, you will see the following user interface:
80
83
 
81
- ![Demo of shrink ray running](demo.png)
84
+ <video controls poster="gallery/enterprise-hello/hello.png">
85
+ <source src="https://drmaciver.github.io/shrinkray/assets/hello.mp4" type="video/mp4">
86
+ Your browser doesn't support video. <a href="gallery/enterprise-hello/hello.gif">View the GIF instead</a>.
87
+ </video>
88
+
89
+ (This is a toy example based on reducing a ridiculously bad version of hello world)
82
90
 
83
91
  When it finishes you will be left with the reduced test case in `mytestcase.py`.
84
92
 
@@ -6,8 +6,8 @@ shrinkray/problem.py,sha256=_edENYk8OC5o_2ng0WZrhIfilhlY5IuOrqt0qWBZAuM,25979
6
6
  shrinkray/process.py,sha256=-eP8h5X0ESbkcTic8FFEzkd4-vwaZ0YI5tLxUR25L8U,1599
7
7
  shrinkray/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
8
  shrinkray/reducer.py,sha256=8CF_SxkfVMBxiikQKwv-rlrQawTLhQxy6QnVwIWWiws,18601
9
- shrinkray/state.py,sha256=V5vUyBKXudLoMe3TvGJwWf6EDzdVr2TRrmdNyU13qio,29565
10
- shrinkray/tui.py,sha256=Ovd6Kbk_cmOYJlwnjEyl6ka_2g1qJAtwxojh5CWEzNA,33001
9
+ shrinkray/state.py,sha256=rrQQj30eHkFb1yTkAtwxJzZ70HvCYRzOtgM7pYAdFPY,29607
10
+ shrinkray/tui.py,sha256=cSq-tnr9hNFvM6xHxGcGZXf-wH_TsB6OZKTSRQRppQM,53250
11
11
  shrinkray/ui.py,sha256=xuDUwU-MM3AetvwUB7bfzav0P_drUsBrKFPhON_Nr-k,2251
12
12
  shrinkray/validation.py,sha256=piBCO-k9he_id6TWC4EHMK3GfuyPqRcNfkNJPVjxEaU,13366
13
13
  shrinkray/work.py,sha256=GEZ14Kk3bvwUxAnACvY-wom2lVWaGrELMNxrDjv03dk,8110
@@ -23,11 +23,11 @@ shrinkray/passes/sat.py,sha256=OboY6jsKf6lph3pAFh535plvhNOVzEF8HJ66WEqsNm4,19483
23
23
  shrinkray/passes/sequences.py,sha256=-5ajmMeHnS7onjjppbxLiP0F6mRSqiFI5DspBTj2x_M,2206
24
24
  shrinkray/subprocess/__init__.py,sha256=qxZ19Nzizbm7H0MkKL38OqfP7U-VuOAvaqBVkmHFivY,375
25
25
  shrinkray/subprocess/client.py,sha256=abBkrXaJcA6cd3l_avPuteO_kYnjU5IRG7VtlmiAJgE,9428
26
- shrinkray/subprocess/protocol.py,sha256=di4vXIFcCSs2ej1E4Y3WeYXsNoo4VsBjEIeogf5AYQY,6336
27
- shrinkray/subprocess/worker.py,sha256=0D719AHSF-oL5iifG6x4xYeUCMxEUZ_QXrDrogC901U,21238
28
- shrinkray-25.12.28.0.dist-info/licenses/LICENSE,sha256=iMKX79AuokJfIZUnGUARdUp30vVAoIPOJ7ek8TY63kk,1072
29
- shrinkray-25.12.28.0.dist-info/METADATA,sha256=pwliGh5xlyr_LLxhuqsoCnl_pl77NYLaB8EqCIt4iBU,7600
30
- shrinkray-25.12.28.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
- shrinkray-25.12.28.0.dist-info/entry_points.txt,sha256=wIZvnGyOdVeaLTiv2klnSyTe-EKkkwn4SwHh9bmJ7qk,104
32
- shrinkray-25.12.28.0.dist-info/top_level.txt,sha256=fLif8-rFoFOnf5h8-vs3ECkKNWQopTQh3xvl1s7pchQ,10
33
- shrinkray-25.12.28.0.dist-info/RECORD,,
26
+ shrinkray/subprocess/protocol.py,sha256=ow_BqMN82Z4QY19s2C8ifx4Tf4xpbypbYy-YaKgSOYk,6940
27
+ shrinkray/subprocess/worker.py,sha256=s-3D-2keFmOGElPEdvZTOSBWS6C7P2I_Ti9PGDxWjYM,23225
28
+ shrinkray-25.12.29.0.dist-info/licenses/LICENSE,sha256=iMKX79AuokJfIZUnGUARdUp30vVAoIPOJ7ek8TY63kk,1072
29
+ shrinkray-25.12.29.0.dist-info/METADATA,sha256=kXk3VCnESHkuX7Rv6Q2G3PtTqOCg1trCnhPgxnbZm4Y,8051
30
+ shrinkray-25.12.29.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
31
+ shrinkray-25.12.29.0.dist-info/entry_points.txt,sha256=wIZvnGyOdVeaLTiv2klnSyTe-EKkkwn4SwHh9bmJ7qk,104
32
+ shrinkray-25.12.29.0.dist-info/top_level.txt,sha256=fLif8-rFoFOnf5h8-vs3ECkKNWQopTQh3xvl1s7pchQ,10
33
+ shrinkray-25.12.29.0.dist-info/RECORD,,