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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
overcode/tui.py CHANGED
@@ -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,
@@ -175,6 +182,30 @@ class DaemonStatusBar(Static):
175
182
  content.append("○ ", style="red")
176
183
  content.append("stopped", style="red")
177
184
 
185
+ # AI Summarizer status (from TUI's local summarizer, not daemon)
186
+ content.append(" │ ", style="dim")
187
+ content.append("AI: ", style="bold")
188
+ # Get summarizer state from parent app
189
+ summarizer_available = SummarizerClient.is_available()
190
+ summarizer_enabled = False
191
+ summarizer_calls = 0
192
+ if hasattr(self.app, '_summarizer'):
193
+ summarizer_enabled = self.app._summarizer.enabled
194
+ summarizer_calls = self.app._summarizer.total_calls
195
+ if summarizer_available:
196
+ if summarizer_enabled:
197
+ content.append("● ", style="green")
198
+ if summarizer_calls > 0:
199
+ content.append(f"{summarizer_calls}", style="cyan")
200
+ else:
201
+ content.append("on", style="green")
202
+ else:
203
+ content.append("○ ", style="dim")
204
+ content.append("off", style="dim")
205
+ else:
206
+ content.append("○ ", style="red")
207
+ content.append("n/a", style="red dim")
208
+
178
209
  # Spin rate stats (only when monitor running with sessions)
179
210
  if monitor_running and self.monitor_state.sessions:
180
211
  content.append(" │ ", style="dim")
@@ -202,6 +233,11 @@ class DaemonStatusBar(Static):
202
233
  if mean_spin > 0:
203
234
  content.append(f" μ{mean_spin:.1f}x", style="cyan")
204
235
 
236
+ # Total tokens across all sessions (include sleeping agents - they used tokens too)
237
+ total_tokens = sum(s.input_tokens + s.output_tokens for s in all_sessions)
238
+ if total_tokens > 0:
239
+ content.append(f" Σ{format_tokens(total_tokens)}", style="orange1")
240
+
205
241
  # Safe break duration (time until 50%+ agents need attention) - exclude sleeping
206
242
  safe_break = calculate_safe_break_duration(active_sessions)
207
243
  if safe_break is not None:
@@ -256,13 +292,16 @@ class DaemonStatusBar(Static):
256
292
  class StatusTimeline(Static):
257
293
  """Widget displaying historical status timelines for user presence and agents.
258
294
 
259
- Shows the last 3 hours with each character representing a time slice.
295
+ Shows the last N hours with each character representing a time slice.
260
296
  - User presence: green=active, yellow=inactive, red/gray=locked/away
261
297
  - Agent status: green=running, red=waiting, grey=terminated
298
+
299
+ Timeline hours configurable via ~/.overcode/config.yaml (timeline.hours).
262
300
  """
263
301
 
264
- TIMELINE_HOURS = 3.0 # Show last 3 hours
265
- LABEL_WIDTH = 12 # Width of labels like " User: " or " agent: "
302
+ TIMELINE_HOURS = 3.0 # Default hours
303
+ MIN_NAME_WIDTH = 6 # Minimum width for agent names
304
+ MAX_NAME_WIDTH = 30 # Maximum width for agent names
266
305
  MIN_TIMELINE = 20 # Minimum timeline width
267
306
  DEFAULT_TIMELINE = 60 # Fallback if can't detect width
268
307
 
@@ -272,17 +311,34 @@ class StatusTimeline(Static):
272
311
  self.tmux_session = tmux_session
273
312
  self._presence_history = []
274
313
  self._agent_histories = {}
314
+ # Get timeline hours from config (config file > env var > default)
315
+ from .config import get_timeline_config
316
+ timeline_config = get_timeline_config()
317
+ self.timeline_hours = timeline_config["hours"]
318
+
319
+ @property
320
+ def label_width(self) -> int:
321
+ """Calculate label width based on longest agent name (#75)."""
322
+ if not self.sessions:
323
+ return self.MIN_NAME_WIDTH
324
+ longest = max(len(s.name) for s in self.sessions)
325
+ # Clamp to min/max and add padding for " " prefix and " " suffix
326
+ return min(self.MAX_NAME_WIDTH, max(self.MIN_NAME_WIDTH, longest))
275
327
 
276
328
  @property
277
329
  def timeline_width(self) -> int:
278
- """Calculate timeline width based on available space."""
330
+ """Calculate timeline width based on available space after labels (#75)."""
279
331
  import shutil
280
332
  try:
281
333
  # Try to get terminal size directly - most reliable
282
334
  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))
335
+ # Subtract:
336
+ # - label_width (agent name)
337
+ # - 3 for " " prefix and " " suffix around label
338
+ # - 5 for percentage display " XXX%"
339
+ # - 2 for CSS padding (padding: 0 1 = 1 char each side)
340
+ available = term_width - self.label_width - 3 - 5 - 2
341
+ return max(self.MIN_TIMELINE, min(available, 200))
286
342
  except (OSError, ValueError):
287
343
  # No terminal available or invalid size
288
344
  return self.DEFAULT_TIMELINE
@@ -290,7 +346,7 @@ class StatusTimeline(Static):
290
346
  def update_history(self, sessions: list) -> None:
291
347
  """Refresh history data from log files."""
292
348
  self.sessions = sessions
293
- self._presence_history = read_presence_history(hours=self.TIMELINE_HOURS)
349
+ self._presence_history = read_presence_history(hours=self.timeline_hours)
294
350
  self._agent_histories = {}
295
351
 
296
352
  # Get agent names from sessions
@@ -298,7 +354,7 @@ class StatusTimeline(Static):
298
354
 
299
355
  # Read agent history from session-specific file and group by agent
300
356
  history_path = get_agent_history_path(self.tmux_session)
301
- all_history = read_agent_status_history(hours=self.TIMELINE_HOURS, history_file=history_path)
357
+ all_history = read_agent_status_history(hours=self.timeline_hours, history_file=history_path)
302
358
  for ts, agent, status, activity in all_history:
303
359
  if agent not in self._agent_histories:
304
360
  self._agent_histories[agent] = []
@@ -322,8 +378,8 @@ class StatusTimeline(Static):
322
378
  return "─" * width
323
379
 
324
380
  now = datetime.now()
325
- start_time = now - timedelta(hours=self.TIMELINE_HOURS)
326
- slot_duration = timedelta(hours=self.TIMELINE_HOURS) / width
381
+ start_time = now - timedelta(hours=self.timeline_hours)
382
+ slot_duration = timedelta(hours=self.timeline_hours) / width
327
383
 
328
384
  # Initialize timeline with empty slots
329
385
  timeline = ["─"] * width
@@ -347,19 +403,20 @@ class StatusTimeline(Static):
347
403
  width = self.timeline_width
348
404
 
349
405
  # Time scale header
406
+ label_w = self.label_width
350
407
  content.append("Timeline: ", style="bold")
351
- content.append(f"-{self.TIMELINE_HOURS:.0f}h", style="dim")
408
+ content.append(f"-{self.timeline_hours:.0f}h", style="dim")
352
409
  header_padding = max(0, width - 10)
353
410
  content.append(" " * header_padding, style="dim")
354
411
  content.append("now", style="dim")
355
412
  content.append("\n")
356
413
 
357
414
  # 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")
415
+ # Align with agent names using dynamic label width (#75)
416
+ content.append(f" {'User:':<{label_w}} ", style="cyan")
360
417
  if self._presence_history:
361
418
  slot_states = build_timeline_slots(
362
- self._presence_history, width, self.TIMELINE_HOURS, now
419
+ self._presence_history, width, self.timeline_hours, now
363
420
  )
364
421
  # Render timeline with colors
365
422
  for i in range(width):
@@ -383,14 +440,14 @@ class StatusTimeline(Static):
383
440
  agent_name = session.name
384
441
  history = self._agent_histories.get(agent_name, [])
385
442
 
386
- # Truncate name to fit
387
- display_name = truncate_name(agent_name)
443
+ # Use dynamic label width (#75)
444
+ display_name = truncate_name(agent_name, max_len=label_w)
388
445
  content.append(f" {display_name} ", style="cyan")
389
446
 
390
447
  green_slots = 0
391
448
  total_slots = 0
392
449
  if history:
393
- slot_states = build_timeline_slots(history, width, self.TIMELINE_HOURS, now)
450
+ slot_states = build_timeline_slots(history, width, self.timeline_hours, now)
394
451
  # Render timeline with colors
