shrinkray 25.12.27.3__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/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: