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.
Files changed (41) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/cli.py +7 -2
  3. overcode/implementations.py +74 -8
  4. overcode/monitor_daemon.py +60 -65
  5. overcode/monitor_daemon_core.py +261 -0
  6. overcode/monitor_daemon_state.py +7 -0
  7. overcode/session_manager.py +1 -0
  8. overcode/settings.py +22 -0
  9. overcode/supervisor_daemon.py +48 -47
  10. overcode/supervisor_daemon_core.py +210 -0
  11. overcode/testing/__init__.py +6 -0
  12. overcode/testing/renderer.py +268 -0
  13. overcode/testing/tmux_driver.py +223 -0
  14. overcode/testing/tui_eye.py +185 -0
  15. overcode/testing/tui_eye_skill.md +187 -0
  16. overcode/tmux_manager.py +17 -3
  17. overcode/tui.py +196 -2462
  18. overcode/tui_actions/__init__.py +20 -0
  19. overcode/tui_actions/daemon.py +201 -0
  20. overcode/tui_actions/input.py +128 -0
  21. overcode/tui_actions/navigation.py +117 -0
  22. overcode/tui_actions/session.py +428 -0
  23. overcode/tui_actions/view.py +357 -0
  24. overcode/tui_helpers.py +41 -9
  25. overcode/tui_logic.py +347 -0
  26. overcode/tui_render.py +414 -0
  27. overcode/tui_widgets/__init__.py +24 -0
  28. overcode/tui_widgets/command_bar.py +399 -0
  29. overcode/tui_widgets/daemon_panel.py +153 -0
  30. overcode/tui_widgets/daemon_status_bar.py +245 -0
  31. overcode/tui_widgets/help_overlay.py +71 -0
  32. overcode/tui_widgets/preview_pane.py +69 -0
  33. overcode/tui_widgets/session_summary.py +514 -0
  34. overcode/tui_widgets/status_timeline.py +253 -0
  35. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/METADATA +3 -1
  36. overcode-0.1.4.dist-info/RECORD +68 -0
  37. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
  38. overcode-0.1.3.dist-info/RECORD +0 -45
  39. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/WHEEL +0 -0
  40. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
  41. {overcode-0.1.3.dist-info → overcode-0.1.4.dist-info}/top_level.txt +0 -0
