overcode 0.1.0__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 (43) hide show
  1. overcode/__init__.py +5 -0
  2. overcode/cli.py +812 -0
  3. overcode/config.py +72 -0
  4. overcode/daemon.py +1184 -0
  5. overcode/daemon_claude_skill.md +180 -0
  6. overcode/daemon_state.py +113 -0
  7. overcode/data_export.py +257 -0
  8. overcode/dependency_check.py +227 -0
  9. overcode/exceptions.py +219 -0
  10. overcode/history_reader.py +448 -0
  11. overcode/implementations.py +214 -0
  12. overcode/interfaces.py +49 -0
  13. overcode/launcher.py +434 -0
  14. overcode/logging_config.py +193 -0
  15. overcode/mocks.py +152 -0
  16. overcode/monitor_daemon.py +808 -0
  17. overcode/monitor_daemon_state.py +358 -0
  18. overcode/pid_utils.py +225 -0
  19. overcode/presence_logger.py +454 -0
  20. overcode/protocols.py +143 -0
  21. overcode/session_manager.py +606 -0
  22. overcode/settings.py +412 -0
  23. overcode/standing_instructions.py +276 -0
  24. overcode/status_constants.py +190 -0
  25. overcode/status_detector.py +339 -0
  26. overcode/status_history.py +164 -0
  27. overcode/status_patterns.py +264 -0
  28. overcode/summarizer_client.py +136 -0
  29. overcode/summarizer_component.py +312 -0
  30. overcode/supervisor_daemon.py +1000 -0
  31. overcode/supervisor_layout.sh +50 -0
  32. overcode/tmux_manager.py +228 -0
  33. overcode/tui.py +2549 -0
  34. overcode/tui_helpers.py +495 -0
  35. overcode/web_api.py +279 -0
  36. overcode/web_server.py +138 -0
  37. overcode/web_templates.py +563 -0
  38. overcode-0.1.0.dist-info/METADATA +87 -0
  39. overcode-0.1.0.dist-info/RECORD +43 -0
  40. overcode-0.1.0.dist-info/WHEEL +5 -0
  41. overcode-0.1.0.dist-info/entry_points.txt +2 -0
  42. overcode-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. overcode-0.1.0.dist-info/top_level.txt +1 -0
