wafer-core 0.1.37__py3-none-any.whl → 0.1.39__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.
Files changed (32) hide show
  1. wafer_core/lib/trace_compare/fusion_analyzer.py +2 -0
  2. wafer_core/rollouts/_logging/__init__.py +5 -1
  3. wafer_core/rollouts/_logging/logging_config.py +95 -3
  4. wafer_core/rollouts/_logging/sample_handler.py +66 -0
  5. wafer_core/rollouts/_pytui/__init__.py +114 -0
  6. wafer_core/rollouts/_pytui/app.py +809 -0
  7. wafer_core/rollouts/_pytui/console.py +291 -0
  8. wafer_core/rollouts/_pytui/renderer.py +210 -0
  9. wafer_core/rollouts/_pytui/spinner.py +73 -0
  10. wafer_core/rollouts/_pytui/terminal.py +489 -0
  11. wafer_core/rollouts/_pytui/text.py +470 -0
  12. wafer_core/rollouts/_pytui/theme.py +241 -0
  13. wafer_core/rollouts/evaluation.py +142 -177
  14. wafer_core/rollouts/progress_app.py +395 -0
  15. wafer_core/rollouts/tui/DESIGN.md +251 -115
  16. wafer_core/rollouts/tui/monitor.py +64 -20
  17. wafer_core/tools/compile/__init__.py +30 -0
  18. wafer_core/tools/compile/compiler.py +314 -0
  19. wafer_core/tools/compile/modal_compile.py +359 -0
  20. wafer_core/tools/compile/tests/__init__.py +1 -0
  21. wafer_core/tools/compile/tests/test_compiler.py +675 -0
  22. wafer_core/tools/compile/tests/test_data/utils.cuh +10 -0
  23. wafer_core/tools/compile/tests/test_data/vector_add.cu +7 -0
  24. wafer_core/tools/compile/tests/test_data/with_header.cu +9 -0
  25. wafer_core/tools/compile/tests/test_modal_integration.py +326 -0
  26. wafer_core/tools/compile/types.py +117 -0
  27. {wafer_core-0.1.37.dist-info → wafer_core-0.1.39.dist-info}/METADATA +1 -1
  28. {wafer_core-0.1.37.dist-info → wafer_core-0.1.39.dist-info}/RECORD +29 -12
  29. wafer_core/rollouts/events.py +0 -240
  30. wafer_core/rollouts/progress_display.py +0 -476
  31. wafer_core/utils/event_streaming.py +0 -63
  32. {wafer_core-0.1.37.dist-info → wafer_core-0.1.39.dist-info}/WHEEL +0 -0
