overcode 0.1.0__py3-none-any.whl → 0.1.2__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 +42 -3
- overcode/config.py +49 -0
- overcode/daemon_logging.py +144 -0
- overcode/daemon_utils.py +84 -0
- overcode/history_reader.py +17 -5
- overcode/implementations.py +11 -0
- overcode/launcher.py +3 -0
- overcode/mocks.py +4 -0
- overcode/monitor_daemon.py +25 -126
- overcode/pid_utils.py +10 -3
- overcode/protocols.py +12 -0
- overcode/session_manager.py +3 -0
- overcode/settings.py +20 -1
- overcode/standing_instructions.py +15 -6
- overcode/status_constants.py +11 -0
- overcode/status_detector.py +38 -0
- overcode/status_patterns.py +12 -0
- overcode/supervisor_daemon.py +40 -171
- overcode/tui.py +326 -39
- overcode/tui_helpers.py +18 -0
- overcode/web_api.py +486 -2
- overcode/web_chartjs.py +32 -0
- overcode/web_server.py +355 -3
- overcode/web_server_runner.py +104 -0
- overcode/web_templates.py +1093 -0
- {overcode-0.1.0.dist-info → overcode-0.1.2.dist-info}/METADATA +13 -1
- overcode-0.1.2.dist-info/RECORD +45 -0
- {overcode-0.1.0.dist-info → overcode-0.1.2.dist-info}/WHEEL +1 -1
- overcode/daemon.py +0 -1184
- overcode/daemon_state.py +0 -113
- overcode-0.1.0.dist-info/RECORD +0 -43
- {overcode-0.1.0.dist-info → overcode-0.1.2.dist-info}/entry_points.txt +0 -0
- {overcode-0.1.0.dist-info → overcode-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {overcode-0.1.0.dist-info → overcode-0.1.2.dist-info}/top_level.txt +0 -0
overcode/tui.py
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Textual TUI for Overcode monitor.
|
|
3
|
+
|
|
4
|
+
TODO: Split this file into smaller modules for maintainability:
|
|
5
|
+
- tui_core.py: Main App class and core lifecycle
|
|
6
|
+
- tui_panels.py: Panel widgets (StatusPanel, AgentPanel, etc.)
|
|
7
|
+
- tui_commands.py: Command handlers and actions
|
|
8
|
+
- tui_keybindings.py: Key bindings and input handling
|
|
3
9
|
"""
|
|
4
10
|
|
|
5
11
|
from concurrent.futures import ThreadPoolExecutor
|
|
@@ -7,6 +13,7 @@ from datetime import datetime, timedelta
|
|
|
7
13
|
from typing import List, Optional
|
|
8
14
|
import subprocess
|
|
9
15
|
import sys
|
|
16
|
+
import time
|
|
10
17
|
|
|
11
18
|
from textual.app import App, ComposeResult
|
|
12
19
|
from textual.containers import Container, Vertical, ScrollableContainer, Horizontal
|
|
@@ -21,8 +28,9 @@ from rich.panel import Panel
|
|
|
21
28
|
from .session_manager import SessionManager, Session
|
|
22
29
|
from .launcher import ClaudeLauncher
|
|
23
30
|
from .status_detector import StatusDetector
|
|
31
|
+
from .status_constants import STATUS_WAITING_USER
|
|
24
32
|
from .history_reader import get_session_stats, ClaudeSessionStats
|
|
25
|
-
from .settings import signal_activity, get_session_dir, TUIPreferences, DAEMON_VERSION # Activity signaling to daemon
|
|
33
|
+
from .settings import signal_activity, get_session_dir, get_agent_history_path, TUIPreferences, DAEMON_VERSION # Activity signaling to daemon
|
|
26
34
|
from .monitor_daemon_state import MonitorDaemonState, get_monitor_daemon_state
|
|
27
35
|
from .monitor_daemon import (
|
|
28
36
|
is_monitor_daemon_running,
|
|
@@ -33,15 +41,22 @@ from .supervisor_daemon import (
|
|
|
33
41
|
is_supervisor_daemon_running,
|
|
34
42
|
stop_supervisor_daemon,
|
|
35
43
|
)
|
|
44
|
+
from .web_server import (
|
|
45
|
+
is_web_server_running,
|
|
46
|
+
get_web_server_url,
|
|
47
|
+
toggle_web_server,
|
|
48
|
+
)
|
|
36
49
|
from .config import get_default_standing_instructions
|
|
37
50
|
from .status_history import read_agent_status_history
|
|
38
|
-
from .presence_logger import read_presence_history
|
|
51
|
+
from .presence_logger import read_presence_history, MACOS_APIS_AVAILABLE
|
|
39
52
|
from .launcher import ClaudeLauncher
|
|
53
|
+
from .implementations import RealTmux
|
|
40
54
|
from .tui_helpers import (
|
|
41
55
|
format_interval,
|
|
42
56
|
format_ago,
|
|
43
57
|
format_duration,
|
|
44
58
|
format_tokens,
|
|
59
|
+
format_line_count,
|
|
45
60
|
calculate_uptime,
|
|
46
61
|
presence_state_to_char,
|
|
47
62
|
agent_status_to_char,
|
|
@@ -84,14 +99,21 @@ class DaemonStatusBar(Static):
|
|
|
84
99
|
Presence is shown only when available (macOS with monitor daemon running).
|
|
85
100
|
"""
|
|
86
101
|
|
|
87
|
-
def __init__(self, tmux_session: str = "agents", *args, **kwargs):
|
|
102
|
+
def __init__(self, tmux_session: str = "agents", session_manager: Optional["SessionManager"] = None, *args, **kwargs):
|
|
88
103
|
super().__init__(*args, **kwargs)
|
|
89
104
|
self.tmux_session = tmux_session
|
|
90
105
|
self.monitor_state: Optional[MonitorDaemonState] = None
|
|
106
|
+
self._session_manager = session_manager
|
|
107
|
+
self._asleep_session_ids: set = set() # Cache of asleep session IDs
|
|
91
108
|
|
|
92
109
|
def update_status(self) -> None:
|
|
93
110
|
"""Refresh daemon state from file"""
|
|
94
111
|
self.monitor_state = get_monitor_daemon_state(self.tmux_session)
|
|
112
|
+
# Update cache of asleep session IDs from session manager
|
|
113
|
+
if self._session_manager:
|
|
114
|
+
self._asleep_session_ids = {
|
|
115
|
+
s.id for s in self._session_manager.list_sessions() if s.is_asleep
|
|
116
|
+
}
|
|
95
117
|
self.refresh()
|
|
96
118
|
|
|
97
119
|
def render(self) -> Text:
|
|
@@ -156,13 +178,18 @@ class DaemonStatusBar(Static):
|
|
|
156
178
|
# Spin rate stats (only when monitor running with sessions)
|
|
157
179
|
if monitor_running and self.monitor_state.sessions:
|
|
158
180
|
content.append(" │ ", style="dim")
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
181
|
+
# Filter out sleeping agents from stats
|
|
182
|
+
all_sessions = self.monitor_state.sessions
|
|
183
|
+
active_sessions = [s for s in all_sessions if s.session_id not in self._asleep_session_ids]
|
|
184
|
+
sleeping_count = len(all_sessions) - len(active_sessions)
|
|
162
185
|
|
|
163
|
-
|
|
186
|
+
total_agents = len(active_sessions)
|
|
187
|
+
# Recalculate green_now excluding sleeping agents
|
|
188
|
+
green_now = sum(1 for s in active_sessions if s.current_status == "running")
|
|
189
|
+
|
|
190
|
+
# Calculate mean spin rate from green_time percentages (exclude sleeping)
|
|
164
191
|
mean_spin = 0.0
|
|
165
|
-
for s in
|
|
192
|
+
for s in active_sessions:
|
|
166
193
|
total_time = s.green_time_seconds + s.non_green_time_seconds
|
|
167
194
|
if total_time > 0:
|
|
168
195
|
mean_spin += s.green_time_seconds / total_time
|
|
@@ -170,11 +197,13 @@ class DaemonStatusBar(Static):
|
|
|
170
197
|
content.append("Spin: ", style="bold")
|
|
171
198
|
content.append(f"{green_now}", style="bold green" if green_now > 0 else "dim")
|
|
172
199
|
content.append(f"/{total_agents}", style="dim")
|
|
200
|
+
if sleeping_count > 0:
|
|
201
|
+
content.append(f" 💤{sleeping_count}", style="dim") # Show sleeping count
|
|
173
202
|
if mean_spin > 0:
|
|
174
203
|
content.append(f" μ{mean_spin:.1f}x", style="cyan")
|
|
175
204
|
|
|
176
|
-
# Safe break duration (time until 50%+ agents need attention)
|
|
177
|
-
safe_break = calculate_safe_break_duration(
|
|
205
|
+
# Safe break duration (time until 50%+ agents need attention) - exclude sleeping
|
|
206
|
+
safe_break = calculate_safe_break_duration(active_sessions)
|
|
178
207
|
if safe_break is not None:
|
|
179
208
|
content.append(" │ ", style="dim")
|
|
180
209
|
content.append("☕", style="bold")
|
|
@@ -210,6 +239,17 @@ class DaemonStatusBar(Static):
|
|
|
210
239
|
else:
|
|
211
240
|
content.append("📡", style="dim")
|
|
212
241
|
|
|
242
|
+
# Web server status
|
|
243
|
+
web_running = is_web_server_running(self.tmux_session)
|
|
244
|
+
if web_running:
|
|
245
|
+
content.append(" │ ", style="dim")
|
|
246
|
+
url = get_web_server_url(self.tmux_session)
|
|
247
|
+
content.append("🌐", style="green")
|
|
248
|
+
if url:
|
|
249
|
+
# Just show port
|
|
250
|
+
port = url.split(":")[-1] if url else ""
|
|
251
|
+
content.append(f":{port}", style="cyan")
|
|
252
|
+
|
|
213
253
|
return content
|
|
214
254
|
|
|
215
255
|
|
|
@@ -226,9 +266,10 @@ class StatusTimeline(Static):
|
|
|
226
266
|
MIN_TIMELINE = 20 # Minimum timeline width
|
|
227
267
|
DEFAULT_TIMELINE = 60 # Fallback if can't detect width
|
|
228
268
|
|
|
229
|
-
def __init__(self, sessions: list, *args, **kwargs):
|
|
269
|
+
def __init__(self, sessions: list, tmux_session: str = "agents", *args, **kwargs):
|
|
230
270
|
super().__init__(*args, **kwargs)
|
|
231
271
|
self.sessions = sessions
|
|
272
|
+
self.tmux_session = tmux_session
|
|
232
273
|
self._presence_history = []
|
|
233
274
|
self._agent_histories = {}
|
|
234
275
|
|
|
@@ -255,8 +296,9 @@ class StatusTimeline(Static):
|
|
|
255
296
|
# Get agent names from sessions
|
|
256
297
|
agent_names = [s.name for s in sessions]
|
|
257
298
|
|
|
258
|
-
# Read
|
|
259
|
-
|
|
299
|
+
# Read agent history from session-specific file and group by agent
|
|
300
|
+
history_path = get_agent_history_path(self.tmux_session)
|
|
301
|
+
all_history = read_agent_status_history(hours=self.TIMELINE_HOURS, history_file=history_path)
|
|
260
302
|
for ts, agent, status, activity in all_history:
|
|
261
303
|
if agent not in self._agent_histories:
|
|
262
304
|
self._agent_histories[agent] = []
|
|
@@ -328,6 +370,10 @@ class StatusTimeline(Static):
|
|
|
328
370
|
content.append(char, style=color)
|
|
329
371
|
else:
|
|
330
372
|
content.append("─", style="dim")
|
|
373
|
+
elif not MACOS_APIS_AVAILABLE:
|
|
374
|
+
# Show install instructions when presence deps not installed (macOS only)
|
|
375
|
+
msg = "macOS only - pip install overcode[presence]"
|
|
376
|
+
content.append(msg[:width], style="dim italic")
|
|
331
377
|
else:
|
|
332
378
|
content.append("─" * width, style="dim")
|
|
333
379
|
content.append("\n")
|
|
@@ -449,10 +495,11 @@ class HelpOverlay(Static):
|
|
|
449
495
|
║ ────────────────────────────────────────────────────────────────────────────║
|
|
450
496
|
║ [ Start supervisor ] Stop supervisor ║
|
|
451
497
|
║ \\ Restart monitor d Toggle daemon log panel ║
|
|
498
|
+
║ w Toggle web dashboard (analytics server) ║
|
|
452
499
|
║ ║
|
|
453
500
|
║ SUMMARY DETAIL LEVELS (s key) ║
|
|
454
501
|
║ ────────────────────────────────────────────────────────────────────────────║
|
|
455
|
-
║ low Name, tokens,
|
|
502
|
+
║ low Name, tokens, ctx% (context usage), git Δ, mode, steers, orders ║
|
|
456
503
|
║ med + uptime, running time, stalled time, latency ║
|
|
457
504
|
║ full + repo:branch, % active, git diff details (+ins -del) ║
|
|
458
505
|
║ ║
|
|
@@ -615,6 +662,8 @@ class SessionSummary(Static, can_focus=True):
|
|
|
615
662
|
self.pane_content: List[str] = [] # Cached pane content
|
|
616
663
|
self.claude_stats: Optional[ClaudeSessionStats] = None # Token/interaction stats
|
|
617
664
|
self.git_diff_stats: Optional[tuple] = None # (files, insertions, deletions)
|
|
665
|
+
# Track if this is a stalled agent that hasn't been visited yet
|
|
666
|
+
self.is_unvisited_stalled: bool = False
|
|
618
667
|
# Start with expanded class since expanded=True by default
|
|
619
668
|
self.add_class("expanded")
|
|
620
669
|
|
|
@@ -623,6 +672,22 @@ class SessionSummary(Static, can_focus=True):
|
|
|
623
672
|
self.expanded = not self.expanded
|
|
624
673
|
# Notify parent app to save state
|
|
625
674
|
self.post_message(self.ExpandedChanged(self.session.id, self.expanded))
|
|
675
|
+
# Mark as visited if this is an unvisited stalled agent
|
|
676
|
+
if self.is_unvisited_stalled:
|
|
677
|
+
self.post_message(self.StalledAgentVisited(self.session.id))
|
|
678
|
+
|
|
679
|
+
def on_focus(self) -> None:
|
|
680
|
+
"""Handle focus event - mark stalled agent as visited and update selection"""
|
|
681
|
+
if self.is_unvisited_stalled:
|
|
682
|
+
self.post_message(self.StalledAgentVisited(self.session.id))
|
|
683
|
+
# Notify app to update selection highlighting
|
|
684
|
+
self.post_message(self.SessionSelected(self.session.id))
|
|
685
|
+
|
|
686
|
+
class SessionSelected(events.Message):
|
|
687
|
+
"""Message sent when a session is selected/focused"""
|
|
688
|
+
def __init__(self, session_id: str):
|
|
689
|
+
super().__init__()
|
|
690
|
+
self.session_id = session_id
|
|
626
691
|
|
|
627
692
|
class ExpandedChanged(events.Message):
|
|
628
693
|
"""Message sent when expanded state changes"""
|
|
@@ -631,6 +696,12 @@ class SessionSummary(Static, can_focus=True):
|
|
|
631
696
|
self.session_id = session_id
|
|
632
697
|
self.expanded = expanded
|
|
633
698
|
|
|
699
|
+
class StalledAgentVisited(events.Message):
|
|
700
|
+
"""Message sent when user visits a stalled agent (focus or click)"""
|
|
701
|
+
def __init__(self, session_id: str):
|
|
702
|
+
super().__init__()
|
|
703
|
+
self.session_id = session_id
|
|
704
|
+
|
|
634
705
|
def watch_expanded(self, expanded: bool) -> None:
|
|
635
706
|
"""Called when expanded state changes"""
|
|
636
707
|
# Toggle CSS class for proper height
|
|
@@ -691,7 +762,11 @@ class SessionSummary(Static, can_focus=True):
|
|
|
691
762
|
# Update detected status for display
|
|
692
763
|
# NOTE: Time tracking removed - Monitor Daemon is the single source of truth
|
|
693
764
|
# The session.stats values are read from what Monitor Daemon has persisted
|
|
694
|
-
|
|
765
|
+
# If session is asleep, keep the asleep status instead of the detected status
|
|
766
|
+
if self.session.is_asleep:
|
|
767
|
+
self.detected_status = "asleep"
|
|
768
|
+
else:
|
|
769
|
+
self.detected_status = status
|
|
695
770
|
|
|
696
771
|
# Use pre-fetched claude stats (no file I/O on main thread)
|
|
697
772
|
if claude_stats is not None:
|
|
@@ -753,6 +828,12 @@ class SessionSummary(Static, can_focus=True):
|
|
|
753
828
|
# Always show: status symbol, time in state, expand icon, agent name
|
|
754
829
|
content.append(f"{status_symbol} ", style=status_color)
|
|
755
830
|
|
|
831
|
+
# Show 🔔 indicator for unvisited stalled agents (needs attention)
|
|
832
|
+
if self.is_unvisited_stalled:
|
|
833
|
+
content.append("🔔", style=f"bold blink red{bg}")
|
|
834
|
+
else:
|
|
835
|
+
content.append(" ", style=f"dim{bg}") # Maintain alignment
|
|
836
|
+
|
|
756
837
|
# Time in current state (directly after status light)
|
|
757
838
|
if stats.state_since:
|
|
758
839
|
try:
|
|
@@ -791,27 +872,36 @@ class SessionSummary(Static, can_focus=True):
|
|
|
791
872
|
content.append(f" {pct:>3.0f}%", style=f"bold green{bg}" if pct >= 50 else f"bold red{bg}")
|
|
792
873
|
|
|
793
874
|
# Always show: token usage (from Claude Code)
|
|
875
|
+
# ALIGNMENT: context indicator is always 7 chars " c@NNN%" (or placeholder)
|
|
794
876
|
if self.claude_stats is not None:
|
|
795
|
-
content.append(f" {format_tokens(self.claude_stats.total_tokens):>6}", style=f"bold orange1{bg}")
|
|
877
|
+
content.append(f" Σ{format_tokens(self.claude_stats.total_tokens):>6}", style=f"bold orange1{bg}")
|
|
878
|
+
# Show current context window usage as percentage (assuming 200K max)
|
|
879
|
+
if self.claude_stats.current_context_tokens > 0:
|
|
880
|
+
max_context = 200_000 # Claude models have 200K context window
|
|
881
|
+
ctx_pct = min(100, self.claude_stats.current_context_tokens / max_context * 100)
|
|
882
|
+
content.append(f" c@{ctx_pct:>3.0f}%", style=f"bold orange1{bg}")
|
|
883
|
+
else:
|
|
884
|
+
content.append(" c@ -%", style=f"dim orange1{bg}")
|
|
796
885
|
else:
|
|
797
|
-
content.append(" -", style=f"dim orange1{bg}")
|
|
886
|
+
content.append(" - c@ -%", style=f"dim orange1{bg}")
|
|
798
887
|
|
|
799
888
|
# Git diff stats (outstanding changes since last commit)
|
|
800
|
-
# ALIGNMENT: Use fixed widths - low/med: 4 chars "Δnn ", full:
|
|
889
|
+
# ALIGNMENT: Use fixed widths - low/med: 4 chars "Δnn ", full: 16 chars "Δnn +nnnn -nnnn"
|
|
890
|
+
# Large line counts are shortened: 173242 -> "173K", 1234567 -> "1.2M"
|
|
801
891
|
if self.git_diff_stats:
|
|
802
892
|
files, ins, dels = self.git_diff_stats
|
|
803
893
|
if self.summary_detail == "full":
|
|
804
894
|
# Full: show files and lines with fixed widths
|
|
805
895
|
content.append(f" Δ{files:>2}", style=f"bold magenta{bg}")
|
|
806
|
-
content.append(f" +{ins:>4}", style=f"bold green{bg}")
|
|
807
|
-
content.append(f" -{dels:>
|
|
896
|
+
content.append(f" +{format_line_count(ins):>4}", style=f"bold green{bg}")
|
|
897
|
+
content.append(f" -{format_line_count(dels):>4}", style=f"bold red{bg}")
|
|
808
898
|
else:
|
|
809
899
|
# Compact: just files changed (fixed 4 char width)
|
|
810
900
|
content.append(f" Δ{files:>2}", style=f"bold magenta{bg}" if files > 0 else f"dim{bg}")
|
|
811
901
|
else:
|
|
812
902
|
# Placeholder matching width for alignment
|
|
813
903
|
if self.summary_detail == "full":
|
|
814
|
-
content.append(" Δ- + - -", style=f"dim{bg}")
|
|
904
|
+
content.append(" Δ- + - - ", style=f"dim{bg}")
|
|
815
905
|
else:
|
|
816
906
|
content.append(" Δ-", style=f"dim{bg}")
|
|
817
907
|
|
|
@@ -936,19 +1026,27 @@ class PreviewPane(Static):
|
|
|
936
1026
|
|
|
937
1027
|
def render(self) -> Text:
|
|
938
1028
|
content = Text()
|
|
939
|
-
#
|
|
1029
|
+
# Use widget width for layout, with sensible fallback
|
|
1030
|
+
pane_width = self.size.width if self.size.width > 0 else 80
|
|
1031
|
+
|
|
1032
|
+
# Header with session name - pad to full pane width
|
|
940
1033
|
header = f"─── {self.session_name} " if self.session_name else "─── Preview "
|
|
941
1034
|
content.append(header, style="bold cyan")
|
|
942
|
-
content.append("─" * max(0,
|
|
1035
|
+
content.append("─" * max(0, pane_width - len(header)), style="dim")
|
|
943
1036
|
content.append("\n")
|
|
944
1037
|
|
|
945
1038
|
if not self.content_lines:
|
|
946
1039
|
content.append("(no output)", style="dim italic")
|
|
947
1040
|
else:
|
|
948
|
-
#
|
|
949
|
-
for
|
|
950
|
-
|
|
951
|
-
|
|
1041
|
+
# Calculate available lines based on widget height
|
|
1042
|
+
# Reserve 2 lines for header and some padding
|
|
1043
|
+
available_lines = max(10, self.size.height - 2) if self.size.height > 0 else 30
|
|
1044
|
+
# Show last N lines of output - plain text, no decoration
|
|
1045
|
+
# Truncate lines to pane width to match tmux display
|
|
1046
|
+
max_line_len = max(pane_width - 1, 40) # Leave room for newline, minimum 40
|
|
1047
|
+
for line in self.content_lines[-available_lines:]:
|
|
1048
|
+
# Truncate long lines to pane width
|
|
1049
|
+
display_line = line[:max_line_len] if len(line) > max_line_len else line
|
|
952
1050
|
content.append(display_line + "\n")
|
|
953
1051
|
|
|
954
1052
|
return content
|
|
@@ -1332,6 +1430,12 @@ class SupervisorTUI(App):
|
|
|
1332
1430
|
text-style: bold;
|
|
1333
1431
|
}
|
|
1334
1432
|
|
|
1433
|
+
/* .selected class preserves highlight when app loses focus */
|
|
1434
|
+
SessionSummary.selected {
|
|
1435
|
+
background: #2d4a5a;
|
|
1436
|
+
text-style: bold;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1335
1439
|
#help-text {
|
|
1336
1440
|
dock: bottom;
|
|
1337
1441
|
height: 1;
|
|
@@ -1489,6 +1593,12 @@ class SupervisorTUI(App):
|
|
|
1489
1593
|
("5", "send_5_to_focused", "Send 5"),
|
|
1490
1594
|
# Copy mode - disable mouse capture for native terminal selection
|
|
1491
1595
|
("y", "toggle_copy_mode", "Copy mode"),
|
|
1596
|
+
# Tmux sync - sync navigation to external tmux pane
|
|
1597
|
+
("p", "toggle_tmux_sync", "Pane sync"),
|
|
1598
|
+
# Web server toggle
|
|
1599
|
+
("w", "toggle_web_server", "Web dashboard"),
|
|
1600
|
+
# Sleep mode toggle - mark agent as paused (excluded from stats)
|
|
1601
|
+
("z", "toggle_sleep", "Sleep mode"),
|
|
1492
1602
|
]
|
|
1493
1603
|
|
|
1494
1604
|
# Detail level cycles through 5, 10, 20, 50 lines
|
|
@@ -1498,6 +1608,7 @@ class SupervisorTUI(App):
|
|
|
1498
1608
|
|
|
1499
1609
|
sessions: reactive[List[Session]] = reactive(list)
|
|
1500
1610
|
view_mode: reactive[str] = reactive("tree") # "tree" or "list_preview"
|
|
1611
|
+
tmux_sync: reactive[bool] = reactive(False) # sync navigation to external tmux pane
|
|
1501
1612
|
|
|
1502
1613
|
def __init__(self, tmux_session: str = "agents", diagnostics: bool = False):
|
|
1503
1614
|
super().__init__()
|
|
@@ -1530,6 +1641,8 @@ class SupervisorTUI(App):
|
|
|
1530
1641
|
|
|
1531
1642
|
# Track focused session for navigation
|
|
1532
1643
|
self.focused_session_index = 0
|
|
1644
|
+
# Track previous status of each session for detecting transitions to stalled state
|
|
1645
|
+
self._previous_statuses: dict[str, str] = {}
|
|
1533
1646
|
# Session cache to avoid disk I/O on every status update (250ms interval)
|
|
1534
1647
|
self._sessions_cache: dict[str, Session] = {}
|
|
1535
1648
|
self._sessions_cache_time: float = 0
|
|
@@ -1538,19 +1651,25 @@ class SupervisorTUI(App):
|
|
|
1538
1651
|
self._status_update_in_progress = False
|
|
1539
1652
|
# Track if we've warned about multiple daemons (to avoid spam)
|
|
1540
1653
|
self._multiple_daemon_warning_shown = False
|
|
1654
|
+
# Pending kill confirmation (session name, timestamp)
|
|
1655
|
+
self._pending_kill: tuple[str, float] | None = None
|
|
1656
|
+
# Tmux interface for sync operations
|
|
1657
|
+
self._tmux = RealTmux()
|
|
1658
|
+
# Initialize tmux_sync from preferences
|
|
1659
|
+
self.tmux_sync = self._prefs.tmux_sync
|
|
1541
1660
|
|
|
1542
1661
|
def compose(self) -> ComposeResult:
|
|
1543
1662
|
"""Create child widgets"""
|
|
1544
1663
|
yield Header(show_clock=True)
|
|
1545
|
-
yield DaemonStatusBar(tmux_session=self.tmux_session, id="daemon-status")
|
|
1546
|
-
yield StatusTimeline([], id="timeline")
|
|
1664
|
+
yield DaemonStatusBar(tmux_session=self.tmux_session, session_manager=self.session_manager, id="daemon-status")
|
|
1665
|
+
yield StatusTimeline([], tmux_session=self.tmux_session, id="timeline")
|
|
1547
1666
|
yield DaemonPanel(tmux_session=self.tmux_session, id="daemon-panel")
|
|
1548
1667
|
yield ScrollableContainer(id="sessions-container")
|
|
1549
1668
|
yield PreviewPane(id="preview-pane")
|
|
1550
1669
|
yield CommandBar(id="command-bar")
|
|
1551
1670
|
yield HelpOverlay(id="help-overlay")
|
|
1552
1671
|
yield Static(
|
|
1553
|
-
"h:Help | q:Quit | j/k:Nav | i:Send | n:New | x:Kill | space | m:Mode | d:Daemon | t:Timeline
|
|
1672
|
+
"h:Help | q:Quit | j/k:Nav | i:Send | n:New | x:Kill | space | m:Mode | p:Sync | d:Daemon | t:Timeline",
|
|
1554
1673
|
id="help-text"
|
|
1555
1674
|
)
|
|
1556
1675
|
|
|
@@ -1624,7 +1743,7 @@ class SupervisorTUI(App):
|
|
|
1624
1743
|
pass
|
|
1625
1744
|
|
|
1626
1745
|
# Check for multiple daemon processes (potential time tracking bug)
|
|
1627
|
-
daemon_count = count_daemon_processes("monitor_daemon")
|
|
1746
|
+
daemon_count = count_daemon_processes("monitor_daemon", session=self.tmux_session)
|
|
1628
1747
|
if daemon_count > 1 and not self._multiple_daemon_warning_shown:
|
|
1629
1748
|
self._multiple_daemon_warning_shown = True
|
|
1630
1749
|
self.notify(
|
|
@@ -1802,6 +1921,8 @@ class SupervisorTUI(App):
|
|
|
1802
1921
|
All data has been pre-fetched in background - this just updates widget state.
|
|
1803
1922
|
No file I/O happens here.
|
|
1804
1923
|
"""
|
|
1924
|
+
prefs_changed = False
|
|
1925
|
+
|
|
1805
1926
|
for widget in self.query(SessionSummary):
|
|
1806
1927
|
session_id = widget.session.id
|
|
1807
1928
|
|
|
@@ -1814,9 +1935,31 @@ class SupervisorTUI(App):
|
|
|
1814
1935
|
status, activity, content = status_results[session_id]
|
|
1815
1936
|
claude_stats = stats_results.get(session_id)
|
|
1816
1937
|
git_diff = git_diff_results.get(session_id)
|
|
1938
|
+
|
|
1939
|
+
# Detect transitions TO stalled state (waiting_user)
|
|
1940
|
+
prev_status = self._previous_statuses.get(session_id)
|
|
1941
|
+
if status == STATUS_WAITING_USER and prev_status != STATUS_WAITING_USER:
|
|
1942
|
+
# Agent just became stalled - mark as unvisited
|
|
1943
|
+
self._prefs.visited_stalled_agents.discard(session_id)
|
|
1944
|
+
prefs_changed = True
|
|
1945
|
+
|
|
1946
|
+
# Update previous status for next round
|
|
1947
|
+
self._previous_statuses[session_id] = status
|
|
1948
|
+
|
|
1949
|
+
# Update widget's unvisited state
|
|
1950
|
+
is_unvisited_stalled = (
|
|
1951
|
+
status == STATUS_WAITING_USER and
|
|
1952
|
+
session_id not in self._prefs.visited_stalled_agents
|
|
1953
|
+
)
|
|
1954
|
+
widget.is_unvisited_stalled = is_unvisited_stalled
|
|
1955
|
+
|
|
1817
1956
|
widget.apply_status_no_refresh(status, activity, content, claude_stats, git_diff)
|
|
1818
1957
|
widget.refresh() # Refresh each widget to repaint
|
|
1819
1958
|
|
|
1959
|
+
# Save preferences if we marked any agents as unvisited
|
|
1960
|
+
if prefs_changed:
|
|
1961
|
+
self._save_prefs()
|
|
1962
|
+
|
|
1820
1963
|
# Update preview pane if in list_preview mode
|
|
1821
1964
|
if self.view_mode == "list_preview":
|
|
1822
1965
|
self._update_preview()
|
|
@@ -1942,6 +2085,28 @@ class SupervisorTUI(App):
|
|
|
1942
2085
|
"""Handle expanded state changes from session widgets"""
|
|
1943
2086
|
self.expanded_states[message.session_id] = message.expanded
|
|
1944
2087
|
|
|
2088
|
+
def on_session_summary_stalled_agent_visited(self, message: SessionSummary.StalledAgentVisited) -> None:
|
|
2089
|
+
"""Handle when user visits a stalled agent - mark as visited"""
|
|
2090
|
+
session_id = message.session_id
|
|
2091
|
+
self._prefs.visited_stalled_agents.add(session_id)
|
|
2092
|
+
self._save_prefs()
|
|
2093
|
+
|
|
2094
|
+
# Update the widget's state
|
|
2095
|
+
for widget in self.query(SessionSummary):
|
|
2096
|
+
if widget.session.id == session_id:
|
|
2097
|
+
widget.is_unvisited_stalled = False
|
|
2098
|
+
widget.refresh()
|
|
2099
|
+
break
|
|
2100
|
+
|
|
2101
|
+
def on_session_summary_session_selected(self, message: SessionSummary.SessionSelected) -> None:
|
|
2102
|
+
"""Handle session selection - update .selected class to preserve highlight when unfocused"""
|
|
2103
|
+
session_id = message.session_id
|
|
2104
|
+
for widget in self.query(SessionSummary):
|
|
2105
|
+
if widget.session.id == session_id:
|
|
2106
|
+
widget.add_class("selected")
|
|
2107
|
+
else:
|
|
2108
|
+
widget.remove_class("selected")
|
|
2109
|
+
|
|
1945
2110
|
def action_toggle_focused(self) -> None:
|
|
1946
2111
|
"""Toggle expansion of focused session (only in tree mode)"""
|
|
1947
2112
|
if self.view_mode == "list_preview":
|
|
@@ -1996,9 +2161,11 @@ class SupervisorTUI(App):
|
|
|
1996
2161
|
if not widgets:
|
|
1997
2162
|
return
|
|
1998
2163
|
self.focused_session_index = (self.focused_session_index + 1) % len(widgets)
|
|
1999
|
-
widgets[self.focused_session_index]
|
|
2164
|
+
target_widget = widgets[self.focused_session_index]
|
|
2165
|
+
target_widget.focus()
|
|
2000
2166
|
if self.view_mode == "list_preview":
|
|
2001
2167
|
self._update_preview()
|
|
2168
|
+
self._sync_tmux_window(target_widget)
|
|
2002
2169
|
|
|
2003
2170
|
def action_focus_previous_session(self) -> None:
|
|
2004
2171
|
"""Focus the previous session in the list."""
|
|
@@ -2006,9 +2173,11 @@ class SupervisorTUI(App):
|
|
|
2006
2173
|
if not widgets:
|
|
2007
2174
|
return
|
|
2008
2175
|
self.focused_session_index = (self.focused_session_index - 1) % len(widgets)
|
|
2009
|
-
widgets[self.focused_session_index]
|
|
2176
|
+
target_widget = widgets[self.focused_session_index]
|
|
2177
|
+
target_widget.focus()
|
|
2010
2178
|
if self.view_mode == "list_preview":
|
|
2011
2179
|
self._update_preview()
|
|
2180
|
+
self._sync_tmux_window(target_widget)
|
|
2012
2181
|
|
|
2013
2182
|
def action_toggle_view_mode(self) -> None:
|
|
2014
2183
|
"""Toggle between tree and list+preview view modes."""
|
|
@@ -2021,6 +2190,39 @@ class SupervisorTUI(App):
|
|
|
2021
2190
|
self._prefs.view_mode = self.view_mode
|
|
2022
2191
|
self._save_prefs()
|
|
2023
2192
|
|
|
2193
|
+
def action_toggle_tmux_sync(self) -> None:
|
|
2194
|
+
"""Toggle tmux pane sync - syncs navigation to external tmux pane."""
|
|
2195
|
+
self.tmux_sync = not self.tmux_sync
|
|
2196
|
+
|
|
2197
|
+
# Save preference
|
|
2198
|
+
self._prefs.tmux_sync = self.tmux_sync
|
|
2199
|
+
self._save_prefs()
|
|
2200
|
+
|
|
2201
|
+
# Update subtitle to show sync state
|
|
2202
|
+
self._update_subtitle()
|
|
2203
|
+
|
|
2204
|
+
# If enabling, sync to currently focused session immediately
|
|
2205
|
+
if self.tmux_sync:
|
|
2206
|
+
self._sync_tmux_window()
|
|
2207
|
+
|
|
2208
|
+
def _sync_tmux_window(self, widget: Optional["SessionSummary"] = None) -> None:
|
|
2209
|
+
"""Sync external tmux pane to show the focused session's window.
|
|
2210
|
+
|
|
2211
|
+
Args:
|
|
2212
|
+
widget: The session widget to sync to. If None, uses self.focused.
|
|
2213
|
+
"""
|
|
2214
|
+
if not self.tmux_sync:
|
|
2215
|
+
return
|
|
2216
|
+
|
|
2217
|
+
try:
|
|
2218
|
+
target = widget if widget is not None else self.focused
|
|
2219
|
+
if isinstance(target, SessionSummary):
|
|
2220
|
+
window_index = target.session.tmux_window
|
|
2221
|
+
if window_index is not None:
|
|
2222
|
+
self._tmux.select_window(self.tmux_session, window_index)
|
|
2223
|
+
except Exception:
|
|
2224
|
+
pass # Silent fail - don't disrupt navigation
|
|
2225
|
+
|
|
2024
2226
|
def watch_view_mode(self, view_mode: str) -> None:
|
|
2025
2227
|
"""React to view mode changes."""
|
|
2026
2228
|
# Update subtitle to show current mode
|
|
@@ -2049,10 +2251,11 @@ class SupervisorTUI(App):
|
|
|
2049
2251
|
def _update_subtitle(self) -> None:
|
|
2050
2252
|
"""Update the header subtitle to show session and view mode."""
|
|
2051
2253
|
mode_label = "Tree" if self.view_mode == "tree" else "List+Preview"
|
|
2254
|
+
sync_label = " [Sync]" if self.tmux_sync else ""
|
|
2052
2255
|
if self.diagnostics:
|
|
2053
|
-
self.sub_title = f"{self.tmux_session} [{mode_label}] [DIAGNOSTICS]"
|
|
2256
|
+
self.sub_title = f"{self.tmux_session} [{mode_label}]{sync_label} [DIAGNOSTICS]"
|
|
2054
2257
|
else:
|
|
2055
|
-
self.sub_title = f"{self.tmux_session} [{mode_label}]"
|
|
2258
|
+
self.sub_title = f"{self.tmux_session} [{mode_label}]{sync_label}"
|
|
2056
2259
|
|
|
2057
2260
|
def _select_first_agent(self) -> None:
|
|
2058
2261
|
"""Select the first agent for initial preview pane display."""
|
|
@@ -2071,9 +2274,9 @@ class SupervisorTUI(App):
|
|
|
2071
2274
|
"""Update preview pane with focused session's content."""
|
|
2072
2275
|
try:
|
|
2073
2276
|
preview = self.query_one("#preview-pane", PreviewPane)
|
|
2074
|
-
|
|
2075
|
-
if
|
|
2076
|
-
preview.update_from_widget(
|
|
2277
|
+
widgets = self._get_widgets_in_session_order()
|
|
2278
|
+
if widgets and 0 <= self.focused_session_index < len(widgets):
|
|
2279
|
+
preview.update_from_widget(widgets[self.focused_session_index])
|
|
2077
2280
|
except NoMatches:
|
|
2078
2281
|
pass
|
|
2079
2282
|
|
|
@@ -2340,9 +2543,16 @@ class SupervisorTUI(App):
|
|
|
2340
2543
|
The Monitor Daemon handles status tracking, time accumulation,
|
|
2341
2544
|
stats sync, and user presence detection.
|
|
2342
2545
|
"""
|
|
2546
|
+
# Check PID file first
|
|
2343
2547
|
if is_monitor_daemon_running(self.tmux_session):
|
|
2344
2548
|
return # Already running
|
|
2345
2549
|
|
|
2550
|
+
# Also check for running processes (in case PID file is stale or daemon is starting)
|
|
2551
|
+
# This prevents race conditions where multiple TUIs start daemons simultaneously
|
|
2552
|
+
daemon_count = count_daemon_processes("monitor_daemon", session=self.tmux_session)
|
|
2553
|
+
if daemon_count > 0:
|
|
2554
|
+
return # Daemon process exists, just PID file might be missing/stale
|
|
2555
|
+
|
|
2346
2556
|
try:
|
|
2347
2557
|
subprocess.Popen(
|
|
2348
2558
|
[sys.executable, "-m", "overcode.monitor_daemon",
|
|
@@ -2355,8 +2565,62 @@ class SupervisorTUI(App):
|
|
|
2355
2565
|
except (OSError, subprocess.SubprocessError) as e:
|
|
2356
2566
|
self.notify(f"Failed to start Monitor Daemon: {e}", severity="warning")
|
|
2357
2567
|
|
|
2568
|
+
def action_toggle_web_server(self) -> None:
|
|
2569
|
+
"""Toggle the web analytics dashboard server on/off."""
|
|
2570
|
+
is_running, msg = toggle_web_server(self.tmux_session)
|
|
2571
|
+
|
|
2572
|
+
if is_running:
|
|
2573
|
+
url = get_web_server_url(self.tmux_session)
|
|
2574
|
+
self.notify(f"Web server: {url}", severity="information")
|
|
2575
|
+
try:
|
|
2576
|
+
panel = self.query_one("#daemon-panel", DaemonPanel)
|
|
2577
|
+
panel.log_lines.append(f">>> Web server started: {url}")
|
|
2578
|
+
except NoMatches:
|
|
2579
|
+
pass
|
|
2580
|
+
else:
|
|
2581
|
+
self.notify(f"Web server: {msg}", severity="information")
|
|
2582
|
+
try:
|
|
2583
|
+
panel = self.query_one("#daemon-panel", DaemonPanel)
|
|
2584
|
+
panel.log_lines.append(f">>> Web server: {msg}")
|
|
2585
|
+
except NoMatches:
|
|
2586
|
+
pass
|
|
2587
|
+
|
|
2588
|
+
self.update_daemon_status()
|
|
2589
|
+
|
|
2590
|
+
def action_toggle_sleep(self) -> None:
|
|
2591
|
+
"""Toggle sleep mode for the focused agent.
|
|
2592
|
+
|
|
2593
|
+
Sleep mode marks an agent as 'asleep' (human doesn't want it to do anything).
|
|
2594
|
+
Sleeping agents are excluded from stats calculations.
|
|
2595
|
+
Press z again to wake the agent.
|
|
2596
|
+
"""
|
|
2597
|
+
focused = self.focused
|
|
2598
|
+
if not isinstance(focused, SessionSummary):
|
|
2599
|
+
self.notify("No agent focused", severity="warning")
|
|
2600
|
+
return
|
|
2601
|
+
|
|
2602
|
+
session = focused.session
|
|
2603
|
+
new_asleep_state = not session.is_asleep
|
|
2604
|
+
|
|
2605
|
+
# Update the session in the session manager
|
|
2606
|
+
self.session_manager.update_session(session.id, is_asleep=new_asleep_state)
|
|
2607
|
+
|
|
2608
|
+
# Update the local session object
|
|
2609
|
+
session.is_asleep = new_asleep_state
|
|
2610
|
+
|
|
2611
|
+
# Update the widget's display status if sleeping
|
|
2612
|
+
if new_asleep_state:
|
|
2613
|
+
focused.detected_status = "asleep"
|
|
2614
|
+
self.notify(f"Agent '{session.name}' is now asleep (excluded from stats)", severity="information")
|
|
2615
|
+
else:
|
|
2616
|
+
# Wake up - status will be refreshed on next update cycle
|
|
2617
|
+
self.notify(f"Agent '{session.name}' is now awake", severity="information")
|
|
2618
|
+
|
|
2619
|
+
# Force a refresh
|
|
2620
|
+
focused.refresh()
|
|
2621
|
+
|
|
2358
2622
|
def action_kill_focused(self) -> None:
|
|
2359
|
-
"""Kill the currently focused agent."""
|
|
2623
|
+
"""Kill the currently focused agent (requires confirmation)."""
|
|
2360
2624
|
focused = self.focused
|
|
2361
2625
|
if not isinstance(focused, SessionSummary):
|
|
2362
2626
|
self.notify("No agent focused", severity="warning")
|
|
@@ -2364,7 +2628,30 @@ class SupervisorTUI(App):
|
|
|
2364
2628
|
|
|
2365
2629
|
session_name = focused.session.name
|
|
2366
2630
|
session_id = focused.session.id
|
|
2631
|
+
now = time.time()
|
|
2632
|
+
|
|
2633
|
+
# Check if this is a confirmation of a pending kill
|
|
2634
|
+
if self._pending_kill:
|
|
2635
|
+
pending_name, pending_time = self._pending_kill
|
|
2636
|
+
# Confirm if same session and within 3 second window
|
|
2637
|
+
if pending_name == session_name and (now - pending_time) < 3.0:
|
|
2638
|
+
self._pending_kill = None # Clear pending state
|
|
2639
|
+
self._execute_kill(focused, session_name, session_id)
|
|
2640
|
+
return
|
|
2641
|
+
else:
|
|
2642
|
+
# Different session or expired - start new confirmation
|
|
2643
|
+
self._pending_kill = None
|
|
2644
|
+
|
|
2645
|
+
# First press - request confirmation
|
|
2646
|
+
self._pending_kill = (session_name, now)
|
|
2647
|
+
self.notify(
|
|
2648
|
+
f"Press x again to kill '{session_name}'",
|
|
2649
|
+
severity="warning",
|
|
2650
|
+
timeout=3
|
|
2651
|
+
)
|
|
2367
2652
|
|
|
2653
|
+
def _execute_kill(self, focused: "SessionSummary", session_name: str, session_id: str) -> None:
|
|
2654
|
+
"""Execute the actual kill operation after confirmation."""
|
|
2368
2655
|
# Use launcher to kill the session
|
|
2369
2656
|
launcher = ClaudeLauncher(
|
|
2370
2657
|
tmux_session=self.tmux_session,
|