overcode 0.1.3__py3-none-any.whl → 0.1.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/cli.py +7 -2
  3. overcode/implementations.py +74 -8
  4. overcode/monitor_daemon.py +60 -65
  5. overcode/monitor_daemon_core.py +261 -0
  6. overcode/monitor_daemon_state.py +7 -0
  7. overcode/session_manager.py +1 -0
  8. overcode/settings.py +22 -0
  9. overcode/supervisor_daemon.py +48 -47
  10. overcode/supervisor_daemon_core.py +210 -0
  11. overcode/testing/__init__.py +6 -0
  12. overcode/testing/renderer.py +268 -0
  13. overcode/testing/tmux_driver.py +223 -0
  14. overcode/testing/tui_eye.py +185 -0
  15. overcode/testing/tui_eye_skill.md +187 -0
  16. overcode/tmux_manager.py +17 -3
  17. overcode/tui.py +196 -2462
  18. overcode/tui_actions/__init__.py +20 -0
  19. overcode/tui_actions/daemon.py +201 -0
  20. overcode/tui_actions/input.py +128 -0
  21. overcode/tui_actions/navigation.py +117 -0
  22. overcode/tui_actions/session.py +428 -0
  23. overcode/tui_actions/view.py +357 -0
  24. overcode/tui_helpers.py +41 -9
  25. overcode/tui_logic.py +347 -0
  26. overcode/tui_render.py +414 -0
  27. overcode/tui_widgets/__init__.py +24 -0
  28. overcode/tui_widgets/command_bar.py +399 -0
  29. overcode/tui_widgets/daemon_panel.py +153 -0
  30. overcode/tui_widgets/daemon_status_bar.py +245 -0
  31. overcode/tui_widgets/help_overlay.py +71 -0
  32. overcode/tui_widgets/preview_pane.py +69 -0
  33. overcode/tui_widgets/session_summary.py +514 -0
  34. overcode/tui_widgets/status_timeline.py +253 -0
  35. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/METADATA +3 -1
  36. overcode-0.1.4.dist-info/RECORD +68 -0
  37. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
  38. overcode-0.1.3.dist-info/RECORD +0 -45
  39. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/WHEEL +0 -0
  40. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
  41. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,20 @@