@@ -0,0 +1,809 @@
1
+ """Elm-style TUI application runtime.
2
+
3
+ Provides the App class that runs the Model-Update-View loop,
4
+ plus Cmd and Sub types for side effects and subscriptions.
5
+
6
+ Usage:
7
+ from dataclasses import dataclass, replace
8
+ from pytui import App, Cmd, Sub, KeyPress
9
+
10
+ @dataclass(frozen=True)
11
+ class Model:
12
+ count: int = 0
13
+
14
+ def update(model: Model, msg: object) -> tuple[Model, Cmd]:
15
+ match msg:
16
+ case KeyPress(key="q"):
17
+ return model, Cmd.quit()
18
+ case KeyPress(key="j"):
19
+ return replace(model, count=model.count + 1), Cmd.none()
20
+ return model, Cmd.none()
21
+
22
+ def view(model: Model, width: int, height: int) -> list[str]:
23
+ return [f"Count: {model.count}", "", "j: increment q: quit"]
24
+
25
+ App(init=(Model(), Cmd.none()), update=update, view=view).run()
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import queue
33
+ import select
34
+ import threading
35
+ import time
36
+ from collections.abc import Callable
37
+ from dataclasses import dataclass
38
+ from datetime import datetime
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ from .renderer import RenderState, diff_render
43
+ from .terminal import Terminal
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Debug logging (file-based, not stderr)
47
+ # ---------------------------------------------------------------------------
48
+
49
+ _DEBUG_LOG: Path | None = None
50
+
51
+
52
+ def _log(event: str, **data: Any) -> None:
53
+ """Append a debug event to the log file (if enabled)."""
54
+ if _DEBUG_LOG is None:
55
+ return
56
+ entry = {
57
+ "ts": datetime.now().isoformat(),
58
+ "event": event,
59
+ **data,
60
+ }
61
+ with open(_DEBUG_LOG, "a") as f:
62
+ f.write(json.dumps(entry) + "\n")
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Built-in messages (sent by runtime)
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class KeyPress:
72
+ """Keyboard input. key is the raw string (e.g. "j", "\x1b[A")."""
73
+
74
+ key: str
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Resize:
79
+ """Terminal was resized."""
80
+
81
+ width: int
82
+ height: int
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class MouseEvent:
87
+ """Mouse event (button press, release, wheel scroll).
88
+
89
+ Button values:
90
+ 0 = left click
91
+ 1 = middle click
92
+ 2 = right click
93
+ 64 = wheel up
94
+ 65 = wheel down
95
+ 66 = wheel left
96
+ 67 = wheel right
97
+
98
+ Action values:
99
+ "press" = button pressed
100
+ "release" = button released
101
+ "motion" = mouse moved while button held
102
+ """
103
+
104
+ button: int
105
+ x: int # 1-indexed column
106
+ y: int # 1-indexed row
107
+ action: str # "press", "release", or "motion"
108
+
109
+ @property
110
+ def is_wheel_up(self) -> bool:
111
+ return self.button == 64
112
+
113
+ @property
114
+ def is_wheel_down(self) -> bool:
115
+ return self.button == 65
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class PasteEvent:
120
+ """Bracketed paste content.
121
+
122
+ When bracketed paste mode is enabled, pasted text is wrapped in
123
+ escape sequences so it can be distinguished from typed input.
124
+ This prevents pasted text from triggering keybindings.
125
+ """
126
+
127
+ text: str
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class FocusEvent:
132
+ """Terminal focus change.
133
+
134
+ Sent when the terminal window gains or loses focus.
135
+ Requires focus reporting to be enabled.
136
+ """
137
+
138
+ focused: bool # True = gained focus, False = lost focus
139
+
140
+
141
+ def _parse_mouse_sgr(seq: str) -> MouseEvent | None:
142
+ """Parse SGR mouse sequence: ESC [ < Btn ; X ; Y M/m
143
+
144
+ Returns MouseEvent or None if not a valid mouse sequence.
145
+ """
146
+ import re
147
+
148
+ # SGR format: \x1b[<btn;x;y[Mm]
149
+ match = re.match(r"\x1b\[<(\d+);(\d+);(\d+)([Mm])", seq)
150
+ if not match:
151
+ return None
152
+
153
+ btn = int(match.group(1))
154
+ x = int(match.group(2))
155
+ y = int(match.group(3))
156
+ release = match.group(4) == "m"
157
+
158
+ # Decode button and modifiers
159
+ # Bits 0-1: button (0=left, 1=middle, 2=right)
160
+ # Bit 5: motion
161
+ # Bits 6-7: wheel (64=up, 65=down)
162
+ action = "release" if release else "press"
163
+ if btn & 32:
164
+ action = "motion"
165
+
166
+ return MouseEvent(button=btn & ~32, x=x, y=y, action=action)
167
+
168
+
169
+ def _parse_focus(seq: str) -> FocusEvent | None:
170
+ """Parse focus event: ESC [ I (focus) or ESC [ O (blur)."""
171
+ if seq == "\x1b[I":
172
+ return FocusEvent(focused=True)
173
+ if seq == "\x1b[O":
174
+ return FocusEvent(focused=False)
175
+ return None
176
+
177
+
178
+ # Bracketed paste markers
179
+ _PASTE_START = "\x1b[200~"
180
+ _PASTE_END = "\x1b[201~"
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Cmd: side effect descriptors
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ @dataclass(frozen=True)
189
+ class Cmd:
190
+ """Side effect descriptor. Created by update(), executed by runtime.
191
+
192
+ Users should only create Cmd values via the static methods.
193
+ """
194
+
195
+ _kind: str = "none"
196
+ _data: Any = None
197
+
198
+ @staticmethod
199
+ def none() -> Cmd:
200
+ """No side effect."""
201
+ return Cmd()
202
+
203
+ @staticmethod
204
+ def quit() -> Cmd:
205
+ """Exit the application."""
206
+ return Cmd(_kind="quit")
207
+
208
+ @staticmethod
209
+ def batch(*cmds: Cmd) -> Cmd:
210
+ """Run multiple commands."""
211
+ # Flatten: skip nones, unwrap single
212
+ real = [c for c in cmds if c._kind != "none"]
213
+ if not real:
214
+ return Cmd.none()
215
+ if len(real) == 1:
216
+ return real[0]
217
+ return Cmd(_kind="batch", _data=tuple(real))
218
+
219
+ @staticmethod
220
+ def task(fn: Callable[[], object]) -> Cmd:
221
+ """Run fn() in a background thread. Result is sent as a message.
222
+
223
+ fn must be safe to call from a thread. The return value becomes
224
+ the next message dispatched to update().
225
+ """
226
+ return Cmd(_kind="task", _data=fn)
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Sub: subscription descriptors
231
+ # ---------------------------------------------------------------------------
232
+
233
+
234
+ @dataclass(frozen=True)
235
+ class Sub:
236
+ """Subscription descriptor. Long-running sources of messages.
237
+
238
+ Users should only create Sub values via the static methods.
239
+ """
240
+
241
+ _kind: str = "none"
242
+ _data: Any = None
243
+
244
+ @staticmethod
245
+ def none() -> Sub:
246
+ """No subscription."""
247
+ return Sub()
248
+
249
+ @staticmethod
250
+ def every(interval_sec: float, msg_fn: Callable[[], object]) -> Sub:
251
+ """Call msg_fn() every interval_sec seconds, send result as message."""
252
+ return Sub(_kind="every", _data=(interval_sec, msg_fn))
253
+
254
+ @staticmethod
255
+ def file_tail(path: str, msg_fn: Callable[[str], object]) -> Sub:
256
+ """Tail a file. msg_fn(line) called for each new line appended."""
257
+ return Sub(_kind="file_tail", _data=(path, msg_fn))
258
+
259
+ @staticmethod
260
+ def batch(*subs: Sub) -> Sub:
261
+ """Combine multiple subscriptions."""
262
+ real = [s for s in subs if s._kind != "none"]
263
+ if not real:
264
+ return Sub.none()
265
+ if len(real) == 1:
266
+ return real[0]
267
+ return Sub(_kind="batch", _data=tuple(real))
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # Internal: subscription identity for lifecycle management
272
+ # ---------------------------------------------------------------------------
273
+
274
+
275
+ def _sub_key(sub: Sub) -> tuple:
276
+ """Identity key for a subscription (for start/stop diffing)."""
277
+ def _stable_callable_key(fn: Callable[..., object]) -> tuple:
278
+ """Return a stable, hashable identity for callables.
279
+
280
+ Subscriptions are recomputed on every update(). Users often write
281
+ `lambda ...` inline inside subscriptions(), which creates a new
282
+ function object each call. Keying on `id(fn)` causes subscriptions
283
+ to churn (stop/restart) on every message, which breaks file tailing
284
+ and floods the UI with duplicate log lines.
285
+
286
+ Prefer code-location-based keys when available; fall back to object
287
+ identity for non-function callables.
288
+ """
289
+ # Bound method: stabilize on (underlying func location, instance id)
290
+ func = getattr(fn, "__func__", None)
291
+ self_obj = getattr(fn, "__self__", None)
292
+ if func is not None and hasattr(func, "__code__"):
293
+ code = func.__code__
294
+ return ("method", code.co_filename, code.co_firstlineno, code.co_name, id(self_obj))
295
+
296
+ # Plain function / lambda: stabilize on code location (and closure contents)
297
+ code = getattr(fn, "__code__", None)
298
+ if code is not None:
299
+ closure = getattr(fn, "__closure__", None) or ()
300
+ closure_key = []
301
+ for cell in closure:
302
+ try:
303
+ val = cell.cell_contents
304
+ except ValueError:
305
+ val = None
306
+ if val is None or isinstance(val, (bool, int, float, str)):
307
+ closure_key.append(("lit", val))
308
+ else:
309
+ closure_key.append(("id", id(val)))
310
+ return (
311
+ "func",
312
+ code.co_filename,
313
+ code.co_firstlineno,
314
+ code.co_name,
315
+ tuple(closure_key),
316
+ )
317
+
318
+ return ("id", id(fn))
319
+
320
+ if sub._kind == "every":
321
+ interval, fn = sub._data
322
+ return ("every", interval, _stable_callable_key(fn))
323
+ elif sub._kind == "file_tail":
324
+ path, fn = sub._data
325
+ return ("file_tail", path, _stable_callable_key(fn))
326
+ elif sub._kind == "batch":
327
+ return ("batch", tuple(_sub_key(s) for s in sub._data))
328
+ return ("none",)
329
+
330
+
331
+ def _flatten_subs(sub: Sub) -> list[Sub]:
332
+ """Flatten batch subs into a flat list of leaf subs."""
333
+ if sub._kind == "batch":
334
+ result = []
335
+ for s in sub._data:
336
+ result.extend(_flatten_subs(s))
337
+ return result
338
+ elif sub._kind == "none":
339
+ return []
340
+ return [sub]
341
+
342
+
343
+ # ---------------------------------------------------------------------------
344
+ # Internal: subscription runners (threads)
345
+ # ---------------------------------------------------------------------------
346
+
347
+
348
+ @dataclass
349
+ class _SubRunner:
350
+ """Running subscription thread + stop event."""
351
+
352
+ key: tuple
353
+ stop: threading.Event
354
+ thread: threading.Thread
355
+
356
+
357
+ class _WakeQueue:
358
+ """Queue that writes a byte to a wakeup pipe on put().
359
+
360
+ Background threads enqueue messages here. The main loop select()s
361
+ on the wakeup fd alongside stdin, so it wakes immediately instead
362
+ of polling on a timer.
363
+ """
364
+
365
+ __slots__ = ("_q", "_wakeup_w")
366
+
367
+ def __init__(self, wakeup_w: int) -> None:
368
+ self._q: queue.Queue = queue.Queue()
369
+ self._wakeup_w = wakeup_w
370
+
371
+ def put(self, msg: object) -> None:
372
+ self._q.put(msg)
373
+ try:
374
+ os.write(self._wakeup_w, b"\x00")
375
+ except OSError:
376
+ pass # Pipe full or closed — main loop will drain queue anyway
377
+
378
+ def get_nowait(self) -> object:
379
+ return self._q.get_nowait()
380
+
381
+ def empty(self) -> bool:
382
+ return self._q.empty()
383
+
384
+
385
+ def _run_every(
386
+ interval: float,
387
+ msg_fn: Callable[[], object],
388
+ msg_queue: _WakeQueue,
389
+ stop: threading.Event,
390
+ ) -> None:
391
+ """Thread target for Sub.every."""
392
+ while not stop.is_set():
393
+ stop.wait(interval)
394
+ if not stop.is_set():
395
+ msg_queue.put(msg_fn())
396
+
397
+
398
+ def _run_file_tail(
399
+ path: str,
400
+ msg_fn: Callable[[str], object],
401
+ msg_queue: _WakeQueue,
402
+ stop: threading.Event,
403
+ ) -> None:
404
+ """Thread target for Sub.file_tail.
405
+
406
+ Waits for file to exist, reads existing content, then tails for new lines.
407
+ """
408
+ p = Path(path)
409
+
410
+ # Wait for file to exist
411
+ while not p.exists() and not stop.is_set():
412
+ stop.wait(0.5)
413
+
414
+ if stop.is_set():
415
+ return
416
+
417
+ # Use errors="replace" so a single bad byte can't kill the tail thread.
418
+ with open(p, encoding="utf-8", errors="replace") as f:
419
+ # Read from beginning (existing content + new lines).
420
+ # Keep a buffer so we don't emit partial lines when the writer flushes
421
+ # without a trailing newline.
422
+ pending = ""
423
+ while not stop.is_set():
424
+ chunk = f.readline()
425
+ if not chunk:
426
+ stop.wait(0.1)
427
+ continue
428
+
429
+ if chunk.endswith("\n"):
430
+ full = pending + chunk
431
+ pending = ""
432
+ stripped = full.rstrip("\n")
433
+ if stripped: # Skip blank lines
434
+ msg_queue.put(msg_fn(stripped))
435
+ else:
436
+ pending += chunk
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # App: the runtime
441
+ # ---------------------------------------------------------------------------
442
+
443
+ # Type aliases for user-provided functions
444
+ UpdateFn = Callable[[Any, object], tuple[Any, Cmd]]
445
+ ViewFn = Callable[[Any, int, int], list[str]]
446
+ SubsFn = Callable[[Any], Sub]
447
+
448
+
449
+ class App:
450
+ """Elm-style TUI application runtime.
451
+
452
+ Owns the terminal, runs the main loop, executes commands,
453
+ manages subscriptions, and calls view.
454
+
455
+ Args:
456
+ init: (initial_model, initial_cmd) tuple.
457
+ update: Pure function (model, msg) -> (model, cmd).
458
+ view: Pure function (model, width, height) -> list[str].
459
+ subscriptions: Optional function (model) -> Sub.
460
+ alternate_screen: Use alternate screen buffer (monitor-style apps).
461
+ bracketed_paste: Enable bracketed paste mode (editor-style apps).
462
+ mouse: Enable mouse tracking (wheel scroll, clicks).
463
+ fps: Deprecated — kept for backwards compat. Sets min_frame_interval
464
+ to 1/fps. Prefer min_frame_interval directly.
465
+ min_frame_interval: Minimum seconds between renders. Batches rapid
466
+ events (e.g. file tail spew) into fewer frames. Default 0.016
467
+ (~60fps cap). Set to 0 for unlimited.
468
+ debug_log: Path to debug log file. If set, logs subscriptions, messages,
469
+ and app lifecycle events to this file as JSONL.
470
+ debug_fn: Optional callback (model, width, height, frame_count) -> None.
471
+ Called every debug_frame_interval rendered frames. For dumping
472
+ layout snapshots, model state, etc. to a debug log file.
473
+ debug_frame_interval: How often to call debug_fn (every N frames).
474
+ """
475
+
476
+ def __init__(
477
+ self,
478
+ *,
479
+ init: tuple[Any, Cmd],
480
+ update: UpdateFn,
481
+ view: ViewFn,
482
+ subscriptions: SubsFn | None = None,
483
+ alternate_screen: bool = True,
484
+ bracketed_paste: bool = False,
485
+ mouse: bool = False,
486
+ fps: int | None = None,
487
+ min_frame_interval: float = 0.016,
488
+ debug_log: str | Path | None = None,
489
+ debug_fn: Callable[[Any, int, int, int], None] | None = None,
490
+ debug_frame_interval: int = 100,
491
+ ) -> None:
492
+ self._init = init
493
+ self._update_fn = update
494
+ self._view_fn = view
495
+ self._subs_fn = subscriptions
496
+ self._alternate_screen = alternate_screen
497
+ self._bracketed_paste = bracketed_paste
498
+ self._mouse = mouse
499
+ self._min_frame_interval = 1.0 / fps if fps is not None else min_frame_interval
500
+ self._debug_fn = debug_fn
501
+ self._debug_frame_interval = debug_frame_interval
502
+
503
+ self._model: Any = None
504
+ self._running = False
505
+ self._frame_count: int = 0
506
+
507
+ # Wakeup pipe: background threads write a byte here to wake select()
508
+ self._wakeup_r, self._wakeup_w = os.pipe()
509
+ os.set_blocking(self._wakeup_r, False)
510
+ os.set_blocking(self._wakeup_w, False)
511
+
512
+ self._msg_queue = _WakeQueue(self._wakeup_w)
513
+ self._terminal: Terminal | None = None
514
+ self._paste_buffer: str | None = None # Collecting paste content
515
+
516
+ # Set up debug logging
517
+ global _DEBUG_LOG
518
+ if debug_log is not None:
519
+ _DEBUG_LOG = Path(debug_log)
520
+ # Truncate on startup
521
+ _DEBUG_LOG.write_text("")
522
+ os.chmod(_DEBUG_LOG, 0o644)
523
+ _log(
524
+ "app_init",
525
+ min_frame_interval=self._min_frame_interval,
526
+ alternate_screen=alternate_screen,
527
+ )
528
+ else:
529
+ _DEBUG_LOG = None
530
+ self._render_state = RenderState(log_fn=_log if _DEBUG_LOG is not None else None)
531
+ self._active_subs: dict[tuple, _SubRunner] = {}
532
+
533
+ def send(self, msg: object) -> None:
534
+ """Send a message from outside the update loop (thread-safe).
535
+
536
+ Wakes the main loop immediately via the wakeup pipe.
537
+ """
538
+ self._msg_queue.put(msg)
539
+
540
+ def _drain_wakeup(self) -> None:
541
+ """Drain all bytes from the wakeup pipe (non-blocking)."""
542
+ try:
543
+ while os.read(self._wakeup_r, 4096):
544
+ pass
545
+ except (BlockingIOError, OSError):
546
+ pass
547
+
548
+ def run(self) -> None:
549
+ """Run the application. Blocks until quit.
550
+
551
+ Event-driven: blocks on select() until stdin has input or a
552
+ background thread enqueues a message (via wakeup pipe).
553
+ Zero CPU when idle.
554
+ """
555
+ terminal = Terminal(
556
+ alternate_screen=self._alternate_screen,
557
+ bracketed_paste=self._bracketed_paste,
558
+ mouse=self._mouse,
559
+ )
560
+ self._terminal = terminal
561
+
562
+ # We don't use the on_input callback — we select() on the tty fd.
563
+ # Resize writes to the wakeup pipe so select() wakes up.
564
+ def on_input(data: str) -> None:
565
+ pass # Unused, we read from tty fd directly
566
+
567
+ def on_resize() -> None:
568
+ self._msg_queue.put(
569
+ Resize(
570
+ width=terminal.columns,
571
+ height=terminal.rows,
572
+ )
573
+ )
574
+
575
+ terminal.start(on_input=on_input, on_resize=on_resize)
576
+ terminal.hide_cursor()
577
+
578
+ tty_fd = terminal._tty_fd
579
+ wakeup_r = self._wakeup_r
580
+ wait_fds = [wakeup_r]
581
+ if tty_fd is not None:
582
+ wait_fds.append(tty_fd)
583
+
584
+ try:
585
+ # Initialize
586
+ self._model, init_cmd = self._init
587
+ self._running = True
588
+ self._execute_cmd(init_cmd)
589
+
590
+ # Initial subscription setup
591
+ if self._subs_fn:
592
+ self._sync_subs(self._subs_fn(self._model))
593
+
594
+ # Initial render
595
+ self._render()
596
+
597
+ last_render = 0.0 # Force immediate first-event render
598
+ min_interval = self._min_frame_interval
599
+ pending_dirty = False
600
+ msgs_since_render = 0
601
+ msg_types_since_render: dict[str, int] = {}
602
+
603
+ # Main loop — event-driven, blocks on select()
604
+ while self._running:
605
+ # Calculate select timeout:
606
+ # - If we have pending dirty state from rapid events,
607
+ # wait only until min_interval elapses, then render.
608
+ # - Otherwise block indefinitely until something happens.
609
+ if pending_dirty:
610
+ remaining = min_interval - (time.monotonic() - last_render)
611
+ timeout = max(0.0, remaining)
612
+ else:
613
+ timeout = None # Block indefinitely
614
+
615
+ try:
616
+ select.select(wait_fds, [], [], timeout)
617
+ except (InterruptedError, OSError):
618
+ # SIGWINCH can interrupt select — that's fine,
619
+ # the resize handler already enqueued a message.
620
+ pass
621
+
622
+ # Drain wakeup pipe
623
+ self._drain_wakeup()
624
+
625
+ dirty = False
626
+
627
+ # 1. Drain all available keyboard/mouse input
628
+ while True:
629
+ key = terminal.read_input()
630
+ if key is None:
631
+ break
632
+ dirty = True
633
+ msgs_since_render += 1
634
+ msg = self._parse_input(key)
635
+ if msg is not None:
636
+ t = type(msg).__name__
637
+ msg_types_since_render[t] = msg_types_since_render.get(t, 0) + 1
638
+ self._dispatch(msg)
639
+
640
+ # 2. Drain message queue (from Cmd.task threads, subs, resize)
641
+ while not self._msg_queue.empty():
642
+ dirty = True
643
+ try:
644
+ msg = self._msg_queue.get_nowait()
645
+ except queue.Empty:
646
+ break
647
+ msgs_since_render += 1
648
+ t = type(msg).__name__
649
+ msg_types_since_render[t] = msg_types_since_render.get(t, 0) + 1
650
+ self._dispatch(msg)
651
+
652
+ # 3. Re-sync subscriptions if model changed this iteration
653
+ if dirty and self._subs_fn:
654
+ self._sync_subs(self._subs_fn(self._model))
655
+
656
+ if dirty:
657
+ pending_dirty = True
658
+
659
+ # 4. Render if dirty and enough time has passed since last render
660
+ now = time.monotonic()
661
+ if pending_dirty and now - last_render >= min_interval:
662
+ self._render_state.msgs_batched = msgs_since_render
663
+ self._render_state.msg_types_batched = dict(msg_types_since_render)
664
+ self._render()
665
+ last_render = now
666
+ pending_dirty = False
667
+ msgs_since_render = 0
668
+ msg_types_since_render.clear()
669
+
670
+ finally:
671
+ # Stop all subscriptions
672
+ self._stop_all_subs()
673
+ terminal.show_cursor()
674
+ terminal.stop()
675
+ self._terminal = None
676
+ # Close wakeup pipe
677
+ try:
678
+ os.close(self._wakeup_r)
679
+ os.close(self._wakeup_w)
680
+ except OSError:
681
+ pass
682
+
683
+ def _parse_input(self, key: str) -> object | None:
684
+ """Parse raw input into a message type.
685
+
686
+ Handles:
687
+ - Bracketed paste: collects text between ESC[200~ and ESC[201~
688
+ - Mouse events: SGR format ESC[<...M/m
689
+ - Focus events: ESC[I (focus) and ESC[O (blur)
690
+ - Regular keys: everything else
691
+ """
692
+ # Check for bracketed paste
693
+ if key == _PASTE_START:
694
+ self._paste_buffer = ""
695
+ return None # Don't dispatch yet
696
+
697
+ if self._paste_buffer is not None:
698
+ if key == _PASTE_END:
699
+ # Paste complete
700
+ text = self._paste_buffer
701
+ self._paste_buffer = None
702
+ return PasteEvent(text=text)
703
+ else:
704
+ # Accumulate paste content
705
+ self._paste_buffer += key
706
+ return None # Don't dispatch yet
707
+
708
+ # Check for mouse event
709
+ mouse = _parse_mouse_sgr(key)
710
+ if mouse is not None:
711
+ return mouse
712
+
713
+ # Check for focus event
714
+ focus = _parse_focus(key)
715
+ if focus is not None:
716
+ return focus
717
+
718
+ # Regular key
719
+ return KeyPress(key=key)
720
+
721
+ def _dispatch(self, msg: object) -> None:
722
+ """Send message through update, execute resulting command."""
723
+ if not self._running:
724
+ return
725
+ self._model, cmd = self._update_fn(self._model, msg)
726
+ self._execute_cmd(cmd)
727
+
728
+ def _execute_cmd(self, cmd: Cmd) -> None:
729
+ """Execute a command."""
730
+ if cmd._kind == "none":
731
+ return
732
+ elif cmd._kind == "quit":
733
+ self._running = False
734
+ elif cmd._kind == "batch":
735
+ for c in cmd._data:
736
+ self._execute_cmd(c)
737
+ elif cmd._kind == "task":
738
+ fn = cmd._data
739
+ q = self._msg_queue
740
+
741
+ def run() -> None:
742
+ result = fn()
743
+ q.put(result)
744
+
745
+ t = threading.Thread(target=run, daemon=True)
746
+ t.start()
747
+
748
+ def _render(self) -> None:
749
+ """Render current model to terminal."""
750
+ if self._terminal is None:
751
+ return
752
+ width = self._terminal.columns
753
+ height = self._terminal.rows
754
+ lines = self._view_fn(self._model, width, height)
755
+ diff_render(self._terminal, lines, self._render_state)
756
+
757
+ self._frame_count += 1
758
+ if self._debug_fn is not None and self._frame_count % self._debug_frame_interval == 0:
759
+ self._debug_fn(self._model, width, height, self._frame_count)
760
+
761
+ # ---------------------------------------------------------------------------
762
+ # Subscription lifecycle
763
+ # ---------------------------------------------------------------------------
764
+
765
+ def _sync_subs(self, wanted: Sub) -> None:
766
+ """Start/stop subscriptions to match what's wanted."""
767
+ wanted_flat = _flatten_subs(wanted)
768
+ wanted_keys = {_sub_key(s): s for s in wanted_flat}
769
+
770
+ # Stop subs that are no longer wanted
771
+ to_stop = [k for k in self._active_subs if k not in wanted_keys]
772
+ for k in to_stop:
773
+ runner = self._active_subs.pop(k)
774
+ runner.stop.set()
775
+
776
+ # Start subs that are new
777
+ for k, sub in wanted_keys.items():
778
+ if k not in self._active_subs:
779
+ self._start_sub(k, sub)
780
+
781
+ def _start_sub(self, key: tuple, sub: Sub) -> None:
782
+ """Start a subscription thread."""
783
+ stop = threading.Event()
784
+
785
+ if sub._kind == "every":
786
+ interval, msg_fn = sub._data
787
+ t = threading.Thread(
788
+ target=_run_every,
789
+ args=(interval, msg_fn, self._msg_queue, stop),
790
+ daemon=True,
791
+ )
792
+ elif sub._kind == "file_tail":
793
+ path, msg_fn = sub._data
794
+ t = threading.Thread(
795
+ target=_run_file_tail,
796
+ args=(path, msg_fn, self._msg_queue, stop),
797
+ daemon=True,
798
+ )
799
+ else:
800
+ return
801
+
802
+ t.start()
803
+ self._active_subs[key] = _SubRunner(key=key, stop=stop, thread=t)
804
+
805
+ def _stop_all_subs(self) -> None:
806
+ """Stop all running subscriptions."""
807
+ for runner in self._active_subs.values():
808
+ runner.stop.set()
809
+ self._active_subs.clear()