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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/cli.py +7 -2
  3. overcode/implementations.py +74 -8
  4. overcode/monitor_daemon.py +60 -65
  5. overcode/monitor_daemon_core.py +261 -0
  6. overcode/monitor_daemon_state.py +7 -0
  7. overcode/session_manager.py +1 -0
  8. overcode/settings.py +22 -0
  9. overcode/supervisor_daemon.py +48 -47
  10. overcode/supervisor_daemon_core.py +210 -0
  11. overcode/testing/__init__.py +6 -0
  12. overcode/testing/renderer.py +268 -0
  13. overcode/testing/tmux_driver.py +223 -0
  14. overcode/testing/tui_eye.py +185 -0
  15. overcode/testing/tui_eye_skill.md +187 -0
  16. overcode/tmux_manager.py +17 -3
  17. overcode/tui.py +196 -2462
  18. overcode/tui_actions/__init__.py +20 -0
  19. overcode/tui_actions/daemon.py +201 -0
  20. overcode/tui_actions/input.py +128 -0
  21. overcode/tui_actions/navigation.py +117 -0
  22. overcode/tui_actions/session.py +428 -0
  23. overcode/tui_actions/view.py +357 -0
  24. overcode/tui_helpers.py +41 -9
  25. overcode/tui_logic.py +347 -0
  26. overcode/tui_render.py +414 -0
  27. overcode/tui_widgets/__init__.py +24 -0
  28. overcode/tui_widgets/command_bar.py +399 -0
  29. overcode/tui_widgets/daemon_panel.py +153 -0
  30. overcode/tui_widgets/daemon_status_bar.py +245 -0
  31. overcode/tui_widgets/help_overlay.py +71 -0
  32. overcode/tui_widgets/preview_pane.py +69 -0
  33. overcode/tui_widgets/session_summary.py +514 -0
  34. overcode/tui_widgets/status_timeline.py +253 -0
  35. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/METADATA +3 -1
  36. overcode-0.1.4.dist-info/RECORD +68 -0
  37. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
  38. overcode-0.1.3.dist-info/RECORD +0 -45
  39. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/WHEEL +0 -0
  40. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
  41. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
overcode/tui.py CHANGED
@@ -79,1654 +79,48 @@ from .tui_helpers import (
79
79
  get_git_diff_stats,
80
80
  calculate_safe_break_duration,
81
81
  )
82
+ from .tui_logic import (
83
+ sort_sessions,
84
+ filter_visible_sessions,
85
+ get_sort_mode_display_name,
86
+ cycle_sort_mode,
87
+ calculate_green_percentage,
88
+ calculate_human_interaction_count,
89
+ )
90
+ from .tui_widgets import (
91
+ HelpOverlay,
92
+ PreviewPane,
93
+ DaemonPanel,
94
+ DaemonStatusBar,
95
+ StatusTimeline,
96
+ SessionSummary,
97
+ CommandBar,
98
+ )
99
+ from .tui_actions import (
100
+ NavigationActionsMixin,
101
+ ViewActionsMixin,
102
+ DaemonActionsMixin,
103
+ SessionActionsMixin,
104
+ InputActionsMixin,
105
+ )
82
106
 
83
107
 
