overcode 0.1.3__py3-none-any.whl → 0.1.4__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 (41) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/cli.py +7 -2
  3. overcode/implementations.py +74 -8
  4. overcode/monitor_daemon.py +60 -65
  5. overcode/monitor_daemon_core.py +261 -0
  6. overcode/monitor_daemon_state.py +7 -0
  7. overcode/session_manager.py +1 -0
  8. overcode/settings.py +22 -0
  9. overcode/supervisor_daemon.py +48 -47
  10. overcode/supervisor_daemon_core.py +210 -0
  11. overcode/testing/__init__.py +6 -0
  12. overcode/testing/renderer.py +268 -0
  13. overcode/testing/tmux_driver.py +223 -0
  14. overcode/testing/tui_eye.py +185 -0
  15. overcode/testing/tui_eye_skill.md +187 -0
  16. overcode/tmux_manager.py +17 -3
  17. overcode/tui.py +196 -2462
  18. overcode/tui_actions/__init__.py +20 -0
  19. overcode/tui_actions/daemon.py +201 -0
  20. overcode/tui_actions/input.py +128 -0
  21. overcode/tui_actions/navigation.py +117 -0
  22. overcode/tui_actions/session.py +428 -0
  23. overcode/tui_actions/view.py +357 -0
  24. overcode/tui_helpers.py +41 -9
  25. overcode/tui_logic.py +347 -0
  26. overcode/tui_render.py +414 -0
  27. overcode/tui_widgets/__init__.py +24 -0
  28. overcode/tui_widgets/command_bar.py +399 -0
  29. overcode/tui_widgets/daemon_panel.py +153 -0
  30. overcode/tui_widgets/daemon_status_bar.py +245 -0
  31. overcode/tui_widgets/help_overlay.py +71 -0
  32. overcode/tui_widgets/preview_pane.py +69 -0
  33. overcode/tui_widgets/session_summary.py +514 -0
  34. overcode/tui_widgets/status_timeline.py +253 -0
  35. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/METADATA +3 -1
  36. overcode-0.1.4.dist-info/RECORD +68 -0
  37. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
  38. overcode-0.1.3.dist-info/RECORD +0 -45
  39. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/WHEEL +0 -0
  40. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
  41. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
overcode/settings.py CHANGED
@@ -216,6 +216,12 @@ class UserConfig:
216
216
  default_standing_instructions: str = ""
217
217
  tmux_session: str = "agents"
218
218
 
219
+ # Token pricing (per million tokens) - defaults to Opus 4.5
220
+ price_input: float = 5.0 # $/MTok for input tokens
221
+ price_output: float = 25.0 # $/MTok for output tokens
222
+ price_cache_write: float = 6.25 # $/MTok for cache creation
223
+ price_cache_read: float = 0.50 # $/MTok for cache reads
224
+
219
225
  @classmethod
220
226
  def load(cls) -> "UserConfig":
221
227
  """Load configuration from config file."""
@@ -230,11 +236,18 @@ class UserConfig:
230
236
  if not isinstance(data, dict):
231
237
  return cls()
232
238
 
239
+ # Load pricing config (nested under 'pricing' key)
240
+ pricing = data.get("pricing", {})
241
+
233
242
  return cls(
234
243
  default_standing_instructions=data.get(
235
244
  "default_standing_instructions", ""
236
245
  ),
237
246
  tmux_session=data.get("tmux_session", "agents"),
247
+ price_input=pricing.get("input", 5.0),
248
+ price_output=pricing.get("output", 25.0),
249
+ price_cache_write=pricing.get("cache_write", 6.25),
250
+ price_cache_read=pricing.get("cache_read", 0.50),
238
251
  )
239
252
  except (yaml.YAMLError, IOError):
240
253
  return cls()
@@ -384,6 +397,9 @@ class TUIPreferences:
384
397
  hide_asleep: bool = False # hide sleeping agents from display
385
398
  sort_mode: str = "alphabetical" # alphabetical, by_status, by_value (#61)
386
399
  summary_content_mode: str = "ai_short" # ai_short, ai_long, orders, annotation (#98)
400
+ baseline_minutes: int = 60 # 0=now (instantaneous), 15/30/.../180 = minutes back for mean spin
401
+ monochrome: bool = False # B&W mode for terminals with ANSI issues (#138)
402
+ show_cost: bool = False # Show $ cost instead of token counts
387
403
  # Session IDs of stalled agents that have been visited by the user
