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,470 @@
|
|
|
1
|
+
"""Text utilities for TUI rendering.
|
|
2
|
+
|
|
3
|
+
Handles ANSI-aware text width calculation, word wrapping, and truncation.
|
|
4
|
+
|
|
5
|
+
Extracted from rollouts/frontends/tui/utils.py - same code, new home.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import NamedTuple
|
|
15
|
+
|
|
16
|
+
# Pre-compiled patterns for performance (avoid re-compiling on every call)
|
|
17
|
+
TERMINAL_CONTROL_PATTERN = re.compile(
|
|
18
|
+
r"\x1b\[\?2004[hl]" # Bracketed paste enable/disable
|
|
19
|
+
r"|\x1b\[20[01]~" # Bracketed paste start/end markers
|
|
20
|
+
r"|\x1b\[\d+~" # Other ~ terminated sequences (function keys, etc.)
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# ANSI escape sequences (SGR codes and others)
|
|
24
|
+
ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z~]")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def strip_terminal_control_sequences(text: str) -> str:
|
|
28
|
+
"""Strip terminal control sequences that should never appear in content.
|
|
29
|
+
|
|
30
|
+
This includes bracketed paste sequences and other control codes that
|
|
31
|
+
can leak into text when returning from external editors.
|
|
32
|
+
"""
|
|
33
|
+
return TERMINAL_CONTROL_PATTERN.sub("", text)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@functools.lru_cache(maxsize=1024)
|
|
37
|
+
def visible_width(text: str) -> int:
|
|
38
|
+
"""Calculate the visible width of a string in terminal columns.
|
|
39
|
+
|
|
40
|
+
Handles:
|
|
41
|
+
- ANSI escape sequences (zero width)
|
|
42
|
+
- Terminal control sequences like bracketed paste (zero width)
|
|
43
|
+
- Wide characters (CJK, emoji = 2 columns)
|
|
44
|
+
- Combining characters (zero width)
|
|
45
|
+
- Tabs (converted to 3 spaces)
|
|
46
|
+
|
|
47
|
+
Results are cached (LRU, 1024 entries) since the same strings are
|
|
48
|
+
often measured repeatedly during rendering.
|
|
49
|
+
"""
|
|
50
|
+
# Normalize tabs
|
|
51
|
+
text = text.replace("\t", " ")
|
|
52
|
+
|
|
53
|
+
# Strip terminal control sequences (bracketed paste, etc.)
|
|
54
|
+
text = strip_terminal_control_sequences(text)
|
|
55
|
+
|
|
56
|
+
# Strip ANSI escape sequences using pre-compiled pattern
|
|
57
|
+
text_no_ansi = ANSI_ESCAPE_PATTERN.sub("", text)
|
|
58
|
+
|
|
59
|
+
width = 0
|
|
60
|
+
for char in text_no_ansi:
|
|
61
|
+
# Get East Asian Width property
|
|
62
|
+
ea_width = unicodedata.east_asian_width(char)
|
|
63
|
+
cat = unicodedata.category(char)
|
|
64
|
+
|
|
65
|
+
# Combining marks have zero width
|
|
66
|
+
if cat.startswith("M"):
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
# Wide characters (CJK, emoji) take 2 columns
|
|
70
|
+
if ea_width in ("F", "W"):
|
|
71
|
+
width += 2
|
|
72
|
+
else:
|
|
73
|
+
width += 1
|
|
74
|
+
|
|
75
|
+
return width
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AnsiCode(NamedTuple):
|
|
79
|
+
"""Extracted ANSI escape code."""
|
|
80
|
+
|
|
81
|
+
code: str
|
|
82
|
+
length: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def extract_ansi_code(text: str, pos: int) -> AnsiCode | None:
|
|
86
|
+
"""Extract ANSI escape sequence at given position.
|
|
87
|
+
|
|
88
|
+
Handles:
|
|
89
|
+
- SGR codes ending in m (colors, styles)
|
|
90
|
+
- Cursor codes ending in G, K, H, J
|
|
91
|
+
- Special sequences ending in ~ (function keys, bracketed paste)
|
|
92
|
+
|
|
93
|
+
Returns None if no escape sequence at position.
|
|
94
|
+
"""
|
|
95
|
+
if pos >= len(text) or text[pos] != "\x1b":
|
|
96
|
+
return None
|
|
97
|
+
if pos + 1 >= len(text) or text[pos + 1] != "[":
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
j = pos + 2
|
|
101
|
+
# Include ~ for bracketed paste and function key sequences
|
|
102
|
+
while j < len(text) and text[j] not in "mGKHJ~":
|
|
103
|
+
j += 1
|
|
104
|
+
|
|
105
|
+
if j < len(text):
|
|
106
|
+
return AnsiCode(code=text[pos : j + 1], length=j + 1 - pos)
|
|
107
|
+
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AnsiCodeTracker:
|
|
112
|
+
"""Track active ANSI SGR codes to preserve styling across line breaks."""
|
|
113
|
+
|
|
114
|
+
def __init__(self) -> None:
|
|
115
|
+
self._active_codes: list[str] = []
|
|
116
|
+
|
|
117
|
+
def process(self, ansi_code: str) -> None:
|
|
118
|
+
"""Process an ANSI code, updating active state."""
|
|
119
|
+
if not ansi_code.endswith("m"):
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# Full reset clears everything
|
|
123
|
+
if ansi_code in ("\x1b[0m", "\x1b[m"):
|
|
124
|
+
self._active_codes.clear()
|
|
125
|
+
else:
|
|
126
|
+
self._active_codes.append(ansi_code)
|
|
127
|
+
|
|
128
|
+
def get_active_codes(self) -> str:
|
|
129
|
+
"""Get string of all active codes to reapply styling."""
|
|
130
|
+
return "".join(self._active_codes)
|
|
131
|
+
|
|
132
|
+
def has_active_codes(self) -> bool:
|
|
133
|
+
"""Check if there are any active codes."""
|
|
134
|
+
return len(self._active_codes) > 0
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _update_tracker_from_text(text: str, tracker: AnsiCodeTracker) -> None:
|
|
138
|
+
"""Scan text for ANSI codes and update tracker."""
|
|
139
|
+
i = 0
|
|
140
|
+
while i < len(text):
|
|
141
|
+
result = extract_ansi_code(text, i)
|
|
142
|
+
if result:
|
|
143
|
+
tracker.process(result.code)
|
|
144
|
+
i += result.length
|
|
145
|
+
else:
|
|
146
|
+
i += 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _split_into_tokens_with_ansi(text: str) -> list[str]:
|
|
150
|
+
"""Split text into tokens (words/whitespace) while keeping ANSI codes attached."""
|
|
151
|
+
tokens: list[str] = []
|
|
152
|
+
current = ""
|
|
153
|
+
in_whitespace = False
|
|
154
|
+
i = 0
|
|
155
|
+
|
|
156
|
+
while i < len(text):
|
|
157
|
+
result = extract_ansi_code(text, i)
|
|
158
|
+
if result:
|
|
159
|
+
current += result.code
|
|
160
|
+
i += result.length
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
char = text[i]
|
|
164
|
+
char_is_space = char == " "
|
|
165
|
+
|
|
166
|
+
if char_is_space != in_whitespace and current:
|
|
167
|
+
tokens.append(current)
|
|
168
|
+
current = ""
|
|
169
|
+
|
|
170
|
+
in_whitespace = char_is_space
|
|
171
|
+
current += char
|
|
172
|
+
i += 1
|
|
173
|
+
|
|
174
|
+
if current:
|
|
175
|
+
tokens.append(current)
|
|
176
|
+
|
|
177
|
+
return tokens
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def wrap_text_with_ansi(text: str, width: int) -> list[str]:
|
|
181
|
+
"""Wrap text with ANSI codes preserved.
|
|
182
|
+
|
|
183
|
+
Does word wrapping only - NO padding, NO background colors.
|
|
184
|
+
Returns lines where each line is <= width visible chars.
|
|
185
|
+
Active ANSI codes are preserved across line breaks.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
text: Text to wrap (may contain ANSI codes and newlines)
|
|
189
|
+
width: Maximum visible width per line
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Array of wrapped lines (NOT padded to width)
|
|
193
|
+
"""
|
|
194
|
+
if not text:
|
|
195
|
+
return [""]
|
|
196
|
+
|
|
197
|
+
# Handle newlines by processing each line separately
|
|
198
|
+
input_lines = text.split("\n")
|
|
199
|
+
result: list[str] = []
|
|
200
|
+
|
|
201
|
+
for input_line in input_lines:
|
|
202
|
+
result.extend(_wrap_single_line(input_line, width))
|
|
203
|
+
|
|
204
|
+
return result if result else [""]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _wrap_single_line(line: str, width: int) -> list[str]:
|
|
208
|
+
"""Wrap a single line (no embedded newlines)."""
|
|
209
|
+
if not line:
|
|
210
|
+
return [""]
|
|
211
|
+
|
|
212
|
+
visible_len = visible_width(line)
|
|
213
|
+
if visible_len <= width:
|
|
214
|
+
return [line]
|
|
215
|
+
|
|
216
|
+
wrapped: list[str] = []
|
|
217
|
+
tracker = AnsiCodeTracker()
|
|
218
|
+
tokens = _split_into_tokens_with_ansi(line)
|
|
219
|
+
|
|
220
|
+
current_line = ""
|
|
221
|
+
current_visible_length = 0
|
|
222
|
+
|
|
223
|
+
for token in tokens:
|
|
224
|
+
token_visible_length = visible_width(token)
|
|
225
|
+
is_whitespace = token.strip() == ""
|
|
226
|
+
|
|
227
|
+
# Token itself is too long - break it character by character
|
|
228
|
+
if token_visible_length > width and not is_whitespace:
|
|
229
|
+
if current_line:
|
|
230
|
+
wrapped.append(current_line)
|
|
231
|
+
current_line = ""
|
|
232
|
+
current_visible_length = 0
|
|
233
|
+
|
|
234
|
+
# Break long token
|
|
235
|
+
broken = _break_long_word(token, width, tracker)
|
|
236
|
+
wrapped.extend(broken[:-1])
|
|
237
|
+
current_line = broken[-1] if broken else ""
|
|
238
|
+
current_visible_length = visible_width(current_line)
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
# Check if adding this token would exceed width
|
|
242
|
+
total_needed = current_visible_length + token_visible_length
|
|
243
|
+
|
|
244
|
+
if total_needed > width and current_visible_length > 0:
|
|
245
|
+
# Wrap to next line - don't carry trailing whitespace
|
|
246
|
+
wrapped.append(current_line.rstrip())
|
|
247
|
+
if is_whitespace:
|
|
248
|
+
# Don't start new line with whitespace
|
|
249
|
+
current_line = tracker.get_active_codes()
|
|
250
|
+
current_visible_length = 0
|
|
251
|
+
else:
|
|
252
|
+
current_line = tracker.get_active_codes() + token
|
|
253
|
+
current_visible_length = token_visible_length
|
|
254
|
+
else:
|
|
255
|
+
# Add to current line
|
|
256
|
+
current_line += token
|
|
257
|
+
current_visible_length += token_visible_length
|
|
258
|
+
|
|
259
|
+
_update_tracker_from_text(token, tracker)
|
|
260
|
+
|
|
261
|
+
if current_line:
|
|
262
|
+
wrapped.append(current_line)
|
|
263
|
+
|
|
264
|
+
return wrapped if wrapped else [""]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _break_long_word(word: str, width: int, tracker: AnsiCodeTracker) -> list[str]:
|
|
268
|
+
"""Break a word that's too long to fit on one line."""
|
|
269
|
+
lines: list[str] = []
|
|
270
|
+
current_line = tracker.get_active_codes()
|
|
271
|
+
current_width = 0
|
|
272
|
+
|
|
273
|
+
# Separate ANSI codes from visible content
|
|
274
|
+
segments: list[tuple[str, str]] = [] # (type, value)
|
|
275
|
+
i = 0
|
|
276
|
+
|
|
277
|
+
while i < len(word):
|
|
278
|
+
result = extract_ansi_code(word, i)
|
|
279
|
+
if result:
|
|
280
|
+
segments.append(("ansi", result.code))
|
|
281
|
+
i += result.length
|
|
282
|
+
else:
|
|
283
|
+
# Add single character as grapheme
|
|
284
|
+
segments.append(("grapheme", word[i]))
|
|
285
|
+
i += 1
|
|
286
|
+
|
|
287
|
+
# Process segments
|
|
288
|
+
for seg_type, seg_value in segments:
|
|
289
|
+
if seg_type == "ansi":
|
|
290
|
+
current_line += seg_value
|
|
291
|
+
tracker.process(seg_value)
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
grapheme = seg_value
|
|
295
|
+
grapheme_width = visible_width(grapheme)
|
|
296
|
+
|
|
297
|
+
if current_width + grapheme_width > width:
|
|
298
|
+
lines.append(current_line)
|
|
299
|
+
current_line = tracker.get_active_codes()
|
|
300
|
+
current_width = 0
|
|
301
|
+
|
|
302
|
+
current_line += grapheme
|
|
303
|
+
current_width += grapheme_width
|
|
304
|
+
|
|
305
|
+
if current_line:
|
|
306
|
+
lines.append(current_line)
|
|
307
|
+
|
|
308
|
+
return lines if lines else [""]
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def truncate_to_width(text: str, max_width: int, ellipsis: str = "...") -> str:
|
|
312
|
+
"""Truncate text to fit within a maximum visible width, adding ellipsis if needed.
|
|
313
|
+
|
|
314
|
+
Properly handles ANSI escape codes (they don't count toward width).
|
|
315
|
+
|
|
316
|
+
Args:
|
|
317
|
+
text: Text to truncate (may contain ANSI codes)
|
|
318
|
+
max_width: Maximum visible width
|
|
319
|
+
ellipsis: Ellipsis string to append when truncating
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
Truncated text with ellipsis if it exceeded max_width
|
|
323
|
+
"""
|
|
324
|
+
text_visible_width = visible_width(text)
|
|
325
|
+
|
|
326
|
+
if text_visible_width <= max_width:
|
|
327
|
+
return text
|
|
328
|
+
|
|
329
|
+
ellipsis_width = visible_width(ellipsis)
|
|
330
|
+
target_width = max_width - ellipsis_width
|
|
331
|
+
|
|
332
|
+
if target_width <= 0:
|
|
333
|
+
return ellipsis[:max_width]
|
|
334
|
+
|
|
335
|
+
current_width = 0
|
|
336
|
+
truncate_at = 0
|
|
337
|
+
i = 0
|
|
338
|
+
|
|
339
|
+
while i < len(text) and current_width < target_width:
|
|
340
|
+
# Skip ANSI escape sequences
|
|
341
|
+
if text[i] == "\x1b" and i + 1 < len(text) and text[i + 1] == "[":
|
|
342
|
+
j = i + 2
|
|
343
|
+
while j < len(text) and not text[j].isalpha():
|
|
344
|
+
j += 1
|
|
345
|
+
j += 1 # Include the final letter
|
|
346
|
+
truncate_at = j
|
|
347
|
+
i = j
|
|
348
|
+
continue
|
|
349
|
+
|
|
350
|
+
char = text[i]
|
|
351
|
+
char_width = visible_width(char)
|
|
352
|
+
|
|
353
|
+
if current_width + char_width > target_width:
|
|
354
|
+
break
|
|
355
|
+
|
|
356
|
+
current_width += char_width
|
|
357
|
+
truncate_at = i + 1
|
|
358
|
+
i += 1
|
|
359
|
+
|
|
360
|
+
# Add reset code before ellipsis to prevent styling leaking into it
|
|
361
|
+
return text[:truncate_at] + "\x1b[0m" + ellipsis
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def slice_ansi(text: str, start: int, end: int) -> str:
|
|
365
|
+
"""Extract a horizontal slice of text by visible column positions.
|
|
366
|
+
|
|
367
|
+
Like text[start:end] but works with ANSI codes - the slice is based on
|
|
368
|
+
visible character positions, not byte positions. ANSI codes that were
|
|
369
|
+
active at the start position are prepended to maintain styling.
|
|
370
|
+
|
|
371
|
+
This is equivalent to bubble tea's ansi.Cut(text, start, end).
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
text: Text to slice (may contain ANSI codes)
|
|
375
|
+
start: Starting visible column (0-indexed, inclusive)
|
|
376
|
+
end: Ending visible column (exclusive)
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Sliced text with ANSI codes preserved
|
|
380
|
+
|
|
381
|
+
Invariants:
|
|
382
|
+
- visible_width(result) <= end - start
|
|
383
|
+
- Result preserves ANSI styling from the sliced region
|
|
384
|
+
|
|
385
|
+
Example:
|
|
386
|
+
slice_ansi("\\x1b[31mhello world\\x1b[0m", 2, 7) -> "\\x1b[31mllo w\\x1b[0m"
|
|
387
|
+
"""
|
|
388
|
+
assert start >= 0, f"start must be >= 0, got {start}"
|
|
389
|
+
assert end >= 0, f"end must be >= 0, got {end}"
|
|
390
|
+
|
|
391
|
+
if start >= end:
|
|
392
|
+
return ""
|
|
393
|
+
|
|
394
|
+
tracker = AnsiCodeTracker()
|
|
395
|
+
result_chars: list[str] = []
|
|
396
|
+
current_col = 0
|
|
397
|
+
i = 0
|
|
398
|
+
|
|
399
|
+
# First pass: skip to start position, tracking ANSI codes
|
|
400
|
+
while i < len(text) and current_col < start:
|
|
401
|
+
ansi = extract_ansi_code(text, i)
|
|
402
|
+
if ansi:
|
|
403
|
+
tracker.process(ansi.code)
|
|
404
|
+
i += ansi.length
|
|
405
|
+
continue
|
|
406
|
+
|
|
407
|
+
char = text[i]
|
|
408
|
+
char_width = visible_width(char)
|
|
409
|
+
|
|
410
|
+
# Check if this character spans the start position
|
|
411
|
+
if current_col + char_width > start:
|
|
412
|
+
# Character starts before start but extends into our slice
|
|
413
|
+
# Include it if any part is visible
|
|
414
|
+
break
|
|
415
|
+
|
|
416
|
+
current_col += char_width
|
|
417
|
+
i += 1
|
|
418
|
+
|
|
419
|
+
# Prepend active ANSI codes to maintain styling
|
|
420
|
+
prefix = tracker.get_active_codes()
|
|
421
|
+
|
|
422
|
+
# Second pass: collect characters from start to end
|
|
423
|
+
while i < len(text) and current_col < end:
|
|
424
|
+
ansi = extract_ansi_code(text, i)
|
|
425
|
+
if ansi:
|
|
426
|
+
result_chars.append(ansi.code)
|
|
427
|
+
tracker.process(ansi.code)
|
|
428
|
+
i += ansi.length
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
char = text[i]
|
|
432
|
+
char_width = visible_width(char)
|
|
433
|
+
|
|
434
|
+
# Include character if it starts before end
|
|
435
|
+
result_chars.append(char)
|
|
436
|
+
current_col += char_width
|
|
437
|
+
i += 1
|
|
438
|
+
|
|
439
|
+
# Add reset at end to prevent style leaking
|
|
440
|
+
suffix = "\x1b[0m" if tracker.has_active_codes() or prefix else ""
|
|
441
|
+
|
|
442
|
+
result = prefix + "".join(result_chars) + suffix
|
|
443
|
+
|
|
444
|
+
# Assert invariant: result width should not exceed requested slice width
|
|
445
|
+
result_width = visible_width(result)
|
|
446
|
+
max_expected = end - start
|
|
447
|
+
assert result_width <= max_expected, (
|
|
448
|
+
f"slice_ansi result width {result_width} > expected max {max_expected}. "
|
|
449
|
+
f"start={start}, end={end}, text={repr(text[:80])}"
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def apply_background_to_line(line: str, width: int, bg_fn: Callable[[str], str]) -> str:
|
|
456
|
+
"""Apply background color to a line, padding to full width.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
line: Line of text (may contain ANSI codes)
|
|
460
|
+
width: Total width to pad to
|
|
461
|
+
bg_fn: Background color function (text -> styled text)
|
|
462
|
+
|
|
463
|
+
Returns:
|
|
464
|
+
Line with background applied and padded to width
|
|
465
|
+
"""
|
|
466
|
+
visible_len = visible_width(line)
|
|
467
|
+
padding_needed = max(0, width - visible_len)
|
|
468
|
+
padding = " " * padding_needed
|
|
469
|
+
with_padding = line + padding
|
|
470
|
+
return bg_fn(with_padding)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Theme system for TUI - true-color ANSI support with configurable palettes.
|
|
2
|
+
|
|
3
|
+
Extracted from rollouts/frontends/tui/theme.py - same code, new home.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
# Tool display modes
|
|
13
|
+
ToolDisplayMode = Literal["compact", "standard", "expanded"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
|
|
17
|
+
"""Convert hex color to RGB tuple."""
|
|
18
|
+
h = hex_color.lstrip("#")
|
|
19
|
+
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hex_to_fg(hex_color: str) -> str:
|
|
23
|
+
"""Convert hex color to ANSI true-color foreground escape."""
|
|
24
|
+
r, g, b = hex_to_rgb(hex_color)
|
|
25
|
+
return f"\x1b[38;2;{r};{g};{b}m"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def hex_to_bg(hex_color: str) -> str:
|
|
29
|
+
"""Convert hex color to ANSI true-color background escape."""
|
|
30
|
+
r, g, b = hex_to_rgb(hex_color)
|
|
31
|
+
return f"\x1b[48;2;{r};{g};{b}m"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
RESET = "\x1b[0m"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Theme:
|
|
39
|
+
"""TUI color theme - matches pi-mono dark theme."""
|
|
40
|
+
|
|
41
|
+
# Core UI
|
|
42
|
+
accent: str = "#8abeb7"
|
|
43
|
+
border: str = "#808080"
|
|
44
|
+
muted: str = "#666666"
|
|
45
|
+
dim: str = "#505050"
|
|
46
|
+
text: str = "#cccccc"
|
|
47
|
+
warning: str = "#f0c674" # Yellow/amber for warnings and retries
|
|
48
|
+
error: str = "#cc6666" # Red for errors
|
|
49
|
+
|
|
50
|
+
# Message backgrounds
|
|
51
|
+
user_message_bg: str = "#343541"
|
|
52
|
+
tool_pending_bg: str = "#282832"
|
|
53
|
+
tool_success_bg: str = "#283228"
|
|
54
|
+
tool_error_bg: str = "#3c2828"
|
|
55
|
+
|
|
56
|
+
# Diff colors (pi-mono inspired)
|
|
57
|
+
diff_added: str = "#2d4a2d" # Dark green for added lines
|
|
58
|
+
diff_removed: str = "#4a2d2d" # Dark red for removed lines
|
|
59
|
+
diff_context: str = "#505050" # Dim gray for context
|
|
60
|
+
|
|
61
|
+
# Padding settings (vertical padding in lines)
|
|
62
|
+
message_padding_y: int = 0 # Padding for regular message text
|
|
63
|
+
tool_padding_y: int = 0 # Padding for tool execution blocks
|
|
64
|
+
thinking_padding_y: int = 0 # Padding for thinking blocks
|
|
65
|
+
|
|
66
|
+
# Compact padding: Uses half-height unicode blocks instead of full blank lines
|
|
67
|
+
# When True, padding lines show "▁" (lower one eighth block) with background color
|
|
68
|
+
use_compact_padding: bool = False
|
|
69
|
+
|
|
70
|
+
# Gutter prefix symbols
|
|
71
|
+
assistant_gutter: str = "☻ " # Assistant message prefix
|
|
72
|
+
user_gutter: str = "> " # User message prefix
|
|
73
|
+
tool_success_gutter: str = "☺ " # Tool success prefix
|
|
74
|
+
tool_error_gutter: str = "☹ " # Tool error prefix
|
|
75
|
+
input_gutter: str = "> " # Input box prefix
|
|
76
|
+
|
|
77
|
+
# Markdown
|
|
78
|
+
md_heading: str = "#ffffff" # White (Claude Code style - just bold)
|
|
79
|
+
md_link: str = "#81a2be"
|
|
80
|
+
md_link_url: str = "#666666"
|
|
81
|
+
md_code: str = "#b1b9f9" # Lavender (Claude Code style)
|
|
82
|
+
md_code_block: str = "#b1b9f9" # Lavender (Claude Code style)
|
|
83
|
+
md_code_border: str = "#666666"
|
|
84
|
+
md_quote: str = "#cccccc"
|
|
85
|
+
md_quote_border: str = "#00d7ff"
|
|
86
|
+
md_hr: str = "#666666"
|
|
87
|
+
md_list_bullet: str = "#00d7ff"
|
|
88
|
+
|
|
89
|
+
# Thinking intensity levels (gray → purple gradient)
|
|
90
|
+
thinking_minimal: str = "#6e6e6e"
|
|
91
|
+
thinking_low: str = "#5f87af"
|
|
92
|
+
thinking_medium: str = "#81a2be"
|
|
93
|
+
thinking_high: str = "#b294bb"
|
|
94
|
+
|
|
95
|
+
# Thinking block text color (gray instead of white)
|
|
96
|
+
thinking_text: str = "#888888"
|
|
97
|
+
|
|
98
|
+
# Tool display mode: compact (one-liner), standard (truncated), expanded (full)
|
|
99
|
+
tool_display: ToolDisplayMode = "standard"
|
|
100
|
+
|
|
101
|
+
# Lines shown in standard mode (expanded = unlimited, compact = 0)
|
|
102
|
+
tool_lines_standard: int = 10
|
|
103
|
+
|
|
104
|
+
# Helper methods for common operations
|
|
105
|
+
def fg(self, hex_color: str) -> Callable[[str], str]:
|
|
106
|
+
"""Return a function that applies foreground color to text."""
|
|
107
|
+
prefix = hex_to_fg(hex_color)
|
|
108
|
+
return lambda text: f"{prefix}{text}{RESET}"
|
|
109
|
+
|
|
110
|
+
def bg(self, hex_color: str) -> Callable[[str], str]:
|
|
111
|
+
"""Return a function that applies background color to text."""
|
|
112
|
+
prefix = hex_to_bg(hex_color)
|
|
113
|
+
return lambda text: f"{prefix}{text}{RESET}"
|
|
114
|
+
|
|
115
|
+
def fg_bg(self, fg_hex: str, bg_hex: str) -> Callable[[str], str]:
|
|
116
|
+
"""Return a function that applies both foreground and background."""
|
|
117
|
+
fg_prefix = hex_to_fg(fg_hex)
|
|
118
|
+
bg_prefix = hex_to_bg(bg_hex)
|
|
119
|
+
return lambda text: f"{fg_prefix}{bg_prefix}{text}{RESET}"
|
|
120
|
+
|
|
121
|
+
# Convenience color functions
|
|
122
|
+
def accent_fg(self, text: str) -> str:
|
|
123
|
+
return f"{hex_to_fg(self.accent)}{text}{RESET}"
|
|
124
|
+
|
|
125
|
+
def muted_fg(self, text: str) -> str:
|
|
126
|
+
return f"{hex_to_fg(self.muted)}{text}{RESET}"
|
|
127
|
+
|
|
128
|
+
def border_fg(self, text: str) -> str:
|
|
129
|
+
return f"{hex_to_fg(self.border)}{text}{RESET}"
|
|
130
|
+
|
|
131
|
+
def warning_fg(self, text: str) -> str:
|
|
132
|
+
return f"{hex_to_fg(self.warning)}{text}{RESET}"
|
|
133
|
+
|
|
134
|
+
def error_fg(self, text: str) -> str:
|
|
135
|
+
return f"{hex_to_fg(self.error)}{text}{RESET}"
|
|
136
|
+
|
|
137
|
+
# Subtle backgrounds for error/warning blocks
|
|
138
|
+
def warning_subtle_bg(self, text: str) -> str:
|
|
139
|
+
"""Subtle warning background (darker yellow tint)."""
|
|
140
|
+
return f"{hex_to_bg('#3a3520')}{text}{RESET}"
|
|
141
|
+
|
|
142
|
+
def error_subtle_bg(self, text: str) -> str:
|
|
143
|
+
"""Subtle error background (darker red tint)."""
|
|
144
|
+
return f"{hex_to_bg('#3c2828')}{text}{RESET}"
|
|
145
|
+
|
|
146
|
+
# Tool backgrounds
|
|
147
|
+
def tool_pending_bg_fn(self, text: str) -> str:
|
|
148
|
+
return f"{hex_to_bg(self.tool_pending_bg)}{text}{RESET}"
|
|
149
|
+
|
|
150
|
+
def tool_success_bg_fn(self, text: str) -> str:
|
|
151
|
+
return f"{hex_to_bg(self.tool_success_bg)}{text}{RESET}"
|
|
152
|
+
|
|
153
|
+
def tool_error_bg_fn(self, text: str) -> str:
|
|
154
|
+
return f"{hex_to_bg(self.tool_error_bg)}{text}{RESET}"
|
|
155
|
+
|
|
156
|
+
# User message background
|
|
157
|
+
def user_message_bg_fn(self, text: str) -> str:
|
|
158
|
+
return f"{hex_to_bg(self.user_message_bg)}{text}{RESET}"
|
|
159
|
+
|
|
160
|
+
# Diff colors (foreground only, for readability)
|
|
161
|
+
def diff_added_fg(self, text: str) -> str:
|
|
162
|
+
return f"{hex_to_fg(self.diff_added)}{text}{RESET}"
|
|
163
|
+
|
|
164
|
+
def diff_removed_fg(self, text: str) -> str:
|
|
165
|
+
return f"{hex_to_fg(self.diff_removed)}{text}{RESET}"
|
|
166
|
+
|
|
167
|
+
def diff_context_fg(self, text: str) -> str:
|
|
168
|
+
return f"{hex_to_fg(self.diff_context)}{text}{RESET}"
|
|
169
|
+
|
|
170
|
+
# Thinking colors by intensity
|
|
171
|
+
def thinking_fg(self, intensity: str = "medium") -> Callable[[str], str]:
|
|
172
|
+
"""Get thinking color function by intensity level."""
|
|
173
|
+
colors = {
|
|
174
|
+
"minimal": self.thinking_minimal,
|
|
175
|
+
"low": self.thinking_low,
|
|
176
|
+
"medium": self.thinking_medium,
|
|
177
|
+
"high": self.thinking_high,
|
|
178
|
+
}
|
|
179
|
+
color = colors.get(intensity, self.thinking_medium)
|
|
180
|
+
return self.fg(color)
|
|
181
|
+
|
|
182
|
+
def thinking_text_fg(self, text: str) -> str:
|
|
183
|
+
"""Apply gray color to thinking block text."""
|
|
184
|
+
return f"{hex_to_fg(self.thinking_text)}{text}{RESET}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# Default dark theme instance (minimal, no padding)
|
|
188
|
+
DARK_THEME = Theme()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# Soft dark theme with subtle padding separators
|
|
192
|
+
SOFT_DARK_THEME = Theme(
|
|
193
|
+
message_padding_y=1,
|
|
194
|
+
tool_padding_y=1,
|
|
195
|
+
thinking_padding_y=1,
|
|
196
|
+
use_compact_padding=True, # Use thin ▁ lines instead of blank space
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass
|
|
201
|
+
class MinimalTheme(Theme):
|
|
202
|
+
"""Minimal theme - no backgrounds on tools/thinking, only user messages."""
|
|
203
|
+
|
|
204
|
+
def tool_pending_bg_fn(self, text: str) -> str:
|
|
205
|
+
return text
|
|
206
|
+
|
|
207
|
+
def tool_success_bg_fn(self, text: str) -> str:
|
|
208
|
+
return text
|
|
209
|
+
|
|
210
|
+
def tool_error_bg_fn(self, text: str) -> str:
|
|
211
|
+
return text
|
|
212
|
+
|
|
213
|
+
def thinking_bg_fn(self, text: str) -> str:
|
|
214
|
+
return text
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# Minimal theme instance - clean look without colored backgrounds
|
|
218
|
+
MINIMAL_THEME = MinimalTheme(
|
|
219
|
+
# Brighter diff colors for better visibility without backgrounds
|
|
220
|
+
diff_added="#98c379", # Bright green
|
|
221
|
+
diff_removed="#e06c75", # Bright red
|
|
222
|
+
diff_context="#abb2bf", # Light gray
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass
|
|
227
|
+
class RoundedTheme(Theme):
|
|
228
|
+
"""Rounded theme variant with border decorations."""
|
|
229
|
+
|
|
230
|
+
# Enable rounded corners
|
|
231
|
+
use_rounded_corners: bool = True
|
|
232
|
+
|
|
233
|
+
# Corner characters
|
|
234
|
+
corner_tl: str = "╭" # Top-left
|
|
235
|
+
corner_tr: str = "╮" # Top-right
|
|
236
|
+
corner_bl: str = "╰" # Bottom-left
|
|
237
|
+
corner_br: str = "╯" # Bottom-right
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# Rounded theme instance
|
|
241
|
+
ROUNDED_THEME = RoundedTheme()
|