overcode 0.1.2__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 (50) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/cli.py +154 -51
  3. overcode/config.py +66 -0
  4. overcode/daemon_claude_skill.md +36 -33
  5. overcode/history_reader.py +69 -8
  6. overcode/implementations.py +178 -87
  7. overcode/monitor_daemon.py +87 -97
  8. overcode/monitor_daemon_core.py +261 -0
  9. overcode/monitor_daemon_state.py +24 -15
  10. overcode/pid_utils.py +17 -3
  11. overcode/session_manager.py +54 -0
  12. overcode/settings.py +34 -0
  13. overcode/status_constants.py +1 -1
  14. overcode/status_detector.py +8 -2
  15. overcode/status_patterns.py +19 -0
  16. overcode/summarizer_client.py +72 -27
  17. overcode/summarizer_component.py +87 -107
  18. overcode/supervisor_daemon.py +55 -38
  19. overcode/supervisor_daemon_core.py +210 -0
  20. overcode/testing/__init__.py +6 -0
  21. overcode/testing/renderer.py +268 -0
  22. overcode/testing/tmux_driver.py +223 -0
  23. overcode/testing/tui_eye.py +185 -0
  24. overcode/testing/tui_eye_skill.md +187 -0
  25. overcode/tmux_manager.py +117 -93
  26. overcode/tui.py +399 -1969
  27. overcode/tui_actions/__init__.py +20 -0
  28. overcode/tui_actions/daemon.py +201 -0
  29. overcode/tui_actions/input.py +128 -0
  30. overcode/tui_actions/navigation.py +117 -0
  31. overcode/tui_actions/session.py +428 -0
  32. overcode/tui_actions/view.py +357 -0
  33. overcode/tui_helpers.py +42 -9
  34. overcode/tui_logic.py +347 -0
  35. overcode/tui_render.py +414 -0
  36. overcode/tui_widgets/__init__.py +24 -0
  37. overcode/tui_widgets/command_bar.py +399 -0
  38. overcode/tui_widgets/daemon_panel.py +153 -0
  39. overcode/tui_widgets/daemon_status_bar.py +245 -0
  40. overcode/tui_widgets/help_overlay.py +71 -0
  41. overcode/tui_widgets/preview_pane.py +69 -0
  42. overcode/tui_widgets/session_summary.py +514 -0
  43. overcode/tui_widgets/status_timeline.py +253 -0
  44. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/METADATA +4 -1
  45. overcode-0.1.4.dist-info/RECORD +68 -0
  46. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/WHEEL +1 -1
  47. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
  48. overcode-0.1.2.dist-info/RECORD +0 -45
  49. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
  50. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
overcode/tui.py CHANGED
@@ -25,6 +25,7 @@ from textual.message import Message
25
25
  from rich.text import Text
26
26
  from rich.panel import Panel
27
27
 
28
+ from . import __version__
28
29
  from .session_manager import SessionManager, Session
29
30
  from .launcher import ClaudeLauncher
30
31
  from .status_detector import StatusDetector
@@ -41,6 +42,12 @@ from .supervisor_daemon import (
41
42
  is_supervisor_daemon_running,
42
43
  stop_supervisor_daemon,
43
44
  )
