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.
Files changed (50) hide show
  1. overcode/__init__.py +1 -1
  2. overcode/cli.py +154 -51
  3. overcode/config.py +66 -0
  4. overcode/daemon_claude_skill.md +36 -33
  5. overcode/history_reader.py +69 -8
  6. overcode/implementations.py +178 -87
  7. overcode/monitor_daemon.py +87 -97
  8. overcode/monitor_daemon_core.py +261 -0
  9. overcode/monitor_daemon_state.py +24 -15
  10. overcode/pid_utils.py +17 -3
  11. overcode/session_manager.py +54 -0
  12. overcode/settings.py +34 -0
  13. overcode/status_constants.py +1 -1
  14. overcode/status_detector.py +8 -2
  15. overcode/status_patterns.py +19 -0
  16. overcode/summarizer_client.py +72 -27
  17. overcode/summarizer_component.py +87 -107
  18. overcode/supervisor_daemon.py +55 -38
  19. overcode/supervisor_daemon_core.py +210 -0
  20. overcode/testing/__init__.py +6 -0
  21. overcode/testing/renderer.py +268 -0
  22. overcode/testing/tmux_driver.py +223 -0
  23. overcode/testing/tui_eye.py +185 -0
  24. overcode/testing/tui_eye_skill.md +187 -0
  25. overcode/tmux_manager.py +117 -93
  26. overcode/tui.py +399 -1969
  27. overcode/tui_actions/__init__.py +20 -0
  28. overcode/tui_actions/daemon.py +201 -0
  29. overcode/tui_actions/input.py +128 -0
  30. overcode/tui_actions/navigation.py +117 -0
  31. overcode/tui_actions/session.py +428 -0
  32. overcode/tui_actions/view.py +357 -0
  33. overcode/tui_helpers.py +42 -9
  34. overcode/tui_logic.py +347 -0
  35. overcode/tui_render.py +414 -0
  36. overcode/tui_widgets/__init__.py +24 -0
  37. overcode/tui_widgets/command_bar.py +399 -0
  38. overcode/tui_widgets/daemon_panel.py +153 -0
  39. overcode/tui_widgets/daemon_status_bar.py +245 -0
  40. overcode/tui_widgets/help_overlay.py +71 -0
  41. overcode/tui_widgets/preview_pane.py +69 -0
  42. overcode/tui_widgets/session_summary.py +514 -0
  43. overcode/tui_widgets/status_timeline.py +253 -0
  44. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/METADATA +4 -1
  45. overcode-0.1.4.dist-info/RECORD +68 -0
  46. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/WHEEL +1 -1
  47. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
  48. overcode-0.1.2.dist-info/RECORD +0 -45
  49. {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
  50. {overcode-0.1.2.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
@@ -18,6 +18,7 @@ from .status_constants import (
18
18
  get_presence_timeline_char as _get_presence_timeline_char,
19
19
  get_presence_color as _get_presence_color,
20
20
  get_daemon_status_style as _get_daemon_status_style,
21
+ STATUS_ASLEEP,
21
22
  STATUS_RUNNING,
22
23
  STATUS_TERMINATED,
23
24
  )
@@ -96,6 +97,30 @@ def format_tokens(tokens: int) -> str:
96
97
  return str(tokens)
97
98
 
98
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
+
99
124
  def format_line_count(count: int) -> str:
100
125
  """Format line count (insertions/deletions) to human readable (K/M).
101
126
 
@@ -221,25 +246,29 @@ def get_standing_orders_indicator(session) -> str:
221
246
  return "📋"
222
247
 
223
248
 
224
- def get_current_state_times(stats, now: Optional[datetime] = None) -> Tuple[float, float]:
225
- """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.
226
251
 
227
252
  Adds the time elapsed since the last daemon accumulation to the accumulated times.
228
253
  This provides real-time updates between daemon polling cycles.
229
254
 
230
255
  Args:
231
256
  stats: SessionStats object with green_time_seconds, non_green_time_seconds,
232
- last_time_accumulation, and current_state
257
+ sleep_time_seconds, last_time_accumulation, and current_state
233
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).
234
262
 
235
263
  Returns:
236
- Tuple of (green_time, non_green_time) in seconds
264
+ Tuple of (green_time, non_green_time, sleep_time) in seconds
237
265
  """
238
266
  if now is None:
239
267
  now = datetime.now()
240
268
 
241
269
  green_time = stats.green_time_seconds
242
270
  non_green_time = stats.non_green_time_seconds
271
+ sleep_time = getattr(stats, 'sleep_time_seconds', 0.0)
243
272
 
244
273
  # Add elapsed time since the daemon last accumulated times
245
274
  # Use last_time_accumulation (when daemon last updated), NOT state_since (when state started)
@@ -252,16 +281,20 @@ def get_current_state_times(stats, now: Optional[datetime] = None) -> Tuple[floa
252
281
 
253
282
  # Only add positive elapsed time
254
283
  if current_elapsed > 0:
255
- 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:
256
288
  green_time += current_elapsed
257
- elif stats.current_state != STATUS_TERMINATED:
258
- # Only count non-green time for non-terminated states
289
+ elif effective_state == STATUS_ASLEEP:
290
+ sleep_time += current_elapsed # Accumulate sleep time (#141)
291
+ elif effective_state != STATUS_TERMINATED:
259
292
  non_green_time += current_elapsed
260
- # else: terminated state - time is frozen, don't accumulate
293
+ # else: terminated - time is frozen, don't accumulate
261
294
  except (ValueError, AttributeError, TypeError):
262
295
  pass
263
296
 
264
- return green_time, non_green_time
297
+ return green_time, non_green_time, sleep_time
265
298
 
266
299
 
267
300
  def build_timeline_slots(