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
@@ -0,0 +1,357 @@
1
+ """
2
+ View action methods for TUI.
3
+
4
+ Handles display settings, toggles, and visual modes.
5
+ """
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from textual.css.query import NoMatches
10
+
11
+ if TYPE_CHECKING:
12
+ from ..tui_widgets import SessionSummary, StatusTimeline, HelpOverlay
13
+
14
+
15
+ class ViewActionsMixin:
16
+ """Mixin providing view/display actions for SupervisorTUI."""
17
+
18
+ def action_toggle_timeline(self) -> None:
19
+ """Toggle timeline visibility."""
20
+ from ..tui_widgets import StatusTimeline
21
+ try:
22
+ timeline = self.query_one("#timeline", StatusTimeline)
23
+ timeline.display = not timeline.display
24
+ self._prefs.timeline_visible = timeline.display
25
+ self._save_prefs()
26
+ state = "shown" if timeline.display else "hidden"
27
+ self.notify(f"Timeline {state}", severity="information")
28
+ except NoMatches:
29
+ pass
30
+
31
+ def action_toggle_help(self) -> None:
32
+ """Toggle help overlay visibility."""
33
+ from ..tui_widgets import HelpOverlay
34
+ try:
35
+ help_overlay = self.query_one("#help-overlay", HelpOverlay)
36
+ if help_overlay.has_class("visible"):
37
+ help_overlay.remove_class("visible")
38
+ else:
39
+ help_overlay.add_class("visible")
40
+ except NoMatches:
41
+ pass
42
+
43
+ def action_manual_refresh(self) -> None:
44
+ """Manually trigger a full refresh (useful in diagnostics mode)."""
45
+ self.refresh_sessions()
46
+ self.update_all_statuses()
47
+ self.update_daemon_status()
48
+ self.update_timeline()
49
+ self.notify("Refreshed", severity="information", timeout=2)
50
+
51
+ def action_toggle_expand_all(self) -> None:
52
+ """Toggle expand/collapse all sessions."""
53
+ from ..tui_widgets import SessionSummary
54
+ widgets = list(self.query(SessionSummary))
55
+ if not widgets:
56
+ return
57
+ # If any are expanded, collapse all; otherwise expand all
58
+ any_expanded = any(w.expanded for w in widgets)
59
+ new_state = not any_expanded
60
+ for widget in widgets:
61
+ widget.expanded = new_state
62
+ self.expanded_states[widget.session.id] = new_state
63
+
64
+ def action_cycle_detail(self) -> None:
65
+ """Cycle through detail levels (5, 10, 20, 50 lines)."""
66
+ from ..tui_widgets import SessionSummary
67
+ self.detail_level_index = (self.detail_level_index + 1) % len(self.DETAIL_LEVELS)
68
+ new_level = self.DETAIL_LEVELS[self.detail_level_index]
69
+
70
+ # Update all session widgets
71
+ for widget in self.query(SessionSummary):
72
+ widget.detail_lines = new_level
73
+
74
+ # Save preference
75
+ self._prefs.detail_lines = new_level
76
+ self._save_prefs()
77
+
78
+ self.notify(f"Detail: {new_level} lines", severity="information")
79
+
80
+ def action_cycle_summary(self) -> None:
81
+ """Cycle through summary detail levels (low, med, full)."""
82
+ from ..tui_widgets import SessionSummary
83
+ self.summary_level_index = (self.summary_level_index + 1) % len(self.SUMMARY_LEVELS)
84
+ new_level = self.SUMMARY_LEVELS[self.summary_level_index]
85
+
86
+ # Update all session widgets
87
+ for widget in self.query(SessionSummary):
88
+ widget.summary_detail = new_level
89
+
90
+ # Save preference
91
+ self._prefs.summary_detail = new_level
92
+ self._save_prefs()
93
+
94
+ self.notify(f"Summary: {new_level}", severity="information")
95
+
96
+ def action_cycle_summary_content(self) -> None:
97
+ """Cycle through summary content modes (ai_short, ai_long, orders, annotation) (#74)."""
98
+ from ..tui_widgets import SessionSummary
99
+ modes = self.SUMMARY_CONTENT_MODES
100
+ current_idx = modes.index(self.summary_content_mode) if self.summary_content_mode in modes else 0
101
+ new_idx = (current_idx + 1) % len(modes)
102
+ self.summary_content_mode = modes[new_idx]
103
+
104
+ # Save preference (#98)
105
+ self._prefs.summary_content_mode = self.summary_content_mode
106
+ self._save_prefs()
107
+
108
+ # Update all session widgets
109
+ for widget in self.query(SessionSummary):
110
+ widget.summary_content_mode = self.summary_content_mode
111
+
112
+ mode_names = {
113
+ "ai_short": "AI Summary (short)",
114
+ "ai_long": "AI Summary (context)",
115
+ "orders": "Standing Orders",
116
+ "annotation": "Human Annotation",
117
+ }
118
+ self.notify(f"{mode_names.get(self.summary_content_mode, self.summary_content_mode)}", severity="information")
119
+
120
+ def action_toggle_view_mode(self) -> None:
121
+ """Toggle between tree and list+preview view modes."""
122
+ if self.view_mode == "tree":
123
+ self.view_mode = "list_preview"
124
+ else:
125
+ self.view_mode = "tree"
126
+
127
+ # Save preference
128
+ self._prefs.view_mode = self.view_mode
129
+ self._save_prefs()
130
+
131
+ def action_toggle_tmux_sync(self) -> None:
132
+ """Toggle tmux pane sync - syncs navigation to external tmux pane."""
133
+ self.tmux_sync = not self.tmux_sync
134
+
135
+ # Save preference
136
+ self._prefs.tmux_sync = self.tmux_sync
137
+ self._save_prefs()
138
+
139
+ # Update subtitle to show sync state
140
+ self._update_subtitle()
141
+
142
+ # If enabling, sync to currently focused session immediately
143
+ if self.tmux_sync:
144
+ self._sync_tmux_window()
145
+
146
+ def action_toggle_show_terminated(self) -> None:
147
+ """Toggle showing killed/terminated sessions in the timeline."""
148
+ self.show_terminated = not self.show_terminated
149
+
150
+ # Save preference
151
+ self._prefs.show_terminated = self.show_terminated
152
+ self._save_prefs()
153
+
154
+ # Refresh session widgets to show/hide terminated sessions
155
+ self.update_session_widgets()
156
+
157
+ # Notify user
158
+ status = "visible" if self.show_terminated else "hidden"
159
+ count = len(self._terminated_sessions)
160
+ if count > 0:
161
+ self.notify(f"Killed sessions: {status} ({count})", severity="information")
162
+ else:
163
+ self.notify(f"Killed sessions: {status}", severity="information")
164
+
165
+ def action_toggle_hide_asleep(self) -> None:
166
+ """Toggle hiding sleeping agents from display."""
167
+ self.hide_asleep = not self.hide_asleep
168
+
169
+ # Save preference
170
+ self._prefs.hide_asleep = self.hide_asleep
171
+ self._save_prefs()
172
+
173
+ # Update subtitle to show state
174
+ self._update_subtitle()
175
+
176
+ # Refresh session widgets to show/hide sleeping agents
177
+ self.update_session_widgets()
178
+
179
+ # Count sleeping agents
180
+ asleep_count = sum(1 for s in self.sessions if s.is_asleep)
181
+ if self.hide_asleep:
182
+ self.notify(f"Sleeping agents hidden ({asleep_count})", severity="information")
183
+ else:
184
+ self.notify(f"Sleeping agents visible ({asleep_count})", severity="information")
185
+
186
+ def action_cycle_sort_mode(self) -> None:
187
+ """Cycle through sort modes (#61)."""
188
+ from ..tui_logic import cycle_sort_mode, get_sort_mode_display_name
189
+
190
+ # Remember the currently focused session before sorting
191
+ widgets = self._get_widgets_in_session_order()
192
+ focused_session_id = None
193
+ if widgets and 0 <= self.focused_session_index < len(widgets):
194
+ focused_session_id = widgets[self.focused_session_index].session.id
195
+
196
+ # Use extracted logic for cycling
197
+ self._prefs.sort_mode = cycle_sort_mode(self._prefs.sort_mode, self.SORT_MODES)
198
+ self._save_prefs()
199
+
200
+ # Re-sort and refresh
201
+ self._sort_sessions()
202
+ self.update_session_widgets()
203
+ self._update_subtitle()
204
+
205
+ # Update focused_session_index to follow the same session at its new position
206
+ if focused_session_id:
207
+ widgets = self._get_widgets_in_session_order()
208
+ for i, widget in enumerate(widgets):
209
+ if widget.session.id == focused_session_id:
210
+ self.focused_session_index = i
211
+ break
212
+
213
+ self.notify(f"Sort: {get_sort_mode_display_name(self._prefs.sort_mode)}", severity="information")
214
+
215
+ def action_toggle_copy_mode(self) -> None:
216
+ """Toggle mouse capture to allow native terminal text selection.
217
+
218
+ When copy mode is ON:
219
+ - Mouse events pass through to terminal
220
+ - You can select text and Cmd+C to copy
221
+ - Press 'y' again to exit copy mode
222
+ """
223
+ if not hasattr(self, '_copy_mode'):
224
+ self._copy_mode = False
225
+
226
+ self._copy_mode = not self._copy_mode
227
+
228
+ if self._copy_mode:
229
+ # Write escape sequences directly to the driver's file (stderr)
230
+ driver_file = self._driver._file
231
+
232
+ # Disable all mouse tracking modes
233
+ driver_file.write("\x1b[?1000l") # Disable basic mouse tracking
234
+ driver_file.write("\x1b[?1002l") # Disable cell motion tracking
235
+ driver_file.write("\x1b[?1003l") # Disable all motion tracking
236
+ driver_file.write("\x1b[?1015l") # Disable urxvt extended mode
237
+ driver_file.write("\x1b[?1006l") # Disable SGR extended mode
238
+ driver_file.flush()
239
+
240
+ self.notify("COPY MODE - select with mouse, Cmd+C to copy, 'y' to exit", severity="warning")
241
+ else:
242
+ # Re-enable mouse support using driver's method
243
+ self._driver._mouse = True
244
+ self._driver._enable_mouse_support()
245
+ self.refresh()
246
+ self.notify("Copy mode OFF", severity="information")
247
+
248
+ def action_baseline_back(self) -> None:
249
+ """Move baseline back by 15 minutes (max 180 = 3 hours)."""
250
+ new_baseline = min(self.baseline_minutes + 15, 180)
251
+ self.baseline_minutes = new_baseline
252
+ self._prefs.baseline_minutes = new_baseline
253
+ self._save_prefs()
254
+ self._notify_baseline_change()
255
+
256
+ def action_baseline_forward(self) -> None:
257
+ """Move baseline forward by 15 minutes (min 0 = now)."""
258
+ new_baseline = max(self.baseline_minutes - 15, 0)
259
+ self.baseline_minutes = new_baseline
260
+ self._prefs.baseline_minutes = new_baseline
261
+ self._save_prefs()
262
+ self._notify_baseline_change()
263
+
264
+ def action_baseline_reset(self) -> None:
265
+ """Reset baseline to now (instantaneous)."""
266
+ self.baseline_minutes = 0
267
+ self._prefs.baseline_minutes = 0
268
+ self._save_prefs()
269
+ self._notify_baseline_change()
270
+
271
+ def _notify_baseline_change(self) -> None:
272
+ """Notify user and trigger UI updates after baseline change."""
273
+ if self.baseline_minutes == 0:
274
+ label = "now"
275
+ elif self.baseline_minutes < 60:
276
+ label = f"-{self.baseline_minutes}m"
277
+ else:
278
+ hours = self.baseline_minutes // 60
279
+ mins = self.baseline_minutes % 60
280
+ if mins == 0:
281
+ label = f"-{hours}h"
282
+ else:
283
+ label = f"-{hours}h{mins}m"
284
+ self.notify(f"Baseline: {label}", severity="information")
285
+ # Trigger status bar refresh to show updated mean spin
286
+ self.update_daemon_status()
287
+ # Trigger timeline refresh to show baseline marker
288
+ self.update_timeline()
289
+
290
+ def action_toggle_monochrome(self) -> None:
291
+ """Toggle monochrome (B&W) mode for terminals with ANSI issues (#138).
292
+
293
+ When enabled:
294
+ - Strips ANSI color codes from preview pane content
295
+ - Uses plain text rendering for terminal output
296
+ - Helps with terminals that garble ANSI color codes
297
+ """
298
+ from ..tui_widgets import PreviewPane
299
+
300
+ self.monochrome = not self.monochrome
301
+
302
+ # Save preference
303
+ self._prefs.monochrome = self.monochrome
304
+ self._save_prefs()
305
+
306
+ # Toggle CSS class on the app for any CSS-based styling changes
307
+ if self.monochrome:
308
+ self.add_class("monochrome")
309
+ else:
310
+ self.remove_class("monochrome")
311
+
312
+ # Update preview pane to use monochrome rendering
313
+ try:
314
+ preview = self.query_one("#preview-pane", PreviewPane)
315
+ preview.monochrome = self.monochrome
316
+ preview.refresh()
317
+ except NoMatches:
318
+ pass
319
+
320
+ self.notify(
321
+ "Monochrome mode ON" if self.monochrome else "Monochrome mode OFF",
322
+ severity="information"
323
+ )
324
+
325
+ def action_toggle_cost_display(self) -> None:
326
+ """Toggle between showing token counts and dollar costs.
327
+
328
+ When enabled:
329
+ - Shows estimated cost in USD instead of token counts
330
+ - Format: $X.XX for small amounts, $X.XK/$X.XM for large
331
+ - Uses Sonnet 3.5 pricing model
332
+ """
333
+ from ..tui_widgets import SessionSummary, DaemonStatusBar
334
+
335
+ self.show_cost = not self.show_cost
336
+
337
+ # Save preference
338
+ self._prefs.show_cost = self.show_cost
339
+ self._save_prefs()
340
+
341
+ # Update all session widgets
342
+ for widget in self.query(SessionSummary):
343
+ widget.show_cost = self.show_cost
344
+ widget.refresh()
345
+
346
+ # Update daemon status bar
347
+ try:
348
+ status_bar = self.query_one(DaemonStatusBar)
349
+ status_bar.show_cost = self.show_cost
350
+ status_bar.refresh()
351
+ except NoMatches:
352
+ pass
353
+
354
+ self.notify(
355
+ "Showing $ cost" if self.show_cost else "Showing tokens",
356
+ severity="information"
357
+ )
overcode/tui_helpers.py CHANGED
@@ -97,6 +97,30 @@ def format_tokens(tokens: int) -> str:
97
97
  return str(tokens)
