overcode 0.1.2__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 +154 -51
- overcode/config.py +66 -0
- overcode/daemon_claude_skill.md +36 -33
- overcode/history_reader.py +69 -8
- overcode/implementations.py +178 -87
- overcode/monitor_daemon.py +87 -97
- overcode/monitor_daemon_core.py +261 -0
- overcode/monitor_daemon_state.py +24 -15
- overcode/pid_utils.py +17 -3
- overcode/session_manager.py +54 -0
- overcode/settings.py +34 -0
- overcode/status_constants.py +1 -1
- overcode/status_detector.py +8 -2
- overcode/status_patterns.py +19 -0
- overcode/summarizer_client.py +72 -27
- overcode/summarizer_component.py +87 -107
- overcode/supervisor_daemon.py +55 -38
- 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 +117 -93
- overcode/tui.py +399 -1969
- 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 +42 -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.2.dist-info → overcode-0.1.4.dist-info}/METADATA +4 -1
- overcode-0.1.4.dist-info/RECORD +68 -0
- {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/WHEEL +1 -1
- {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
- overcode-0.1.2.dist-info/RECORD +0 -45
- {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
|
@@ -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
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""tmux session management for TUI testing."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import libtmux
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TUIDriver:
|
|
9
|
+
"""Manages a tmux session for testing TUI applications.
|
|
10
|
+
|
|
11
|
+
This class provides a simple interface to:
|
|
12
|
+
- Start a TUI app in a tmux session with controlled dimensions
|
|
13
|
+
- Send keystrokes to the app
|
|
14
|
+
- Capture the screen content (with ANSI codes)
|
|
15
|
+
- Wait for specific content to appear
|
|
16
|
+
- Clean up the session
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
session_name: str = "tui-eye",
|
|
22
|
+
socket_name: Optional[str] = None,
|
|
23
|
+
):
|
|
24
|
+
"""Initialize the TUI driver.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
session_name: Name for the tmux session
|
|
28
|
+
socket_name: Optional tmux socket name (for isolation)
|
|
29
|
+
"""
|
|
30
|
+
self.session_name = session_name
|
|
31
|
+
self.socket_name = socket_name
|
|
32
|
+
self.server: Optional[libtmux.Server] = None
|
|
33
|
+
self.session: Optional[libtmux.Session] = None
|
|
34
|
+
|
|
35
|
+
def start(
|
|
36
|
+
self,
|
|
37
|
+
command: str,
|
|
38
|
+
width: int = 120,
|
|
39
|
+
height: int = 40,
|
|
40
|
+
env: Optional[dict[str, str]] = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Start a TUI application in a new tmux session.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
command: The command to run (e.g., "overcode supervisor")
|
|
46
|
+
width: Terminal width in characters
|
|
47
|
+
height: Terminal height in characters
|
|
48
|
+
env: Optional environment variables to set
|
|
49
|
+
"""
|
|
50
|
+
# Kill any existing session with this name
|
|
51
|
+
self.stop()
|
|
52
|
+
|
|
53
|
+
# Create server connection
|
|
54
|
+
if self.socket_name:
|
|
55
|
+
self.server = libtmux.Server(socket_name=self.socket_name)
|
|
56
|
+
else:
|
|
57
|
+
self.server = libtmux.Server()
|
|
58
|
+
|
|
59
|
+
# Create new session
|
|
60
|
+
self.session = self.server.new_session(
|
|
61
|
+
session_name=self.session_name,
|
|
62
|
+
window_command=command,
|
|
63
|
+
x=width,
|
|
64
|
+
y=height,
|
|
65
|
+
environment=env,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def send_keys(self, *keys: str, enter: bool = False) -> None:
|
|
69
|
+
"""Send keystrokes to the TUI.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
keys: Key names to send (e.g., "j", "k", "Enter", "escape")
|
|
73
|
+
enter: Whether to send Enter after all keys
|
|
74
|
+
"""
|
|
75
|
+
if not self.session:
|
|
76
|
+
self.connect()
|
|
77
|
+
if not self.session:
|
|
78
|
+
raise RuntimeError("No active session. Call start() first.")
|
|
79
|
+
|
|
80
|
+
pane = self.session.active_window.active_pane
|
|
81
|
+
|
|
82
|
+
for key in keys:
|
|
83
|
+
# Map common key names
|
|
84
|
+
key_map = {
|
|
85
|
+
"enter": "Enter",
|
|
86
|
+
"escape": "Escape",
|
|
87
|
+
"esc": "Escape",
|
|
88
|
+
"tab": "Tab",
|
|
89
|
+
"space": "Space",
|
|
90
|
+
"up": "Up",
|
|
91
|
+
"down": "Down",
|
|
92
|
+
"left": "Left",
|
|
93
|
+
"right": "Right",
|
|
94
|
+
"backspace": "BSpace",
|
|
95
|
+
}
|
|
96
|
+
mapped_key = key_map.get(key.lower(), key)
|
|
97
|
+
pane.send_keys(mapped_key, enter=False)
|
|
98
|
+
|
|
99
|
+
if enter:
|
|
100
|
+
pane.send_keys("Enter", enter=False)
|
|
101
|
+
|
|
102
|
+
def capture(self, with_ansi: bool = True) -> str:
|
|
103
|
+
"""Capture the current screen content.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
with_ansi: Whether to include ANSI escape codes
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
The screen content as a string
|
|
110
|
+
"""
|
|
111
|
+
if not self.session:
|
|
112
|
+
self.connect()
|
|
113
|
+
if not self.session:
|
|
114
|
+
raise RuntimeError("No active session. Call start() first.")
|
|
115
|
+
|
|
116
|
+
pane = self.session.active_window.active_pane
|
|
117
|
+
|
|
118
|
+
# Use capture_pane with escape sequences if requested
|
|
119
|
+
if with_ansi:
|
|
120
|
+
# -e preserves ANSI escape sequences
|
|
121
|
+
# -p prints to stdout
|
|
122
|
+
content = pane.cmd("capture-pane", "-e", "-p").stdout
|
|
123
|
+
else:
|
|
124
|
+
content = pane.cmd("capture-pane", "-p").stdout
|
|
125
|
+
|
|
126
|
+
return "\n".join(content) if isinstance(content, list) else content
|
|
127
|
+
|
|
128
|
+
def wait_for(
|
|
129
|
+
self,
|
|
130
|
+
text: str,
|
|
131
|
+
timeout: float = 10.0,
|
|
132
|
+
poll_interval: float = 0.2,
|
|
133
|
+
) -> bool:
|
|
134
|
+
"""Wait for specific text to appear on screen.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
text: The text to wait for
|
|
138
|
+
timeout: Maximum time to wait in seconds
|
|
139
|
+
poll_interval: Time between checks in seconds
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
True if text was found, False if timeout occurred
|
|
143
|
+
"""
|
|
144
|
+
start_time = time.time()
|
|
145
|
+
|
|
146
|
+
while time.time() - start_time < timeout:
|
|
147
|
+
content = self.capture(with_ansi=False)
|
|
148
|
+
if text in content:
|
|
149
|
+
return True
|
|
150
|
+
time.sleep(poll_interval)
|
|
151
|
+
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
def stop(self) -> None:
|
|
155
|
+
"""Stop and clean up the tmux session."""
|
|
156
|
+
if self.session:
|
|
157
|
+
try:
|
|
158
|
+
self.session.kill()
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
self.session = None
|
|
162
|
+
|
|
163
|
+
# Always try to kill by name, connecting first if needed
|
|
164
|
+
try:
|
|
165
|
+
if not self.server:
|
|
166
|
+
if self.socket_name:
|
|
167
|
+
self.server = libtmux.Server(socket_name=self.socket_name)
|
|
168
|
+
else:
|
|
169
|
+
self.server = libtmux.Server()
|
|
170
|
+
|
|
171
|
+
for session in self.server.sessions:
|
|
172
|
+
if session.name == self.session_name:
|
|
173
|
+
session.kill()
|
|
174
|
+
break
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
def __enter__(self) -> "TUIDriver":
|
|
179
|
+
"""Context manager entry."""
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
183
|
+
"""Context manager exit - ensures cleanup."""
|
|
184
|
+
self.stop()
|
|
185
|
+
|
|
186
|
+
def connect(self) -> bool:
|
|
187
|
+
"""Connect to an existing tmux session.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
True if successfully connected, False if session doesn't exist
|
|
191
|
+
"""
|
|
192
|
+
if self.socket_name:
|
|
193
|
+
self.server = libtmux.Server(socket_name=self.socket_name)
|
|
194
|
+
else:
|
|
195
|
+
self.server = libtmux.Server()
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
for session in self.server.sessions:
|
|
199
|
+
if session.name == self.session_name:
|
|
200
|
+
self.session = session
|
|
201
|
+
return True
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
@property
|
|
208
|
+
def is_running(self) -> bool:
|
|
209
|
+
"""Check if the session is still running."""
|
|
210
|
+
# First try to connect if we don't have a session reference
|
|
211
|
+
if not self.session:
|
|
212
|
+
self.connect()
|
|
213
|
+
|
|
214
|
+
if not self.session:
|
|
215
|
+
return False
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
# Try to access the session to verify it exists
|
|
219
|
+
_ = self.session.name
|
|
220
|
+
return True
|
|
221
|
+
except Exception:
|
|
222
|
+
self.session = None
|
|
223
|
+
return False
|