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