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