388
404
  visited_stalled_agents: Set[str] = field(default_factory=set)
389
405
 
@@ -413,6 +429,9 @@ class TUIPreferences:
413
429
  hide_asleep=data.get("hide_asleep", False),
414
430
  sort_mode=data.get("sort_mode", "alphabetical"),
415
431
  summary_content_mode=data.get("summary_content_mode", "ai_short"),
432
+ baseline_minutes=data.get("baseline_minutes", 0),
433
+ monochrome=data.get("monochrome", False),
434
+ show_cost=data.get("show_cost", False),
416
435
  visited_stalled_agents=set(data.get("visited_stalled_agents", [])),
417
436
  )
418
437
  except (json.JSONDecodeError, IOError):
@@ -437,6 +456,9 @@ class TUIPreferences:
437
456
  "hide_asleep": self.hide_asleep,
438
457
  "sort_mode": self.sort_mode,
439
458
  "summary_content_mode": self.summary_content_mode,
459
+ "baseline_minutes": self.baseline_minutes,
460
+ "monochrome": self.monochrome,
461
+ "show_cost": self.show_cost,
440
462
  "visited_stalled_agents": list(self.visited_stalled_agents),
441
463
  }, f, indent=2)
442
464
  except (IOError, OSError):
@@ -16,7 +16,7 @@ Prerequisites:
16
16
  Architecture:
17
17
  Monitor Daemon (metrics) → monitor_daemon_state.json → Supervisor Daemon (claude)
18
18
 
19
- TODO: Add unit tests (currently 0% coverage)
19
+ Pure business logic is extracted to supervisor_daemon_core.py for testability.
20
20
  TODO: Extract _send_prompt_to_window to a shared tmux utilities module
21
21
  (duplicated in launcher.py)
22
22
  """
@@ -60,6 +60,13 @@ from .status_constants import (
60
60
  )
61
61
  from .tmux_manager import TmuxManager
62
62
  from .history_reader import encode_project_path, read_token_usage_from_session_file
63
+ from .supervisor_daemon_core import (
64
+ build_daemon_claude_context as _build_daemon_claude_context,
65
+ filter_non_green_sessions,
66
+ calculate_daemon_claude_run_seconds,
67
+ should_launch_daemon_claude,
68
+ parse_intervention_log_line,
69
+ )
63
70
 
64
71
 
65
72
  @dataclass
@@ -473,14 +480,14 @@ class SupervisorDaemon:
473
480
  def _mark_daemon_claude_stopped(self) -> None:
474
481
  """Mark daemon claude as stopped and accumulate run time."""
475
482
  if self.supervisor_stats.supervisor_claude_running:
476
- # Calculate run duration
477
- if self.supervisor_stats.supervisor_claude_started_at:
478
- try:
479
- started_at = datetime.fromisoformat(self.supervisor_stats.supervisor_claude_started_at)
480
- run_seconds = (datetime.now() - started_at).total_seconds()
481
- self.supervisor_stats.supervisor_claude_total_run_seconds += run_seconds
482
- except (ValueError, TypeError):
483
- pass
483
+ # Calculate total run time using pure function
484
+ self.supervisor_stats.supervisor_claude_total_run_seconds = (
485
+ calculate_daemon_claude_run_seconds(
486
+ started_at_iso=self.supervisor_stats.supervisor_claude_started_at,
487
+ now_iso=datetime.now().isoformat(),
488
+ previous_total=self.supervisor_stats.supervisor_claude_total_run_seconds,
489
+ )
490
+ )
484
491
 
485
492
  self.supervisor_stats.supervisor_claude_running = False
486
493
  self.supervisor_stats.supervisor_claude_started_at = None
@@ -555,31 +562,18 @@ class SupervisorDaemon:
555
562
  non_green_sessions: List[SessionDaemonState]
556
563
  ) -> str:
557
564
  """Build initial context for daemon claude."""
558
- context_parts = []
559
-
560
- context_parts.append("You are the Overcode daemon claude agent.")
561
- context_parts.append("Your mission: Make all RED/YELLOW/ORANGE sessions GREEN.")
562
- context_parts.append("")
563
- context_parts.append(f"TMUX SESSION: {self.tmux_session}")
564
- context_parts.append(f"Sessions needing attention: {len(non_green_sessions)}")
565
- context_parts.append("")
566
-
567
- for session in non_green_sessions:
568
- emoji = get_status_emoji(session.current_status)
569
- context_parts.append(f"{emoji} {session.name} (window {session.tmux_window})")
570
- if session.standing_instructions:
571
- context_parts.append(f" Autopilot: {session.standing_instructions}")
572
- else:
573
- context_parts.append(f" No autopilot instructions set")
574
- if session.repo_name:
575
- context_parts.append(f" Repo: {session.repo_name}")
576
- context_parts.append("")
577
-
578
- context_parts.append("Read the daemon claude skill for how to control sessions via tmux.")
579
- context_parts.append("Start by reading ~/.overcode/sessions/sessions.json to see full state.")
580
- context_parts.append("Then check each non-green session and help them make progress.")
581
-
582
- return "\n".join(context_parts)
565
+ # Convert dataclass objects to dicts for pure function
566
+ session_dicts = [
567
+ {
568
+ "name": s.name,
569
+ "tmux_window": s.tmux_window,
570
+ "current_status": s.current_status,
571
+ "standing_instructions": s.standing_instructions,
572
+ "repo_name": s.repo_name,
573
+ }
574
+ for s in non_green_sessions
575
+ ]
576
+ return _build_daemon_claude_context(self.tmux_session, session_dicts)
583
577
 
584
578
  def _send_prompt_to_window(self, window_index: int, prompt: str) -> bool:
585
579
  """Send a large prompt to a tmux window via load-buffer/paste-buffer."""
@@ -686,19 +680,26 @@ class SupervisorDaemon:
686
680
  - Asleep sessions (#70)
687
681
  - Sessions with DO_NOTHING standing orders (#70)
688
682
  """
