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.
- wafer_core/lib/trace_compare/fusion_analyzer.py +2 -0
- wafer_core/rollouts/_logging/__init__.py +5 -1
- wafer_core/rollouts/_logging/logging_config.py +95 -3
- wafer_core/rollouts/_logging/sample_handler.py +66 -0
- wafer_core/rollouts/_pytui/__init__.py +114 -0
- wafer_core/rollouts/_pytui/app.py +809 -0
- wafer_core/rollouts/_pytui/console.py +291 -0
- wafer_core/rollouts/_pytui/renderer.py +210 -0
- wafer_core/rollouts/_pytui/spinner.py +73 -0
- wafer_core/rollouts/_pytui/terminal.py +489 -0
- wafer_core/rollouts/_pytui/text.py +470 -0
- wafer_core/rollouts/_pytui/theme.py +241 -0
- wafer_core/rollouts/evaluation.py +142 -177
- wafer_core/rollouts/progress_app.py +395 -0
- wafer_core/rollouts/tui/DESIGN.md +251 -115
- wafer_core/rollouts/tui/monitor.py +64 -20
- wafer_core/tools/compile/__init__.py +30 -0
- wafer_core/tools/compile/compiler.py +314 -0
- wafer_core/tools/compile/modal_compile.py +359 -0
- wafer_core/tools/compile/tests/__init__.py +1 -0
- wafer_core/tools/compile/tests/test_compiler.py +675 -0
- wafer_core/tools/compile/tests/test_data/utils.cuh +10 -0
- wafer_core/tools/compile/tests/test_data/vector_add.cu +7 -0
- wafer_core/tools/compile/tests/test_data/with_header.cu +9 -0
- wafer_core/tools/compile/tests/test_modal_integration.py +326 -0
- wafer_core/tools/compile/types.py +117 -0
- {wafer_core-0.1.37.dist-info → wafer_core-0.1.39.dist-info}/METADATA +1 -1
- {wafer_core-0.1.37.dist-info → wafer_core-0.1.39.dist-info}/RECORD +29 -12
- wafer_core/rollouts/events.py +0 -240
- wafer_core/rollouts/progress_display.py +0 -476
- wafer_core/utils/event_streaming.py +0 -63
- {wafer_core-0.1.37.dist-info → wafer_core-0.1.39.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Console: unified output manager coordinating spinners with logging.
|
|
2
|
+
|
|
3
|
+
The problem: Spinner animations and log messages both write to stderr.
|
|
4
|
+
When they interleave, output gets corrupted. Console solves this by:
|
|
5
|
+
1. Owning the spinner lifecycle
|
|
6
|
+
2. Providing a logging handler that pauses the spinner before emitting
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
import logging
|
|
10
|
+
from pytui import Console
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
console = Console()
|
|
14
|
+
console.install_logging_handler(logger)
|
|
15
|
+
|
|
16
|
+
with console.spinner("Provisioning..."):
|
|
17
|
+
# Any logger.info/warning/error calls here will:
|
|
18
|
+
# 1. Pause the spinner (clear its line)
|
|
19
|
+
# 2. Print the log message on its own line
|
|
20
|
+
# 3. Resume the spinner below
|
|
21
|
+
result = do_stuff_that_logs()
|
|
22
|
+
# Shows: ✓ Provisioning...
|
|
23
|
+
|
|
24
|
+
Three levels of granularity (Casey Muratori's rule: no API holes):
|
|
25
|
+
- Low-level: pause_spinner(), resume_spinner(), write()
|
|
26
|
+
- Mid-level: status() for one-off messages
|
|
27
|
+
- High-level: spinner() context manager
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import itertools
|
|
33
|
+
import logging
|
|
34
|
+
import sys
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
from collections.abc import Iterator
|
|
38
|
+
from contextlib import contextmanager
|
|
39
|
+
from typing import TextIO
|
|
40
|
+
|
|
41
|
+
BRAILLE_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Console:
|
|
45
|
+
"""Unified output manager coordinating spinners with logging.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
output: Stream to write to. Defaults to stderr (matches logging convention).
|
|
49
|
+
spinner_interval: Seconds between spinner frame updates.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
output: TextIO = sys.stderr,
|
|
55
|
+
spinner_interval: float = 0.08,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._output = output
|
|
58
|
+
self._spinner_interval = spinner_interval
|
|
59
|
+
self._lock = threading.Lock()
|
|
60
|
+
|
|
61
|
+
# Active spinner state (immediate mode: only one at a time)
|
|
62
|
+
self._spinner_message: str | None = None
|
|
63
|
+
self._spinner_frame_iter: Iterator[str] | None = None
|
|
64
|
+
self._spinner_stop: threading.Event | None = None
|
|
65
|
+
self._spinner_thread: threading.Thread | None = None
|
|
66
|
+
self._spinner_paused: bool = False
|
|
67
|
+
self._spinner_start_time: float | None = None
|
|
68
|
+
|
|
69
|
+
# Installed logging handlers (for cleanup)
|
|
70
|
+
self._handlers: list[tuple[logging.Logger, logging.Handler]] = []
|
|
71
|
+
|
|
72
|
+
# ─── Low-level API ─────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def write(self, text: str) -> None:
|
|
75
|
+
"""Write text to output stream. Thread-safe."""
|
|
76
|
+
with self._lock:
|
|
77
|
+
self._output.write(text)
|
|
78
|
+
self._output.flush()
|
|
79
|
+
|
|
80
|
+
def write_line(self, text: str) -> None:
|
|
81
|
+
"""Write a complete line to output. Coordinates with active spinner."""
|
|
82
|
+
with self._lock:
|
|
83
|
+
if self._spinner_message is not None and not self._spinner_paused:
|
|
84
|
+
# Clear spinner line, write message, redraw spinner
|
|
85
|
+
self._output.write(f"\r\033[K{text}\n")
|
|
86
|
+
frame = next(self._spinner_frame_iter) if self._spinner_frame_iter else "⠋"
|
|
87
|
+
self._output.write(f"{frame} {self._spinner_message}")
|
|
88
|
+
self._output.flush()
|
|
89
|
+
else:
|
|
90
|
+
self._output.write(f"{text}\n")
|
|
91
|
+
self._output.flush()
|
|
92
|
+
|
|
93
|
+
def pause_spinner(self) -> None:
|
|
94
|
+
"""Pause the spinner animation and clear its line.
|
|
95
|
+
|
|
96
|
+
Used by ConsoleHandler before emitting log records.
|
|
97
|
+
"""
|
|
98
|
+
with self._lock:
|
|
99
|
+
if self._spinner_message is not None and not self._spinner_paused:
|
|
100
|
+
self._spinner_paused = True
|
|
101
|
+
self._output.write("\r\033[K")
|
|
102
|
+
self._output.flush()
|
|
103
|
+
|
|
104
|
+
def resume_spinner(self) -> None:
|
|
105
|
+
"""Resume the spinner animation.
|
|
106
|
+
|
|
107
|
+
Used by ConsoleHandler after emitting log records.
|
|
108
|
+
"""
|
|
109
|
+
with self._lock:
|
|
110
|
+
if self._spinner_message is not None and self._spinner_paused:
|
|
111
|
+
self._spinner_paused = False
|
|
112
|
+
frame = next(self._spinner_frame_iter) if self._spinner_frame_iter else "⠋"
|
|
113
|
+
self._output.write(f"{frame} {self._spinner_message}")
|
|
114
|
+
self._output.flush()
|
|
115
|
+
|
|
116
|
+
# ─── Mid-level API ─────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def status(self, text: str, success: bool = True) -> None:
|
|
119
|
+
"""Print a status line with ✓ or ✗ prefix."""
|
|
120
|
+
mark = "✓" if success else "✗"
|
|
121
|
+
self.write_line(f"{mark} {text}")
|
|
122
|
+
|
|
123
|
+
# ─── High-level API ────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
@contextmanager
|
|
126
|
+
def spinner(self, message: str) -> Iterator[SpinnerHandle]:
|
|
127
|
+
"""Context manager for spinner animation.
|
|
128
|
+
|
|
129
|
+
Any log messages emitted inside the block will be coordinated:
|
|
130
|
+
the spinner pauses, the message prints, the spinner resumes.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
message: Text to display next to the spinner.
|
|
134
|
+
|
|
135
|
+
Yields:
|
|
136
|
+
SpinnerHandle with update() method to change the message.
|
|
137
|
+
|
|
138
|
+
Example:
|
|
139
|
+
with console.spinner("Loading...") as s:
|
|
140
|
+
s.update("Loading... 50%")
|
|
141
|
+
do_work()
|
|
142
|
+
# Shows: ✓ Loading... 50%
|
|
143
|
+
"""
|
|
144
|
+
assert self._spinner_message is None, "Cannot nest spinners"
|
|
145
|
+
|
|
146
|
+
handle = SpinnerHandle(self)
|
|
147
|
+
self._spinner_message = message
|
|
148
|
+
self._spinner_frame_iter = itertools.cycle(BRAILLE_FRAMES)
|
|
149
|
+
self._spinner_stop = threading.Event()
|
|
150
|
+
self._spinner_paused = False
|
|
151
|
+
self._spinner_start_time = time.time()
|
|
152
|
+
|
|
153
|
+
self._spinner_thread = threading.Thread(target=self._spin_loop, daemon=True)
|
|
154
|
+
self._spinner_thread.start()
|
|
155
|
+
|
|
156
|
+
exc_info: BaseException | None = None
|
|
157
|
+
try:
|
|
158
|
+
yield handle
|
|
159
|
+
except BaseException as e:
|
|
160
|
+
exc_info = e
|
|
161
|
+
raise
|
|
162
|
+
finally:
|
|
163
|
+
# Stop spinner thread
|
|
164
|
+
assert self._spinner_stop is not None
|
|
165
|
+
self._spinner_stop.set()
|
|
166
|
+
if self._spinner_thread is not None:
|
|
167
|
+
self._spinner_thread.join()
|
|
168
|
+
self._spinner_thread = None
|
|
169
|
+
|
|
170
|
+
# Print final status with elapsed time
|
|
171
|
+
final_message = self._spinner_message
|
|
172
|
+
success = exc_info is None
|
|
173
|
+
mark = "✓" if success else "✗"
|
|
174
|
+
elapsed = self._format_elapsed(time.time() - self._spinner_start_time)
|
|
175
|
+
|
|
176
|
+
with self._lock:
|
|
177
|
+
self._output.write(f"\r\033[K{mark} {final_message} ({elapsed})\n")
|
|
178
|
+
self._output.flush()
|
|
179
|
+
|
|
180
|
+
# Clear spinner state
|
|
181
|
+
self._spinner_message = None
|
|
182
|
+
self._spinner_frame_iter = None
|
|
183
|
+
self._spinner_stop = None
|
|
184
|
+
self._spinner_paused = False
|
|
185
|
+
self._spinner_start_time = None
|
|
186
|
+
|
|
187
|
+
def _spin_loop(self) -> None:
|
|
188
|
+
"""Background thread that animates the spinner."""
|
|
189
|
+
assert self._spinner_stop is not None
|
|
190
|
+
assert self._spinner_frame_iter is not None
|
|
191
|
+
assert self._spinner_start_time is not None
|
|
192
|
+
|
|
193
|
+
while not self._spinner_stop.is_set():
|
|
194
|
+
with self._lock:
|
|
195
|
+
if not self._spinner_paused and self._spinner_message is not None:
|
|
196
|
+
frame = next(self._spinner_frame_iter)
|
|
197
|
+
elapsed = self._format_elapsed(time.time() - self._spinner_start_time)
|
|
198
|
+
self._output.write(f"\r\033[K{frame} {self._spinner_message} ({elapsed})")
|
|
199
|
+
self._output.flush()
|
|
200
|
+
time.sleep(self._spinner_interval)
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _format_elapsed(seconds: float) -> str:
|
|
204
|
+
"""Format elapsed time as human-readable string (e.g., '1m7s', '45s')."""
|
|
205
|
+
seconds = int(seconds)
|
|
206
|
+
if seconds < 60:
|
|
207
|
+
return f"{seconds}s"
|
|
208
|
+
minutes = seconds // 60
|
|
209
|
+
secs = seconds % 60
|
|
210
|
+
if minutes < 60:
|
|
211
|
+
return f"{minutes}m{secs}s" if secs else f"{minutes}m"
|
|
212
|
+
hours = minutes // 60
|
|
213
|
+
mins = minutes % 60
|
|
214
|
+
if secs:
|
|
215
|
+
return f"{hours}h{mins}m{secs}s"
|
|
216
|
+
elif mins:
|
|
217
|
+
return f"{hours}h{mins}m"
|
|
218
|
+
else:
|
|
219
|
+
return f"{hours}h"
|
|
220
|
+
|
|
221
|
+
# ─── Logging Integration ───────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
def install_logging_handler(
|
|
224
|
+
self,
|
|
225
|
+
logger: logging.Logger | None = None,
|
|
226
|
+
level: int = logging.DEBUG,
|
|
227
|
+
fmt: str = "[%(asctime)s] %(levelname)s: %(message)s",
|
|
228
|
+
datefmt: str = "%H:%M:%S",
|
|
229
|
+
) -> logging.Handler:
|
|
230
|
+
"""Install a logging handler that coordinates with the spinner.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
logger: Logger to attach to. Defaults to root logger.
|
|
234
|
+
level: Minimum log level to handle.
|
|
235
|
+
fmt: Log message format string.
|
|
236
|
+
datefmt: Date format for %(asctime)s.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
The installed handler (for advanced use cases).
|
|
240
|
+
"""
|
|
241
|
+
if logger is None:
|
|
242
|
+
logger = logging.getLogger()
|
|
243
|
+
|
|
244
|
+
handler = ConsoleHandler(self)
|
|
245
|
+
handler.setLevel(level)
|
|
246
|
+
handler.setFormatter(logging.Formatter(fmt, datefmt))
|
|
247
|
+
logger.addHandler(handler)
|
|
248
|
+
|
|
249
|
+
self._handlers.append((logger, handler))
|
|
250
|
+
return handler
|
|
251
|
+
|
|
252
|
+
def remove_logging_handlers(self) -> None:
|
|
253
|
+
"""Remove all logging handlers installed by this Console."""
|
|
254
|
+
for logger, handler in self._handlers:
|
|
255
|
+
logger.removeHandler(handler)
|
|
256
|
+
self._handlers.clear()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class SpinnerHandle:
|
|
260
|
+
"""Handle returned by Console.spinner() for updating the message."""
|
|
261
|
+
|
|
262
|
+
def __init__(self, console: Console) -> None:
|
|
263
|
+
self._console = console
|
|
264
|
+
|
|
265
|
+
def update(self, message: str) -> None:
|
|
266
|
+
"""Update the spinner message."""
|
|
267
|
+
with self._console._lock:
|
|
268
|
+
self._console._spinner_message = message
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class ConsoleHandler(logging.Handler):
|
|
272
|
+
"""Logging handler that coordinates with Console's spinner.
|
|
273
|
+
|
|
274
|
+
When a log record is emitted:
|
|
275
|
+
1. Pause the spinner (clear its line)
|
|
276
|
+
2. Print the formatted log message
|
|
277
|
+
3. Resume the spinner on a new line
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def __init__(self, console: Console) -> None:
|
|
281
|
+
super().__init__()
|
|
282
|
+
self._console = console
|
|
283
|
+
|
|
284
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
285
|
+
try:
|
|
286
|
+
msg = self.format(record)
|
|
287
|
+
self._console.pause_spinner()
|
|
288
|
+
self._console.write_line(msg)
|
|
289
|
+
self._console.resume_spinner()
|
|
290
|
+
except Exception:
|
|
291
|
+
self.handleError(record)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Differential terminal renderer.
|
|
2
|
+
|
|
3
|
+
Extracted from rollouts/frontends/tui/tui.py _do_render() - same algorithm.
|
|
4
|
+
|
|
5
|
+
Compares new lines against previous lines and only updates changed regions.
|
|
6
|
+
Uses synchronized output mode to prevent flicker.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .terminal import (
|
|
17
|
+
CLEAR_LINE_FULL,
|
|
18
|
+
CLEAR_SCREEN_HOME,
|
|
19
|
+
SYNC_OUTPUT_OFF,
|
|
20
|
+
SYNC_OUTPUT_ON,
|
|
21
|
+
Terminal,
|
|
22
|
+
)
|
|
23
|
+
from .text import truncate_to_width, visible_width
|
|
24
|
+
|
|
25
|
+
# Type for render logging callback
|
|
26
|
+
RenderLogFn = Callable[..., None] | None
|
|
27
|
+
|
|
28
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _strip_ansi(s: str) -> str:
|
|
32
|
+
return _ANSI_RE.sub("", s)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class RenderState:
|
|
37
|
+
"""Tracks state between renders for differential updates."""
|
|
38
|
+
|
|
39
|
+
previous_lines: list[str] = field(default_factory=list)
|
|
40
|
+
previous_width: int = 0
|
|
41
|
+
cursor_row: int = 0 # 0-indexed, relative to first line
|
|
42
|
+
render_count: int = 0
|
|
43
|
+
log_fn: RenderLogFn = None
|
|
44
|
+
# Set by App before each render
|
|
45
|
+
msgs_batched: int = 0
|
|
46
|
+
msg_types_batched: dict = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def diff_render(
|
|
50
|
+
terminal: Terminal,
|
|
51
|
+
new_lines: list[str],
|
|
52
|
+
state: RenderState,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Render lines to terminal with differential updates.
|
|
55
|
+
|
|
56
|
+
Only redraws lines that changed since last render.
|
|
57
|
+
Uses synchronized output mode (\\x1b[?2026h/l) to prevent flicker.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
terminal: Terminal to write to.
|
|
61
|
+
new_lines: Complete list of lines to display.
|
|
62
|
+
state: Mutable render state (updated in place).
|
|
63
|
+
"""
|
|
64
|
+
width = terminal.columns
|
|
65
|
+
height = terminal.rows
|
|
66
|
+
state.render_count += 1
|
|
67
|
+
_rlog = state.log_fn
|
|
68
|
+
|
|
69
|
+
# Common log data for every render
|
|
70
|
+
def _emit_log(kind: str, buffer: str, **extra: Any) -> None:
|
|
71
|
+
if not _rlog:
|
|
72
|
+
return
|
|
73
|
+
line0 = _strip_ansi(new_lines[0])[:80] if new_lines else ""
|
|
74
|
+
_rlog(
|
|
75
|
+
"render",
|
|
76
|
+
n=state.render_count,
|
|
77
|
+
kind=kind,
|
|
78
|
+
width=width,
|
|
79
|
+
height=height,
|
|
80
|
+
lines=len(new_lines),
|
|
81
|
+
prev_lines=len(state.previous_lines),
|
|
82
|
+
cursor_row_before=state.cursor_row,
|
|
83
|
+
buf_bytes=len(buffer),
|
|
84
|
+
line0=line0,
|
|
85
|
+
msgs=state.msgs_batched,
|
|
86
|
+
msg_types=state.msg_types_batched or None,
|
|
87
|
+
**extra,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Width changed - need full re-render
|
|
91
|
+
width_changed = state.previous_width != 0 and state.previous_width != width
|
|
92
|
+
|
|
93
|
+
# First render - just output everything
|
|
94
|
+
if len(state.previous_lines) == 0:
|
|
95
|
+
buffer = SYNC_OUTPUT_ON
|
|
96
|
+
for i, line in enumerate(new_lines):
|
|
97
|
+
if i > 0:
|
|
98
|
+
buffer += "\r\n"
|
|
99
|
+
buffer += line
|
|
100
|
+
buffer += SYNC_OUTPUT_OFF
|
|
101
|
+
_emit_log("first", buffer)
|
|
102
|
+
terminal.write(buffer)
|
|
103
|
+
state.cursor_row = len(new_lines) - 1
|
|
104
|
+
state.previous_lines = list(new_lines)
|
|
105
|
+
state.previous_width = width
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
# Width changed - full re-render
|
|
109
|
+
if width_changed:
|
|
110
|
+
buffer = SYNC_OUTPUT_ON
|
|
111
|
+
buffer += CLEAR_SCREEN_HOME
|
|
112
|
+
for i, line in enumerate(new_lines):
|
|
113
|
+
if i > 0:
|
|
114
|
+
buffer += "\r\n"
|
|
115
|
+
buffer += line
|
|
116
|
+
buffer += SYNC_OUTPUT_OFF
|
|
117
|
+
_emit_log("width_changed", buffer, old_width=state.previous_width)
|
|
118
|
+
terminal.write(buffer)
|
|
119
|
+
state.cursor_row = len(new_lines) - 1
|
|
120
|
+
state.previous_lines = list(new_lines)
|
|
121
|
+
state.previous_width = width
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Find first changed line
|
|
125
|
+
first_changed = -1
|
|
126
|
+
max_lines = max(len(new_lines), len(state.previous_lines))
|
|
127
|
+
|
|
128
|
+
for i in range(max_lines):
|
|
129
|
+
old_line = state.previous_lines[i] if i < len(state.previous_lines) else ""
|
|
130
|
+
new_line = new_lines[i] if i < len(new_lines) else ""
|
|
131
|
+
if old_line != new_line:
|
|
132
|
+
first_changed = i
|
|
133
|
+
break
|
|
134
|
+
|
|
135
|
+
# No changes
|
|
136
|
+
if first_changed == -1:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
# Check if first_changed is outside the viewport
|
|
140
|
+
viewport_top = state.cursor_row - height + 1
|
|
141
|
+
if first_changed < viewport_top:
|
|
142
|
+
# First change is above viewport - need full re-render
|
|
143
|
+
buffer = SYNC_OUTPUT_ON
|
|
144
|
+
buffer += CLEAR_SCREEN_HOME
|
|
145
|
+
for i, line in enumerate(new_lines):
|
|
146
|
+
if i > 0:
|
|
147
|
+
buffer += "\r\n"
|
|
148
|
+
buffer += line
|
|
149
|
+
buffer += SYNC_OUTPUT_OFF
|
|
150
|
+
_emit_log(
|
|
151
|
+
"above_viewport", buffer,
|
|
152
|
+
first_changed=first_changed, viewport_top=viewport_top,
|
|
153
|
+
)
|
|
154
|
+
terminal.write(buffer)
|
|
155
|
+
state.cursor_row = len(new_lines) - 1
|
|
156
|
+
state.previous_lines = list(new_lines)
|
|
157
|
+
state.previous_width = width
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
# Render from first changed line to end
|
|
161
|
+
line_diff = first_changed - state.cursor_row
|
|
162
|
+
|
|
163
|
+
buffer = SYNC_OUTPUT_ON
|
|
164
|
+
|
|
165
|
+
# Move cursor to first changed line
|
|
166
|
+
if line_diff > 0:
|
|
167
|
+
buffer += f"\x1b[{line_diff}B" # Move down
|
|
168
|
+
elif line_diff < 0:
|
|
169
|
+
buffer += f"\x1b[{-line_diff}A" # Move up
|
|
170
|
+
|
|
171
|
+
buffer += "\r" # Move to column 0
|
|
172
|
+
|
|
173
|
+
# Render from first changed line to end, clearing each line before writing
|
|
174
|
+
cursor_after_render = first_changed
|
|
175
|
+
for i in range(first_changed, len(new_lines)):
|
|
176
|
+
if i > first_changed:
|
|
177
|
+
buffer += "\r\n"
|
|
178
|
+
cursor_after_render = i
|
|
179
|
+
buffer += CLEAR_LINE_FULL
|
|
180
|
+
|
|
181
|
+
line = new_lines[i]
|
|
182
|
+
if visible_width(line) > width:
|
|
183
|
+
line = truncate_to_width(line, width, ellipsis="\u2026")
|
|
184
|
+
buffer += line
|
|
185
|
+
|
|
186
|
+
# If we had more lines before, clear them
|
|
187
|
+
extra_cleared = 0
|
|
188
|
+
if len(state.previous_lines) > len(new_lines):
|
|
189
|
+
extra_cleared = len(state.previous_lines) - len(new_lines)
|
|
190
|
+
for _i in range(extra_cleared):
|
|
191
|
+
buffer += "\r\n" + CLEAR_LINE_FULL
|
|
192
|
+
# Move cursor back to correct position
|
|
193
|
+
lines_to_move_up = cursor_after_render + extra_cleared - (len(new_lines) - 1)
|
|
194
|
+
if lines_to_move_up > 0:
|
|
195
|
+
buffer += f"\x1b[{lines_to_move_up}A"
|
|
196
|
+
|
|
197
|
+
buffer += SYNC_OUTPUT_OFF
|
|
198
|
+
|
|
199
|
+
_emit_log(
|
|
200
|
+
"diff", buffer,
|
|
201
|
+
first_changed=first_changed,
|
|
202
|
+
cursor_move=line_diff,
|
|
203
|
+
redraw=len(new_lines) - first_changed,
|
|
204
|
+
extra_cleared=extra_cleared,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
terminal.write(buffer)
|
|
208
|
+
state.cursor_row = len(new_lines) - 1
|
|
209
|
+
state.previous_lines = list(new_lines)
|
|
210
|
+
state.previous_width = width
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Standalone CLI spinner for long-running operations.
|
|
2
|
+
|
|
3
|
+
No TUI app needed — just stderr + ANSI escape codes.
|
|
4
|
+
Uses the same braille frames as the rollouts Loader component.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
with Spinner("Provisioning GPU...") as s:
|
|
8
|
+
provision()
|
|
9
|
+
s.update("Deploying code...")
|
|
10
|
+
deploy()
|
|
11
|
+
# prints: ✓ Deploying code...
|
|
12
|
+
|
|
13
|
+
# Or without context manager:
|
|
14
|
+
s = Spinner("Working...")
|
|
15
|
+
s.start()
|
|
16
|
+
s.update("Still working...")
|
|
17
|
+
s.stop() # prints: ✓ Still working...
|
|
18
|
+
s.stop("Failed!") # prints: ✗ Failed!
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import itertools
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
BRAILLE_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Spinner:
|
|
32
|
+
"""Braille spinner that overwrites a single stderr line."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str) -> None:
|
|
35
|
+
self._message = message
|
|
36
|
+
self._stop = threading.Event()
|
|
37
|
+
self._thread: threading.Thread | None = None
|
|
38
|
+
|
|
39
|
+
def update(self, message: str) -> None:
|
|
40
|
+
"""Change the spinner text (thread-safe)."""
|
|
41
|
+
self._message = message
|
|
42
|
+
|
|
43
|
+
def start(self) -> None:
|
|
44
|
+
"""Start the spinner background thread."""
|
|
45
|
+
assert self._thread is None, "Spinner already started"
|
|
46
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
47
|
+
self._thread.start()
|
|
48
|
+
|
|
49
|
+
def stop(self, final_message: str | None = None, success: bool = True) -> None:
|
|
50
|
+
"""Stop the spinner and print final status."""
|
|
51
|
+
self._stop.set()
|
|
52
|
+
if self._thread is not None:
|
|
53
|
+
self._thread.join()
|
|
54
|
+
self._thread = None
|
|
55
|
+
msg = final_message or self._message
|
|
56
|
+
mark = "✓" if success else "✗"
|
|
57
|
+
sys.stderr.write(f"\r\033[K{mark} {msg}\n")
|
|
58
|
+
sys.stderr.flush()
|
|
59
|
+
|
|
60
|
+
def _spin(self) -> None:
|
|
61
|
+
for frame in itertools.cycle(BRAILLE_FRAMES):
|
|
62
|
+
if self._stop.is_set():
|
|
63
|
+
break
|
|
64
|
+
sys.stderr.write(f"\r\033[K{frame} {self._message}")
|
|
65
|
+
sys.stderr.flush()
|
|
66
|
+
time.sleep(0.08)
|
|
67
|
+
|
|
68
|
+
def __enter__(self) -> Spinner:
|
|
69
|
+
self.start()
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
def __exit__(self, exc_type: type | None, *_: object) -> None:
|
|
73
|
+
self.stop(success=exc_type is None)
|