overcode 0.1.3__py3-none-any.whl → 0.1.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- overcode/__init__.py +1 -1
- overcode/cli.py +7 -2
- overcode/implementations.py +74 -8
- overcode/monitor_daemon.py +60 -65
- overcode/monitor_daemon_core.py +261 -0
- overcode/monitor_daemon_state.py +7 -0
- overcode/session_manager.py +1 -0
- overcode/settings.py +22 -0
- overcode/supervisor_daemon.py +48 -47
- overcode/supervisor_daemon_core.py +210 -0
- overcode/testing/__init__.py +6 -0
- overcode/testing/renderer.py +268 -0
- overcode/testing/tmux_driver.py +223 -0
- overcode/testing/tui_eye.py +185 -0
- overcode/testing/tui_eye_skill.md +187 -0
- overcode/tmux_manager.py +17 -3
- overcode/tui.py +196 -2462
- overcode/tui_actions/__init__.py +20 -0
- overcode/tui_actions/daemon.py +201 -0
- overcode/tui_actions/input.py +128 -0
- overcode/tui_actions/navigation.py +117 -0
- overcode/tui_actions/session.py +428 -0
- overcode/tui_actions/view.py +357 -0
- overcode/tui_helpers.py +41 -9
- overcode/tui_logic.py +347 -0
- overcode/tui_render.py +414 -0
- overcode/tui_widgets/__init__.py +24 -0
- overcode/tui_widgets/command_bar.py +399 -0
- overcode/tui_widgets/daemon_panel.py +153 -0
- overcode/tui_widgets/daemon_status_bar.py +245 -0
- overcode/tui_widgets/help_overlay.py +71 -0
- overcode/tui_widgets/preview_pane.py +69 -0
- overcode/tui_widgets/session_summary.py +514 -0
- overcode/tui_widgets/status_timeline.py +253 -0
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/METADATA +3 -1
- overcode-0.1.4.dist-info/RECORD +68 -0
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
- overcode-0.1.3.dist-info/RECORD +0 -45
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/WHEEL +0 -0
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command bar widget for TUI.
|
|
3
|
+
|
|
4
|
+
Inline command bar for sending instructions to agents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from textual.widgets import Static, Label, Input, TextArea
|
|
10
|
+
from textual.containers import Horizontal
|
|
11
|
+
from textual.reactive import reactive
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.message import Message
|
|
14
|
+
from textual import events
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CommandBar(Static):
|
|
18
|
+
"""Inline command bar for sending instructions to agents.
|
|
19
|
+
|
|
20
|
+
Supports single-line (Input) and multi-line (TextArea) modes.
|
|
21
|
+
Toggle with Ctrl+E. Send with Enter (single) or Ctrl+Enter (multi).
|
|
22
|
+
Use Ctrl+O to set as standing order instead of sending.
|
|
23
|
+
|
|
24
|
+
Modes:
|
|
25
|
+
- "send": Default mode for sending instructions to an agent
|
|
26
|
+
- "standing_orders": Mode for editing standing orders for an agent
|
|
27
|
+
- "new_agent_dir": First step of new agent creation - enter working directory
|
|
28
|
+
- "new_agent_name": Second step of new agent creation - enter agent name
|
|
29
|
+
- "new_agent_perms": Third step of new agent creation - choose permission mode
|
|
30
|
+
|
|
31
|
+
Key handling is done via on_key() since Input/TextArea consume most keys.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
expanded = reactive(False) # Toggle single/multi-line mode
|
|
35
|
+
target_session: Optional[str] = None
|
|
36
|
+
mode: str = "send" # "send", "standing_orders", "new_agent_dir", "new_agent_name", or "new_agent_perms"
|
|
37
|
+
new_agent_dir: Optional[str] = None # Store directory between steps
|
|
38
|
+
new_agent_name: Optional[str] = None # Store name between steps
|
|
39
|
+
|
|
40
|
+
class SendRequested(Message):
|
|
41
|
+
"""Message sent when user wants to send text to a session."""
|
|
42
|
+
def __init__(self, session_name: str, text: str):
|
|
43
|
+
super().__init__()
|
|
44
|
+
self.session_name = session_name
|
|
45
|
+
self.text = text
|
|
46
|
+
|
|
47
|
+
class StandingOrderRequested(Message):
|
|
48
|
+
"""Message sent when user wants to set a standing order."""
|
|
49
|
+
def __init__(self, session_name: str, text: str):
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.session_name = session_name
|
|
52
|
+
self.text = text
|
|
53
|
+
|
|
54
|
+
class NewAgentRequested(Message):
|
|
55
|
+
"""Message sent when user wants to create a new agent."""
|
|
56
|
+
def __init__(self, agent_name: str, directory: Optional[str] = None, bypass_permissions: bool = False):
|
|
57
|
+
super().__init__()
|
|
58
|
+
self.agent_name = agent_name
|
|
59
|
+
self.directory = directory
|
|
60
|
+
self.bypass_permissions = bypass_permissions
|
|
61
|
+
|
|
62
|
+
class ValueUpdated(Message):
|
|
63
|
+
"""Message sent when user updates agent value (#61)."""
|
|
64
|
+
def __init__(self, session_name: str, value: int):
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.session_name = session_name
|
|
67
|
+
self.value = value
|
|
68
|
+
|
|
69
|
+
class AnnotationUpdated(Message):
|
|
70
|
+
"""Message sent when user updates human annotation (#74)."""
|
|
71
|
+
def __init__(self, session_name: str, annotation: str):
|
|
72
|
+
super().__init__()
|
|
73
|
+
self.session_name = session_name
|
|
74
|
+
self.annotation = annotation
|
|
75
|
+
|
|
76
|
+
class ClearRequested(Message):
|
|
77
|
+
"""Message sent when user clears the command bar."""
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
def compose(self) -> ComposeResult:
|
|
81
|
+
"""Create command bar widgets."""
|
|
82
|
+
with Horizontal(id="cmd-bar-container"):
|
|
83
|
+
yield Label("", id="target-label")
|
|
84
|
+
yield Input(id="cmd-input", placeholder="Type instruction (Enter to send)...", disabled=True)
|
|
85
|
+
yield TextArea(id="cmd-textarea", classes="hidden", disabled=True)
|
|
86
|
+
yield Label("[^E]", id="expand-hint")
|
|
87
|
+
|
|
88
|
+
def on_mount(self) -> None:
|
|
89
|
+
"""Initialize command bar state."""
|
|
90
|
+
self._update_target_label()
|
|
91
|
+
# Ensure widgets start disabled to prevent auto-focus
|
|
92
|
+
self.query_one("#cmd-input", Input).disabled = True
|
|
93
|
+
self.query_one("#cmd-textarea", TextArea).disabled = True
|
|
94
|
+
|
|
95
|
+
def _update_target_label(self) -> None:
|
|
96
|
+
"""Update the target session label based on mode."""
|
|
97
|
+
label = self.query_one("#target-label", Label)
|
|
98
|
+
input_widget = self.query_one("#cmd-input", Input)
|
|
99
|
+
|
|
100
|
+
if self.mode == "new_agent_dir":
|
|
101
|
+
label.update("[New Agent: Directory] ")
|
|
102
|
+
input_widget.placeholder = "Enter working directory path..."
|
|
103
|
+
elif self.mode == "new_agent_name":
|
|
104
|
+
label.update("[New Agent: Name] ")
|
|
105
|
+
input_widget.placeholder = "Enter agent name (or Enter to accept default)..."
|
|
106
|
+
elif self.mode == "new_agent_perms":
|
|
107
|
+
label.update("[New Agent: Permissions] ")
|
|
108
|
+
input_widget.placeholder = "Type 'bypass' for --dangerously-skip-permissions, or Enter for normal..."
|
|
109
|
+
elif self.mode == "standing_orders":
|
|
110
|
+
if self.target_session:
|
|
111
|
+
label.update(f"[{self.target_session} Standing Orders] ")
|
|
112
|
+
else:
|
|
113
|
+
label.update("[Standing Orders] ")
|
|
114
|
+
input_widget.placeholder = "Enter standing orders (or empty to clear)..."
|
|
115
|
+
elif self.mode == "value":
|
|
116
|
+
if self.target_session:
|
|
117
|
+
label.update(f"[{self.target_session} Value] ")
|
|
118
|
+
else:
|
|
119
|
+
label.update("[Value] ")
|
|
120
|
+
input_widget.placeholder = "Enter priority value (1000 = normal, higher = more important)..."
|
|
121
|
+
elif self.mode == "annotation":
|
|
122
|
+
if self.target_session:
|
|
123
|
+
label.update(f"[{self.target_session} Annotation] ")
|
|
124
|
+
else:
|
|
125
|
+
label.update("[Annotation] ")
|
|
126
|
+
input_widget.placeholder = "Enter human annotation (or empty to clear)..."
|
|
127
|
+
elif self.target_session:
|
|
128
|
+
label.update(f"[{self.target_session}] ")
|
|
129
|
+
input_widget.placeholder = "Type instruction (Enter to send)..."
|
|
130
|
+
else:
|
|
131
|
+
label.update("[no session] ")
|
|
132
|
+
input_widget.placeholder = "Type instruction (Enter to send)..."
|
|
133
|
+
|
|
134
|
+
def set_target(self, session_name: Optional[str]) -> None:
|
|
135
|
+
"""Set the target session for commands."""
|
|
136
|
+
self.target_session = session_name
|
|
137
|
+
self.mode = "send" # Reset to send mode when target changes
|
|
138
|
+
self._update_target_label()
|
|
139
|
+
|
|
140
|
+
def set_mode(self, mode: str) -> None:
|
|
141
|
+
"""Set the command bar mode ('send' or 'new_agent')."""
|
|
142
|
+
self.mode = mode
|
|
143
|
+
self._update_target_label()
|
|
144
|
+
|
|
145
|
+
def watch_expanded(self, expanded: bool) -> None:
|
|
146
|
+
"""Toggle between single-line and multi-line mode."""
|
|
147
|
+
input_widget = self.query_one("#cmd-input", Input)
|
|
148
|
+
textarea = self.query_one("#cmd-textarea", TextArea)
|
|
149
|
+
|
|
150
|
+
if expanded:
|
|
151
|
+
# Switch to multi-line
|
|
152
|
+
input_widget.add_class("hidden")
|
|
153
|
+
input_widget.disabled = True
|
|
154
|
+
textarea.remove_class("hidden")
|
|
155
|
+
textarea.disabled = False
|
|
156
|
+
# Transfer content
|
|
157
|
+
textarea.text = input_widget.value
|
|
158
|
+
input_widget.value = ""
|
|
159
|
+
textarea.focus()
|
|
160
|
+
else:
|
|
161
|
+
# Switch to single-line
|
|
162
|
+
textarea.add_class("hidden")
|
|
163
|
+
textarea.disabled = True
|
|
164
|
+
input_widget.remove_class("hidden")
|
|
165
|
+
input_widget.disabled = False
|
|
166
|
+
# Transfer content (first line only for single-line)
|
|
167
|
+
if textarea.text:
|
|
168
|
+
first_line = textarea.text.split('\n')[0]
|
|
169
|
+
input_widget.value = first_line
|
|
170
|
+
textarea.text = ""
|
|
171
|
+
input_widget.focus()
|
|
172
|
+
|
|
173
|
+
def on_key(self, event: events.Key) -> None:
|
|
174
|
+
"""Handle key events for command bar shortcuts."""
|
|
175
|
+
if event.key == "ctrl+e":
|
|
176
|
+
self.action_toggle_expand()
|
|
177
|
+
event.stop()
|
|
178
|
+
elif event.key == "ctrl+o":
|
|
179
|
+
self.action_set_standing_order()
|
|
180
|
+
event.stop()
|
|
181
|
+
elif event.key == "escape":
|
|
182
|
+
self.action_clear_and_unfocus()
|
|
183
|
+
event.stop()
|
|
184
|
+
elif event.key == "ctrl+enter" and self.expanded:
|
|
185
|
+
self.action_send_multiline()
|
|
186
|
+
event.stop()
|
|
187
|
+
|
|
188
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
189
|
+
"""Handle Enter in single-line mode."""
|
|
190
|
+
if event.input.id == "cmd-input":
|
|
191
|
+
text = event.value.strip()
|
|
192
|
+
|
|
193
|
+
if self.mode == "new_agent_dir":
|
|
194
|
+
# Step 1: Directory entered, validate and move to name step
|
|
195
|
+
# Note: _handle_new_agent_dir sets input value to default name, don't clear it
|
|
196
|
+
self._handle_new_agent_dir(text if text else None)
|
|
197
|
+
return
|
|
198
|
+
elif self.mode == "new_agent_name":
|
|
199
|
+
# Step 2: Name entered (or default accepted), move to permissions step
|
|
200
|
+
# If empty, use the pre-filled default
|
|
201
|
+
name = text if text else event.input.value.strip()
|
|
202
|
+
if not name:
|
|
203
|
+
# Derive from directory as fallback
|
|
204
|
+
from pathlib import Path
|
|
205
|
+
name = Path(self.new_agent_dir).name if self.new_agent_dir else "agent"
|
|
206
|
+
self._handle_new_agent_name(name)
|
|
207
|
+
event.input.value = ""
|
|
208
|
+
return
|
|
209
|
+
elif self.mode == "new_agent_perms":
|
|
210
|
+
# Step 3: Permissions chosen, create agent
|
|
211
|
+
bypass = text.lower().strip() in ("bypass", "y", "yes", "!")
|
|
212
|
+
self._create_new_agent(self.new_agent_name, bypass)
|
|
213
|
+
event.input.value = ""
|
|
214
|
+
self.action_clear_and_unfocus()
|
|
215
|
+
return
|
|
216
|
+
elif self.mode == "standing_orders":
|
|
217
|
+
# Set standing orders (empty string clears them)
|
|
218
|
+
self._set_standing_order(text)
|
|
219
|
+
event.input.value = ""
|
|
220
|
+
self.action_clear_and_unfocus()
|
|
221
|
+
return
|
|
222
|
+
elif self.mode == "value":
|
|
223
|
+
# Set agent value (#61)
|
|
224
|
+
self._set_value(text)
|
|
225
|
+
event.input.value = ""
|
|
226
|
+
self.action_clear_and_unfocus()
|
|
227
|
+
return
|
|
228
|
+
elif self.mode == "annotation":
|
|
229
|
+
# Set human annotation (empty string clears it)
|
|
230
|
+
self._set_annotation(text)
|
|
231
|
+
event.input.value = ""
|
|
232
|
+
self.action_clear_and_unfocus()
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
# Default "send" mode
|
|
236
|
+
if not text:
|
|
237
|
+
return
|
|
238
|
+
self._send_message(text)
|
|
239
|
+
event.input.value = ""
|
|
240
|
+
self.action_clear_and_unfocus()
|
|
241
|
+
|
|
242
|
+
def _send_message(self, text: str) -> None:
|
|
243
|
+
"""Send message to target session."""
|
|
244
|
+
if not self.target_session or not text.strip():
|
|
245
|
+
return
|
|
246
|
+
self.post_message(self.SendRequested(self.target_session, text.strip()))
|
|
247
|
+
|
|
248
|
+
def _handle_new_agent_dir(self, directory: Optional[str]) -> None:
|
|
249
|
+
"""Handle directory input for new agent creation.
|
|
250
|
+
|
|
251
|
+
Validates directory and transitions to name input step.
|
|
252
|
+
"""
|
|
253
|
+
from pathlib import Path
|
|
254
|
+
|
|
255
|
+
# Expand ~ and resolve path
|
|
256
|
+
if directory:
|
|
257
|
+
dir_path = Path(directory).expanduser().resolve()
|
|
258
|
+
if not dir_path.exists():
|
|
259
|
+
# Create the directory
|
|
260
|
+
try:
|
|
261
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
self.app.notify(f"Created directory: {dir_path}", severity="information")
|
|
263
|
+
except OSError as e:
|
|
264
|
+
self.app.notify(f"Failed to create directory: {e}", severity="error")
|
|
265
|
+
return
|
|
266
|
+
if not dir_path.is_dir():
|
|
267
|
+
self.app.notify(f"Not a directory: {dir_path}", severity="error")
|
|
268
|
+
return
|
|
269
|
+
self.new_agent_dir = str(dir_path)
|
|
270
|
+
else:
|
|
271
|
+
# Use current working directory if none specified
|
|
272
|
+
self.new_agent_dir = str(Path.cwd())
|
|
273
|
+
|
|
274
|
+
# Derive default agent name from directory basename (#131)
|
|
275
|
+
# If an agent with that name exists, increment (foo -> foo2 -> foo3)
|
|
276
|
+
base_name = Path(self.new_agent_dir).name
|
|
277
|
+
default_name = self._get_unique_agent_name(base_name)
|
|
278
|
+
|
|
279
|
+
# Transition to name step
|
|
280
|
+
self.mode = "new_agent_name"
|
|
281
|
+
self._update_target_label()
|
|
282
|
+
|
|
283
|
+
# Pre-fill the input with the default name
|
|
284
|
+
input_widget = self.query_one("#cmd-input", Input)
|
|
285
|
+
input_widget.value = default_name
|
|
286
|
+
|
|
287
|
+
def _get_unique_agent_name(self, base_name: str) -> str:
|
|
288
|
+
"""Get a unique agent name by incrementing suffix if needed (#131).
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
base_name: The base name to start with (e.g., directory name)
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
A unique name: base_name if available, else base_name2, base_name3, etc.
|
|
295
|
+
"""
|
|
296
|
+
# Check if base name is available
|
|
297
|
+
if not self.app.session_manager.get_session_by_name(base_name):
|
|
298
|
+
return base_name
|
|
299
|
+
|
|
300
|
+
# Try incrementing suffix until we find an unused name
|
|
301
|
+
suffix = 2
|
|
302
|
+
while suffix < 100: # Reasonable limit
|
|
303
|
+
candidate = f"{base_name}{suffix}"
|
|
304
|
+
if not self.app.session_manager.get_session_by_name(candidate):
|
|
305
|
+
return candidate
|
|
306
|
+
suffix += 1
|
|
307
|
+
|
|
308
|
+
# Fallback (very unlikely to reach)
|
|
309
|
+
return f"{base_name}_{suffix}"
|
|
310
|
+
|
|
311
|
+
def _handle_new_agent_name(self, name: str) -> None:
|
|
312
|
+
"""Handle name input for new agent creation.
|
|
313
|
+
|
|
314
|
+
Stores the name and transitions to permissions step.
|
|
315
|
+
"""
|
|
316
|
+
self.new_agent_name = name
|
|
317
|
+
|
|
318
|
+
# Transition to permissions step
|
|
319
|
+
self.mode = "new_agent_perms"
|
|
320
|
+
self._update_target_label()
|
|
321
|
+
|
|
322
|
+
def _create_new_agent(self, name: str, bypass_permissions: bool = False) -> None:
|
|
323
|
+
"""Create a new agent with the given name, directory, and permission mode."""
|
|
324
|
+
self.post_message(self.NewAgentRequested(name, self.new_agent_dir, bypass_permissions))
|
|
325
|
+
# Reset state
|
|
326
|
+
self.new_agent_dir = None
|
|
327
|
+
self.new_agent_name = None
|
|
328
|
+
self.mode = "send"
|
|
329
|
+
self._update_target_label()
|
|
330
|
+
|
|
331
|
+
def _set_standing_order(self, text: str) -> None:
|
|
332
|
+
"""Set text as standing order (empty string clears orders)."""
|
|
333
|
+
if not self.target_session:
|
|
334
|
+
return
|
|
335
|
+
self.post_message(self.StandingOrderRequested(self.target_session, text.strip()))
|
|
336
|
+
|
|
337
|
+
def _set_value(self, text: str) -> None:
|
|
338
|
+
"""Set agent value (#61)."""
|
|
339
|
+
if not self.target_session:
|
|
340
|
+
return
|
|
341
|
+
try:
|
|
342
|
+
value = int(text.strip()) if text.strip() else 1000
|
|
343
|
+
if value < 0 or value > 9999:
|
|
344
|
+
self.app.notify("Value must be between 0 and 9999", severity="error")
|
|
345
|
+
return
|
|
346
|
+
self.post_message(self.ValueUpdated(self.target_session, value))
|
|
347
|
+
except ValueError:
|
|
348
|
+
# Invalid input, notify user but don't crash
|
|
349
|
+
self.app.notify("Invalid value - please enter a number", severity="error")
|
|
350
|
+
|
|
351
|
+
def _set_annotation(self, text: str) -> None:
|
|
352
|
+
"""Set human annotation (empty string clears it) (#74)."""
|
|
353
|
+
if not self.target_session:
|
|
354
|
+
return
|
|
355
|
+
self.post_message(self.AnnotationUpdated(self.target_session, text.strip()))
|
|
356
|
+
|
|
357
|
+
def action_toggle_expand(self) -> None:
|
|
358
|
+
"""Toggle between single and multi-line mode."""
|
|
359
|
+
self.expanded = not self.expanded
|
|
360
|
+
|
|
361
|
+
def action_send_multiline(self) -> None:
|
|
362
|
+
"""Send content from multi-line textarea."""
|
|
363
|
+
textarea = self.query_one("#cmd-textarea", TextArea)
|
|
364
|
+
self._send_message(textarea.text)
|
|
365
|
+
textarea.text = ""
|
|
366
|
+
self.action_clear_and_unfocus()
|
|
367
|
+
|
|
368
|
+
def action_set_standing_order(self) -> None:
|
|
369
|
+
"""Set current content as standing order."""
|
|
370
|
+
if self.expanded:
|
|
371
|
+
textarea = self.query_one("#cmd-textarea", TextArea)
|
|
372
|
+
self._set_standing_order(textarea.text)
|
|
373
|
+
textarea.text = ""
|
|
374
|
+
else:
|
|
375
|
+
input_widget = self.query_one("#cmd-input", Input)
|
|
376
|
+
self._set_standing_order(input_widget.value)
|
|
377
|
+
input_widget.value = ""
|
|
378
|
+
|
|
379
|
+
def action_clear_and_unfocus(self) -> None:
|
|
380
|
+
"""Clear input and unfocus command bar."""
|
|
381
|
+
if self.expanded:
|
|
382
|
+
textarea = self.query_one("#cmd-textarea", TextArea)
|
|
383
|
+
textarea.text = ""
|
|
384
|
+
else:
|
|
385
|
+
input_widget = self.query_one("#cmd-input", Input)
|
|
386
|
+
input_widget.value = ""
|
|
387
|
+
# Reset mode and state
|
|
388
|
+
self.mode = "send"
|
|
389
|
+
self.new_agent_dir = None
|
|
390
|
+
self.new_agent_name = None
|
|
391
|
+
self._update_target_label()
|
|
392
|
+
# Let parent handle unfocus
|
|
393
|
+
self.post_message(self.ClearRequested())
|
|
394
|
+
|
|
395
|
+
def focus_input(self) -> None:
|
|
396
|
+
"""Focus the command bar input and enable it."""
|
|
397
|
+
input_widget = self.query_one("#cmd-input", Input)
|
|
398
|
+
input_widget.disabled = False
|
|
399
|
+
input_widget.focus()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Daemon panel widget for TUI.
|
|
3
|
+
|
|
4
|
+
Inline panel showing daemon status and log viewer.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from textual.widgets import Static
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from ..monitor_daemon_state import MonitorDaemonState, get_monitor_daemon_state
|
|
14
|
+
from ..settings import get_session_dir
|
|
15
|
+
from ..tui_helpers import (
|
|
16
|
+
format_interval,
|
|
17
|
+
format_ago,
|
|
18
|
+
get_daemon_status_style,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DaemonPanel(Static):
|
|
23
|
+
"""Inline daemon panel with status and log viewer (like timeline)"""
|
|
24
|
+
|
|
25
|
+
LOG_LINES_TO_SHOW = 8 # Number of log lines to display
|
|
26
|
+
|
|
27
|
+
def __init__(self, tmux_session: str = "agents", *args, **kwargs):
|
|
28
|
+
super().__init__(*args, **kwargs)
|
|
29
|
+
self.tmux_session = tmux_session
|
|
30
|
+
self.log_lines: list[str] = []
|
|
31
|
+
self.monitor_state: Optional[MonitorDaemonState] = None
|
|
32
|
+
self._log_file_pos = 0
|
|
33
|
+
|
|
34
|
+
def on_mount(self) -> None:
|
|
35
|
+
"""Start log tailing when mounted"""
|
|
36
|
+
self.set_interval(1.0, self._refresh_logs)
|
|
37
|
+
self._refresh_logs()
|
|
38
|
+
|
|
39
|
+
def _refresh_logs(self) -> None:
|
|
40
|
+
"""Refresh daemon status and logs"""
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
# Only refresh if visible
|
|
44
|
+
if not self.display:
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
# Update daemon state from Monitor Daemon
|
|
48
|
+
self.monitor_state = get_monitor_daemon_state(self.tmux_session)
|
|
49
|
+
|
|
50
|
+
# Read log lines from session-specific monitor_daemon.log
|
|
51
|
+
session_dir = get_session_dir(self.tmux_session)
|
|
52
|
+
log_file = session_dir / "monitor_daemon.log"
|
|
53
|
+
if log_file.exists():
|
|
54
|
+
try:
|
|
55
|
+
with open(log_file, 'r') as f:
|
|
56
|
+
if not self.log_lines:
|
|
57
|
+
# First read: get last 100 lines of file
|
|
58
|
+
all_lines = f.readlines()
|
|
59
|
+
self.log_lines = [l.rstrip() for l in all_lines[-100:]]
|
|
60
|
+
self._log_file_pos = f.tell()
|
|
61
|
+
else:
|
|
62
|
+
# Subsequent reads: only get new content
|
|
63
|
+
f.seek(self._log_file_pos)
|
|
64
|
+
new_content = f.read()
|
|
65
|
+
self._log_file_pos = f.tell()
|
|
66
|
+
|
|
67
|
+
if new_content:
|
|
68
|
+
new_lines = new_content.strip().split('\n')
|
|
69
|
+
self.log_lines.extend(new_lines)
|
|
70
|
+
# Keep last 100 lines
|
|
71
|
+
self.log_lines = self.log_lines[-100:]
|
|
72
|
+
except (OSError, IOError, ValueError):
|
|
73
|
+
# Log file not available, read error, or seek error
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
self.refresh()
|
|
77
|
+
|
|
78
|
+
def render(self) -> Text:
|
|
79
|
+
"""Render daemon panel inline (similar to timeline style)"""
|
|
80
|
+
content = Text()
|
|
81
|
+
|
|
82
|
+
# Header with status - match DaemonStatusBar format exactly
|
|
83
|
+
content.append("🤖 Supervisor Daemon: ", style="bold")
|
|
84
|
+
|
|
85
|
+
# Check Monitor Daemon state
|
|
86
|
+
if self.monitor_state and not self.monitor_state.is_stale():
|
|
87
|
+
state = self.monitor_state
|
|
88
|
+
symbol, style = get_daemon_status_style(state.status)
|
|
89
|
+
|
|
90
|
+
content.append(f"{symbol} ", style=style)
|
|
91
|
+
content.append(f"{state.status}", style=style)
|
|
92
|
+
|
|
93
|
+
# State details
|
|
94
|
+
content.append(" │ ", style="dim")
|
|
95
|
+
content.append(f"#{state.loop_count}", style="cyan")
|
|
96
|
+
content.append(f" @{format_interval(state.current_interval)}", style="dim")
|
|
97
|
+
last_loop = datetime.fromisoformat(state.last_loop_time) if state.last_loop_time else None
|
|
98
|
+
content.append(f" ({format_ago(last_loop)})", style="dim")
|
|
99
|
+
if state.total_supervisions > 0:
|
|
100
|
+
content.append(f" sup:{state.total_supervisions}", style="magenta")
|
|
101
|
+
else:
|
|
102
|
+
# Monitor Daemon not running or stale
|
|
103
|
+
content.append("○ ", style="red")
|
|
104
|
+
content.append("stopped", style="red")
|
|
105
|
+
# Show last activity if available from stale state
|
|
106
|
+
if self.monitor_state and self.monitor_state.last_loop_time:
|
|
107
|
+
try:
|
|
108
|
+
last_time = datetime.fromisoformat(self.monitor_state.last_loop_time)
|
|
109
|
+
content.append(f" (last: {format_ago(last_time)})", style="dim")
|
|
110
|
+
except ValueError:
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
# Controls hint
|
|
114
|
+
content.append(" │ ", style="dim")
|
|
115
|
+
content.append("[", style="bold green")
|
|
116
|
+
content.append(":sup ", style="dim")
|
|
117
|
+
content.append("]", style="bold red")
|
|
118
|
+
content.append(":sup ", style="dim")
|
|
119
|
+
content.append("\\", style="bold yellow")
|
|
120
|
+
content.append(":mon", style="dim")
|
|
121
|
+
|
|
122
|
+
content.append("\n")
|
|
123
|
+
|
|
124
|
+
# Log lines
|
|
125
|
+
display_lines = self.log_lines[-self.LOG_LINES_TO_SHOW:] if self.log_lines else []
|
|
126
|
+
|
|
127
|
+
if not display_lines:
|
|
128
|
+
content.append(" (no logs yet - daemon may not have run)", style="dim italic")
|
|
129
|
+
content.append("\n")
|
|
130
|
+
else:
|
|
131
|
+
for line in display_lines:
|
|
132
|
+
content.append(" ", style="")
|
|
133
|
+
# Truncate line
|
|
134
|
+
display_line = line[:120] if len(line) > 120 else line
|
|
135
|
+
|
|
136
|
+
# Color based on content
|
|
137
|
+
if "ERROR" in line or "error" in line:
|
|
138
|
+
style = "red"
|
|
139
|
+
elif "WARNING" in line or "warning" in line:
|
|
140
|
+
style = "yellow"
|
|
141
|
+
elif ">>>" in line:
|
|
142
|
+
style = "bold cyan"
|
|
143
|
+
elif "supervising" in line.lower() or "steering" in line.lower():
|
|
144
|
+
style = "magenta"
|
|
145
|
+
elif "Loop" in line:
|
|
146
|
+
style = "dim cyan"
|
|
147
|
+
else:
|
|
148
|
+
style = "dim"
|
|
149
|
+
|
|
150
|
+
content.append(display_line, style=style)
|
|
151
|
+
content.append("\n")
|
|
152
|
+
|
|
153
|
+
return content
|