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/__init__.py +1 -1
- overcode/cli.py +147 -49
- overcode/config.py +66 -0
- overcode/daemon_claude_skill.md +36 -33
- overcode/history_reader.py +69 -8
- overcode/implementations.py +109 -84
- overcode/monitor_daemon.py +33 -38
- overcode/monitor_daemon_state.py +17 -15
- overcode/pid_utils.py +17 -3
- overcode/session_manager.py +53 -0
- overcode/settings.py +12 -0
- overcode/status_constants.py +1 -1
- overcode/status_detector.py +8 -2
- overcode/status_patterns.py +19 -0
- overcode/summarizer_client.py +72 -27
- overcode/summarizer_component.py +87 -107
- overcode/supervisor_daemon.py +21 -5
- overcode/tmux_manager.py +101 -91
- overcode/tui.py +829 -133
- overcode/tui_helpers.py +4 -3
- {overcode-0.1.2.dist-info → overcode-0.1.3.dist-info}/METADATA +2 -1
- overcode-0.1.3.dist-info/RECORD +45 -0
- {overcode-0.1.2.dist-info → overcode-0.1.3.dist-info}/WHEEL +1 -1
- overcode-0.1.2.dist-info/RECORD +0 -45
- {overcode-0.1.2.dist-info → overcode-0.1.3.dist-info}/entry_points.txt +0 -0
- {overcode-0.1.2.dist-info → overcode-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {overcode-0.1.2.dist-info → overcode-0.1.3.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,
|
|
@@ -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
|
|
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 #
|
|
265
|
-
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
326
|
-
slot_duration = timedelta(hours=self.
|
|
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.
|
|
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
|
|
359
|
-
content.append(f" {'User:':<
|
|
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.
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
459
|
-
║
|
|
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
|
-
║
|
|
465
|
-
║
|
|
466
|
-
║
|
|
467
|
-
║
|
|
468
|
-
║
|
|
469
|
-
║
|
|
470
|
-
║
|
|
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
|
-
║
|
|
479
|
-
║
|
|
480
|
-
║
|
|
481
|
-
║
|
|
482
|
-
║ v Cycle detail lines
|
|
483
|
-
║
|
|
484
|
-
║
|
|
485
|
-
║
|
|
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
|
-
║
|
|
489
|
-
║
|
|
490
|
-
║
|
|
491
|
-
║
|
|
492
|
-
║
|
|
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
|
|
495
|
-
║
|
|
529
|
+
║ DAEMON CONTROL ║
|
|
530
|
+
║ ────────────────────────────────────────────────────────────────────────── ║
|
|
496
531
|
║ [ Start supervisor ] Stop supervisor ║
|
|
497
|
-
║ \\
|
|
498
|
-
║
|
|
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
|
-
║
|
|
501
|
-
║
|
|
502
|
-
║
|
|
503
|
-
║
|
|
504
|
-
║
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
style
|
|
951
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
1262
|
-
|
|
1263
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2343
|
+
if has_empty_message and display_sessions:
|
|
2006
2344
|
container.remove_children()
|
|
2007
2345
|
|
|
2008
2346
|
# Handle empty state
|
|
2009
|
-
if not
|
|
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
|
|
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
|
|
2038
|
-
#
|
|
2039
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
2278
|
-
if
|
|
2279
|
-
preview.update_from_widget(
|
|
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
|
-
|
|
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
|
-
|
|
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
|