84
- def format_standing_instructions(instructions: str, max_len: int = 95) -> str:
85
- """Format standing instructions for display.
86
-
87
- Shows "[DEFAULT]" if instructions match the configured default,
88
- otherwise shows the truncated instructions.
89
- """
90
- if not instructions:
91
- return ""
92
-
93
- default = get_default_standing_instructions()
94
- if default and instructions.strip() == default.strip():
95
- return "[DEFAULT]"
96
-
97
- if len(instructions) > max_len:
98
- return instructions[:max_len - 3] + "..."
99
- return instructions
100
-
101
-
102
- class DaemonStatusBar(Static):
103
- """Widget displaying daemon status.
104
-
105
- Shows Monitor Daemon and Supervisor Daemon status explicitly.
106
- Presence is shown only when available (macOS with monitor daemon running).
107
- """
108
-
109
- def __init__(self, tmux_session: str = "agents", session_manager: Optional["SessionManager"] = None, *args, **kwargs):
110
- super().__init__(*args, **kwargs)
111
- self.tmux_session = tmux_session
112
- self.monitor_state: Optional[MonitorDaemonState] = None
113
- self._session_manager = session_manager
114
- self._asleep_session_ids: set = set() # Cache of asleep session IDs
115
-
116
- def update_status(self) -> None:
117
- """Refresh daemon state from file"""
118
- self.monitor_state = get_monitor_daemon_state(self.tmux_session)
119
- # Update cache of asleep session IDs from session manager
120
- if self._session_manager:
121
- self._asleep_session_ids = {
122
- s.id for s in self._session_manager.list_sessions() if s.is_asleep
123
- }
124
- self.refresh()
125
-
126
- def render(self) -> Text:
127
- """Render daemon status bar.
128
-
129
- Shows Monitor Daemon and Supervisor Daemon status explicitly.
130
- """
131
- content = Text()
132
-
133
- # Monitor Daemon status
134
- content.append("Monitor: ", style="bold")
135
- monitor_running = self.monitor_state and not self.monitor_state.is_stale()
136
-
137
- if monitor_running:
138
- state = self.monitor_state
139
- symbol, style = get_daemon_status_style(state.status)
140
- content.append(f"{symbol} ", style=style)
141
- content.append(f"#{state.loop_count}", style="cyan")
142
- content.append(f" @{format_interval(state.current_interval)}", style="dim")
143
- # Version mismatch warning
144
- if state.daemon_version != DAEMON_VERSION:
145
- content.append(f" ⚠v{state.daemon_version}→{DAEMON_VERSION}", style="bold yellow")
146
- else:
147
- content.append("○ ", style="red")
148
- content.append("stopped", style="red")
149
-
150
- content.append(" │ ", style="dim")
151
-
152
- # Supervisor Daemon status
153
- content.append("Supervisor: ", style="bold")
154
- supervisor_running = is_supervisor_daemon_running(self.tmux_session)
155
-
156
- if supervisor_running:
157
- content.append("● ", style="green")
158
- # Show if daemon Claude is currently running
159
- if monitor_running and self.monitor_state.supervisor_claude_running:
160
- # Calculate current run duration
161
- run_duration = ""
162
- if self.monitor_state.supervisor_claude_started_at:
163
- try:
164
- started = datetime.fromisoformat(self.monitor_state.supervisor_claude_started_at)
165
- elapsed = (datetime.now() - started).total_seconds()
166
- run_duration = format_duration(elapsed)
167
- except (ValueError, TypeError):
168
- run_duration = "?"
169
- content.append(f"🤖 RUNNING {run_duration}", style="bold yellow")
170
- # Show supervision stats if available from monitor state
171
- elif monitor_running and self.monitor_state.total_supervisions > 0:
172
- content.append(f"sup:{self.monitor_state.total_supervisions}", style="magenta")
173
- if self.monitor_state.supervisor_tokens > 0:
174
- content.append(f" {format_tokens(self.monitor_state.supervisor_tokens)}", style="blue")
175
- # Show cumulative daemon Claude run time
176
- if self.monitor_state.supervisor_claude_total_run_seconds > 0:
177
- total_run = format_duration(self.monitor_state.supervisor_claude_total_run_seconds)
178
- content.append(f" ⏱{total_run}", style="dim")
179
- else:
180
- content.append("ready", style="green")
181
- else:
182
- content.append("○ ", style="red")
183
- content.append("stopped", style="red")
184
-
185
- # AI Summarizer status (from TUI's local summarizer, not daemon)
186
- content.append(" │ ", style="dim")
187
- content.append("AI: ", style="bold")
188
- # Get summarizer state from parent app
189
- summarizer_available = SummarizerClient.is_available()
190
- summarizer_enabled = False
191
- summarizer_calls = 0
192
- if hasattr(self.app, '_summarizer'):
193
- summarizer_enabled = self.app._summarizer.enabled
194
- summarizer_calls = self.app._summarizer.total_calls
195
- if summarizer_available:
196
- if summarizer_enabled:
197
- content.append("● ", style="green")
198
- if summarizer_calls > 0:
199
- content.append(f"{summarizer_calls}", style="cyan")
200
- else:
201
- content.append("on", style="green")
202
- else:
203
- content.append("○ ", style="dim")
204
- content.append("off", style="dim")
205
- else:
206
- content.append("○ ", style="red")
207
- content.append("n/a", style="red dim")
208
-
209
- # Spin rate stats (only when monitor running with sessions)
210
- if monitor_running and self.monitor_state.sessions:
211
- content.append(" │ ", style="dim")
212
- # Filter out sleeping agents from stats
213
- all_sessions = self.monitor_state.sessions
214
- active_sessions = [s for s in all_sessions if s.session_id not in self._asleep_session_ids]
215
- sleeping_count = len(all_sessions) - len(active_sessions)
216
-
217
- total_agents = len(active_sessions)
218
- # Recalculate green_now excluding sleeping agents
219
- green_now = sum(1 for s in active_sessions if s.current_status == "running")
220
-
221
- # Calculate mean spin rate from green_time percentages (exclude sleeping)
222
- mean_spin = 0.0
223
- for s in active_sessions:
224
- total_time = s.green_time_seconds + s.non_green_time_seconds
225
- if total_time > 0:
226
- mean_spin += s.green_time_seconds / total_time
227
-
228
- content.append("Spin: ", style="bold")
229
- content.append(f"{green_now}", style="bold green" if green_now > 0 else "dim")
230
- content.append(f"/{total_agents}", style="dim")
231
- if sleeping_count > 0:
232
- content.append(f" 💤{sleeping_count}", style="dim") # Show sleeping count
233
- if mean_spin > 0:
234
- content.append(f" μ{mean_spin:.1f}x", style="cyan")
235
-
236
- # Total tokens across all sessions (include sleeping agents - they used tokens too)
237
- total_tokens = sum(s.input_tokens + s.output_tokens for s in all_sessions)
238
- if total_tokens > 0:
239
- content.append(f" Σ{format_tokens(total_tokens)}", style="orange1")
240
-
241
- # Safe break duration (time until 50%+ agents need attention) - exclude sleeping
242
- safe_break = calculate_safe_break_duration(active_sessions)
243
- if safe_break is not None:
244
- content.append(" │ ", style="dim")
245
- content.append("☕", style="bold")
246
- if safe_break < 60:
247
- content.append(f" <1m", style="bold red")
248
- elif safe_break < 300: # < 5 min
249
- content.append(f" {format_duration(safe_break)}", style="bold yellow")
250
- else:
251
- content.append(f" {format_duration(safe_break)}", style="bold green")
252
-
253
- # Presence status (only show if available via monitor daemon on macOS)
254
- if monitor_running and self.monitor_state.presence_available:
255
- content.append(" │ ", style="dim")
256
- state = self.monitor_state.presence_state
257
- idle = self.monitor_state.presence_idle_seconds or 0
258
-
259
- state_names = {1: "🔒", 2: "💤", 3: "👤"}
260
- state_colors = {1: "red", 2: "yellow", 3: "green"}
261
-
262
- icon = state_names.get(state, "?")
263
- color = state_colors.get(state, "dim")
264
- content.append(f"{icon}", style=color)
265
- content.append(f" {int(idle)}s", style="dim")
266
-
267
- # Relay status (small indicator)
268
- if monitor_running and self.monitor_state.relay_enabled:
269
- content.append(" │ ", style="dim")
270
- relay_status = self.monitor_state.relay_last_status
271
- if relay_status == "ok":
272
- content.append("📡", style="green")
273
- elif relay_status == "error":
274
- content.append("📡", style="red")
275
- else:
276
- content.append("📡", style="dim")
277
-
278
- # Web server status
279
- web_running = is_web_server_running(self.tmux_session)
280
- if web_running:
281
- content.append(" │ ", style="dim")
282
- url = get_web_server_url(self.tmux_session)
283
- content.append("🌐", style="green")
284
- if url:
285
- # Just show port
286
- port = url.split(":")[-1] if url else ""
287
- content.append(f":{port}", style="cyan")
288
-
289
- return content
290
-
291
-
292
- class StatusTimeline(Static):
293
- """Widget displaying historical status timelines for user presence and agents.
294
-
295
- Shows the last N hours with each character representing a time slice.
296
- - User presence: green=active, yellow=inactive, red/gray=locked/away
297
- - Agent status: green=running, red=waiting, grey=terminated
298
-
299
- Timeline hours configurable via ~/.overcode/config.yaml (timeline.hours).
300
- """
301
-
302
- TIMELINE_HOURS = 3.0 # Default hours
303
- MIN_NAME_WIDTH = 6 # Minimum width for agent names
304
- MAX_NAME_WIDTH = 30 # Maximum width for agent names
305
- MIN_TIMELINE = 20 # Minimum timeline width
306
- DEFAULT_TIMELINE = 60 # Fallback if can't detect width
307
-
308
- def __init__(self, sessions: list, tmux_session: str = "agents", *args, **kwargs):
309
- super().__init__(*args, **kwargs)
310
- self.sessions = sessions
311
- self.tmux_session = tmux_session
312
- self._presence_history = []
313
- self._agent_histories = {}
314
- # Get timeline hours from config (config file > env var > default)
315
- from .config import get_timeline_config
316
- timeline_config = get_timeline_config()
317
- self.timeline_hours = timeline_config["hours"]
318
-
319
- @property
320
- def label_width(self) -> int:
321
- """Calculate label width based on longest agent name (#75)."""
322
- if not self.sessions:
323
- return self.MIN_NAME_WIDTH
324
- longest = max(len(s.name) for s in self.sessions)
325
- # Clamp to min/max and add padding for " " prefix and " " suffix
326
- return min(self.MAX_NAME_WIDTH, max(self.MIN_NAME_WIDTH, longest))
327
-
328
- @property
329
- def timeline_width(self) -> int:
330
- """Calculate timeline width based on available space after labels (#75)."""
331
- import shutil
332
- try:
333
- # Try to get terminal size directly - most reliable
334
- term_width = shutil.get_terminal_size().columns
335
- # Subtract:
336
- # - label_width (agent name)
337
- # - 3 for " " prefix and " " suffix around label
338
- # - 5 for percentage display " XXX%"
339
- # - 2 for CSS padding (padding: 0 1 = 1 char each side)
340
- available = term_width - self.label_width - 3 - 5 - 2
341
- return max(self.MIN_TIMELINE, min(available, 200))
342
- except (OSError, ValueError):
343
- # No terminal available or invalid size
344
- return self.DEFAULT_TIMELINE
345
-
346
- def update_history(self, sessions: list) -> None:
347
- """Refresh history data from log files."""
348
- self.sessions = sessions
349
- self._presence_history = read_presence_history(hours=self.timeline_hours)
350
- self._agent_histories = {}
351
-
352
- # Get agent names from sessions
353
- agent_names = [s.name for s in sessions]
354
-
355
- # Read agent history from session-specific file and group by agent
356
- history_path = get_agent_history_path(self.tmux_session)
357
- all_history = read_agent_status_history(hours=self.timeline_hours, history_file=history_path)
358
- for ts, agent, status, activity in all_history:
359
- if agent not in self._agent_histories:
360
- self._agent_histories[agent] = []
361
- self._agent_histories[agent].append((ts, status))
362
-
363
- # Force layout refresh when content changes (agent count may have changed)
364
- self.refresh(layout=True)
365
-
366
- def _build_timeline(self, history: list, state_to_char: callable) -> str:
367
- """Build a timeline string from history data.
368
-
369
- Args:
370
- history: List of (timestamp, state) tuples
371
- state_to_char: Function to convert state to display character
372
-
373
- Returns:
374
- String of timeline_width characters representing the timeline
375
- """
376
- width = self.timeline_width
377
- if not history:
378
- return "─" * width
379
-
380
- now = datetime.now()
381
- start_time = now - timedelta(hours=self.timeline_hours)
382
- slot_duration = timedelta(hours=self.timeline_hours) / width
383
-
384
- # Initialize timeline with empty slots
385
- timeline = ["─"] * width
386
-
387
- # Fill in slots based on history
388
- for ts, state in history:
389
- if ts < start_time:
390
- continue
391
- # Calculate which slot this belongs to
392
- elapsed = ts - start_time
393
- slot_idx = int(elapsed / slot_duration)
394
- if 0 <= slot_idx < width:
395
- timeline[slot_idx] = state_to_char(state)
396
-
397
- return "".join(timeline)
398
-
399
- def render(self) -> Text:
400
- """Render the timeline visualization."""
401
- content = Text()
402
- now = datetime.now()
403
- width = self.timeline_width
404
-
405
- # Time scale header
406
- label_w = self.label_width
407
- content.append("Timeline: ", style="bold")
408
- content.append(f"-{self.timeline_hours:.0f}h", style="dim")
409
- header_padding = max(0, width - 10)
410
- content.append(" " * header_padding, style="dim")
411
- content.append("now", style="dim")
412
- content.append("\n")
413
-
414
- # User presence timeline - group by time slots like agent timelines
415
- # Align with agent names using dynamic label width (#75)
416
- content.append(f" {'User:':<{label_w}} ", style="cyan")
417
- if self._presence_history:
418
- slot_states = build_timeline_slots(
419
- self._presence_history, width, self.timeline_hours, now
420
- )
421
- # Render timeline with colors
422
- for i in range(width):
423
- if i in slot_states:
424
- state = slot_states[i]
425
- char = presence_state_to_char(state)
426
- color = get_presence_color(state)
427
- content.append(char, style=color)
428
- else:
429
- content.append("─", style="dim")
430
- elif not MACOS_APIS_AVAILABLE:
431
- # Show install instructions when presence deps not installed (macOS only)
432
- msg = "macOS only - pip install overcode[presence]"
433
- content.append(msg[:width], style="dim italic")
434
- else:
435
- content.append("─" * width, style="dim")
436
- content.append("\n")
437
-
438
- # Agent timelines
439
- for session in self.sessions:
440
- agent_name = session.name
441
- history = self._agent_histories.get(agent_name, [])
442
-
443
- # Use dynamic label width (#75)
444
- display_name = truncate_name(agent_name, max_len=label_w)
445
- content.append(f" {display_name} ", style="cyan")
446
-
447
- green_slots = 0
448
- total_slots = 0
449
- if history:
450
- slot_states = build_timeline_slots(history, width, self.timeline_hours, now)
451
- # Render timeline with colors
452
- for i in range(width):
453
- if i in slot_states:
454
- status = slot_states[i]
455
- char = agent_status_to_char(status)
456
- color = get_agent_timeline_color(status)
457
- content.append(char, style=color)
458
- total_slots += 1
459
- if status == "running":
460
- green_slots += 1
461
- else:
462
- content.append("─", style="dim")
463
- else:
464
- content.append("─" * width, style="dim")
465
-
466
- # Show percentage green in last 3 hours
467
- if total_slots > 0:
468
- pct = green_slots / total_slots * 100
469
- pct_style = "bold green" if pct >= 50 else "bold red"
470
- content.append(f" {pct:>3.0f}%", style=pct_style)
471
- else:
472
- content.append(" - ", style="dim")
473
-
474
- content.append("\n")
475
-
476
- # Legend (combined on one line to save space)
477
- content.append(f" {'Legend:':<14} ", style="dim")
478
- content.append("█", style="green")
479
- content.append("active/running ", style="dim")
480
- content.append("▒", style="yellow")
481
- content.append("inactive ", style="dim")
482
- content.append("░", style="red")
483
- content.append("waiting/away ", style="dim")
484
- content.append("░", style="dim")
485
- content.append("asleep ", style="dim")
486
- content.append("×", style="dim")
487
- content.append("terminated", style="dim")
488
-
489
- return content
490
-
491
-
492
- class HelpOverlay(Static):
493
- """Help overlay explaining all TUI metrics and controls"""
494
-
495
- HELP_TEXT = """
496
- ╔══════════════════════════════════════════════════════════════════════════════╗
497
- ║ OVERCODE MONITOR HELP ║
498
- ╠══════════════════════════════════════════════════════════════════════════════╣
499
- ║ STATUS COLORS ║
500
- ║ ────────────────────────────────────────────────────────────────────────── ║
501
- ║ 🟢 Running 🟡 No orders 🟠 Wait supervisor 🔴 Wait user ║
502
- ║ 💤 Asleep ⚫ Terminated ║
503
- ║ ║
504
- ║ NAVIGATION & VIEW ║
505
- ║ ────────────────────────────────────────────────────────────────────────── ║
506
- ║ j/↓ Next agent k/↑ Previous agent ║
507
- ║ space Toggle expand m Toggle tree/list mode ║
508
- ║ e Expand all c Collapse all ║
509
- ║ h/? Toggle help r Refresh ║
510
- ║ q Quit ║
511
- ║ ║
512
- ║ DISPLAY MODES ║
513
- ║ ────────────────────────────────────────────────────────────────────────── ║
514
- ║ s Cycle summary detail (low → med → full) ║
515
- ║ l Cycle summary content (💬 short → 📖 context → 🎯 orders → ✏️ note)║
516
- ║ v Cycle detail lines (5 → 10 → 20 → 50) ║
517
- ║ S Cycle sort mode (alpha → status → value) ║
518
- ║ t Toggle timeline d Toggle daemon panel ║
519
- ║ g Show killed agents Z Hide sleeping agents ║
520
- ║ ║
521
- ║ AGENT CONTROL ║
522
- ║ ────────────────────────────────────────────────────────────────────────── ║
523
- ║ i/: Send instruction o Set standing orders ║
524
- ║ I Edit annotation Enter Approve (send Enter) ║
525
- ║ 1-5 Send number n New agent ║
526
- ║ x Kill agent z Toggle sleep ║
527
- ║ b Jump to red/attention V Edit agent value ║
528
- ║ ║
529
- ║ DAEMON CONTROL ║
530
- ║ ────────────────────────────────────────────────────────────────────────── ║
531
- ║ [ Start supervisor ] Stop supervisor ║
532
- ║ \\ Restart monitor w Toggle web dashboard ║
533
- ║ ║
534
- ║ OTHER ║
535
- ║ ────────────────────────────────────────────────────────────────────────── ║
536
- ║ y Copy mode (mouse sel) p Sync to tmux pane ║
537
- ║ ║
538
- ║ COMMAND BAR (i or :) ║
539
- ║ ────────────────────────────────────────────────────────────────────────── ║
540
- ║ Enter Send instruction Esc Clear & unfocus ║
541
- ║ Ctrl+E Multi-line mode Ctrl+O Set as standing order ║
542
- ║ Ctrl+Enter Send (multi-line) ║
543
- ║ ║
544
- ║ Press h or ? to close ║
545
- ╚══════════════════════════════════════════════════════════════════════════════╝
546
- """
547
-
548
- def render(self) -> Text:
549
- return Text(self.HELP_TEXT.strip())
550
-
551
-
552
- class DaemonPanel(Static):
553
- """Inline daemon panel with status and log viewer (like timeline)"""
554
-
555
- LOG_LINES_TO_SHOW = 8 # Number of log lines to display
556
-
557
- def __init__(self, tmux_session: str = "agents", *args, **kwargs):
558
- super().__init__(*args, **kwargs)
559
- self.tmux_session = tmux_session
560
- self.log_lines: list[str] = []
561
- self.monitor_state: Optional[MonitorDaemonState] = None
562
- self._log_file_pos = 0
563
-
564
- def on_mount(self) -> None:
565
- """Start log tailing when mounted"""
566
- self.set_interval(1.0, self._refresh_logs)
567
- self._refresh_logs()
568
-
569
- def _refresh_logs(self) -> None:
570
- """Refresh daemon status and logs"""
571
- from pathlib import Path
572
-
573
- # Only refresh if visible
574
- if not self.display:
575
- return
576
-
577
- # Update daemon state from Monitor Daemon
578
- self.monitor_state = get_monitor_daemon_state(self.tmux_session)
579
-
580
- # Read log lines from session-specific monitor_daemon.log
581
- session_dir = get_session_dir(self.tmux_session)
582
- log_file = session_dir / "monitor_daemon.log"
583
- if log_file.exists():
584
- try:
585
- with open(log_file, 'r') as f:
586
- if not self.log_lines:
587
- # First read: get last 100 lines of file
588
- all_lines = f.readlines()
589
- self.log_lines = [l.rstrip() for l in all_lines[-100:]]
590
- self._log_file_pos = f.tell()
591
- else:
592
- # Subsequent reads: only get new content
593
- f.seek(self._log_file_pos)
594
- new_content = f.read()
595
- self._log_file_pos = f.tell()
596
-
597
- if new_content:
598
- new_lines = new_content.strip().split('\n')
599
- self.log_lines.extend(new_lines)
600
- # Keep last 100 lines
601
- self.log_lines = self.log_lines[-100:]
602
- except (OSError, IOError, ValueError):
603
- # Log file not available, read error, or seek error
604
- pass
605
-
606
- self.refresh()
607
-
608
- def render(self) -> Text:
609
- """Render daemon panel inline (similar to timeline style)"""
610
- content = Text()
611
-
612
- # Header with status - match DaemonStatusBar format exactly
613
- content.append("🤖 Supervisor Daemon: ", style="bold")
614
-
615
- # Check Monitor Daemon state
616
- if self.monitor_state and not self.monitor_state.is_stale():
617
- state = self.monitor_state
618
- symbol, style = get_daemon_status_style(state.status)
619
-
620
- content.append(f"{symbol} ", style=style)
621
- content.append(f"{state.status}", style=style)
622
-
623
- # State details
624
- content.append(" │ ", style="dim")
625
- content.append(f"#{state.loop_count}", style="cyan")
626
- content.append(f" @{format_interval(state.current_interval)}", style="dim")
627
- last_loop = datetime.fromisoformat(state.last_loop_time) if state.last_loop_time else None
628
- content.append(f" ({format_ago(last_loop)})", style="dim")
629
- if state.total_supervisions > 0:
630
- content.append(f" sup:{state.total_supervisions}", style="magenta")
631
- else:
632
- # Monitor Daemon not running or stale
633
- content.append("○ ", style="red")
634
- content.append("stopped", style="red")
635
- # Show last activity if available from stale state
636
- if self.monitor_state and self.monitor_state.last_loop_time:
637
- try:
638
- last_time = datetime.fromisoformat(self.monitor_state.last_loop_time)
639
- content.append(f" (last: {format_ago(last_time)})", style="dim")
640
- except ValueError:
641
- pass
642
-
643
- # Controls hint
644
- content.append(" │ ", style="dim")
645
- content.append("[", style="bold green")
646
- content.append(":sup ", style="dim")
647
- content.append("]", style="bold red")
648
- content.append(":sup ", style="dim")
649
- content.append("\\", style="bold yellow")
650
- content.append(":mon", style="dim")
651
-
652
- content.append("\n")
653
-
654
- # Log lines
655
- display_lines = self.log_lines[-self.LOG_LINES_TO_SHOW:] if self.log_lines else []
656
-
657
- if not display_lines:
658
- content.append(" (no logs yet - daemon may not have run)", style="dim italic")
659
- content.append("\n")
660
- else:
661
- for line in display_lines:
662
- content.append(" ", style="")
663
- # Truncate line
664
- display_line = line[:120] if len(line) > 120 else line
665
-
666
- # Color based on content
667
- if "ERROR" in line or "error" in line:
668
- style = "red"
669
- elif "WARNING" in line or "warning" in line:
670
- style = "yellow"
671
- elif ">>>" in line:
672
- style = "bold cyan"
673
- elif "supervising" in line.lower() or "steering" in line.lower():
674
- style = "magenta"
675
- elif "Loop" in line:
676
- style = "dim cyan"
677
- else:
678
- style = "dim"
679
-
680
- content.append(display_line, style=style)
681
- content.append("\n")
682
-
683
- return content
684
-
685
-
686
- class SessionSummary(Static, can_focus=True):
687
- """Widget displaying expandable session summary"""
688
-
689
- expanded: reactive[bool] = reactive(True) # Start expanded
690
- detail_lines: reactive[int] = reactive(5) # Lines of output to show (5, 10, 20, 50)
691
- summary_detail: reactive[str] = reactive("low") # low, med, full
692
- summary_content_mode: reactive[str] = reactive("ai_short") # ai_short, ai_long, orders, annotation (#74)
693
-
694
- def __init__(self, session: Session, status_detector: StatusDetector, *args, **kwargs):
695
- super().__init__(*args, **kwargs)
696
- self.session = session
697
- self.status_detector = status_detector
698
- # Initialize from persisted session state, not hardcoded "running"
699
- self.detected_status = session.stats.current_state if session.stats.current_state else "running"
700
- self.current_activity = "Initializing..."
701
- # AI-generated summaries (from daemon's SummarizerComponent)
702
- self.ai_summary_short: str = "" # Short: current activity (~50 chars)
703
- self.ai_summary_context: str = "" # Context: wider context (~80 chars)
704
- self.pane_content: List[str] = [] # Cached pane content
705
- self.claude_stats: Optional[ClaudeSessionStats] = None # Token/interaction stats
706
- self.git_diff_stats: Optional[tuple] = None # (files, insertions, deletions)
707
- # Track if this is a stalled agent that hasn't been visited yet
708
- self.is_unvisited_stalled: bool = False
709
- # Track when status last changed (for immediate time-in-state updates)
710
- self._status_changed_at: Optional[datetime] = None
711
- self._last_known_status: str = self.detected_status
712
- # Start with expanded class since expanded=True by default
713
- self.add_class("expanded")
714
-
715
- def on_click(self) -> None:
716
- """Toggle expanded state on click"""
717
- self.expanded = not self.expanded
718
- # Notify parent app to save state
719
- self.post_message(self.ExpandedChanged(self.session.id, self.expanded))
720
- # Mark as visited if this is an unvisited stalled agent
721
- if self.is_unvisited_stalled:
722
- self.post_message(self.StalledAgentVisited(self.session.id))
723
-
724
- def on_focus(self) -> None:
725
- """Handle focus event - mark stalled agent as visited and update selection"""
726
- if self.is_unvisited_stalled:
727
- self.post_message(self.StalledAgentVisited(self.session.id))
728
- # Notify app to update selection highlighting
729
- self.post_message(self.SessionSelected(self.session.id))
730
-
731
- class SessionSelected(events.Message):
732
- """Message sent when a session is selected/focused"""
733
- def __init__(self, session_id: str):
734
- super().__init__()
735
- self.session_id = session_id
736
-
737
- class ExpandedChanged(events.Message):
738
- """Message sent when expanded state changes"""
739
- def __init__(self, session_id: str, expanded: bool):
740
- super().__init__()
741
- self.session_id = session_id
742
- self.expanded = expanded
743
-
744
- class StalledAgentVisited(events.Message):
745
- """Message sent when user visits a stalled agent (focus or click)"""
746
- def __init__(self, session_id: str):
747
- super().__init__()
748
- self.session_id = session_id
749
-
750
- def watch_expanded(self, expanded: bool) -> None:
751
- """Called when expanded state changes"""
752
- # Toggle CSS class for proper height
753
- if expanded:
754
- self.add_class("expanded")
755
- else:
756
- self.remove_class("expanded")
757
- self.refresh(layout=True)
758
- # Notify parent app to save state
759
- self.post_message(self.ExpandedChanged(self.session.id, expanded))
760
-
761
- def watch_detail_lines(self, detail_lines: int) -> None:
762
- """Called when detail_lines changes - force layout refresh"""
763
- self.refresh(layout=True)
764
-
765
- def update_status(self) -> None:
766
- """Update the detected status for this session.
767
-
768
- NOTE: This is now VIEW-ONLY. Time tracking is handled by the Monitor Daemon.
769
- We only detect status for display and capture pane content for the expanded view.
770
- """
771
- # detect_status returns (status, activity, pane_content) - reuse content to avoid
772
- # duplicate tmux subprocess calls (was 2 calls per widget, now just 1)
773
- new_status, self.current_activity, content = self.status_detector.detect_status(self.session)
774
- self.apply_status(new_status, self.current_activity, content)
775
-
776
- def apply_status(self, status: str, activity: str, content: str) -> None:
777
- """Apply pre-fetched status data to this widget.
778
-
779
- Used by parallel status updates to apply data fetched in background threads.
780
- Note: This still fetches claude_stats synchronously - used for single widget updates.
781
- """
782
- # Fetch claude stats (only for standalone update_status calls)
783
- claude_stats = get_session_stats(self.session)
784
- # Fetch git diff stats
785
- git_diff = None
786
- if self.session.start_directory:
787
- git_diff = get_git_diff_stats(self.session.start_directory)
788
- self.apply_status_no_refresh(status, activity, content, claude_stats, git_diff)
789
- self.refresh()
790
-
791
- def apply_status_no_refresh(self, status: str, activity: str, content: str, claude_stats: Optional[ClaudeSessionStats] = None, git_diff_stats: Optional[tuple] = None) -> None:
792
- """Apply pre-fetched status data without triggering refresh.
793
-
794
- Used for batched updates where the caller will refresh once at the end.
795
- All data including claude_stats should be pre-fetched in background thread.
796
- """
797
- self.current_activity = activity
798
-
799
- # Use pane content from detect_status (already fetched)
800
- if content:
801
- # Keep all lines including blanks for proper formatting, just strip trailing blanks
802
- lines = content.rstrip().split('\n')
803
- self.pane_content = lines[-50:] if lines else [] # Keep last 50 lines max
804
- else:
805
- self.pane_content = []
806
-
807
- # Update detected status for display
808
- # NOTE: Time tracking removed - Monitor Daemon is the single source of truth
809
- # The session.stats values are read from what Monitor Daemon has persisted
810
- # If session is asleep, keep the asleep status instead of the detected status
811
- new_status = "asleep" if self.session.is_asleep else status
812
-
813
- # Track status changes for immediate time-in-state reset (#73)
814
- if new_status != self._last_known_status:
815
- self._status_changed_at = datetime.now()
816
- self._last_known_status = new_status
817
-
818
- self.detected_status = new_status
819
-
820
- # Use pre-fetched claude stats (no file I/O on main thread)
821
- if claude_stats is not None:
822
- self.claude_stats = claude_stats
823
-
824
- # Use pre-fetched git diff stats
825
- if git_diff_stats is not None:
826
- self.git_diff_stats = git_diff_stats
827
-
828
- def watch_summary_detail(self, summary_detail: str) -> None:
829
- """Called when summary_detail changes"""
830
- self.refresh()
831
-
832
- def watch_summary_content_mode(self, summary_content_mode: str) -> None:
833
- """Called when summary_content_mode changes (#74)"""
834
- self.refresh()
835
-
836
- def render(self) -> Text:
837
- """Render session summary (compact or expanded)"""
838
- import shutil
839
- s = self.session
840
- stats = s.stats
841
- term_width = shutil.get_terminal_size().columns
842
-
843
- # Expansion indicator
844
- expand_icon = "▼" if self.expanded else "▶"
845
-
846
- # Calculate all values (only use what we need per level)
847
- uptime = calculate_uptime(self.session.start_time)
848
- repo_info = f"{s.repo_name or 'n/a'}:{s.branch or 'n/a'}"
849
- green_time, non_green_time = get_current_state_times(self.session.stats)
850
-
851
- # Get median work time from claude stats (or 0 if unavailable)
852
- median_work = self.claude_stats.median_work_time if self.claude_stats else 0.0
853
-
854
- # Status indicator - larger emoji circles based on detected status
855
- # Blue background matching Textual header/footer style
856
- bg = " on #0d2137"
857
- status_symbol, base_color = get_status_symbol(self.detected_status)
858
- status_color = f"bold {base_color}{bg}"
859
-
860
- # Permissiveness mode with emoji
861
- if s.permissiveness_mode == "bypass":
862
- perm_emoji = "🔥" # Fire - burning through all permissions
863
- elif s.permissiveness_mode == "permissive":
864
- perm_emoji = "🏃" # Running permissively
865
- else:
866
- perm_emoji = "👮" # Normal mode with permissions
867
-
868
- content = Text()
869
-
870
- # Determine name width based on detail level (more space in lower detail modes)
871
- if self.summary_detail == "low":
872
- name_width = 24
873
- elif self.summary_detail == "med":
874
- name_width = 20
875
- else: # full
876
- name_width = 16
877
-
878
- # Truncate name if needed
879
- display_name = s.name[:name_width].ljust(name_width)
880
-
881
- # Always show: status symbol, time in state, expand icon, agent name
882
- content.append(f"{status_symbol} ", style=status_color)
883
-
884
- # Show 🔔 indicator for unvisited stalled agents (needs attention)
885
- if self.is_unvisited_stalled:
886
- content.append("🔔", style=f"bold blink red{bg}")
887
- else:
888
- content.append(" ", style=f"dim{bg}") # Maintain alignment
889
-
890
- # Time in current state (directly after status light)
891
- # Use locally tracked change time if more recent than daemon's state_since (#73)
892
- state_start = None
893
- if self._status_changed_at:
894
- state_start = self._status_changed_at
895
- if stats.state_since:
896
- try:
897
- daemon_state_start = datetime.fromisoformat(stats.state_since)
898
- # Use whichever is more recent (our local detection or daemon's record)
899
- if state_start is None or daemon_state_start > state_start:
900
- state_start = daemon_state_start
901
- except (ValueError, TypeError):
902
- pass
903
- if state_start:
904
- elapsed = (datetime.now() - state_start).total_seconds()
905
- content.append(f"{format_duration(elapsed):>5} ", style=status_color)
906
- else:
907
- content.append(" - ", style=f"dim{bg}")
908
-
909
- # In list-mode, show focus indicator instead of expand icon
910
- if "list-mode" in self.classes:
911
- if self.has_focus:
912
- content.append("→ ", style=status_color)
913
- else:
914
- content.append(" ", style=status_color)
915
- else:
916
- content.append(f"{expand_icon} ", style=status_color)
917
- content.append(f"{display_name}", style=f"bold cyan{bg}")
918
-
919
- # Full detail: add repo:branch (padded to longest across all sessions)
920
- if self.summary_detail == "full":
921
- repo_width = getattr(self.app, 'max_repo_info_width', 18)
922
- content.append(f" {repo_info:<{repo_width}} ", style=f"bold dim{bg}")
923
-
924
- # Med/Full detail: add uptime, running time, stalled time
925
- if self.summary_detail in ("med", "full"):
926
- content.append(f" ↑{uptime:>5}", style=f"bold white{bg}")
927
- content.append(f" ▶{format_duration(green_time):>5}", style=f"bold green{bg}")
928
- content.append(f" ⏸{format_duration(non_green_time):>5}", style=f"bold red{bg}")
929
- # Full detail: show percentage active
930
- if self.summary_detail == "full":
931
- total_time = green_time + non_green_time
932
- pct = (green_time / total_time * 100) if total_time > 0 else 0
933
- content.append(f" {pct:>3.0f}%", style=f"bold green{bg}" if pct >= 50 else f"bold red{bg}")
934
-
935
- # Always show: token usage (from Claude Code)
936
- # ALIGNMENT: context indicator is always 7 chars " c@NNN%" (or placeholder)
937
- if self.claude_stats is not None:
938
- content.append(f" Σ{format_tokens(self.claude_stats.total_tokens):>6}", style=f"bold orange1{bg}")
939
- # Show current context window usage as percentage (assuming 200K max)
940
- if self.claude_stats.current_context_tokens > 0:
941
- max_context = 200_000 # Claude models have 200K context window
942
- ctx_pct = min(100, self.claude_stats.current_context_tokens / max_context * 100)
943
- content.append(f" c@{ctx_pct:>3.0f}%", style=f"bold orange1{bg}")
944
- else:
945
- content.append(" c@ -%", style=f"dim orange1{bg}")
946
- else:
947
- content.append(" - c@ -%", style=f"dim orange1{bg}")
948
-
949
- # Git diff stats (outstanding changes since last commit)
950
- # ALIGNMENT: Use fixed widths - low/med: 4 chars "Δnn ", full: 16 chars "Δnn +nnnn -nnnn"
951
- # Large line counts are shortened: 173242 -> "173K", 1234567 -> "1.2M"
952
- if self.git_diff_stats:
953
- files, ins, dels = self.git_diff_stats
954
- if self.summary_detail == "full":
955
- # Full: show files and lines with fixed widths
956
- content.append(f" Δ{files:>2}", style=f"bold magenta{bg}")
957
- content.append(f" +{format_line_count(ins):>4}", style=f"bold green{bg}")
958
- content.append(f" -{format_line_count(dels):>4}", style=f"bold red{bg}")
959
- else:
960
- # Compact: just files changed (fixed 4 char width)
961
- content.append(f" Δ{files:>2}", style=f"bold magenta{bg}" if files > 0 else f"dim{bg}")
962
- else:
963
- # Placeholder matching width for alignment
964
- if self.summary_detail == "full":
965
- content.append(" Δ- + - - ", style=f"dim{bg}")
966
- else:
967
- content.append(" Δ-", style=f"dim{bg}")
968
-
969
- # Med/Full detail: add median work time (p50 autonomous work duration)
970
- if self.summary_detail in ("med", "full"):
971
- work_str = format_duration(median_work) if median_work > 0 else "0s"
972
- content.append(f" ⏱{work_str:>5}", style=f"bold blue{bg}")
973
-
974
- # Always show: permission mode, human interactions, robot supervisions
975
- content.append(f" {perm_emoji}", style=f"bold white{bg}")
976
- # Human interaction count = total interactions - robot interventions
977
- if self.claude_stats is not None:
978
- human_count = max(0, self.claude_stats.interaction_count - stats.steers_count)
979
- content.append(f" 👤{human_count:>3}", style=f"bold yellow{bg}")
980
- else:
981
- content.append(" 👤 -", style=f"dim yellow{bg}")
982
- # Robot supervision count (from daemon steers) - 3 digit padding
983
- content.append(f" 🤖{stats.steers_count:>3}", style=f"bold cyan{bg}")
984
-
985
- # Standing orders indicator (after supervision count) - always show for alignment
986
- if s.standing_instructions:
987
- if s.standing_orders_complete:
988
- content.append(" ✓", style=f"bold green{bg}")
989
- elif s.standing_instructions_preset:
990
- # Show preset name (truncated to fit)
991
- preset_display = f" {s.standing_instructions_preset[:8]}"
992
- content.append(preset_display, style=f"bold cyan{bg}")
993
- else:
994
- content.append(" 📋", style=f"bold yellow{bg}")
995
- else:
996
- content.append(" ➖", style=f"bold dim{bg}") # No instructions indicator
997
-
998
- # Agent value indicator (#61)
999
- # Full detail: show numeric value with money bag
1000
- # Short/med: show priority chevrons (⏫ high, ⏹ normal, ⏬ low)
1001
- if self.summary_detail == "full":
1002
- content.append(f" 💰{s.agent_value:>4}", style=f"bold magenta{bg}")
1003
- else:
1004
- # Priority icon based on value relative to default 1000
1005
- # Note: Rich measures ⏹️ as 2 cells but ⏫️/⏬️ as 3 cells, so we add
1006
- # a trailing space to ⏹️ for alignment
1007
- if s.agent_value > 1000:
1008
- content.append(" ⏫️", style=f"bold red{bg}") # High priority
1009
- elif s.agent_value < 1000:
1010
- content.append(" ⏬️", style=f"bold blue{bg}") # Low priority
1011
- else:
1012
- content.append(" ⏹️ ", style=f"dim{bg}") # Normal (extra space for alignment)
1013
-
1014
- if not self.expanded:
1015
- # Compact view: show content based on summary_content_mode (#74)
1016
- content.append(" │ ", style=f"bold dim{bg}")
1017
- # Calculate remaining space for content
1018
- current_len = len(content.plain)
1019
- remaining = max(20, term_width - current_len - 2)
1020
-
1021
- # Determine what to show based on mode
1022
- mode = self.summary_content_mode
1023
-
1024
- if mode == "annotation":
1025
- # Show human annotation (✏️ icon)
1026
- if s.human_annotation:
1027
- content.append(f"✏️ {s.human_annotation[:remaining-3]}", style=f"bold magenta{bg}")
1028
- else:
1029
- content.append("✏️ (no annotation)", style=f"dim italic{bg}")
1030
- elif mode == "orders":
1031
- # Show standing orders (🎯 icon, ✓ if complete)
1032
- if s.standing_instructions:
1033
- if s.standing_orders_complete:
1034
- style = f"bold green{bg}"
1035
- prefix = "🎯✓ "
1036
- elif s.standing_instructions_preset:
1037
- style = f"bold cyan{bg}"
1038
- prefix = f"🎯 {s.standing_instructions_preset}: "
1039
- else:
1040
- style = f"bold italic yellow{bg}"
1041
- prefix = "🎯 "
1042
- display_text = f"{prefix}{format_standing_instructions(s.standing_instructions, remaining - len(prefix))}"
1043
- content.append(display_text[:remaining], style=style)
1044
- else:
1045
- content.append("🎯 (no standing orders)", style=f"dim italic{bg}")
1046
- elif mode == "ai_long":
1047
- # ai_long: show context summary (📖 icon - wider context/goal from AI)
1048
- if self.ai_summary_context:
1049
- content.append(f"📖 {self.ai_summary_context[:remaining-3]}", style=f"bold italic{bg}")
1050
- else:
1051
- content.append("📖 (awaiting context...)", style=f"dim italic{bg}")
1052
- else:
1053
- # ai_short: show short summary (💬 icon - current activity from AI)
1054
- if self.ai_summary_short:
1055
- content.append(f"💬 {self.ai_summary_short[:remaining-3]}", style=f"bold italic{bg}")
1056
- else:
1057
- content.append("💬 (awaiting summary...)", style=f"dim italic{bg}")
1058
-
1059
- # Pad to fill terminal width
1060
- current_len = len(content.plain)
1061
- if current_len < term_width:
1062
- content.append(" " * (term_width - current_len), style=f"{bg}")
1063
- return content
1064
-
1065
- # Pad header line to full width before adding expanded content
1066
- current_len = len(content.plain)
1067
- if current_len < term_width:
1068
- content.append(" " * (term_width - current_len), style=f"{bg}")
1069
-
1070
- # Expanded view: show standing instructions first if set
1071
- if s.standing_instructions:
1072
- content.append("\n")
1073
- content.append(" ")
1074
- display_instr = format_standing_instructions(s.standing_instructions)
1075
- if s.standing_orders_complete:
1076
- content.append("│ ", style="bold green")
1077
- content.append("✓ ", style="bold green")
1078
- content.append(display_instr, style="green")
1079
- elif s.standing_instructions_preset:
1080
- content.append("│ ", style="cyan")
1081
- content.append(f"{s.standing_instructions_preset}: ", style="bold cyan")
1082
- content.append(display_instr, style="cyan")
1083
- else:
1084
- content.append("│ ", style="cyan")
1085
- content.append("📋 ", style="yellow")
1086
- content.append(display_instr, style="italic yellow")
1087
-
1088
- # Expanded view: show pane content based on detail_lines setting
1089
- lines_to_show = self.detail_lines
1090
- # Account for standing instructions line if present
1091
- if s.standing_instructions:
1092
- lines_to_show = max(1, lines_to_show - 1)
1093
-
1094
- # Get the last N lines of pane content
1095
- pane_lines = self.pane_content[-lines_to_show:] if self.pane_content else []
1096
-
1097
- # Show pane output lines
1098
- for line in pane_lines:
1099
- content.append("\n")
1100
- content.append(" ") # Indent
1101
- # Truncate long lines and style based on content
1102
- display_line = line[:100] + "..." if len(line) > 100 else line
1103
- prefix_style, content_style = style_pane_line(line)
1104
- content.append("│ ", style=prefix_style)
1105
- content.append(display_line, style=content_style)
1106
-
1107
- # If no pane content and no standing instructions shown above, show placeholder
1108
- if not pane_lines and not s.standing_instructions:
1109
- content.append("\n")
1110
- content.append(" ") # Indent
1111
- content.append("│ ", style="cyan")
1112
- content.append("(no output)", style="dim italic")
1113
-
1114
- return content
1115
-
1116
-
1117
- class PreviewPane(Static):
1118
- """Preview pane showing focused agent's terminal output in list+preview mode."""
1119
-
1120
- content_lines: reactive[List[str]] = reactive(list, init=False)
1121
- session_name: str = ""
1122
-
1123
- def __init__(self, **kwargs):
1124
- super().__init__(**kwargs)
1125
- self.content_lines = []
1126
-
1127
- def render(self) -> Text:
1128
- content = Text()
1129
- # Use widget width for layout, with sensible fallback
1130
- pane_width = self.size.width if self.size.width > 0 else 80
1131
-
1132
- # Header with session name - pad to full pane width
1133
- header = f"─── {self.session_name} " if self.session_name else "─── Preview "
1134
- content.append(header, style="bold cyan")
1135
- content.append("─" * max(0, pane_width - len(header)), style="dim")
1136
- content.append("\n")
1137
-
1138
- if not self.content_lines:
1139
- content.append("(no output)", style="dim italic")
1140
- else:
1141
- # Calculate available lines based on widget height
1142
- # Reserve 2 lines for header and some padding
1143
- available_lines = max(10, self.size.height - 2) if self.size.height > 0 else 30
1144
- # Show last N lines of output with ANSI color support
1145
- # Truncate lines to pane width to match tmux display
1146
- max_line_len = max(pane_width - 1, 40) # Leave room for newline, minimum 40
1147
- for line in self.content_lines[-available_lines:]:
1148
- # Truncate long lines to pane width
1149
- display_line = line[:max_line_len] if len(line) > max_line_len else line
1150
- # Parse ANSI escape sequences to preserve colors from tmux
1151
- # Note: Text.from_ansi() strips trailing newlines, so add newline separately
1152
- content.append(Text.from_ansi(display_line))
1153
- content.append("\n")
1154
-
1155
- return content
1156
-
1157
- def update_from_widget(self, widget: "SessionSummary") -> None:
1158
- """Update preview content from a SessionSummary widget."""
1159
- self.session_name = widget.session.name
1160
- self.content_lines = list(widget.pane_content) if widget.pane_content else []
1161
- self.refresh()
1162
-
1163
-
1164
- class CommandBar(Static):
1165
- """Inline command bar for sending instructions to agents.
1166
-
1167
- Supports single-line (Input) and multi-line (TextArea) modes.
1168
- Toggle with Ctrl+E. Send with Enter (single) or Ctrl+Enter (multi).
1169
- Use Ctrl+O to set as standing order instead of sending.
1170
-
1171
- Modes:
1172
- - "send": Default mode for sending instructions to an agent
1173
- - "standing_orders": Mode for editing standing orders for an agent
1174
- - "new_agent_dir": First step of new agent creation - enter working directory
1175
- - "new_agent_name": Second step of new agent creation - enter agent name
1176
- - "new_agent_perms": Third step of new agent creation - choose permission mode
1177
-
1178
- Key handling is done via on_key() since Input/TextArea consume most keys.
1179
- """
1180
-
1181
- expanded = reactive(False) # Toggle single/multi-line mode
1182
- target_session: Optional[str] = None
1183
- mode: str = "send" # "send", "standing_orders", "new_agent_dir", "new_agent_name", or "new_agent_perms"
1184
- new_agent_dir: Optional[str] = None # Store directory between steps
1185
- new_agent_name: Optional[str] = None # Store name between steps
1186
-
1187
- class SendRequested(Message):
1188
- """Message sent when user wants to send text to a session."""
1189
- def __init__(self, session_name: str, text: str):
1190
- super().__init__()
1191
- self.session_name = session_name
1192
- self.text = text
1193
-
1194
- class StandingOrderRequested(Message):
1195
- """Message sent when user wants to set a standing order."""
1196
- def __init__(self, session_name: str, text: str):
1197
- super().__init__()
1198
- self.session_name = session_name
1199
- self.text = text
1200
-
1201
- class NewAgentRequested(Message):
1202
- """Message sent when user wants to create a new agent."""
1203
- def __init__(self, agent_name: str, directory: Optional[str] = None, bypass_permissions: bool = False):
1204
- super().__init__()
1205
- self.agent_name = agent_name
1206
- self.directory = directory
1207
- self.bypass_permissions = bypass_permissions
1208
-
1209
- class ValueUpdated(Message):
1210
- """Message sent when user updates agent value (#61)."""
1211
- def __init__(self, session_name: str, value: int):
1212
- super().__init__()
1213
- self.session_name = session_name
1214
- self.value = value
1215
-
1216
- class AnnotationUpdated(Message):
1217
- """Message sent when user updates human annotation (#74)."""
1218
- def __init__(self, session_name: str, annotation: str):
1219
- super().__init__()
1220
- self.session_name = session_name
1221
- self.annotation = annotation
1222
-
1223
- def compose(self) -> ComposeResult:
1224
- """Create command bar widgets."""
1225
- with Horizontal(id="cmd-bar-container"):
1226
- yield Label("", id="target-label")
1227
- yield Input(id="cmd-input", placeholder="Type instruction (Enter to send)...", disabled=True)
1228
- yield TextArea(id="cmd-textarea", classes="hidden", disabled=True)
1229
- yield Label("[^E]", id="expand-hint")
1230
-
1231
- def on_mount(self) -> None:
1232
- """Initialize command bar state."""
1233
- self._update_target_label()
1234
- # Ensure widgets start disabled to prevent auto-focus
1235
- self.query_one("#cmd-input", Input).disabled = True
1236
- self.query_one("#cmd-textarea", TextArea).disabled = True
1237
-
1238
- def _update_target_label(self) -> None:
1239
- """Update the target session label based on mode."""
1240
- label = self.query_one("#target-label", Label)
1241
- input_widget = self.query_one("#cmd-input", Input)
1242
-
1243
- if self.mode == "new_agent_dir":
1244
- label.update("[New Agent: Directory] ")
1245
- input_widget.placeholder = "Enter working directory path..."
1246
- elif self.mode == "new_agent_name":
1247
- label.update("[New Agent: Name] ")
1248
- input_widget.placeholder = "Enter agent name (or Enter to accept default)..."
1249
- elif self.mode == "new_agent_perms":
1250
- label.update("[New Agent: Permissions] ")
1251
- input_widget.placeholder = "Type 'bypass' for --dangerously-skip-permissions, or Enter for normal..."
1252
- elif self.mode == "standing_orders":
1253
- if self.target_session:
1254
- label.update(f"[{self.target_session} Standing Orders] ")
1255
- else:
1256
- label.update("[Standing Orders] ")
1257
- input_widget.placeholder = "Enter standing orders (or empty to clear)..."
1258
- elif self.mode == "value":
1259
- if self.target_session:
1260
- label.update(f"[{self.target_session} Value] ")
1261
- else:
1262
- label.update("[Value] ")
1263
- input_widget.placeholder = "Enter priority value (1000 = normal, higher = more important)..."
1264
- elif self.mode == "annotation":
1265
- if self.target_session:
1266
- label.update(f"[{self.target_session} Annotation] ")
1267
- else:
1268
- label.update("[Annotation] ")
1269
- input_widget.placeholder = "Enter human annotation (or empty to clear)..."
1270
- elif self.target_session:
1271
- label.update(f"[{self.target_session}] ")
1272
- input_widget.placeholder = "Type instruction (Enter to send)..."
1273
- else:
1274
- label.update("[no session] ")
1275
- input_widget.placeholder = "Type instruction (Enter to send)..."
1276
-
1277
- def set_target(self, session_name: Optional[str]) -> None:
1278
- """Set the target session for commands."""
1279
- self.target_session = session_name
1280
- self.mode = "send" # Reset to send mode when target changes
1281
- self._update_target_label()
1282
-
1283
- def set_mode(self, mode: str) -> None:
1284
- """Set the command bar mode ('send' or 'new_agent')."""
1285
- self.mode = mode
1286
- self._update_target_label()
1287
-
1288
- def watch_expanded(self, expanded: bool) -> None:
1289
- """Toggle between single-line and multi-line mode."""
1290
- input_widget = self.query_one("#cmd-input", Input)
1291
- textarea = self.query_one("#cmd-textarea", TextArea)
1292
-
1293
- if expanded:
1294
- # Switch to multi-line
1295
- input_widget.add_class("hidden")
1296
- input_widget.disabled = True
1297
- textarea.remove_class("hidden")
1298
- textarea.disabled = False
1299
- # Transfer content
1300
- textarea.text = input_widget.value
1301
- input_widget.value = ""
1302
- textarea.focus()
1303
- else:
1304
- # Switch to single-line
1305
- textarea.add_class("hidden")
1306
- textarea.disabled = True
1307
- input_widget.remove_class("hidden")
1308
- input_widget.disabled = False
1309
- # Transfer content (first line only for single-line)
1310
- if textarea.text:
1311
- first_line = textarea.text.split('\n')[0]
1312
- input_widget.value = first_line
1313
- textarea.text = ""
1314
- input_widget.focus()
1315
-
1316
- def on_key(self, event: events.Key) -> None:
1317
- """Handle key events for command bar shortcuts."""
1318
- if event.key == "ctrl+e":
1319
- self.action_toggle_expand()
1320
- event.stop()
1321
- elif event.key == "ctrl+o":
1322
- self.action_set_standing_order()
1323
- event.stop()
1324
- elif event.key == "escape":
1325
- self.action_clear_and_unfocus()
1326
- event.stop()
1327
- elif event.key == "ctrl+enter" and self.expanded:
1328
- self.action_send_multiline()
1329
- event.stop()
1330
-
1331
- def on_input_submitted(self, event: Input.Submitted) -> None:
1332
- """Handle Enter in single-line mode."""
1333
- if event.input.id == "cmd-input":
1334
- text = event.value.strip()
1335
-
1336
- if self.mode == "new_agent_dir":
1337
- # Step 1: Directory entered, validate and move to name step
1338
- # Note: _handle_new_agent_dir sets input value to default name, don't clear it
1339
- self._handle_new_agent_dir(text if text else None)
1340
- return
1341
- elif self.mode == "new_agent_name":
1342
- # Step 2: Name entered (or default accepted), move to permissions step
1343
- # If empty, use the pre-filled default
1344
- name = text if text else event.input.value.strip()
1345
- if not name:
1346
- # Derive from directory as fallback
1347
- from pathlib import Path
1348
- name = Path(self.new_agent_dir).name if self.new_agent_dir else "agent"
1349
- self._handle_new_agent_name(name)
1350
- event.input.value = ""
1351
- return
1352
- elif self.mode == "new_agent_perms":
1353
- # Step 3: Permissions chosen, create agent
1354
- bypass = text.lower().strip() in ("bypass", "y", "yes", "!")
1355
- self._create_new_agent(self.new_agent_name, bypass)
1356
- event.input.value = ""
1357
- self.action_clear_and_unfocus()
1358
- return
1359
- elif self.mode == "standing_orders":
1360
- # Set standing orders (empty string clears them)
1361
- self._set_standing_order(text)
1362
- event.input.value = ""
1363
- self.action_clear_and_unfocus()
1364
- return
1365
- elif self.mode == "value":
1366
- # Set agent value (#61)
1367
- self._set_value(text)
1368
- event.input.value = ""
1369
- self.action_clear_and_unfocus()
1370
- return
1371
- elif self.mode == "annotation":
1372
- # Set human annotation (empty string clears it)
1373
- self._set_annotation(text)
1374
- event.input.value = ""
1375
- self.action_clear_and_unfocus()
1376
- return
1377
-
1378
- # Default "send" mode
1379
- if not text:
1380
- return
1381
- self._send_message(text)
1382
- event.input.value = ""
1383
- self.action_clear_and_unfocus()
1384
-
1385
- def _send_message(self, text: str) -> None:
1386
- """Send message to target session."""
1387
- if not self.target_session or not text.strip():
1388
- return
1389
- self.post_message(self.SendRequested(self.target_session, text.strip()))
1390
-
1391
- def _handle_new_agent_dir(self, directory: Optional[str]) -> None:
1392
- """Handle directory input for new agent creation.
1393
-
1394
- Validates directory and transitions to name input step.
1395
- """
1396
- from pathlib import Path
1397
-
1398
- # Expand ~ and resolve path
1399
- if directory:
1400
- dir_path = Path(directory).expanduser().resolve()
1401
- if not dir_path.exists():
1402
- # Create the directory
1403
- try:
1404
- dir_path.mkdir(parents=True, exist_ok=True)
1405
- self.app.notify(f"Created directory: {dir_path}", severity="information")
1406
- except OSError as e:
1407
- self.app.notify(f"Failed to create directory: {e}", severity="error")
1408
- return
1409
- if not dir_path.is_dir():
1410
- self.app.notify(f"Not a directory: {dir_path}", severity="error")
1411
- return
1412
- self.new_agent_dir = str(dir_path)
1413
- else:
1414
- # Use current working directory if none specified
1415
- self.new_agent_dir = str(Path.cwd())
1416
-
1417
- # Derive default agent name from directory basename
1418
- default_name = Path(self.new_agent_dir).name
1419
-
1420
- # Transition to name step
1421
- self.mode = "new_agent_name"
1422
- self._update_target_label()
1423
-
1424
- # Pre-fill the input with the default name
1425
- input_widget = self.query_one("#cmd-input", Input)
1426
- input_widget.value = default_name
1427
-
1428
- def _handle_new_agent_name(self, name: str) -> None:
1429
- """Handle name input for new agent creation.
1430
-
1431
- Stores the name and transitions to permissions step.
1432
- """
1433
- self.new_agent_name = name
1434
-
1435
- # Transition to permissions step
1436
- self.mode = "new_agent_perms"
1437
- self._update_target_label()
1438
-
1439
- def _create_new_agent(self, name: str, bypass_permissions: bool = False) -> None:
1440
- """Create a new agent with the given name, directory, and permission mode."""
1441
- self.post_message(self.NewAgentRequested(name, self.new_agent_dir, bypass_permissions))
1442
- # Reset state
1443
- self.new_agent_dir = None
1444
- self.new_agent_name = None
1445
- self.mode = "send"
1446
- self._update_target_label()
1447
-
1448
- def _set_standing_order(self, text: str) -> None:
1449
- """Set text as standing order (empty string clears orders)."""
1450
- if not self.target_session:
1451
- return
1452
- self.post_message(self.StandingOrderRequested(self.target_session, text.strip()))
1453
-
1454
- def _set_value(self, text: str) -> None:
1455
- """Set agent value (#61)."""
1456
- if not self.target_session:
1457
- return
1458
- try:
1459
- value = int(text.strip()) if text.strip() else 1000
1460
- if value < 0 or value > 9999:
1461
- self.app.notify("Value must be between 0 and 9999", severity="error")
1462
- return
1463
- self.post_message(self.ValueUpdated(self.target_session, value))
1464
- except ValueError:
1465
- # Invalid input, notify user but don't crash
1466
- self.app.notify("Invalid value - please enter a number", severity="error")
1467
-
1468
- def _set_annotation(self, text: str) -> None:
1469
- """Set human annotation (empty string clears it) (#74)."""
1470
- if not self.target_session:
1471
- return
1472
- self.post_message(self.AnnotationUpdated(self.target_session, text.strip()))
1473
-
1474
- def action_toggle_expand(self) -> None:
1475
- """Toggle between single and multi-line mode."""
1476
- self.expanded = not self.expanded
1477
-
1478
- def action_send_multiline(self) -> None:
1479
- """Send content from multi-line textarea."""
1480
- textarea = self.query_one("#cmd-textarea", TextArea)
1481
- self._send_message(textarea.text)
1482
- textarea.text = ""
1483
- self.action_clear_and_unfocus()
1484
-
1485
- def action_set_standing_order(self) -> None:
1486
- """Set current content as standing order."""
1487
- if self.expanded:
1488
- textarea = self.query_one("#cmd-textarea", TextArea)
1489
- self._set_standing_order(textarea.text)
1490
- textarea.text = ""
1491
- else:
1492
- input_widget = self.query_one("#cmd-input", Input)
1493
- self._set_standing_order(input_widget.value)
1494
- input_widget.value = ""
1495
-
1496
- def action_clear_and_unfocus(self) -> None:
1497
- """Clear input and unfocus command bar."""
1498
- if self.expanded:
1499
- textarea = self.query_one("#cmd-textarea", TextArea)
1500
- textarea.text = ""
1501
- else:
1502
- input_widget = self.query_one("#cmd-input", Input)
1503
- input_widget.value = ""
1504
- # Reset mode and state
1505
- self.mode = "send"
1506
- self.new_agent_dir = None
1507
- self.new_agent_name = None
1508
- self._update_target_label()
1509
- # Let parent handle unfocus
1510
- self.post_message(self.ClearRequested())
1511
-
1512
- def focus_input(self) -> None:
1513
- """Focus the command bar input and enable it."""
1514
- input_widget = self.query_one("#cmd-input", Input)
1515
- input_widget.disabled = False
1516
- input_widget.focus()
1517
-
1518
- class ClearRequested(Message):
1519
- """Message sent when user clears the command bar."""
1520
- pass
1521
-
1522
-
1523
- class SupervisorTUI(App):
108
+ class SupervisorTUI(
109
+ NavigationActionsMixin,
110
+ ViewActionsMixin,
111
+ DaemonActionsMixin,
112
+ SessionActionsMixin,
113
+ InputActionsMixin,
114
+ App,
115
+ ):
1524
116
  """Overcode Supervisor TUI"""
