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.
- overcode/__init__.py +1 -1
- overcode/cli.py +154 -51
- overcode/config.py +66 -0
- overcode/daemon_claude_skill.md +36 -33
- overcode/history_reader.py +69 -8
- overcode/implementations.py +178 -87
- overcode/monitor_daemon.py +87 -97
- overcode/monitor_daemon_core.py +261 -0
- overcode/monitor_daemon_state.py +24 -15
- overcode/pid_utils.py +17 -3
- overcode/session_manager.py +54 -0
- overcode/settings.py +34 -0
- overcode/status_constants.py +1 -1
- overcode/status_detector.py +8 -2
- overcode/status_patterns.py +19 -0
- overcode/summarizer_client.py +72 -27
- overcode/summarizer_component.py +87 -107
- overcode/supervisor_daemon.py +55 -38
- 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 +117 -93
- overcode/tui.py +399 -1969
- 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 +42 -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.2.dist-info → overcode-0.1.4.dist-info}/METADATA +4 -1
- overcode-0.1.4.dist-info/RECORD +68 -0
- {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/WHEEL +1 -1
- {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/entry_points.txt +1 -0
- overcode-0.1.2.dist-info/RECORD +0 -45
- {overcode-0.1.2.dist-info → overcode-0.1.4.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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
|
-
|
|
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
|
|
258
|
-
#
|
|
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
|
|
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(
|