45
+ from .summarizer_component import (
46
+ SummarizerComponent,
47
+ SummarizerConfig,
48
+ AgentSummary,
49
+ )
50
+ from .summarizer_client import SummarizerClient
44
51
  from .web_server import (
45
52
  is_web_server_running,
46
53
  get_web_server_url,
@@ -72,1485 +79,48 @@ from .tui_helpers import (
72
79
  get_git_diff_stats,
73
80
  calculate_safe_break_duration,
74
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
+ )
75
106
 
76
107
 
77
- def format_standing_instructions(instructions: str, max_len: int = 95) -> str:
78
- """Format standing instructions for display.
79
-
80
- Shows "[DEFAULT]" if instructions match the configured default,
81
- otherwise shows the truncated instructions.
82
- """
83
- if not instructions:
84
- return ""
85
-
86
- default = get_default_standing_instructions()
87
- if default and instructions.strip() == default.strip():
88
- return "[DEFAULT]"
89
-
90
- if len(instructions) > max_len:
91
- return instructions[:max_len - 3] + "..."
92
- return instructions
93
-
94
-
95
- class DaemonStatusBar(Static):
96
- """Widget displaying daemon status.
97
-
98
- Shows Monitor Daemon and Supervisor Daemon status explicitly.
99
- Presence is shown only when available (macOS with monitor daemon running).
100
- """
101
-
102
- def __init__(self, tmux_session: str = "agents", session_manager: Optional["SessionManager"] = None, *args, **kwargs):
103
- super().__init__(*args, **kwargs)
104
- self.tmux_session = tmux_session
105
- self.monitor_state: Optional[MonitorDaemonState] = None
106
- self._session_manager = session_manager
107
- self._asleep_session_ids: set = set() # Cache of asleep session IDs
108
-
109
- def update_status(self) -> None:
110
- """Refresh daemon state from file"""
111
- self.monitor_state = get_monitor_daemon_state(self.tmux_session)
112
- # Update cache of asleep session IDs from session manager
113
- if self._session_manager:
114
- self._asleep_session_ids = {
115
- s.id for s in self._session_manager.list_sessions() if s.is_asleep
116
- }
117
- self.refresh()
118
-
119
- def render(self) -> Text:
120
- """Render daemon status bar.
121
-
122
- Shows Monitor Daemon and Supervisor Daemon status explicitly.
123
- """
124
- content = Text()
125
-
126
- # Monitor Daemon status
127
- content.append("Monitor: ", style="bold")
128
- monitor_running = self.monitor_state and not self.monitor_state.is_stale()
129
-
130
- if monitor_running:
131
- state = self.monitor_state
132
- symbol, style = get_daemon_status_style(state.status)
133
- content.append(f"{symbol} ", style=style)
134
- content.append(f"#{state.loop_count}", style="cyan")
135
- content.append(f" @{format_interval(state.current_interval)}", style="dim")
136
- # Version mismatch warning
137
- if state.daemon_version != DAEMON_VERSION:
138
- content.append(f" ⚠v{state.daemon_version}→{DAEMON_VERSION}", style="bold yellow")
139
- else:
140
- content.append("○ ", style="red")
141
- content.append("stopped", style="red")
142
-
143
- content.append(" │ ", style="dim")
144
-
145
- # Supervisor Daemon status
146
- content.append("Supervisor: ", style="bold")
147
- supervisor_running = is_supervisor_daemon_running(self.tmux_session)
148
-
149
- if supervisor_running:
150
- content.append("● ", style="green")
151
- # Show if daemon Claude is currently running
152
- if monitor_running and self.monitor_state.supervisor_claude_running:
153
- # Calculate current run duration
154
- run_duration = ""
155
- if self.monitor_state.supervisor_claude_started_at:
156
- try:
157
- started = datetime.fromisoformat(self.monitor_state.supervisor_claude_started_at)
158
- elapsed = (datetime.now() - started).total_seconds()
159
- run_duration = format_duration(elapsed)
160
- except (ValueError, TypeError):
161
- run_duration = "?"
162
- content.append(f"🤖 RUNNING {run_duration}", style="bold yellow")
163
- # Show supervision stats if available from monitor state
164
- elif monitor_running and self.monitor_state.total_supervisions > 0:
165
- content.append(f"sup:{self.monitor_state.total_supervisions}", style="magenta")
166
- if self.monitor_state.supervisor_tokens > 0:
167
- content.append(f" {format_tokens(self.monitor_state.supervisor_tokens)}", style="blue")
168
- # Show cumulative daemon Claude run time
169
- if self.monitor_state.supervisor_claude_total_run_seconds > 0:
170
- total_run = format_duration(self.monitor_state.supervisor_claude_total_run_seconds)
171
- content.append(f" ⏱{total_run}", style="dim")
172
- else:
173
- content.append("ready", style="green")
174
- else:
175
- content.append("○ ", style="red")
176
- content.append("stopped", style="red")
177
-
178
- # Spin rate stats (only when monitor running with sessions)
179
- if monitor_running and self.monitor_state.sessions:
180
- content.append(" │ ", style="dim")
181
- # Filter out sleeping agents from stats
182
- all_sessions = self.monitor_state.sessions
183
- active_sessions = [s for s in all_sessions if s.session_id not in self._asleep_session_ids]
184
- sleeping_count = len(all_sessions) - len(active_sessions)
185
-
186
- total_agents = len(active_sessions)
187
- # Recalculate green_now excluding sleeping agents
188
- green_now = sum(1 for s in active_sessions if s.current_status == "running")
189
-
190
- # Calculate mean spin rate from green_time percentages (exclude sleeping)
191
- mean_spin = 0.0
192
- for s in active_sessions:
193
- total_time = s.green_time_seconds + s.non_green_time_seconds
194
- if total_time > 0:
195
- mean_spin += s.green_time_seconds / total_time
196
-
197
- content.append("Spin: ", style="bold")
198
- content.append(f"{green_now}", style="bold green" if green_now > 0 else "dim")
199
- content.append(f"/{total_agents}", style="dim")
200
- if sleeping_count > 0:
201
- content.append(f" 💤{sleeping_count}", style="dim") # Show sleeping count
202
- if mean_spin > 0:
203
- content.append(f" μ{mean_spin:.1f}x", style="cyan")
204
-
205
- # Safe break duration (time until 50%+ agents need attention) - exclude sleeping
206
- safe_break = calculate_safe_break_duration(active_sessions)
207
- if safe_break is not None:
208
- content.append(" │ ", style="dim")
209
- content.append("☕", style="bold")
210
- if safe_break < 60:
211
- content.append(f" <1m", style="bold red")
212
- elif safe_break < 300: # < 5 min
213
- content.append(f" {format_duration(safe_break)}", style="bold yellow")
214
- else:
215
- content.append(f" {format_duration(safe_break)}", style="bold green")
216
-
217
- # Presence status (only show if available via monitor daemon on macOS)
218
- if monitor_running and self.monitor_state.presence_available:
219
- content.append(" │ ", style="dim")
220
- state = self.monitor_state.presence_state
221
- idle = self.monitor_state.presence_idle_seconds or 0
222
-
223
- state_names = {1: "🔒", 2: "💤", 3: "👤"}
224
- state_colors = {1: "red", 2: "yellow", 3: "green"}
225
-
226
- icon = state_names.get(state, "?")
227
- color = state_colors.get(state, "dim")
228
- content.append(f"{icon}", style=color)
229
- content.append(f" {int(idle)}s", style="dim")
230
-
231
- # Relay status (small indicator)
232
- if monitor_running and self.monitor_state.relay_enabled:
233
- content.append(" │ ", style="dim")
234
- relay_status = self.monitor_state.relay_last_status
235
- if relay_status == "ok":
236
- content.append("📡", style="green")
237
- elif relay_status == "error":
238
- content.append("📡", style="red")
239
- else:
240
- content.append("📡", style="dim")
241
-
242
- # Web server status
243
- web_running = is_web_server_running(self.tmux_session)
244
- if web_running:
245
- content.append(" │ ", style="dim")
246
- url = get_web_server_url(self.tmux_session)
247
- content.append("🌐", style="green")
248
- if url:
249
- # Just show port
250
- port = url.split(":")[-1] if url else ""
251
- content.append(f":{port}", style="cyan")
252
-
253
- return content
254
-
255
-
256
- class StatusTimeline(Static):
257
- """Widget displaying historical status timelines for user presence and agents.
258
-
259
- Shows the last 3 hours with each character representing a time slice.
260
- - User presence: green=active, yellow=inactive, red/gray=locked/away
261
- - Agent status: green=running, red=waiting, grey=terminated
262
- """
263
-
264
- TIMELINE_HOURS = 3.0 # Show last 3 hours
265
- LABEL_WIDTH = 12 # Width of labels like " User: " or " agent: "
266
- MIN_TIMELINE = 20 # Minimum timeline width
267
- DEFAULT_TIMELINE = 60 # Fallback if can't detect width
268
-
269
- def __init__(self, sessions: list, tmux_session: str = "agents", *args, **kwargs):
270
- super().__init__(*args, **kwargs)
271
- self.sessions = sessions
272
- self.tmux_session = tmux_session
273
- self._presence_history = []
274
- self._agent_histories = {}
275
-
276
- @property
277
- def timeline_width(self) -> int:
278
- """Calculate timeline width based on available space."""
279
- import shutil
280
- try:
281
- # Try to get terminal size directly - most reliable
282
- term_width = shutil.get_terminal_size().columns
283
- # Subtract label width and some padding
284
- available = term_width - self.LABEL_WIDTH - 6
285
- return max(self.MIN_TIMELINE, min(available, 120))
286
- except (OSError, ValueError):
287
- # No terminal available or invalid size
288
- return self.DEFAULT_TIMELINE
289
-
290
- def update_history(self, sessions: list) -> None:
291
- """Refresh history data from log files."""
292
- self.sessions = sessions
293
- self._presence_history = read_presence_history(hours=self.TIMELINE_HOURS)
294
- self._agent_histories = {}
295
-
296
- # Get agent names from sessions
297
- agent_names = [s.name for s in sessions]
298
-
299
- # Read agent history from session-specific file and group by agent
300
- history_path = get_agent_history_path(self.tmux_session)
301
- all_history = read_agent_status_history(hours=self.TIMELINE_HOURS, history_file=history_path)
302
- for ts, agent, status, activity in all_history:
303
- if agent not in self._agent_histories:
304
- self._agent_histories[agent] = []
305
- self._agent_histories[agent].append((ts, status))
306
-
307
- # Force layout refresh when content changes (agent count may have changed)
308
- self.refresh(layout=True)
309
-
310
- def _build_timeline(self, history: list, state_to_char: callable) -> str:
311
- """Build a timeline string from history data.
312
-
313
- Args:
314
- history: List of (timestamp, state) tuples
315
- state_to_char: Function to convert state to display character
316
-
317
- Returns:
318
- String of timeline_width characters representing the timeline
319
- """
320
- width = self.timeline_width
321
- if not history:
322
- return "─" * width
323
-
324
- now = datetime.now()
325
- start_time = now - timedelta(hours=self.TIMELINE_HOURS)
326
- slot_duration = timedelta(hours=self.TIMELINE_HOURS) / width
327
-
328
- # Initialize timeline with empty slots
329
- timeline = ["─"] * width
330
-
331
- # Fill in slots based on history
332
- for ts, state in history:
333
- if ts < start_time:
334
- continue
335
- # Calculate which slot this belongs to
336
- elapsed = ts - start_time
337
- slot_idx = int(elapsed / slot_duration)
338
- if 0 <= slot_idx < width:
339
- timeline[slot_idx] = state_to_char(state)
340
-
341
- return "".join(timeline)
342
-
343
- def render(self) -> Text:
344
- """Render the timeline visualization."""
345
- content = Text()
346
- now = datetime.now()
347
- width = self.timeline_width
348
-
349
- # Time scale header
350
- content.append("Timeline: ", style="bold")
351
- content.append(f"-{self.TIMELINE_HOURS:.0f}h", style="dim")
352
- header_padding = max(0, width - 10)
353
- content.append(" " * header_padding, style="dim")
354
- content.append("now", style="dim")
355
- content.append("\n")
356
-
357
- # User presence timeline - group by time slots like agent timelines
358
- # Align with agent names (14 chars): " " + name + " " = 17 chars total
359
- content.append(f" {'User:':<14} ", style="cyan")
360
- if self._presence_history:
361
- slot_states = build_timeline_slots(
362
- self._presence_history, width, self.TIMELINE_HOURS, now
363
- )
364
- # Render timeline with colors
365
- for i in range(width):
366
- if i in slot_states:
367
- state = slot_states[i]
368
- char = presence_state_to_char(state)
369
- color = get_presence_color(state)
370
- content.append(char, style=color)
371
- else:
372
- content.append("─", style="dim")
373
- elif not MACOS_APIS_AVAILABLE:
374
- # Show install instructions when presence deps not installed (macOS only)
375
- msg = "macOS only - pip install overcode[presence]"
376
- content.append(msg[:width], style="dim italic")
377
- else:
378
- content.append("─" * width, style="dim")
379
- content.append("\n")
380
-
381
- # Agent timelines
382
- for session in self.sessions:
383
- agent_name = session.name
384
- history = self._agent_histories.get(agent_name, [])
385
-
386
- # Truncate name to fit
387
- display_name = truncate_name(agent_name)
388
- content.append(f" {display_name} ", style="cyan")
389
-
390
- green_slots = 0
391
- total_slots = 0
392
- if history:
393
- slot_states = build_timeline_slots(history, width, self.TIMELINE_HOURS, now)
394
- # Render timeline with colors
395
- for i in range(width):
396
- if i in slot_states:
397
- status = slot_states[i]
398
- char = agent_status_to_char(status)
399
- color = get_agent_timeline_color(status)
400
- content.append(char, style=color)
401
- total_slots += 1
402
- if status == "running":
403
- green_slots += 1
404
- else:
405
- content.append("─", style="dim")
406
- else:
407
- content.append("─" * width, style="dim")
408
-
409
- # Show percentage green in last 3 hours
410
- if total_slots > 0:
411
- pct = green_slots / total_slots * 100
412
- pct_style = "bold green" if pct >= 50 else "bold red"
413
- content.append(f" {pct:>3.0f}%", style=pct_style)
414
- else:
415
- content.append(" - ", style="dim")
416
-
417
- content.append("\n")
418
-
419
- # Legend (combined on one line to save space)
420
- content.append(f" {'Legend:':<14} ", style="dim")
421
- content.append("█", style="green")
422
- content.append("active/running ", style="dim")
423
- content.append("▒", style="yellow")
424
- content.append("inactive ", style="dim")
425
- content.append("░", style="red")
426
- content.append("waiting/away ", style="dim")
427
- content.append("×", style="dim")
428
- content.append("terminated", style="dim")
429
-
430
- return content
431
-
432
-
433
- class HelpOverlay(Static):
434
- """Help overlay explaining all TUI metrics and controls"""
435
-
436
- HELP_TEXT = """
437
- ╔══════════════════════════════════════════════════════════════════════════════╗
438
- ║ OVERCODE MONITOR HELP ║
439
- ╠══════════════════════════════════════════════════════════════════════════════╣
440
- ║ AGENT STATUS LINE ║
441
- ║ ────────────────────────────────────────────────────────────────────────────║
442
- ║ 🟢 agent-name repo:branch ↑4.2h ▶ 2.1h ⏸ 2.1h 12i $0.45 ⏱3.2s 🏃 5s║
443
- ║ │ │ │ │ │ │ │ │ │ │ │ ║
444
- ║ │ │ │ │ │ │ │ │ │ │ └─ steers: overcode interventions
445
- ║ │ │ │ │ │ │ │ │ │ └──── mode: 🔥bypass 🏃permissive 👮normal
446
- ║ │ │ │ │ │ │ │ │ └────────── avg op time (seconds)
447
- ║ │ │ │ │ │ │ │ └───────────────── estimated cost (USD)
448
- ║ │ │ │ │ │ │ └────────────────────── interactions (claude turns)
449
- ║ │ │ │ │ │ └─────────────────────────────── paused time (non-green)
450
- ║ │ │ │ │ └────────────────────────────────────── active time (green/running)
451
- ║ │ │ │ └───────────────────────────────────────────── uptime since launch
452
- ║ │ │ └──────────────────────────────────────────────────────────── git repo:branch
453
- ║ │ └───────────────────────────────────────────────────────────────────────── agent name
454
- ║ └───────────────────────────────────────────────────────────────────────────── status (see below)
455
- ║ ║
456
- ║ STATUS COLORS ║
457
- ║ ────────────────────────────────────────────────────────────────────────────║
458
- ║ 🟢 Running - Agent is actively working ║
459
- ║ 🟡 No Instruct - Running but no standing instructions set ║
460
- ║ 🟠 Wait Super - Waiting for overcode supervisor ║
461
- ║ 🔴 Wait User - Blocked! Needs user input (permission prompt, question) ║
462
- ║ ⚫ Terminated - Claude exited, shell prompt showing (ready for cleanup) ║
463
- ║ ║
464
- ║ DAEMON STATUS LINE ║
465
- ║ ────────────────────────────────────────────────────────────────────────────║
466
- ║ Daemon: ● active │ #42 @10s (5s ago) │ sup:3 │ Presence: ● active (3s idle) ║
467
- ║ │ │ │ │ │ │ │ │ │ │ ║
468
- ║ │ │ │ │ │ │ │ │ │ └── idle seconds
469
- ║ │ │ │ │ │ │ │ │ └────────── user state
470
- ║ │ │ │ │ │ │ │ └───────────── presence logger status
471
- ║ │ │ │ │ │ │ └──────────────────────────── supervisor launches
472
- ║ │ │ │ │ │ └───────────────────────────────────────── time since last loop
473
- ║ │ │ │ │ └────────────────────────────────────────────── current interval
474
- ║ │ │ │ └────────────────────────────────────────────────── loop count
475
- ║ │ └──────┴──────────────────────────────────────────────────── daemon status
476
- ║ └───────────────────────────────────────────────────────────── status indicator
477
- ║ ║
478
- ║ KEYBOARD SHORTCUTS ║
479
- ║ ────────────────────────────────────────────────────────────────────────────║
480
- ║ q Quit d Toggle daemon panel ║
481
- ║ h/? Toggle this help t Toggle timeline ║
482
- ║ v Cycle detail lines s Cycle summary detail ║
483
- ║ e Expand all agents c Collapse all agents ║
484
- ║ space Toggle focused agent i/: Focus command bar ║
485
- ║ n Create new agent x Kill focused agent ║
486
- ║ click Toggle agent expand/collapse ║
487
- ║ ║
488
- ║ COMMAND BAR (i or : to focus) ║
489
- ║ ────────────────────────────────────────────────────────────────────────────║
490
- ║ Enter Send instruction Esc Clear & unfocus ║
491
- ║ Ctrl+E Toggle multi-line Ctrl+O Set as standing order ║
492
- ║ Ctrl+Enter Send (multi-line) ║
493
- ║ ║
494
- ║ DAEMON CONTROLS (work anywhere) ║
495
- ║ ────────────────────────────────────────────────────────────────────────────║
496
- ║ [ Start supervisor ] Stop supervisor ║
497
- ║ \\ Restart monitor d Toggle daemon log panel ║
498
- ║ w Toggle web dashboard (analytics server) ║
499
- ║ ║
500
- ║ SUMMARY DETAIL LEVELS (s key) ║
501
- ║ ────────────────────────────────────────────────────────────────────────────║
502
- ║ low Name, tokens, ctx% (context usage), git Δ, mode, steers, orders ║
503
- ║ med + uptime, running time, stalled time, latency ║
504
- ║ full + repo:branch, % active, git diff details (+ins -del) ║
505
- ║ ║
506
- ║ Press h or ? to close ║
507
- ╚══════════════════════════════════════════════════════════════════════════════╝
508
- """
509
-
510
- def render(self) -> Text:
511
- return Text(self.HELP_TEXT.strip())
512
-
513
-
514
- class DaemonPanel(Static):
515
- """Inline daemon panel with status and log viewer (like timeline)"""
516
-
517
- LOG_LINES_TO_SHOW = 8 # Number of log lines to display
518
-
519
- def __init__(self, tmux_session: str = "agents", *args, **kwargs):
520
- super().__init__(*args, **kwargs)
521
- self.tmux_session = tmux_session
522
- self.log_lines: list[str] = []
523
- self.monitor_state: Optional[MonitorDaemonState] = None
524
- self._log_file_pos = 0
525
-
526
- def on_mount(self) -> None:
527
- """Start log tailing when mounted"""
528
- self.set_interval(1.0, self._refresh_logs)
529
- self._refresh_logs()
530
-
531
- def _refresh_logs(self) -> None:
532
- """Refresh daemon status and logs"""
533
- from pathlib import Path
534
-
535
- # Only refresh if visible
536
- if not self.display:
537
- return
538
-
539
- # Update daemon state from Monitor Daemon
540
- self.monitor_state = get_monitor_daemon_state(self.tmux_session)
541
-
542
- # Read log lines from session-specific monitor_daemon.log
543
- session_dir = get_session_dir(self.tmux_session)
544
- log_file = session_dir / "monitor_daemon.log"
545
- if log_file.exists():
546
- try:
547
- with open(log_file, 'r') as f:
548
- if not self.log_lines:
549
- # First read: get last 100 lines of file
550
- all_lines = f.readlines()
551
- self.log_lines = [l.rstrip() for l in all_lines[-100:]]
552
- self._log_file_pos = f.tell()
553
- else:
554
- # Subsequent reads: only get new content
555
- f.seek(self._log_file_pos)
556
- new_content = f.read()
557
- self._log_file_pos = f.tell()
558
-
559
- if new_content:
560
- new_lines = new_content.strip().split('\n')
561
- self.log_lines.extend(new_lines)
562
- # Keep last 100 lines
563
- self.log_lines = self.log_lines[-100:]
564
- except (OSError, IOError, ValueError):
565
- # Log file not available, read error, or seek error
566
- pass
567
-
568
- self.refresh()
569
-
570
- def render(self) -> Text:
571
- """Render daemon panel inline (similar to timeline style)"""
572
- content = Text()
573
-
574
- # Header with status - match DaemonStatusBar format exactly
575
- content.append("🤖 Supervisor Daemon: ", style="bold")
576
-
577
- # Check Monitor Daemon state
578
- if self.monitor_state and not self.monitor_state.is_stale():
579
- state = self.monitor_state
580
- symbol, style = get_daemon_status_style(state.status)
581
-
582
- content.append(f"{symbol} ", style=style)
583
- content.append(f"{state.status}", style=style)
584
-
585
- # State details
586
- content.append(" │ ", style="dim")
587
- content.append(f"#{state.loop_count}", style="cyan")
588
- content.append(f" @{format_interval(state.current_interval)}", style="dim")
589
- last_loop = datetime.fromisoformat(state.last_loop_time) if state.last_loop_time else None
590
- content.append(f" ({format_ago(last_loop)})", style="dim")
591
- if state.total_supervisions > 0:
592
- content.append(f" sup:{state.total_supervisions}", style="magenta")
593
- else:
594
- # Monitor Daemon not running or stale
595
- content.append("○ ", style="red")
596
- content.append("stopped", style="red")
597
- # Show last activity if available from stale state
598
- if self.monitor_state and self.monitor_state.last_loop_time:
599
- try:
600
- last_time = datetime.fromisoformat(self.monitor_state.last_loop_time)
601
- content.append(f" (last: {format_ago(last_time)})", style="dim")
602
- except ValueError:
603
- pass
604
-
605
- # Controls hint
606
- content.append(" │ ", style="dim")
607
- content.append("[", style="bold green")
608
- content.append(":sup ", style="dim")
609
- content.append("]", style="bold red")
610
- content.append(":sup ", style="dim")
611
- content.append("\\", style="bold yellow")
612
- content.append(":mon", style="dim")
613
-
614
- content.append("\n")
615
-
616
- # Log lines
617
- display_lines = self.log_lines[-self.LOG_LINES_TO_SHOW:] if self.log_lines else []
618
-
619
- if not display_lines:
620
- content.append(" (no logs yet - daemon may not have run)", style="dim italic")
621
- content.append("\n")
622
- else:
623
- for line in display_lines:
624
- content.append(" ", style="")
625
- # Truncate line
626
- display_line = line[:120] if len(line) > 120 else line
627
-
628
- # Color based on content
629
- if "ERROR" in line or "error" in line:
630
- style = "red"
631
- elif "WARNING" in line or "warning" in line:
632
- style = "yellow"
633
- elif ">>>" in line:
634
- style = "bold cyan"
635
- elif "supervising" in line.lower() or "steering" in line.lower():
636
- style = "magenta"
637
- elif "Loop" in line:
638
- style = "dim cyan"
639
- else:
640
- style = "dim"
641
-
642
- content.append(display_line, style=style)
643
- content.append("\n")
644
-
645
- return content
646
-
647
-
648
- class SessionSummary(Static, can_focus=True):
649
- """Widget displaying expandable session summary"""
650
-
651
- expanded: reactive[bool] = reactive(True) # Start expanded
652
- detail_lines: reactive[int] = reactive(5) # Lines of output to show (5, 10, 20, 50)
653
- summary_detail: reactive[str] = reactive("low") # low, med, full
654
-
655
- def __init__(self, session: Session, status_detector: StatusDetector, *args, **kwargs):
656
- super().__init__(*args, **kwargs)
657
- self.session = session
658
- self.status_detector = status_detector
659
- # Initialize from persisted session state, not hardcoded "running"
660
- self.detected_status = session.stats.current_state if session.stats.current_state else "running"
661
- self.current_activity = "Initializing..."
662
- self.pane_content: List[str] = [] # Cached pane content
663
- self.claude_stats: Optional[ClaudeSessionStats] = None # Token/interaction stats
664
- self.git_diff_stats: Optional[tuple] = None # (files, insertions, deletions)
665
- # Track if this is a stalled agent that hasn't been visited yet
666
- self.is_unvisited_stalled: bool = False
667
- # Start with expanded class since expanded=True by default
668
- self.add_class("expanded")
669
-
670
- def on_click(self) -> None:
671
- """Toggle expanded state on click"""
672
- self.expanded = not self.expanded
673
- # Notify parent app to save state
674
- self.post_message(self.ExpandedChanged(self.session.id, self.expanded))
675
- # Mark as visited if this is an unvisited stalled agent
676
- if self.is_unvisited_stalled:
677
- self.post_message(self.StalledAgentVisited(self.session.id))
678
-
679
- def on_focus(self) -> None:
680
- """Handle focus event - mark stalled agent as visited and update selection"""
681
- if self.is_unvisited_stalled:
682
- self.post_message(self.StalledAgentVisited(self.session.id))
683
- # Notify app to update selection highlighting
684
- self.post_message(self.SessionSelected(self.session.id))
685
-
686
- class SessionSelected(events.Message):
687
- """Message sent when a session is selected/focused"""
688
- def __init__(self, session_id: str):
689
- super().__init__()
690
- self.session_id = session_id
691
-
692
- class ExpandedChanged(events.Message):
693
- """Message sent when expanded state changes"""
694
- def __init__(self, session_id: str, expanded: bool):
695
- super().__init__()
696
- self.session_id = session_id
697
- self.expanded = expanded
698
-
699
- class StalledAgentVisited(events.Message):
700
- """Message sent when user visits a stalled agent (focus or click)"""
701
- def __init__(self, session_id: str):
702
- super().__init__()
703
- self.session_id = session_id
704
-
705
- def watch_expanded(self, expanded: bool) -> None:
706
- """Called when expanded state changes"""
707
- # Toggle CSS class for proper height
708
- if expanded:
709
- self.add_class("expanded")
710
- else:
711
- self.remove_class("expanded")
712
- self.refresh(layout=True)
713
- # Notify parent app to save state
714
- self.post_message(self.ExpandedChanged(self.session.id, expanded))
715
-
716
- def watch_detail_lines(self, detail_lines: int) -> None:
717
- """Called when detail_lines changes - force layout refresh"""
718
- self.refresh(layout=True)
719
-
720
- def update_status(self) -> None:
721
- """Update the detected status for this session.
722
-
723
- NOTE: This is now VIEW-ONLY. Time tracking is handled by the Monitor Daemon.
724
- We only detect status for display and capture pane content for the expanded view.
725
- """
726
- # detect_status returns (status, activity, pane_content) - reuse content to avoid
727
- # duplicate tmux subprocess calls (was 2 calls per widget, now just 1)
728
- new_status, self.current_activity, content = self.status_detector.detect_status(self.session)
729
- self.apply_status(new_status, self.current_activity, content)
730
-
731
- def apply_status(self, status: str, activity: str, content: str) -> None:
732
- """Apply pre-fetched status data to this widget.
733
-
734
- Used by parallel status updates to apply data fetched in background threads.
735
- Note: This still fetches claude_stats synchronously - used for single widget updates.
736
- """
737
- # Fetch claude stats (only for standalone update_status calls)
738
- claude_stats = get_session_stats(self.session)
739
- # Fetch git diff stats
740
- git_diff = None
741
- if self.session.start_directory:
742
- git_diff = get_git_diff_stats(self.session.start_directory)
743
- self.apply_status_no_refresh(status, activity, content, claude_stats, git_diff)
744
- self.refresh()
745
-
746
- def apply_status_no_refresh(self, status: str, activity: str, content: str, claude_stats: Optional[ClaudeSessionStats] = None, git_diff_stats: Optional[tuple] = None) -> None:
747
- """Apply pre-fetched status data without triggering refresh.
748
-
749
- Used for batched updates where the caller will refresh once at the end.
750
- All data including claude_stats should be pre-fetched in background thread.
751
- """
752
- self.current_activity = activity
753
-
754
- # Use pane content from detect_status (already fetched)
755
- if content:
756
- # Keep all lines including blanks for proper formatting, just strip trailing blanks
757
- lines = content.rstrip().split('\n')
758
- self.pane_content = lines[-50:] if lines else [] # Keep last 50 lines max
759
- else:
760
- self.pane_content = []
761
-
762
- # Update detected status for display
763
- # NOTE: Time tracking removed - Monitor Daemon is the single source of truth
764
- # The session.stats values are read from what Monitor Daemon has persisted
765
- # If session is asleep, keep the asleep status instead of the detected status
766
- if self.session.is_asleep:
767
- self.detected_status = "asleep"
768
- else:
769
- self.detected_status = status
770
-
771
- # Use pre-fetched claude stats (no file I/O on main thread)
772
- if claude_stats is not None:
773
- self.claude_stats = claude_stats
774
-
775
- # Use pre-fetched git diff stats
776
- if git_diff_stats is not None:
777
- self.git_diff_stats = git_diff_stats
778
-
779
- def watch_summary_detail(self, summary_detail: str) -> None:
780
- """Called when summary_detail changes"""
781
- self.refresh()
782
-
783
- def render(self) -> Text:
784
- """Render session summary (compact or expanded)"""
785
- import shutil
786
- s = self.session
787
- stats = s.stats
788
- term_width = shutil.get_terminal_size().columns
789
-
790
- # Expansion indicator
791
- expand_icon = "▼" if self.expanded else "▶"
792
-
793
- # Calculate all values (only use what we need per level)
794
- uptime = calculate_uptime(self.session.start_time)
795
- repo_info = f"{s.repo_name or 'n/a'}:{s.branch or 'n/a'}"
796
- green_time, non_green_time = get_current_state_times(self.session.stats)
797
-
798
- # Get median work time from claude stats (or 0 if unavailable)
799
- median_work = self.claude_stats.median_work_time if self.claude_stats else 0.0
800
-
801
- # Status indicator - larger emoji circles based on detected status
802
- # Blue background matching Textual header/footer style
803
- bg = " on #0d2137"
804
- status_symbol, base_color = get_status_symbol(self.detected_status)
805
- status_color = f"bold {base_color}{bg}"
806
-
807
- # Permissiveness mode with emoji
808
- if s.permissiveness_mode == "bypass":
809
- perm_emoji = "🔥" # Fire - burning through all permissions
810
- elif s.permissiveness_mode == "permissive":
811
- perm_emoji = "🏃" # Running permissively
812
- else:
813
- perm_emoji = "👮" # Normal mode with permissions
814
-
815
- content = Text()
816
-
817
- # Determine name width based on detail level (more space in lower detail modes)
818
- if self.summary_detail == "low":
819
- name_width = 24
820
- elif self.summary_detail == "med":
821
- name_width = 20
822
- else: # full
823
- name_width = 16
824
-
825
- # Truncate name if needed
826
- display_name = s.name[:name_width].ljust(name_width)
827
-
828
- # Always show: status symbol, time in state, expand icon, agent name
829
- content.append(f"{status_symbol} ", style=status_color)
830
-
831
- # Show 🔔 indicator for unvisited stalled agents (needs attention)
832
- if self.is_unvisited_stalled:
833
- content.append("🔔", style=f"bold blink red{bg}")
834
- else:
835
- content.append(" ", style=f"dim{bg}") # Maintain alignment
836
-
837
- # Time in current state (directly after status light)
838
- if stats.state_since:
839
- try:
840
- state_start = datetime.fromisoformat(stats.state_since)
841
- elapsed = (datetime.now() - state_start).total_seconds()
842
- content.append(f"{format_duration(elapsed):>5} ", style=status_color)
843
- except (ValueError, TypeError):
844
- content.append(" - ", style=f"dim{bg}")
845
- else:
846
- content.append(" - ", style=f"dim{bg}")
847
-
848
- # In list-mode, show focus indicator instead of expand icon
849
- if "list-mode" in self.classes:
850
- if self.has_focus:
851
- content.append("→ ", style=status_color)
852
- else:
853
- content.append(" ", style=status_color)
854
- else:
855
- content.append(f"{expand_icon} ", style=status_color)
856
- content.append(f"{display_name}", style=f"bold cyan{bg}")
857
-
858
- # Full detail: add repo:branch (padded to longest across all sessions)
859
- if self.summary_detail == "full":
860
- repo_width = getattr(self.app, 'max_repo_info_width', 18)
861
- content.append(f" {repo_info:<{repo_width}} ", style=f"bold dim{bg}")
862
-
863
- # Med/Full detail: add uptime, running time, stalled time
864
- if self.summary_detail in ("med", "full"):
865
- content.append(f" ↑{uptime:>5}", style=f"bold white{bg}")
866
- content.append(f" ▶{format_duration(green_time):>5}", style=f"bold green{bg}")
867
- content.append(f" ⏸{format_duration(non_green_time):>5}", style=f"bold red{bg}")
868
- # Full detail: show percentage active
869
- if self.summary_detail == "full":
870
- total_time = green_time + non_green_time
871
- pct = (green_time / total_time * 100) if total_time > 0 else 0
872
- content.append(f" {pct:>3.0f}%", style=f"bold green{bg}" if pct >= 50 else f"bold red{bg}")
873
-
874
- # Always show: token usage (from Claude Code)
875
- # ALIGNMENT: context indicator is always 7 chars " c@NNN%" (or placeholder)
876
- if self.claude_stats is not None:
877
- content.append(f" Σ{format_tokens(self.claude_stats.total_tokens):>6}", style=f"bold orange1{bg}")
878
- # Show current context window usage as percentage (assuming 200K max)
879
- if self.claude_stats.current_context_tokens > 0:
880
- max_context = 200_000 # Claude models have 200K context window
881
- ctx_pct = min(100, self.claude_stats.current_context_tokens / max_context * 100)
882
- content.append(f" c@{ctx_pct:>3.0f}%", style=f"bold orange1{bg}")
883
- else:
884
- content.append(" c@ -%", style=f"dim orange1{bg}")
885
- else:
886
- content.append(" - c@ -%", style=f"dim orange1{bg}")
887
-
888
- # Git diff stats (outstanding changes since last commit)
889
- # ALIGNMENT: Use fixed widths - low/med: 4 chars "Δnn ", full: 16 chars "Δnn +nnnn -nnnn"
890
- # Large line counts are shortened: 173242 -> "173K", 1234567 -> "1.2M"
891
- if self.git_diff_stats:
892
- files, ins, dels = self.git_diff_stats
893
- if self.summary_detail == "full":
894
- # Full: show files and lines with fixed widths
895
- content.append(f" Δ{files:>2}", style=f"bold magenta{bg}")
896
- content.append(f" +{format_line_count(ins):>4}", style=f"bold green{bg}")
897
- content.append(f" -{format_line_count(dels):>4}", style=f"bold red{bg}")
898
- else:
899
- # Compact: just files changed (fixed 4 char width)
900
- content.append(f" Δ{files:>2}", style=f"bold magenta{bg}" if files > 0 else f"dim{bg}")
901
- else:
902
- # Placeholder matching width for alignment
903
- if self.summary_detail == "full":
904
- content.append(" Δ- + - - ", style=f"dim{bg}")
905
- else:
906
- content.append(" Δ-", style=f"dim{bg}")
907
-
908
- # Med/Full detail: add median work time (p50 autonomous work duration)
909
- if self.summary_detail in ("med", "full"):
910
- work_str = format_duration(median_work) if median_work > 0 else "0s"
911
- content.append(f" ⏱{work_str:>5}", style=f"bold blue{bg}")
912
-
913
- # Always show: permission mode, human interactions, robot supervisions
914
- content.append(f" {perm_emoji}", style=f"bold white{bg}")
915
- # Human interaction count = total interactions - robot interventions
916
- if self.claude_stats is not None:
917
- human_count = max(0, self.claude_stats.interaction_count - stats.steers_count)
918
- content.append(f" 👤{human_count:>3}", style=f"bold yellow{bg}")
919
- else:
920
- content.append(" 👤 -", style=f"dim yellow{bg}")
921
- # Robot supervision count (from daemon steers) - 3 digit padding
922
- content.append(f" 🤖{stats.steers_count:>3}", style=f"bold cyan{bg}")
923
-
924
- # Standing orders indicator (after supervision count) - always show for alignment
925
- if s.standing_instructions:
926
- if s.standing_orders_complete:
927
- content.append(" ✓", style=f"bold green{bg}")
928
- elif s.standing_instructions_preset:
929
- # Show preset name (truncated to fit)
930
- preset_display = f" {s.standing_instructions_preset[:8]}"
931
- content.append(preset_display, style=f"bold cyan{bg}")
932
- else:
933
- content.append(" 📋", style=f"bold yellow{bg}")
934
- else:
935
- content.append(" ➖", style=f"bold dim{bg}") # No instructions indicator
936
-
937
- if not self.expanded:
938
- # Compact view: show standing orders or current activity
939
- content.append(" │ ", style=f"bold dim{bg}")
940
- # Calculate remaining space for standing orders/activity
941
- current_len = len(content.plain)
942
- remaining = max(20, term_width - current_len - 2)
943
-
944
- if s.standing_instructions:
945
- # Show standing orders with completion indicator
946
- if s.standing_orders_complete:
947
- style = f"bold green{bg}"
948
- prefix = "✓ "
949
- elif s.standing_instructions_preset:
950
- style = f"bold cyan{bg}"
951
- prefix = f"{s.standing_instructions_preset}: "
952
- else:
953
- style = f"bold italic yellow{bg}"
954
- prefix = ""
955
- display_text = f"{prefix}{format_standing_instructions(s.standing_instructions, remaining - len(prefix))}"
956
- content.append(display_text[:remaining], style=style)
957
- else:
958
- content.append(self.current_activity[:remaining], style=f"bold italic{bg}")
959
- # Pad to fill terminal width
960
- current_len = len(content.plain)
961
- if current_len < term_width:
962
- content.append(" " * (term_width - current_len), style=f"{bg}")
963
- return content
964
-
965
- # Pad header line to full width before adding expanded content
966
- current_len = len(content.plain)
967
- if current_len < term_width:
968
- content.append(" " * (term_width - current_len), style=f"{bg}")
969
-
970
- # Expanded view: show standing instructions first if set
971
- if s.standing_instructions:
972
- content.append("\n")
973
- content.append(" ")
974
- display_instr = format_standing_instructions(s.standing_instructions)
975
- if s.standing_orders_complete:
976
- content.append("│ ", style="bold green")
977
- content.append("✓ ", style="bold green")
978
- content.append(display_instr, style="green")
979
- elif s.standing_instructions_preset:
980
- content.append("│ ", style="cyan")
981
- content.append(f"{s.standing_instructions_preset}: ", style="bold cyan")
982
- content.append(display_instr, style="cyan")
983
- else:
984
- content.append("│ ", style="cyan")
985
- content.append("📋 ", style="yellow")
986
- content.append(display_instr, style="italic yellow")
987
-
988
- # Expanded view: show pane content based on detail_lines setting
989
- lines_to_show = self.detail_lines
990
- # Account for standing instructions line if present
991
- if s.standing_instructions:
992
- lines_to_show = max(1, lines_to_show - 1)
993
-
994
- # Get the last N lines of pane content
995
- pane_lines = self.pane_content[-lines_to_show:] if self.pane_content else []
996
-
997
- # Show pane output lines
998
- for line in pane_lines:
999
- content.append("\n")
1000
- content.append(" ") # Indent
1001
- # Truncate long lines and style based on content
1002
- display_line = line[:100] + "..." if len(line) > 100 else line
1003
- prefix_style, content_style = style_pane_line(line)
1004
- content.append("│ ", style=prefix_style)
1005
- content.append(display_line, style=content_style)
1006
-
1007
- # If no pane content and no standing instructions shown above, show placeholder
1008
- if not pane_lines and not s.standing_instructions:
1009
- content.append("\n")
1010
- content.append(" ") # Indent
1011
- content.append("│ ", style="cyan")
1012
- content.append("(no output)", style="dim italic")
1013
-
1014
- return content
1015
-
1016
-
1017
- class PreviewPane(Static):
1018
- """Preview pane showing focused agent's terminal output in list+preview mode."""
1019
-
1020
- content_lines: reactive[List[str]] = reactive(list, init=False)
1021
- session_name: str = ""
1022
-
1023
- def __init__(self, **kwargs):
1024
- super().__init__(**kwargs)
1025
- self.content_lines = []
1026
-
1027
- def render(self) -> Text:
1028
- content = Text()
1029
- # Use widget width for layout, with sensible fallback
1030
- pane_width = self.size.width if self.size.width > 0 else 80
1031
-
1032
- # Header with session name - pad to full pane width
1033
- header = f"─── {self.session_name} " if self.session_name else "─── Preview "
1034
- content.append(header, style="bold cyan")
1035
- content.append("─" * max(0, pane_width - len(header)), style="dim")
1036
- content.append("\n")
1037
-
1038
- if not self.content_lines:
1039
- content.append("(no output)", style="dim italic")
1040
- else:
1041
- # Calculate available lines based on widget height
1042
- # Reserve 2 lines for header and some padding
1043
- available_lines = max(10, self.size.height - 2) if self.size.height > 0 else 30
1044
- # Show last N lines of output - plain text, no decoration
1045
- # Truncate lines to pane width to match tmux display
1046
- max_line_len = max(pane_width - 1, 40) # Leave room for newline, minimum 40
1047
- for line in self.content_lines[-available_lines:]:
1048
- # Truncate long lines to pane width
1049
- display_line = line[:max_line_len] if len(line) > max_line_len else line
1050
- content.append(display_line + "\n")
1051
-
1052
- return content
1053
-
1054
- def update_from_widget(self, widget: "SessionSummary") -> None:
1055
- """Update preview content from a SessionSummary widget."""
1056
- self.session_name = widget.session.name
1057
- self.content_lines = list(widget.pane_content) if widget.pane_content else []
1058
- self.refresh()
1059
-
1060
-
1061
- class CommandBar(Static):
1062
- """Inline command bar for sending instructions to agents.
1063
-
1064
- Supports single-line (Input) and multi-line (TextArea) modes.
1065
- Toggle with Ctrl+E. Send with Enter (single) or Ctrl+Enter (multi).
1066
- Use Ctrl+O to set as standing order instead of sending.
1067
-
1068
- Modes:
1069
- - "send": Default mode for sending instructions to an agent
1070
- - "standing_orders": Mode for editing standing orders for an agent
1071
- - "new_agent_dir": First step of new agent creation - enter working directory
1072
- - "new_agent_name": Second step of new agent creation - enter agent name
1073
- - "new_agent_perms": Third step of new agent creation - choose permission mode
1074
-
1075
- Key handling is done via on_key() since Input/TextArea consume most keys.
1076
- """
1077
-
1078
- expanded = reactive(False) # Toggle single/multi-line mode
1079
- target_session: Optional[str] = None
1080
- mode: str = "send" # "send", "standing_orders", "new_agent_dir", "new_agent_name", or "new_agent_perms"
1081
- new_agent_dir: Optional[str] = None # Store directory between steps
1082
- new_agent_name: Optional[str] = None # Store name between steps
1083
-
1084
- class SendRequested(Message):
1085
- """Message sent when user wants to send text to a session."""
1086
- def __init__(self, session_name: str, text: str):
1087
- super().__init__()
1088
- self.session_name = session_name
1089
- self.text = text
1090
-
1091
- class StandingOrderRequested(Message):
1092
- """Message sent when user wants to set a standing order."""
1093
- def __init__(self, session_name: str, text: str):
1094
- super().__init__()
1095
- self.session_name = session_name
1096
- self.text = text
1097
-
1098
- class NewAgentRequested(Message):
1099
- """Message sent when user wants to create a new agent."""
1100
- def __init__(self, agent_name: str, directory: Optional[str] = None, bypass_permissions: bool = False):
1101
- super().__init__()
1102
- self.agent_name = agent_name
1103
- self.directory = directory
1104
- self.bypass_permissions = bypass_permissions
1105
-
1106
- def compose(self) -> ComposeResult:
1107
- """Create command bar widgets."""
1108
- with Horizontal(id="cmd-bar-container"):
1109
- yield Label("", id="target-label")
1110
- yield Input(id="cmd-input", placeholder="Type instruction (Enter to send)...", disabled=True)
1111
- yield TextArea(id="cmd-textarea", classes="hidden", disabled=True)
1112
- yield Label("[^E]", id="expand-hint")
1113
-
1114
- def on_mount(self) -> None:
1115
- """Initialize command bar state."""
1116
- self._update_target_label()
1117
- # Ensure widgets start disabled to prevent auto-focus
1118
- self.query_one("#cmd-input", Input).disabled = True
1119
- self.query_one("#cmd-textarea", TextArea).disabled = True
1120
-
1121
- def _update_target_label(self) -> None:
1122
- """Update the target session label based on mode."""
1123
- label = self.query_one("#target-label", Label)
1124
- input_widget = self.query_one("#cmd-input", Input)
1125
-
1126
- if self.mode == "new_agent_dir":
1127
- label.update("[New Agent: Directory] ")
1128
- input_widget.placeholder = "Enter working directory path..."
1129
- elif self.mode == "new_agent_name":
1130
- label.update("[New Agent: Name] ")
1131
- input_widget.placeholder = "Enter agent name (or Enter to accept default)..."
1132
- elif self.mode == "new_agent_perms":
1133
- label.update("[New Agent: Permissions] ")
1134
- input_widget.placeholder = "Type 'bypass' for --dangerously-skip-permissions, or Enter for normal..."
1135
- elif self.mode == "standing_orders":
1136
- if self.target_session:
1137
- label.update(f"[{self.target_session} Standing Orders] ")
1138
- else:
1139
- label.update("[Standing Orders] ")
1140
- input_widget.placeholder = "Enter standing orders (or empty to clear)..."
1141
- elif self.target_session:
1142
- label.update(f"[{self.target_session}] ")
1143
- input_widget.placeholder = "Type instruction (Enter to send)..."
1144
- else:
1145
- label.update("[no session] ")
1146
- input_widget.placeholder = "Type instruction (Enter to send)..."
1147
-
1148
- def set_target(self, session_name: Optional[str]) -> None:
1149
- """Set the target session for commands."""
1150
- self.target_session = session_name
1151
- self.mode = "send" # Reset to send mode when target changes
1152
- self._update_target_label()
1153
-
1154
- def set_mode(self, mode: str) -> None:
1155
- """Set the command bar mode ('send' or 'new_agent')."""
1156
- self.mode = mode
1157
- self._update_target_label()
1158
-
1159
- def watch_expanded(self, expanded: bool) -> None:
1160
- """Toggle between single-line and multi-line mode."""
1161
- input_widget = self.query_one("#cmd-input", Input)
1162
- textarea = self.query_one("#cmd-textarea", TextArea)
1163
-
1164
- if expanded:
1165
- # Switch to multi-line
1166
- input_widget.add_class("hidden")
1167
- input_widget.disabled = True
1168
- textarea.remove_class("hidden")
1169
- textarea.disabled = False
1170
- # Transfer content
1171
- textarea.text = input_widget.value
1172
- input_widget.value = ""
1173
- textarea.focus()
1174
- else:
1175
- # Switch to single-line
1176
- textarea.add_class("hidden")
1177
- textarea.disabled = True
1178
- input_widget.remove_class("hidden")
1179
- input_widget.disabled = False
1180
- # Transfer content (first line only for single-line)
1181
- if textarea.text:
1182
- first_line = textarea.text.split('\n')[0]
1183
- input_widget.value = first_line
1184
- textarea.text = ""
1185
- input_widget.focus()
1186
-
1187
- def on_key(self, event: events.Key) -> None:
1188
- """Handle key events for command bar shortcuts."""
1189
- if event.key == "ctrl+e":
1190
- self.action_toggle_expand()
1191
- event.stop()
1192
- elif event.key == "ctrl+o":
1193
- self.action_set_standing_order()
1194
- event.stop()
1195
- elif event.key == "escape":
1196
- self.action_clear_and_unfocus()
1197
- event.stop()
1198
- elif event.key == "ctrl+enter" and self.expanded:
1199
- self.action_send_multiline()
1200
- event.stop()
1201
-
1202
- def on_input_submitted(self, event: Input.Submitted) -> None:
1203
- """Handle Enter in single-line mode."""
1204
- if event.input.id == "cmd-input":
1205
- text = event.value.strip()
1206
-
1207
- if self.mode == "new_agent_dir":
1208
- # Step 1: Directory entered, validate and move to name step
1209
- # Note: _handle_new_agent_dir sets input value to default name, don't clear it
1210
- self._handle_new_agent_dir(text if text else None)
1211
- return
1212
- elif self.mode == "new_agent_name":
1213
- # Step 2: Name entered (or default accepted), move to permissions step
1214
- # If empty, use the pre-filled default
1215
- name = text if text else event.input.value.strip()
1216
- if not name:
1217
- # Derive from directory as fallback
1218
- from pathlib import Path
1219
- name = Path(self.new_agent_dir).name if self.new_agent_dir else "agent"
1220
- self._handle_new_agent_name(name)
1221
- event.input.value = ""
1222
- return
1223
- elif self.mode == "new_agent_perms":
1224
- # Step 3: Permissions chosen, create agent
1225
- bypass = text.lower().strip() in ("bypass", "y", "yes", "!")
1226
- self._create_new_agent(self.new_agent_name, bypass)
1227
- event.input.value = ""
1228
- self.action_clear_and_unfocus()
1229
- return
1230
- elif self.mode == "standing_orders":
1231
- # Set standing orders (empty string clears them)
1232
- self._set_standing_order(text)
1233
- event.input.value = ""
1234
- self.action_clear_and_unfocus()
1235
- return
1236
-
1237
- # Default "send" mode
1238
- if not text:
1239
- return
1240
- self._send_message(text)
1241
- event.input.value = ""
1242
- self.action_clear_and_unfocus()
1243
-
1244
- def _send_message(self, text: str) -> None:
1245
- """Send message to target session."""
1246
- if not self.target_session or not text.strip():
1247
- return
1248
- self.post_message(self.SendRequested(self.target_session, text.strip()))
1249
-
1250
- def _handle_new_agent_dir(self, directory: Optional[str]) -> None:
1251
- """Handle directory input for new agent creation.
1252
-
1253
- Validates directory and transitions to name input step.
1254
- """
1255
- from pathlib import Path
1256
-
1257
- # Expand ~ and resolve path
1258
- if directory:
1259
- dir_path = Path(directory).expanduser().resolve()
1260
- if not dir_path.exists():
1261
- # Try to create it or warn
1262
- self.app.notify(f"Directory does not exist: {dir_path}", severity="warning")
1263
- return
1264
- if not dir_path.is_dir():
1265
- self.app.notify(f"Not a directory: {dir_path}", severity="error")
1266
- return
1267
- self.new_agent_dir = str(dir_path)
1268
- else:
1269
- # Use current working directory if none specified
1270
- self.new_agent_dir = str(Path.cwd())
1271
-
1272
- # Derive default agent name from directory basename
1273
- default_name = Path(self.new_agent_dir).name
1274
-
1275
- # Transition to name step
1276
- self.mode = "new_agent_name"
1277
- self._update_target_label()
1278
-
1279
- # Pre-fill the input with the default name
1280
- input_widget = self.query_one("#cmd-input", Input)
1281
- input_widget.value = default_name
1282
-
1283
- def _handle_new_agent_name(self, name: str) -> None:
1284
- """Handle name input for new agent creation.
1285
-
1286
- Stores the name and transitions to permissions step.
1287
- """
1288
- self.new_agent_name = name
1289
-
1290
- # Transition to permissions step
1291
- self.mode = "new_agent_perms"
1292
- self._update_target_label()
1293
-
1294
- def _create_new_agent(self, name: str, bypass_permissions: bool = False) -> None:
1295
- """Create a new agent with the given name, directory, and permission mode."""
1296
- self.post_message(self.NewAgentRequested(name, self.new_agent_dir, bypass_permissions))
1297
- # Reset state
1298
- self.new_agent_dir = None
1299
- self.new_agent_name = None
1300
- self.mode = "send"
1301
- self._update_target_label()
1302
-
1303
- def _set_standing_order(self, text: str) -> None:
1304
- """Set text as standing order."""
1305
- if not self.target_session or not text.strip():
1306
- return
1307
- self.post_message(self.StandingOrderRequested(self.target_session, text.strip()))
1308
-
1309
- def action_toggle_expand(self) -> None:
1310
- """Toggle between single and multi-line mode."""
1311
- self.expanded = not self.expanded
1312
-
1313
- def action_send_multiline(self) -> None:
1314
- """Send content from multi-line textarea."""
1315
- textarea = self.query_one("#cmd-textarea", TextArea)
1316
- self._send_message(textarea.text)
1317
- textarea.text = ""
1318
- self.action_clear_and_unfocus()
1319
-
1320
- def action_set_standing_order(self) -> None:
1321
- """Set current content as standing order."""
1322
- if self.expanded:
1323
- textarea = self.query_one("#cmd-textarea", TextArea)
1324
- self._set_standing_order(textarea.text)
1325
- textarea.text = ""
1326
- else:
1327
- input_widget = self.query_one("#cmd-input", Input)
1328
- self._set_standing_order(input_widget.value)
1329
- input_widget.value = ""
1330
-
1331
- def action_clear_and_unfocus(self) -> None:
1332
- """Clear input and unfocus command bar."""
1333
- if self.expanded:
1334
- textarea = self.query_one("#cmd-textarea", TextArea)
1335
- textarea.text = ""
1336
- else:
1337
- input_widget = self.query_one("#cmd-input", Input)
1338
- input_widget.value = ""
1339
- # Reset mode and state
1340
- self.mode = "send"
1341
- self.new_agent_dir = None
1342
- self.new_agent_name = None
1343
- self._update_target_label()
1344
- # Let parent handle unfocus
1345
- self.post_message(self.ClearRequested())
1346
-
1347
- def focus_input(self) -> None:
1348
- """Focus the command bar input and enable it."""
1349
- input_widget = self.query_one("#cmd-input", Input)
1350
- input_widget.disabled = False
1351
- input_widget.focus()
1352
-
1353
- class ClearRequested(Message):
1354
- """Message sent when user clears the command bar."""
1355
- pass
1356
-
1357
-
1358
- class SupervisorTUI(App):
108
+ class SupervisorTUI(
109
+ NavigationActionsMixin,
110
+ ViewActionsMixin,
111
+ DaemonActionsMixin,
112
+ SessionActionsMixin,
113
+ InputActionsMixin,
114
+ App,
115
+ ):
1359
116
  """Overcode Supervisor TUI"""
1360
117
 
1361
118
  # Disable any size restrictions
1362
119
  AUTO_FOCUS = None
1363
120
 
1364
- CSS = """
1365
- Screen {
1366
- background: $background;
1367
- overflow: hidden;
1368
- height: 100%;
1369
- }
1370
-
1371
- Header {
1372
- dock: top;
1373
- height: 1;
1374
- }
1375
-
1376
- #daemon-status {
1377
- height: 1;
1378
- width: 100%;
1379
- background: $panel;
1380
- padding: 0 1;
1381
- }
1382
-
1383
- #timeline {
1384
- height: auto;
1385
- min-height: 4;
1386
- max-height: 20;
1387
- width: 100%;
1388
- background: $surface;
1389
- padding: 0 1;
1390
- border-bottom: solid $panel;
1391
- }
1392
-
1393
- #sessions-container {
1394
- height: 1fr;
1395
- width: 100%;
1396
- overflow: auto auto;
1397
- padding: 0;
1398
- }
1399
-
1400
- /* In list+preview mode, sessions container is compact (auto-size to content) */
1401
- #sessions-container.list-mode {
1402
- height: auto;
1403
- max-height: 30%;
1404
- }
1405
-
1406
- SessionSummary {
1407
- height: 1;
1408
- width: 100%;
1409
- padding: 0 1;
1410
- margin: 0;
1411
- border: none;
1412
- background: $surface;
1413
- overflow: hidden;
1414
- }
1415
-
1416
- SessionSummary.expanded {
1417
- height: auto;
1418
- min-height: 2;
1419
- max-height: 55; /* Support up to 50 lines detail + header/instructions */
1420
- background: #1c1c1c;
1421
- border-bottom: solid #5588aa;
1422
- }
1423
-
1424
- SessionSummary:hover {
1425
- background: $boost;
1426
- }
1427
-
1428
- SessionSummary:focus {
1429
- background: #2d4a5a;
1430
- text-style: bold;
1431
- }
1432
-
1433
- /* .selected class preserves highlight when app loses focus */
1434
- SessionSummary.selected {
1435
- background: #2d4a5a;
1436
- text-style: bold;
1437
- }
1438
-
1439
- #help-text {
1440
- dock: bottom;
1441
- height: 1;
1442
- width: 100%;
1443
- background: $panel;
1444
- color: $text-muted;
1445
- padding: 0 1;
1446
- }
1447
-
1448
- #help-overlay {
1449
- display: none;
1450
- layer: above;
1451
- dock: top;
1452
- width: 100%;
1453
- height: 100%;
1454
- background: $surface 90%;
1455
- padding: 1 2;
1456
- overflow-y: auto;
1457
- }
1458
-
1459
- #help-overlay.visible {
1460
- display: block;
1461
- }
1462
-
1463
- #daemon-panel {
1464
- display: none;
1465
- height: auto;
1466
- min-height: 2;
1467
- max-height: 12;
1468
- width: 100%;
1469
- background: $surface;
1470
- padding: 0 1;
1471
- border-bottom: solid $panel;
1472
- }
1473
-
1474
- CommandBar {
1475
- dock: bottom;
1476
- height: auto;
1477
- min-height: 1;
1478
- max-height: 8;
1479
- width: 100%;
1480
- background: $surface;
1481
- border-top: solid $primary;
1482
- padding: 0 1;
1483
- display: none; /* Hidden by default, shown with 'i' key */
1484
- }
1485
-
1486
- CommandBar.visible {
1487
- display: block;
1488
- }
1489
-
1490
- #cmd-bar-container {
1491
- width: 100%;
1492
- height: auto;
1493
- }
1494
-
1495
- #target-label {
1496
- width: auto;
1497
- color: $primary;
1498
- text-style: bold;
1499
- }
1500
-
1501
- #cmd-input {
1502
- width: 1fr;
1503
- min-width: 20;
1504
- }
1505
-
1506
- #cmd-input.hidden {
1507
- display: none;
1508
- }
1509
-
1510
- #cmd-textarea {
1511
- width: 1fr;
1512
- min-width: 20;
1513
- height: 4;
1514
- }
1515
-
1516
- #cmd-textarea.hidden {
1517
- display: none;
1518
- }
1519
-
1520
- #expand-hint {
1521
- width: auto;
1522
- color: $text-muted;
1523
- padding-left: 1;
1524
- }
1525
-
1526
- /* List mode - always collapsed */
1527
- /* List mode: compact single-line, no borders/dividers */
1528
- SessionSummary.list-mode {
1529
- height: 1;
1530
- border: none;
1531
- margin: 0;
1532
- padding: 0 1;
1533
- }
1534
-
1535
- /* Preview pane - hidden by default, shown via .visible class */
1536
- #preview-pane {
1537
- display: none;
1538
- height: 1fr;
1539
- border-top: solid $primary;
1540
- padding: 0 1;
1541
- background: $surface;
1542
- overflow-y: auto;
1543
- }
1544
-
1545
- #preview-pane.visible {
1546
- display: block;
1547
- }
1548
-
1549
- /* Focused indicator in list mode */
1550
- SessionSummary:focus.list-mode {
1551
- background: $accent;
1552
- }
1553
- """
121
+ # Load CSS from external file
122
+ CSS_PATH = "tui.tcss"
123
+
1554
124
 
1555
125
  BINDINGS = [
1556
126
  ("q", "quit", "Quit"),
@@ -1560,8 +130,8 @@ class SupervisorTUI(App):
1560
130
  ("t", "toggle_timeline", "Toggle timeline"),
1561
131
  ("v", "cycle_detail", "Cycle detail"),
1562
132
  ("s", "cycle_summary", "Summary detail"),
1563
- ("e", "expand_all", "Expand all"),
1564
- ("c", "collapse_all", "Collapse all"),
133
+ ("e", "toggle_expand_all", "Expand/Collapse"),
134
+ ("c", "sync_to_main_and_clear", "Sync main+clear"),
1565
135
  ("space", "toggle_focused", "Toggle"),
1566
136
  # Navigation between agents
1567
137
  ("j", "focus_next_session", "Next"),
@@ -1578,10 +148,12 @@ class SupervisorTUI(App):
1578
148
  ("left_square_bracket", "supervisor_start", "Start supervisor"),
1579
149
  ("right_square_bracket", "supervisor_stop", "Stop supervisor"),
1580
150
  ("backslash", "monitor_restart", "Restart monitor"),
151
+ ("a", "toggle_summarizer", "AI summarizer"),
1581
152
  # Manual refresh (useful in diagnostics mode)
1582
153
  ("r", "manual_refresh", "Refresh"),
1583
154
  # Agent management
1584
155
  ("x", "kill_focused", "Kill agent"),
156
+ ("R", "restart_focused", "Restart agent"),
1585
157
  ("n", "new_agent", "New agent"),
1586
158
  # Send Enter to focused agent (for approvals)
1587
159
  ("enter", "send_enter_to_focused", "Send Enter"),
@@ -1599,16 +171,50 @@ class SupervisorTUI(App):
1599
171
  ("w", "toggle_web_server", "Web dashboard"),
1600
172
  # Sleep mode toggle - mark agent as paused (excluded from stats)
1601
173
  ("z", "toggle_sleep", "Sleep mode"),
174
+ # Show terminated/killed sessions (ghost mode)
175
+ ("g", "toggle_show_terminated", "Show killed"),
176
+ # Jump to sessions needing attention (bell/red)
177
+ ("b", "jump_to_attention", "Jump attention"),
178
+ # Hide sleeping agents from display
179
+ ("Z", "toggle_hide_asleep", "Hide sleeping"),
180
+ # Sort mode cycle (#61)
181
+ ("S", "cycle_sort_mode", "Sort mode"),
182
+ # Edit agent value (#61)
183
+ ("V", "edit_agent_value", "Edit value"),
184
+ # Cycle summary content mode (#74)
185
+ ("l", "cycle_summary_content", "Summary content"),
186
+ # Edit human annotation (#74)
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"),
1602
198
  ]
1603
199
 
1604
200
  # Detail level cycles through 5, 10, 20, 50 lines
1605
201
  DETAIL_LEVELS = [5, 10, 20, 50]
1606
202
  # Summary detail levels: low (minimal), med (timing), full (all + repo)
1607
203
  SUMMARY_LEVELS = ["low", "med", "full"]
204
+ # Sort modes (#61)
205
+ SORT_MODES = ["alphabetical", "by_status", "by_value"]
206
+ # Summary content modes: what to show in the summary line (#74)
207
+ SUMMARY_CONTENT_MODES = ["ai_short", "ai_long", "orders", "annotation"]
1608
208
 
1609
209
  sessions: reactive[List[Session]] = reactive(list)
1610
210
  view_mode: reactive[str] = reactive("tree") # "tree" or "list_preview"
1611
211
  tmux_sync: reactive[bool] = reactive(False) # sync navigation to external tmux pane
212
+ show_terminated: reactive[bool] = reactive(False) # show killed sessions in timeline
213
+ hide_asleep: reactive[bool] = reactive(False) # hide sleeping agents from display
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
1612
218
 
1613
219
  def __init__(self, tmux_session: str = "agents", diagnostics: bool = False):
1614
220
  super().__init__()
@@ -1651,12 +257,42 @@ class SupervisorTUI(App):
1651
257
  self._status_update_in_progress = False
1652
258
  # Track if we've warned about multiple daemons (to avoid spam)
1653
259
  self._multiple_daemon_warning_shown = False
260
+ # Track attention jump state (for 'b' key cycling)
261
+ self._attention_jump_index = 0
262
+ self._attention_jump_list: list = [] # Cached list of sessions needing attention
1654
263
  # Pending kill confirmation (session name, timestamp)
1655
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
1656
271
  # Tmux interface for sync operations
1657
272
  self._tmux = RealTmux()
1658
273
  # Initialize tmux_sync from preferences
1659
274
  self.tmux_sync = self._prefs.tmux_sync
275
+ # Initialize show_terminated from preferences
276
+ self.show_terminated = self._prefs.show_terminated
277
+ # Initialize hide_asleep from preferences
278
+ self.hide_asleep = self._prefs.hide_asleep
279
+ # Initialize summary_content_mode from preferences (#98)
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
287
+ # Cache of terminated sessions (killed during this TUI session)
288
+ self._terminated_sessions: dict[str, Session] = {}
289
+
290
+ # AI Summarizer - owned by TUI, not daemon (zero cost when TUI closed)
291
+ self._summarizer = SummarizerComponent(
292
+ tmux_session=tmux_session,
293
+ config=SummarizerConfig(enabled=False), # Disabled by default
294
+ )
295
+ self._summaries: dict[str, AgentSummary] = {}
1660
296
 
1661
297
  def compose(self) -> ComposeResult:
1662
298
  """Create child widgets"""
@@ -1669,13 +305,13 @@ class SupervisorTUI(App):
1669
305
  yield CommandBar(id="command-bar")
1670
306
  yield HelpOverlay(id="help-overlay")
1671
307
  yield Static(
1672
- "h:Help | q:Quit | j/k:Nav | i:Send | n:New | x:Kill | space | m:Mode | p:Sync | d:Daemon | t:Timeline",
308
+ "h:Help | q:Quit | j/k:Nav | i:Send | n:New | x:Kill | space | m:Mode | p:Sync | d:Daemon | t:Timeline | g:Killed",
1673
309
  id="help-text"
1674
310
  )
1675
311
 
1676
312
  def on_mount(self) -> None:
1677
313
  """Called when app starts"""
1678
- self.title = "Overcode Monitor"
314
+ self.title = f"Overcode v{__version__}"
1679
315
  self._update_subtitle()
1680
316
 
1681
317
  # Auto-start Monitor Daemon if not running
@@ -1704,6 +340,20 @@ class SupervisorTUI(App):
1704
340
  except NoMatches:
1705
341
  pass
1706
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
+
1707
357
  # Set view_mode from preferences (triggers watch_view_mode)
1708
358
  self.view_mode = self._prefs.view_mode
1709
359
 
@@ -1727,12 +377,17 @@ class SupervisorTUI(App):
1727
377
  # Normal mode: Set up all timers
1728
378
  # Refresh session list every 10 seconds
1729
379
  self.set_interval(10, self.refresh_sessions)
1730
- # Update status very frequently for real-time detail view
1731
- 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)
1732
385
  # Update daemon status every 5 seconds