98
98
 
99
99
 
100
+ def format_cost(cost_usd: float) -> str:
101
+ """Format cost in USD to human readable with stable width.
102
+
103
+ Uses 1 decimal place and K/M suffixes for large amounts.
104
+ Prefixed with $ symbol.
105
+
106
+ Args:
107
+ cost_usd: Cost in US dollars
108
+
109
+ Returns:
110
+ Formatted string like "$0.1", "$12.3", "$1.2K", "$3.5M"
111
+ """
112
+ if cost_usd >= 1_000_000:
113
+ return f"${cost_usd / 1_000_000:.1f}M"
114
+ elif cost_usd >= 1_000:
115
+ return f"${cost_usd / 1_000:.1f}K"
116
+ elif cost_usd >= 100:
117
+ return f"${cost_usd:.0f}"
118
+ elif cost_usd >= 10:
119
+ return f"${cost_usd:.1f}"
120
+ else:
121
+ return f"${cost_usd:.2f}"
122
+
123
+
100
124
  def format_line_count(count: int) -> str:
101
125
  """Format line count (insertions/deletions) to human readable (K/M).
102
126
 
@@ -222,25 +246,29 @@ def get_standing_orders_indicator(session) -> str:
222
246
  return "📋"
223
247
 
224
248
 
225
- def get_current_state_times(stats, now: Optional[datetime] = None) -> Tuple[float, float]:
226
- """Get current green and non-green times including ongoing state.
249
+ def get_current_state_times(stats, now: Optional[datetime] = None, is_asleep: bool = False) -> Tuple[float, float, float]:
250
+ """Get current green, non-green, and sleep times including ongoing state.
227
251
 
228
252
  Adds the time elapsed since the last daemon accumulation to the accumulated times.
229
253
  This provides real-time updates between daemon polling cycles.
230
254
 
231
255
  Args:
232
256
  stats: SessionStats object with green_time_seconds, non_green_time_seconds,
233
- last_time_accumulation, and current_state
257
+ sleep_time_seconds, last_time_accumulation, and current_state
234
258
  now: Reference time (defaults to datetime.now())
259
+ is_asleep: If True, treat session as asleep regardless of stats.current_state.
260
+ This handles the case where user toggles sleep but daemon hasn't
261
+ updated stats.current_state yet (#141).
235
262
 
236
263
  Returns:
237
- Tuple of (green_time, non_green_time) in seconds
264
+ Tuple of (green_time, non_green_time, sleep_time) in seconds
238
265
  """