1525
117
 
1526
118
  # Disable any size restrictions
1527
119
  AUTO_FOCUS = None
1528
120
 
1529
- CSS = """
1530
- Screen {
1531
- background: $background;
1532
- overflow: hidden;
1533
- height: 100%;
1534
- }
1535
-
1536
- Header {
1537
- dock: top;
1538
- height: 1;
1539
- }
1540
-
1541
- #daemon-status {
1542
- height: 1;
1543
- width: 100%;
1544
- background: $panel;
1545
- padding: 0 1;
1546
- }
1547
-
1548
- #timeline {
1549
- height: auto;
1550
- min-height: 4;
1551
- max-height: 20;
1552
- width: 100%;
1553
- background: $surface;
1554
- padding: 0 1;
1555
- border-bottom: solid $panel;
1556
- }
1557
-
1558
- #sessions-container {
1559
- height: 1fr;
1560
- width: 100%;
1561
- overflow: auto auto;
1562
- padding: 0;
1563
- }
1564
-
1565
- /* In list+preview mode, sessions container is compact (auto-size to content) */
1566
- #sessions-container.list-mode {
1567
- height: auto;
1568
- max-height: 30%;
1569
- }
1570
-
1571
- SessionSummary {
1572
- height: 1;
1573
- width: 100%;
1574
- padding: 0 1;
1575
- margin: 0;
1576
- border: none;
1577
- background: $surface;
1578
- overflow: hidden;
1579
- }
1580
-
1581
- SessionSummary.expanded {
1582
- height: auto;
1583
- min-height: 2;
1584
- max-height: 55; /* Support up to 50 lines detail + header/instructions */
1585
- background: #1c1c1c;
1586
- border-bottom: solid #5588aa;
1587
- }
1588
-
1589
- SessionSummary:hover {
1590
- background: $boost;
1591
- }
1592
-
1593
- SessionSummary:focus {
1594
- background: #2d4a5a;
1595
- text-style: bold;
1596
- }
1597
-
1598
- /* .selected class preserves highlight when app loses focus */
1599
- SessionSummary.selected {
1600
- background: #2d4a5a;
1601
- text-style: bold;
1602
- }
1603
-
1604
- /* Terminated/killed sessions shown as ghosts */
1605
- SessionSummary.terminated {
1606
- background: #1a1a1a;
1607
- color: #666666;
1608
- text-style: italic;
1609
- }
1610
-
1611
- SessionSummary.terminated:focus {
1612
- background: #2a2a2a;
1613
- }
1614
-
1615
- #help-text {
1616
- dock: bottom;
1617
- height: 1;
1618
- width: 100%;
1619
- background: $panel;
1620
- color: $text-muted;
1621
- padding: 0 1;
1622
- }
1623
-
1624
- #help-overlay {
1625
- display: none;
1626
- layer: above;
1627
- dock: top;
1628
- width: 100%;
1629
- height: 100%;
1630
- background: $surface 90%;
1631
- padding: 1 2;
1632
- overflow-y: auto;
1633
- }
1634
-
1635
- #help-overlay.visible {
1636
- display: block;
1637
- }
1638
-
1639
- #daemon-panel {
1640
- display: none;
1641
- height: auto;
1642
- min-height: 2;
1643
- max-height: 12;
1644
- width: 100%;
1645
- background: $surface;
1646
- padding: 0 1;
1647
- border-bottom: solid $panel;
1648
- }
1649
-
1650
- CommandBar {
1651
- dock: bottom;
1652
- height: auto;
1653
- min-height: 1;
1654
- max-height: 8;
1655
- width: 100%;
1656
- background: $surface;
1657
- border-top: solid $primary;
1658
- padding: 0 1;
1659
- display: none; /* Hidden by default, shown with 'i' key */
1660
- }
1661
-
1662
- CommandBar.visible {
1663
- display: block;
1664
- }
1665
-
1666
- #cmd-bar-container {
1667
- width: 100%;
1668
- height: auto;
1669
- }
1670
-
1671
- #target-label {
1672
- width: auto;
1673
- color: $primary;
1674
- text-style: bold;
1675
- }
1676
-
1677
- #cmd-input {
1678
- width: 1fr;
1679
- min-width: 20;
1680
- }
1681
-
1682
- #cmd-input.hidden {
1683
- display: none;
1684
- }
1685
-
1686
- #cmd-textarea {
1687
- width: 1fr;
1688
- min-width: 20;
1689
- height: 4;
1690
- }
1691
-
1692
- #cmd-textarea.hidden {
1693
- display: none;
1694
- }
1695
-
1696
- #expand-hint {
1697
- width: auto;
1698
- color: $text-muted;
1699
- padding-left: 1;
1700
- }
1701
-
1702
- /* List mode - always collapsed */
1703
- /* List mode: compact single-line, no borders/dividers */
1704
- SessionSummary.list-mode {
1705
- height: 1;
1706
- border: none;
1707
- margin: 0;
1708
- padding: 0 1;
1709
- }
1710
-
1711
- /* Preview pane - hidden by default, shown via .visible class */
1712
- #preview-pane {
1713
- display: none;
1714
- height: 1fr;
1715
- border-top: solid $primary;
1716
- padding: 0 1;
1717
- background: $surface;
1718
- overflow-y: auto;
1719
- }
1720
-
1721
- #preview-pane.visible {
1722
- display: block;
1723
- }
1724
-
1725
- /* Focused indicator in list mode */
1726
- SessionSummary:focus.list-mode {
1727
- background: $accent;
1728
- }
1729
- """
121
+ # Load CSS from external file
122
+ CSS_PATH = "tui.tcss"
123
+
1730
124
 