1733
386
  self.set_interval(5, self.update_daemon_status)
1734
387
  # Update timeline every 30 seconds
1735
388
  self.set_interval(30, self.update_timeline)
389
+ # Update AI summaries every 5 seconds (only runs if enabled)
390
+ self.set_interval(5, self._update_summaries_async)
1736
391
 
1737
392
  def update_daemon_status(self) -> None:
1738
393
  """Update daemon status bar"""
@@ -1768,37 +423,6 @@ class SupervisorTUI(App):
1768
423
  """Save current TUI preferences to disk."""
1769
424
  self._prefs.save(self.tmux_session)
1770
425
 
1771
- def action_toggle_timeline(self) -> None:
1772
- """Toggle timeline visibility"""
1773
- try:
1774
- timeline = self.query_one("#timeline", StatusTimeline)
1775
- timeline.display = not timeline.display
1776
- self._prefs.timeline_visible = timeline.display
1777
- self._save_prefs()
1778
- state = "shown" if timeline.display else "hidden"
1779
- self.notify(f"Timeline {state}", severity="information")
1780
- except NoMatches:
1781
- pass
1782
-
1783
- def action_toggle_help(self) -> None:
1784
- """Toggle help overlay visibility"""
1785
- try:
1786
- help_overlay = self.query_one("#help-overlay", HelpOverlay)
1787
- if help_overlay.has_class("visible"):
1788
- help_overlay.remove_class("visible")
1789
- else:
1790
- help_overlay.add_class("visible")
1791
- except NoMatches:
1792
- pass
1793
-
1794
- def action_manual_refresh(self) -> None:
1795
- """Manually trigger a full refresh (useful in diagnostics mode)"""
1796
- self.refresh_sessions()
1797
- self.update_all_statuses()
1798
- self.update_daemon_status()
1799
- self.update_timeline()
1800
- self.notify("Refreshed", severity="information", timeout=2)
1801
-
1802
426
  def on_resize(self) -> None:
1803
427
  """Handle terminal resize events"""
1804
428
  self.refresh()
@@ -1810,8 +434,14 @@ class SupervisorTUI(App):
1810
434
  Uses launcher.list_sessions() to detect terminated sessions
1811
435
  (tmux windows that no longer exist, e.g., after machine reboot).
1812
436
  """
437
+ # Remember the currently focused session before refreshing/sorting
438
+ focused = self.focused
439
+ focused_session_id = focused.session.id if isinstance(focused, SessionSummary) else None
440
+
1813
441
  self._invalidate_sessions_cache() # Force cache refresh
1814
442
  self.sessions = self.launcher.list_sessions()
443
+ # Apply sorting (#61)
444
+ self._sort_sessions()
1815
445
  # Calculate max repo:branch width for alignment in full detail mode
1816
446
  self.max_repo_info_width = max(
1817
447
  (len(f"{s.repo_name or 'n/a'}:{s.branch or 'n/a'}") for s in self.sessions),
@@ -1819,9 +449,21 @@ class SupervisorTUI(App):
1819
449
  )
1820
450
  self.max_repo_info_width = max(self.max_repo_info_width, 10) # Minimum 10 chars
1821
451
  self.update_session_widgets()
452
+
453
+ # Update focused_session_index to follow the same session at its new position
454
+ if focused_session_id:
455
+ widgets = self._get_widgets_in_session_order()
456
+ for i, widget in enumerate(widgets):
457
+ if widget.session.id == focused_session_id:
458
+ self.focused_session_index = i
459
+ break
1822
460
  # NOTE: Don't call update_timeline() here - it has its own 30s interval
1823
461
  # and reading log files during session refresh causes UI stutter
1824
462
 
463
+ def _sort_sessions(self) -> None:
464
+ """Sort sessions based on current sort mode (#61)."""
465
+ self.sessions = sort_sessions(self.sessions, self._prefs.sort_mode)
466
+
1825
467
  def _get_cached_sessions(self) -> dict[str, Session]:
1826
468
  """Get sessions with caching to reduce disk I/O.
1827
469
 
@@ -1840,11 +482,53 @@ class SupervisorTUI(App):
1840
482
  """Invalidate the sessions cache to force reload on next access."""
1841
483
  self._sessions_cache_time = 0
1842
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
+
1843
524
  def update_all_statuses(self) -> None:
1844
525
  """Trigger async status update for all session widgets.
1845
526
 
1846
527
  This is NON-BLOCKING - it kicks off a background worker that fetches
1847
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.
1848
532
  """
1849
533
  # Skip if an update is already in progress
1850
534
  if self._status_update_in_progress:
@@ -1910,18 +594,36 @@ class SupervisorTUI(App):
1910
594
  stats_results[session_id] = claude_stats
1911
595
  git_diff_results[session_id] = git_diff
1912
596
 
597
+ # Use local summaries from TUI's summarizer (not daemon state)
598
+ ai_summaries = {}
599
+ for session_id, summary in self._summaries.items():
600
+ ai_summaries[session_id] = (
601
+ summary.text or "",
602
+ summary.context or "",
603
+ )
604
+
1913
605
  # Update UI on main thread
1914
- self.call_from_thread(self._apply_status_results, status_results, stats_results, git_diff_results, fresh_sessions)
606
+ self.call_from_thread(self._apply_status_results, status_results, stats_results, git_diff_results, fresh_sessions, ai_summaries)
1915
607
  finally:
1916
608
  self._status_update_in_progress = False
1917
609
 
1918
- def _apply_status_results(self, status_results: dict, stats_results: dict, git_diff_results: dict, fresh_sessions: dict) -> None:
610
+ def _apply_status_results(self, status_results: dict, stats_results: dict, git_diff_results: dict, fresh_sessions: dict, ai_summaries: dict = None) -> None:
1919
611
  """Apply fetched status results to widgets (runs on main thread).
1920
612
 
1921
613
  All data has been pre-fetched in background - this just updates widget state.
1922
614
  No file I/O happens here.
1923
615
  """
1924
616
  prefs_changed = False
617
+ ai_summaries = ai_summaries or {}
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)
1925
627
 
1926
628
  for widget in self.query(SessionSummary):
1927
629
  session_id = widget.session.id
@@ -1930,6 +632,10 @@ class SupervisorTUI(App):
1930
632
  if session_id in fresh_sessions:
1931
633
  widget.session = fresh_sessions[session_id]
1932
634
 
635
+ # Update AI summaries from daemon state (if available)
636
+ if session_id in ai_summaries:
637
+ widget.ai_summary_short, widget.ai_summary_context = ai_summaries[session_id]
638
+
1933
639
  # Apply status and stats if we have results for this widget
1934
640
  if session_id in status_results:
1935
641
  status, activity, content = status_results[session_id]
@@ -1946,10 +652,11 @@ class SupervisorTUI(App):
1946
652
  # Update previous status for next round
1947
653
  self._previous_statuses[session_id] = status
1948
654
 
1949
- # Update widget's unvisited state
655
+ # Update widget's unvisited state (never show bell on asleep sessions #120)
1950
656
  is_unvisited_stalled = (
1951
657
  status == STATUS_WAITING_USER and
1952
- session_id not in self._prefs.visited_stalled_agents
658
+ session_id not in self._prefs.visited_stalled_agents and
659
+ not widget.session.is_asleep
1953
660
  )
1954
661
  widget.is_unvisited_stalled = is_unvisited_stalled
1955
662
 
@@ -1964,6 +671,41 @@ class SupervisorTUI(App):
1964
671
  if self.view_mode == "list_preview":
1965
672
  self._update_preview()
1966
673
 
674
+ @work(thread=True, exclusive=True, name="summarizer")
675
+ def _update_summaries_async(self) -> None:
676
+ """Background thread for AI summarization.
677
+
678
+ Only runs if summarizer is enabled. Updates are applied to widgets
679
+ via call_from_thread.
680
+ """
681
+ if not self._summarizer.enabled:
682
+ return
683
+
684
+ # Get fresh session list
685
+ sessions = self.session_manager.list_sessions()
686
+ if not sessions:
687
+ return
688
+
689
+ # Update summaries (this makes API calls)
690
+ summaries = self._summarizer.update(sessions)
691
+
692
+ # Apply to widgets on main thread
693
+ self.call_from_thread(self._apply_summaries, summaries)
694
+
695
+ def _apply_summaries(self, summaries: dict) -> None:
696
+ """Apply AI summaries to session widgets (runs on main thread)."""
697
+ self._summaries = summaries
698
+ is_enabled = self._summarizer.config.enabled
699
+
700
+ for widget in self.query(SessionSummary):
701
+ widget.summarizer_enabled = is_enabled
702
+ session_id = widget.session.id
703
+ if session_id in summaries:
704
+ summary = summaries[session_id]
705
+ widget.ai_summary_short = summary.text or ""
706
+ widget.ai_summary_context = summary.context or ""
707
+ widget.refresh()
708
+
1967
709
  def update_session_widgets(self) -> None:
1968
710
  """Update the session display incrementally.
1969
711
 
@@ -1972,9 +714,17 @@ class SupervisorTUI(App):
1972
714
  """
1973
715
  container = self.query_one("#sessions-container", ScrollableContainer)
1974
716
 
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
+ )
724
+
1975
725
  # Get existing widgets and their session IDs
1976
726
  existing_widgets = {w.session.id: w for w in self.query(SessionSummary)}
1977
- new_session_ids = {s.id for s in self.sessions}
727
+ new_session_ids = {s.id for s in display_sessions}
1978
728
  existing_session_ids = set(existing_widgets.keys())
1979
729
 
1980
730
  # Check if we have an empty message widget that needs removal
@@ -1990,10 +740,17 @@ class SupervisorTUI(App):
1990
740
 
1991
741
  if not sessions_added and not sessions_removed and not has_empty_message:
1992
742
  # No structural changes needed - just update session data in existing widgets
1993
- session_map = {s.id: s for s in self.sessions}
743
+ session_map = {s.id: s for s in display_sessions}
1994
744
  for widget in existing_widgets.values():
1995
745
  if widget.session.id in session_map:
1996
746
  widget.session = session_map[widget.session.id]
747
+ # Update terminated visual state
748
+ if widget.session.status == "terminated":
749
+ widget.add_class("terminated")
750
+ else:
751
+ widget.remove_class("terminated")
752
+ # Still reorder widgets to handle sort mode changes
753
+ self._reorder_session_widgets(container)
1997
754
  return
1998
755
 
1999
756
  # Remove widgets for deleted sessions
@@ -2002,11 +759,11 @@ class SupervisorTUI(App):
2002
759
  widget.remove()
2003
760
 
2004
761
  # Clear empty message if we now have sessions
2005
- if has_empty_message and self.sessions:
762
+ if has_empty_message and display_sessions:
2006
763
  container.remove_children()
2007
764
 
2008
765
  # Handle empty state
2009
- if not self.sessions:
766
+ if not display_sessions:
2010
767
  if not has_empty_message:
2011
768
  container.remove_children()
2012
769
  container.mount(Static(
@@ -2016,7 +773,7 @@ class SupervisorTUI(App):
2016
773
  return
2017
774
 
2018
775
  # Add widgets for new sessions
2019
- for session in self.sessions:
776
+ for session in display_sessions:
2020
777
  if session.id in sessions_added:
2021
778
  widget = SessionSummary(session, self.status_detector)
2022
779
  # Restore expanded state if we have it saved
@@ -2026,60 +783,33 @@ class SupervisorTUI(App):
2026
783
  widget.detail_lines = self.DETAIL_LEVELS[self.detail_level_index]
2027
784
  # Apply current summary detail level
2028
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
2029
790
  # Apply list-mode class if in list_preview view
2030
791
  if self.view_mode == "list_preview":
2031
792
  widget.add_class("list-mode")
2032
793
  widget.expanded = False # Force collapsed in list mode
794
+ # Mark terminated sessions with visual styling and status
795
+ if session.status == "terminated":
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 ""
2033
806
  container.mount(widget)
2034
807
  # NOTE: Don't call update_status() here - it does blocking tmux calls
2035
808
  # The 250ms interval (update_all_statuses) will update status shortly
2036
809
 
2037
- # Reorder widgets to match self.sessions order
2038
- # New widgets are appended at end, but should appear in correct position
2039
- if sessions_added:
2040
- self._reorder_session_widgets(container)
2041
-
2042
- def action_expand_all(self) -> None:
2043
- """Expand all sessions"""
2044
- for widget in self.query(SessionSummary):
2045
- widget.expanded = True
2046
- self.expanded_states[widget.session.id] = True
2047
-
2048
- def action_collapse_all(self) -> None:
2049
- """Collapse all sessions"""
2050
- for widget in self.query(SessionSummary):
2051
- widget.expanded = False
2052
- self.expanded_states[widget.session.id] = False
2053
-
2054
- def action_cycle_detail(self) -> None:
2055
- """Cycle through detail levels (5, 10, 20, 50 lines)"""
2056
- self.detail_level_index = (self.detail_level_index + 1) % len(self.DETAIL_LEVELS)
2057
- new_level = self.DETAIL_LEVELS[self.detail_level_index]
2058
-
2059
- # Update all session widgets
2060
- for widget in self.query(SessionSummary):
2061
- widget.detail_lines = new_level
2062
-
2063
- # Save preference
2064
- self._prefs.detail_lines = new_level
2065
- self._save_prefs()
2066
-
2067
- self.notify(f"Detail: {new_level} lines", severity="information")
2068
-
2069
- def action_cycle_summary(self) -> None:
2070
- """Cycle through summary detail levels (low, med, full)"""
2071
- self.summary_level_index = (self.summary_level_index + 1) % len(self.SUMMARY_LEVELS)
2072
- new_level = self.SUMMARY_LEVELS[self.summary_level_index]
2073
-
2074
- # Update all session widgets
2075
- for widget in self.query(SessionSummary):
2076
- widget.summary_detail = new_level
2077
-
2078
- # Save preference
2079
- self._prefs.summary_detail = new_level
2080
- self._save_prefs()
2081
-
2082
- self.notify(f"Summary: {new_level}", severity="information")
810
+ # Reorder widgets to match display_sessions order
811
+ # This must run after any structural changes AND after sort mode changes
812
+ self._reorder_session_widgets(container)
2083
813
 
2084
814
  def on_session_summary_expanded_changed(self, message: SessionSummary.ExpandedChanged) -> None:
2085
815
  """Handle expanded state changes from session widgets"""
@@ -2107,14 +837,6 @@ class SupervisorTUI(App):
2107
837
  else:
2108
838
  widget.remove_class("selected")
2109
839
 
2110
- def action_toggle_focused(self) -> None:
2111
- """Toggle expansion of focused session (only in tree mode)"""
2112
- if self.view_mode == "list_preview":
2113
- return # Don't toggle in list mode
2114
- focused = self.focused
2115
- if isinstance(focused, SessionSummary):
2116
- focused.expanded = not focused.expanded
2117
-
2118
840
  def _get_widgets_in_session_order(self) -> List[SessionSummary]:
2119
841
  """Get session widgets sorted to match self.sessions order.
2120
842
 
@@ -2131,18 +853,26 @@ class SupervisorTUI(App):
2131
853
  return widgets
2132
854
 
2133
855
  def _reorder_session_widgets(self, container: ScrollableContainer) -> None:
2134
- """Reorder session widgets in container to match self.sessions order.
856
+ """Reorder session widgets in container to match session display order.
2135
857
 
2136
858
  When new widgets are mounted, they're appended at the end.
2137
- This method reorders them to match self.sessions order.
859
+ This method reorders them to match the display order (active + terminated).
2138
860
  """
2139
861
  widgets = {w.session.id: w for w in self.query(SessionSummary)}
2140
862
  if not widgets:
2141
863
  return
2142
864
 
2143
- # Get desired order from self.sessions
865
+ # Build display sessions list (active + terminated if enabled)
866
+ display_sessions = list(self.sessions)
867
+ if self.show_terminated:
868
+ active_ids = {s.id for s in self.sessions}
869
+ for session in self._terminated_sessions.values():
870
+ if session.id not in active_ids:
871
+ display_sessions.append(session)
872
+
873
+ # Get desired order from display_sessions
2144
874
  ordered_widgets = []
2145
- for session in self.sessions:
875
+ for session in display_sessions:
2146
876
  if session.id in widgets:
2147
877
  ordered_widgets.append(widgets[session.id])
2148
878
 
@@ -2155,56 +885,6 @@ class SupervisorTUI(App):
2155
885
  # Each subsequent widget should be after the previous one
2156
886
  container.move_child(widget, after=ordered_widgets[i - 1])
2157
887
 
2158
- def action_focus_next_session(self) -> None:
2159
- """Focus the next session in the list."""
2160
- widgets = self._get_widgets_in_session_order()
2161
- if not widgets:
2162
- return
2163
- self.focused_session_index = (self.focused_session_index + 1) % len(widgets)
2164
- target_widget = widgets[self.focused_session_index]
2165
- target_widget.focus()
2166
- if self.view_mode == "list_preview":
2167
- self._update_preview()
2168
- self._sync_tmux_window(target_widget)
2169
-
2170
- def action_focus_previous_session(self) -> None:
2171
- """Focus the previous session in the list."""
2172
- widgets = self._get_widgets_in_session_order()
2173
- if not widgets:
2174
- return
2175
- self.focused_session_index = (self.focused_session_index - 1) % len(widgets)
2176
- target_widget = widgets[self.focused_session_index]
2177
- target_widget.focus()
2178
- if self.view_mode == "list_preview":
2179
- self._update_preview()
2180
- self._sync_tmux_window(target_widget)
2181
-
2182
- def action_toggle_view_mode(self) -> None:
2183
- """Toggle between tree and list+preview view modes."""
2184
- if self.view_mode == "tree":
2185
- self.view_mode = "list_preview"
2186
- else:
2187
- self.view_mode = "tree"
2188
-
2189
- # Save preference
2190
- self._prefs.view_mode = self.view_mode
2191
- self._save_prefs()
2192
-
2193
- def action_toggle_tmux_sync(self) -> None:
2194
- """Toggle tmux pane sync - syncs navigation to external tmux pane."""
2195
- self.tmux_sync = not self.tmux_sync
2196
-
2197
- # Save preference
2198
- self._prefs.tmux_sync = self.tmux_sync
2199
- self._save_prefs()
2200
-
2201
- # Update subtitle to show sync state
2202
- self._update_subtitle()
2203
-
2204
- # If enabling, sync to currently focused session immediately
2205
- if self.tmux_sync:
2206
- self._sync_tmux_window()
2207
-
2208
888
  def _sync_tmux_window(self, widget: Optional["SessionSummary"] = None) -> None:
2209
889
  """Sync external tmux pane to show the focused session's window.
2210
890
 
@@ -2271,64 +951,17 @@ class SupervisorTUI(App):
2271
951
  pass
2272
952
 
2273
953
  def _update_preview(self) -> None:
2274
- """Update preview pane with focused session's content."""
2275
- try:
2276
- preview = self.query_one("#preview-pane", PreviewPane)
2277
- widgets = self._get_widgets_in_session_order()
2278
- if widgets and 0 <= self.focused_session_index < len(widgets):
2279
- preview.update_from_widget(widgets[self.focused_session_index])
2280
- except NoMatches:
2281
- pass
2282
-
2283
- def action_focus_command_bar(self) -> None:
2284
- """Focus the command bar for input."""
2285
- try:
2286
- cmd_bar = self.query_one("#command-bar", CommandBar)
954
+ """Update preview pane with focused session's content.
2287
955
 
2288
- # Show the command bar
2289
- cmd_bar.add_class("visible")
2290
-
2291
- # Get the currently focused session (if any)
2292
- focused = self.focused
2293
- if isinstance(focused, SessionSummary):
2294
- cmd_bar.set_target(focused.session.name)
2295
- elif not cmd_bar.target_session and self.sessions:
2296
- # Default to first session if none focused
2297
- cmd_bar.set_target(self.sessions[0].name)
2298
-
2299
- # Enable and focus the input
2300
- cmd_input = cmd_bar.query_one("#cmd-input", Input)
2301
- cmd_input.disabled = False
2302
- cmd_input.focus()
2303
- except NoMatches:
2304
- pass
2305
-
2306
- def action_focus_standing_orders(self) -> None:
2307
- """Focus the command bar for editing standing orders."""
956
+ Uses self.focused directly to ensure the preview always shows the
957
+ actually-focused widget, regardless of any index tracking issues
958
+ that might occur during sorting or session refresh.
959
+ """
2308
960
  try:
2309
- cmd_bar = self.query_one("#command-bar", CommandBar)
2310
-
2311
- # Show the command bar
2312
- cmd_bar.add_class("visible")
2313
-
2314
- # Get the currently focused session (if any)
961
+ preview = self.query_one("#preview-pane", PreviewPane)
2315
962
  focused = self.focused
2316
963
  if isinstance(focused, SessionSummary):
2317
- cmd_bar.set_target(focused.session.name)
2318
- # Pre-fill with existing standing orders
2319
- cmd_input = cmd_bar.query_one("#cmd-input", Input)
2320
- cmd_input.value = focused.session.standing_instructions or ""
2321
- elif not cmd_bar.target_session and self.sessions:
2322
- # Default to first session if none focused
2323
- cmd_bar.set_target(self.sessions[0].name)
2324
-
2325
- # Set mode to standing_orders
2326
- cmd_bar.set_mode("standing_orders")
2327
-
2328
- # Enable and focus the input
2329
- cmd_input = cmd_bar.query_one("#cmd-input", Input)
2330
- cmd_input.disabled = False
2331
- cmd_input.focus()
964
+ preview.update_from_widget(focused)
2332
965
  except NoMatches:
2333
966
  pass
2334
967
 
@@ -2359,12 +992,40 @@ class SupervisorTUI(App):
2359
992
  session = self.session_manager.get_session_by_name(message.session_name)
2360
993
  if session:
2361
994
  self.session_manager.set_standing_instructions(session.id, message.text)
2362
- self.notify(f"Standing order set for {message.session_name}")
995
+ if message.text:
996
+ self.notify(f"Standing order set for {message.session_name}")
997
+ else:
998
+ self.notify(f"Standing order cleared for {message.session_name}")
2363
999
  # Refresh session list to show updated standing order
2364
1000
  self.refresh_sessions()
2365
1001
  else:
2366
1002
  self.notify(f"Session '{message.session_name}' not found", severity="error")
2367
1003
 
1004
+ def on_command_bar_value_updated(self, message: CommandBar.ValueUpdated) -> None:
1005
+ """Handle agent value update from command bar (#61)."""
1006
+ session = self.session_manager.get_session_by_name(message.session_name)
1007
+ if session:
1008
+ self.session_manager.set_agent_value(session.id, message.value)
1009
+ self.notify(f"Value set to {message.value} for {message.session_name}")
1010
+ # Refresh and re-sort session list
1011
+ self.refresh_sessions()
1012
+ else:
1013
+ self.notify(f"Session '{message.session_name}' not found", severity="error")
1014
+
1015
+ def on_command_bar_annotation_updated(self, message: CommandBar.AnnotationUpdated) -> None:
1016
+ """Handle human annotation update from command bar (#74)."""
1017
+ session = self.session_manager.get_session_by_name(message.session_name)
1018
+ if session:
1019
+ self.session_manager.set_human_annotation(session.id, message.annotation)
1020
+ if message.annotation:
1021
+ self.notify(f"Annotation set for {message.session_name}")
1022
+ else:
1023
+ self.notify(f"Annotation cleared for {message.session_name}")
1024
+ # Refresh session list to show updated annotation
1025
+ self.refresh_sessions()
1026
+ else:
1027
+ self.notify(f"Session '{message.session_name}' not found", severity="error")
1028
+
2368
1029
  def on_command_bar_clear_requested(self, message: CommandBar.ClearRequested) -> None:
2369
1030
  """Handle clear request - hide and unfocus command bar."""
2370
1031
  try:
@@ -2436,106 +1097,6 @@ class SupervisorTUI(App):
2436
1097
  except Exception as e:
2437
1098
  self.notify(f"Failed to create agent: {e}", severity="error")
2438
1099
 
2439
- def action_toggle_daemon(self) -> None:
2440
- """Toggle daemon panel visibility (like timeline)."""
2441
- try:
2442
- daemon_panel = self.query_one("#daemon-panel", DaemonPanel)
2443
- daemon_panel.display = not daemon_panel.display
2444
- if daemon_panel.display:
2445
- # Force immediate refresh when becoming visible
2446
- daemon_panel._refresh_logs()
2447
- # Save preference
2448
- self._prefs.daemon_panel_visible = daemon_panel.display
2449
- self._save_prefs()
2450
- state = "shown" if daemon_panel.display else "hidden"
2451
- self.notify(f"Daemon panel {state}", severity="information")
2452
- except NoMatches:
2453
- pass
2454
-
2455
- def action_supervisor_start(self) -> None:
2456
- """Start the Supervisor Daemon (handles Claude orchestration)."""
2457
- # Ensure Monitor Daemon is running first (Supervisor depends on it)
2458
- if not is_monitor_daemon_running(self.tmux_session):
2459
- self._ensure_monitor_daemon()
2460
- import time
2461
- time.sleep(1.0)
2462
-
2463
- if is_supervisor_daemon_running(self.tmux_session):
2464
- self.notify("Supervisor Daemon already running", severity="warning")
2465
- return
2466
-
2467
- try:
2468
- panel = self.query_one("#daemon-panel", DaemonPanel)
2469
- panel.log_lines.append(">>> Starting Supervisor Daemon...")
2470
- except NoMatches:
2471
- pass
2472
-
2473
- try:
2474
- subprocess.Popen(
2475
- [sys.executable, "-m", "overcode.supervisor_daemon",
2476
- "--session", self.tmux_session],
2477
- stdout=subprocess.DEVNULL,
2478
- stderr=subprocess.DEVNULL,
2479
- start_new_session=True,
2480
- )
2481
- self.notify("Started Supervisor Daemon", severity="information")
2482
- self.set_timer(1.0, self.update_daemon_status)
2483
- except (OSError, subprocess.SubprocessError) as e:
2484
- self.notify(f"Failed to start Supervisor Daemon: {e}", severity="error")
2485
-
2486
- def action_supervisor_stop(self) -> None:
2487
- """Stop the Supervisor Daemon."""
2488
- if not is_supervisor_daemon_running(self.tmux_session):
2489
- self.notify("Supervisor Daemon not running", severity="warning")
2490
- return
2491
-
2492
- if stop_supervisor_daemon(self.tmux_session):
2493
- self.notify("Stopped Supervisor Daemon", severity="information")
2494
- try:
2495
- panel = self.query_one("#daemon-panel", DaemonPanel)
2496
- panel.log_lines.append(">>> Supervisor Daemon stopped")
2497
- except NoMatches:
2498
- pass
2499
- else:
2500
- self.notify("Failed to stop Supervisor Daemon", severity="error")
2501
-
2502
- self.update_daemon_status()
2503
-
2504
- def action_monitor_restart(self) -> None:
2505
- """Restart the Monitor Daemon (handles metrics/state tracking)."""
2506
- import time
2507
-
2508
- try:
2509
- panel = self.query_one("#daemon-panel", DaemonPanel)
2510
- panel.log_lines.append(">>> Restarting Monitor Daemon...")
2511
- except NoMatches:
2512
- pass
2513
-
2514
- # Stop if running
2515
- if is_monitor_daemon_running(self.tmux_session):
2516
- stop_monitor_daemon(self.tmux_session)
2517
- time.sleep(0.5)
2518
-
2519
- # Start fresh
2520
- try:
2521
- subprocess.Popen(
2522
- [sys.executable, "-m", "overcode.monitor_daemon",
2523
- "--session", self.tmux_session],
2524
- stdout=subprocess.DEVNULL,
2525
- stderr=subprocess.DEVNULL,
2526
- start_new_session=True,
2527
- )
2528
-
2529
- self.notify("Monitor Daemon restarted", severity="information")
2530
- try:
2531
- panel = self.query_one("#daemon-panel", DaemonPanel)
2532
- panel.log_lines.append(">>> Monitor Daemon restarted")
2533
- except NoMatches:
2534
- pass
2535
- self.set_timer(1.0, self.update_daemon_status)
2536
- except (OSError, subprocess.SubprocessError) as e:
2537
- self.notify(f"Failed to restart Monitor Daemon: {e}", severity="error")
2538
-
2539
1100
  def _ensure_monitor_daemon(self) -> None:
2540
1101
  """Start the Monitor Daemon if not running.
2541
1102
 
@@ -2565,93 +1126,14 @@ class SupervisorTUI(App):
2565
1126
  except (OSError, subprocess.SubprocessError) as e:
2566
1127
  self.notify(f"Failed to start Monitor Daemon: {e}", severity="warning")
2567
1128
 
2568
- def action_toggle_web_server(self) -> None:
2569
- """Toggle the web analytics dashboard server on/off."""
2570
- is_running, msg = toggle_web_server(self.tmux_session)
2571
-
2572
- if is_running:
2573
- url = get_web_server_url(self.tmux_session)
2574
- self.notify(f"Web server: {url}", severity="information")
2575
- try:
2576
- panel = self.query_one("#daemon-panel", DaemonPanel)
2577
- panel.log_lines.append(f">>> Web server started: {url}")
2578
- except NoMatches:
2579
- pass
2580
- else:
2581
- self.notify(f"Web server: {msg}", severity="information")
2582
- try:
2583
- panel = self.query_one("#daemon-panel", DaemonPanel)
2584
- panel.log_lines.append(f">>> Web server: {msg}")
2585
- except NoMatches:
2586
- pass
2587
-
2588
- self.update_daemon_status()
2589
-
2590
- def action_toggle_sleep(self) -> None:
2591
- """Toggle sleep mode for the focused agent.
2592
-
2593
- Sleep mode marks an agent as 'asleep' (human doesn't want it to do anything).
2594
- Sleeping agents are excluded from stats calculations.
2595
- Press z again to wake the agent.
2596
- """
2597
- focused = self.focused
2598
- if not isinstance(focused, SessionSummary):
2599
- self.notify("No agent focused", severity="warning")
2600
- return
2601
-
2602
- session = focused.session
2603
- new_asleep_state = not session.is_asleep
2604
-
2605
- # Update the session in the session manager
2606
- self.session_manager.update_session(session.id, is_asleep=new_asleep_state)
2607
-
2608
- # Update the local session object
2609
- session.is_asleep = new_asleep_state
2610
-
2611
- # Update the widget's display status if sleeping
2612
- if new_asleep_state:
2613
- focused.detected_status = "asleep"
2614
- self.notify(f"Agent '{session.name}' is now asleep (excluded from stats)", severity="information")
2615
- else:
2616
- # Wake up - status will be refreshed on next update cycle
2617
- self.notify(f"Agent '{session.name}' is now awake", severity="information")
2618
-
2619
- # Force a refresh
2620
- focused.refresh()
2621
-
2622
- def action_kill_focused(self) -> None:
2623
- """Kill the currently focused agent (requires confirmation)."""
2624
- focused = self.focused
2625
- if not isinstance(focused, SessionSummary):
2626
- self.notify("No agent focused", severity="warning")
2627
- return
2628
-
2629
- session_name = focused.session.name
2630
- session_id = focused.session.id
2631
- now = time.time()
2632
-
2633
- # Check if this is a confirmation of a pending kill
2634
- if self._pending_kill:
2635
- pending_name, pending_time = self._pending_kill
2636
- # Confirm if same session and within 3 second window
2637
- if pending_name == session_name and (now - pending_time) < 3.0:
2638
- self._pending_kill = None # Clear pending state
2639
- self._execute_kill(focused, session_name, session_id)
2640
- return
2641
- else:
2642
- # Different session or expired - start new confirmation
2643
- self._pending_kill = None
2644
-
2645
- # First press - request confirmation
2646
- self._pending_kill = (session_name, now)
2647
- self.notify(
2648
- f"Press x again to kill '{session_name}'",
2649
- severity="warning",
2650
- timeout=3
2651
- )
2652
-
2653
1129
  def _execute_kill(self, focused: "SessionSummary", session_name: str, session_id: str) -> None:
2654
1130
  """Execute the actual kill operation after confirmation."""
1131
+ # Save a copy of the session for showing when show_terminated is True
1132
+ session_copy = focused.session
1133
+ # Mark it as terminated for display purposes
1134
+ from dataclasses import replace
1135
+ terminated_session = replace(session_copy, status="terminated")
1136
+
2655
1137
  # Use launcher to kill the session
2656
1138
  launcher = ClaudeLauncher(
2657
1139
  tmux_session=self.tmux_session,
@@ -2660,13 +1142,21 @@ class SupervisorTUI(App):
2660
1142
 
2661
1143
  if launcher.kill_session(session_name):
2662
1144
  self.notify(f"Killed agent: {session_name}", severity="information")
2663
- # Remove the widget and refresh
1145
+
1146
+ # Store in terminated sessions cache for ghost mode
1147
+ self._terminated_sessions[session_id] = terminated_session
1148
+
1149
+ # Remove the widget (will be re-added if show_terminated is True)
2664
1150
  focused.remove()
2665
1151
  # Update session cache
2666
1152
  if session_id in self._sessions_cache:
2667
1153
  del self._sessions_cache[session_id]
2668
1154
  if session_id in self.expanded_states:
2669
1155
  del self.expanded_states[session_id]
1156
+
1157
+ # If showing terminated sessions, refresh to add it back
1158
+ if self.show_terminated:
1159
+ self.update_session_widgets()
2670
1160
  # Clear preview pane and focus next agent if in list_preview mode
2671
1161
  if self.view_mode == "list_preview":
2672
1162
  try:
@@ -2685,118 +1175,55 @@ class SupervisorTUI(App):
2685
1175
  else:
2686
1176
  self.notify(f"Failed to kill agent: {session_name}", severity="error")
2687
1177
 
2688
- def action_new_agent(self) -> None:
2689
- """Prompt for directory and name to create a new agent.
1178
+ def _execute_restart(self, focused: "SessionSummary") -> None:
1179
+ """Execute the actual restart operation after confirmation (#133).
2690
1180
 
2691
- Two-step flow:
2692
- 1. Enter working directory (or press Enter for current directory)
2693
- 2. Enter agent name (defaults to directory basename)
1181
+ Sends Ctrl-C to kill the current Claude process, then restarts it
1182
+ with the same configuration (directory, permissions).
2694
1183
  """
2695
- from pathlib import Path
2696
-
2697
- try:
2698
- command_bar = self.query_one("#command-bar", CommandBar)
2699
- command_bar.add_class("visible") # Must show the command bar first
2700
- command_bar.set_mode("new_agent_dir")
2701
- # Pre-fill with current working directory
2702
- input_widget = command_bar.query_one("#cmd-input", Input)
2703
- input_widget.value = str(Path.cwd())
2704
- command_bar.focus_input()
2705
- except NoMatches:
2706
- self.notify("Command bar not found", severity="error")
2707
-
2708
- def action_toggle_copy_mode(self) -> None:
2709
- """Toggle mouse capture to allow native terminal text selection.
1184
+ import os
1185
+ session = focused.session
1186
+ session_name = session.name
2710
1187
 
2711
- When copy mode is ON:
2712
- - Mouse events pass through to terminal
2713
- - You can select text and Cmd+C to copy
2714
- - Press 'y' again to exit copy mode
2715
- """
2716
- if not hasattr(self, '_copy_mode'):
2717
- self._copy_mode = False
2718
-
2719
- self._copy_mode = not self._copy_mode
2720
-
2721
- if self._copy_mode:
2722
- # Write escape sequences directly to the driver's file (stderr)
2723
- # This is what Textual uses internally for terminal output
2724
- # We bypass the driver methods because they check _mouse flag
2725
- driver_file = self._driver._file
2726
-
2727
- # Disable all mouse tracking modes
2728
- driver_file.write("\x1b[?1000l") # Disable basic mouse tracking
2729
- driver_file.write("\x1b[?1002l") # Disable cell motion tracking
2730
- driver_file.write("\x1b[?1003l") # Disable all motion tracking
2731
- driver_file.write("\x1b[?1015l") # Disable urxvt extended mode
2732
- driver_file.write("\x1b[?1006l") # Disable SGR extended mode
2733
- driver_file.flush()
2734
-
2735
- self.notify("COPY MODE - select with mouse, Cmd+C to copy, 'y' to exit", severity="warning")
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"]
2736
1192
  else:
2737
- # Re-enable mouse support using driver's method
2738
- self._driver._mouse = True # Ensure flag is set so enable actually sends codes
2739
- self._driver._enable_mouse_support()
2740
- self.refresh()
2741
- self.notify("Copy mode OFF", severity="information")
2742
-
2743
- def action_send_enter_to_focused(self) -> None:
2744
- """Send Enter keypress to the focused agent (for approvals)."""
2745
- focused = self.focused
2746
- if not isinstance(focused, SessionSummary):
2747
- self.notify("No agent focused", severity="warning")
2748
- return
1193
+ cmd_parts = [claude_command]
2749
1194
 
2750
- session_name = focused.session.name
2751
- launcher = ClaudeLauncher(
2752
- tmux_session=self.tmux_session,
2753
- session_manager=self.session_manager
2754
- )
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"])
2755
1199
 
2756
- # Send "enter" which the launcher handles as just pressing Enter
2757
- if launcher.send_to_session(session_name, "enter"):
2758
- self.notify(f"Sent Enter to {session_name}", severity="information")
2759
- else:
2760
- self.notify(f"Failed to send Enter to {session_name}", severity="error")
1200
+ cmd_str = " ".join(cmd_parts)
2761
1201
 
2762
- def _send_key_to_focused(self, key: str) -> None:
2763
- """Send a key to the focused agent."""
2764
- focused = self.focused
2765
- if not isinstance(focused, SessionSummary):
2766
- self.notify("No agent focused", severity="warning")
2767
- return
1202
+ # Get tmux manager
1203
+ from .tmux_manager import TmuxManager
1204
+ tmux = TmuxManager(self.tmux_session)
2768
1205
 
2769
- session_name = focused.session.name
2770
- launcher = ClaudeLauncher(
2771
- tmux_session=self.tmux_session,
2772
- session_manager=self.session_manager
2773
- )
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")
1209
+ return
2774
1210
 
2775
- # Send the key followed by Enter (to select the numbered option)
2776
- if launcher.send_to_session(session_name, key, enter=True):
2777
- self.notify(f"Sent '{key}' to {session_name}", severity="information")
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=[])
2778
1225
  else:
2779
- self.notify(f"Failed to send '{key}' to {session_name}", severity="error")
2780
-
2781
- def action_send_1_to_focused(self) -> None:
2782
- """Send '1' to focused agent."""
2783
- self._send_key_to_focused("1")
2784
-
2785
- def action_send_2_to_focused(self) -> None:
2786
- """Send '2' to focused agent."""
2787
- self._send_key_to_focused("2")
2788
-
2789
- def action_send_3_to_focused(self) -> None:
2790
- """Send '3' to focused agent."""
2791
- self._send_key_to_focused("3")
2792
-
2793
- def action_send_4_to_focused(self) -> None:
2794
- """Send '4' to focused agent."""
2795
- self._send_key_to_focused("4")
2796
-
2797
- def action_send_5_to_focused(self) -> None:
2798
- """Send '5' to focused agent."""
2799
- self._send_key_to_focused("5")
1226
+ self.notify(f"Failed to restart agent: {session_name}", severity="error")
2800
1227
 
2801
1228
  def on_key(self, event: events.Key) -> None:
2802
1229
  """Signal activity to daemon on any keypress."""
@@ -2805,6 +1232,9 @@ class SupervisorTUI(App):
2805
1232
  def on_unmount(self) -> None:
2806
1233
  """Clean up terminal state on exit"""
2807
1234
  import sys
1235
+ # Stop the summarizer (release API client resources)
1236
+ self._summarizer.stop()
1237
+
2808
1238
  # Ensure mouse tracking is disabled
2809
1239
  sys.stdout.write('\033[?1000l') # Disable mouse tracking
2810
1240
  sys.stdout.write('\033[?1002l') # Disable cell motion tracking