395
452
  for i in range(width):
396
453
  if i in slot_states:
@@ -424,6 +481,8 @@ class StatusTimeline(Static):
424
481
  content.append("inactive ", style="dim")
425
482
  content.append("░", style="red")
426
483
  content.append("waiting/away ", style="dim")
484
+ content.append("░", style="dim")
485
+ content.append("asleep ", style="dim")
427
486
  content.append("×", style="dim")
428
487
  content.append("terminated", style="dim")
429
488
 
@@ -435,73 +494,52 @@ class HelpOverlay(Static):
435
494
 
436
495
  HELP_TEXT = """
437
496
  ╔══════════════════════════════════════════════════════════════════════════════╗
438
- ║ OVERCODE MONITOR HELP
497
+ ║ OVERCODE MONITOR HELP
439
498
  ╠══════════════════════════════════════════════════════════════════════════════╣
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
499
  ║ 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) ║
500
+ ────────────────────────────────────────────────────────────────────────── ║
501
+ ║ 🟢 Running 🟡 No orders 🟠 Wait supervisor 🔴 Wait user
502
+ 💤 Asleep ⚫ Terminated
463
503
  ║ ║
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
504
+ NAVIGATION & VIEW
505
+ ────────────────────────────────────────────────────────────────────────── ║
506
+ j/↓ Next agent k/↑ Previous agent
507
+ space Toggle expand m Toggle tree/list mode
508
+ e Expand all c Collapse all ║
509
+ h/? Toggle help r Refresh ║
510
+ q Quit ║
477
511
  ║ ║
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 ║
512
+ DISPLAY MODES
513
+ ────────────────────────────────────────────────────────────────────────── ║
514
+ s Cycle summary detail (low → med → full)
515
+ l Cycle summary content (💬 short → 📖 context → 🎯 orders → ✏️ note)
516
+ ║ v Cycle detail lines (5 10 → 20 → 50)
517
+ S Cycle sort mode (alpha status → value)
518
+ t Toggle timeline d Toggle daemon panel
519
+ g Show killed agents Z Hide sleeping agents
487
520
  ║ ║
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)
521
+ AGENT CONTROL
522
+ ────────────────────────────────────────────────────────────────────────── ║
523
+ i/: Send instruction o Set standing orders
524
+ I Edit annotation Enter Approve (send Enter)
525
+ 1-5 Send number n New agent
526
+ ║ x Kill agent z Toggle sleep ║
527
+ ║ b Jump to red/attention V Edit agent value ║
493
528
  ║ ║
494
- ║ DAEMON CONTROLS (work anywhere)
495
- ────────────────────────────────────────────────────────────────────────────║
529
+ ║ DAEMON CONTROL
530
+ ────────────────────────────────────────────────────────────────────────── ║
496
531
  ║ [ Start supervisor ] Stop supervisor ║
497
- ║ \\ Restart monitor d Toggle daemon log panel
498
- w Toggle web dashboard (analytics server)
532
+ ║ \\ Restart monitor w Toggle web dashboard
533
+
534
+ ║ OTHER ║
535
+ ║ ────────────────────────────────────────────────────────────────────────── ║
536
+ ║ y Copy mode (mouse sel) p Sync to tmux pane ║
499
537
  ║ ║
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)
538
+ COMMAND BAR (i or :)
539
+ ────────────────────────────────────────────────────────────────────────── ║
540
+ Enter Send instruction Esc Clear & unfocus
541
+ Ctrl+E Multi-line mode Ctrl+O Set as standing order
542
+ Ctrl+Enter Send (multi-line)
505
543
  ║ ║
506
544
  ║ Press h or ? to close ║
507
545
  ╚══════════════════════════════════════════════════════════════════════════════╝
@@ -651,6 +689,7 @@ class SessionSummary(Static, can_focus=True):
651
689
  expanded: reactive[bool] = reactive(True) # Start expanded
652
690
  detail_lines: reactive[int] = reactive(5) # Lines of output to show (5, 10, 20, 50)
653
691
  summary_detail: reactive[str] = reactive("low") # low, med, full
692
+ summary_content_mode: reactive[str] = reactive("ai_short") # ai_short, ai_long, orders, annotation (#74)
654
693
 
655
694
  def __init__(self, session: Session, status_detector: StatusDetector, *args, **kwargs):
656
695
  super().__init__(*args, **kwargs)
@@ -659,11 +698,17 @@ class SessionSummary(Static, can_focus=True):
659
698
  # Initialize from persisted session state, not hardcoded "running"
660
699
  self.detected_status = session.stats.current_state if session.stats.current_state else "running"
661
700
  self.current_activity = "Initializing..."
701
+ # AI-generated summaries (from daemon's SummarizerComponent)
702
+ self.ai_summary_short: str = "" # Short: current activity (~50 chars)
703
+ self.ai_summary_context: str = "" # Context: wider context (~80 chars)
662
704
  self.pane_content: List[str] = [] # Cached pane content
663
705
  self.claude_stats: Optional[ClaudeSessionStats] = None # Token/interaction stats
664
706
  self.git_diff_stats: Optional[tuple] = None # (files, insertions, deletions)
665
707
  # Track if this is a stalled agent that hasn't been visited yet
666
708
  self.is_unvisited_stalled: bool = False
709
+ # Track when status last changed (for immediate time-in-state updates)
710
+ self._status_changed_at: Optional[datetime] = None
711
+ self._last_known_status: str = self.detected_status
667
712
  # Start with expanded class since expanded=True by default
668
713
  self.add_class("expanded")
669
714
 
@@ -763,10 +808,14 @@ class SessionSummary(Static, can_focus=True):
763
808
  # NOTE: Time tracking removed - Monitor Daemon is the single source of truth
764
809
  # The session.stats values are read from what Monitor Daemon has persisted
765
810
  # 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
811
+ new_status = "asleep" if self.session.is_asleep else status
812
+
813
+ # Track status changes for immediate time-in-state reset (#73)
814
+ if new_status != self._last_known_status:
815
+ self._status_changed_at = datetime.now()
816
+ self._last_known_status = new_status
817
+
818
+ self.detected_status = new_status
770
819
 
771
820
  # Use pre-fetched claude stats (no file I/O on main thread)
772
821
  if claude_stats is not None:
@@ -780,6 +829,10 @@ class SessionSummary(Static, can_focus=True):
780
829
  """Called when summary_detail changes"""
781
830
  self.refresh()
782
831
 
832
+ def watch_summary_content_mode(self, summary_content_mode: str) -> None:
833
+ """Called when summary_content_mode changes (#74)"""
834
+ self.refresh()
835
+
783
836
  def render(self) -> Text:
784
837
  """Render session summary (compact or expanded)"""
785
838
  import shutil
@@ -835,13 +888,21 @@ class SessionSummary(Static, can_focus=True):
835
888
  content.append(" ", style=f"dim{bg}") # Maintain alignment
836
889
 
837
890
  # Time in current state (directly after status light)
891
+ # Use locally tracked change time if more recent than daemon's state_since (#73)
892
+ state_start = None
893
+ if self._status_changed_at:
894
+ state_start = self._status_changed_at
838
895
  if stats.state_since:
839
896
  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)
897
+ daemon_state_start = datetime.fromisoformat(stats.state_since)
898
+ # Use whichever is more recent (our local detection or daemon's record)
899
+ if state_start is None or daemon_state_start > state_start:
900
+ state_start = daemon_state_start
843
901
  except (ValueError, TypeError):
844
- content.append(" - ", style=f"dim{bg}")
902
+ pass
903
+ if state_start:
904
+ elapsed = (datetime.now() - state_start).total_seconds()
905
+ content.append(f"{format_duration(elapsed):>5} ", style=status_color)
845
906
  else:
846
907
  content.append(" - ", style=f"dim{bg}")
847
908
 
@@ -934,28 +995,67 @@ class SessionSummary(Static, can_focus=True):
934
995
  else:
935
996
  content.append(" ➖", style=f"bold dim{bg}") # No instructions indicator
936
997
 