overcode/tui.py ADDED
@@ -0,0 +1,2549 @@
1
+ """
2
+ Textual TUI for Overcode monitor.
3
+ """
4
+
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from datetime import datetime, timedelta
7
+ from typing import List, Optional
8
+ import subprocess
9
+ import sys
10
+
11
+ from textual.app import App, ComposeResult
12
+ from textual.containers import Container, Vertical, ScrollableContainer, Horizontal
13
+ from textual.widgets import Header, Footer, Static, Label, Input, TextArea
14
+ from textual.reactive import reactive
15
+ from textual.css.query import NoMatches
16
+ from textual import events, work
17
+ from textual.message import Message
18
+ from rich.text import Text
19
+ from rich.panel import Panel
20
+
21
+ from .session_manager import SessionManager, Session
22
+ from .launcher import ClaudeLauncher
23
+ from .status_detector import StatusDetector
24
+ from .history_reader import get_session_stats, ClaudeSessionStats
25
+ from .settings import signal_activity, get_session_dir, TUIPreferences, DAEMON_VERSION # Activity signaling to daemon
26
+ from .monitor_daemon_state import MonitorDaemonState, get_monitor_daemon_state
27
+ from .monitor_daemon import (
28
+ is_monitor_daemon_running,
29
+ stop_monitor_daemon,
30
+ )
31
+ from .pid_utils import count_daemon_processes
32
+ from .supervisor_daemon import (
33
+ is_supervisor_daemon_running,
34
+ stop_supervisor_daemon,
35
+ )
36
+ from .config import get_default_standing_instructions
37
+ from .status_history import read_agent_status_history
38
+ from .presence_logger import read_presence_history
39
+ from .launcher import ClaudeLauncher
40
+ from .tui_helpers import (
41
+ format_interval,
42
+ format_ago,
43
+ format_duration,
44
+ format_tokens,
45
+ calculate_uptime,
46
+ presence_state_to_char,
47
+ agent_status_to_char,
48
+ get_current_state_times,
49
+ build_timeline_slots,
50
+ build_timeline_string,
51
+ get_status_symbol,
52
+ get_presence_color,
53
+ get_agent_timeline_color,
54
+ style_pane_line,
55
+ truncate_name,
56
+ get_daemon_status_style,
57
+ get_git_diff_stats,
58
+ calculate_safe_break_duration,
59
+ )
60
+
61
+
62
+ def format_standing_instructions(instructions: str, max_len: int = 95) -> str:
63
+ """Format standing instructions for display.
64
+
65
+ Shows "[DEFAULT]" if instructions match the configured default,
66
+ otherwise shows the truncated instructions.
67
+ """
68
+ if not instructions:
69
+ return ""
70
+
71
+ default = get_default_standing_instructions()
72
+ if default and instructions.strip() == default.strip():
73
+ return "[DEFAULT]"
74
+
75
+ if len(instructions) > max_len:
76
+ return instructions[:max_len - 3] + "..."
77
+ return instructions
78
+
79
+
80
+ class DaemonStatusBar(Static):
81
+ """Widget displaying daemon status.
82
+
83
+ Shows Monitor Daemon and Supervisor Daemon status explicitly.
84
+ Presence is shown only when available (macOS with monitor daemon running).
85
+ """
86
+
87
+ def __init__(self, tmux_session: str = "agents", *args, **kwargs):
88
+ super().__init__(*args, **kwargs)
89
+ self.tmux_session = tmux_session
90
+ self.monitor_state: Optional[MonitorDaemonState] = None
91
+
92
+ def update_status(self) -> None:
93
+ """Refresh daemon state from file"""
94
+ self.monitor_state = get_monitor_daemon_state(self.tmux_session)
95
+ self.refresh()
96
+
97
+ def render(self) -> Text:
98
+ """Render daemon status bar.
99
+
100
+ Shows Monitor Daemon and Supervisor Daemon status explicitly.
101
+ """
102
+ content = Text()
103
+
104
+ # Monitor Daemon status
105
+ content.append("Monitor: ", style="bold")
106
+ monitor_running = self.monitor_state and not self.monitor_state.is_stale()
107
+
108
+ if monitor_running:
109
+ state = self.monitor_state
110
+ symbol, style = get_daemon_status_style(state.status)
111
+ content.append(f"{symbol} ", style=style)
112
+ content.append(f"#{state.loop_count}", style="cyan")
113
+ content.append(f" @{format_interval(state.current_interval)}", style="dim")
114
+ # Version mismatch warning
115
+ if state.daemon_version != DAEMON_VERSION:
116
+ content.append(f" ⚠v{state.daemon_version}→{DAEMON_VERSION}", style="bold yellow")
117
+ else:
118
+ content.append("○ ", style="red")
119
+ content.append("stopped", style="red")
120
+
121
+ content.append(" │ ", style="dim")
122
+
123
+ # Supervisor Daemon status
124
+ content.append("Supervisor: ", style="bold")
125
+ supervisor_running = is_supervisor_daemon_running(self.tmux_session)
126
+
127
+ if supervisor_running:
128
+ content.append("● ", style="green")
129
+ # Show if daemon Claude is currently running
130
+ if monitor_running and self.monitor_state.supervisor_claude_running:
131
+ # Calculate current run duration
132
+ run_duration = ""
133
+ if self.monitor_state.supervisor_claude_started_at:
134
+ try:
135
+ started = datetime.fromisoformat(self.monitor_state.supervisor_claude_started_at)
136
+ elapsed = (datetime.now() - started).total_seconds()
137
+ run_duration = format_duration(elapsed)
138
+ except (ValueError, TypeError):
139
+ run_duration = "?"
140
+ content.append(f"🤖 RUNNING {run_duration}", style="bold yellow")
141
+ # Show supervision stats if available from monitor state
142
+ elif monitor_running and self.monitor_state.total_supervisions > 0:
143
+ content.append(f"sup:{self.monitor_state.total_supervisions}", style="magenta")
144
+ if self.monitor_state.supervisor_tokens > 0:
145
+ content.append(f" {format_tokens(self.monitor_state.supervisor_tokens)}", style="blue")
146
+ # Show cumulative daemon Claude run time
147
+ if self.monitor_state.supervisor_claude_total_run_seconds > 0:
148
+ total_run = format_duration(self.monitor_state.supervisor_claude_total_run_seconds)
149
+ content.append(f" ⏱{total_run}", style="dim")
150
+ else:
151
+ content.append("ready", style="green")
152
+ else:
153
+ content.append("○ ", style="red")
154
+ content.append("stopped", style="red")
155
+
156
+ # Spin rate stats (only when monitor running with sessions)
157
+ if monitor_running and self.monitor_state.sessions:
158
+ content.append(" │ ", style="dim")
159
+ sessions = self.monitor_state.sessions
160
+ total_agents = len(sessions)
161
+ green_now = self.monitor_state.green_sessions
162
+
163
+ # Calculate mean spin rate from green_time percentages
164
+ mean_spin = 0.0
165
+ for s in sessions:
166
+ total_time = s.green_time_seconds + s.non_green_time_seconds
167
+ if total_time > 0:
168
+ mean_spin += s.green_time_seconds / total_time
169
+
170
+ content.append("Spin: ", style="bold")
171
+ content.append(f"{green_now}", style="bold green" if green_now > 0 else "dim")
172
+ content.append(f"/{total_agents}", style="dim")
173
+ if mean_spin > 0:
174
+ content.append(f" μ{mean_spin:.1f}x", style="cyan")
175
+
176
+ # Safe break duration (time until 50%+ agents need attention)
177
+ safe_break = calculate_safe_break_duration(sessions)
178
+ if safe_break is not None:
179
+ content.append(" │ ", style="dim")
180
+ content.append("☕", style="bold")
181
+ if safe_break < 60:
182
+ content.append(f" <1m", style="bold red")
183
+ elif safe_break < 300: # < 5 min
184
+ content.append(f" {format_duration(safe_break)}", style="bold yellow")
185
+ else:
186
+ content.append(f" {format_duration(safe_break)}", style="bold green")
187
+
188
+ # Presence status (only show if available via monitor daemon on macOS)
189
+ if monitor_running and self.monitor_state.presence_available:
190
+ content.append(" │ ", style="dim")
191
+ state = self.monitor_state.presence_state
192
+ idle = self.monitor_state.presence_idle_seconds or 0
193
+
194
+ state_names = {1: "🔒", 2: "💤", 3: "👤"}
195
+ state_colors = {1: "red", 2: "yellow", 3: "green"}
196
+
197
+ icon = state_names.get(state, "?")
198
+ color = state_colors.get(state, "dim")
199
+ content.append(f"{icon}", style=color)
200
+ content.append(f" {int(idle)}s", style="dim")
201
+
202
+ # Relay status (small indicator)
203
+ if monitor_running and self.monitor_state.relay_enabled:
204
+ content.append(" │ ", style="dim")
205
+ relay_status = self.monitor_state.relay_last_status
206
+ if relay_status == "ok":
207
+ content.append("📡", style="green")
208
+ elif relay_status == "error":
209
+ content.append("📡", style="red")
210
+ else:
211
+ content.append("📡", style="dim")
212
+
213
+ return content
214
+
215
+
216
+ class StatusTimeline(Static):
217
+ """Widget displaying historical status timelines for user presence and agents.
218
+
219
+ Shows the last 3 hours with each character representing a time slice.
220
+ - User presence: green=active, yellow=inactive, red/gray=locked/away
221
+ - Agent status: green=running, red=waiting, grey=terminated
222
+ """
223
+
224
+ TIMELINE_HOURS = 3.0 # Show last 3 hours
225
+ LABEL_WIDTH = 12 # Width of labels like " User: " or " agent: "
226
+ MIN_TIMELINE = 20 # Minimum timeline width
227
+ DEFAULT_TIMELINE = 60 # Fallback if can't detect width
228
+
229
+ def __init__(self, sessions: list, *args, **kwargs):
230
+ super().__init__(*args, **kwargs)
231
+ self.sessions = sessions
232
+ self._presence_history = []
233
+ self._agent_histories = {}
234
+
235
+ @property
236
+ def timeline_width(self) -> int:
237
+ """Calculate timeline width based on available space."""
238
+ import shutil
239
+ try:
240
+ # Try to get terminal size directly - most reliable
241
+ term_width = shutil.get_terminal_size().columns
242
+ # Subtract label width and some padding
243
+ available = term_width - self.LABEL_WIDTH - 6
244
+ return max(self.MIN_TIMELINE, min(available, 120))
245
+ except (OSError, ValueError):
246
+ # No terminal available or invalid size
247
+ return self.DEFAULT_TIMELINE
248
+
249
+ def update_history(self, sessions: list) -> None:
250
+ """Refresh history data from log files."""
251
+ self.sessions = sessions
252
+ self._presence_history = read_presence_history(hours=self.TIMELINE_HOURS)
253
+ self._agent_histories = {}
254
+
255
+ # Get agent names from sessions
256
+ agent_names = [s.name for s in sessions]
257
+
258
+ # Read all agent history and group by agent
259
+ all_history = read_agent_status_history(hours=self.TIMELINE_HOURS)
260
+ for ts, agent, status, activity in all_history:
261
+ if agent not in self._agent_histories:
262
+ self._agent_histories[agent] = []
263
+ self._agent_histories[agent].append((ts, status))
264
+
265
+ # Force layout refresh when content changes (agent count may have changed)
266
+ self.refresh(layout=True)
267
+
268
+ def _build_timeline(self, history: list, state_to_char: callable) -> str:
269
+ """Build a timeline string from history data.
270
+
271
+ Args:
272
+ history: List of (timestamp, state) tuples
273
+ state_to_char: Function to convert state to display character
274
+
275
+ Returns:
276
+ String of timeline_width characters representing the timeline
277
+ """
278
+ width = self.timeline_width
279
+ if not history:
280
+ return "─" * width
281
+
282
+ now = datetime.now()
283
+ start_time = now - timedelta(hours=self.TIMELINE_HOURS)
284
+ slot_duration = timedelta(hours=self.TIMELINE_HOURS) / width
285
+
286
+ # Initialize timeline with empty slots
287
+ timeline = ["─"] * width
288
+
289
+ # Fill in slots based on history
290
+ for ts, state in history:
291
+ if ts < start_time:
292
+ continue
293
+ # Calculate which slot this belongs to
294
+ elapsed = ts - start_time
295
+ slot_idx = int(elapsed / slot_duration)
296
+ if 0 <= slot_idx < width:
297
+ timeline[slot_idx] = state_to_char(state)
298
+
299
+ return "".join(timeline)
300
+
301
+ def render(self) -> Text:
302
+ """Render the timeline visualization."""
303
+ content = Text()
304
+ now = datetime.now()
305
+ width = self.timeline_width
306
+
307
+ # Time scale header
308
+ content.append("Timeline: ", style="bold")
309
+ content.append(f"-{self.TIMELINE_HOURS:.0f}h", style="dim")
310
+ header_padding = max(0, width - 10)
311
+ content.append(" " * header_padding, style="dim")
312
+ content.append("now", style="dim")
313
+ content.append("\n")
314
+
315
+ # User presence timeline - group by time slots like agent timelines
316
+ # Align with agent names (14 chars): " " + name + " " = 17 chars total
317
+ content.append(f" {'User:':<14} ", style="cyan")
318
+ if self._presence_history:
319
+ slot_states = build_timeline_slots(
320
+ self._presence_history, width, self.TIMELINE_HOURS, now
321
+ )
322
+ # Render timeline with colors
323
+ for i in range(width):
324
+ if i in slot_states:
325
+ state = slot_states[i]
326
+ char = presence_state_to_char(state)
327
+ color = get_presence_color(state)
328
+ content.append(char, style=color)
329
+ else:
330
+ content.append("─", style="dim")
331
+ else:
332
+ content.append("─" * width, style="dim")
333
+ content.append("\n")
334
+
335
+ # Agent timelines
336
+ for session in self.sessions:
337
+ agent_name = session.name
338
+ history = self._agent_histories.get(agent_name, [])
339
+
340
+ # Truncate name to fit
341
+ display_name = truncate_name(agent_name)
342
+ content.append(f" {display_name} ", style="cyan")
343
+
344
+ green_slots = 0
345
+ total_slots = 0
346
+ if history:
347
+ slot_states = build_timeline_slots(history, width, self.TIMELINE_HOURS, now)
348
+ # Render timeline with colors
349
+ for i in range(width):
350
+ if i in slot_states:
351
+ status = slot_states[i]
352
+ char = agent_status_to_char(status)
353
+ color = get_agent_timeline_color(status)
354
+ content.append(char, style=color)
355
+ total_slots += 1
356
+ if status == "running":
357
+ green_slots += 1
358
+ else:
359
+ content.append("─", style="dim")
360
+ else:
361
+ content.append("─" * width, style="dim")
362
+
363
+ # Show percentage green in last 3 hours
364
+ if total_slots > 0:
365
+ pct = green_slots / total_slots * 100
366
+ pct_style = "bold green" if pct >= 50 else "bold red"
367
+ content.append(f" {pct:>3.0f}%", style=pct_style)
368
+ else:
369
+ content.append(" - ", style="dim")
370
+
371
+ content.append("\n")
372
+
373
+ # Legend (combined on one line to save space)
374
+ content.append(f" {'Legend:':<14} ", style="dim")
375
+ content.append("█", style="green")
376
+ content.append("active/running ", style="dim")
377
+ content.append("▒", style="yellow")
378
+ content.append("inactive ", style="dim")
379
+ content.append("░", style="red")
380
+ content.append("waiting/away ", style="dim")
381
+ content.append("×", style="dim")
382
+ content.append("terminated", style="dim")
383
+
384
+ return content
385
+
386
+
387
+ class HelpOverlay(Static):
388
+ """Help overlay explaining all TUI metrics and controls"""
389
+
390
+ HELP_TEXT = """
391
+ ╔══════════════════════════════════════════════════════════════════════════════╗
392
+ ║ OVERCODE MONITOR HELP ║
393
+ ╠══════════════════════════════════════════════════════════════════════════════╣
394
+ ║ AGENT STATUS LINE ║
395
+ ║ ────────────────────────────────────────────────────────────────────────────║
396
+ ║ 🟢 agent-name repo:branch ↑4.2h ▶ 2.1h ⏸ 2.1h 12i $0.45 ⏱3.2s 🏃 5s║
397
+ ║ │ │ │ │ │ │ │ │ │ │ │ ║
398
+ ║ │ │ │ │ │ │ │ │ │ │ └─ steers: overcode interventions
399
+ ║ │ │ │ │ │ │ │ │ │ └──── mode: 🔥bypass 🏃permissive 👮normal
400
+ ║ │ │ │ │ │ │ │ │ └────────── avg op time (seconds)
401
+ ║ │ │ │ │ │ │ │ └───────────────── estimated cost (USD)
402
+ ║ │ │ │ │ │ │ └────────────────────── interactions (claude turns)
403
+ ║ │ │ │ │ │ └─────────────────────────────── paused time (non-green)
404
+ ║ │ │ │ │ └────────────────────────────────────── active time (green/running)
405
+ ║ │ │ │ └───────────────────────────────────────────── uptime since launch
406
+ ║ │ │ └──────────────────────────────────────────────────────────── git repo:branch
407
+ ║ │ └───────────────────────────────────────────────────────────────────────── agent name
408
+ ║ └───────────────────────────────────────────────────────────────────────────── status (see below)
409
+ ║ ║
410
+ ║ STATUS COLORS ║
411
+ ║ ────────────────────────────────────────────────────────────────────────────║
412
+ ║ 🟢 Running - Agent is actively working ║
413
+ ║ 🟡 No Instruct - Running but no standing instructions set ║
414
+ ║ 🟠 Wait Super - Waiting for overcode supervisor ║
415
+ ║ 🔴 Wait User - Blocked! Needs user input (permission prompt, question) ║
416
+ ║ ⚫ Terminated - Claude exited, shell prompt showing (ready for cleanup) ║
417
+ ║ ║
418
+ ║ DAEMON STATUS LINE ║
419
+ ║ ────────────────────────────────────────────────────────────────────────────║
420
+ ║ Daemon: ● active │ #42 @10s (5s ago) │ sup:3 │ Presence: ● active (3s idle) ║
421
+ ║ │ │ │ │ │ │ │ │ │ │ ║
422
+ ║ │ │ │ │ │ │ │ │ │ └── idle seconds
423
+ ║ │ │ │ │ │ │ │ │ └────────── user state
424
+ ║ │ │ │ │ │ │ │ └───────────── presence logger status
425
+ ║ │ │ │ │ │ │ └──────────────────────────── supervisor launches
426
+ ║ │ │ │ │ │ └───────────────────────────────────────── time since last loop
427
+ ║ │ │ │ │ └────────────────────────────────────────────── current interval
428
+ ║ │ │ │ └────────────────────────────────────────────────── loop count
429
+ ║ │ └──────┴──────────────────────────────────────────────────── daemon status
430
+ ║ └───────────────────────────────────────────────────────────── status indicator
431
+ ║ ║
432
+ ║ KEYBOARD SHORTCUTS ║
433
+ ║ ────────────────────────────────────────────────────────────────────────────║
434
+ ║ q Quit d Toggle daemon panel ║
435
+ ║ h/? Toggle this help t Toggle timeline ║
436
+ ║ v Cycle detail lines s Cycle summary detail ║
437
+ ║ e Expand all agents c Collapse all agents ║
438
+ ║ space Toggle focused agent i/: Focus command bar ║
439
+ ║ n Create new agent x Kill focused agent ║
440
+ ║ click Toggle agent expand/collapse ║
441
+ ║ ║
442
+ ║ COMMAND BAR (i or : to focus) ║
443
+ ║ ────────────────────────────────────────────────────────────────────────────║
444
+ ║ Enter Send instruction Esc Clear & unfocus ║
445
+ ║ Ctrl+E Toggle multi-line Ctrl+O Set as standing order ║
446
+ ║ Ctrl+Enter Send (multi-line) ║
447
+ ║ ║
448
+ ║ DAEMON CONTROLS (work anywhere) ║
449
+ ║ ────────────────────────────────────────────────────────────────────────────║
450
+ ║ [ Start supervisor ] Stop supervisor ║
451
+ ║ \\ Restart monitor d Toggle daemon log panel ║
452
+ ║ ║
453
+ ║ SUMMARY DETAIL LEVELS (s key) ║
454
+ ║ ────────────────────────────────────────────────────────────────────────────║
455
+ ║ low Name, tokens, git changes (Δn files), mode, steers, standing orders ║
456
+ ║ med + uptime, running time, stalled time, latency ║
457
+ ║ full + repo:branch, % active, git diff details (+ins -del) ║
458
+ ║ ║
459
+ ║ Press h or ? to close ║
460
+ ╚══════════════════════════════════════════════════════════════════════════════╝
461
+ """
462
+
463
+ def render(self) -> Text:
464
+ return Text(self.HELP_TEXT.strip())
465
+
466
+
467
+ class DaemonPanel(Static):
468
+ """Inline daemon panel with status and log viewer (like timeline)"""
469
+
470
+ LOG_LINES_TO_SHOW = 8 # Number of log lines to display
471
+
472
+ def __init__(self, tmux_session: str = "agents", *args, **kwargs):
473
+ super().__init__(*args, **kwargs)
474
+ self.tmux_session = tmux_session
475
+ self.log_lines: list[str] = []
476
+ self.monitor_state: Optional[MonitorDaemonState] = None
477
+ self._log_file_pos = 0
478
+
479
+ def on_mount(self) -> None:
480
+ """Start log tailing when mounted"""
481
+ self.set_interval(1.0, self._refresh_logs)
482
+ self._refresh_logs()
483
+
484
+ def _refresh_logs(self) -> None:
485
+ """Refresh daemon status and logs"""
486
+ from pathlib import Path
487
+
488
+ # Only refresh if visible
489
+ if not self.display:
490
+ return
491
+
492
+ # Update daemon state from Monitor Daemon
493
+ self.monitor_state = get_monitor_daemon_state(self.tmux_session)
494
+
495
+ # Read log lines from session-specific monitor_daemon.log
496
+ session_dir = get_session_dir(self.tmux_session)
497
+ log_file = session_dir / "monitor_daemon.log"
498
+ if log_file.exists():
499
+ try:
500
+ with open(log_file, 'r') as f:
501
+ if not self.log_lines:
502
+ # First read: get last 100 lines of file
503
+ all_lines = f.readlines()
504
+ self.log_lines = [l.rstrip() for l in all_lines[-100:]]
505
+ self._log_file_pos = f.tell()
506
+ else:
507
+ # Subsequent reads: only get new content
508
+ f.seek(self._log_file_pos)
509
+ new_content = f.read()
510
+ self._log_file_pos = f.tell()
511
+
512
+ if new_content:
513
+ new_lines = new_content.strip().split('\n')
514
+ self.log_lines.extend(new_lines)
515
+ # Keep last 100 lines
516
+ self.log_lines = self.log_lines[-100:]
517
+ except (OSError, IOError, ValueError):
518
+ # Log file not available, read error, or seek error
519
+ pass
520
+
521
+ self.refresh()
522
+
523
+ def render(self) -> Text:
524
+ """Render daemon panel inline (similar to timeline style)"""
525
+ content = Text()
526
+
527
+ # Header with status - match DaemonStatusBar format exactly
528
+ content.append("🤖 Supervisor Daemon: ", style="bold")
529
+
530
+ # Check Monitor Daemon state
531
+ if self.monitor_state and not self.monitor_state.is_stale():
532
+ state = self.monitor_state
533
+ symbol, style = get_daemon_status_style(state.status)
534
+
535
+ content.append(f"{symbol} ", style=style)
536
+ content.append(f"{state.status}", style=style)
537
+
538
+ # State details
539
+ content.append(" │ ", style="dim")
540
+ content.append(f"#{state.loop_count}", style="cyan")
541
+ content.append(f" @{format_interval(state.current_interval)}", style="dim")
542
+ last_loop = datetime.fromisoformat(state.last_loop_time) if state.last_loop_time else None
543
+ content.append(f" ({format_ago(last_loop)})", style="dim")
544
+ if state.total_supervisions > 0:
545
+ content.append(f" sup:{state.total_supervisions}", style="magenta")
546
+ else:
547
+ # Monitor Daemon not running or stale
548
+ content.append("○ ", style="red")
549
+ content.append("stopped", style="red")
550
+ # Show last activity if available from stale state
551
+ if self.monitor_state and self.monitor_state.last_loop_time:
552
+ try:
553
+ last_time = datetime.fromisoformat(self.monitor_state.last_loop_time)
554
+ content.append(f" (last: {format_ago(last_time)})", style="dim")
555
+ except ValueError:
556
+ pass
557
+
558
+ # Controls hint
559
+ content.append(" │ ", style="dim")
560
+ content.append("[", style="bold green")
561
+ content.append(":sup ", style="dim")
562
+ content.append("]", style="bold red")
563
+ content.append(":sup ", style="dim")
564
+ content.append("\\", style="bold yellow")
565
+ content.append(":mon", style="dim")
566
+
567
+ content.append("\n")
568
+
569
+ # Log lines
570
+ display_lines = self.log_lines[-self.LOG_LINES_TO_SHOW:] if self.log_lines else []
571
+
572
+ if not display_lines:
573
+ content.append(" (no logs yet - daemon may not have run)", style="dim italic")
574
+ content.append("\n")
575
+ else:
576
+ for line in display_lines:
577
+ content.append(" ", style="")
578
+ # Truncate line
579
+ display_line = line[:120] if len(line) > 120 else line
580
+
581
+ # Color based on content
582
+ if "ERROR" in line or "error" in line:
583
+ style = "red"
584
+ elif "WARNING" in line or "warning" in line:
585
+ style = "yellow"
586
+ elif ">>>" in line:
587
+ style = "bold cyan"
588
+ elif "supervising" in line.lower() or "steering" in line.lower():
589
+ style = "magenta"
590
+ elif "Loop" in line:
591
+ style = "dim cyan"
592
+ else:
593
+ style = "dim"
594
+
595
+ content.append(display_line, style=style)
596
+ content.append("\n")
597
+
598
+ return content
599
+
600
+
601
+ class SessionSummary(Static, can_focus=True):
602
+ """Widget displaying expandable session summary"""
603
+
604
+ expanded: reactive[bool] = reactive(True) # Start expanded
605
+ detail_lines: reactive[int] = reactive(5) # Lines of output to show (5, 10, 20, 50)
606
+ summary_detail: reactive[str] = reactive("low") # low, med, full
607
+
608
+ def __init__(self, session: Session, status_detector: StatusDetector, *args, **kwargs):
609
+ super().__init__(*args, **kwargs)
610
+ self.session = session
611
+ self.status_detector = status_detector
612
+ # Initialize from persisted session state, not hardcoded "running"
613
+ self.detected_status = session.stats.current_state if session.stats.current_state else "running"
614
+ self.current_activity = "Initializing..."
615
+ self.pane_content: List[str] = [] # Cached pane content
616
+ self.claude_stats: Optional[ClaudeSessionStats] = None # Token/interaction stats
617
+ self.git_diff_stats: Optional[tuple] = None # (files, insertions, deletions)
618
+ # Start with expanded class since expanded=True by default
619
+ self.add_class("expanded")
620
+
621
+ def on_click(self) -> None:
622
+ """Toggle expanded state on click"""
623
+ self.expanded = not self.expanded
624
+ # Notify parent app to save state
625
+ self.post_message(self.ExpandedChanged(self.session.id, self.expanded))
626
+
627
+ class ExpandedChanged(events.Message):
628
+ """Message sent when expanded state changes"""
629
+ def __init__(self, session_id: str, expanded: bool):
630
+ super().__init__()
631
+ self.session_id = session_id
632
+ self.expanded = expanded
633
+
634
+ def watch_expanded(self, expanded: bool) -> None:
635
+ """Called when expanded state changes"""
636
+ # Toggle CSS class for proper height
637
+ if expanded:
638
+ self.add_class("expanded")
639
+ else:
640
+ self.remove_class("expanded")
641
+ self.refresh(layout=True)
642
+ # Notify parent app to save state
643
+ self.post_message(self.ExpandedChanged(self.session.id, expanded))
644
+
645
+ def watch_detail_lines(self, detail_lines: int) -> None:
646
+ """Called when detail_lines changes - force layout refresh"""
647
+ self.refresh(layout=True)
648
+
649
+ def update_status(self) -> None:
650
+ """Update the detected status for this session.
651
+
652
+ NOTE: This is now VIEW-ONLY. Time tracking is handled by the Monitor Daemon.
653
+ We only detect status for display and capture pane content for the expanded view.
654
+ """
655
+ # detect_status returns (status, activity, pane_content) - reuse content to avoid
656
+ # duplicate tmux subprocess calls (was 2 calls per widget, now just 1)
657
+ new_status, self.current_activity, content = self.status_detector.detect_status(self.session)
658
+ self.apply_status(new_status, self.current_activity, content)
659
+
660
+ def apply_status(self, status: str, activity: str, content: str) -> None:
661
+ """Apply pre-fetched status data to this widget.
662
+
663
+ Used by parallel status updates to apply data fetched in background threads.
664
+ Note: This still fetches claude_stats synchronously - used for single widget updates.
665
+ """
666
+ # Fetch claude stats (only for standalone update_status calls)
667
+ claude_stats = get_session_stats(self.session)
668
+ # Fetch git diff stats
669
+ git_diff = None
670
+ if self.session.start_directory:
671
+ git_diff = get_git_diff_stats(self.session.start_directory)
672
+ self.apply_status_no_refresh(status, activity, content, claude_stats, git_diff)
673
+ self.refresh()
674
+
675
+ def apply_status_no_refresh(self, status: str, activity: str, content: str, claude_stats: Optional[ClaudeSessionStats] = None, git_diff_stats: Optional[tuple] = None) -> None:
676
+ """Apply pre-fetched status data without triggering refresh.
677
+
678
+ Used for batched updates where the caller will refresh once at the end.
679
+ All data including claude_stats should be pre-fetched in background thread.
680
+ """
681
+ self.current_activity = activity
682
+
683
+ # Use pane content from detect_status (already fetched)
684
+ if content:
685
+ # Keep all lines including blanks for proper formatting, just strip trailing blanks
686
+ lines = content.rstrip().split('\n')
687
+ self.pane_content = lines[-50:] if lines else [] # Keep last 50 lines max
688
+ else:
689
+ self.pane_content = []
690
+
691
+ # Update detected status for display
692
+ # NOTE: Time tracking removed - Monitor Daemon is the single source of truth
693
+ # The session.stats values are read from what Monitor Daemon has persisted
694
+ self.detected_status = status
695
+
696
+ # Use pre-fetched claude stats (no file I/O on main thread)
697
+ if claude_stats is not None:
698
+ self.claude_stats = claude_stats
699
+
700
+ # Use pre-fetched git diff stats
701
+ if git_diff_stats is not None:
702
+ self.git_diff_stats = git_diff_stats
703
+
704
+ def watch_summary_detail(self, summary_detail: str) -> None:
705
+ """Called when summary_detail changes"""
706
+ self.refresh()
707
+
708
+ def render(self) -> Text:
709
+ """Render session summary (compact or expanded)"""
710
+ import shutil
711
+ s = self.session
712
+ stats = s.stats
713
+ term_width = shutil.get_terminal_size().columns
714
+
715
+ # Expansion indicator
716
+ expand_icon = "▼" if self.expanded else "▶"
717
+
718
+ # Calculate all values (only use what we need per level)
719
+ uptime = calculate_uptime(self.session.start_time)
720
+ repo_info = f"{s.repo_name or 'n/a'}:{s.branch or 'n/a'}"
721
+ green_time, non_green_time = get_current_state_times(self.session.stats)
722
+
723
+ # Get median work time from claude stats (or 0 if unavailable)
724
+ median_work = self.claude_stats.median_work_time if self.claude_stats else 0.0
725
+
726
+ # Status indicator - larger emoji circles based on detected status
727
+ # Blue background matching Textual header/footer style
728
+ bg = " on #0d2137"
729
+ status_symbol, base_color = get_status_symbol(self.detected_status)
730
+ status_color = f"bold {base_color}{bg}"
731
+
732
+ # Permissiveness mode with emoji
733
+ if s.permissiveness_mode == "bypass":
734
+ perm_emoji = "🔥" # Fire - burning through all permissions
735
+ elif s.permissiveness_mode == "permissive":
736
+ perm_emoji = "🏃" # Running permissively
737
+ else:
738
+ perm_emoji = "👮" # Normal mode with permissions
739
+
740
+ content = Text()
741
+
742
+ # Determine name width based on detail level (more space in lower detail modes)
743
+ if self.summary_detail == "low":
744
+ name_width = 24
745
+ elif self.summary_detail == "med":
746
+ name_width = 20
747
+ else: # full
748
+ name_width = 16
749
+
750
+ # Truncate name if needed
751
+ display_name = s.name[:name_width].ljust(name_width)
752
+
753
+ # Always show: status symbol, time in state, expand icon, agent name
754
+ content.append(f"{status_symbol} ", style=status_color)
755
+
756
+ # Time in current state (directly after status light)
757
+ if stats.state_since:
758
+ try:
759
+ state_start = datetime.fromisoformat(stats.state_since)
760
+ elapsed = (datetime.now() - state_start).total_seconds()
761
+ content.append(f"{format_duration(elapsed):>5} ", style=status_color)
762
+ except (ValueError, TypeError):
763
+ content.append(" - ", style=f"dim{bg}")
764
+ else:
765
+ content.append(" - ", style=f"dim{bg}")
766
+
767
+ # In list-mode, show focus indicator instead of expand icon
768
+ if "list-mode" in self.classes:
769
+ if self.has_focus:
770
+ content.append("→ ", style=status_color)
771
+ else:
772
+ content.append(" ", style=status_color)
773
+ else:
774
+ content.append(f"{expand_icon} ", style=status_color)
775
+ content.append(f"{display_name}", style=f"bold cyan{bg}")
776
+
777
+ # Full detail: add repo:branch (padded to longest across all sessions)
778
+ if self.summary_detail == "full":
779
+ repo_width = getattr(self.app, 'max_repo_info_width', 18)
780
+ content.append(f" {repo_info:<{repo_width}} ", style=f"bold dim{bg}")
781
+
782
+ # Med/Full detail: add uptime, running time, stalled time
783
+ if self.summary_detail in ("med", "full"):
784
+ content.append(f" ↑{uptime:>5}", style=f"bold white{bg}")
785
+ content.append(f" ▶{format_duration(green_time):>5}", style=f"bold green{bg}")
786
+ content.append(f" ⏸{format_duration(non_green_time):>5}", style=f"bold red{bg}")
787
+ # Full detail: show percentage active
788
+ if self.summary_detail == "full":
789
+ total_time = green_time + non_green_time
790
+ pct = (green_time / total_time * 100) if total_time > 0 else 0
791
+ content.append(f" {pct:>3.0f}%", style=f"bold green{bg}" if pct >= 50 else f"bold red{bg}")
792
+
793
+ # Always show: token usage (from Claude Code)
794
+ if self.claude_stats is not None:
795
+ content.append(f" {format_tokens(self.claude_stats.total_tokens):>6}", style=f"bold orange1{bg}")
796
+ else:
797
+ content.append(" -", style=f"dim orange1{bg}")
798
+
799
+ # Git diff stats (outstanding changes since last commit)
800
+ # ALIGNMENT: Use fixed widths - low/med: 4 chars "Δnn ", full: 15 chars "Δnn +nnnn -nnn"
801
+ if self.git_diff_stats:
802
+ files, ins, dels = self.git_diff_stats
803
+ if self.summary_detail == "full":
804
+ # Full: show files and lines with fixed widths
805
+ 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}")
808
+ else:
809
+ # Compact: just files changed (fixed 4 char width)
810
+ content.append(f" Δ{files:>2}", style=f"bold magenta{bg}" if files > 0 else f"dim{bg}")
811
+ else:
812
+ # Placeholder matching width for alignment
813
+ if self.summary_detail == "full":
814
+ content.append(" Δ- + - -", style=f"dim{bg}")
815
+ else:
816
+ content.append(" Δ-", style=f"dim{bg}")
817
+
818
+ # Med/Full detail: add median work time (p50 autonomous work duration)
819
+ if self.summary_detail in ("med", "full"):
820
+ work_str = format_duration(median_work) if median_work > 0 else "0s"
821
+ content.append(f" ⏱{work_str:>5}", style=f"bold blue{bg}")
822
+
823
+ # Always show: permission mode, human interactions, robot supervisions
824
+ content.append(f" {perm_emoji}", style=f"bold white{bg}")
825
+ # Human interaction count = total interactions - robot interventions
826
+ if self.claude_stats is not None:
827
+ human_count = max(0, self.claude_stats.interaction_count - stats.steers_count)
828
+ content.append(f" 👤{human_count:>3}", style=f"bold yellow{bg}")
829
+ else:
830
+ content.append(" 👤 -", style=f"dim yellow{bg}")
831
+ # Robot supervision count (from daemon steers) - 3 digit padding
832
+ content.append(f" 🤖{stats.steers_count:>3}", style=f"bold cyan{bg}")
833
+
834
+ # Standing orders indicator (after supervision count) - always show for alignment
835
+ if s.standing_instructions:
836
+ if s.standing_orders_complete:
837
+ content.append(" ✓", style=f"bold green{bg}")
838
+ elif s.standing_instructions_preset:
839
+ # Show preset name (truncated to fit)
840
+ preset_display = f" {s.standing_instructions_preset[:8]}"
841
+ content.append(preset_display, style=f"bold cyan{bg}")
842
+ else:
843
+ content.append(" 📋", style=f"bold yellow{bg}")
844
+ else:
845
+ content.append(" ➖", style=f"bold dim{bg}") # No instructions indicator
846
+
847
+ if not self.expanded:
848
+ # Compact view: show standing orders or current activity
849
+ content.append(" │ ", style=f"bold dim{bg}")
850
+ # Calculate remaining space for standing orders/activity
851
+ current_len = len(content.plain)
852
+ remaining = max(20, term_width - current_len - 2)
853
+
854
+ if s.standing_instructions:
855
+ # Show standing orders with completion indicator
856
+ if s.standing_orders_complete:
857
+ style = f"bold green{bg}"
858
+ prefix = "✓ "
859
+ elif s.standing_instructions_preset:
860
+ style = f"bold cyan{bg}"
861
+ prefix = f"{s.standing_instructions_preset}: "
862
+ else:
863
+ style = f"bold italic yellow{bg}"
864
+ prefix = ""
865
+ display_text = f"{prefix}{format_standing_instructions(s.standing_instructions, remaining - len(prefix))}"
866
+ content.append(display_text[:remaining], style=style)
867
+ else:
868
+ content.append(self.current_activity[:remaining], style=f"bold italic{bg}")
869
+ # Pad to fill terminal width
870
+ current_len = len(content.plain)
871
+ if current_len < term_width:
872
+ content.append(" " * (term_width - current_len), style=f"{bg}")
873
+ return content
874
+
875
+ # Pad header line to full width before adding expanded content
876
+ current_len = len(content.plain)
877
+ if current_len < term_width:
878
+ content.append(" " * (term_width - current_len), style=f"{bg}")
879
+
880
+ # Expanded view: show standing instructions first if set
881
+ if s.standing_instructions:
882
+ content.append("\n")
883
+ content.append(" ")
884
+ display_instr = format_standing_instructions(s.standing_instructions)
885
+ if s.standing_orders_complete:
886
+ content.append("│ ", style="bold green")
887
+ content.append("✓ ", style="bold green")
888
+ content.append(display_instr, style="green")
889
+ elif s.standing_instructions_preset:
890
+ content.append("│ ", style="cyan")
891
+ content.append(f"{s.standing_instructions_preset}: ", style="bold cyan")
892
+ content.append(display_instr, style="cyan")
893
+ else:
894
+ content.append("│ ", style="cyan")
895
+ content.append("📋 ", style="yellow")
896
+ content.append(display_instr, style="italic yellow")
897
+
898
+ # Expanded view: show pane content based on detail_lines setting
899
+ lines_to_show = self.detail_lines
900
+ # Account for standing instructions line if present
901
+ if s.standing_instructions:
902
+ lines_to_show = max(1, lines_to_show - 1)
903
+
904
+ # Get the last N lines of pane content
905
+ pane_lines = self.pane_content[-lines_to_show:] if self.pane_content else []
906
+
907
+ # Show pane output lines
908
+ for line in pane_lines:
909
+ content.append("\n")
910
+ content.append(" ") # Indent
911
+ # Truncate long lines and style based on content
912
+ display_line = line[:100] + "..." if len(line) > 100 else line
913
+ prefix_style, content_style = style_pane_line(line)
914
+ content.append("│ ", style=prefix_style)
915
+ content.append(display_line, style=content_style)
916
+
917
+ # If no pane content and no standing instructions shown above, show placeholder
918
+ if not pane_lines and not s.standing_instructions:
919
+ content.append("\n")
920
+ content.append(" ") # Indent
921
+ content.append("│ ", style="cyan")
922
+ content.append("(no output)", style="dim italic")
923
+
924
+ return content
925
+
926
+
927
+ class PreviewPane(Static):
928
+ """Preview pane showing focused agent's terminal output in list+preview mode."""
929
+
930
+ content_lines: reactive[List[str]] = reactive(list, init=False)
931
+ session_name: str = ""
932
+
933
+ def __init__(self, **kwargs):
934
+ super().__init__(**kwargs)
935
+ self.content_lines = []
936
+
937
+ def render(self) -> Text:
938
+ content = Text()
939
+ # Header with session name
940
+ header = f"─── {self.session_name} " if self.session_name else "─── Preview "
941
+ content.append(header, style="bold cyan")
942
+ content.append("─" * max(0, 60 - len(header)), style="dim")
943
+ content.append("\n")
944
+
945
+ if not self.content_lines:
946
+ content.append("(no output)", style="dim italic")
947
+ 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
952
+ content.append(display_line + "\n")
953
+
954
+ return content
955
+
956
+ def update_from_widget(self, widget: "SessionSummary") -> None:
957
+ """Update preview content from a SessionSummary widget."""
958
+ self.session_name = widget.session.name
959
+ self.content_lines = list(widget.pane_content) if widget.pane_content else []
960
+ self.refresh()
961
+
962
+
963
+ class CommandBar(Static):
964
+ """Inline command bar for sending instructions to agents.
965
+
966
+ Supports single-line (Input) and multi-line (TextArea) modes.
967
+ Toggle with Ctrl+E. Send with Enter (single) or Ctrl+Enter (multi).
968
+ Use Ctrl+O to set as standing order instead of sending.
969
+
970
+ Modes:
971
+ - "send": Default mode for sending instructions to an agent
972
+ - "standing_orders": Mode for editing standing orders for an agent
973
+ - "new_agent_dir": First step of new agent creation - enter working directory
974
+ - "new_agent_name": Second step of new agent creation - enter agent name
975
+ - "new_agent_perms": Third step of new agent creation - choose permission mode
976
+
977
+ Key handling is done via on_key() since Input/TextArea consume most keys.
978
+ """
979
+
980
+ expanded = reactive(False) # Toggle single/multi-line mode
981
+ target_session: Optional[str] = None
982
+ mode: str = "send" # "send", "standing_orders", "new_agent_dir", "new_agent_name", or "new_agent_perms"
983
+ new_agent_dir: Optional[str] = None # Store directory between steps
984
+ new_agent_name: Optional[str] = None # Store name between steps
985
+
986
+ class SendRequested(Message):
987
+ """Message sent when user wants to send text to a session."""
988
+ def __init__(self, session_name: str, text: str):
989
+ super().__init__()
990
+ self.session_name = session_name
991
+ self.text = text
992
+
993
+ class StandingOrderRequested(Message):
994
+ """Message sent when user wants to set a standing order."""
995
+ def __init__(self, session_name: str, text: str):
996
+ super().__init__()
997
+ self.session_name = session_name
998
+ self.text = text
999
+
1000
+ class NewAgentRequested(Message):
1001
+ """Message sent when user wants to create a new agent."""
1002
+ def __init__(self, agent_name: str, directory: Optional[str] = None, bypass_permissions: bool = False):
1003
+ super().__init__()
1004
+ self.agent_name = agent_name
1005
+ self.directory = directory
1006
+ self.bypass_permissions = bypass_permissions
1007
+
1008
+ def compose(self) -> ComposeResult:
1009
+ """Create command bar widgets."""
1010
+ with Horizontal(id="cmd-bar-container"):
1011
+ yield Label("", id="target-label")
1012
+ yield Input(id="cmd-input", placeholder="Type instruction (Enter to send)...", disabled=True)
1013
+ yield TextArea(id="cmd-textarea", classes="hidden", disabled=True)
1014
+ yield Label("[^E]", id="expand-hint")
1015
+
1016
+ def on_mount(self) -> None:
1017
+ """Initialize command bar state."""
1018
+ self._update_target_label()
1019
+ # Ensure widgets start disabled to prevent auto-focus
1020
+ self.query_one("#cmd-input", Input).disabled = True
1021
+ self.query_one("#cmd-textarea", TextArea).disabled = True
1022
+
1023
+ def _update_target_label(self) -> None:
1024
+ """Update the target session label based on mode."""
1025
+ label = self.query_one("#target-label", Label)
1026
+ input_widget = self.query_one("#cmd-input", Input)
1027
+
1028
+ if self.mode == "new_agent_dir":
1029
+ label.update("[New Agent: Directory] ")
1030
+ input_widget.placeholder = "Enter working directory path..."
1031
+ elif self.mode == "new_agent_name":
1032
+ label.update("[New Agent: Name] ")
1033
+ input_widget.placeholder = "Enter agent name (or Enter to accept default)..."
1034
+ elif self.mode == "new_agent_perms":
1035
+ label.update("[New Agent: Permissions] ")
1036
+ input_widget.placeholder = "Type 'bypass' for --dangerously-skip-permissions, or Enter for normal..."
1037
+ elif self.mode == "standing_orders":
1038
+ if self.target_session:
1039
+ label.update(f"[{self.target_session} Standing Orders] ")
1040
+ else:
1041
+ label.update("[Standing Orders] ")
1042
+ input_widget.placeholder = "Enter standing orders (or empty to clear)..."
1043
+ elif self.target_session:
1044
+ label.update(f"[{self.target_session}] ")
1045
+ input_widget.placeholder = "Type instruction (Enter to send)..."
1046
+ else:
1047
+ label.update("[no session] ")
1048
+ input_widget.placeholder = "Type instruction (Enter to send)..."
1049
+
1050
+ def set_target(self, session_name: Optional[str]) -> None:
1051
+ """Set the target session for commands."""
1052
+ self.target_session = session_name
1053
+ self.mode = "send" # Reset to send mode when target changes
1054
+ self._update_target_label()
1055
+
1056
+ def set_mode(self, mode: str) -> None:
1057
+ """Set the command bar mode ('send' or 'new_agent')."""
1058
+ self.mode = mode
1059
+ self._update_target_label()
1060
+
1061
+ def watch_expanded(self, expanded: bool) -> None:
1062
+ """Toggle between single-line and multi-line mode."""
1063
+ input_widget = self.query_one("#cmd-input", Input)
1064
+ textarea = self.query_one("#cmd-textarea", TextArea)
1065
+
1066
+ if expanded:
1067
+ # Switch to multi-line
1068
+ input_widget.add_class("hidden")
1069
+ input_widget.disabled = True
1070
+ textarea.remove_class("hidden")
1071
+ textarea.disabled = False
1072
+ # Transfer content
1073
+ textarea.text = input_widget.value
1074
+ input_widget.value = ""
1075
+ textarea.focus()
1076
+ else:
1077
+ # Switch to single-line
1078
+ textarea.add_class("hidden")
1079
+ textarea.disabled = True
1080
+ input_widget.remove_class("hidden")
1081
+ input_widget.disabled = False
1082
+ # Transfer content (first line only for single-line)
1083
+ if textarea.text:
1084
+ first_line = textarea.text.split('\n')[0]
1085
+ input_widget.value = first_line
1086
+ textarea.text = ""
1087
+ input_widget.focus()
1088
+
1089
+ def on_key(self, event: events.Key) -> None:
1090
+ """Handle key events for command bar shortcuts."""
1091
+ if event.key == "ctrl+e":
1092
+ self.action_toggle_expand()
1093
+ event.stop()
1094
+ elif event.key == "ctrl+o":
1095
+ self.action_set_standing_order()
1096
+ event.stop()
1097
+ elif event.key == "escape":
1098
+ self.action_clear_and_unfocus()
1099
+ event.stop()
1100
+ elif event.key == "ctrl+enter" and self.expanded:
1101
+ self.action_send_multiline()
1102
+ event.stop()
1103
+
1104
+ def on_input_submitted(self, event: Input.Submitted) -> None:
1105
+ """Handle Enter in single-line mode."""
1106
+ if event.input.id == "cmd-input":
1107
+ text = event.value.strip()
1108
+
1109
+ if self.mode == "new_agent_dir":
1110
+ # Step 1: Directory entered, validate and move to name step
1111
+ # Note: _handle_new_agent_dir sets input value to default name, don't clear it
1112
+ self._handle_new_agent_dir(text if text else None)
1113
+ return
1114
+ elif self.mode == "new_agent_name":
1115
+ # Step 2: Name entered (or default accepted), move to permissions step
1116
+ # If empty, use the pre-filled default
1117
+ name = text if text else event.input.value.strip()
1118
+ if not name:
1119
+ # Derive from directory as fallback
1120
+ from pathlib import Path
1121
+ name = Path(self.new_agent_dir).name if self.new_agent_dir else "agent"
1122
+ self._handle_new_agent_name(name)
1123
+ event.input.value = ""
1124
+ return
1125
+ elif self.mode == "new_agent_perms":
1126
+ # Step 3: Permissions chosen, create agent
1127
+ bypass = text.lower().strip() in ("bypass", "y", "yes", "!")
1128
+ self._create_new_agent(self.new_agent_name, bypass)
1129
+ event.input.value = ""
1130
+ self.action_clear_and_unfocus()
1131
+ return
1132
+ elif self.mode == "standing_orders":
1133
+ # Set standing orders (empty string clears them)
1134
+ self._set_standing_order(text)
1135
+ event.input.value = ""
1136
+ self.action_clear_and_unfocus()
1137
+ return
1138
+
1139
+ # Default "send" mode
1140
+ if not text:
1141
+ return
1142
+ self._send_message(text)
1143
+ event.input.value = ""
1144
+ self.action_clear_and_unfocus()
1145
+
1146
+ def _send_message(self, text: str) -> None:
1147
+ """Send message to target session."""
1148
+ if not self.target_session or not text.strip():
1149
+ return
1150
+ self.post_message(self.SendRequested(self.target_session, text.strip()))
1151
+
1152
+ def _handle_new_agent_dir(self, directory: Optional[str]) -> None:
1153
+ """Handle directory input for new agent creation.
1154
+
1155
+ Validates directory and transitions to name input step.
1156
+ """
1157
+ from pathlib import Path
1158
+
1159
+ # Expand ~ and resolve path
1160
+ if directory:
1161
+ dir_path = Path(directory).expanduser().resolve()
1162
+ if not dir_path.exists():
1163
+ # Try to create it or warn
1164
+ self.app.notify(f"Directory does not exist: {dir_path}", severity="warning")
1165
+ return
1166
+ if not dir_path.is_dir():
1167
+ self.app.notify(f"Not a directory: {dir_path}", severity="error")
1168
+ return
1169
+ self.new_agent_dir = str(dir_path)
1170
+ else:
1171
+ # Use current working directory if none specified
1172
+ self.new_agent_dir = str(Path.cwd())
1173
+
1174
+ # Derive default agent name from directory basename
1175
+ default_name = Path(self.new_agent_dir).name
1176
+
1177
+ # Transition to name step
1178
+ self.mode = "new_agent_name"
1179
+ self._update_target_label()
1180
+
1181
+ # Pre-fill the input with the default name
1182
+ input_widget = self.query_one("#cmd-input", Input)
1183
+ input_widget.value = default_name
1184
+
1185
+ def _handle_new_agent_name(self, name: str) -> None:
1186
+ """Handle name input for new agent creation.
1187
+
1188
+ Stores the name and transitions to permissions step.
1189
+ """
1190
+ self.new_agent_name = name
1191
+
1192
+ # Transition to permissions step
1193
+ self.mode = "new_agent_perms"
1194
+ self._update_target_label()
1195
+
1196
+ def _create_new_agent(self, name: str, bypass_permissions: bool = False) -> None:
1197
+ """Create a new agent with the given name, directory, and permission mode."""
1198
+ self.post_message(self.NewAgentRequested(name, self.new_agent_dir, bypass_permissions))
1199
+ # Reset state
1200
+ self.new_agent_dir = None
1201
+ self.new_agent_name = None
1202
+ self.mode = "send"
1203
+ self._update_target_label()
1204
+
1205
+ def _set_standing_order(self, text: str) -> None:
1206
+ """Set text as standing order."""
1207
+ if not self.target_session or not text.strip():
1208
+ return
1209
+ self.post_message(self.StandingOrderRequested(self.target_session, text.strip()))
1210
+
1211
+ def action_toggle_expand(self) -> None:
1212
+ """Toggle between single and multi-line mode."""
1213
+ self.expanded = not self.expanded
1214
+
1215
+ def action_send_multiline(self) -> None:
1216
+ """Send content from multi-line textarea."""
1217
+ textarea = self.query_one("#cmd-textarea", TextArea)
1218
+ self._send_message(textarea.text)
1219
+ textarea.text = ""
1220
+ self.action_clear_and_unfocus()
1221
+
1222
+ def action_set_standing_order(self) -> None:
1223
+ """Set current content as standing order."""
1224
+ if self.expanded:
1225
+ textarea = self.query_one("#cmd-textarea", TextArea)
1226
+ self._set_standing_order(textarea.text)
1227
+ textarea.text = ""
1228
+ else:
1229
+ input_widget = self.query_one("#cmd-input", Input)
1230
+ self._set_standing_order(input_widget.value)
1231
+ input_widget.value = ""
1232
+
1233
+ def action_clear_and_unfocus(self) -> None:
1234
+ """Clear input and unfocus command bar."""
1235
+ if self.expanded:
1236
+ textarea = self.query_one("#cmd-textarea", TextArea)
1237
+ textarea.text = ""
1238
+ else:
1239
+ input_widget = self.query_one("#cmd-input", Input)
1240
+ input_widget.value = ""
1241
+ # Reset mode and state
1242
+ self.mode = "send"
1243
+ self.new_agent_dir = None
1244
+ self.new_agent_name = None
1245
+ self._update_target_label()
1246
+ # Let parent handle unfocus
1247
+ self.post_message(self.ClearRequested())
1248
+
1249
+ def focus_input(self) -> None:
1250
+ """Focus the command bar input and enable it."""
1251
+ input_widget = self.query_one("#cmd-input", Input)
1252
+ input_widget.disabled = False
1253
+ input_widget.focus()
1254
+
1255
+ class ClearRequested(Message):
1256
+ """Message sent when user clears the command bar."""
1257
+ pass
1258
+
1259
+
1260
+ class SupervisorTUI(App):
1261
+ """Overcode Supervisor TUI"""
1262
+
1263
+ # Disable any size restrictions
1264
+ AUTO_FOCUS = None
1265
+
1266
+ CSS = """
1267
+ Screen {
1268
+ background: $background;
1269
+ overflow: hidden;
1270
+ height: 100%;
1271
+ }
1272
+
1273
+ Header {
1274
+ dock: top;
1275
+ height: 1;
1276
+ }
1277
+
1278
+ #daemon-status {
1279
+ height: 1;
1280
+ width: 100%;
1281
+ background: $panel;
1282
+ padding: 0 1;
1283
+ }
1284
+
1285
+ #timeline {
1286
+ height: auto;
1287
+ min-height: 4;
1288
+ max-height: 20;
1289
+ width: 100%;
1290
+ background: $surface;
1291
+ padding: 0 1;
1292
+ border-bottom: solid $panel;
1293
+ }
1294
+
1295
+ #sessions-container {
1296
+ height: 1fr;
1297
+ width: 100%;
1298
+ overflow: auto auto;
1299
+ padding: 0;
1300
+ }
1301
+
1302
+ /* In list+preview mode, sessions container is compact (auto-size to content) */
1303
+ #sessions-container.list-mode {
1304
+ height: auto;
1305
+ max-height: 30%;
1306
+ }
1307
+
1308
+ SessionSummary {
1309
+ height: 1;
1310
+ width: 100%;
1311
+ padding: 0 1;
1312
+ margin: 0;
1313
+ border: none;
1314
+ background: $surface;
1315
+ overflow: hidden;
1316
+ }
1317
+
1318
+ SessionSummary.expanded {
1319
+ height: auto;
1320
+ min-height: 2;
1321
+ max-height: 55; /* Support up to 50 lines detail + header/instructions */
1322
+ background: #1c1c1c;
1323
+ border-bottom: solid #5588aa;
1324
+ }
1325
+
1326
+ SessionSummary:hover {
1327
+ background: $boost;
1328
+ }
1329
+
1330
+ SessionSummary:focus {
1331
+ background: #2d4a5a;
1332
+ text-style: bold;
1333
+ }
1334
+
1335
+ #help-text {
1336
+ dock: bottom;
1337
+ height: 1;
1338
+ width: 100%;
1339
+ background: $panel;
1340
+ color: $text-muted;
1341
+ padding: 0 1;
1342
+ }
1343
+
1344
+ #help-overlay {
1345
+ display: none;
1346
+ layer: above;
1347
+ dock: top;
1348
+ width: 100%;
1349
+ height: 100%;
1350
+ background: $surface 90%;
1351
+ padding: 1 2;
1352
+ overflow-y: auto;
1353
+ }
1354
+
1355
+ #help-overlay.visible {
1356
+ display: block;
1357
+ }
1358
+
1359
+ #daemon-panel {
1360
+ display: none;
1361
+ height: auto;
1362
+ min-height: 2;
1363
+ max-height: 12;
1364
+ width: 100%;
1365
+ background: $surface;
1366
+ padding: 0 1;
1367
+ border-bottom: solid $panel;
1368
+ }
1369
+
1370
+ CommandBar {
1371
+ dock: bottom;
1372
+ height: auto;
1373
+ min-height: 1;
1374
+ max-height: 8;
1375
+ width: 100%;
1376
+ background: $surface;
1377
+ border-top: solid $primary;
1378
+ padding: 0 1;
1379
+ display: none; /* Hidden by default, shown with 'i' key */
1380
+ }
1381
+
1382
+ CommandBar.visible {
1383
+ display: block;
1384
+ }
1385
+
1386
+ #cmd-bar-container {
1387
+ width: 100%;
1388
+ height: auto;
1389
+ }
1390
+
1391
+ #target-label {
1392
+ width: auto;
1393
+ color: $primary;
1394
+ text-style: bold;
1395
+ }
1396
+
1397
+ #cmd-input {
1398
+ width: 1fr;
1399
+ min-width: 20;
1400
+ }
1401
+
1402
+ #cmd-input.hidden {
1403
+ display: none;
1404
+ }
1405
+
1406
+ #cmd-textarea {
1407
+ width: 1fr;
1408
+ min-width: 20;
1409
+ height: 4;
1410
+ }
1411
+
1412
+ #cmd-textarea.hidden {
1413
+ display: none;
1414
+ }
1415
+
1416
+ #expand-hint {
1417
+ width: auto;
1418
+ color: $text-muted;
1419
+ padding-left: 1;
1420
+ }
1421
+
1422
+ /* List mode - always collapsed */
1423
+ /* List mode: compact single-line, no borders/dividers */
1424
+ SessionSummary.list-mode {
1425
+ height: 1;
1426
+ border: none;
1427
+ margin: 0;
1428
+ padding: 0 1;
1429
+ }
1430
+
1431
+ /* Preview pane - hidden by default, shown via .visible class */
1432
+ #preview-pane {
1433
+ display: none;
1434
+ height: 1fr;
1435
+ border-top: solid $primary;
1436
+ padding: 0 1;
1437
+ background: $surface;
1438
+ overflow-y: auto;
1439
+ }
1440
+
1441
+ #preview-pane.visible {
1442
+ display: block;
1443
+ }
1444
+
1445
+ /* Focused indicator in list mode */
1446
+ SessionSummary:focus.list-mode {
1447
+ background: $accent;
1448
+ }
1449
+ """
1450
+
1451
+ BINDINGS = [
1452
+ ("q", "quit", "Quit"),
1453
+ ("h", "toggle_help", "Help"),
1454
+ ("question_mark", "toggle_help", "Help"),
1455
+ ("d", "toggle_daemon", "Daemon panel"),
1456
+ ("t", "toggle_timeline", "Toggle timeline"),
1457
+ ("v", "cycle_detail", "Cycle detail"),
1458
+ ("s", "cycle_summary", "Summary detail"),
1459
+ ("e", "expand_all", "Expand all"),
1460
+ ("c", "collapse_all", "Collapse all"),
1461
+ ("space", "toggle_focused", "Toggle"),
1462
+ # Navigation between agents
1463
+ ("j", "focus_next_session", "Next"),
1464
+ ("k", "focus_previous_session", "Prev"),
1465
+ ("down", "focus_next_session", "Next"),
1466
+ ("up", "focus_previous_session", "Prev"),
1467
+ # View mode toggle
1468
+ ("m", "toggle_view_mode", "Mode"),
1469
+ # Command bar (send instructions to agents)
1470
+ ("i", "focus_command_bar", "Send"),
1471
+ ("colon", "focus_command_bar", "Send"),
1472
+ ("o", "focus_standing_orders", "Standing orders"),
1473
+ # Daemon controls (simple keys that work everywhere)
1474
+ ("left_square_bracket", "supervisor_start", "Start supervisor"),
1475
+ ("right_square_bracket", "supervisor_stop", "Stop supervisor"),
1476
+ ("backslash", "monitor_restart", "Restart monitor"),
1477
+ # Manual refresh (useful in diagnostics mode)
1478
+ ("r", "manual_refresh", "Refresh"),
1479
+ # Agent management
1480
+ ("x", "kill_focused", "Kill agent"),
1481
+ ("n", "new_agent", "New agent"),
1482
+ # Send Enter to focused agent (for approvals)
1483
+ ("enter", "send_enter_to_focused", "Send Enter"),
1484
+ # Send number keys 1-5 to focused agent (for numbered prompts)
1485
+ ("1", "send_1_to_focused", "Send 1"),
1486
+ ("2", "send_2_to_focused", "Send 2"),
1487
+ ("3", "send_3_to_focused", "Send 3"),
1488
+ ("4", "send_4_to_focused", "Send 4"),
1489
+ ("5", "send_5_to_focused", "Send 5"),
1490
+ # Copy mode - disable mouse capture for native terminal selection
1491
+ ("y", "toggle_copy_mode", "Copy mode"),
1492
+ ]
1493
+
1494
+ # Detail level cycles through 5, 10, 20, 50 lines
1495
+ DETAIL_LEVELS = [5, 10, 20, 50]
1496
+ # Summary detail levels: low (minimal), med (timing), full (all + repo)
1497
+ SUMMARY_LEVELS = ["low", "med", "full"]
1498
+
1499
+ sessions: reactive[List[Session]] = reactive(list)
1500
+ view_mode: reactive[str] = reactive("tree") # "tree" or "list_preview"
1501
+
1502
+ def __init__(self, tmux_session: str = "agents", diagnostics: bool = False):
1503
+ super().__init__()
1504
+ self.tmux_session = tmux_session
1505
+ self.diagnostics = diagnostics # Disable all auto-refresh timers
1506
+ self.session_manager = SessionManager()
1507
+ self.launcher = ClaudeLauncher(tmux_session)
1508
+ self.status_detector = StatusDetector(tmux_session)
1509
+ # Track expanded state per session ID to preserve across refreshes
1510
+ self.expanded_states: dict[str, bool] = {}
1511
+ # Max repo:branch width for alignment in full detail mode
1512
+ self.max_repo_info_width: int = 18
1513
+
1514
+ # Load persisted TUI preferences
1515
+ self._prefs = TUIPreferences.load(tmux_session)
1516
+
1517
+ # Current detail level index (cycles through DETAIL_LEVELS)
1518
+ # Initialize from saved preferences
1519
+ try:
1520
+ self.detail_level_index = self.DETAIL_LEVELS.index(self._prefs.detail_lines)
1521
+ except ValueError:
1522
+ self.detail_level_index = 0 # Default to 5 lines
1523
+
1524
+ # Current summary detail level index (cycles through SUMMARY_LEVELS)
1525
+ # Initialize from saved preferences
1526
+ try:
1527
+ self.summary_level_index = self.SUMMARY_LEVELS.index(self._prefs.summary_detail)
1528
+ except ValueError:
1529
+ self.summary_level_index = 0 # Default to "low"
1530
+
1531
+ # Track focused session for navigation
1532
+ self.focused_session_index = 0
1533
+ # Session cache to avoid disk I/O on every status update (250ms interval)
1534
+ self._sessions_cache: dict[str, Session] = {}
1535
+ self._sessions_cache_time: float = 0
1536
+ self._sessions_cache_ttl: float = 1.0 # 1 second TTL
1537
+ # Flag to prevent overlapping async status updates
1538
+ self._status_update_in_progress = False
1539
+ # Track if we've warned about multiple daemons (to avoid spam)
1540
+ self._multiple_daemon_warning_shown = False
1541
+
1542
+ def compose(self) -> ComposeResult:
1543
+ """Create child widgets"""
1544
+ yield Header(show_clock=True)
1545
+ yield DaemonStatusBar(tmux_session=self.tmux_session, id="daemon-status")
1546
+ yield StatusTimeline([], id="timeline")
1547
+ yield DaemonPanel(tmux_session=self.tmux_session, id="daemon-panel")
1548
+ yield ScrollableContainer(id="sessions-container")
1549
+ yield PreviewPane(id="preview-pane")
1550
+ yield CommandBar(id="command-bar")
1551
+ yield HelpOverlay(id="help-overlay")
1552
+ 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",
1554
+ id="help-text"
1555
+ )
1556
+
1557
+ def on_mount(self) -> None:
1558
+ """Called when app starts"""
1559
+ self.title = "Overcode Monitor"
1560
+ self._update_subtitle()
1561
+
1562
+ # Auto-start Monitor Daemon if not running
1563
+ self._ensure_monitor_daemon()
1564
+
1565
+ # Disable command bar inputs to prevent auto-focus capture
1566
+ try:
1567
+ cmd_bar = self.query_one("#command-bar", CommandBar)
1568
+ cmd_bar.query_one("#cmd-input", Input).disabled = True
1569
+ cmd_bar.query_one("#cmd-textarea", TextArea).disabled = True
1570
+ # Clear any focus from the command bar
1571
+ self.set_focus(None)
1572
+ except NoMatches:
1573
+ pass
1574
+
1575
+ # Apply persisted preferences
1576
+ try:
1577
+ timeline = self.query_one("#timeline", StatusTimeline)
1578
+ timeline.display = self._prefs.timeline_visible
1579
+ except NoMatches:
1580
+ pass
1581
+
1582
+ try:
1583
+ daemon_panel = self.query_one("#daemon-panel", DaemonPanel)
1584
+ daemon_panel.display = self._prefs.daemon_panel_visible
1585
+ except NoMatches:
1586
+ pass
1587
+
1588
+ # Set view_mode from preferences (triggers watch_view_mode)
1589
+ self.view_mode = self._prefs.view_mode
1590
+
1591
+ self.refresh_sessions()
1592
+ self.update_daemon_status()
1593
+ self.update_timeline()
1594
+ # Schedule initial status fetch after widgets are mounted (small delay ensures DOM is ready)
1595
+ self.set_timer(0.1, self.update_all_statuses)
1596
+ # Select first agent for preview pane (slightly longer delay to ensure widgets exist)
1597
+ self.set_timer(0.2, self._select_first_agent)
1598
+
1599
+ if self.diagnostics:
1600
+ # DIAGNOSTICS MODE: No auto-refresh timers
1601
+ self._update_subtitle() # Will include [DIAGNOSTICS]
1602
+ self.notify(
1603
+ "DIAGNOSTICS MODE: All auto-refresh disabled. Press 'r' to manually refresh.",
1604
+ severity="warning",
1605
+ timeout=10
1606
+ )
1607
+ else:
1608
+ # Normal mode: Set up all timers
1609
+ # Refresh session list every 10 seconds
1610
+ self.set_interval(10, self.refresh_sessions)
1611
+ # Update status very frequently for real-time detail view
1612
+ self.set_interval(0.25, self.update_all_statuses)
1613
+ # Update daemon status every 5 seconds
1614
+ self.set_interval(5, self.update_daemon_status)
1615
+ # Update timeline every 30 seconds
1616
+ self.set_interval(30, self.update_timeline)
1617
+
1618
+ def update_daemon_status(self) -> None:
1619
+ """Update daemon status bar"""
1620
+ try:
1621
+ daemon_bar = self.query_one("#daemon-status", DaemonStatusBar)
1622
+ daemon_bar.update_status()
1623
+ except NoMatches:
1624
+ pass
1625
+
1626
+ # Check for multiple daemon processes (potential time tracking bug)
1627
+ daemon_count = count_daemon_processes("monitor_daemon")
1628
+ if daemon_count > 1 and not self._multiple_daemon_warning_shown:
1629
+ self._multiple_daemon_warning_shown = True
1630
+ self.notify(
1631
+ f"WARNING: {daemon_count} monitor daemons detected! "
1632
+ "This causes time tracking bugs. Press \\ to restart daemon.",
1633
+ severity="error",
1634
+ timeout=30
1635
+ )
1636
+ elif daemon_count <= 1:
1637
+ # Reset warning flag when back to normal
1638
+ self._multiple_daemon_warning_shown = False
1639
+
1640
+ def update_timeline(self) -> None:
1641
+ """Update the status timeline widget"""
1642
+ try:
1643
+ timeline = self.query_one("#timeline", StatusTimeline)
1644
+ timeline.update_history(self.sessions)
1645
+ except NoMatches:
1646
+ pass
1647
+
1648
+ def _save_prefs(self) -> None:
1649
+ """Save current TUI preferences to disk."""
1650
+ self._prefs.save(self.tmux_session)
1651
+
1652
+ def action_toggle_timeline(self) -> None:
1653
+ """Toggle timeline visibility"""
1654
+ try:
1655
+ timeline = self.query_one("#timeline", StatusTimeline)
1656
+ timeline.display = not timeline.display
1657
+ self._prefs.timeline_visible = timeline.display
1658
+ self._save_prefs()
1659
+ state = "shown" if timeline.display else "hidden"
1660
+ self.notify(f"Timeline {state}", severity="information")
1661
+ except NoMatches:
1662
+ pass
1663
+
1664
+ def action_toggle_help(self) -> None:
1665
+ """Toggle help overlay visibility"""
1666
+ try:
1667
+ help_overlay = self.query_one("#help-overlay", HelpOverlay)
1668
+ if help_overlay.has_class("visible"):
1669
+ help_overlay.remove_class("visible")
1670
+ else:
1671
+ help_overlay.add_class("visible")
1672
+ except NoMatches:
1673
+ pass
1674
+
1675
+ def action_manual_refresh(self) -> None:
1676
+ """Manually trigger a full refresh (useful in diagnostics mode)"""
1677
+ self.refresh_sessions()
1678
+ self.update_all_statuses()
1679
+ self.update_daemon_status()
1680
+ self.update_timeline()
1681
+ self.notify("Refreshed", severity="information", timeout=2)
1682
+
1683
+ def on_resize(self) -> None:
1684
+ """Handle terminal resize events"""
1685
+ self.refresh()
1686
+ self.update_session_widgets()
1687
+
1688
+ def refresh_sessions(self) -> None:
1689
+ """Refresh session list (checks for new/removed sessions)
1690
+
1691
+ Uses launcher.list_sessions() to detect terminated sessions
1692
+ (tmux windows that no longer exist, e.g., after machine reboot).
1693
+ """
1694
+ self._invalidate_sessions_cache() # Force cache refresh
1695
+ self.sessions = self.launcher.list_sessions()
1696
+ # Calculate max repo:branch width for alignment in full detail mode
1697
+ self.max_repo_info_width = max(
1698
+ (len(f"{s.repo_name or 'n/a'}:{s.branch or 'n/a'}") for s in self.sessions),
1699
+ default=18
1700
+ )
1701
+ self.max_repo_info_width = max(self.max_repo_info_width, 10) # Minimum 10 chars
1702
+ self.update_session_widgets()
1703
+ # NOTE: Don't call update_timeline() here - it has its own 30s interval
1704
+ # and reading log files during session refresh causes UI stutter
1705
+
1706
+ def _get_cached_sessions(self) -> dict[str, Session]:
1707
+ """Get sessions with caching to reduce disk I/O.
1708
+
1709
+ Returns cached session data if TTL hasn't expired, otherwise
1710
+ reloads from disk and updates the cache.
1711
+ """
1712
+ import time
1713
+ now = time.time()
1714
+ if now - self._sessions_cache_time > self._sessions_cache_ttl:
1715
+ # Cache expired, reload from disk
1716
+ self._sessions_cache = {s.id: s for s in self.session_manager.list_sessions()}
1717
+ self._sessions_cache_time = now
1718
+ return self._sessions_cache
1719
+
1720
+ def _invalidate_sessions_cache(self) -> None:
1721
+ """Invalidate the sessions cache to force reload on next access."""
1722
+ self._sessions_cache_time = 0
1723
+
1724
+ def update_all_statuses(self) -> None:
1725
+ """Trigger async status update for all session widgets.
1726
+
1727
+ This is NON-BLOCKING - it kicks off a background worker that fetches
1728
+ all statuses in parallel, then updates widgets when done.
1729
+ """
1730
+ # Skip if an update is already in progress
1731
+ if self._status_update_in_progress:
1732
+ return
1733
+ self._status_update_in_progress = True
1734
+
1735
+ # Gather widget info needed for the background fetch
1736
+ widgets = list(self.query(SessionSummary))
1737
+ if not widgets:
1738
+ self._status_update_in_progress = False
1739
+ return
1740
+
1741
+ # Kick off async status fetch
1742
+ self._fetch_statuses_async(widgets)
1743
+
1744
+ @work(thread=True, exclusive=True)
1745
+ def _fetch_statuses_async(self, widgets: list) -> None:
1746
+ """Fetch all statuses in background thread, then update UI.
1747
+
1748
+ Uses ThreadPoolExecutor to parallelize tmux calls within the worker.
1749
+ The @work decorator runs this in a background thread so it doesn't
1750
+ block the main event loop.
1751
+ """
1752
+ try:
1753
+ # Load fresh session data (this does file I/O but we're in a thread)
1754
+ fresh_sessions = {s.id: s for s in self.session_manager.list_sessions()}
1755
+
1756
+ # Build list of sessions to check (use fresh data if available)
1757
+ sessions_to_check = []
1758
+ for widget in widgets:
1759
+ session = fresh_sessions.get(widget.session.id, widget.session)
1760
+ sessions_to_check.append((widget.session.id, session))
1761
+
1762
+ # Fetch all statuses AND claude stats AND git diff stats in parallel
1763
+ def fetch_all(session):
1764
+ """Fetch status, stats, and git diff for a session (runs in thread pool)."""
1765
+ try:
1766
+ # For terminated sessions, return status directly without checking tmux
1767
+ if session.status == "terminated":
1768
+ status_result = ("terminated", "(tmux window no longer exists)", "")
1769
+ else:
1770
+ status_result = self.status_detector.detect_status(session)
1771
+ # Also fetch claude stats here (heavy file I/O)
1772
+ claude_stats = get_session_stats(session)
1773
+ # Fetch git diff stats
1774
+ git_diff = None
1775
+ if session.start_directory:
1776
+ git_diff = get_git_diff_stats(session.start_directory)
1777
+ return (status_result, claude_stats, git_diff)
1778
+ except Exception:
1779
+ return ((StatusDetector.STATUS_WAITING_USER, "Error", ""), None, None)
1780
+
1781
+ sessions = [s for _, s in sessions_to_check]
1782
+ with ThreadPoolExecutor(max_workers=min(8, len(sessions))) as executor:
1783
+ results = list(executor.map(fetch_all, sessions))
1784
+
1785
+ # Package results with session IDs
1786
+ status_results = {}
1787
+ stats_results = {}
1788
+ git_diff_results = {}
1789
+ for (session_id, _), (status_result, claude_stats, git_diff) in zip(sessions_to_check, results):
1790
+ status_results[session_id] = status_result
1791
+ stats_results[session_id] = claude_stats
1792
+ git_diff_results[session_id] = git_diff
1793
+
1794
+ # Update UI on main thread
1795
+ self.call_from_thread(self._apply_status_results, status_results, stats_results, git_diff_results, fresh_sessions)
1796
+ finally:
1797
+ self._status_update_in_progress = False
1798
+
1799
+ def _apply_status_results(self, status_results: dict, stats_results: dict, git_diff_results: dict, fresh_sessions: dict) -> None:
1800
+ """Apply fetched status results to widgets (runs on main thread).
1801
+
1802
+ All data has been pre-fetched in background - this just updates widget state.
1803
+ No file I/O happens here.
1804
+ """
1805
+ for widget in self.query(SessionSummary):
1806
+ session_id = widget.session.id
1807
+
1808
+ # Update widget's session with fresh data
1809
+ if session_id in fresh_sessions:
1810
+ widget.session = fresh_sessions[session_id]
1811
+
1812
+ # Apply status and stats if we have results for this widget
1813
+ if session_id in status_results:
1814
+ status, activity, content = status_results[session_id]
1815
+ claude_stats = stats_results.get(session_id)
1816
+ git_diff = git_diff_results.get(session_id)
1817
+ widget.apply_status_no_refresh(status, activity, content, claude_stats, git_diff)
1818
+ widget.refresh() # Refresh each widget to repaint
1819
+
1820
+ # Update preview pane if in list_preview mode
1821
+ if self.view_mode == "list_preview":
1822
+ self._update_preview()
1823
+
1824
+ def update_session_widgets(self) -> None:
1825
+ """Update the session display incrementally.
1826
+
1827
+ Only adds/removes widgets when sessions change, rather than
1828
+ destroying and recreating all widgets (which causes UI stutter).
1829
+ """
1830
+ container = self.query_one("#sessions-container", ScrollableContainer)
1831
+
1832
+ # Get existing widgets and their session IDs
1833
+ existing_widgets = {w.session.id: w for w in self.query(SessionSummary)}
1834
+ new_session_ids = {s.id for s in self.sessions}
1835
+ existing_session_ids = set(existing_widgets.keys())
1836
+
1837
+ # Check if we have an empty message widget that needs removal
1838
+ # (Static widgets that aren't SessionSummary)
1839
+ has_empty_message = any(
1840
+ isinstance(w, Static) and not isinstance(w, SessionSummary)
1841
+ for w in container.children
1842
+ )
1843
+
1844
+ # If sessions changed or we need to show/hide empty message, do incremental update
1845
+ sessions_added = new_session_ids - existing_session_ids
1846
+ sessions_removed = existing_session_ids - new_session_ids
1847
+
1848
+ if not sessions_added and not sessions_removed and not has_empty_message:
1849
+ # No structural changes needed - just update session data in existing widgets
1850
+ session_map = {s.id: s for s in self.sessions}
1851
+ for widget in existing_widgets.values():
1852
+ if widget.session.id in session_map:
1853
+ widget.session = session_map[widget.session.id]
1854
+ return
1855
+
1856
+ # Remove widgets for deleted sessions
1857
+ for session_id in sessions_removed:
1858
+ widget = existing_widgets[session_id]
1859
+ widget.remove()
1860
+
1861
+ # Clear empty message if we now have sessions
1862
+ if has_empty_message and self.sessions:
1863
+ container.remove_children()
1864
+
1865
+ # Handle empty state
1866
+ if not self.sessions:
1867
+ if not has_empty_message:
1868
+ container.remove_children()
1869
+ container.mount(Static(
1870
+ "\n No active sessions.\n\n Launch a session with:\n overcode launch --name my-agent code\n",
1871
+ classes="dim"
1872
+ ))
1873
+ return
1874
+
1875
+ # Add widgets for new sessions
1876
+ for session in self.sessions:
1877
+ if session.id in sessions_added:
1878
+ widget = SessionSummary(session, self.status_detector)
1879
+ # Restore expanded state if we have it saved
1880
+ if session.id in self.expanded_states:
1881
+ widget.expanded = self.expanded_states[session.id]
1882
+ # Apply current detail level
1883
+ widget.detail_lines = self.DETAIL_LEVELS[self.detail_level_index]
1884
+ # Apply current summary detail level
1885
+ widget.summary_detail = self.SUMMARY_LEVELS[self.summary_level_index]
1886
+ # Apply list-mode class if in list_preview view
1887
+ if self.view_mode == "list_preview":
1888
+ widget.add_class("list-mode")
1889
+ widget.expanded = False # Force collapsed in list mode
1890
+ container.mount(widget)
1891
+ # NOTE: Don't call update_status() here - it does blocking tmux calls
1892
+ # The 250ms interval (update_all_statuses) will update status shortly
1893
+
1894
+ # Reorder widgets to match self.sessions order
1895
+ # New widgets are appended at end, but should appear in correct position
1896
+ if sessions_added:
1897
+ self._reorder_session_widgets(container)
1898
+
1899
+ def action_expand_all(self) -> None:
1900
+ """Expand all sessions"""
1901
+ for widget in self.query(SessionSummary):
1902
+ widget.expanded = True
1903
+ self.expanded_states[widget.session.id] = True
1904
+
1905
+ def action_collapse_all(self) -> None:
1906
+ """Collapse all sessions"""
1907
+ for widget in self.query(SessionSummary):
1908
+ widget.expanded = False
1909
+ self.expanded_states[widget.session.id] = False
1910
+
1911
+ def action_cycle_detail(self) -> None:
1912
+ """Cycle through detail levels (5, 10, 20, 50 lines)"""
1913
+ self.detail_level_index = (self.detail_level_index + 1) % len(self.DETAIL_LEVELS)
1914
+ new_level = self.DETAIL_LEVELS[self.detail_level_index]
1915
+
1916
+ # Update all session widgets
1917
+ for widget in self.query(SessionSummary):
1918
+ widget.detail_lines = new_level
1919
+
1920
+ # Save preference
1921
+ self._prefs.detail_lines = new_level
1922
+ self._save_prefs()
1923
+
1924
+ self.notify(f"Detail: {new_level} lines", severity="information")
1925
+
1926
+ def action_cycle_summary(self) -> None:
1927
+ """Cycle through summary detail levels (low, med, full)"""
1928
+ self.summary_level_index = (self.summary_level_index + 1) % len(self.SUMMARY_LEVELS)
1929
+ new_level = self.SUMMARY_LEVELS[self.summary_level_index]
1930
+
1931
+ # Update all session widgets
1932
+ for widget in self.query(SessionSummary):
1933
+ widget.summary_detail = new_level
1934
+
1935
+ # Save preference
1936
+ self._prefs.summary_detail = new_level
1937
+ self._save_prefs()
1938
+
1939
+ self.notify(f"Summary: {new_level}", severity="information")
1940
+
1941
+ def on_session_summary_expanded_changed(self, message: SessionSummary.ExpandedChanged) -> None:
1942
+ """Handle expanded state changes from session widgets"""
1943
+ self.expanded_states[message.session_id] = message.expanded
1944
+
1945
+ def action_toggle_focused(self) -> None:
1946
+ """Toggle expansion of focused session (only in tree mode)"""
1947
+ if self.view_mode == "list_preview":
1948
+ return # Don't toggle in list mode
1949
+ focused = self.focused
1950
+ if isinstance(focused, SessionSummary):
1951
+ focused.expanded = not focused.expanded
1952
+
1953
+ def _get_widgets_in_session_order(self) -> List[SessionSummary]:
1954
+ """Get session widgets sorted to match self.sessions order.
1955
+
1956
+ query() returns widgets in DOM/mount order, but we want navigation
1957
+ to follow self.sessions order for consistency with display.
1958
+ """
1959
+ widgets = list(self.query(SessionSummary))
1960
+ if not widgets:
1961
+ return []
1962
+ # Build session_id -> order mapping from self.sessions
1963
+ session_order = {s.id: i for i, s in enumerate(self.sessions)}
1964
+ # Sort widgets by their session's position in self.sessions
1965
+ widgets.sort(key=lambda w: session_order.get(w.session.id, 999))
1966
+ return widgets
1967
+
1968
+ def _reorder_session_widgets(self, container: ScrollableContainer) -> None:
1969
+ """Reorder session widgets in container to match self.sessions order.
1970
+
1971
+ When new widgets are mounted, they're appended at the end.
1972
+ This method reorders them to match self.sessions order.
1973
+ """
1974
+ widgets = {w.session.id: w for w in self.query(SessionSummary)}
1975
+ if not widgets:
1976
+ return
1977
+
1978
+ # Get desired order from self.sessions
1979
+ ordered_widgets = []
1980
+ for session in self.sessions:
1981
+ if session.id in widgets:
1982
+ ordered_widgets.append(widgets[session.id])
1983
+
1984
+ # Reorder by moving each widget to the correct position
1985
+ for i, widget in enumerate(ordered_widgets):
1986
+ if i == 0:
1987
+ # First widget should be at the start
1988
+ container.move_child(widget, before=0)
1989
+ else:
1990
+ # Each subsequent widget should be after the previous one
1991
+ container.move_child(widget, after=ordered_widgets[i - 1])
1992
+
1993
+ def action_focus_next_session(self) -> None:
1994
+ """Focus the next session in the list."""
1995
+ widgets = self._get_widgets_in_session_order()
1996
+ if not widgets:
1997
+ return
1998
+ self.focused_session_index = (self.focused_session_index + 1) % len(widgets)
1999
+ widgets[self.focused_session_index].focus()
2000
+ if self.view_mode == "list_preview":
2001
+ self._update_preview()
2002
+
2003
+ def action_focus_previous_session(self) -> None:
2004
+ """Focus the previous session in the list."""
2005
+ widgets = self._get_widgets_in_session_order()
2006
+ if not widgets:
2007
+ return
2008
+ self.focused_session_index = (self.focused_session_index - 1) % len(widgets)
2009
+ widgets[self.focused_session_index].focus()
2010
+ if self.view_mode == "list_preview":
2011
+ self._update_preview()
2012
+
2013
+ def action_toggle_view_mode(self) -> None:
2014
+ """Toggle between tree and list+preview view modes."""
2015
+ if self.view_mode == "tree":
2016
+ self.view_mode = "list_preview"
2017
+ else:
2018
+ self.view_mode = "tree"
2019
+
2020
+ # Save preference
2021
+ self._prefs.view_mode = self.view_mode
2022
+ self._save_prefs()
2023
+
2024
+ def watch_view_mode(self, view_mode: str) -> None:
2025
+ """React to view mode changes."""
2026
+ # Update subtitle to show current mode
2027
+ self._update_subtitle()
2028
+
2029
+ try:
2030
+ preview = self.query_one("#preview-pane", PreviewPane)
2031
+ container = self.query_one("#sessions-container", ScrollableContainer)
2032
+ if view_mode == "list_preview":
2033
+ # Collapse all sessions, show preview pane
2034
+ container.add_class("list-mode")
2035
+ for widget in self.query(SessionSummary):
2036
+ widget.add_class("list-mode")
2037
+ widget.expanded = False # Force collapsed
2038
+ preview.add_class("visible")
2039
+ self._update_preview()
2040
+ else:
2041
+ # Restore tree mode, hide preview
2042
+ container.remove_class("list-mode")
2043
+ for widget in self.query(SessionSummary):
2044
+ widget.remove_class("list-mode")
2045
+ preview.remove_class("visible")
2046
+ except NoMatches:
2047
+ pass
2048
+
2049
+ def _update_subtitle(self) -> None:
2050
+ """Update the header subtitle to show session and view mode."""
2051
+ mode_label = "Tree" if self.view_mode == "tree" else "List+Preview"
2052
+ if self.diagnostics:
2053
+ self.sub_title = f"{self.tmux_session} [{mode_label}] [DIAGNOSTICS]"
2054
+ else:
2055
+ self.sub_title = f"{self.tmux_session} [{mode_label}]"
2056
+
2057
+ def _select_first_agent(self) -> None:
2058
+ """Select the first agent for initial preview pane display."""
2059
+ if self.view_mode != "list_preview":
2060
+ return
2061
+ try:
2062
+ widgets = list(self.query(SessionSummary))
2063
+ if widgets:
2064
+ self.focused_session_index = 0
2065
+ widgets[0].focus()
2066
+ self._update_preview()
2067
+ except NoMatches:
2068
+ pass
2069
+
2070
+ def _update_preview(self) -> None:
2071
+ """Update preview pane with focused session's content."""
2072
+ try:
2073
+ preview = self.query_one("#preview-pane", PreviewPane)
2074
+ focused = self.focused
2075
+ if isinstance(focused, SessionSummary):
2076
+ preview.update_from_widget(focused)
2077
+ except NoMatches:
2078
+ pass
2079
+
2080
+ def action_focus_command_bar(self) -> None:
2081
+ """Focus the command bar for input."""
2082
+ try:
2083
+ cmd_bar = self.query_one("#command-bar", CommandBar)
2084
+
2085
+ # Show the command bar
2086
+ cmd_bar.add_class("visible")
2087
+
2088
+ # Get the currently focused session (if any)
2089
+ focused = self.focused
2090
+ if isinstance(focused, SessionSummary):
2091
+ cmd_bar.set_target(focused.session.name)
2092
+ elif not cmd_bar.target_session and self.sessions:
2093
+ # Default to first session if none focused
2094
+ cmd_bar.set_target(self.sessions[0].name)
2095
+
2096
+ # Enable and focus the input
2097
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
2098
+ cmd_input.disabled = False
2099
+ cmd_input.focus()
2100
+ except NoMatches:
2101
+ pass
2102
+
2103
+ def action_focus_standing_orders(self) -> None:
2104
+ """Focus the command bar for editing standing orders."""
2105
+ try:
2106
+ cmd_bar = self.query_one("#command-bar", CommandBar)
2107
+
2108
+ # Show the command bar
2109
+ cmd_bar.add_class("visible")
2110
+
2111
+ # Get the currently focused session (if any)
2112
+ focused = self.focused
2113
+ if isinstance(focused, SessionSummary):
2114
+ cmd_bar.set_target(focused.session.name)
2115
+ # Pre-fill with existing standing orders
2116
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
2117
+ cmd_input.value = focused.session.standing_instructions or ""
2118
+ elif not cmd_bar.target_session and self.sessions:
2119
+ # Default to first session if none focused
2120
+ cmd_bar.set_target(self.sessions[0].name)
2121
+
2122
+ # Set mode to standing_orders
2123
+ cmd_bar.set_mode("standing_orders")
2124
+
2125
+ # Enable and focus the input
2126
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
2127
+ cmd_input.disabled = False
2128
+ cmd_input.focus()
2129
+ except NoMatches:
2130
+ pass
2131
+
2132
+ def on_command_bar_send_requested(self, message: CommandBar.SendRequested) -> None:
2133
+ """Handle send request from command bar."""
2134
+ from datetime import datetime
2135
+
2136
+ launcher = ClaudeLauncher(
2137
+ tmux_session=self.tmux_session,
2138
+ session_manager=self.session_manager
2139
+ )
2140
+ success = launcher.send_to_session(message.session_name, message.text)
2141
+ if success:
2142
+ # Reset the state timer immediately so UI shows instant feedback
2143
+ session = self.session_manager.get_session_by_name(message.session_name)
2144
+ if session:
2145
+ self.session_manager.update_stats(
2146
+ session.id,
2147
+ state_since=datetime.now().isoformat()
2148
+ )
2149
+ self._invalidate_sessions_cache() # Refresh to show updated stats
2150
+ self.notify(f"Sent to {message.session_name}")
2151
+ else:
2152
+ self.notify(f"Failed to send to {message.session_name}", severity="error")
2153
+
2154
+ def on_command_bar_standing_order_requested(self, message: CommandBar.StandingOrderRequested) -> None:
2155
+ """Handle standing order request from command bar."""
2156
+ session = self.session_manager.get_session_by_name(message.session_name)
2157
+ if session:
2158
+ self.session_manager.set_standing_instructions(session.id, message.text)
2159
+ self.notify(f"Standing order set for {message.session_name}")
2160
+ # Refresh session list to show updated standing order
2161
+ self.refresh_sessions()
2162
+ else:
2163
+ self.notify(f"Session '{message.session_name}' not found", severity="error")
2164
+
2165
+ def on_command_bar_clear_requested(self, message: CommandBar.ClearRequested) -> None:
2166
+ """Handle clear request - hide and unfocus command bar."""
2167
+ try:
2168
+ # Disable and hide the command bar
2169
+ cmd_bar = self.query_one("#command-bar", CommandBar)
2170
+ target_session_name = cmd_bar.target_session # Remember before disabling
2171
+ cmd_bar.query_one("#cmd-input", Input).disabled = True
2172
+ cmd_bar.query_one("#cmd-textarea", TextArea).disabled = True
2173
+ cmd_bar.remove_class("visible")
2174
+
2175
+ # Focus the targeted session (not first session) to keep preview on it
2176
+ if self.sessions:
2177
+ widgets = self._get_widgets_in_session_order()
2178
+ if widgets:
2179
+ # Find widget matching target session, fall back to current index
2180
+ target_widget = None
2181
+ for i, w in enumerate(widgets):
2182
+ if w.session.name == target_session_name:
2183
+ target_widget = w
2184
+ self.focused_session_index = i
2185
+ break
2186
+ if target_widget:
2187
+ target_widget.focus()
2188
+ else:
2189
+ widgets[self.focused_session_index].focus()
2190
+ if self.view_mode == "list_preview":
2191
+ self._update_preview()
2192
+ except NoMatches:
2193
+ pass
2194
+
2195
+ def on_command_bar_new_agent_requested(self, message: CommandBar.NewAgentRequested) -> None:
2196
+ """Handle new agent creation request."""
2197
+ agent_name = message.agent_name
2198
+ directory = message.directory
2199
+ bypass_permissions = message.bypass_permissions
2200
+
2201
+ # Validate name (no spaces, reasonable length)
2202
+ if not agent_name or len(agent_name) > 50:
2203
+ self.notify("Invalid agent name", severity="error")
2204
+ return
2205
+
2206
+ if ' ' in agent_name:
2207
+ self.notify("Agent name cannot contain spaces", severity="error")
2208
+ return
2209
+
2210
+ # Check if agent with this name already exists
2211
+ existing = self.session_manager.get_session_by_name(agent_name)
2212
+ if existing:
2213
+ self.notify(f"Agent '{agent_name}' already exists", severity="error")
2214
+ return
2215
+
2216
+ # Create new agent using launcher
2217
+ launcher = ClaudeLauncher(
2218
+ tmux_session=self.tmux_session,
2219
+ session_manager=self.session_manager
2220
+ )
2221
+
2222
+ try:
2223
+ launcher.launch(
2224
+ name=agent_name,
2225
+ start_directory=directory,
2226
+ dangerously_skip_permissions=bypass_permissions
2227
+ )
2228
+ dir_info = f" in {directory}" if directory else ""
2229
+ perm_info = " (bypass mode)" if bypass_permissions else ""
2230
+ self.notify(f"Created agent: {agent_name}{dir_info}{perm_info}", severity="information")
2231
+ # Refresh to show new agent
2232
+ self.refresh_sessions()
2233
+ except Exception as e:
2234
+ self.notify(f"Failed to create agent: {e}", severity="error")
2235
+
2236
+ def action_toggle_daemon(self) -> None:
2237
+ """Toggle daemon panel visibility (like timeline)."""
2238
+ try:
2239
+ daemon_panel = self.query_one("#daemon-panel", DaemonPanel)
2240
+ daemon_panel.display = not daemon_panel.display
2241
+ if daemon_panel.display:
2242
+ # Force immediate refresh when becoming visible
2243
+ daemon_panel._refresh_logs()
2244
+ # Save preference
2245
+ self._prefs.daemon_panel_visible = daemon_panel.display
2246
+ self._save_prefs()
2247
+ state = "shown" if daemon_panel.display else "hidden"
2248
+ self.notify(f"Daemon panel {state}", severity="information")
2249
+ except NoMatches:
2250
+ pass
2251
+
2252
+ def action_supervisor_start(self) -> None:
2253
+ """Start the Supervisor Daemon (handles Claude orchestration)."""
2254
+ # Ensure Monitor Daemon is running first (Supervisor depends on it)
2255
+ if not is_monitor_daemon_running(self.tmux_session):
2256
+ self._ensure_monitor_daemon()
2257
+ import time
2258
+ time.sleep(1.0)
2259
+
2260
+ if is_supervisor_daemon_running(self.tmux_session):
2261
+ self.notify("Supervisor Daemon already running", severity="warning")
2262
+ return
2263
+
2264
+ try:
2265
+ panel = self.query_one("#daemon-panel", DaemonPanel)
2266
+ panel.log_lines.append(">>> Starting Supervisor Daemon...")
2267
+ except NoMatches:
2268
+ pass
2269
+
2270
+ try:
2271
+ subprocess.Popen(
2272
+ [sys.executable, "-m", "overcode.supervisor_daemon",
2273
+ "--session", self.tmux_session],
2274
+ stdout=subprocess.DEVNULL,
2275
+ stderr=subprocess.DEVNULL,
2276
+ start_new_session=True,
2277
+ )
2278
+ self.notify("Started Supervisor Daemon", severity="information")
2279
+ self.set_timer(1.0, self.update_daemon_status)
2280
+ except (OSError, subprocess.SubprocessError) as e:
2281
+ self.notify(f"Failed to start Supervisor Daemon: {e}", severity="error")
2282
+
2283
+ def action_supervisor_stop(self) -> None:
2284
+ """Stop the Supervisor Daemon."""
2285
+ if not is_supervisor_daemon_running(self.tmux_session):
2286
+ self.notify("Supervisor Daemon not running", severity="warning")
2287
+ return
2288
+
2289
+ if stop_supervisor_daemon(self.tmux_session):
2290
+ self.notify("Stopped Supervisor Daemon", severity="information")
2291
+ try:
2292
+ panel = self.query_one("#daemon-panel", DaemonPanel)
2293
+ panel.log_lines.append(">>> Supervisor Daemon stopped")
2294
+ except NoMatches:
2295
+ pass
2296
+ else:
2297
+ self.notify("Failed to stop Supervisor Daemon", severity="error")
2298
+
2299
+ self.update_daemon_status()
2300
+
2301
+ def action_monitor_restart(self) -> None:
2302
+ """Restart the Monitor Daemon (handles metrics/state tracking)."""
2303
+ import time
2304
+
2305
+ try:
2306
+ panel = self.query_one("#daemon-panel", DaemonPanel)
2307
+ panel.log_lines.append(">>> Restarting Monitor Daemon...")
2308
+ except NoMatches:
2309
+ pass
2310
+
2311
+ # Stop if running
2312
+ if is_monitor_daemon_running(self.tmux_session):
2313
+ stop_monitor_daemon(self.tmux_session)
2314
+ time.sleep(0.5)
2315
+
2316
+ # Start fresh
2317
+ try:
2318
+ subprocess.Popen(
2319
+ [sys.executable, "-m", "overcode.monitor_daemon",
2320
+ "--session", self.tmux_session],
2321
+ stdout=subprocess.DEVNULL,
2322
+ stderr=subprocess.DEVNULL,
2323
+ start_new_session=True,
2324
+ )
2325
+
2326
+ self.notify("Monitor Daemon restarted", severity="information")
2327
+ try:
2328
+ panel = self.query_one("#daemon-panel", DaemonPanel)
2329
+ panel.log_lines.append(">>> Monitor Daemon restarted")
2330
+ except NoMatches:
2331
+ pass
2332
+ self.set_timer(1.0, self.update_daemon_status)
2333
+ except (OSError, subprocess.SubprocessError) as e:
2334
+ self.notify(f"Failed to restart Monitor Daemon: {e}", severity="error")
2335
+
2336
+ def _ensure_monitor_daemon(self) -> None:
2337
+ """Start the Monitor Daemon if not running.
2338
+
2339
+ Called automatically on TUI mount to ensure continuous monitoring.
2340
+ The Monitor Daemon handles status tracking, time accumulation,
2341
+ stats sync, and user presence detection.
2342
+ """
2343
+ if is_monitor_daemon_running(self.tmux_session):
2344
+ return # Already running
2345
+
2346
+ try:
2347
+ subprocess.Popen(
2348
+ [sys.executable, "-m", "overcode.monitor_daemon",
2349
+ "--session", self.tmux_session],
2350
+ stdout=subprocess.DEVNULL,
2351
+ stderr=subprocess.DEVNULL,
2352
+ start_new_session=True,
2353
+ )
2354
+ self.notify("Monitor Daemon started", severity="information")
2355
+ except (OSError, subprocess.SubprocessError) as e:
2356
+ self.notify(f"Failed to start Monitor Daemon: {e}", severity="warning")
2357
+
2358
+ def action_kill_focused(self) -> None:
2359
+ """Kill the currently focused agent."""
2360
+ focused = self.focused
2361
+ if not isinstance(focused, SessionSummary):
2362
+ self.notify("No agent focused", severity="warning")
2363
+ return
2364
+
2365
+ session_name = focused.session.name
2366
+ session_id = focused.session.id
2367
+
2368
+ # Use launcher to kill the session
2369
+ launcher = ClaudeLauncher(
2370
+ tmux_session=self.tmux_session,
2371
+ session_manager=self.session_manager
2372
+ )
2373
+
2374
+ if launcher.kill_session(session_name):
2375
+ self.notify(f"Killed agent: {session_name}", severity="information")
2376
+ # Remove the widget and refresh
2377
+ focused.remove()
2378
+ # Update session cache
2379
+ if session_id in self._sessions_cache:
2380
+ del self._sessions_cache[session_id]
2381
+ if session_id in self.expanded_states:
2382
+ del self.expanded_states[session_id]
2383
+ # Clear preview pane and focus next agent if in list_preview mode
2384
+ if self.view_mode == "list_preview":
2385
+ try:
2386
+ preview = self.query_one("#preview-pane", PreviewPane)
2387
+ preview.session_name = ""
2388
+ preview.content_lines = []
2389
+ preview.refresh()
2390
+ # Focus next available agent
2391
+ widgets = list(self.query(SessionSummary))
2392
+ if widgets:
2393
+ self.focused_session_index = min(self.focused_session_index, len(widgets) - 1)
2394
+ widgets[self.focused_session_index].focus()
2395
+ self._update_preview()
2396
+ except NoMatches:
2397
+ pass
2398
+ else:
2399
+ self.notify(f"Failed to kill agent: {session_name}", severity="error")
2400
+
2401
+ def action_new_agent(self) -> None:
2402
+ """Prompt for directory and name to create a new agent.
2403
+
2404
+ Two-step flow:
2405
+ 1. Enter working directory (or press Enter for current directory)
2406
+ 2. Enter agent name (defaults to directory basename)
2407
+ """
2408
+ from pathlib import Path
2409
+
2410
+ try:
2411
+ command_bar = self.query_one("#command-bar", CommandBar)
2412
+ command_bar.add_class("visible") # Must show the command bar first
2413
+ command_bar.set_mode("new_agent_dir")
2414
+ # Pre-fill with current working directory
2415
+ input_widget = command_bar.query_one("#cmd-input", Input)
2416
+ input_widget.value = str(Path.cwd())
2417
+ command_bar.focus_input()
2418
+ except NoMatches:
2419
+ self.notify("Command bar not found", severity="error")
2420
+
2421
+ def action_toggle_copy_mode(self) -> None:
2422
+ """Toggle mouse capture to allow native terminal text selection.
2423
+
2424
+ When copy mode is ON:
2425
+ - Mouse events pass through to terminal
2426
+ - You can select text and Cmd+C to copy
2427
+ - Press 'y' again to exit copy mode
2428
+ """
2429
+ if not hasattr(self, '_copy_mode'):
2430
+ self._copy_mode = False
2431
+
2432
+ self._copy_mode = not self._copy_mode
2433
+
2434
+ if self._copy_mode:
2435
+ # Write escape sequences directly to the driver's file (stderr)
2436
+ # This is what Textual uses internally for terminal output
2437
+ # We bypass the driver methods because they check _mouse flag
2438
+ driver_file = self._driver._file
2439
+
2440
+ # Disable all mouse tracking modes
2441
+ driver_file.write("\x1b[?1000l") # Disable basic mouse tracking
2442
+ driver_file.write("\x1b[?1002l") # Disable cell motion tracking
2443
+ driver_file.write("\x1b[?1003l") # Disable all motion tracking
2444
+ driver_file.write("\x1b[?1015l") # Disable urxvt extended mode
2445
+ driver_file.write("\x1b[?1006l") # Disable SGR extended mode
2446
+ driver_file.flush()
2447
+
2448
+ self.notify("COPY MODE - select with mouse, Cmd+C to copy, 'y' to exit", severity="warning")
2449
+ else:
2450
+ # Re-enable mouse support using driver's method
2451
+ self._driver._mouse = True # Ensure flag is set so enable actually sends codes
2452
+ self._driver._enable_mouse_support()
2453
+ self.refresh()
2454
+ self.notify("Copy mode OFF", severity="information")
2455
+
2456
+ def action_send_enter_to_focused(self) -> None:
2457
+ """Send Enter keypress to the focused agent (for approvals)."""
2458
+ focused = self.focused
2459
+ if not isinstance(focused, SessionSummary):
2460
+ self.notify("No agent focused", severity="warning")
2461
+ return
2462
+
2463
+ session_name = focused.session.name
2464
+ launcher = ClaudeLauncher(
2465
+ tmux_session=self.tmux_session,
2466
+ session_manager=self.session_manager
2467
+ )
2468
+
2469
+ # Send "enter" which the launcher handles as just pressing Enter
2470
+ if launcher.send_to_session(session_name, "enter"):
2471
+ self.notify(f"Sent Enter to {session_name}", severity="information")
2472
+ else:
2473
+ self.notify(f"Failed to send Enter to {session_name}", severity="error")
2474
+
2475
+ def _send_key_to_focused(self, key: str) -> None:
2476
+ """Send a key to the focused agent."""
2477
+ focused = self.focused
2478
+ if not isinstance(focused, SessionSummary):
2479
+ self.notify("No agent focused", severity="warning")
2480
+ return
2481
+
2482
+ session_name = focused.session.name
2483
+ launcher = ClaudeLauncher(
2484
+ tmux_session=self.tmux_session,
2485
+ session_manager=self.session_manager
2486
+ )
2487
+
2488
+ # Send the key followed by Enter (to select the numbered option)
2489
+ if launcher.send_to_session(session_name, key, enter=True):
2490
+ self.notify(f"Sent '{key}' to {session_name}", severity="information")
2491
+ else:
2492
+ self.notify(f"Failed to send '{key}' to {session_name}", severity="error")
2493
+
2494
+ def action_send_1_to_focused(self) -> None:
2495
+ """Send '1' to focused agent."""
2496
+ self._send_key_to_focused("1")
2497
+
2498
+ def action_send_2_to_focused(self) -> None:
2499
+ """Send '2' to focused agent."""
2500
+ self._send_key_to_focused("2")
2501
+
2502
+ def action_send_3_to_focused(self) -> None:
2503
+ """Send '3' to focused agent."""
2504
+ self._send_key_to_focused("3")
2505
+
2506
+ def action_send_4_to_focused(self) -> None:
2507
+ """Send '4' to focused agent."""
2508
+ self._send_key_to_focused("4")
2509
+
2510
+ def action_send_5_to_focused(self) -> None:
2511
+ """Send '5' to focused agent."""
2512
+ self._send_key_to_focused("5")
2513
+
2514
+ def on_key(self, event: events.Key) -> None:
2515
+ """Signal activity to daemon on any keypress."""
2516
+ signal_activity(self.tmux_session)
2517
+
2518
+ def on_unmount(self) -> None:
2519
+ """Clean up terminal state on exit"""
2520
+ import sys
2521
+ # Ensure mouse tracking is disabled
2522
+ sys.stdout.write('\033[?1000l') # Disable mouse tracking
2523
+ sys.stdout.write('\033[?1002l') # Disable cell motion tracking
2524
+ sys.stdout.write('\033[?1003l') # Disable all motion tracking
2525
+ sys.stdout.flush()
2526
+
2527
+
2528
+ def run_tui(tmux_session: str = "agents", diagnostics: bool = False):
2529
+ """Run the TUI supervisor"""
2530
+ import os
2531
+ import sys
2532
+
2533
+ # Ensure we're using a proper terminal
2534
+ if not sys.stdout.isatty():
2535
+ print("Error: Must run in a TTY terminal", file=sys.stderr)
2536
+ sys.exit(1)
2537
+
2538
+ # Force terminal size detection
2539
+ os.environ.setdefault('TERM', 'xterm-256color')
2540
+
2541
+ app = SupervisorTUI(tmux_session, diagnostics=diagnostics)
2542
+ # Use driver=None to auto-detect, and size will be detected from terminal
2543
+ app.run()
2544
+
2545
+
2546
+ if __name__ == "__main__":
2547
+ import sys
2548
+ tmux_session = sys.argv[1] if len(sys.argv) > 1 else "agents"
2549
+ run_tui(tmux_session)