1
+ """
2
+ TUI Action Mixins for Overcode.
3
+
4
+ This package contains action method mixins organized by domain.
5
+ These are mixed into SupervisorTUI via multiple inheritance.
6
+ """
7
+
8
+ from .navigation import NavigationActionsMixin
9
+ from .view import ViewActionsMixin
10
+ from .daemon import DaemonActionsMixin
11
+ from .session import SessionActionsMixin
12
+ from .input import InputActionsMixin
13
+
14
+ __all__ = [
15
+ "NavigationActionsMixin",
16
+ "ViewActionsMixin",
17
+ "DaemonActionsMixin",
18
+ "SessionActionsMixin",
19
+ "InputActionsMixin",
20
+ ]
@@ -0,0 +1,201 @@
1
+ """
2
+ Daemon action methods for TUI.
3
+
4
+ Handles Monitor Daemon, Supervisor Daemon, and Web Server controls.
5
+ """
6
+
7
+ import subprocess
8
+ import sys
9
+ from typing import TYPE_CHECKING
10
+
11
+ from textual.css.query import NoMatches
12
+
13
+ if TYPE_CHECKING:
14
+ from ..tui_widgets import DaemonPanel
15
+
16
+
17
+ class DaemonActionsMixin:
18
+ """Mixin providing daemon control actions for SupervisorTUI."""
19
+
20
+ def action_toggle_daemon(self) -> None:
21
+ """Toggle daemon panel visibility (like timeline)."""
22
+ from ..tui_widgets import DaemonPanel
23
+ try:
24
+ daemon_panel = self.query_one("#daemon-panel", DaemonPanel)
25
+ daemon_panel.display = not daemon_panel.display
26
+ if daemon_panel.display:
27
+ # Force immediate refresh when becoming visible
28
+ daemon_panel._refresh_logs()
29
+ # Save preference
30
+ self._prefs.daemon_panel_visible = daemon_panel.display
31
+ self._save_prefs()
32
+ state = "shown" if daemon_panel.display else "hidden"
33
+ self.notify(f"Daemon panel {state}", severity="information")
34
+ except NoMatches:
35
+ pass
36
+
37
+ def action_supervisor_start(self) -> None:
38
+ """Start the Supervisor Daemon (handles Claude orchestration)."""
39
+ from ..monitor_daemon import is_monitor_daemon_running
40
+ from ..supervisor_daemon import is_supervisor_daemon_running
41
+ from ..tui_widgets import DaemonPanel
42
+ import time
43
+
44
+ # Ensure Monitor Daemon is running first (Supervisor depends on it)
45
+ if not is_monitor_daemon_running(self.tmux_session):
46
+ self._ensure_monitor_daemon()
47
+ time.sleep(1.0)
48
+
49
+ if is_supervisor_daemon_running(self.tmux_session):
50
+ self.notify("Supervisor Daemon already running", severity="warning")
51
+ return
52
+
53
+ try:
54
+ panel = self.query_one("#daemon-panel", DaemonPanel)
55
+ panel.log_lines.append(">>> Starting Supervisor Daemon...")
56
+ except NoMatches:
57
+ pass
58
+
59
+ try:
60
+ subprocess.Popen(
61
+ [sys.executable, "-m", "overcode.supervisor_daemon",
62
+ "--session", self.tmux_session],
63
+ stdout=subprocess.DEVNULL,
64
+ stderr=subprocess.DEVNULL,
65
+ start_new_session=True,
66
+ )
67
+ self.notify("Started Supervisor Daemon", severity="information")
68
+ self.set_timer(1.0, self.update_daemon_status)
69
+ except (OSError, subprocess.SubprocessError) as e:
70
+ self.notify(f"Failed to start Supervisor Daemon: {e}", severity="error")
71
+
72
+ def action_supervisor_stop(self) -> None:
73
+ """Stop the Supervisor Daemon."""
74
+ from ..supervisor_daemon import is_supervisor_daemon_running, stop_supervisor_daemon
75
+ from ..tui_widgets import DaemonPanel
76
+
77
+ if not is_supervisor_daemon_running(self.tmux_session):
78
+ self.notify("Supervisor Daemon not running", severity="warning")
79
+ return
80
+
81
+ if stop_supervisor_daemon(self.tmux_session):
82
+ self.notify("Stopped Supervisor Daemon", severity="information")
83
+ try:
84
+ panel = self.query_one("#daemon-panel", DaemonPanel)
85
+ panel.log_lines.append(">>> Supervisor Daemon stopped")
86
+ except NoMatches:
87
+ pass
88
+ else:
89
+ self.notify("Failed to stop Supervisor Daemon", severity="error")
90
+
91
+ self.update_daemon_status()
92
+
93
+ def action_toggle_summarizer(self) -> None:
94
+ """Toggle the AI Summarizer on/off."""
95
+ from ..summarizer_client import SummarizerClient
96
+ from ..tui_widgets import SessionSummary
97
+
98
+ # Check if summarizer is available (OPENAI_API_KEY set)
99
+ if not SummarizerClient.is_available():
100
+ self.notify("AI Summarizer unavailable - set OPENAI_API_KEY", severity="warning")
101
+ return
102
+
103
+ # Toggle the state
104
+ self._summarizer.config.enabled = not self._summarizer.config.enabled
105
+
106
+ if self._summarizer.config.enabled:
107
+ # Enable: create client if needed
108
+ if not self._summarizer._client:
109
+ self._summarizer._client = SummarizerClient()
110
+ self.notify("AI Summarizer enabled", severity="information")
111
+ # Update all widgets to show summarizer is enabled
112
+ for widget in self.query(SessionSummary):
113
+ widget.summarizer_enabled = True
114
+ # Trigger an immediate update
115
+ self._update_summaries_async()
116
+ else:
117
+ # Disable: close client to release resources
118
+ if self._summarizer._client:
119
+ self._summarizer._client.close()
120
+ self._summarizer._client = None
121
+ # Clear cached summaries
122
+ self._summaries = {}
123
+ # Update all widgets to clear summaries and show disabled state
124
+ for widget in self.query(SessionSummary):
125
+ widget.ai_summary_short = ""
126
+ widget.ai_summary_context = ""
127
+ widget.summarizer_enabled = False
128
+ widget.refresh()
129
+ self.notify("AI Summarizer disabled", severity="information")
130
+
131
+ # Refresh status bar
132
+ self.update_daemon_status()
133
+
134
+ def action_monitor_restart(self) -> None:
135
+ """Restart the Monitor Daemon (handles metrics/state tracking)."""
136
+ from ..monitor_daemon import is_monitor_daemon_running, stop_monitor_daemon
137
+ from ..tui_widgets import DaemonPanel
138
+
139
+ try:
140
+ panel = self.query_one("#daemon-panel", DaemonPanel)
141
+ panel.log_lines.append(">>> Restarting Monitor Daemon...")
142
+ except NoMatches:
143
+ pass
144
+
145
+ # Stop if running
146
+ if is_monitor_daemon_running(self.tmux_session):
147
+ stop_monitor_daemon(self.tmux_session)
148
+ # Use non-blocking timer to wait before starting
149
+ # (avoids blocking the event loop which caused double-press issue)
150
+ self.set_timer(0.5, self._start_monitor_daemon)
151
+ else:
152
+ # Not running, start immediately
153
+ self._start_monitor_daemon()
154
+
155
+ def _start_monitor_daemon(self) -> None:
156
+ """Start the monitor daemon (called by action_monitor_restart)."""
157
+ from ..tui_widgets import DaemonPanel
158
+
159
+ try:
160
+ subprocess.Popen(
161
+ [sys.executable, "-m", "overcode.monitor_daemon",
162
+ "--session", self.tmux_session],
163
+ stdout=subprocess.DEVNULL,
164
+ stderr=subprocess.DEVNULL,
165
+ start_new_session=True,
166
+ )
167
+
168
+ self.notify("Monitor Daemon restarted", severity="information")
169
+ try:
170
+ panel = self.query_one("#daemon-panel", DaemonPanel)
171
+ panel.log_lines.append(">>> Monitor Daemon restarted")
172
+ except NoMatches:
173
+ pass
174
+ self.set_timer(1.0, self.update_daemon_status)
175
+ except (OSError, subprocess.SubprocessError) as e:
176
+ self.notify(f"Failed to restart Monitor Daemon: {e}", severity="error")
177
+
178
+ def action_toggle_web_server(self) -> None:
179
+ """Toggle the web analytics dashboard server on/off."""
180
+ from ..web_server import toggle_web_server, get_web_server_url
181
+ from ..tui_widgets import DaemonPanel
182
+
183
+ is_running, msg = toggle_web_server(self.tmux_session)
184
+
185
+ if is_running:
186
+ url = get_web_server_url(self.tmux_session)
187
+ self.notify(f"Web server: {url}", severity="information")
188
+ try:
189
+ panel = self.query_one("#daemon-panel", DaemonPanel)
190
+ panel.log_lines.append(f">>> Web server started: {url}")
191
+ except NoMatches:
192
+ pass
193
+ else:
194
+ self.notify(f"Web server: {msg}", severity="information")
195
+ try:
196
+ panel = self.query_one("#daemon-panel", DaemonPanel)
197
+ panel.log_lines.append(f">>> Web server: {msg}")
198
+ except NoMatches:
199
+ pass
200
+
201
+ self.update_daemon_status()
@@ -0,0 +1,128 @@
1
+ """
2
+ Input action methods for TUI.
3
+
4
+ Handles sending keys and commands to agents.
5
+ """
6
+
7
+ import re
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ from ..tui_widgets import SessionSummary
12
+
13
+
14
+ class InputActionsMixin:
15
+ """Mixin providing input/send actions for SupervisorTUI."""
16
+
17
+ def action_send_enter_to_focused(self) -> None:
18
+ """Send Enter keypress to the focused agent (for approvals)."""
19
+ from ..tui_widgets import SessionSummary
20
+ from ..launcher import ClaudeLauncher
21
+
22
+ focused = self.focused
23
+ if not isinstance(focused, SessionSummary):
24
+ self.notify("No agent focused", severity="warning")
25
+ return
26
+
27
+ session_name = focused.session.name
28
+ launcher = ClaudeLauncher(
29
+ tmux_session=self.tmux_session,
30
+ session_manager=self.session_manager
31
+ )
32
+
33
+ # Send "enter" which the launcher handles as just pressing Enter
34
+ if launcher.send_to_session(session_name, "enter"):
35
+ self.notify(f"Sent Enter to {session_name}", severity="information")
36
+ else:
37
+ self.notify(f"Failed to send Enter to {session_name}", severity="error")
38
+
39
+ def _is_freetext_option(self, pane_content: str, key: str) -> bool:
40
+ """Check if a numbered menu option is a free-text instruction option.
41
+
42
+ Scans the pane content for patterns like "5. Tell Claude what to do"
43
+ or "3) Give custom instructions" to determine if selecting this option
44
+ should open the command bar for user input.
45
+
46
+ Args:
47
+ pane_content: The tmux pane content to scan
48
+ key: The number key being pressed (e.g., "5")
49
+
50
+ Returns:
51
+ True if this option expects free-text input
52
+ """
53
+ # Claude Code v2.x only has one freetext option format:
54
+ # "3. No, and tell Claude what to do differently (esc)"
55
+ # This appears on all permission prompts (Bash, Read, Write, etc.)
56
+ freetext_patterns = [
57
+ r"tell\s+claude\s+what\s+to\s+do",
58
+ ]
59
+
60
+ # Look for the numbered option in the content
61
+ # Match patterns like "5. text", "5) text", "5: text"
62
+ option_pattern = rf"^\s*{key}[\.\)\:]\s*(.+)$"
63
+
64
+ for line in pane_content.split('\n'):
65
+ match = re.match(option_pattern, line.strip(), re.IGNORECASE)
66
+ if match:
67
+ option_text = match.group(1).lower()
68
+ # Check if this option matches any freetext pattern
69
+ for pattern in freetext_patterns:
70
+ if re.search(pattern, option_text):
71
+ return True
72
+ return False
73
+
74
+ def _send_key_to_focused(self, key: str) -> None:
75
+ """Send a key to the focused agent.
76
+
77
+ If the key selects a "free text instruction" menu option (detected by
78
+ scanning the pane content), automatically opens the command bar (#72).
79
+
80
+ Args:
81
+ key: The key to send
82
+ """
83
+ from ..tui_widgets import SessionSummary
84
+ from ..launcher import ClaudeLauncher
85
+
86
+ focused = self.focused
87
+ if not isinstance(focused, SessionSummary):
88
+ self.notify("No agent focused", severity="warning")
89
+ return
90
+
91
+ session_name = focused.session.name
92
+ launcher = ClaudeLauncher(
93
+ tmux_session=self.tmux_session,
94
+ session_manager=self.session_manager
95
+ )
96
+
97
+ # Check if this option is a free-text instruction option before sending
98
+ pane_content = self.status_detector.get_pane_content(focused.session.tmux_window) or ""
99
+ is_freetext = self._is_freetext_option(pane_content, key)
100
+
101
+ # Send the key followed by Enter (to select the numbered option)
102
+ if launcher.send_to_session(session_name, key, enter=True):
103
+ self.notify(f"Sent '{key}' to {session_name}", severity="information")
104
+ # Open command bar if this was a free-text instruction option (#72)
105
+ if is_freetext:
106
+ self.action_focus_command_bar()
107
+ else:
108
+ self.notify(f"Failed to send '{key}' to {session_name}", severity="error")
109
+
110
+ def action_send_1_to_focused(self) -> None:
111
+ """Send '1' to focused agent."""
112
+ self._send_key_to_focused("1")
113
+
114
+ def action_send_2_to_focused(self) -> None:
115
+ """Send '2' to focused agent."""
116
+ self._send_key_to_focused("2")
117
+
118
+ def action_send_3_to_focused(self) -> None:
119
+ """Send '3' to focused agent."""
120
+ self._send_key_to_focused("3")
121
+
122
+ def action_send_4_to_focused(self) -> None:
123
+ """Send '4' to focused agent."""
124
+ self._send_key_to_focused("4")
125
+
126
+ def action_send_5_to_focused(self) -> None:
127
+ """Send '5' to focused agent."""
128
+ self._send_key_to_focused("5")
@@ -0,0 +1,117 @@
1
+ """
2
+ Navigation action methods for TUI.
3
+
4
+ Handles moving between sessions in the list.
5
+ """
6
+
7
+ from typing import TYPE_CHECKING, List
8
+
9
+ if TYPE_CHECKING:
10
+ from ..tui_widgets import SessionSummary
11
+
12
+
13
+ class NavigationActionsMixin:
14
+ """Mixin providing navigation actions for SupervisorTUI."""
15
+
16
+ def action_focus_next_session(self) -> None:
17
+ """Focus the next session in the list."""
18
+ widgets = self._get_widgets_in_session_order()
19
+ if not widgets:
20
+ return
21
+ self.focused_session_index = (self.focused_session_index + 1) % len(widgets)
22
+ target_widget = widgets[self.focused_session_index]
23
+ target_widget.focus()
24
+ if self.view_mode == "list_preview":
25
+ self._update_preview()
26
+ self._sync_tmux_window(target_widget)
27
+
28
+ def action_focus_previous_session(self) -> None:
29
+ """Focus the previous session in the list."""
30
+ widgets = self._get_widgets_in_session_order()
31
+ if not widgets:
32
+ return
33
+ self.focused_session_index = (self.focused_session_index - 1) % len(widgets)
34
+ target_widget = widgets[self.focused_session_index]
35
+ target_widget.focus()
36
+ if self.view_mode == "list_preview":
37
+ self._update_preview()
38
+ self._sync_tmux_window(target_widget)
39
+
40
+ def action_jump_to_attention(self) -> None:
41
+ """Jump to next session needing attention.
42
+
43
+ Cycles through sessions prioritized by:
44
+ 1. Bell indicator (is_unvisited_stalled=True) - highest priority
45
+ 2. waiting_user status (red, no bell)
46
+ 3. no_instructions status (yellow)
47
+ 4. waiting_supervisor status (orange)
48
+ """
49
+ from ..status_constants import (
50
+ STATUS_WAITING_USER,
51
+ STATUS_NO_INSTRUCTIONS,
52
+ STATUS_WAITING_SUPERVISOR,
53
+ STATUS_RUNNING,
54
+ )
55
+
56
+ widgets = self._get_widgets_in_session_order()
57
+ if not widgets:
58
+ return
59
+
60
+ # Build prioritized list of sessions needing attention
61
+ # Priority: bell > waiting_user > no_instructions > waiting_supervisor
62
+ attention_sessions = []
63
+ for i, widget in enumerate(widgets):
64
+ status = getattr(widget, 'detected_status', STATUS_RUNNING)
65
+ is_bell = getattr(widget, 'is_unvisited_stalled', False)
66
+
67
+ # Bell indicator takes highest priority - these are the sessions
68
+ # that truly need attention (user hasn't seen them yet)
69
+ if is_bell:
70
+ attention_sessions.append((0, i, widget)) # Bell = highest priority
71
+ elif status == STATUS_WAITING_USER:
72
+ attention_sessions.append((1, i, widget)) # Red but no bell (already visited)
73
+ elif status == STATUS_NO_INSTRUCTIONS:
74
+ attention_sessions.append((2, i, widget))
75
+ elif status == STATUS_WAITING_SUPERVISOR:
76
+ attention_sessions.append((3, i, widget))
77
+ # Skip running, terminated, asleep
78
+
79
+ if not attention_sessions:
80
+ self.notify("No sessions need attention", severity="information")
81
+ return
82
+
83
+ # Sort by priority, then by original index
84
+ attention_sessions.sort(key=lambda x: (x[0], x[1]))
85
+
86
+ # Check if our cached list changed (sessions may have changed state)
87
+ current_widget_ids = [id(w) for _, _, w in attention_sessions]
88
+ cached_widget_ids = [id(w) for w in self._attention_jump_list]
89
+
90
+ if current_widget_ids != cached_widget_ids:
91
+ # List changed, reset index
92
+ self._attention_jump_list = [w for _, _, w in attention_sessions]
93
+ self._attention_jump_index = 0
94
+ else:
95
+ # Cycle to next
96
+ self._attention_jump_index = (self._attention_jump_index + 1) % len(self._attention_jump_list)
97
+
98
+ # Focus the target widget
99
+ target_widget = self._attention_jump_list[self._attention_jump_index]
100
+ # Find its index in the full widget list
101
+ for i, w in enumerate(widgets):
102
+ if w is target_widget:
103
+ self.focused_session_index = i
104
+ break
105
+
106
+ target_widget.focus()
107
+ if self.view_mode == "list_preview":
108
+ self._update_preview()
109
+ self._sync_tmux_window(target_widget)
110
+
111
+ # Show position indicator
112
+ pos = self._attention_jump_index + 1
113
+ total = len(self._attention_jump_list)
114
+ status = getattr(target_widget, 'detected_status', 'unknown')
115
+ is_bell = getattr(target_widget, 'is_unvisited_stalled', False)
116
+ bell_indicator = "🔔 " if is_bell else ""
117
+ self.notify(f"Attention {pos}/{total}: {bell_indicator}{target_widget.session.name} ({status})", severity="information")