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.
- overcode/__init__.py +1 -1
- overcode/cli.py +7 -2
- overcode/implementations.py +74 -8
- overcode/monitor_daemon.py +60 -65
- overcode/monitor_daemon_core.py +261 -0
- overcode/monitor_daemon_state.py +7 -0
- overcode/session_manager.py +1 -0
- overcode/settings.py +22 -0
- overcode/supervisor_daemon.py +48 -47
- overcode/supervisor_daemon_core.py +210 -0
- overcode/testing/__init__.py +6 -0
- overcode/testing/renderer.py +268 -0
- overcode/testing/tmux_driver.py +223 -0
- overcode/testing/tui_eye.py +185 -0
- overcode/testing/tui_eye_skill.md +187 -0
- overcode/tmux_manager.py +17 -3
- overcode/tui.py +196 -2462
- overcode/tui_actions/__init__.py +20 -0
- overcode/tui_actions/daemon.py +201 -0
- overcode/tui_actions/input.py +128 -0
- overcode/tui_actions/navigation.py +117 -0
- overcode/tui_actions/session.py +428 -0
- overcode/tui_actions/view.py +357 -0
- overcode/tui_helpers.py +41 -9
- overcode/tui_logic.py +347 -0
- overcode/tui_render.py +414 -0
- overcode/tui_widgets/__init__.py +24 -0
- overcode/tui_widgets/command_bar.py +399 -0
- overcode/tui_widgets/daemon_panel.py +153 -0
- overcode/tui_widgets/daemon_status_bar.py +245 -0
- overcode/tui_widgets/help_overlay.py +71 -0
- overcode/tui_widgets/preview_pane.py +69 -0
- overcode/tui_widgets/session_summary.py +514 -0
- overcode/tui_widgets/status_timeline.py +253 -0
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/METADATA +3 -1
- overcode-0.1.4.dist-info/RECORD +68 -0
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
- overcode-0.1.3.dist-info/RECORD +0 -45
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/WHEEL +0 -0
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {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):
|
overcode/supervisor_daemon.py
CHANGED
|
@@ -16,7 +16,7 @@ Prerequisites:
|
|
|
16
16
|
Architecture:
|
|
17
17
|
Monitor Daemon (metrics) → monitor_daemon_state.json → Supervisor Daemon (claude)
|
|
18
18
|
|
|
19
|
-
|
|
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
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
self.supervisor_stats.supervisor_claude_total_run_seconds
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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,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
|