1731
125
  BINDINGS = [
1732
126
  ("q", "quit", "Quit"),
@@ -1736,8 +130,8 @@ class SupervisorTUI(App):
1736
130
  ("t", "toggle_timeline", "Toggle timeline"),
1737
131
  ("v", "cycle_detail", "Cycle detail"),
1738
132
  ("s", "cycle_summary", "Summary detail"),
1739
- ("e", "expand_all", "Expand all"),
1740
- ("c", "collapse_all", "Collapse all"),
133
+ ("e", "toggle_expand_all", "Expand/Collapse"),
134
+ ("c", "sync_to_main_and_clear", "Sync main+clear"),
1741
135
  ("space", "toggle_focused", "Toggle"),
1742
136
  # Navigation between agents
1743
137
  ("j", "focus_next_session", "Next"),
@@ -1759,6 +153,7 @@ class SupervisorTUI(App):
1759
153
  ("r", "manual_refresh", "Refresh"),
1760
154
  # Agent management
1761
155
  ("x", "kill_focused", "Kill agent"),
156
+ ("R", "restart_focused", "Restart agent"),
1762
157
  ("n", "new_agent", "New agent"),
1763
158
  # Send Enter to focused agent (for approvals)
1764
159
  ("enter", "send_enter_to_focused", "Send Enter"),
@@ -1790,6 +185,16 @@ class SupervisorTUI(App):
1790
185
  ("l", "cycle_summary_content", "Summary content"),
1791
186
  # Edit human annotation (#74)
1792
187
  ("I", "focus_human_annotation", "Annotation"),
188
+ # Baseline time adjustment for mean spin calculation
189
+ ("comma", "baseline_back", "Baseline -15m"),
190
+ ("full_stop", "baseline_forward", "Baseline +15m"),
191
+ ("0", "baseline_reset", "Reset baseline"),
192
+ # Monochrome mode for terminals with ANSI issues (#138)
193
+ ("M", "toggle_monochrome", "Monochrome"),
194
+ # Toggle between token count and dollar cost display
195
+ ("dollar_sign", "toggle_cost_display", "Show $"),
196
+ # Transport/handover - prepare all sessions for handoff (double-press)
197
+ ("H", "transport_all", "Handover all"),
1793
198
  ]
