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,185 @@
|
|
|
1
|
+
"""tui-eye: Visual TUI testing tool for Claude Code.
|
|
2
|
+
|
|
3
|
+
This CLI tool allows Claude Code to "see" TUI applications by:
|
|
4
|
+
1. Running them in a controlled tmux session
|
|
5
|
+
2. Capturing screenshots as PNG images
|
|
6
|
+
3. Sending keystrokes for interaction
|
|
7
|
+
|
|
8
|
+
Example usage:
|
|
9
|
+
tui-eye start "overcode supervisor" --size 120x40
|
|
10
|
+
tui-eye screenshot /tmp/tui.png
|
|
11
|
+
tui-eye send j j enter
|
|
12
|
+
tui-eye wait-for "Session:"
|
|
13
|
+
tui-eye stop
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Annotated, Optional
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from .tmux_driver import TUIDriver
|
|
22
|
+
from .renderer import render_terminal_to_png
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="tui-eye",
|
|
26
|
+
help="Visual TUI testing tool - gives Claude Code 'eyes' into TUI apps",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Global driver instance (persists between commands via state file)
|
|
30
|
+
STATE_FILE = Path("/tmp/tui-eye-state")
|
|
31
|
+
DEFAULT_SESSION = "tui-eye"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_driver() -> TUIDriver:
|
|
35
|
+
"""Get or create a TUI driver instance."""
|
|
36
|
+
session_name = DEFAULT_SESSION
|
|
37
|
+
if STATE_FILE.exists():
|
|
38
|
+
session_name = STATE_FILE.read_text().strip() or DEFAULT_SESSION
|
|
39
|
+
return TUIDriver(session_name=session_name)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _save_state(session_name: str) -> None:
|
|
43
|
+
"""Save the current session name to state file."""
|
|
44
|
+
STATE_FILE.write_text(session_name)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _clear_state() -> None:
|
|
48
|
+
"""Clear the state file."""
|
|
49
|
+
if STATE_FILE.exists():
|
|
50
|
+
STATE_FILE.unlink()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def start(
|
|
55
|
+
command: Annotated[str, typer.Argument(help="Command to run in the TUI")],
|
|
56
|
+
size: Annotated[str, typer.Option(help="Terminal size as WIDTHxHEIGHT")] = "220x40",
|
|
57
|
+
session: Annotated[str, typer.Option(help="tmux session name")] = DEFAULT_SESSION,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Start a TUI application in a tmux session."""
|
|
60
|
+
# Parse size
|
|
61
|
+
try:
|
|
62
|
+
width, height = map(int, size.lower().split("x"))
|
|
63
|
+
except ValueError:
|
|
64
|
+
typer.echo(f"Error: Invalid size format '{size}'. Use WIDTHxHEIGHT (e.g., 120x40)")
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
|
|
67
|
+
driver = TUIDriver(session_name=session)
|
|
68
|
+
|
|
69
|
+
typer.echo(f"Starting TUI: {command}")
|
|
70
|
+
typer.echo(f"Size: {width}x{height}")
|
|
71
|
+
typer.echo(f"Session: {session}")
|
|
72
|
+
|
|
73
|
+
driver.start(command, width=width, height=height)
|
|
74
|
+
_save_state(session)
|
|
75
|
+
|
|
76
|
+
typer.echo("TUI started. Use 'tui-eye screenshot' to capture.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def stop() -> None:
|
|
81
|
+
"""Stop the TUI session and clean up."""
|
|
82
|
+
driver = _get_driver()
|
|
83
|
+
driver.stop()
|
|
84
|
+
_clear_state()
|
|
85
|
+
typer.echo("TUI session stopped.")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command()
|
|
89
|
+
def screenshot(
|
|
90
|
+
output: Annotated[
|
|
91
|
+
str, typer.Argument(help="Output PNG file path")
|
|
92
|
+
] = "/tmp/tui-screenshot.png",
|
|
93
|
+
width: Annotated[int, typer.Option(help="Terminal width for rendering")] = 220,
|
|
94
|
+
height: Annotated[int, typer.Option(help="Terminal height for rendering")] = 45,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Capture a screenshot of the TUI as a PNG image."""
|
|
97
|
+
driver = _get_driver()
|
|
98
|
+
|
|
99
|
+
if not driver.is_running:
|
|
100
|
+
typer.echo("Error: No TUI session running. Use 'tui-eye start' first.")
|
|
101
|
+
raise typer.Exit(1)
|
|
102
|
+
|
|
103
|
+
# Capture with ANSI codes
|
|
104
|
+
content = driver.capture(with_ansi=True)
|
|
105
|
+
|
|
106
|
+
# Render to PNG
|
|
107
|
+
output_path = render_terminal_to_png(
|
|
108
|
+
content,
|
|
109
|
+
output,
|
|
110
|
+
width=width,
|
|
111
|
+
height=height,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
typer.echo(f"Screenshot saved: {output_path}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@app.command()
|
|
118
|
+
def capture(
|
|
119
|
+
text: Annotated[bool, typer.Option("--text", help="Output plain text (no ANSI)")] = False,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Capture and print the current screen content."""
|
|
122
|
+
driver = _get_driver()
|
|
123
|
+
|
|
124
|
+
if not driver.is_running:
|
|
125
|
+
typer.echo("Error: No TUI session running. Use 'tui-eye start' first.")
|
|
126
|
+
raise typer.Exit(1)
|
|
127
|
+
|
|
128
|
+
content = driver.capture(with_ansi=not text)
|
|
129
|
+
typer.echo(content)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command()
|
|
133
|
+
def send(
|
|
134
|
+
keys: Annotated[list[str], typer.Argument(help="Keys to send (e.g., j k enter)")],
|
|
135
|
+
) -> None:
|
|
136
|
+
"""Send keystrokes to the TUI."""
|
|
137
|
+
driver = _get_driver()
|
|
138
|
+
|
|
139
|
+
if not driver.is_running:
|
|
140
|
+
typer.echo("Error: No TUI session running. Use 'tui-eye start' first.")
|
|
141
|
+
raise typer.Exit(1)
|
|
142
|
+
|
|
143
|
+
driver.send_keys(*keys)
|
|
144
|
+
typer.echo(f"Sent keys: {' '.join(keys)}")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@app.command("wait-for")
|
|
148
|
+
def wait_for(
|
|
149
|
+
text: Annotated[str, typer.Argument(help="Text to wait for")],
|
|
150
|
+
timeout: Annotated[float, typer.Option(help="Timeout in seconds")] = 10.0,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Wait for specific text to appear on screen."""
|
|
153
|
+
driver = _get_driver()
|
|
154
|
+
|
|
155
|
+
if not driver.is_running:
|
|
156
|
+
typer.echo("Error: No TUI session running. Use 'tui-eye start' first.")
|
|
157
|
+
raise typer.Exit(1)
|
|
158
|
+
|
|
159
|
+
typer.echo(f"Waiting for: '{text}' (timeout: {timeout}s)")
|
|
160
|
+
|
|
161
|
+
if driver.wait_for(text, timeout=timeout):
|
|
162
|
+
typer.echo("Found!")
|
|
163
|
+
else:
|
|
164
|
+
typer.echo(f"Timeout: text '{text}' not found after {timeout}s")
|
|
165
|
+
raise typer.Exit(1)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@app.command()
|
|
169
|
+
def status() -> None:
|
|
170
|
+
"""Check the status of the TUI session."""
|
|
171
|
+
driver = _get_driver()
|
|
172
|
+
|
|
173
|
+
if driver.is_running:
|
|
174
|
+
typer.echo(f"Session '{driver.session_name}' is running.")
|
|
175
|
+
else:
|
|
176
|
+
typer.echo("No TUI session running.")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main() -> None:
|
|
180
|
+
"""Main entry point."""
|
|
181
|
+
app()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if __name__ == "__main__":
|
|
185
|
+
main()
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# TUI Eye - Visual TUI Testing Skill
|
|
2
|
+
|
|
3
|
+
```yaml
|
|
4
|
+
---
|
|
5
|
+
name: tui-eye
|
|
6
|
+
description: Interactive visual testing of TUI applications. Use when testing the overcode supervisor TUI, validating layouts, or running smoke tests.
|
|
7
|
+
disable-model-invocation: true
|
|
8
|
+
---
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
You are performing visual TUI testing using the `tui-eye` tool. This tool gives you "eyes" into TUI applications by capturing screenshots as PNG images that you can read and analyze.
|
|
12
|
+
|
|
13
|
+
## Core Commands
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Start a TUI in a controlled tmux session (220x40 default)
|
|
17
|
+
tui-eye start "overcode monitor" --size 220x40
|
|
18
|
+
|
|
19
|
+
# Capture screenshot for visual inspection
|
|
20
|
+
tui-eye screenshot /tmp/tui.png
|
|
21
|
+
|
|
22
|
+
# Read the screenshot (use Claude Code's Read tool)
|
|
23
|
+
# Then analyze: layout, alignment, colors, text overflow, etc.
|
|
24
|
+
|
|
25
|
+
# Send keystrokes
|
|
26
|
+
tui-eye send j j enter # Navigate down twice, press enter
|
|
27
|
+
tui-eye send h # Toggle help overlay
|
|
28
|
+
tui-eye send escape # Close dialogs/cancel
|
|
29
|
+
|
|
30
|
+
# Wait for content to appear
|
|
31
|
+
tui-eye wait-for "Session:" --timeout 10
|
|
32
|
+
|
|
33
|
+
# Get text-only capture (for searching/assertions)
|
|
34
|
+
tui-eye capture --text
|
|
35
|
+
|
|
36
|
+
# Check session status
|
|
37
|
+
tui-eye status
|
|
38
|
+
|
|
39
|
+
# Clean up when done
|
|
40
|
+
tui-eye stop
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Workflow: Visual Testing
|
|
44
|
+
|
|
45
|
+
1. **Start the TUI**
|
|
46
|
+
```bash
|
|
47
|
+
tui-eye start "overcode monitor" --size 220x40
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
2. **Capture & Analyze**
|
|
51
|
+
```bash
|
|
52
|
+
tui-eye screenshot /tmp/check.png
|
|
53
|
+
```
|
|
54
|
+
Then read `/tmp/check.png` and visually inspect:
|
|
55
|
+
- Is the layout correct?
|
|
56
|
+
- Are columns aligned?
|
|
57
|
+
- Is text truncated or wrapped unexpectedly?
|
|
58
|
+
- Are colors/status indicators showing correctly?
|
|
59
|
+
|
|
60
|
+
3. **Interact**
|
|
61
|
+
```bash
|
|
62
|
+
tui-eye send j # Navigate
|
|
63
|
+
tui-eye send enter # Select/confirm
|
|
64
|
+
tui-eye send h # Toggle help
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
4. **Verify Changes**
|
|
68
|
+
```bash
|
|
69
|
+
tui-eye screenshot /tmp/after.png
|
|
70
|
+
```
|
|
71
|
+
Compare to expected state.
|
|
72
|
+
|
|
73
|
+
5. **Clean Up**
|
|
74
|
+
```bash
|
|
75
|
+
tui-eye stop
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Key Mappings
|
|
79
|
+
|
|
80
|
+
| Key | tmux Name | Description |
|
|
81
|
+
|-----|-----------|-------------|
|
|
82
|
+
| `j` | j | Navigate down |
|
|
83
|
+
| `k` | k | Navigate up |
|
|
84
|
+
| `enter` | Enter | Confirm/select |
|
|
85
|
+
| `escape` | Escape | Cancel/close |
|
|
86
|
+
| `h` | h | Toggle help |
|
|
87
|
+
| `q` | q | Quit (some TUIs) |
|
|
88
|
+
| `tab` | Tab | Next field |
|
|
89
|
+
| `space` | Space | Toggle/expand |
|
|
90
|
+
|
|
91
|
+
## Example: Smoke Test
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
# Start supervisor TUI
|
|
95
|
+
tui-eye start "overcode monitor" --size 220x45
|
|
96
|
+
|
|
97
|
+
# Wait for initial render
|
|
98
|
+
tui-eye wait-for "Timeline:" --timeout 10
|
|
99
|
+
|
|
100
|
+
# Capture initial state
|
|
101
|
+
tui-eye screenshot /tmp/smoke-1.png
|
|
102
|
+
# [Read /tmp/smoke-1.png - verify layout looks correct]
|
|
103
|
+
|
|
104
|
+
# Test help overlay
|
|
105
|
+
tui-eye send h
|
|
106
|
+
tui-eye screenshot /tmp/smoke-help.png
|
|
107
|
+
# [Read - verify help is displayed]
|
|
108
|
+
|
|
109
|
+
tui-eye send h
|
|
110
|
+
tui-eye screenshot /tmp/smoke-help-closed.png
|
|
111
|
+
# [Read - verify help closed, main view restored]
|
|
112
|
+
|
|
113
|
+
# Navigate if there are sessions
|
|
114
|
+
tui-eye send j j
|
|
115
|
+
tui-eye screenshot /tmp/smoke-nav.png
|
|
116
|
+
# [Read - verify navigation worked]
|
|
117
|
+
|
|
118
|
+
# Done
|
|
119
|
+
tui-eye stop
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Example: Multi-Agent Monitoring
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# Launch some test agents first
|
|
126
|
+
overcode launch --name test-agent-1 --prompt "Write hello world"
|
|
127
|
+
overcode launch --name test-agent-2 --prompt "List files"
|
|
128
|
+
|
|
129
|
+
# Start monitor
|
|
130
|
+
tui-eye start "overcode monitor" --size 220x45
|
|
131
|
+
|
|
132
|
+
# Periodic monitoring loop
|
|
133
|
+
tui-eye wait-for "test-agent" --timeout 30
|
|
134
|
+
tui-eye screenshot /tmp/monitor-1.png
|
|
135
|
+
# [Read - check agent statuses, timelines]
|
|
136
|
+
|
|
137
|
+
# If an agent needs attention, navigate and interact
|
|
138
|
+
tui-eye send j enter # Select agent
|
|
139
|
+
tui-eye screenshot /tmp/agent-detail.png
|
|
140
|
+
# [Read - see agent output]
|
|
141
|
+
|
|
142
|
+
# Continue monitoring...
|
|
143
|
+
tui-eye stop
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Visual Checks to Perform
|
|
147
|
+
|
|
148
|
+
When reading screenshots, check for:
|
|
149
|
+
|
|
150
|
+
- **Layout**: Header, timeline, agent list all visible?
|
|
151
|
+
- **Alignment**: Columns aligned, percentages right-justified?
|
|
152
|
+
- **Colors**: Status indicators using correct colors (green=running, red=waiting)?
|
|
153
|
+
- **Text**: No unexpected wrapping or truncation?
|
|
154
|
+
- **Timeline**: Bars extending full width? Percentage shown?
|
|
155
|
+
- **Responsiveness**: After interactions, UI updated correctly?
|
|
156
|
+
|
|
157
|
+
## Troubleshooting
|
|
158
|
+
|
|
159
|
+
**Screenshot too narrow / lines wrapping:**
|
|
160
|
+
```bash
|
|
161
|
+
tui-eye screenshot /tmp/x.png --width 220 --height 45
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**Can't see full content:**
|
|
165
|
+
Increase height:
|
|
166
|
+
```bash
|
|
167
|
+
tui-eye start "overcode monitor" --size 220x60
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**Session already exists:**
|
|
171
|
+
```bash
|
|
172
|
+
tui-eye stop
|
|
173
|
+
tui-eye start "overcode monitor"
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Keys not working:**
|
|
177
|
+
Check session is running:
|
|
178
|
+
```bash
|
|
179
|
+
tui-eye status
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## Arguments
|
|
183
|
+
|
|
184
|
+
`$ARGUMENTS` - Optional test scenario to run. Examples:
|
|
185
|
+
- `help-toggle` - Test the help overlay toggle
|
|
186
|
+
- `navigation` - Test up/down navigation
|
|
187
|
+
- `full-smoke` - Run complete smoke test
|
overcode/tmux_manager.py
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Tmux session and window management for Overcode.
|
|
3
|
+
|
|
4
|
+
Uses libtmux for reliable tmux interaction.
|
|
3
5
|
"""
|
|
4
6
|
|
|
5
7
|
import os
|
|
6
|
-
import
|
|
8
|
+
import time
|
|
7
9
|
from typing import Optional, List, Dict, Any, TYPE_CHECKING
|
|
8
10
|
|
|
11
|
+
import libtmux
|
|
12
|
+
from libtmux.exc import LibTmuxException
|
|
13
|
+
from libtmux._internal.query_list import ObjectDoesNotExist
|
|
14
|
+
|
|
9
15
|
if TYPE_CHECKING:
|
|
10
16
|
from .interfaces import TmuxInterface
|
|
11
17
|
|
|
@@ -13,7 +19,7 @@ if TYPE_CHECKING:
|
|
|
13
19
|
class TmuxManager:
|
|
14
20
|
"""Manages tmux sessions and windows for Overcode.
|
|
15
21
|
|
|
16
|
-
This class can be used directly (uses
|
|
22
|
+
This class can be used directly (uses libtmux) or with an injected
|
|
17
23
|
TmuxInterface for testing.
|
|
18
24
|
"""
|
|
19
25
|
|
|
@@ -26,17 +32,44 @@ class TmuxManager:
|
|
|
26
32
|
socket: Optional tmux socket name (for testing isolation)
|
|
27
33
|
"""
|
|
28
34
|
self.session_name = session_name
|
|
29
|
-
self._tmux = tmux # If None, use
|
|
35
|
+
self._tmux = tmux # If None, use libtmux directly
|
|
30
36
|
# Support OVERCODE_TMUX_SOCKET env var for testing
|
|
31
37
|
self.socket = socket or os.environ.get("OVERCODE_TMUX_SOCKET")
|
|
38
|
+
self._server: Optional[libtmux.Server] = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def server(self) -> libtmux.Server:
|
|
42
|
+
"""Lazy-load the tmux server connection."""
|
|
43
|
+
if self._server is None:
|
|
44
|
+
if self.socket:
|
|
45
|
+
self._server = libtmux.Server(socket_name=self.socket)
|
|
46
|
+
else:
|
|
47
|
+
self._server = libtmux.Server()
|
|
48
|
+
return self._server
|
|
49
|
+
|
|
50
|
+
def _get_session(self) -> Optional[libtmux.Session]:
|
|
51
|
+
"""Get the managed session, or None if it doesn't exist."""
|
|
52
|
+
try:
|
|
53
|
+
return self.server.sessions.get(session_name=self.session_name)
|
|
54
|
+
except (LibTmuxException, ObjectDoesNotExist):
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def _get_window(self, window_index: int) -> Optional[libtmux.Window]:
|
|
58
|
+
"""Get a window by index."""
|
|
59
|
+
sess = self._get_session()
|
|
60
|
+
if sess is None:
|
|
61
|
+
return None
|
|
62
|
+
try:
|
|
63
|
+
return sess.windows.get(window_index=str(window_index))
|
|
64
|
+
except (LibTmuxException, ObjectDoesNotExist):
|
|
65
|
+
return None
|
|
32
66
|
|
|
33
|
-
def
|
|
34
|
-
"""
|
|
35
|
-
|
|
36
|
-
if
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return cmd
|
|
67
|
+
def _get_pane(self, window_index: int) -> Optional[libtmux.Pane]:
|
|
68
|
+
"""Get the first pane of a window."""
|
|
69
|
+
win = self._get_window(window_index)
|
|
70
|
+
if win is None or not win.panes:
|
|
71
|
+
return None
|
|
72
|
+
return win.panes[0]
|
|
40
73
|
|
|
41
74
|
def ensure_session(self) -> bool:
|
|
42
75
|
"""Create tmux session if it doesn't exist"""
|
|
@@ -47,12 +80,9 @@ class TmuxManager:
|
|
|
47
80
|
return self._tmux.new_session(self.session_name)
|
|
48
81
|
|
|
49
82
|
try:
|
|
50
|
-
|
|
51
|
-
self._tmux_cmd("new-session", "-d", "-s", self.session_name),
|
|
52
|
-
check=True
|
|
53
|
-
)
|
|
83
|
+
self.server.new_session(session_name=self.session_name, attach=False)
|
|
54
84
|
return True
|
|
55
|
-
except
|
|
85
|
+
except LibTmuxException:
|
|
56
86
|
return False
|
|
57
87
|
|
|
58
88
|
def session_exists(self) -> bool:
|
|
@@ -60,11 +90,10 @@ class TmuxManager:
|
|
|
60
90
|
if self._tmux:
|
|
61
91
|
return self._tmux.has_session(self.session_name)
|
|
62
92
|
|
|
63
|
-
|
|
64
|
-
self.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return result.returncode == 0
|
|
93
|
+
try:
|
|
94
|
+
return self.server.has_session(self.session_name)
|
|
95
|
+
except LibTmuxException:
|
|
96
|
+
return False
|
|
68
97
|
|
|
69
98
|
def create_window(self, window_name: str, start_directory: Optional[str] = None) -> Optional[int]:
|
|
70
99
|
"""Create a new window in the tmux session"""
|
|
@@ -74,21 +103,18 @@ class TmuxManager:
|
|
|
74
103
|
if self._tmux:
|
|
75
104
|
return self._tmux.new_window(self.session_name, window_name, cwd=start_directory)
|
|
76
105
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
"-P", # print window info
|
|
82
|
-
"-F", "#{window_index}"
|
|
83
|
-
]
|
|
106
|
+
try:
|
|
107
|
+
sess = self._get_session()
|
|
108
|
+
if sess is None:
|
|
109
|
+
return None
|
|
84
110
|
|
|
85
|
-
|
|
86
|
-
|
|
111
|
+
kwargs: Dict[str, Any] = {'window_name': window_name, 'attach': False}
|
|
112
|
+
if start_directory:
|
|
113
|
+
kwargs['start_directory'] = start_directory
|
|
87
114
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
except (subprocess.CalledProcessError, ValueError):
|
|
115
|
+
window = sess.new_window(**kwargs)
|
|
116
|
+
return int(window.window_index)
|
|
117
|
+
except (LibTmuxException, ValueError):
|
|
92
118
|
return None
|
|
93
119
|
|
|
94
120
|
def send_keys(self, window_index: int, keys: str, enter: bool = True) -> bool:
|
|
@@ -97,31 +123,40 @@ class TmuxManager:
|
|
|
97
123
|
For Claude Code: text and Enter must be sent as SEPARATE commands
|
|
98
124
|
with a small delay, otherwise Claude Code doesn't process the Enter.
|
|
99
125
|
"""
|
|
100
|
-
import time
|
|
101
|
-
|
|
102
126
|
if self._tmux:
|
|
103
127
|
return self._tmux.send_keys(self.session_name, window_index, keys, enter)
|
|
104
128
|
|
|
105
|
-
target = f"{self.session_name}:{window_index}"
|
|
106
|
-
|
|
107
129
|
try:
|
|
130
|
+
pane = self._get_pane(window_index)
|
|
131
|
+
if pane is None:
|
|
132
|
+
return False
|
|
133
|
+
|
|
108
134
|
# Send text first (if any)
|
|
109
135
|
if keys:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
|
|
136
|
+
# Special handling for ! commands (#139)
|
|
137
|
+
# Claude Code requires ! to be sent separately to trigger mode switch
|
|
138
|
+
# to bash mode before receiving the rest of the command
|
|
139
|
+
if keys.startswith('!') and len(keys) > 1:
|
|
140
|
+
# Send ! first
|
|
141
|
+
pane.send_keys('!', enter=False)
|
|
142
|
+
# Wait for mode switch to process
|
|
143
|
+
time.sleep(0.15)
|
|
144
|
+
# Send the rest (without the !)
|
|
145
|
+
rest = keys[1:]
|
|
146
|
+
if rest:
|
|
147
|
+
pane.send_keys(rest, enter=False)
|
|
148
|
+
time.sleep(0.1)
|
|
149
|
+
else:
|
|
150
|
+
pane.send_keys(keys, enter=False)
|
|
151
|
+
# Small delay for Claude Code to process text
|
|
152
|
+
time.sleep(0.1)
|
|
116
153
|
|
|
117
154
|
# Send Enter separately
|
|
118
155
|
if enter:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
check=True
|
|
122
|
-
)
|
|
156
|
+
pane.send_keys('', enter=True)
|
|
157
|
+
|
|
123
158
|
return True
|
|
124
|
-
except
|
|
159
|
+
except LibTmuxException:
|
|
125
160
|
return False
|
|
126
161
|
|
|
127
162
|
def attach_session(self):
|
|
@@ -129,7 +164,7 @@ class TmuxManager:
|
|
|
129
164
|
if self._tmux:
|
|
130
165
|
self._tmux.attach(self.session_name)
|
|
131
166
|
return
|
|
132
|
-
|
|
167
|
+
os.execlp("tmux", "tmux", "attach-session", "-t", self.session_name)
|
|
133
168
|
|
|
134
169
|
def list_windows(self) -> List[Dict[str, Any]]:
|
|
135
170
|
"""List all windows in the session.
|
|
@@ -148,31 +183,23 @@ class TmuxManager:
|
|
|
148
183
|
]
|
|
149
184
|
|
|
150
185
|
try:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
"-t", self.session_name,
|
|
155
|
-
"-F", "#{window_index}|#{window_name}|#{pane_current_command}"
|
|
156
|
-
),
|
|
157
|
-
capture_output=True, text=True, check=True
|
|
158
|
-
)
|
|
186
|
+
sess = self._get_session()
|
|
187
|
+
if sess is None:
|
|
188
|
+
return []
|
|
159
189
|
|
|
160
190
|
windows = []
|
|
161
|
-
for
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"name": parts[1],
|
|
172
|
-
"command": parts[2]
|
|
173
|
-
})
|
|
191
|
+
for win in sess.windows:
|
|
192
|
+
# Get command from first pane
|
|
193
|
+
command = ""
|
|
194
|
+
if win.panes:
|
|
195
|
+
command = win.panes[0].pane_current_command or ""
|
|
196
|
+
windows.append({
|
|
197
|
+
"index": int(win.window_index),
|
|
198
|
+
"name": win.window_name,
|
|
199
|
+
"command": command
|
|
200
|
+
})
|
|
174
201
|
return windows
|
|
175
|
-
except
|
|
202
|
+
except LibTmuxException:
|
|
176
203
|
return []
|
|
177
204
|
|
|
178
205
|
def kill_window(self, window_index: int) -> bool:
|
|
@@ -181,12 +208,12 @@ class TmuxManager:
|
|
|
181
208
|
return self._tmux.kill_window(self.session_name, window_index)
|
|
182
209
|
|
|
183
210
|
try:
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
)
|
|
211
|
+
win = self._get_window(window_index)
|
|
212
|
+
if win is None:
|
|
213
|
+
return False
|
|
214
|
+
win.kill()
|
|
188
215
|
return True
|
|
189
|
-
except
|
|
216
|
+
except LibTmuxException:
|
|
190
217
|
return False
|
|
191
218
|
|
|
192
219
|
def kill_session(self) -> bool:
|
|
@@ -195,12 +222,12 @@ class TmuxManager:
|
|
|
195
222
|
return self._tmux.kill_session(self.session_name)
|
|
196
223
|
|
|
197
224
|
try:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
)
|
|
225
|
+
sess = self._get_session()
|
|
226
|
+
if sess is None:
|
|
227
|
+
return False
|
|
228
|
+
sess.kill()
|
|
202
229
|
return True
|
|
203
|
-
except
|
|
230
|
+
except LibTmuxException:
|
|
204
231
|
return False
|
|
205
232
|
|
|
206
233
|
def window_exists(self, window_index: int) -> bool:
|
|
@@ -213,16 +240,13 @@ class TmuxManager:
|
|
|
213
240
|
return any(w.get('index') == window_index for w in windows)
|
|
214
241
|
|
|
215
242
|
try:
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
window_indices = [int(idx.strip()) for idx in result.stdout.strip().split("\n") if idx.strip()]
|
|
226
|
-
return window_index in window_indices
|
|
227
|
-
except (subprocess.CalledProcessError, ValueError):
|
|
243
|
+
sess = self._get_session()
|
|
244
|
+
if sess is None:
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
for win in sess.windows:
|
|
248
|
+
if int(win.window_index) == window_index:
|
|
249
|
+
return True
|
|
250
|
+
return False
|
|
251
|
+
except LibTmuxException:
|
|
228
252
|
return False
|