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,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
|
|
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
|
-
|
|
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
|
|
259
|
-
#
|
|
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
|
|
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(
|