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,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()