689
- result = []
690
- for s in monitor_state.sessions:
691
- # Skip green sessions and daemon_claude
692
- if s.current_status == STATUS_RUNNING or s.name == 'daemon_claude':
693
- continue
694
- # Skip asleep sessions
695
- if s.is_asleep:
696
- continue
697
- # Skip sessions with DO_NOTHING standing orders
698
- if s.standing_instructions and 'DO_NOTHING' in s.standing_instructions.upper():
699
- continue
700
- result.append(s)
701
- return result
683
+ # Convert to dicts for pure function
684
+ session_dicts = [
685
+ {
686
+ "name": s.name,
687
+ "current_status": s.current_status,
688
+ "is_asleep": s.is_asleep,
689
+ "standing_instructions": s.standing_instructions,
690
+ "_session": s, # Keep reference to original
691
+ }
692
+ for s in monitor_state.sessions
693
+ ]
694
+
695
+ # Filter using pure function
696
+ filtered = filter_non_green_sessions(
697
+ session_dicts,
698
+ exclude_names=["daemon_claude"],
699
+ )
700
+
701
+ # Return original SessionDaemonState objects
702
+ return [d["_session"] for d in filtered]
702
703
 
703
704
  def wait_for_monitor_daemon(self, timeout: int = 30, poll_interval: int = 2) -> bool:
704
705
  """Wait for monitor daemon to be running.
@@ -0,0 +1,210 @@
1
+ """
2
+ Pure business logic for Supervisor Daemon.
3
+
4
+ These functions contain no I/O and are fully unit-testable.
5
+ They are used by SupervisorDaemon but can be tested independently.
6
+ """
7
+
8
+ from typing import List, Optional
9
+
10
+ from .status_constants import STATUS_RUNNING, get_status_emoji
11
+
12
+
13
+ def build_daemon_claude_context(
14
+ tmux_session: str,
15
+ non_green_sessions: List[dict],
16
+ ) -> str:
17
+ """Build initial context prompt for daemon claude.
18
+
19
+ Pure function - no side effects, fully testable.
20
+
21
+ Args:
22
+ tmux_session: Name of the tmux session
23
+ non_green_sessions: List of session dicts with 'name', 'tmux_window',
24
+ 'standing_instructions', 'current_status', 'repo_name'
25
+
26
+ Returns:
27
+ Multi-line context string for daemon claude
28
+ """
29
+ context_parts = []
30
+
31
+ context_parts.append("You are the Overcode daemon claude agent.")
32
+ context_parts.append("Your mission: Make all RED/YELLOW/ORANGE sessions GREEN.")
33
+ context_parts.append("")
34
+ context_parts.append(f"TMUX SESSION: {tmux_session}")
35
+ context_parts.append(f"Sessions needing attention: {len(non_green_sessions)}")
36
+ context_parts.append("")
37
+
38
+ for session in non_green_sessions:
39
+ status = session.get("current_status", "unknown")
40
+ emoji = get_status_emoji(status)
41
+ name = session.get("name", "unknown")
42
+ window = session.get("tmux_window", "?")
43
+ context_parts.append(f"{emoji} {name} (window {window})")
44
+
45
+ instructions = session.get("standing_instructions")
46
+ if instructions:
47
+ context_parts.append(f" Autopilot: {instructions}")
48
+ else:
49
+ context_parts.append(" No autopilot instructions set")
50
+
51
+ repo_name = session.get("repo_name")
52
+ if repo_name:
53
+ context_parts.append(f" Repo: {repo_name}")
54
+ context_parts.append("")
55
+
56
+ context_parts.append("Read the daemon claude skill for how to control sessions via tmux.")
57
+ context_parts.append("Start by reading ~/.overcode/sessions/sessions.json to see full state.")
58
+ context_parts.append("Then check each non-green session and help them make progress.")
59
+
60
+ return "\n".join(context_parts)
61
+
62
+
63
+ def filter_non_green_sessions(
64
+ sessions: List[dict],
65
+ exclude_names: Optional[List[str]] = None,
66
+ ) -> List[dict]:
67
+ """Filter sessions to only those needing attention.
68
+
69
+ Pure function - no side effects, fully testable.
70
+
71
+ Filters out:
72
+ - Running (green) sessions
73
+ - Sessions with names in exclude_names (e.g., 'daemon_claude')
74
+ - Asleep sessions
75
+ - Sessions with DO_NOTHING standing orders
76
+
77
+ Args:
78
+ sessions: List of session dicts with 'current_status', 'name',
79
+ 'is_asleep', 'standing_instructions'
80
+ exclude_names: Optional list of session names to always exclude
81
+
82
+ Returns:
83
+ Filtered list of sessions needing attention
84
+ """
85
+ exclude_names = exclude_names or []
86
+ result = []
87
+
88
+ for s in sessions:
89
+ # Skip green sessions
90
+ if s.get("current_status") == STATUS_RUNNING:
91
+ continue
92
+
93
+ # Skip excluded names (e.g., daemon_claude)
94
+ if s.get("name") in exclude_names:
95
+ continue
96
+
97
+ # Skip asleep sessions
98
+ if s.get("is_asleep", False):
99
+ continue
100
+
101
+ # Skip sessions with DO_NOTHING standing orders
102
+ instructions = s.get("standing_instructions", "")
103
+ if instructions and "DO_NOTHING" in instructions.upper():
104
+ continue
105
+
106
+ result.append(s)
107
+
108
+ return result
109
+
110
+
111
+ def calculate_daemon_claude_run_seconds(
112
+ started_at_iso: Optional[str],
113
+ now_iso: str,
114
+ previous_total: float,
115
+ ) -> float:
116
+ """Calculate total daemon claude run time including current run.
117
+
118
+ Pure function - no side effects, fully testable.
119
+
120
+ Args:
121
+ started_at_iso: ISO timestamp when current run started (None if not running)
122
+ now_iso: Current time as ISO timestamp
123
+ previous_total: Previously accumulated run seconds
124
+
125
+ Returns:
126
+ Total run seconds including any current run
127
+ """
128
+ if started_at_iso is None:
129
+ return previous_total
130
+
131
+ try:
132
+ from datetime import datetime
133
+ started_at = datetime.fromisoformat(started_at_iso)
134
+ now = datetime.fromisoformat(now_iso)
135
+ current_run = (now - started_at).total_seconds()
136
+ return previous_total + max(0, current_run)
137
+ except (ValueError, TypeError):
138
+ return previous_total
139
+
140
+
141
+ def should_launch_daemon_claude(
142
+ non_green_sessions: List[dict],
143
+ daemon_claude_running: bool,
144
+ ) -> tuple:
145
+ """Determine if daemon claude should be launched.
146
+
147
+ Pure function - no side effects, fully testable.
148
+
149
+ Args:
150
+ non_green_sessions: List of non-green session dicts
151
+ daemon_claude_running: Whether daemon claude is already running
152
+
153
+ Returns:
154
+ Tuple of (should_launch: bool, reason: str)
155
+ """
156
+ if not non_green_sessions:
157
+ return False, "no_sessions"
158
+
159
+ if daemon_claude_running:
160
+ return False, "already_running"
161
+
162
+ # Check if all are waiting for user with no instructions
163
+ all_waiting_user = all(
164
+ s.get("current_status") == "waiting_user"
165
+ for s in non_green_sessions
166
+ )
167
+ any_has_instructions = any(
168
+ s.get("standing_instructions")
169
+ for s in non_green_sessions
170
+ )
171
+
172
+ if all_waiting_user and not any_has_instructions:
173
+ return False, "waiting_user_no_instructions"
174
+
175
+ reason = "with_instructions" if any_has_instructions else "non_user_blocked"
176
+ return True, reason
177
+
178
+
179
+ def parse_intervention_log_line(
180
+ line: str,
181
+ session_names: List[str],
182
+ action_phrases: List[str],
183
+ no_action_phrases: List[str],
184
+ ) -> Optional[str]:
185
+ """Parse a log line to extract intervention session name if applicable.
186
+
187
+ Pure function - no side effects, fully testable.
188
+
189
+ Args:
190
+ line: Single log line to parse
191
+ session_names: Session names to look for
192
+ action_phrases: Phrases indicating an action was taken
193
+ no_action_phrases: Phrases indicating no action was taken
194
+
195
+ Returns:
196
+ Session name if an intervention was detected, None otherwise
197
+ """
198
+ line_lower = line.lower()
199
+
200
+ for name in session_names:
201
+ if f"{name} - " in line:
202
+ # Check for no-action phrases first
203
+ if any(phrase in line_lower for phrase in no_action_phrases):
204
+ return None
205
+
206
+ # Check for action phrases
207
+ if any(phrase in line_lower for phrase in action_phrases):
208
+ return name
209
+
210
+ return None
@@ -0,0 +1,6 @@
1
+ """TUI visual testing tools for Claude Code integration."""
2
+
3
+ from .renderer import render_terminal_to_png
4
+ from .tmux_driver import TUIDriver
5
+
6
+ __all__ = ["render_terminal_to_png", "TUIDriver"]
@@ -0,0 +1,268 @@
1
+ """Render terminal output with ANSI codes to PNG images."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ import pyte
6
+ from PIL import Image, ImageDraw, ImageFont
7
+
8
+
9
+ # Default terminal color palette (based on common terminal themes)
10
+ ANSI_COLORS = {
11
+ "black": "#1e1e1e",
12
+ "red": "#f44747",
13
+ "green": "#6a9955",
14
+ "yellow": "#dcdcaa",
15
+ "blue": "#569cd6",
16
+ "magenta": "#c586c0",
17
+ "cyan": "#4ec9b0",
18
+ "white": "#d4d4d4",
19
+ # Bright variants
20
+ "brightblack": "#808080",
21
+ "brightred": "#f44747",
22
+ "brightgreen": "#6a9955",
23
+ "brightyellow": "#dcdcaa",
24
+ "brightblue": "#569cd6",
25
+ "brightmagenta": "#c586c0",
26
+ "brightcyan": "#4ec9b0",
27
+ "brightwhite": "#ffffff",
28
+ }
29
+
30
+ # Background color
31
+ BG_COLOR = "#1e1e1e"
32
+ DEFAULT_FG = "#d4d4d4"
33
+
34
+ # Font configuration
35
+ DEFAULT_FONT_SIZE = 14
36
+ FONT_PATHS = [
37
+ # macOS
38
+ "/System/Library/Fonts/Monaco.ttf",
39
+ "/System/Library/Fonts/SFMono-Regular.otf",
40
+ "/Library/Fonts/JetBrainsMono-Regular.ttf",
41
+ # Linux
42
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
43
+ "/usr/share/fonts/TTF/JetBrainsMono-Regular.ttf",
44
+ ]
45
+
46
+
47
+ def _find_monospace_font(size: int = DEFAULT_FONT_SIZE) -> ImageFont.FreeTypeFont:
48
+ """Find a suitable monospace font."""
49
+ for path in FONT_PATHS:
50
+ if Path(path).exists():
51
+ try:
52
+ return ImageFont.truetype(path, size)
53
+ except Exception:
54
+ continue
55
+ # Fallback to default
56
+ return ImageFont.load_default()
57
+
58
+
59
+ def _color_256_to_hex(n: int) -> str:
60
+ """Convert 256-color palette index to hex color."""
61
+ # Colors 0-15: Standard colors (handled by name usually)
62
+ standard_colors = [
63
+ "#000000", "#800000", "#008000", "#808000", "#000080", "#800080", "#008080", "#c0c0c0",
64
+ "#808080", "#ff0000", "#00ff00", "#ffff00", "#0000ff", "#ff00ff", "#00ffff", "#ffffff",
65
+ ]
66
+ if n < 16:
67
+ return standard_colors[n]
68
+
69
+ # Colors 16-231: 6x6x6 color cube
70
+ if n < 232:
71
+ n -= 16
72
+ r = (n // 36) * 51
73
+ g = ((n // 6) % 6) * 51
74
+ b = (n % 6) * 51
75
+ return f"#{r:02x}{g:02x}{b:02x}"
76
+
77
+ # Colors 232-255: Grayscale
78
+ gray = (n - 232) * 10 + 8
79
+ return f"#{gray:02x}{gray:02x}{gray:02x}"
80
+
81
+
82
+ def _pyte_color_to_hex(color, default: str, bright: bool = False) -> str:
83
+ """Convert pyte color to hex color."""
84
+ if color is None or color == "default":
85
+ return default
86
+
87
+ # Handle 256-color codes (pyte returns as int or string number)
88
+ if isinstance(color, int):
89
+ return _color_256_to_hex(color)
90
+
91
+ if isinstance(color, str):
92
+ # Handle 6-digit hex without # (pyte format for 24-bit color)
93
+ # Must check this FIRST before trying int conversion
94
+ if len(color) == 6 and all(c in "0123456789abcdefABCDEF" for c in color):
95
+ return f"#{color}"
96
+
97
+ # Try parsing as integer (256-color)
98
+ if color.isdigit():
99
+ return _color_256_to_hex(int(color))
100
+
101
+ # Handle named colors
102
+ if bright and color in ANSI_COLORS:
103
+ bright_key = f"bright{color}"
104
+ if bright_key in ANSI_COLORS:
105
+ return ANSI_COLORS[bright_key]
106
+
107
+ if color in ANSI_COLORS:
108
+ return ANSI_COLORS[color]
109
+
110
+ # Handle hex colors with #
111
+ if color.startswith("#") and len(color) == 7:
112
+ return color
113
+
114
+ return default
115
+
116
+
117
+ def render_terminal_to_png(
118
+ ansi_text: str,
119
+ output_path: str,
120
+ width: int = 120,
121
+ height: int = 40,
122
+ font_size: int = DEFAULT_FONT_SIZE,
123
+ padding: int = 10,
124
+ ) -> Path:
125
+ """Render ANSI terminal text to a PNG image.
126
+
127
+ Args:
128
+ ansi_text: Terminal output with ANSI escape codes
129
+ output_path: Path to save the PNG image
130
+ width: Terminal width in characters
131
+ height: Terminal height in characters
132
+ font_size: Font size in pixels
133
+ padding: Padding around the terminal content
134
+
135
+ Returns:
136
+ Path to the saved image
137
+ """
138
+ # Use a large internal buffer to prevent scrolling, then crop to actual content
139
+ internal_height = max(height, 200)
140
+ screen = pyte.Screen(width, internal_height)
141
+ stream = pyte.Stream(screen)
142
+
143
+ # Normalize line endings: \n -> \r\n for proper terminal emulation
144
+ # (terminal needs carriage return + line feed to move to start of next line)
145
+ normalized_text = ansi_text.replace('\r\n', '\n').replace('\n', '\r\n')
146
+
147
+ # Feed the ANSI text through the terminal emulator
148
+ stream.feed(normalized_text)
149
+
150
+ # Find actual content bounds (non-empty rows)
151
+ first_row = 0
152
+ last_row = internal_height - 1
153
+ for y in range(internal_height):
154
+ row_has_content = any(
155
+ screen.buffer[y][x].data.strip()
156
+ for x in range(width)
157
+ if hasattr(screen.buffer[y][x], 'data')
158
+ )
159
+ if row_has_content:
160
+ if first_row == 0:
161
+ first_row = y
162
+ last_row = y
163
+
164
+ # Use the requested height - the large internal buffer prevents scrolling,
165
+ # and we render from first_row for `height` rows
166
+ render_height = height
167
+
168
+ # Load font and calculate dimensions
169
+ font = _find_monospace_font(font_size)
170
+
171
+ # Get character dimensions using a test character
172
+ bbox = font.getbbox("M")
173
+ char_width = bbox[2] - bbox[0]
174
+ char_height = int(font_size * 1.4) # Line height
175
+
176
+ # Calculate image dimensions based on actual content
177
+ img_width = width * char_width + 2 * padding
178
+ img_height = render_height * char_height + 2 * padding
179
+
180
+ # Create image with dark background
181
+ img = Image.new("RGB", (img_width, img_height), color=BG_COLOR)
182
+ draw = ImageDraw.Draw(img)
183
+
184
+ # Render each character from content area
185
+ for y in range(render_height):
186
+ buffer_y = first_row + y
187
+ if buffer_y >= internal_height:
188
+ break
189
+ for x in range(width):
190
+ char = screen.buffer[buffer_y][x]
191
+
192
+ # Get character and style
193
+ char_data = char.data if hasattr(char, "data") else str(char)
194
+ if not char_data or char_data == " ":
195
+ continue
196
+
197
+ # Get colors from character attributes
198
+ fg_color = DEFAULT_FG
199
+ bg_color = None
200
+
201
+ if hasattr(char, "fg"):
202
+ fg_color = _pyte_color_to_hex(
203
+ char.fg, DEFAULT_FG, bright=getattr(char, "bold", False)
204
+ )
205
+ if hasattr(char, "bg") and char.bg != "default":
206
+ bg_color = _pyte_color_to_hex(char.bg, BG_COLOR)
207
+
208
+ # Calculate position
209
+ pos_x = padding + x * char_width
210
+ pos_y = padding + y * char_height
211
+
212
+ # Draw background if different from default
213
+ if bg_color and bg_color != BG_COLOR:
214
+ draw.rectangle(
215
+ [pos_x, pos_y, pos_x + char_width, pos_y + char_height],
216
+ fill=bg_color,
217
+ )
218
+
219
+ # Draw character
220
+ draw.text((pos_x, pos_y), char_data, fill=fg_color, font=font)
221
+
222
+ # Save image
223
+ output = Path(output_path)
224
+ output.parent.mkdir(parents=True, exist_ok=True)
225
+ img.save(output)
226
+
227
+ return output
228
+
229
+
230
+ def render_lines_to_png(
231
+ lines: list[str],
232
+ output_path: str,
233
+ font_size: int = DEFAULT_FONT_SIZE,
234
+ padding: int = 10,
235
+ fg_color: str = DEFAULT_FG,
236
+ bg_color: str = BG_COLOR,
237
+ ) -> Path:
238
+ """Render plain text lines to a PNG image (no ANSI parsing).
239
+
240
+ This is a simpler alternative when you have plain text without ANSI codes.
241
+ """
242
+ font = _find_monospace_font(font_size)
243
+
244
+ # Get character dimensions
245
+ bbox = font.getbbox("M")
246
+ char_width = bbox[2] - bbox[0]
247
+ char_height = int(font_size * 1.4)
248
+
249
+ # Calculate dimensions
250
+ max_line_length = max(len(line) for line in lines) if lines else 1
251
+ img_width = max_line_length * char_width + 2 * padding
252
+ img_height = len(lines) * char_height + 2 * padding
253
+
254
+ # Create image
255
+ img = Image.new("RGB", (img_width, img_height), color=bg_color)
256
+ draw = ImageDraw.Draw(img)
257
+
258
+ # Render lines
259
+ for y, line in enumerate(lines):
260
+ pos_y = padding + y * char_height
261
+ draw.text((padding, pos_y), line, fill=fg_color, font=font)
262
+
263
+ # Save
264
+ output = Path(output_path)
265
+ output.parent.mkdir(parents=True, exist_ok=True)
266
+ img.save(output)
267
+
268
+ return output