1794
199
 
1795
200
  # Detail level cycles through 5, 10, 20, 50 lines
@@ -1807,6 +212,9 @@ class SupervisorTUI(App):
1807
212
  show_terminated: reactive[bool] = reactive(False) # show killed sessions in timeline
1808
213
  hide_asleep: reactive[bool] = reactive(False) # hide sleeping agents from display
1809
214
  summary_content_mode: reactive[str] = reactive("ai_short") # what to show in summary (#74)
215
+ baseline_minutes: reactive[int] = reactive(0) # 0=now, 15/30/.../180 = minutes back for mean spin
216
+ monochrome: reactive[bool] = reactive(False) # B&W mode for terminals with ANSI issues (#138)
217
+ show_cost: reactive[bool] = reactive(False) # Show $ cost instead of token counts
1810
218
 
1811
219
  def __init__(self, tmux_session: str = "agents", diagnostics: bool = False):
1812
220
  super().__init__()
@@ -1854,6 +262,12 @@ class SupervisorTUI(App):
1854
262
  self._attention_jump_list: list = [] # Cached list of sessions needing attention
1855
263
  # Pending kill confirmation (session name, timestamp)
1856
264
  self._pending_kill: tuple[str, float] | None = None
265
+ # Pending restart confirmation (session name, timestamp) (#133)
266
+ self._pending_restart: tuple[str, float] | None = None
267
+ # Pending sync-to-main confirmation (session name, timestamp) (#156)
268
+ self._pending_sync: tuple[str, float] | None = None
269
+ # Pending transport/handover confirmation (timestamp)
270
+ self._pending_transport: float | None = None
1857
271
  # Tmux interface for sync operations
1858
272
  self._tmux = RealTmux()
1859
273
  # Initialize tmux_sync from preferences
@@ -1864,6 +278,12 @@ class SupervisorTUI(App):
1864
278
  self.hide_asleep = self._prefs.hide_asleep
1865
279
  # Initialize summary_content_mode from preferences (#98)
1866
280
  self.summary_content_mode = self._prefs.summary_content_mode
281
+ # Initialize baseline_minutes from preferences (for mean spin calculation)
282
+ self.baseline_minutes = self._prefs.baseline_minutes
283
+ # Initialize monochrome from preferences (#138)
284
+ self.monochrome = self._prefs.monochrome
285
+ # Initialize show_cost from preferences
286
+ self.show_cost = self._prefs.show_cost
1867
287
  # Cache of terminated sessions (killed during this TUI session)
1868
288
  self._terminated_sessions: dict[str, Session] = {}
1869
289
 
@@ -1920,6 +340,20 @@ class SupervisorTUI(App):
1920
340
  except NoMatches:
1921
341
  pass
1922
342
 
343
+ # Apply show_cost preference to daemon status bar
344
+ try:
345
+ status_bar = self.query_one("#daemon-status", DaemonStatusBar)
346
+ status_bar.show_cost = self._prefs.show_cost
347
+ except NoMatches:
348
+ pass
349
+
350
+ # Apply monochrome preference to preview pane (#138)
351
+ try:
352
+ preview = self.query_one("#preview-pane", PreviewPane)
353
+ preview.monochrome = self._prefs.monochrome
354
+ except NoMatches:
355
+ pass
356
+
1923
357
  # Set view_mode from preferences (triggers watch_view_mode)
1924
358
  self.view_mode = self._prefs.view_mode
1925
359
 
@@ -1943,8 +377,11 @@ class SupervisorTUI(App):
1943
377
  # Normal mode: Set up all timers
1944
378
  # Refresh session list every 10 seconds
1945
379
  self.set_interval(10, self.refresh_sessions)
1946
- # Update status very frequently for real-time detail view
1947
- self.set_interval(0.25, self.update_all_statuses)
380
+ # Tiered status updates for CPU efficiency:
381
+ # - Focused agent: 250ms (responsive preview pane)
382
+ # - Background agents: 1s (reduced overhead)
383
+ self.set_interval(0.25, self.update_focused_status)
384
+ self.set_interval(1.0, self.update_background_statuses)
1948
385
  # Update daemon status every 5 seconds