overcode/tui_logic.py ADDED
@@ -0,0 +1,347 @@
1
+ """
2
+ Pure business logic functions for TUI components.
3
+
4
+ These functions are extracted from the TUI to enable unit testing
5
+ without requiring the full Textual framework or actual session objects.
6
+
7
+ All functions are pure - they take data as input and return new data.
8
+ No side effects, no mutations of input data.
9
+ """
10
+
11
+ from datetime import datetime, timedelta
12
+ from typing import List, Set, Optional, TypeVar, Protocol, Tuple
13
+ from dataclasses import dataclass
14
+
15
+
16
+ class SessionLike(Protocol):
17
+ """Protocol for session-like objects used in sorting/filtering."""
18
+ @property
19
+ def name(self) -> str: ...
20
+ @property
21
+ def id(self) -> str: ...
22
+ @property
23
+ def is_asleep(self) -> bool: ...
24
+
25
+
26
+ class SessionWithStats(SessionLike, Protocol):
27
+ """Protocol for sessions with stats for sorting."""
28
+ @property
29
+ def stats(self) -> "StatsLike": ...
30
+ @property
31
+ def agent_value(self) -> float: ...
32
+
33
+
34
+ class StatsLike(Protocol):
35
+ """Protocol for stats-like objects."""
36
+ @property
37
+ def current_state(self) -> Optional[str]: ...
38
+
39
+
40
+ T = TypeVar('T', bound=SessionLike)
41
+ S = TypeVar('S', bound=SessionWithStats)
42
+
43
+
44
+ # Status priority orders for sorting
45
+ STATUS_ORDER_BY_ATTENTION = {
46
+ "waiting_user": 0,
47
+ "waiting_supervisor": 1,
48
+ "no_instructions": 2,
49
+ "error": 3,
50
+ "running": 4,
51
+ "terminated": 5,
52
+ "asleep": 6,
53
+ }
54
+
55
+ STATUS_ORDER_BY_VALUE = {
56
+ "waiting_user": 0,
57
+ "waiting_supervisor": 0,
58
+ "no_instructions": 0,
59
+ "error": 0,
60
+ "running": 1,
61
+ "terminated": 2,
62
+ "asleep": 2,
63
+ }
64
+
65
+
66
+ def sort_sessions_alphabetical(sessions: List[T]) -> List[T]:
67
+ """Sort sessions alphabetically by name (case-insensitive).
68
+
69
+ Args:
70
+ sessions: List of session objects with a name attribute
71
+
72
+ Returns:
73
+ New sorted list (does not mutate input)
74
+ """
75
+ return sorted(sessions, key=lambda s: s.name.lower())
76
+
77
+
78
+ def sort_sessions_by_status(sessions: List[S]) -> List[S]:
79
+ """Sort sessions by status priority, then alphabetically.
80
+
81
+ Priority order: waiting_user, waiting_supervisor, no_instructions,
82
+ error, running, terminated, asleep.
83
+
84
+ Args:
85
+ sessions: List of session objects with stats.current_state
86
+
87
+ Returns:
88
+ New sorted list (does not mutate input)
89
+ """
90
+ return sorted(
91
+ sessions,
92
+ key=lambda s: (
93
+ STATUS_ORDER_BY_ATTENTION.get(s.stats.current_state or "running", 4),
94
+ s.name.lower()
95
+ )
96
+ )
97
+
98
+
99
+ def sort_sessions_by_value(sessions: List[S]) -> List[S]:
100
+ """Sort sessions by value (priority) descending, then alphabetically.
101
+
102
+ Non-green agents (needing attention) sort first, then by agent_value
103
+ descending within each group.
104
+
105
+ Args:
106
+ sessions: List of session objects with stats.current_state and agent_value
107
+
108
+ Returns:
109
+ New sorted list (does not mutate input)
110
+ """
111
+ return sorted(
112
+ sessions,
113
+ key=lambda s: (
114
+ STATUS_ORDER_BY_VALUE.get(s.stats.current_state or "running", 1),
115
+ -s.agent_value,
116
+ s.name.lower()
117
+ )
118
+ )
119
+
120
+
121
+ def sort_sessions(sessions: List[S], mode: str) -> List[S]:
122
+ """Sort sessions based on the specified mode.
123
+
124
+ Args:
125
+ sessions: List of session objects
126
+ mode: One of "alphabetical", "by_status", "by_value"
127
+
128
+ Returns:
129
+ New sorted list (does not mutate input)
130
+ """
131
+ if mode == "alphabetical":
132
+ return sort_sessions_alphabetical(sessions)
133
+ elif mode == "by_status":
134
+ return sort_sessions_by_status(sessions)
135
+ elif mode == "by_value":
136
+ return sort_sessions_by_value(sessions)
137
+ else:
138
+ # Default to alphabetical for unknown modes
139
+ return sort_sessions_alphabetical(sessions)
140
+
141
+
142
+ def filter_visible_sessions(
143
+ active_sessions: List[T],
144
+ terminated_sessions: List[T],
145
+ hide_asleep: bool,
146
+ show_terminated: bool,
147
+ ) -> List[T]:
148
+ """Filter sessions based on visibility preferences.
149
+
150
+ Args:
151
+ active_sessions: List of currently active sessions
152
+ terminated_sessions: List of terminated/killed sessions
153
+ hide_asleep: If True, filter out sleeping agents
154
+ show_terminated: If True, include terminated sessions
155
+
156
+ Returns:
157
+ New filtered list (does not mutate inputs)
158
+ """
159
+ result = list(active_sessions)
160
+
161
+ # Filter out sleeping agents if requested
162
+ if hide_asleep:
163
+ result = [s for s in result if not s.is_asleep]
164
+
165
+ # Include terminated sessions if requested
166
+ if show_terminated:
167
+ active_ids = {s.id for s in active_sessions}
168
+ for session in terminated_sessions:
169
+ if session.id not in active_ids:
170
+ result.append(session)
171
+
172
+ return result
173
+
174
+
175
+ def get_sort_mode_display_name(mode: str) -> str:
176
+ """Get human-readable display name for sort mode.
177
+
178
+ Args:
179
+ mode: Sort mode identifier
180
+
181
+ Returns:
182
+ Human-readable name
183
+ """
184
+ mode_names = {
185
+ "alphabetical": "Alphabetical",
186
+ "by_status": "By Status",
187
+ "by_value": "By Value (priority)",
188
+ }
189
+ return mode_names.get(mode, mode)
190
+
191
+
192
+ def cycle_sort_mode(current_mode: str, available_modes: List[str]) -> str:
193
+ """Get the next sort mode in the cycle.
194
+
195
+ Args:
196
+ current_mode: Current sort mode
197
+ available_modes: List of available sort modes
198
+
199
+ Returns:
200
+ Next sort mode in the cycle
201
+ """
202
+ if not available_modes:
203
+ return current_mode
204
+
205
+ try:
206
+ current_idx = available_modes.index(current_mode)
207
+ except ValueError:
208
+ current_idx = -1
209
+
210
+ new_idx = (current_idx + 1) % len(available_modes)
211
+ return available_modes[new_idx]
212
+
213
+
214
+ @dataclass
215
+ class SpinStats:
216
+ """Statistics for spin rate display."""
217
+ green_count: int
218
+ total_count: int
219
+ sleeping_count: int
220
+ mean_spin: float
221
+ total_tokens: int
222
+
223
+
224
+ def calculate_spin_stats(
225
+ sessions: List,
226
+ asleep_session_ids: Set[str],
227
+ ) -> SpinStats:
228
+ """Calculate spin rate statistics from sessions.
229
+
230
+ Args:
231
+ sessions: List of session daemon states with green_time_seconds,
232
+ non_green_time_seconds, current_status, input_tokens, output_tokens
233
+ asleep_session_ids: Set of session IDs that are asleep
234
+
235
+ Returns:
236
+ SpinStats dataclass with calculated values
237
+ """
238
+ # Filter out sleeping agents for active stats
239
+ active_sessions = [s for s in sessions if s.session_id not in asleep_session_ids]
240
+ sleeping_count = len(sessions) - len(active_sessions)
241
+
242
+ total_count = len(active_sessions)
243
+ green_count = sum(1 for s in active_sessions if s.current_status == "running")
244
+
245
+ # Calculate mean spin rate
246
+ mean_spin = 0.0
247
+ for s in active_sessions:
248
+ total_time = s.green_time_seconds + s.non_green_time_seconds
249
+ if total_time > 0:
250
+ mean_spin += s.green_time_seconds / total_time
251
+
252
+ # Total tokens (include sleeping agents)
253
+ total_tokens = sum(s.input_tokens + s.output_tokens for s in sessions)
254
+
255
+ return SpinStats(
256
+ green_count=green_count,
257
+ total_count=total_count,
258
+ sleeping_count=sleeping_count,
259
+ mean_spin=mean_spin,
260
+ total_tokens=total_tokens,
261
+ )
262
+
263
+
264
+ def calculate_mean_spin_from_history(
265
+ history: List[Tuple[datetime, str, str, str]],
266
+ agent_names: List[str],
267
+ baseline_minutes: int,
268
+ now: Optional[datetime] = None,
269
+ ) -> Tuple[float, int]:
270
+ """Calculate mean spin rate from CSV history within a time window.
271
+
272
+ This provides a time-windowed average of how many agents were running,
273
+ as opposed to the cumulative calculation in calculate_spin_stats().
274
+
275
+ Args:
276
+ history: List of (timestamp, agent, status, activity) tuples from CSV
277
+ agent_names: List of active (non-sleeping) agent names to include
278
+ baseline_minutes: Minutes back from now (0 = instantaneous, not used)
279
+ now: Reference time (defaults to datetime.now())
280
+
281
+ Returns:
282
+ Tuple of (mean_spin, sample_count) where:
283
+ - mean_spin: Average number of agents in "running" state during window
284
+ - sample_count: Total samples in the window (0 if no data)
285
+ """
286
+ if now is None:
287
+ now = datetime.now()
288
+
289
+ if baseline_minutes <= 0 or not agent_names:
290
+ return (0.0, 0)
291
+
292
+ cutoff = now - timedelta(minutes=baseline_minutes)
293
+
294
+ # Filter to window and active agents only
295
+ window_history = [
296
+ (ts, agent, status)
297
+ for ts, agent, status, _ in history
298
+ if cutoff <= ts <= now and agent in agent_names
299
+ ]
300
+
301
+ if not window_history:
302
+ return (0.0, 0)
303
+
304
+ running_count = sum(1 for _, _, status in window_history if status == "running")
305
+ total_count = len(window_history)
306
+
307
+ # mean_spin = (fraction of samples that were "running") * num_agents
308
+ # This gives "average number of agents running at any point in time"
309
+ # Example: 2 agents, 50% of samples are "running" -> mean_spin = 1.0
310
+ num_agents = len(agent_names)
311
+ mean_spin = (running_count / total_count) * num_agents if total_count > 0 else 0.0
312
+
313
+ return (mean_spin, total_count)
314
+
315
+
316
+ def calculate_green_percentage(green_time: float, non_green_time: float) -> float:
317
+ """Calculate the percentage of time spent in green (running) state.
318
+
319
+ Args:
320
+ green_time: Total green time in seconds
321
+ non_green_time: Total non-green time in seconds
322
+
323
+ Returns:
324
+ Percentage (0-100) of time in green state
325
+ """
326
+ total_time = green_time + non_green_time
327
+ if total_time <= 0:
328
+ return 0.0
329
+ return green_time / total_time * 100
330
+
331
+
332
+ def calculate_human_interaction_count(
333
+ total_interactions: Optional[int],
334
+ robot_interactions: int,
335
+ ) -> int:
336
+ """Calculate number of human interactions.
337
+
338
+ Args:
339
+ total_interactions: Total interaction count (or None)
340
+ robot_interactions: Number of robot/supervisor interactions
341
+
342
+ Returns:
343
+ Number of human interactions (clamped to 0 minimum)
344
+ """
345
+ if total_interactions is None:
346
+ return 0
347
+ return max(0, total_interactions - robot_interactions)