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