1949
386
  self.set_interval(5, self.update_daemon_status)
1950
387
  # Update timeline every 30 seconds
@@ -1986,37 +423,6 @@ class SupervisorTUI(App):
1986
423
  """Save current TUI preferences to disk."""
1987
424
  self._prefs.save(self.tmux_session)
1988
425
 
1989
- def action_toggle_timeline(self) -> None:
1990
- """Toggle timeline visibility"""
1991
- try:
1992
- timeline = self.query_one("#timeline", StatusTimeline)
1993
- timeline.display = not timeline.display
1994
- self._prefs.timeline_visible = timeline.display
1995
- self._save_prefs()
1996
- state = "shown" if timeline.display else "hidden"
1997
- self.notify(f"Timeline {state}", severity="information")
1998
- except NoMatches:
1999
- pass
2000
-
2001
- def action_toggle_help(self) -> None:
2002
- """Toggle help overlay visibility"""
2003
- try:
2004
- help_overlay = self.query_one("#help-overlay", HelpOverlay)
2005
- if help_overlay.has_class("visible"):
2006
- help_overlay.remove_class("visible")
2007
- else:
2008
- help_overlay.add_class("visible")
2009
- except NoMatches:
2010
- pass
2011
-
2012
- def action_manual_refresh(self) -> None:
2013
- """Manually trigger a full refresh (useful in diagnostics mode)"""
2014
- self.refresh_sessions()
2015
- self.update_all_statuses()
2016
- self.update_daemon_status()
2017
- self.update_timeline()
2018
- self.notify("Refreshed", severity="information", timeout=2)
2019
-
2020
426
  def on_resize(self) -> None:
2021
427
  """Handle terminal resize events"""
2022
428
  self.refresh()
@@ -2056,42 +462,7 @@ class SupervisorTUI(App):
2056
462
 
2057
463
  def _sort_sessions(self) -> None:
2058
464
  """Sort sessions based on current sort mode (#61)."""
2059
- mode = self._prefs.sort_mode
2060
-
2061
- if mode == "alphabetical":
2062
- self.sessions.sort(key=lambda s: s.name.lower())
2063
- elif mode == "by_status":
2064
- # Sort by status priority: waiting_user first (red), then running (green), etc.
2065
- status_order = {
2066
- "waiting_user": 0,
2067
- "waiting_supervisor": 1,
2068
- "no_instructions": 2,
2069
- "error": 3,
2070
- "running": 4,
2071
- "terminated": 5,
2072
- "asleep": 6,
2073
- }
2074
- self.sessions.sort(key=lambda s: (
2075
- status_order.get(s.stats.current_state or "running", 4),
2076
- s.name.lower()
2077
- ))
2078
- elif mode == "by_value":
2079
- # Sort by value descending (higher = more important), then alphabetically
2080
- # Non-green agents first (by value), then green agents (by value)
2081
- status_order = {
2082
- "waiting_user": 0,
2083
- "waiting_supervisor": 0,
2084
- "no_instructions": 0,
2085
- "error": 0,
2086
- "running": 1,
2087
- "terminated": 2,
2088
- "asleep": 2,
2089
- }
2090
- self.sessions.sort(key=lambda s: (
2091
- status_order.get(s.stats.current_state or "running", 1),
2092
- -s.agent_value, # Descending by value
2093
- s.name.lower()
2094
- ))
465
+ self.sessions = sort_sessions(self.sessions, self._prefs.sort_mode)
2095
466
 
2096
467
  def _get_cached_sessions(self) -> dict[str, Session]:
2097
468
  """Get sessions with caching to reduce disk I/O.
@@ -2111,11 +482,53 @@ class SupervisorTUI(App):
2111
482
  """Invalidate the sessions cache to force reload on next access."""
2112
483
  self._sessions_cache_time = 0
2113
484
 
485
+ def update_focused_status(self) -> None:
486
+ """Update only the focused session's status (fast path, 250ms).
487
+
488
+ This provides responsive updates for the session being viewed
489
+ while reducing CPU overhead for background sessions.
490
+ """
491
+ # Skip if an update is already in progress
492
+ if self._status_update_in_progress:
493
+ return
494
+
495
+ # Only update the focused widget
496
+ focused = self.focused
497
+ if not isinstance(focused, SessionSummary):
498
+ return
499
+
500
+ self._status_update_in_progress = True
501
+ self._fetch_statuses_async([focused])
502
+
503
+ def update_background_statuses(self) -> None:
504
+ """Update non-focused sessions' statuses (slow path, 1s).
505
+
506
+ Updates all sessions except the focused one, which gets
507
+ faster updates via update_focused_status.
508
+ """
509
+ # Skip if an update is already in progress
510
+ if self._status_update_in_progress:
511
+ return
512
+
513
+ # Gather all widgets except the focused one
514
+ focused = self.focused
515
+ focused_id = focused.session.id if isinstance(focused, SessionSummary) else None
516
+
517
+ widgets = [w for w in self.query(SessionSummary) if w.session.id != focused_id]
518
+ if not widgets:
519
+ return
520
+
521
+ self._status_update_in_progress = True
522
+ self._fetch_statuses_async(widgets)
523
+
2114
524
  def update_all_statuses(self) -> None:
2115
525
  """Trigger async status update for all session widgets.
2116
526
 
2117
527
  This is NON-BLOCKING - it kicks off a background worker that fetches
2118
528
  all statuses in parallel, then updates widgets when done.
529
+
530
+ Note: Primarily used for manual refresh ('r' key) and initial load.
531
+ Regular updates use tiered update_focused_status/update_background_statuses.
2119
532
  """
2120
533
  # Skip if an update is already in progress
2121
534
  if self._status_update_in_progress:
@@ -2203,6 +616,15 @@ class SupervisorTUI(App):
2203
616
  prefs_changed = False
2204
617
  ai_summaries = ai_summaries or {}
2205
618
 
619
+ # Recalculate max_repo_info_width from fresh session data (#143)
620
+ # This ensures alignment is correct when agents change branches
621
+ if fresh_sessions:
622
+ self.max_repo_info_width = max(
623
+ (len(f"{s.repo_name or 'n/a'}:{s.branch or 'n/a'}") for s in fresh_sessions.values()),
624
+ default=18
625
+ )
626
+ self.max_repo_info_width = max(self.max_repo_info_width, 10)
627
+
2206
628
  for widget in self.query(SessionSummary):
2207
629
  session_id = widget.session.id
2208
630
 
@@ -2273,14 +695,16 @@ class SupervisorTUI(App):
2273
695
  def _apply_summaries(self, summaries: dict) -> None:
2274
696
  """Apply AI summaries to session widgets (runs on main thread)."""
2275
697
  self._summaries = summaries
698
+ is_enabled = self._summarizer.config.enabled
2276
699
 
2277
700
  for widget in self.query(SessionSummary):
701
+ widget.summarizer_enabled = is_enabled
2278
702
  session_id = widget.session.id
2279
703
  if session_id in summaries:
2280
704
  summary = summaries[session_id]
2281
705
  widget.ai_summary_short = summary.text or ""
2282
706
  widget.ai_summary_context = summary.context or ""
2283
- widget.refresh()
707
+ widget.refresh()
2284
708
 
2285
709
  def update_session_widgets(self) -> None:
2286
710
  """Update the session display incrementally.