998
+ # Agent value indicator (#61)
999
+ # Full detail: show numeric value with money bag
1000
+ # Short/med: show priority chevrons (⏫ high, ⏹ normal, ⏬ low)
1001
+ if self.summary_detail == "full":
1002
+ content.append(f" 💰{s.agent_value:>4}", style=f"bold magenta{bg}")
1003
+ else:
1004
+ # Priority icon based on value relative to default 1000
1005
+ # Note: Rich measures ⏹️ as 2 cells but ⏫️/⏬️ as 3 cells, so we add
1006
+ # a trailing space to ⏹️ for alignment
1007
+ if s.agent_value > 1000:
1008
+ content.append(" ⏫️", style=f"bold red{bg}") # High priority
1009
+ elif s.agent_value < 1000:
1010
+ content.append(" ⏬️", style=f"bold blue{bg}") # Low priority
1011
+ else:
1012
+ content.append(" ⏹️ ", style=f"dim{bg}") # Normal (extra space for alignment)
1013
+
937
1014
  if not self.expanded:
938
- # Compact view: show standing orders or current activity
1015
+ # Compact view: show content based on summary_content_mode (#74)
939
1016
  content.append(" │ ", style=f"bold dim{bg}")
940
- # Calculate remaining space for standing orders/activity
1017
+ # Calculate remaining space for content
941
1018
  current_len = len(content.plain)
942
1019
  remaining = max(20, term_width - current_len - 2)
943
1020
 
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}: "
1021
+ # Determine what to show based on mode
1022
+ mode = self.summary_content_mode
1023
+
1024
+ if mode == "annotation":
1025
+ # Show human annotation (✏️ icon)
1026
+ if s.human_annotation:
1027
+ content.append(f"✏️ {s.human_annotation[:remaining-3]}", style=f"bold magenta{bg}")
1028
+ else:
1029
+ content.append("✏️ (no annotation)", style=f"dim italic{bg}")
1030
+ elif mode == "orders":
1031
+ # Show standing orders (🎯 icon, ✓ if complete)
1032
+ if s.standing_instructions:
1033
+ if s.standing_orders_complete:
1034
+ style = f"bold green{bg}"
1035
+ prefix = "🎯✓ "
1036
+ elif s.standing_instructions_preset:
1037
+ style = f"bold cyan{bg}"
1038
+ prefix = f"🎯 {s.standing_instructions_preset}: "
1039
+ else:
1040
+ style = f"bold italic yellow{bg}"
1041
+ prefix = "🎯 "
1042
+ display_text = f"{prefix}{format_standing_instructions(s.standing_instructions, remaining - len(prefix))}"
1043
+ content.append(display_text[:remaining], style=style)
1044
+ else:
1045
+ content.append("🎯 (no standing orders)", style=f"dim italic{bg}")
1046
+ elif mode == "ai_long":
1047
+ # ai_long: show context summary (📖 icon - wider context/goal from AI)
1048
+ if self.ai_summary_context:
1049
+ content.append(f"📖 {self.ai_summary_context[:remaining-3]}", style=f"bold italic{bg}")
952
1050
  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)
1051
+ content.append("📖 (awaiting context...)", style=f"dim italic{bg}")
957
1052
  else:
958
- content.append(self.current_activity[:remaining], style=f"bold italic{bg}")
1053
+ # ai_short: show short summary (💬 icon - current activity from AI)
1054
+ if self.ai_summary_short:
1055
+ content.append(f"💬 {self.ai_summary_short[:remaining-3]}", style=f"bold italic{bg}")
1056
+ else:
1057
+ content.append("💬 (awaiting summary...)", style=f"dim italic{bg}")
1058
+
959
1059
  # Pad to fill terminal width
960
1060
  current_len = len(content.plain)
961
1061
  if current_len < term_width:
@@ -1041,13 +1141,16 @@ class PreviewPane(Static):
1041
1141
  # Calculate available lines based on widget height
1042
1142
  # Reserve 2 lines for header and some padding
1043
1143
  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
1144
+ # Show last N lines of output with ANSI color support
1045
1145
  # Truncate lines to pane width to match tmux display
1046
1146
  max_line_len = max(pane_width - 1, 40) # Leave room for newline, minimum 40
1047
1147
  for line in self.content_lines[-available_lines:]:
1048
1148
  # Truncate long lines to pane width
1049
1149
  display_line = line[:max_line_len] if len(line) > max_line_len else line
1050
- content.append(display_line + "\n")
1150
+ # Parse ANSI escape sequences to preserve colors from tmux
1151
+ # Note: Text.from_ansi() strips trailing newlines, so add newline separately
1152
+ content.append(Text.from_ansi(display_line))
1153
+ content.append("\n")
1051
1154
 
1052
1155
  return content
1053
1156
 
@@ -1103,6 +1206,20 @@ class CommandBar(Static):
1103
1206
  self.directory = directory
1104
1207
  self.bypass_permissions = bypass_permissions
1105
1208
 
1209
+ class ValueUpdated(Message):
1210
+ """Message sent when user updates agent value (#61)."""
1211
+ def __init__(self, session_name: str, value: int):
1212
+ super().__init__()
1213
+ self.session_name = session_name
1214
+ self.value = value
1215
+
1216
+ class AnnotationUpdated(Message):
1217
+ """Message sent when user updates human annotation (#74)."""
1218
+ def __init__(self, session_name: str, annotation: str):
1219
+ super().__init__()
1220
+ self.session_name = session_name
1221
+ self.annotation = annotation
1222
+
1106
1223
  def compose(self) -> ComposeResult:
1107
1224
  """Create command bar widgets."""
1108
1225
  with Horizontal(id="cmd-bar-container"):
@@ -1138,6 +1255,18 @@ class CommandBar(Static):
1138
1255
  else:
1139
1256
  label.update("[Standing Orders] ")
1140
1257
  input_widget.placeholder = "Enter standing orders (or empty to clear)..."
1258
+ elif self.mode == "value":
1259
+ if self.target_session:
1260
+ label.update(f"[{self.target_session} Value] ")
1261
+ else:
1262
+ label.update("[Value] ")
1263
+ input_widget.placeholder = "Enter priority value (1000 = normal, higher = more important)..."
1264
+ elif self.mode == "annotation":
1265
+ if self.target_session:
1266
+ label.update(f"[{self.target_session} Annotation] ")
1267
+ else:
1268
+ label.update("[Annotation] ")
1269
+ input_widget.placeholder = "Enter human annotation (or empty to clear)..."
1141
1270
  elif self.target_session:
1142
1271
  label.update(f"[{self.target_session}] ")
1143
1272
  input_widget.placeholder = "Type instruction (Enter to send)..."
@@ -1233,6 +1362,18 @@ class CommandBar(Static):
1233
1362
  event.input.value = ""
1234
1363
  self.action_clear_and_unfocus()
1235
1364
  return
1365
+ elif self.mode == "value":
1366
+ # Set agent value (#61)
1367
+ self._set_value(text)
1368
+ event.input.value = ""
1369
+ self.action_clear_and_unfocus()
1370
+ return
1371
+ elif self.mode == "annotation":
1372
+ # Set human annotation (empty string clears it)
1373
+ self._set_annotation(text)
1374
+ event.input.value = ""
1375
+ self.action_clear_and_unfocus()
1376
+ return
1236
1377
 
1237
1378
  # Default "send" mode
1238
1379
  if not text:
@@ -1258,9 +1399,13 @@ class CommandBar(Static):
1258
1399
  if directory:
1259
1400
  dir_path = Path(directory).expanduser().resolve()
1260
1401
  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
1402
+ # Create the directory
1403
+ try:
1404
+ dir_path.mkdir(parents=True, exist_ok=True)
1405
+ self.app.notify(f"Created directory: {dir_path}", severity="information")
1406
+ except OSError as e:
1407
+ self.app.notify(f"Failed to create directory: {e}", severity="error")
1408
+ return
1264
1409
  if not dir_path.is_dir():
1265
1410
  self.app.notify(f"Not a directory: {dir_path}", severity="error")
1266
1411
  return
@@ -1301,11 +1446,31 @@ class CommandBar(Static):
1301
1446
  self._update_target_label()
1302
1447
 
1303
1448
  def _set_standing_order(self, text: str) -> None:
1304
- """Set text as standing order."""
1305
- if not self.target_session or not text.strip():
1449
+ """Set text as standing order (empty string clears orders)."""
1450
+ if not self.target_session:
1306
1451
  return
1307
1452
  self.post_message(self.StandingOrderRequested(self.target_session, text.strip()))
1308
1453
 
