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/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
- sessions = self.monitor_state.sessions
160
- total_agents = len(sessions)
161
- green_now = self.monitor_state.green_sessions
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
- # Calculate mean spin rate from green_time percentages
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 sessions:
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(sessions)
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 all agent history and group by agent
259
- all_history = read_agent_status_history(hours=self.TIMELINE_HOURS)
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, git changes (Δn files), mode, steers, standing orders
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
- self.detected_status = status
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: 15 chars "Δnn +nnnn -nnn"
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:>3}", style=f"bold red{bg}")
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
- # Header with session name
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, 60 - len(header)), style="dim")
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
- # Show last 30 lines of output - plain text, no decoration
949
- for line in self.content_lines[-30:]:
950
- # Truncate long lines
951
- display_line = line[:200] if len(line) > 200 else line
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 | v:Lines",
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].focus()
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].focus()
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
- focused = self.focused
2075
- if isinstance(focused, SessionSummary):
2076
- preview.update_from_widget(focused)
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,