@@ -2290,18 +714,13 @@ class SupervisorTUI(App):
2290
714
  """
2291
715
  container = self.query_one("#sessions-container", ScrollableContainer)
2292
716
 
2293
- # Build the list of sessions to display
2294
- # Filter out sleeping agents if hide_asleep is enabled (#69)
2295
- display_sessions = list(self.sessions)
2296
- if self.hide_asleep:
2297
- display_sessions = [s for s in display_sessions if not s.is_asleep]
2298
- # Include terminated sessions if show_terminated is enabled
2299
- if self.show_terminated:
2300
- # Add terminated sessions that aren't already in the active list
2301
- active_ids = {s.id for s in self.sessions}
2302
- for session in self._terminated_sessions.values():
2303
- if session.id not in active_ids:
2304
- display_sessions.append(session)
717
+ # Build the list of sessions to display using extracted logic
718
+ display_sessions = filter_visible_sessions(
719
+ active_sessions=self.sessions,
720
+ terminated_sessions=list(self._terminated_sessions.values()),
721
+ hide_asleep=self.hide_asleep,
722
+ show_terminated=self.show_terminated,
723
+ )
2305
724
 
2306
725
  # Get existing widgets and their session IDs
2307
726
  existing_widgets = {w.session.id: w for w in self.query(SessionSummary)}
@@ -2364,13 +783,26 @@ class SupervisorTUI(App):
2364
783
  widget.detail_lines = self.DETAIL_LEVELS[self.detail_level_index]
2365
784
  # Apply current summary detail level
2366
785
  widget.summary_detail = self.SUMMARY_LEVELS[self.summary_level_index]
786
+ # Apply current summary content mode (#140)
787
+ widget.summary_content_mode = self.summary_content_mode
788
+ # Apply cost display mode
789
+ widget.show_cost = self.show_cost
2367
790
  # Apply list-mode class if in list_preview view
2368
791
  if self.view_mode == "list_preview":
2369
792
  widget.add_class("list-mode")
2370
793
  widget.expanded = False # Force collapsed in list mode
2371
- # Mark terminated sessions with visual styling
794
+ # Mark terminated sessions with visual styling and status
2372
795
  if session.status == "terminated":
2373
796
  widget.add_class("terminated")
797
+ widget.detected_status = "terminated"
798
+ widget.current_activity = "(tmux window no longer exists)"
799
+ # Set summarizer enabled state
800
+ widget.summarizer_enabled = self._summarizer.config.enabled
801
+ # Apply existing summary if available
802
+ if session.id in self._summaries:
803
+ summary = self._summaries[session.id]
804
+ widget.ai_summary_short = summary.text or ""
805
+ widget.ai_summary_context = summary.context or ""
2374
806
  container.mount(widget)
2375
807
  # NOTE: Don't call update_status() here - it does blocking tmux calls
2376
808
  # The 250ms interval (update_all_statuses) will update status shortly
@@ -2379,100 +811,6 @@ class SupervisorTUI(App):
2379
811
  # This must run after any structural changes AND after sort mode changes
2380
812
  self._reorder_session_widgets(container)
2381
813
 
2382
- def action_expand_all(self) -> None:
2383
- """Expand all sessions"""
2384
- for widget in self.query(SessionSummary):
2385
- widget.expanded = True
2386
- self.expanded_states[widget.session.id] = True
2387
-
2388
- def action_collapse_all(self) -> None:
2389
- """Collapse all sessions"""
2390
- for widget in self.query(SessionSummary):
2391
- widget.expanded = False
2392
- self.expanded_states[widget.session.id] = False
2393
-
2394
- def action_cycle_detail(self) -> None:
2395
- """Cycle through detail levels (5, 10, 20, 50 lines)"""
2396
- self.detail_level_index = (self.detail_level_index + 1) % len(self.DETAIL_LEVELS)
2397
- new_level = self.DETAIL_LEVELS[self.detail_level_index]
2398
-
2399
- # Update all session widgets
2400
- for widget in self.query(SessionSummary):
2401
- widget.detail_lines = new_level
2402
-
2403
- # Save preference
2404
- self._prefs.detail_lines = new_level
2405
- self._save_prefs()
2406
-
2407
- self.notify(f"Detail: {new_level} lines", severity="information")
2408
-
2409
- def action_cycle_summary(self) -> None:
2410
- """Cycle through summary detail levels (low, med, full)"""
2411
- self.summary_level_index = (self.summary_level_index + 1) % len(self.SUMMARY_LEVELS)
2412
- new_level = self.SUMMARY_LEVELS[self.summary_level_index]
2413
-
2414
- # Update all session widgets
2415
- for widget in self.query(SessionSummary):
2416
- widget.summary_detail = new_level
2417
-
2418
- # Save preference
2419
- self._prefs.summary_detail = new_level
2420
- self._save_prefs()
2421
-
2422
- self.notify(f"Summary: {new_level}", severity="information")
2423
-
2424
- def action_cycle_summary_content(self) -> None:
2425
- """Cycle through summary content modes (ai_short, ai_long, orders, annotation) (#74)."""
2426
- modes = self.SUMMARY_CONTENT_MODES
2427
- current_idx = modes.index(self.summary_content_mode) if self.summary_content_mode in modes else 0
2428
- new_idx = (current_idx + 1) % len(modes)
2429
- self.summary_content_mode = modes[new_idx]
2430
-
2431
- # Save preference (#98)
2432
- self._prefs.summary_content_mode = self.summary_content_mode
2433
- self._save_prefs()
2434
-
2435
- # Update all session widgets
2436
- for widget in self.query(SessionSummary):
2437
- widget.summary_content_mode = self.summary_content_mode
2438
-
2439
- mode_names = {
2440
- "ai_short": "💬 AI Summary (short)",
2441
- "ai_long": "📖 AI Summary (context)",
2442
- "orders": "🎯 Standing Orders",
2443
- "annotation": "✏️ Human Annotation",
2444
- }
2445
- self.notify(f"{mode_names.get(self.summary_content_mode, self.summary_content_mode)}", severity="information")
2446
-
2447
- def action_focus_human_annotation(self) -> None:
2448
- """Focus input for editing human annotation (#74)."""
2449
- try:
2450
- cmd_bar = self.query_one("#command-bar", CommandBar)
2451
-
2452
- # Show the command bar
2453
- cmd_bar.add_class("visible")
2454
-
2455
- # Get the currently focused session (if any)
2456
- focused = self.focused
2457
- if isinstance(focused, SessionSummary):
2458
- cmd_bar.set_target(focused.session.name)
2459
- # Pre-fill with existing annotation
2460
- cmd_input = cmd_bar.query_one("#cmd-input", Input)
2461
- cmd_input.value = focused.session.human_annotation or ""
2462
- elif not cmd_bar.target_session and self.sessions:
2463
- # Default to first session if none focused
2464
- cmd_bar.set_target(self.sessions[0].name)
2465
-
2466
- # Set mode to annotation editing
2467
- cmd_bar.set_mode("annotation")
2468
-
2469
- # Enable and focus the input
2470
- cmd_input = cmd_bar.query_one("#cmd-input", Input)
2471
- cmd_input.disabled = False
2472
- cmd_input.focus()
2473
- except NoMatches:
2474
- pass
2475
-
2476
814
  def on_session_summary_expanded_changed(self, message: SessionSummary.ExpandedChanged) -> None:
2477
815
  """Handle expanded state changes from session widgets"""
2478
816
  self.expanded_states[message.session_id] = message.expanded
@@ -2499,14 +837,6 @@ class SupervisorTUI(App):
2499
837
  else:
2500
838
  widget.remove_class("selected")
2501
839
 
2502
- def action_toggle_focused(self) -> None:
2503
- """Toggle expansion of focused session (only in tree mode)"""
2504
- if self.view_mode == "list_preview":
2505
- return # Don't toggle in list mode
2506
- focused = self.focused
2507
- if isinstance(focused, SessionSummary):
2508
- focused.expanded = not focused.expanded
2509
-
2510
840
  def _get_widgets_in_session_order(self) -> List[SessionSummary]:
2511
841
  """Get session widgets sorted to match self.sessions order.
2512
842
 
@@ -2555,193 +885,6 @@ class SupervisorTUI(App):
2555
885
  # Each subsequent widget should be after the previous one
2556
886
  container.move_child(widget, after=ordered_widgets[i - 1])
2557
887
 
2558
- def action_focus_next_session(self) -> None:
2559
- """Focus the next session in the list."""
2560
- widgets = self._get_widgets_in_session_order()
2561
- if not widgets:
2562
- return
2563
- self.focused_session_index = (self.focused_session_index + 1) % len(widgets)
2564
- target_widget = widgets[self.focused_session_index]
2565
- target_widget.focus()
2566
- if self.view_mode == "list_preview":
2567
- self._update_preview()
2568
- self._sync_tmux_window(target_widget)
2569
-
2570
- def action_focus_previous_session(self) -> None:
2571
- """Focus the previous session in the list."""
2572
- widgets = self._get_widgets_in_session_order()
2573
- if not widgets:
2574
- return
2575
- self.focused_session_index = (self.focused_session_index - 1) % len(widgets)
2576
- target_widget = widgets[self.focused_session_index]
2577
- target_widget.focus()
2578
- if self.view_mode == "list_preview":
2579
- self._update_preview()
2580
- self._sync_tmux_window(target_widget)
2581
-
2582
- def action_toggle_view_mode(self) -> None:
2583
- """Toggle between tree and list+preview view modes."""
2584
- if self.view_mode == "tree":
2585
- self.view_mode = "list_preview"
2586
- else:
2587
- self.view_mode = "tree"
2588
-
2589
- # Save preference
2590
- self._prefs.view_mode = self.view_mode
2591
- self._save_prefs()
2592
-
2593
- def action_toggle_tmux_sync(self) -> None:
2594
- """Toggle tmux pane sync - syncs navigation to external tmux pane."""
2595
- self.tmux_sync = not self.tmux_sync
2596
-
2597
- # Save preference
2598
- self._prefs.tmux_sync = self.tmux_sync
2599
- self._save_prefs()
2600
-
2601
- # Update subtitle to show sync state
2602
- self._update_subtitle()
2603
-
2604
- # If enabling, sync to currently focused session immediately
2605
- if self.tmux_sync:
2606
- self._sync_tmux_window()
2607
-
2608
- def action_toggle_show_terminated(self) -> None:
2609
- """Toggle showing killed/terminated sessions in the timeline."""
2610
- self.show_terminated = not self.show_terminated
2611
-
2612
- # Save preference
2613
- self._prefs.show_terminated = self.show_terminated
2614
- self._save_prefs()
2615
-
2616
- # Refresh session widgets to show/hide terminated sessions
2617
- self.update_session_widgets()
2618
-
2619
- # Notify user
2620
- status = "visible" if self.show_terminated else "hidden"
2621
- count = len(self._terminated_sessions)
2622
- if count > 0:
2623
- self.notify(f"Killed sessions: {status} ({count})", severity="information")
2624
- else:
2625
- self.notify(f"Killed sessions: {status}", severity="information")
2626
-
2627
- def action_jump_to_attention(self) -> None:
2628
- """Jump to next session needing attention.
2629
-
2630
- Cycles through sessions with waiting_user status first (red/bell),
2631
- then through other non-green statuses (no_instructions, waiting_supervisor).
2632
- """
2633
- from .status_constants import STATUS_WAITING_USER, STATUS_NO_INSTRUCTIONS, STATUS_WAITING_SUPERVISOR, STATUS_RUNNING, STATUS_TERMINATED, STATUS_ASLEEP
2634
-
2635
- widgets = self._get_widgets_in_session_order()
2636
- if not widgets:
2637
- return
2638
-
2639
- # Build prioritized list of sessions needing attention
2640
- # Priority: waiting_user (red) > no_instructions (yellow) > waiting_supervisor (orange)
2641
- attention_sessions = []
2642
- for i, widget in enumerate(widgets):
2643
- status = getattr(widget, 'current_status', STATUS_RUNNING)
2644
- if status == STATUS_WAITING_USER:
2645
- attention_sessions.append((0, i, widget)) # Highest priority
2646
- elif status == STATUS_NO_INSTRUCTIONS:
2647
- attention_sessions.append((1, i, widget))
2648
- elif status == STATUS_WAITING_SUPERVISOR:
2649
- attention_sessions.append((2, i, widget))
2650
- # Skip running, terminated, asleep
2651
-
2652
- if not attention_sessions:
2653
- self.notify("No sessions need attention", severity="information")
2654
- return
2655
-
2656
- # Sort by priority, then by original index
2657
- attention_sessions.sort(key=lambda x: (x[0], x[1]))
2658
-
2659
- # Check if our cached list changed (sessions may have changed state)
2660
- current_widget_ids = [id(w) for _, _, w in attention_sessions]
2661
- cached_widget_ids = [id(w) for w in self._attention_jump_list]
2662
-
2663
- if current_widget_ids != cached_widget_ids:
2664
- # List changed, reset index
2665
- self._attention_jump_list = [w for _, _, w in attention_sessions]
2666
- self._attention_jump_index = 0
2667
- else:
2668
- # Cycle to next
2669
- self._attention_jump_index = (self._attention_jump_index + 1) % len(self._attention_jump_list)
2670
-
2671
- # Focus the target widget
2672
- target_widget = self._attention_jump_list[self._attention_jump_index]
2673
- # Find its index in the full widget list
2674
- for i, w in enumerate(widgets):
2675
- if w is target_widget:
2676
- self.focused_session_index = i
2677
- break
2678
-
2679
- target_widget.focus()
2680
- if self.view_mode == "list_preview":
2681
- self._update_preview()
2682
- self._sync_tmux_window(target_widget)
2683
-
2684
- # Show position indicator
2685
- pos = self._attention_jump_index + 1
2686
- total = len(self._attention_jump_list)
2687
- status = getattr(target_widget, 'current_status', 'unknown')
2688
- self.notify(f"Attention {pos}/{total}: {target_widget.session.name} ({status})", severity="information")
2689
-
2690
- def action_toggle_hide_asleep(self) -> None:
2691
- """Toggle hiding sleeping agents from display."""
2692
- self.hide_asleep = not self.hide_asleep
2693
-
2694
- # Save preference
2695
- self._prefs.hide_asleep = self.hide_asleep
2696
- self._save_prefs()
2697
-
2698
- # Update subtitle to show state
2699
- self._update_subtitle()
2700
-
2701
- # Refresh session widgets to show/hide sleeping agents
2702
- self.update_session_widgets()
2703
-
2704
- # Count sleeping agents
2705
- asleep_count = sum(1 for s in self.sessions if s.is_asleep)
2706
- if self.hide_asleep:
2707
- self.notify(f"Sleeping agents hidden ({asleep_count})", severity="information")
2708
- else:
2709
- self.notify(f"Sleeping agents visible ({asleep_count})", severity="information")
2710
-
2711
- def action_cycle_sort_mode(self) -> None:
2712
- """Cycle through sort modes (#61)."""
2713
- # Remember the currently focused session before sorting
2714
- widgets = self._get_widgets_in_session_order()
2715
- focused_session_id = None
2716
- if widgets and 0 <= self.focused_session_index < len(widgets):
2717
- focused_session_id = widgets[self.focused_session_index].session.id
2718
-
2719
- modes = self.SORT_MODES
2720
- current_idx = modes.index(self._prefs.sort_mode) if self._prefs.sort_mode in modes else 0
2721
- new_idx = (current_idx + 1) % len(modes)
2722
- self._prefs.sort_mode = modes[new_idx]
2723
- self._save_prefs()
2724
-
2725
- # Re-sort and refresh
2726
- self._sort_sessions()
2727
- self.update_session_widgets()
2728
- self._update_subtitle()
2729
-
2730
- # Update focused_session_index to follow the same session at its new position
2731
- if focused_session_id:
2732
- widgets = self._get_widgets_in_session_order()
2733
- for i, widget in enumerate(widgets):
2734
- if widget.session.id == focused_session_id:
2735
- self.focused_session_index = i
2736
- break
2737
-
2738
- mode_names = {
2739
- "alphabetical": "Alphabetical",
2740
- "by_status": "By Status",
2741
- "by_value": "By Value (priority)",
2742
- }
2743
- self.notify(f"Sort: {mode_names.get(self._prefs.sort_mode, self._prefs.sort_mode)}", severity="information")
2744
-
2745
888
  def _sync_tmux_window(self, widget: Optional["SessionSummary"] = None) -> None:
2746
889
  """Sync external tmux pane to show the focused session's window.
2747
890
 
@@ -2822,89 +965,6 @@ class SupervisorTUI(App):
2822
965
  except NoMatches:
2823
966
  pass
2824
967
 
2825
- def action_focus_command_bar(self) -> None:
2826
- """Focus the command bar for input."""
2827
- try:
2828
- cmd_bar = self.query_one("#command-bar", CommandBar)
2829
-
2830
- # Show the command bar
2831
- cmd_bar.add_class("visible")
2832
-
2833
- # Get the currently focused session (if any)
2834
- focused = self.focused
2835
- if isinstance(focused, SessionSummary):
2836
- cmd_bar.set_target(focused.session.name)
2837
- elif not cmd_bar.target_session and self.sessions:
2838
- # Default to first session if none focused
2839
- cmd_bar.set_target(self.sessions[0].name)
2840
-
2841
- # Enable and focus the input
2842
- cmd_input = cmd_bar.query_one("#cmd-input", Input)
2843
- cmd_input.disabled = False
2844
- cmd_input.focus()
2845
- except NoMatches:
2846
- pass
2847
-
2848
- def action_focus_standing_orders(self) -> None:
2849
- """Focus the command bar for editing standing orders."""
2850
- try:
2851
- cmd_bar = self.query_one("#command-bar", CommandBar)
2852
-
2853
- # Show the command bar
2854
- cmd_bar.add_class("visible")
2855
-
2856
- # Get the currently focused session (if any)
2857
- focused = self.focused
2858
- if isinstance(focused, SessionSummary):
2859
- cmd_bar.set_target(focused.session.name)
2860
- # Pre-fill with existing standing orders
2861
- cmd_input = cmd_bar.query_one("#cmd-input", Input)
2862
- cmd_input.value = focused.session.standing_instructions or ""
2863
- elif not cmd_bar.target_session and self.sessions:
2864
- # Default to first session if none focused
2865
- cmd_bar.set_target(self.sessions[0].name)
2866
-
2867
- # Set mode to standing_orders
2868
- cmd_bar.set_mode("standing_orders")
2869
-
2870
- # Enable and focus the input
2871
- cmd_input = cmd_bar.query_one("#cmd-input", Input)
2872
- cmd_input.disabled = False
2873
- cmd_input.focus()
2874
- except NoMatches:
2875
- pass
2876
-
2877
- def action_edit_agent_value(self) -> None:
2878
- """Focus the command bar for editing agent value (#61)."""
2879
- try:
2880
- cmd_bar = self.query_one("#command-bar", CommandBar)
2881
-
2882
- # Show the command bar
2883
- cmd_bar.add_class("visible")
2884
-
2885
- # Get the currently focused session (if any)
2886
- focused = self.focused
2887
- if isinstance(focused, SessionSummary):
2888
- cmd_bar.set_target(focused.session.name)
2889
- # Pre-fill with existing value
2890
- cmd_input = cmd_bar.query_one("#cmd-input", Input)
2891
- cmd_input.value = str(focused.session.agent_value)
2892
- elif not cmd_bar.target_session and self.sessions:
2893
- # Default to first session if none focused
2894
- cmd_bar.set_target(self.sessions[0].name)
2895
- cmd_input = cmd_bar.query_one("#cmd-input", Input)
2896
- cmd_input.value = "1000"
2897
-
2898
- # Set mode to value
2899
- cmd_bar.set_mode("value")
2900
-
2901
- # Enable and focus the input
2902
- cmd_input = cmd_bar.query_one("#cmd-input", Input)
2903
- cmd_input.disabled = False
2904
- cmd_input.focus()
2905
- except NoMatches:
2906
- pass
2907
-
2908
968
  def on_command_bar_send_requested(self, message: CommandBar.SendRequested) -> None:
2909
969
  """Handle send request from command bar."""
2910
970
  from datetime import datetime
@@ -3037,133 +1097,6 @@ class SupervisorTUI(App):
3037
1097
  except Exception as e:
3038
1098
  self.notify(f"Failed to create agent: {e}", severity="error")
3039
1099
 
3040
- def action_toggle_daemon(self) -> None:
3041
- """Toggle daemon panel visibility (like timeline)."""
3042
- try:
3043
- daemon_panel = self.query_one("#daemon-panel", DaemonPanel)
3044
- daemon_panel.display = not daemon_panel.display
3045
- if daemon_panel.display:
3046
- # Force immediate refresh when becoming visible
3047
- daemon_panel._refresh_logs()
3048
- # Save preference
3049
- self._prefs.daemon_panel_visible = daemon_panel.display
3050
- self._save_prefs()
3051
- state = "shown" if daemon_panel.display else "hidden"
3052
- self.notify(f"Daemon panel {state}", severity="information")
3053
- except NoMatches:
3054
- pass
3055
-
3056
- def action_supervisor_start(self) -> None:
3057
- """Start the Supervisor Daemon (handles Claude orchestration)."""
3058
- # Ensure Monitor Daemon is running first (Supervisor depends on it)
3059
- if not is_monitor_daemon_running(self.tmux_session):
3060
- self._ensure_monitor_daemon()
3061
- import time
3062
- time.sleep(1.0)
3063
-
3064
- if is_supervisor_daemon_running(self.tmux_session):
3065
- self.notify("Supervisor Daemon already running", severity="warning")
3066
- return
3067
-
3068
- try:
3069
- panel = self.query_one("#daemon-panel", DaemonPanel)
3070
- panel.log_lines.append(">>> Starting Supervisor Daemon...")
3071
- except NoMatches:
3072
- pass
3073
-
3074
- try:
3075
- subprocess.Popen(
3076
- [sys.executable, "-m", "overcode.supervisor_daemon",
3077
- "--session", self.tmux_session],
3078
- stdout=subprocess.DEVNULL,
3079
- stderr=subprocess.DEVNULL,
3080
- start_new_session=True,
3081
- )
3082
- self.notify("Started Supervisor Daemon", severity="information")
3083
- self.set_timer(1.0, self.update_daemon_status)
3084
- except (OSError, subprocess.SubprocessError) as e:
3085
- self.notify(f"Failed to start Supervisor Daemon: {e}", severity="error")
3086
-
3087
- def action_supervisor_stop(self) -> None:
3088
- """Stop the Supervisor Daemon."""
3089
- if not is_supervisor_daemon_running(self.tmux_session):
3090
- self.notify("Supervisor Daemon not running", severity="warning")
3091
- return
3092
-
3093
- if stop_supervisor_daemon(self.tmux_session):
3094
- self.notify("Stopped Supervisor Daemon", severity="information")
3095
- try:
3096
- panel = self.query_one("#daemon-panel", DaemonPanel)
3097
- panel.log_lines.append(">>> Supervisor Daemon stopped")
3098
- except NoMatches:
3099
- pass
3100
- else:
3101
- self.notify("Failed to stop Supervisor Daemon", severity="error")
3102
-
3103
- self.update_daemon_status()
3104
-
3105
- def action_toggle_summarizer(self) -> None:
3106
- """Toggle the AI Summarizer on/off."""
3107
- # Check if summarizer is available (OPENAI_API_KEY set)
3108
- if not SummarizerClient.is_available():
3109
- self.notify("AI Summarizer unavailable - set OPENAI_API_KEY", severity="warning")
3110
- return
3111
-
3112
- # Toggle the state
3113
- self._summarizer.config.enabled = not self._summarizer.config.enabled
3114
-
3115
- if self._summarizer.config.enabled:
3116
- # Enable: create client if needed
3117
- if not self._summarizer._client:
3118
- self._summarizer._client = SummarizerClient()
3119
- self.notify("AI Summarizer enabled", severity="information")
3120
- # Trigger an immediate update
3121
- self._update_summaries_async()
3122
- else:
3123
- # Disable: close client to release resources
3124
- if self._summarizer._client:
3125
- self._summarizer._client.close()
3126
- self._summarizer._client = None
3127
- self.notify("AI Summarizer disabled", severity="information")
3128
-
3129
- # Refresh status bar
3130
- self.update_daemon_status()
3131
-
3132
- def action_monitor_restart(self) -> None:
3133
- """Restart the Monitor Daemon (handles metrics/state tracking)."""
3134
- import time
3135
-
3136
- try:
3137
- panel = self.query_one("#daemon-panel", DaemonPanel)
3138
- panel.log_lines.append(">>> Restarting Monitor Daemon...")
3139
- except NoMatches:
3140
- pass
3141
-
3142
- # Stop if running
3143
- if is_monitor_daemon_running(self.tmux_session):
3144
- stop_monitor_daemon(self.tmux_session)
3145
- time.sleep(0.5)
3146
-
3147
- # Start fresh
3148
- try:
3149
- subprocess.Popen(
3150
- [sys.executable, "-m", "overcode.monitor_daemon",
3151
- "--session", self.tmux_session],
3152
- stdout=subprocess.DEVNULL,
3153
- stderr=subprocess.DEVNULL,
3154
- start_new_session=True,
3155
- )
3156
-
3157
- self.notify("Monitor Daemon restarted", severity="information")
3158
- try:
3159
- panel = self.query_one("#daemon-panel", DaemonPanel)
3160
- panel.log_lines.append(">>> Monitor Daemon restarted")
3161
- except NoMatches:
3162
- pass
3163
- self.set_timer(1.0, self.update_daemon_status)
3164
- except (OSError, subprocess.SubprocessError) as e:
3165
- self.notify(f"Failed to restart Monitor Daemon: {e}", severity="error")
3166
-
3167
1100
  def _ensure_monitor_daemon(self) -> None:
3168
1101
  """Start the Monitor Daemon if not running.
3169
1102
 
@@ -3193,91 +1126,6 @@ class SupervisorTUI(App):
3193
1126
  except (OSError, subprocess.SubprocessError) as e:
3194
1127
  self.notify(f"Failed to start Monitor Daemon: {e}", severity="warning")
3195
1128
 
3196
- def action_toggle_web_server(self) -> None:
3197
- """Toggle the web analytics dashboard server on/off."""
3198
- is_running, msg = toggle_web_server(self.tmux_session)
3199
-
3200
- if is_running:
3201
- url = get_web_server_url(self.tmux_session)
3202
- self.notify(f"Web server: {url}", severity="information")
3203
- try:
3204
- panel = self.query_one("#daemon-panel", DaemonPanel)
3205
- panel.log_lines.append(f">>> Web server started: {url}")
3206
- except NoMatches:
3207
- pass
3208
- else:
3209
- self.notify(f"Web server: {msg}", severity="information")
3210
- try:
3211
- panel = self.query_one("#daemon-panel", DaemonPanel)
3212
- panel.log_lines.append(f">>> Web server: {msg}")
3213
- except NoMatches:
3214
- pass
3215
-
3216
- self.update_daemon_status()
3217
-
3218
- def action_toggle_sleep(self) -> None:
3219
- """Toggle sleep mode for the focused agent.
3220
-
3221
- Sleep mode marks an agent as 'asleep' (human doesn't want it to do anything).
3222
- Sleeping agents are excluded from stats calculations.
3223
- Press z again to wake the agent.
3224
- """
3225
- focused = self.focused
3226
- if not isinstance(focused, SessionSummary):
3227
- self.notify("No agent focused", severity="warning")
3228
- return
3229
-
3230
- session = focused.session
3231
- new_asleep_state = not session.is_asleep
3232
-
3233
- # Update the session in the session manager
3234
- self.session_manager.update_session(session.id, is_asleep=new_asleep_state)
3235
-
3236
- # Update the local session object
3237
- session.is_asleep = new_asleep_state
3238
-
3239
- # Update the widget's display status if sleeping
3240
- if new_asleep_state:
3241
- focused.detected_status = "asleep"
3242
- self.notify(f"Agent '{session.name}' is now asleep (excluded from stats)", severity="information")
3243
- else:
3244
- # Wake up - status will be refreshed on next update cycle
3245
- self.notify(f"Agent '{session.name}' is now awake", severity="information")
3246
-
3247
- # Force a refresh
3248
- focused.refresh()
3249
-
3250
- def action_kill_focused(self) -> None:
3251
- """Kill the currently focused agent (requires confirmation)."""
3252
- focused = self.focused
3253
- if not isinstance(focused, SessionSummary):
3254
- self.notify("No agent focused", severity="warning")
3255
- return
3256
-
3257
- session_name = focused.session.name
3258
- session_id = focused.session.id
3259
- now = time.time()
3260
-
3261
- # Check if this is a confirmation of a pending kill
3262
- if self._pending_kill:
3263
- pending_name, pending_time = self._pending_kill
3264
- # Confirm if same session and within 3 second window
3265
- if pending_name == session_name and (now - pending_time) < 3.0:
3266
- self._pending_kill = None # Clear pending state
3267
- self._execute_kill(focused, session_name, session_id)
3268
- return
3269
- else:
3270
- # Different session or expired - start new confirmation
3271
- self._pending_kill = None
3272
-
3273
- # First press - request confirmation
3274
- self._pending_kill = (session_name, now)
3275
- self.notify(
3276
- f"Press x again to kill '{session_name}'",
3277
- severity="warning",
3278
- timeout=3
3279
- )
3280
-
3281
1129
  def _execute_kill(self, focused: "SessionSummary", session_name: str, session_id: str) -> None:
3282
1130
  """Execute the actual kill operation after confirmation."""
3283
1131
  # Save a copy of the session for showing when show_terminated is True
@@ -3327,169 +1175,55 @@ class SupervisorTUI(App):
3327
1175
  else:
3328
1176
  self.notify(f"Failed to kill agent: {session_name}", severity="error")
3329
1177
 
3330
- def action_new_agent(self) -> None:
3331
- """Prompt for directory and name to create a new agent.
3332
-
3333
- Two-step flow:
3334
- 1. Enter working directory (or press Enter for current directory)
3335
- 2. Enter agent name (defaults to directory basename)
3336
- """
3337
- from pathlib import Path
3338
-
3339
- try:
3340
- command_bar = self.query_one("#command-bar", CommandBar)
3341
- command_bar.add_class("visible") # Must show the command bar first
3342
- command_bar.set_mode("new_agent_dir")
3343
- # Pre-fill with current working directory
3344
- input_widget = command_bar.query_one("#cmd-input", Input)
3345
- input_widget.value = str(Path.cwd())
3346
- command_bar.focus_input()
3347
- except NoMatches:
3348
- self.notify("Command bar not found", severity="error")
1178
+ def _execute_restart(self, focused: "SessionSummary") -> None:
1179
+ """Execute the actual restart operation after confirmation (#133).
3349
1180
 
3350
- def action_toggle_copy_mode(self) -> None:
3351
- """Toggle mouse capture to allow native terminal text selection.
3352
-
3353
- When copy mode is ON:
3354
- - Mouse events pass through to terminal
3355
- - You can select text and Cmd+C to copy
3356
- - Press 'y' again to exit copy mode
1181
+ Sends Ctrl-C to kill the current Claude process, then restarts it
1182
+ with the same configuration (directory, permissions).
3357
1183
  """
3358
- if not hasattr(self, '_copy_mode'):
3359
- self._copy_mode = False
3360
-
3361
- self._copy_mode = not self._copy_mode
3362
-
3363
- if self._copy_mode:
3364
- # Write escape sequences directly to the driver's file (stderr)
3365
- # This is what Textual uses internally for terminal output
3366
- # We bypass the driver methods because they check _mouse flag
3367
- driver_file = self._driver._file
3368
-
3369
- # Disable all mouse tracking modes
3370
- driver_file.write("\x1b[?1000l") # Disable basic mouse tracking
3371
- driver_file.write("\x1b[?1002l") # Disable cell motion tracking
3372
- driver_file.write("\x1b[?1003l") # Disable all motion tracking
3373
- driver_file.write("\x1b[?1015l") # Disable urxvt extended mode
3374
- driver_file.write("\x1b[?1006l") # Disable SGR extended mode
3375
- driver_file.flush()
3376
-
3377
- self.notify("COPY MODE - select with mouse, Cmd+C to copy, 'y' to exit", severity="warning")
3378
- else:
3379
- # Re-enable mouse support using driver's method
3380
- self._driver._mouse = True # Ensure flag is set so enable actually sends codes
3381
- self._driver._enable_mouse_support()
3382
- self.refresh()
3383
- self.notify("Copy mode OFF", severity="information")
3384
-
3385
- def action_send_enter_to_focused(self) -> None:
3386
- """Send Enter keypress to the focused agent (for approvals)."""
3387
- focused = self.focused
3388
- if not isinstance(focused, SessionSummary):
3389
- self.notify("No agent focused", severity="warning")
3390
- return
3391
-
3392
- session_name = focused.session.name
3393
- launcher = ClaudeLauncher(
3394
- tmux_session=self.tmux_session,
3395
- session_manager=self.session_manager
3396
- )
1184
+ import os
1185
+ session = focused.session
1186
+ session_name = session.name
3397
1187
 
3398
- # Send "enter" which the launcher handles as just pressing Enter
3399
- if launcher.send_to_session(session_name, "enter"):
3400
- self.notify(f"Sent Enter to {session_name}", severity="information")
1188
+ # Build the claude command based on permissiveness mode
1189
+ claude_command = os.environ.get("CLAUDE_COMMAND", "claude")
1190
+ if claude_command == "claude":
1191
+ cmd_parts = ["claude", "code"]
3401
1192
  else:
3402
- self.notify(f"Failed to send Enter to {session_name}", severity="error")
3403
-
3404
- def _is_freetext_option(self, pane_content: str, key: str) -> bool:
3405
- """Check if a numbered menu option is a free-text instruction option.
1193
+ cmd_parts = [claude_command]
3406
1194
 
3407
- Scans the pane content for patterns like "5. Tell Claude what to do"
3408
- or "3) Give custom instructions" to determine if selecting this option
3409
- should open the command bar for user input.
1195
+ if session.permissiveness_mode == "bypass":
1196
+ cmd_parts.append("--dangerously-skip-permissions")
1197
+ elif session.permissiveness_mode == "permissive":
1198
+ cmd_parts.extend(["--permission-mode", "dontAsk"])
3410
1199
 
3411
- Args:
3412
- pane_content: The tmux pane content to scan
3413
- key: The number key being pressed (e.g., "5")
1200
+ cmd_str = " ".join(cmd_parts)
3414
1201
 
3415
- Returns:
3416
- True if this option expects free-text input
3417
- """
3418
- import re
3419
-
3420
- # Claude Code v2.x only has one freetext option format:
3421
- # "3. No, and tell Claude what to do differently (esc)"
3422
- # This appears on all permission prompts (Bash, Read, Write, etc.)
3423
- freetext_patterns = [
3424
- r"tell\s+claude\s+what\s+to\s+do",
3425
- ]
3426
-
3427
- # Look for the numbered option in the content
3428
- # Match patterns like "5. text", "5) text", "5: text"
3429
- option_pattern = rf"^\s*{key}[\.\)\:]\s*(.+)$"
3430
-
3431
- for line in pane_content.split('\n'):
3432
- match = re.match(option_pattern, line.strip(), re.IGNORECASE)
3433
- if match:
3434
- option_text = match.group(1).lower()
3435
- # Check if this option matches any freetext pattern
3436
- for pattern in freetext_patterns:
3437
- if re.search(pattern, option_text):
3438
- return True
3439
- return False
3440
-
3441
- def _send_key_to_focused(self, key: str) -> None:
3442
- """Send a key to the focused agent.
3443
-
3444
- If the key selects a "free text instruction" menu option (detected by
3445
- scanning the pane content), automatically opens the command bar (#72).
1202
+ # Get tmux manager
1203
+ from .tmux_manager import TmuxManager
1204
+ tmux = TmuxManager(self.tmux_session)
3446
1205
 
3447
- Args:
3448
- key: The key to send
3449
- """
3450
- focused = self.focused
3451
- if not isinstance(focused, SessionSummary):
3452
- self.notify("No agent focused", severity="warning")
1206
+ # Send Ctrl-C to kill the current process
1207
+ if not tmux.send_keys(session.tmux_window, "C-c", enter=False):
1208
+ self.notify(f"Failed to send Ctrl-C to '{session_name}'", severity="error")
3453
1209
  return
3454
1210
 
3455
- session_name = focused.session.name
3456
- launcher = ClaudeLauncher(
3457
- tmux_session=self.tmux_session,
3458
- session_manager=self.session_manager
3459
- )
3460
-
3461
- # Check if this option is a free-text instruction option before sending
3462
- pane_content = self.status_detector.get_pane_content(focused.session.tmux_window) or ""
3463
- is_freetext = self._is_freetext_option(pane_content, key)
3464
-
3465
- # Send the key followed by Enter (to select the numbered option)
3466
- if launcher.send_to_session(session_name, key, enter=True):
3467
- self.notify(f"Sent '{key}' to {session_name}", severity="information")
3468
- # Open command bar if this was a free-text instruction option (#72)
3469
- if is_freetext:
3470
- self.action_focus_command_bar()
1211
+ # Brief delay to allow process to terminate
1212
+ import time
1213
+ time.sleep(0.5)
1214
+
1215
+ # Send the claude command to restart
1216
+ if tmux.send_keys(session.tmux_window, cmd_str, enter=True):
1217
+ self.notify(f"Restarted agent: {session_name}", severity="information")
1218
+ # Reset session stats for fresh start
1219
+ self.session_manager.update_stats(
1220
+ session.id,
1221
+ current_task="Restarting..."
1222
+ )
1223
+ # Clear the claude session IDs since this is a new claude instance
1224
+ self.session_manager.update_session(session.id, claude_session_ids=[])
3471
1225
  else:
3472
- self.notify(f"Failed to send '{key}' to {session_name}", severity="error")
3473
-
3474
- def action_send_1_to_focused(self) -> None:
3475
- """Send '1' to focused agent."""
3476
- self._send_key_to_focused("1")
3477
-
3478
- def action_send_2_to_focused(self) -> None:
3479
- """Send '2' to focused agent."""
3480
- self._send_key_to_focused("2")
3481
-
3482
- def action_send_3_to_focused(self) -> None:
3483
- """Send '3' to focused agent."""
3484
- self._send_key_to_focused("3")
3485
-
3486
- def action_send_4_to_focused(self) -> None:
3487
- """Send '4' to focused agent."""
3488
- self._send_key_to_focused("4")
3489
-
3490
- def action_send_5_to_focused(self) -> None:
3491
- """Send '5' to focused agent."""
3492
- self._send_key_to_focused("5")
1226
+ self.notify(f"Failed to restart agent: {session_name}", severity="error")
3493
1227
 
3494
1228
  def on_key(self, event: events.Key) -> None:
3495
1229
  """Signal activity to daemon on any keypress."""