239
266
  if now is None:
240
267
  now = datetime.now()
241
268
 
242
269
  green_time = stats.green_time_seconds
243
270
  non_green_time = stats.non_green_time_seconds
271
+ sleep_time = getattr(stats, 'sleep_time_seconds', 0.0)
244
272
 
245
273
  # Add elapsed time since the daemon last accumulated times
246
274
  # Use last_time_accumulation (when daemon last updated), NOT state_since (when state started)
@@ -253,16 +281,20 @@ def get_current_state_times(stats, now: Optional[datetime] = None) -> Tuple[floa
253
281
 
254
282
  # Only add positive elapsed time
255
283
  if current_elapsed > 0:
256
- if stats.current_state == STATUS_RUNNING:
284
+ # Use is_asleep parameter to override stats.current_state when user
285
+ # has toggled sleep but daemon hasn't updated yet (#141)
286
+ effective_state = STATUS_ASLEEP if is_asleep else stats.current_state
287
+ if effective_state == STATUS_RUNNING:
257
288
  green_time += current_elapsed
258
- elif stats.current_state not in (STATUS_TERMINATED, STATUS_ASLEEP):
259
- # Only count non-green time for non-terminated/non-asleep states (#68)
289
+ elif effective_state == STATUS_ASLEEP:
290
+ sleep_time += current_elapsed # Accumulate sleep time (#141)
291
+ elif effective_state != STATUS_TERMINATED:
260
292
  non_green_time += current_elapsed
261
- # else: terminated or asleep - time is frozen, don't accumulate
293
+ # else: terminated - time is frozen, don't accumulate
262
294
  except (ValueError, AttributeError, TypeError):
263
295
  pass
264
296
 
265
- return green_time, non_green_time
297
+ return green_time, non_green_time, sleep_time
266
298
 
267
299
 
268
300
  def build_timeline_slots(