overcode 0.1.3__py3-none-any.whl → 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- overcode/__init__.py +1 -1
- overcode/cli.py +7 -2
- overcode/implementations.py +74 -8
- overcode/monitor_daemon.py +60 -65
- overcode/monitor_daemon_core.py +261 -0
- overcode/monitor_daemon_state.py +7 -0
- overcode/session_manager.py +1 -0
- overcode/settings.py +22 -0
- overcode/supervisor_daemon.py +48 -47
- overcode/supervisor_daemon_core.py +210 -0
- overcode/testing/__init__.py +6 -0
- overcode/testing/renderer.py +268 -0
- overcode/testing/tmux_driver.py +223 -0
- overcode/testing/tui_eye.py +185 -0
- overcode/testing/tui_eye_skill.md +187 -0
- overcode/tmux_manager.py +17 -3
- overcode/tui.py +196 -2462
- overcode/tui_actions/__init__.py +20 -0
- overcode/tui_actions/daemon.py +201 -0
- overcode/tui_actions/input.py +128 -0
- overcode/tui_actions/navigation.py +117 -0
- overcode/tui_actions/session.py +428 -0
- overcode/tui_actions/view.py +357 -0
- overcode/tui_helpers.py +41 -9
- overcode/tui_logic.py +347 -0
- overcode/tui_render.py +414 -0
- overcode/tui_widgets/__init__.py +24 -0
- overcode/tui_widgets/command_bar.py +399 -0
- overcode/tui_widgets/daemon_panel.py +153 -0
- overcode/tui_widgets/daemon_status_bar.py +245 -0
- overcode/tui_widgets/help_overlay.py +71 -0
- overcode/tui_widgets/preview_pane.py +69 -0
- overcode/tui_widgets/session_summary.py +514 -0
- overcode/tui_widgets/status_timeline.py +253 -0
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/METADATA +3 -1
- overcode-0.1.4.dist-info/RECORD +68 -0
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
- overcode-0.1.3.dist-info/RECORD +0 -45
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/WHEEL +0 -0
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
|
@@ -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")
|