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.
Files changed (50) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/cli.py +154 -51
  3. overcode/config.py +66 -0
  4. overcode/daemon_claude_skill.md +36 -33
  5. overcode/history_reader.py +69 -8
  6. overcode/implementations.py +178 -87
  7. overcode/monitor_daemon.py +87 -97
  8. overcode/monitor_daemon_core.py +261 -0
  9. overcode/monitor_daemon_state.py +24 -15
  10. overcode/pid_utils.py +17 -3
  11. overcode/session_manager.py +54 -0
  12. overcode/settings.py +34 -0
  13. overcode/status_constants.py +1 -1
  14. overcode/status_detector.py +8 -2
  15. overcode/status_patterns.py +19 -0
  16. overcode/summarizer_client.py +72 -27
  17. overcode/summarizer_component.py +87 -107
  18. overcode/supervisor_daemon.py +55 -38
  19. overcode/supervisor_daemon_core.py +210 -0
  20. overcode/testing/__init__.py +6 -0
  21. overcode/testing/renderer.py +268 -0
  22. overcode/testing/tmux_driver.py +223 -0
  23. overcode/testing/tui_eye.py +185 -0
  24. overcode/testing/tui_eye_skill.md +187 -0
  25. overcode/tmux_manager.py +117 -93
  26. overcode/tui.py +399 -1969
  27. overcode/tui_actions/__init__.py +20 -0
  28. overcode/tui_actions/daemon.py +201 -0
  29. overcode/tui_actions/input.py +128 -0
  30. overcode/tui_actions/navigation.py +117 -0
  31. overcode/tui_actions/session.py +428 -0
  32. overcode/tui_actions/view.py +357 -0
  33. overcode/tui_helpers.py +42 -9
  34. overcode/tui_logic.py +347 -0
  35. overcode/tui_render.py +414 -0
  36. overcode/tui_widgets/__init__.py +24 -0
  37. overcode/tui_widgets/command_bar.py +399 -0
  38. overcode/tui_widgets/daemon_panel.py +153 -0
  39. overcode/tui_widgets/daemon_status_bar.py +245 -0
  40. overcode/tui_widgets/help_overlay.py +71 -0
  41. overcode/tui_widgets/preview_pane.py +69 -0
  42. overcode/tui_widgets/session_summary.py +514 -0
  43. overcode/tui_widgets/status_timeline.py +253 -0
  44. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/METADATA +4 -1
  45. overcode-0.1.4.dist-info/RECORD +68 -0
  46. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/WHEEL +1 -1
  47. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
  48. overcode-0.1.2.dist-info/RECORD +0 -45
  49. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
  50. {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,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
@@ -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