1454
+ def _set_value(self, text: str) -> None:
1455
+ """Set agent value (#61)."""
1456
+ if not self.target_session:
1457
+ return
1458
+ try:
1459
+ value = int(text.strip()) if text.strip() else 1000
1460
+ if value < 0 or value > 9999:
1461
+ self.app.notify("Value must be between 0 and 9999", severity="error")
1462
+ return
1463
+ self.post_message(self.ValueUpdated(self.target_session, value))
1464
+ except ValueError:
1465
+ # Invalid input, notify user but don't crash
1466
+ self.app.notify("Invalid value - please enter a number", severity="error")
1467
+
1468
+ def _set_annotation(self, text: str) -> None:
1469
+ """Set human annotation (empty string clears it) (#74)."""
1470
+ if not self.target_session:
1471
+ return
1472
+ self.post_message(self.AnnotationUpdated(self.target_session, text.strip()))
1473
+
1309
1474
  def action_toggle_expand(self) -> None:
1310
1475
  """Toggle between single and multi-line mode."""
1311
1476
  self.expanded = not self.expanded
@@ -1436,6 +1601,17 @@ class SupervisorTUI(App):
1436
1601
  text-style: bold;
1437
1602
  }
1438
1603
 
1604
+ /* Terminated/killed sessions shown as ghosts */
1605
+ SessionSummary.terminated {
1606
+ background: #1a1a1a;
1607
+ color: #666666;
1608
+ text-style: italic;
1609
+ }
1610
+
1611
+ SessionSummary.terminated:focus {
1612
+ background: #2a2a2a;
1613
+ }
1614
+
1439
1615
  #help-text {
1440
1616
  dock: bottom;
1441
1617
  height: 1;
@@ -1578,6 +1754,7 @@ class SupervisorTUI(App):
1578
1754
  ("left_square_bracket", "supervisor_start", "Start supervisor"),
1579
1755
  ("right_square_bracket", "supervisor_stop", "Stop supervisor"),
1580
1756
  ("backslash", "monitor_restart", "Restart monitor"),
1757
+ ("a", "toggle_summarizer", "AI summarizer"),
1581
1758
  # Manual refresh (useful in diagnostics mode)
1582
1759
  ("r", "manual_refresh", "Refresh"),
1583
1760
  # Agent management
@@ -1599,16 +1776,37 @@ class SupervisorTUI(App):
1599
1776
  ("w", "toggle_web_server", "Web dashboard"),
1600
1777
  # Sleep mode toggle - mark agent as paused (excluded from stats)
1601
1778
  ("z", "toggle_sleep", "Sleep mode"),
1779
+ # Show terminated/killed sessions (ghost mode)
1780
+ ("g", "toggle_show_terminated", "Show killed"),
1781
+ # Jump to sessions needing attention (bell/red)
1782
+ ("b", "jump_to_attention", "Jump attention"),
1783
+ # Hide sleeping agents from display
1784
+ ("Z", "toggle_hide_asleep", "Hide sleeping"),
1785
+ # Sort mode cycle (#61)
1786
+ ("S", "cycle_sort_mode", "Sort mode"),
1787
+ # Edit agent value (#61)
1788
+ ("V", "edit_agent_value", "Edit value"),
1789
+ # Cycle summary content mode (#74)
1790
+ ("l", "cycle_summary_content", "Summary content"),
1791
+ # Edit human annotation (#74)
1792
+ ("I", "focus_human_annotation", "Annotation"),
1602
1793
  ]
1603
1794
 
1604
1795
  # Detail level cycles through 5, 10, 20, 50 lines
1605
1796
  DETAIL_LEVELS = [5, 10, 20, 50]
1606
1797
  # Summary detail levels: low (minimal), med (timing), full (all + repo)
1607
1798
  SUMMARY_LEVELS = ["low", "med", "full"]
1799
+ # Sort modes (#61)
1800
+ SORT_MODES = ["alphabetical", "by_status", "by_value"]
1801
+ # Summary content modes: what to show in the summary line (#74)
1802
+ SUMMARY_CONTENT_MODES = ["ai_short", "ai_long", "orders", "annotation"]
1608
1803
 
1609
1804
  sessions: reactive[List[Session]] = reactive(list)
1610
1805
  view_mode: reactive[str] = reactive("tree") # "tree" or "list_preview"
1611
1806
  tmux_sync: reactive[bool] = reactive(False) # sync navigation to external tmux pane
1807
+ show_terminated: reactive[bool] = reactive(False) # show killed sessions in timeline
1808
+ hide_asleep: reactive[bool] = reactive(False) # hide sleeping agents from display
1809
+ summary_content_mode: reactive[str] = reactive("ai_short") # what to show in summary (#74)
1612
1810
 
1613
1811
  def __init__(self, tmux_session: str = "agents", diagnostics: bool = False):
1614
1812
  super().__init__()
@@ -1651,12 +1849,30 @@ class SupervisorTUI(App):
1651
1849
  self._status_update_in_progress = False
1652
1850
  # Track if we've warned about multiple daemons (to avoid spam)
1653
1851
  self._multiple_daemon_warning_shown = False
1852
+ # Track attention jump state (for 'b' key cycling)
1853
+ self._attention_jump_index = 0
1854
+ self._attention_jump_list: list = [] # Cached list of sessions needing attention
1654
1855
  # Pending kill confirmation (session name, timestamp)
1655
1856
  self._pending_kill: tuple[str, float] | None = None
1656
1857
  # Tmux interface for sync operations
1657
1858
  self._tmux = RealTmux()
1658
1859
  # Initialize tmux_sync from preferences
1659
1860
  self.tmux_sync = self._prefs.tmux_sync
1861
+ # Initialize show_terminated from preferences
1862
+ self.show_terminated = self._prefs.show_terminated
1863
+ # Initialize hide_asleep from preferences
1864
+ self.hide_asleep = self._prefs.hide_asleep
1865
+ # Initialize summary_content_mode from preferences (#98)
1866
+ self.summary_content_mode = self._prefs.summary_content_mode
1867
+ # Cache of terminated sessions (killed during this TUI session)
1868
+ self._terminated_sessions: dict[str, Session] = {}
1869
+
1870
+ # AI Summarizer - owned by TUI, not daemon (zero cost when TUI closed)
1871
+ self._summarizer = SummarizerComponent(
1872
+ tmux_session=tmux_session,
1873
+ config=SummarizerConfig(enabled=False), # Disabled by default
1874
+ )
1875
+ self._summaries: dict[str, AgentSummary] = {}
1660
1876
 
1661
1877
  def compose(self) -> ComposeResult:
1662
1878
  """Create child widgets"""
@@ -1669,13 +1885,13 @@ class SupervisorTUI(App):
1669
1885
  yield CommandBar(id="command-bar")
1670
1886
  yield HelpOverlay(id="help-overlay")
1671
1887
  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",
1888
+ "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
1889
  id="help-text"
1674
1890
  )
1675
1891
 
1676
1892
  def on_mount(self) -> None:
1677
1893
  """Called when app starts"""
1678
- self.title = "Overcode Monitor"
1894
+ self.title = f"Overcode v{__version__}"
1679
1895
  self._update_subtitle()
1680
1896
 
1681
1897
  # Auto-start Monitor Daemon if not running
@@ -1733,6 +1949,8 @@ class SupervisorTUI(App):
1733
1949
  self.set_interval(5, self.update_daemon_status)
1734
1950
  # Update timeline every 30 seconds
1735
1951
  self.set_interval(30, self.update_timeline)
1952
+ # Update AI summaries every 5 seconds (only runs if enabled)
1953
+ self.set_interval(5, self._update_summaries_async)
1736
1954
 
1737
1955
  def update_daemon_status(self) -> None:
1738
1956
  """Update daemon status bar"""
@@ -1810,8 +2028,14 @@ class SupervisorTUI(App):
1810
2028
  Uses launcher.list_sessions() to detect terminated sessions
1811
2029
  (tmux windows that no longer exist, e.g., after machine reboot).
1812
2030
  """
2031
+ # Remember the currently focused session before refreshing/sorting
2032
+ focused = self.focused
2033
+ focused_session_id = focused.session.id if isinstance(focused, SessionSummary) else None
2034
+
1813
2035
  self._invalidate_sessions_cache() # Force cache refresh
1814
2036
  self.sessions = self.launcher.list_sessions()
2037
+ # Apply sorting (#61)
2038
+ self._sort_sessions()
1815
2039
  # Calculate max repo:branch width for alignment in full detail mode
1816
2040
  self.max_repo_info_width = max(
1817
2041
  (len(f"{s.repo_name or 'n/a'}:{s.branch or 'n/a'}") for s in self.sessions),
@@ -1819,9 +2043,56 @@ class SupervisorTUI(App):
1819
2043
  )
1820
2044
  self.max_repo_info_width = max(self.max_repo_info_width, 10) # Minimum 10 chars
1821
2045
  self.update_session_widgets()
2046
+
2047
+ # Update focused_session_index to follow the same session at its new position
2048
+ if focused_session_id:
2049
+ widgets = self._get_widgets_in_session_order()
2050
+ for i, widget in enumerate(widgets):
2051
+ if widget.session.id == focused_session_id:
2052
+ self.focused_session_index = i
2053
+ break
1822
2054
  # NOTE: Don't call update_timeline() here - it has its own 30s interval
1823
2055
  # and reading log files during session refresh causes UI stutter
1824
2056
 
2057
+ def _sort_sessions(self) -> None:
2058
+ """Sort sessions based on current sort mode (#61)."""
2059
+ mode = self._prefs.sort_mode
2060
+
2061
+ if mode == "alphabetical":
2062
+ self.sessions.sort(key=lambda s: s.name.lower())
2063
+ elif mode == "by_status":
2064
+ # Sort by status priority: waiting_user first (red), then running (green), etc.
2065
+ status_order = {
2066
+ "waiting_user": 0,
2067
+ "waiting_supervisor": 1,
2068
+ "no_instructions": 2,
2069
+ "error": 3,
2070
+ "running": 4,
2071
+ "terminated": 5,
2072
+ "asleep": 6,
2073
+ }
2074
+ self.sessions.sort(key=lambda s: (
2075
+ status_order.get(s.stats.current_state or "running", 4),
2076
+ s.name.lower()
2077
+ ))
2078
+ elif mode == "by_value":
2079
+ # Sort by value descending (higher = more important), then alphabetically
2080
+ # Non-green agents first (by value), then green agents (by value)
2081
+ status_order = {
2082
+ "waiting_user": 0,
2083
+ "waiting_supervisor": 0,
2084
+ "no_instructions": 0,
2085
+ "error": 0,
2086
+ "running": 1,
2087
+ "terminated": 2,
2088
+ "asleep": 2,
2089
+ }
2090
+ self.sessions.sort(key=lambda s: (
2091
+ status_order.get(s.stats.current_state or "running", 1),
2092
+ -s.agent_value, # Descending by value
2093
+ s.name.lower()
2094
+ ))
2095
+
1825
2096
  def _get_cached_sessions(self) -> dict[str, Session]:
1826
2097
  """Get sessions with caching to reduce disk I/O.
1827
2098
 
@@ -1910,18 +2181,27 @@ class SupervisorTUI(App):
1910
2181
  stats_results[session_id] = claude_stats
1911
2182
  git_diff_results[session_id] = git_diff
1912
2183
 
2184
+ # Use local summaries from TUI's summarizer (not daemon state)
2185
+ ai_summaries = {}
2186
+ for session_id, summary in self._summaries.items():
2187
+ ai_summaries[session_id] = (
2188
+ summary.text or "",
2189
+ summary.context or "",
2190
+ )
2191
+
1913
2192
  # Update UI on main thread
1914
- self.call_from_thread(self._apply_status_results, status_results, stats_results, git_diff_results, fresh_sessions)
2193
+ self.call_from_thread(self._apply_status_results, status_results, stats_results, git_diff_results, fresh_sessions, ai_summaries)
1915
2194
  finally:
1916
2195
  self._status_update_in_progress = False
1917
2196
 
1918
- def _apply_status_results(self, status_results: dict, stats_results: dict, git_diff_results: dict, fresh_sessions: dict) -> None:
2197
+ def _apply_status_results(self, status_results: dict, stats_results: dict, git_diff_results: dict, fresh_sessions: dict, ai_summaries: dict = None) -> None:
1919
2198
  """Apply fetched status results to widgets (runs on main thread).
1920
2199
 
1921
2200
  All data has been pre-fetched in background - this just updates widget state.
1922
2201
  No file I/O happens here.
1923
2202
  """
1924
2203
  prefs_changed = False
2204
+ ai_summaries = ai_summaries or {}
1925
2205
 
1926
2206
  for widget in self.query(SessionSummary):
1927
2207
  session_id = widget.session.id
@@ -1930,6 +2210,10 @@ class SupervisorTUI(App):
1930
2210
  if session_id in fresh_sessions:
1931
2211
  widget.session = fresh_sessions[session_id]
1932
2212
 
2213
+ # Update AI summaries from daemon state (if available)
2214
+ if session_id in ai_summaries:
2215
+ widget.ai_summary_short, widget.ai_summary_context = ai_summaries[session_id]
2216
+
1933
2217
  # Apply status and stats if we have results for this widget
1934
2218
  if session_id in status_results:
1935
2219
  status, activity, content = status_results[session_id]
@@ -1946,10 +2230,11 @@ class SupervisorTUI(App):
1946
2230
  # Update previous status for next round
1947
2231
  self._previous_statuses[session_id] = status
1948
2232
 
1949
- # Update widget's unvisited state
2233
+ # Update widget's unvisited state (never show bell on asleep sessions #120)
1950
2234
  is_unvisited_stalled = (
1951
2235
  status == STATUS_WAITING_USER and
1952
- session_id not in self._prefs.visited_stalled_agents
2236
+ session_id not in self._prefs.visited_stalled_agents and
2237
+ not widget.session.is_asleep
1953
2238
  )
1954
2239
  widget.is_unvisited_stalled = is_unvisited_stalled
1955
2240
 
@@ -1964,6 +2249,39 @@ class SupervisorTUI(App):
1964
2249
  if self.view_mode == "list_preview":
1965
2250
  self._update_preview()
1966
2251
 
2252
+ @work(thread=True, exclusive=True, name="summarizer")
2253
+ def _update_summaries_async(self) -> None:
2254
+ """Background thread for AI summarization.
2255
+
2256
+ Only runs if summarizer is enabled. Updates are applied to widgets
2257
+ via call_from_thread.
2258
+ """
2259
+ if not self._summarizer.enabled:
2260
+ return
2261
+
2262
+ # Get fresh session list
2263
+ sessions = self.session_manager.list_sessions()
2264
+ if not sessions:
2265
+ return
2266
+
2267
+ # Update summaries (this makes API calls)
2268
+ summaries = self._summarizer.update(sessions)
2269
+
2270
+ # Apply to widgets on main thread
2271
+ self.call_from_thread(self._apply_summaries, summaries)
2272
+
2273
+ def _apply_summaries(self, summaries: dict) -> None:
2274
+ """Apply AI summaries to session widgets (runs on main thread)."""
2275
+ self._summaries = summaries
2276
+
2277
+ for widget in self.query(SessionSummary):
2278
+ session_id = widget.session.id
2279
+ if session_id in summaries:
2280
+ summary = summaries[session_id]
2281
+ widget.ai_summary_short = summary.text or ""
2282
+ widget.ai_summary_context = summary.context or ""
2283
+ widget.refresh()
2284
+
1967
2285
  def update_session_widgets(self) -> None:
1968
2286
  """Update the session display incrementally.
1969
2287
 
@@ -1972,9 +2290,22 @@ class SupervisorTUI(App):
1972
2290
  """
1973
2291
  container = self.query_one("#sessions-container", ScrollableContainer)
1974
2292
 
2293
+ # Build the list of sessions to display
2294
+ # Filter out sleeping agents if hide_asleep is enabled (#69)
2295
+ display_sessions = list(self.sessions)
2296
+ if self.hide_asleep:
2297
+ display_sessions = [s for s in display_sessions if not s.is_asleep]
2298
+ # Include terminated sessions if show_terminated is enabled
2299
+ if self.show_terminated:
2300
+ # Add terminated sessions that aren't already in the active list
2301
+ active_ids = {s.id for s in self.sessions}
2302
+ for session in self._terminated_sessions.values():
2303
+ if session.id not in active_ids:
2304
+ display_sessions.append(session)
2305
+
1975
2306
  # Get existing widgets and their session IDs
1976
2307
  existing_widgets = {w.session.id: w for w in self.query(SessionSummary)}
1977
- new_session_ids = {s.id for s in self.sessions}
2308
+ new_session_ids = {s.id for s in display_sessions}
1978
2309
  existing_session_ids = set(existing_widgets.keys())
1979
2310
 
1980
2311
  # Check if we have an empty message widget that needs removal
@@ -1990,10 +2321,17 @@ class SupervisorTUI(App):
1990
2321
 
1991
2322
  if not sessions_added and not sessions_removed and not has_empty_message:
1992
2323
  # No structural changes needed - just update session data in existing widgets
1993
- session_map = {s.id: s for s in self.sessions}
2324
+ session_map = {s.id: s for s in display_sessions}
1994
2325
  for widget in existing_widgets.values():
1995
2326
  if widget.session.id in session_map:
1996
2327
  widget.session = session_map[widget.session.id]
2328
+ # Update terminated visual state
2329
+ if widget.session.status == "terminated":
2330
+ widget.add_class("terminated")
2331
+ else:
2332
+ widget.remove_class("terminated")
2333
+ # Still reorder widgets to handle sort mode changes
2334
+ self._reorder_session_widgets(container)
1997
2335
  return
1998
2336
 
1999
2337
  # Remove widgets for deleted sessions
@@ -2002,11 +2340,11 @@ class SupervisorTUI(App):
2002
2340
  widget.remove()
2003
2341
 
2004
2342
  # Clear empty message if we now have sessions
2005
- if has_empty_message and self.sessions:
2343
+ if has_empty_message and display_sessions:
2006
2344
  container.remove_children()
2007
2345
 
2008
2346
  # Handle empty state
2009
- if not self.sessions:
2347
+ if not display_sessions:
2010
2348
  if not has_empty_message:
2011
2349
  container.remove_children()
2012
2350
  container.mount(Static(
@@ -2016,7 +2354,7 @@ class SupervisorTUI(App):
2016
2354
  return
2017
2355
 
2018
2356
  # Add widgets for new sessions
2019
- for session in self.sessions:
2357
+ for session in display_sessions:
2020
2358
  if session.id in sessions_added:
2021
2359
  widget = SessionSummary(session, self.status_detector)
2022
2360
  # Restore expanded state if we have it saved
@@ -2030,14 +2368,16 @@ class SupervisorTUI(App):
2030
2368
  if self.view_mode == "list_preview":
2031
2369
  widget.add_class("list-mode")
2032
2370
  widget.expanded = False # Force collapsed in list mode
2371
+ # Mark terminated sessions with visual styling
2372
+ if session.status == "terminated":
2373
+ widget.add_class("terminated")
2033
2374
  container.mount(widget)
2034
2375
  # NOTE: Don't call update_status() here - it does blocking tmux calls
2035
2376
  # The 250ms interval (update_all_statuses) will update status shortly
2036
2377
 
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)
2378
+ # Reorder widgets to match display_sessions order
2379
+ # This must run after any structural changes AND after sort mode changes
2380
+ self._reorder_session_widgets(container)
2041
2381
 
2042
2382
  def action_expand_all(self) -> None:
2043
2383
  """Expand all sessions"""
@@ -2081,6 +2421,58 @@ class SupervisorTUI(App):
2081
2421
 
2082
2422
  self.notify(f"Summary: {new_level}", severity="information")
2083
2423
 
2424
+ def action_cycle_summary_content(self) -> None:
2425
+ """Cycle through summary content modes (ai_short, ai_long, orders, annotation) (#74)."""
2426
+ modes = self.SUMMARY_CONTENT_MODES
2427
+ current_idx = modes.index(self.summary_content_mode) if self.summary_content_mode in modes else 0
2428
+ new_idx = (current_idx + 1) % len(modes)
2429
+ self.summary_content_mode = modes[new_idx]
2430
+
2431
+ # Save preference (#98)
2432
+ self._prefs.summary_content_mode = self.summary_content_mode
2433
+ self._save_prefs()
2434
+
2435
+ # Update all session widgets
2436
+ for widget in self.query(SessionSummary):
2437
+ widget.summary_content_mode = self.summary_content_mode
2438
+
2439
+ mode_names = {
2440
+ "ai_short": "💬 AI Summary (short)",
2441
+ "ai_long": "📖 AI Summary (context)",
2442
+ "orders": "🎯 Standing Orders",
2443
+ "annotation": "✏️ Human Annotation",
2444
+ }
2445
+ self.notify(f"{mode_names.get(self.summary_content_mode, self.summary_content_mode)}", severity="information")
2446
+
2447
+ def action_focus_human_annotation(self) -> None:
2448
+ """Focus input for editing human annotation (#74)."""
2449
+ try:
2450
+ cmd_bar = self.query_one("#command-bar", CommandBar)
2451
+
2452
+ # Show the command bar
2453
+ cmd_bar.add_class("visible")
2454
+
2455
+ # Get the currently focused session (if any)
2456
+ focused = self.focused
2457
+ if isinstance(focused, SessionSummary):
2458
+ cmd_bar.set_target(focused.session.name)
2459
+ # Pre-fill with existing annotation
2460
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
2461
+ cmd_input.value = focused.session.human_annotation or ""
2462
+ elif not cmd_bar.target_session and self.sessions:
2463
+ # Default to first session if none focused
2464
+ cmd_bar.set_target(self.sessions[0].name)
2465
+
2466
+ # Set mode to annotation editing
2467
+ cmd_bar.set_mode("annotation")
2468
+
2469
+ # Enable and focus the input
2470
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
2471
+ cmd_input.disabled = False
2472
+ cmd_input.focus()
2473
+ except NoMatches:
2474
+ pass
2475
+
2084
2476
  def on_session_summary_expanded_changed(self, message: SessionSummary.ExpandedChanged) -> None:
2085
2477
  """Handle expanded state changes from session widgets"""
2086
2478
  self.expanded_states[message.session_id] = message.expanded
@@ -2131,18 +2523,26 @@ class SupervisorTUI(App):
2131
2523
  return widgets
2132
2524
 
2133
2525
  def _reorder_session_widgets(self, container: ScrollableContainer) -> None:
2134
- """Reorder session widgets in container to match self.sessions order.
2526
+ """Reorder session widgets in container to match session display order.
2135
2527
 
2136
2528
  When new widgets are mounted, they're appended at the end.
2137
- This method reorders them to match self.sessions order.
2529
+ This method reorders them to match the display order (active + terminated).
2138
2530
  """
2139
2531
  widgets = {w.session.id: w for w in self.query(SessionSummary)}
2140
2532
  if not widgets:
2141
2533
  return
2142
2534
 
2143
- # Get desired order from self.sessions
2535
+ # Build display sessions list (active + terminated if enabled)
2536
+ display_sessions = list(self.sessions)
2537
+ if self.show_terminated:
2538
+ active_ids = {s.id for s in self.sessions}
2539
+ for session in self._terminated_sessions.values():
2540
+ if session.id not in active_ids:
2541
+ display_sessions.append(session)
2542
+
2543
+ # Get desired order from display_sessions
2144
2544
  ordered_widgets = []
2145
- for session in self.sessions:
2545
+ for session in display_sessions:
2146
2546
  if session.id in widgets:
2147
2547
  ordered_widgets.append(widgets[session.id])
2148
2548
 
@@ -2205,6 +2605,143 @@ class SupervisorTUI(App):
2205
2605
  if self.tmux_sync:
2206
2606
  self._sync_tmux_window()
2207
2607
 
2608
+ def action_toggle_show_terminated(self) -> None:
2609
+ """Toggle showing killed/terminated sessions in the timeline."""
2610
+ self.show_terminated = not self.show_terminated
2611
+
2612
+ # Save preference
2613
+ self._prefs.show_terminated = self.show_terminated
2614
+ self._save_prefs()
2615
+
2616
+ # Refresh session widgets to show/hide terminated sessions
2617
+ self.update_session_widgets()
2618
+
2619
+ # Notify user
2620
+ status = "visible" if self.show_terminated else "hidden"
2621
+ count = len(self._terminated_sessions)
2622
+ if count > 0:
2623
+ self.notify(f"Killed sessions: {status} ({count})", severity="information")
2624
+ else:
2625
+ self.notify(f"Killed sessions: {status}", severity="information")
2626
+
2627
+ def action_jump_to_attention(self) -> None:
2628
+ """Jump to next session needing attention.
2629
+
2630
+ Cycles through sessions with waiting_user status first (red/bell),
2631
+ then through other non-green statuses (no_instructions, waiting_supervisor).
2632
+ """
2633
+ from .status_constants import STATUS_WAITING_USER, STATUS_NO_INSTRUCTIONS, STATUS_WAITING_SUPERVISOR, STATUS_RUNNING, STATUS_TERMINATED, STATUS_ASLEEP
2634
+
2635
+ widgets = self._get_widgets_in_session_order()
2636
+ if not widgets:
2637
+ return
2638
+
2639
+ # Build prioritized list of sessions needing attention
2640
+ # Priority: waiting_user (red) > no_instructions (yellow) > waiting_supervisor (orange)
2641
+ attention_sessions = []
2642
+ for i, widget in enumerate(widgets):
2643
+ status = getattr(widget, 'current_status', STATUS_RUNNING)
2644
+ if status == STATUS_WAITING_USER:
2645
+ attention_sessions.append((0, i, widget)) # Highest priority
2646
+ elif status == STATUS_NO_INSTRUCTIONS:
2647
+ attention_sessions.append((1, i, widget))
2648
+ elif status == STATUS_WAITING_SUPERVISOR:
2649
+ attention_sessions.append((2, i, widget))
2650
+ # Skip running, terminated, asleep
2651
+
2652
+ if not attention_sessions:
2653
+ self.notify("No sessions need attention", severity="information")
2654
+ return
2655
+
2656
+ # Sort by priority, then by original index
2657
+ attention_sessions.sort(key=lambda x: (x[0], x[1]))
2658
+
2659
+ # Check if our cached list changed (sessions may have changed state)
2660
+ current_widget_ids = [id(w) for _, _, w in attention_sessions]
2661
+ cached_widget_ids = [id(w) for w in self._attention_jump_list]
2662
+
2663
+ if current_widget_ids != cached_widget_ids:
2664
+ # List changed, reset index
2665
+ self._attention_jump_list = [w for _, _, w in attention_sessions]
2666
+ self._attention_jump_index = 0
2667
+ else:
2668
+ # Cycle to next
2669
+ self._attention_jump_index = (self._attention_jump_index + 1) % len(self._attention_jump_list)
2670
+
2671
+ # Focus the target widget
2672
+ target_widget = self._attention_jump_list[self._attention_jump_index]
2673
+ # Find its index in the full widget list
2674
+ for i, w in enumerate(widgets):
2675
+ if w is target_widget:
2676
+ self.focused_session_index = i
2677
+ break
2678
+
2679
+ target_widget.focus()
2680
+ if self.view_mode == "list_preview":
2681
+ self._update_preview()
2682
+ self._sync_tmux_window(target_widget)
2683
+
2684
+ # Show position indicator
2685
+ pos = self._attention_jump_index + 1
2686
+ total = len(self._attention_jump_list)
2687
+ status = getattr(target_widget, 'current_status', 'unknown')
2688
+ self.notify(f"Attention {pos}/{total}: {target_widget.session.name} ({status})", severity="information")
2689
+
2690
+ def action_toggle_hide_asleep(self) -> None:
2691
+ """Toggle hiding sleeping agents from display."""
2692
+ self.hide_asleep = not self.hide_asleep
2693
+
2694
+ # Save preference
2695
+ self._prefs.hide_asleep = self.hide_asleep
2696
+ self._save_prefs()
2697
+
2698
+ # Update subtitle to show state
2699
+ self._update_subtitle()
2700
+
2701
+ # Refresh session widgets to show/hide sleeping agents
2702
+ self.update_session_widgets()
2703
+
2704
+ # Count sleeping agents
2705
+ asleep_count = sum(1 for s in self.sessions if s.is_asleep)
2706
+ if self.hide_asleep:
2707
+ self.notify(f"Sleeping agents hidden ({asleep_count})", severity="information")
2708
+ else:
2709
+ self.notify(f"Sleeping agents visible ({asleep_count})", severity="information")
2710
+
2711
+ def action_cycle_sort_mode(self) -> None:
2712
+ """Cycle through sort modes (#61)."""
2713
+ # Remember the currently focused session before sorting
2714
+ widgets = self._get_widgets_in_session_order()
2715
+ focused_session_id = None
2716
+ if widgets and 0 <= self.focused_session_index < len(widgets):
2717
+ focused_session_id = widgets[self.focused_session_index].session.id
2718
+
2719
+ modes = self.SORT_MODES
2720
+ current_idx = modes.index(self._prefs.sort_mode) if self._prefs.sort_mode in modes else 0
2721
+ new_idx = (current_idx + 1) % len(modes)
2722
+ self._prefs.sort_mode = modes[new_idx]
2723
+ self._save_prefs()
2724
+
2725
+ # Re-sort and refresh
2726
+ self._sort_sessions()
2727
+ self.update_session_widgets()
2728
+ self._update_subtitle()
2729
+
2730
+ # Update focused_session_index to follow the same session at its new position
2731
+ if focused_session_id:
2732
+ widgets = self._get_widgets_in_session_order()
2733
+ for i, widget in enumerate(widgets):
2734
+ if widget.session.id == focused_session_id:
2735
+ self.focused_session_index = i
2736
+ break
2737
+
2738
+ mode_names = {
2739
+ "alphabetical": "Alphabetical",
2740
+ "by_status": "By Status",
2741
+ "by_value": "By Value (priority)",
2742
+ }
2743
+ self.notify(f"Sort: {mode_names.get(self._prefs.sort_mode, self._prefs.sort_mode)}", severity="information")
2744
+
2208
2745
  def _sync_tmux_window(self, widget: Optional["SessionSummary"] = None) -> None:
2209
2746
  """Sync external tmux pane to show the focused session's window.
2210
2747
 
@@ -2271,12 +2808,17 @@ class SupervisorTUI(App):
2271
2808
  pass
2272
2809
 
2273
2810
  def _update_preview(self) -> None:
2274
- """Update preview pane with focused session's content."""
2811
+ """Update preview pane with focused session's content.
2812
+
2813
+ Uses self.focused directly to ensure the preview always shows the
2814
+ actually-focused widget, regardless of any index tracking issues
2815
+ that might occur during sorting or session refresh.
2816
+ """
2275
2817
  try:
2276
2818
  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])
2819
+ focused = self.focused
2820
+ if isinstance(focused, SessionSummary):
2821
+ preview.update_from_widget(focused)
2280
2822
  except NoMatches:
2281
2823
  pass
2282
2824
 
@@ -2332,6 +2874,37 @@ class SupervisorTUI(App):
2332
2874
  except NoMatches:
2333
2875
  pass
2334
2876
 
2877
+ def action_edit_agent_value(self) -> None:
2878
+ """Focus the command bar for editing agent value (#61)."""
2879
+ try:
2880
+ cmd_bar = self.query_one("#command-bar", CommandBar)
2881
+
2882
+ # Show the command bar
2883
+ cmd_bar.add_class("visible")
2884
+
2885
+ # Get the currently focused session (if any)
2886
+ focused = self.focused
2887
+ if isinstance(focused, SessionSummary):
2888
+ cmd_bar.set_target(focused.session.name)
2889
+ # Pre-fill with existing value
2890
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
2891
+ cmd_input.value = str(focused.session.agent_value)
2892
+ elif not cmd_bar.target_session and self.sessions:
2893
+ # Default to first session if none focused
2894
+ cmd_bar.set_target(self.sessions[0].name)
2895
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
2896
+ cmd_input.value = "1000"
2897
+
2898
+ # Set mode to value
2899
+ cmd_bar.set_mode("value")
2900
+
2901
+ # Enable and focus the input
2902
+ cmd_input = cmd_bar.query_one("#cmd-input", Input)
2903
+ cmd_input.disabled = False
2904
+ cmd_input.focus()
2905
+ except NoMatches:
2906
+ pass
2907
+
2335
2908
  def on_command_bar_send_requested(self, message: CommandBar.SendRequested) -> None:
2336
2909
  """Handle send request from command bar."""
2337
2910
  from datetime import datetime
@@ -2359,12 +2932,40 @@ class SupervisorTUI(App):
2359
2932
  session = self.session_manager.get_session_by_name(message.session_name)
2360
2933
  if session:
2361
2934
  self.session_manager.set_standing_instructions(session.id, message.text)
2362
- self.notify(f"Standing order set for {message.session_name}")
2935
+ if message.text:
2936
+ self.notify(f"Standing order set for {message.session_name}")
2937
+ else:
2938
+ self.notify(f"Standing order cleared for {message.session_name}")
2363
2939
  # Refresh session list to show updated standing order
2364
2940
  self.refresh_sessions()
2365
2941
  else:
2366
2942
  self.notify(f"Session '{message.session_name}' not found", severity="error")
2367
2943
 
2944
+ def on_command_bar_value_updated(self, message: CommandBar.ValueUpdated) -> None:
2945
+ """Handle agent value update from command bar (#61)."""
2946
+ session = self.session_manager.get_session_by_name(message.session_name)
2947
+ if session:
2948
+ self.session_manager.set_agent_value(session.id, message.value)
2949
+ self.notify(f"Value set to {message.value} for {message.session_name}")
2950
+ # Refresh and re-sort session list
2951
+ self.refresh_sessions()
2952
+ else:
2953
+ self.notify(f"Session '{message.session_name}' not found", severity="error")
2954
+
2955
+ def on_command_bar_annotation_updated(self, message: CommandBar.AnnotationUpdated) -> None:
2956
+ """Handle human annotation update from command bar (#74)."""
2957
+ session = self.session_manager.get_session_by_name(message.session_name)
2958
+ if session:
2959
+ self.session_manager.set_human_annotation(session.id, message.annotation)
2960
+ if message.annotation:
2961
+ self.notify(f"Annotation set for {message.session_name}")
2962
+ else:
2963
+ self.notify(f"Annotation cleared for {message.session_name}")
2964
+ # Refresh session list to show updated annotation
2965
+ self.refresh_sessions()
2966
+ else:
2967
+ self.notify(f"Session '{message.session_name}' not found", severity="error")
2968
+
2368
2969
  def on_command_bar_clear_requested(self, message: CommandBar.ClearRequested) -> None:
2369
2970
  """Handle clear request - hide and unfocus command bar."""
2370
2971
  try:
@@ -2501,6 +3102,33 @@ class SupervisorTUI(App):
2501
3102
 
2502
3103
  self.update_daemon_status()
2503
3104
 
3105
+ def action_toggle_summarizer(self) -> None:
3106
+ """Toggle the AI Summarizer on/off."""
3107
+ # Check if summarizer is available (OPENAI_API_KEY set)
3108
+ if not SummarizerClient.is_available():
3109
+ self.notify("AI Summarizer unavailable - set OPENAI_API_KEY", severity="warning")
3110
+ return
3111
+
3112
+ # Toggle the state
3113
+ self._summarizer.config.enabled = not self._summarizer.config.enabled
3114
+
3115
+ if self._summarizer.config.enabled:
3116
+ # Enable: create client if needed
3117
+ if not self._summarizer._client:
3118
+ self._summarizer._client = SummarizerClient()
3119
+ self.notify("AI Summarizer enabled", severity="information")
3120
+ # Trigger an immediate update
3121
+ self._update_summaries_async()
3122
+ else:
3123
+ # Disable: close client to release resources
3124
+ if self._summarizer._client:
3125
+ self._summarizer._client.close()
3126
+ self._summarizer._client = None
3127
+ self.notify("AI Summarizer disabled", severity="information")
3128
+
3129
+ # Refresh status bar
3130
+ self.update_daemon_status()
3131
+
2504
3132
  def action_monitor_restart(self) -> None:
2505
3133
  """Restart the Monitor Daemon (handles metrics/state tracking)."""
2506
3134
  import time
@@ -2652,6 +3280,12 @@ class SupervisorTUI(App):
2652
3280
 
2653
3281
  def _execute_kill(self, focused: "SessionSummary", session_name: str, session_id: str) -> None:
2654
3282
  """Execute the actual kill operation after confirmation."""
3283
+ # Save a copy of the session for showing when show_terminated is True
3284
+ session_copy = focused.session
3285
+ # Mark it as terminated for display purposes
3286
+ from dataclasses import replace
3287
+ terminated_session = replace(session_copy, status="terminated")
3288
+
2655
3289
  # Use launcher to kill the session
2656
3290
  launcher = ClaudeLauncher(
2657
3291
  tmux_session=self.tmux_session,
@@ -2660,13 +3294,21 @@ class SupervisorTUI(App):
2660
3294
 
2661
3295
  if launcher.kill_session(session_name):
2662
3296
  self.notify(f"Killed agent: {session_name}", severity="information")
2663
- # Remove the widget and refresh
3297
+
3298
+ # Store in terminated sessions cache for ghost mode
3299
+ self._terminated_sessions[session_id] = terminated_session
3300
+
3301
+ # Remove the widget (will be re-added if show_terminated is True)
2664
3302
  focused.remove()
2665
3303
  # Update session cache
2666
3304
  if session_id in self._sessions_cache:
2667
3305
  del self._sessions_cache[session_id]
2668
3306
  if session_id in self.expanded_states:
2669
3307
  del self.expanded_states[session_id]
3308
+
3309
+ # If showing terminated sessions, refresh to add it back
3310
+ if self.show_terminated:
3311
+ self.update_session_widgets()
2670
3312
  # Clear preview pane and focus next agent if in list_preview mode
2671
3313
  if self.view_mode == "list_preview":
2672
3314
  try:
@@ -2759,8 +3401,52 @@ class SupervisorTUI(App):
2759
3401
  else:
2760
3402
  self.notify(f"Failed to send Enter to {session_name}", severity="error")
2761
3403
 
3404
+ def _is_freetext_option(self, pane_content: str, key: str) -> bool:
3405
+ """Check if a numbered menu option is a free-text instruction option.
3406
+
3407
+ Scans the pane content for patterns like "5. Tell Claude what to do"
3408
+ or "3) Give custom instructions" to determine if selecting this option
3409
+ should open the command bar for user input.
3410
+
3411
+ Args:
3412
+ pane_content: The tmux pane content to scan
3413
+ key: The number key being pressed (e.g., "5")
3414
+
3415
+ Returns:
3416
+ True if this option expects free-text input
3417
+ """
3418
+ import re
3419
+
3420
+ # Claude Code v2.x only has one freetext option format:
3421
+ # "3. No, and tell Claude what to do differently (esc)"
3422
+ # This appears on all permission prompts (Bash, Read, Write, etc.)
3423
+ freetext_patterns = [
3424
+ r"tell\s+claude\s+what\s+to\s+do",
3425
+ ]
3426
+
3427
+ # Look for the numbered option in the content
3428
+ # Match patterns like "5. text", "5) text", "5: text"
3429
+ option_pattern = rf"^\s*{key}[\.\)\:]\s*(.+)$"
3430
+
3431
+ for line in pane_content.split('\n'):
3432
+ match = re.match(option_pattern, line.strip(), re.IGNORECASE)
3433
+ if match:
3434
+ option_text = match.group(1).lower()
3435
+ # Check if this option matches any freetext pattern
3436
+ for pattern in freetext_patterns:
3437
+ if re.search(pattern, option_text):
3438
+ return True
3439
+ return False
3440
+
2762
3441
  def _send_key_to_focused(self, key: str) -> None:
2763
- """Send a key to the focused agent."""
3442
+ """Send a key to the focused agent.
3443
+
3444
+ If the key selects a "free text instruction" menu option (detected by
3445
+ scanning the pane content), automatically opens the command bar (#72).
3446
+
3447
+ Args:
3448
+ key: The key to send
3449
+ """
2764
3450
  focused = self.focused
2765
3451
  if not isinstance(focused, SessionSummary):
2766
3452
  self.notify("No agent focused", severity="warning")
@@ -2772,9 +3458,16 @@ class SupervisorTUI(App):
2772
3458
  session_manager=self.session_manager
2773
3459
  )
2774
3460
 
3461
+ # Check if this option is a free-text instruction option before sending
3462
+ pane_content = self.status_detector.get_pane_content(focused.session.tmux_window) or ""
3463
+ is_freetext = self._is_freetext_option(pane_content, key)
3464
+
2775
3465
  # Send the key followed by Enter (to select the numbered option)
2776
3466
  if launcher.send_to_session(session_name, key, enter=True):
2777
3467
  self.notify(f"Sent '{key}' to {session_name}", severity="information")
3468
+ # Open command bar if this was a free-text instruction option (#72)
3469
+ if is_freetext:
3470
+ self.action_focus_command_bar()
2778
3471
  else:
2779
3472
  self.notify(f"Failed to send '{key}' to {session_name}", severity="error")
2780
3473
 
@@ -2805,6 +3498,9 @@ class SupervisorTUI(App):
2805
3498
  def on_unmount(self) -> None:
2806
3499
  """Clean up terminal state on exit"""
2807
3500
  import sys
3501
+ # Stop the summarizer (release API client resources)
3502
+ self._summarizer.stop()
3503
+
2808
3504
  # Ensure mouse tracking is disabled
2809
3505
  sys.stdout.write('\033[?1000l') # Disable mouse tracking
2810
3506
  sys.stdout.write('\033[?1002l') # Disable cell motion tracking