cctally 1.7.0 → 1.7.2
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.
- package/CHANGELOG.md +27 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5403 -0
- package/bin/_cctally_db.py +1837 -0
- package/bin/_cctally_record.py +2305 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4487 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +441 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +137 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11694 -35448
- package/package.json +24 -1
|
@@ -0,0 +1,4487 @@
|
|
|
1
|
+
"""TUI subsystem for cctally (live terminal dashboard).
|
|
2
|
+
|
|
3
|
+
Eager I/O sibling: bin/cctally loads this at startup. Owns the entire
|
|
4
|
+
``cctally tui`` user-facing surface plus the shared dataclasses used
|
|
5
|
+
by both the TUI and the dashboard (Phase F #22 deferred these to this
|
|
6
|
+
extraction so the dashboard's existing 5 dataclass shims can resolve
|
|
7
|
+
through cctally's re-exported namespace transparently):
|
|
8
|
+
|
|
9
|
+
- ``cmd_tui`` — ``cctally tui`` entry point. Lazy-imports ``rich``
|
|
10
|
+
inside the function body (CLAUDE.md TUI gotcha: keeps the rest of
|
|
11
|
+
the script zero-dep). Resolves ``--variant`` (2x2 grid vs
|
|
12
|
+
expressive hero), the sync/refresh interval pair, ``--render-once``
|
|
13
|
+
/ ``--snapshot-module`` fixture path, the alternate-screen / cursor
|
|
14
|
+
/ SIGWINCH lifecycle, and the main render loop.
|
|
15
|
+
- ``TuiKeyReader`` — raw-mode keyboard reader. Uses
|
|
16
|
+
``termios.tcgetattr`` / ``setcbreak`` + ``select.select`` to read
|
|
17
|
+
one keystroke at a time without blocking the render loop.
|
|
18
|
+
Re-installs the saved tty mode on ``__exit__`` even when the loop
|
|
19
|
+
raises.
|
|
20
|
+
- ``_TuiSyncThread`` — periodic snapshot-rebuilder. Shared base
|
|
21
|
+
class subclassed inline by the dashboard's
|
|
22
|
+
``_DashboardSyncThread`` (in ``bin/_cctally_dashboard.py`` via
|
|
23
|
+
``c._TuiSyncThread`` resolution at class-definition time). Owns
|
|
24
|
+
the sync-interval cadence + the ``request_sync()`` / monotonic
|
|
25
|
+
budget loop.
|
|
26
|
+
- ``_tui_handle_key`` — central keymap dispatcher. Routes single
|
|
27
|
+
keystrokes through the filter / search input mode, the modal
|
|
28
|
+
open/close lifecycle, and the global hotkeys (panel switching,
|
|
29
|
+
sort cycling, help, refresh, quit). Honors
|
|
30
|
+
CLAUDE.md "Global hotkeys need modal guard" — every global
|
|
31
|
+
binding gates on ``openModal is None``.
|
|
32
|
+
- ``_tui_build_*`` snapshot builder family —
|
|
33
|
+
``_tui_build_current_week``, ``_tui_build_forecast``,
|
|
34
|
+
``_tui_build_trend``, ``_tui_build_weekly_history``,
|
|
35
|
+
``_tui_build_sessions``, ``_tui_build_session_detail``,
|
|
36
|
+
``_tui_build_percent_milestones``, ``_tui_build_snapshot`` —
|
|
37
|
+
read from SQLite + the cache DB and produce one immutable
|
|
38
|
+
``DataSnapshot``. ``_tui_build_snapshot`` is the orchestrator;
|
|
39
|
+
the rest are per-panel builders the dashboard's sync thread
|
|
40
|
+
also calls (re-exported through cctally so the dashboard's
|
|
41
|
+
``c.X`` resolution lands).
|
|
42
|
+
- ``_tui_empty_snapshot`` — minimal placeholder ``DataSnapshot``
|
|
43
|
+
used by the dashboard at boot before the first sync lands; also
|
|
44
|
+
by the panel-level test harnesses via ``ns["_tui_empty_snapshot"]``.
|
|
45
|
+
- ``_tui_panel_*`` panel renderer family —
|
|
46
|
+
``_tui_panel_current_week``, ``_tui_panel_current_week_hero``,
|
|
47
|
+
``_tui_panel_forecast``, ``_tui_panel_trend``,
|
|
48
|
+
``_tui_panel_sessions``. Each takes a ``DataSnapshot`` + the
|
|
49
|
+
current ``RuntimeState`` + width/height/focus hints and returns
|
|
50
|
+
a list of rich-tagged text lines (NOT a rich.Panel — the variant
|
|
51
|
+
renderers box them).
|
|
52
|
+
- ``_tui_modal_*`` modal renderer family —
|
|
53
|
+
``_tui_modal_current_week``, ``_tui_modal_forecast``,
|
|
54
|
+
``_tui_modal_trend``, ``_tui_modal_session``. Each rebuilds its
|
|
55
|
+
body from the latest ``DataSnapshot`` every tick (CLAUDE.md
|
|
56
|
+
"TUI v2 modal/input lifecycle" gotcha: modals are NOT frozen at
|
|
57
|
+
open time; sync continues while open).
|
|
58
|
+
- ``_tui_render_variant_a`` / ``_tui_render_variant_b`` —
|
|
59
|
+
full-frame composers for the two layout variants
|
|
60
|
+
(``variant_a`` is the 2x2 grid; ``variant_b`` is the expressive
|
|
61
|
+
hero). Each owns the focused-border ribbon, the toast slot, the
|
|
62
|
+
modal/overlay positioning, and the header strip.
|
|
63
|
+
- ``_tui_render_help`` — full-frame help overlay (a rich.Panel
|
|
64
|
+
bordered with the keymap legend).
|
|
65
|
+
- ``_tui_render_modal`` — modal dispatcher; selects the
|
|
66
|
+
per-modal renderer by ``runtime.open_modal`` slot.
|
|
67
|
+
- ``_tui_render_once`` — dev hook for the
|
|
68
|
+
``--render-once --snapshot-module`` fixture path
|
|
69
|
+
(argparse-SUPPRESSed; powers ``bin/cctally-tui-test``). Builds
|
|
70
|
+
the console with ``record=True``, runs one frame, exports
|
|
71
|
+
text/SVG, and writes to stdout. Honors ``RUNTIME_OVERRIDES``
|
|
72
|
+
dict on the snapshot module per the spec's allow-list.
|
|
73
|
+
- ``_tui_header_strip_a`` / ``_tui_footer_keys`` /
|
|
74
|
+
``_tui_render_input_prompt`` — chrome helpers for header/footer
|
|
75
|
+
rows and the in-prompt input line (filter ``f`` / search ``/``).
|
|
76
|
+
- ``_tui_render_toast`` — bottom-anchored toast notification line.
|
|
77
|
+
- ``_tui_colortag`` / ``_tui_escape_tags`` / ``_tui_strip_tags`` /
|
|
78
|
+
``_tui_tagged_box_lines`` / ``_tui_lines_to_text`` — markup
|
|
79
|
+
helpers for the in-house ``{name}…{/}`` tag grammar (avoids
|
|
80
|
+
rich's ``[…]`` syntax so panel content can embed literal
|
|
81
|
+
square brackets verbatim).
|
|
82
|
+
- ``_tui_box_lines`` / ``_tui_bar_string`` / ``_tui_bar_color`` /
|
|
83
|
+
``_tui_sparkline_inline`` / ``_tui_sparkline_big`` /
|
|
84
|
+
``_tui_width_bucket`` — drawing primitives.
|
|
85
|
+
- ``_tui_verdict_of`` / ``_tui_session_model_cls`` /
|
|
86
|
+
``_tui_format_started`` / ``_tui_format_dur`` /
|
|
87
|
+
``_tui_sort_sessions`` / ``_tui_next_sort_key`` /
|
|
88
|
+
``_tui_apply_session_filter`` / ``_tui_sessions_title`` —
|
|
89
|
+
data-presentation helpers for the sessions panel.
|
|
90
|
+
- ``_tui_sync_interval_type`` / ``_tui_refresh_interval_type`` —
|
|
91
|
+
argparse type validators for the two CLI interval flags.
|
|
92
|
+
- ``_make_run_sync_now`` / ``_make_run_sync_now_locked`` —
|
|
93
|
+
shared snapshot-rebuilder closures consumed by BOTH the TUI
|
|
94
|
+
loop and the dashboard's ``POST /api/sync`` handler + periodic
|
|
95
|
+
thread (re-exported through cctally so the dashboard's
|
|
96
|
+
shim chain lands; the test harness patches
|
|
97
|
+
``ns["_tui_build_snapshot"]`` to stub the rebuild).
|
|
98
|
+
|
|
99
|
+
- Shared dataclasses (consumed by BOTH the TUI and the dashboard,
|
|
100
|
+
via cctally's eager re-export → the dashboard's existing 5
|
|
101
|
+
dataclass shims at ``bin/_cctally_dashboard.py:487-504``
|
|
102
|
+
continue resolving transparently through
|
|
103
|
+
``sys.modules["cctally"].X``):
|
|
104
|
+
``DataSnapshot``, ``RuntimeState``, ``TuiCurrentWeek``,
|
|
105
|
+
``TuiTrendRow``, ``TuiSessionRow``, ``TuiSessionDetail``,
|
|
106
|
+
``TuiPercentMilestone``, ``WeeklyPeriodRow``,
|
|
107
|
+
``MonthlyPeriodRow``, ``BlocksPanelRow``, ``DailyPanelRow``.
|
|
108
|
+
|
|
109
|
+
What stays in bin/cctally:
|
|
110
|
+
- ``ForecastInputs``, ``ForecastOutput``, ``BudgetRow`` — the
|
|
111
|
+
forecast inputs/output/budget dataclasses. Used by ``_compute_forecast``
|
|
112
|
+
(whose definition stays in cctally alongside the forecast subcommand)
|
|
113
|
+
and by the TUI builder which constructs them via the module-level
|
|
114
|
+
callable shims below.
|
|
115
|
+
- ``_compute_forecast``, ``_resolve_forecast_now``,
|
|
116
|
+
``_fetch_current_week_snapshots``, ``_load_forecast_inputs``,
|
|
117
|
+
``_apply_midweek_reset_override``, ``_sum_cost_for_range``,
|
|
118
|
+
``_compute_cost_for_weekref``, ``_week_ref_has_reset_event`` —
|
|
119
|
+
forecast/cost-aggregation helpers, called from this sibling via
|
|
120
|
+
module-level shims (each resolves
|
|
121
|
+
``sys.modules["cctally"].X`` at call time).
|
|
122
|
+
- The ``Block`` / ``SubWeek`` dataclasses live in ``_lib_blocks``
|
|
123
|
+
and ``_lib_subscription_weeks`` (Phase A lib siblings); accessed
|
|
124
|
+
via cctally's re-export.
|
|
125
|
+
|
|
126
|
+
§5.6 audit on this extraction's monkeypatch surface
|
|
127
|
+
(``tests/test_dashboard_*.py`` + ``tests/test_tui_*.py``: 11
|
|
128
|
+
distinct ``ns["X"]`` direct-dict reads on moved symbols —
|
|
129
|
+
``ns["DataSnapshot"]`` (6 sites), ``ns["WeeklyPeriodRow"]`` (3),
|
|
130
|
+
``ns["MonthlyPeriodRow"]`` (3), ``ns["BlocksPanelRow"]`` (3),
|
|
131
|
+
``ns["DailyPanelRow"]`` (3), ``ns["TuiCurrentWeek"]`` (2),
|
|
132
|
+
``ns["_tui_empty_snapshot"]`` (2), ``ns["_tui_build_snapshot"]`` (1),
|
|
133
|
+
``ns["_make_run_sync_now"]`` (1), ``ns["_make_run_sync_now_locked"]`` (1),
|
|
134
|
+
plus ``monkeypatch.setitem`` on ``_tui_build_snapshot`` in
|
|
135
|
+
``tests/test_dashboard_api_sync_refresh.py``). Forces the **eager
|
|
136
|
+
re-export** carve-out per spec §4.8 (same precedent as Phase E
|
|
137
|
+
#19/#20 + Phase F #21/#22):
|
|
138
|
+
|
|
139
|
+
- ``ns["X"]`` dict-key reads on dataclass / function / class
|
|
140
|
+
objects propagate via eager re-export at sibling-load time;
|
|
141
|
+
PEP 562 ``__getattr__`` does NOT fire on ``ns["X"]`` (``ns`` is
|
|
142
|
+
the module's ``__dict__``, not the module proxy).
|
|
143
|
+
- ``monkeypatch.setitem(ns, "_tui_build_snapshot", mock)`` mutates
|
|
144
|
+
cctally's namespace. ``_make_run_sync_now_locked`` calls
|
|
145
|
+
``_tui_build_snapshot`` bare-name, which resolves in THIS
|
|
146
|
+
sibling's ``__dict__`` — so the mock would not propagate.
|
|
147
|
+
Pattern matches Phase D #17/#18 + F #21/#22: cross-call from
|
|
148
|
+
one moved function to another moved function that's also a
|
|
149
|
+
monkeypatch target routes through the
|
|
150
|
+
``sys.modules['cctally']._tui_build_snapshot`` callable shim
|
|
151
|
+
at call time, ensuring the latest binding wins.
|
|
152
|
+
|
|
153
|
+
Except-clause audit (Phase F #22's P1 lesson): all ``except`` clauses
|
|
154
|
+
in the moved region are stdlib classes (``Exception``, ``ValueError``,
|
|
155
|
+
``ImportError``, ``FileNotFoundError``) — NO cross-module exception
|
|
156
|
+
classes. The ``except sys.modules["cctally"].X:`` form used in
|
|
157
|
+
``_cctally_dashboard.py`` for ``UpdateError`` is NOT required here.
|
|
158
|
+
|
|
159
|
+
``rich`` import policy: ``rich`` is lazy-imported INSIDE function
|
|
160
|
+
bodies (``cmd_tui``, ``_tui_build_theme``, ``_tui_render_*``,
|
|
161
|
+
``_tui_panel_*``, ``_tui_modal_*``, etc.) per CLAUDE.md TUI gotcha.
|
|
162
|
+
The module level intentionally carries NO ``import rich`` or
|
|
163
|
+
``from rich…`` line; ``Panel`` annotations on
|
|
164
|
+
``_tui_render_help`` / ``_tui_render_modal`` are pure string
|
|
165
|
+
annotations (lazy resolution via ``from __future__ import
|
|
166
|
+
annotations``).
|
|
167
|
+
|
|
168
|
+
``_TUI_VALID_STYLE_NAMES`` / ``_TUI_THEME_KEYS`` drift assertions
|
|
169
|
+
(CLAUDE.md TUI gotcha: keep style names in sync with theme) move
|
|
170
|
+
intact alongside the theme builder; the module-level assert at
|
|
171
|
+
load time + the function-level cross-check inside
|
|
172
|
+
``_tui_build_theme`` are preserved verbatim.
|
|
173
|
+
|
|
174
|
+
``RUNTIME_OVERRIDES`` allow-list (CLAUDE.md TUI gotcha: dev-only
|
|
175
|
+
fixture override) is inside ``_tui_render_once``; moved with the
|
|
176
|
+
rest. Same for the ``--render-once --snapshot-module`` argparse
|
|
177
|
+
dev path.
|
|
178
|
+
|
|
179
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md §7.2
|
|
180
|
+
"""
|
|
181
|
+
from __future__ import annotations
|
|
182
|
+
|
|
183
|
+
import argparse
|
|
184
|
+
import dataclasses
|
|
185
|
+
import datetime as dt
|
|
186
|
+
import io
|
|
187
|
+
import json
|
|
188
|
+
import math
|
|
189
|
+
import os
|
|
190
|
+
import re
|
|
191
|
+
import signal as _signal
|
|
192
|
+
import sqlite3
|
|
193
|
+
import sys
|
|
194
|
+
import threading
|
|
195
|
+
import time
|
|
196
|
+
from dataclasses import dataclass, field
|
|
197
|
+
from typing import Any
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _cctally():
|
|
201
|
+
"""Resolve the current ``cctally`` module at call-time (spec §5.5)."""
|
|
202
|
+
return sys.modules["cctally"]
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# === Module-level back-ref shims for helpers that STAY in bin/cctally ======
|
|
206
|
+
# Each shim resolves ``sys.modules['cctally'].X`` at CALL TIME (not bind
|
|
207
|
+
# time), so monkeypatches on cctally's namespace propagate into the moved
|
|
208
|
+
# code unchanged. Mirrors the precedent established in
|
|
209
|
+
# ``bin/_cctally_record.py``, ``bin/_cctally_cache.py``,
|
|
210
|
+
# ``bin/_cctally_db.py``, ``bin/_cctally_update.py``, and
|
|
211
|
+
# ``bin/_cctally_dashboard.py``.
|
|
212
|
+
def eprint(*args, **kwargs):
|
|
213
|
+
return sys.modules["cctally"].eprint(*args, **kwargs)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def parse_iso_datetime(*args, **kwargs):
|
|
217
|
+
return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _now_utc(*args, **kwargs):
|
|
221
|
+
return sys.modules["cctally"]._now_utc(*args, **kwargs)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def open_db(*args, **kwargs):
|
|
225
|
+
return sys.modules["cctally"].open_db(*args, **kwargs)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def load_config(*args, **kwargs):
|
|
229
|
+
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def format_display_dt(*args, **kwargs):
|
|
233
|
+
return sys.modules["cctally"].format_display_dt(*args, **kwargs)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def resolve_display_tz(*args, **kwargs):
|
|
237
|
+
return sys.modules["cctally"].resolve_display_tz(*args, **kwargs)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def normalize_display_tz_value(*args, **kwargs):
|
|
241
|
+
return sys.modules["cctally"].normalize_display_tz_value(*args, **kwargs)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _resolve_display_tz_obj(*args, **kwargs):
|
|
245
|
+
return sys.modules["cctally"]._resolve_display_tz_obj(*args, **kwargs)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _apply_display_tz_override(*args, **kwargs):
|
|
249
|
+
return sys.modules["cctally"]._apply_display_tz_override(*args, **kwargs)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _apply_midweek_reset_override(*args, **kwargs):
|
|
253
|
+
return sys.modules["cctally"]._apply_midweek_reset_override(*args, **kwargs)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _compute_display_block(*args, **kwargs):
|
|
257
|
+
return sys.modules["cctally"]._compute_display_block(*args, **kwargs)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _compute_forecast(*args, **kwargs):
|
|
261
|
+
return sys.modules["cctally"]._compute_forecast(*args, **kwargs)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _resolve_forecast_now(*args, **kwargs):
|
|
265
|
+
return sys.modules["cctally"]._resolve_forecast_now(*args, **kwargs)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _fetch_current_week_snapshots(*args, **kwargs):
|
|
269
|
+
return sys.modules["cctally"]._fetch_current_week_snapshots(*args, **kwargs)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _load_forecast_inputs(*args, **kwargs):
|
|
273
|
+
return sys.modules["cctally"]._load_forecast_inputs(*args, **kwargs)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _sum_cost_for_range(*args, **kwargs):
|
|
277
|
+
return sys.modules["cctally"]._sum_cost_for_range(*args, **kwargs)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _compute_cost_for_weekref(*args, **kwargs):
|
|
281
|
+
return sys.modules["cctally"]._compute_cost_for_weekref(*args, **kwargs)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _week_ref_has_reset_event(*args, **kwargs):
|
|
285
|
+
return sys.modules["cctally"]._week_ref_has_reset_event(*args, **kwargs)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _freshness_label(*args, **kwargs):
|
|
289
|
+
return sys.modules["cctally"]._freshness_label(*args, **kwargs)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _get_oauth_usage_config(*args, **kwargs):
|
|
293
|
+
return sys.modules["cctally"]._get_oauth_usage_config(*args, **kwargs)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _aggregate_claude_sessions(*args, **kwargs):
|
|
297
|
+
return sys.modules["cctally"]._aggregate_claude_sessions(*args, **kwargs)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _aggregate_monthly(*args, **kwargs):
|
|
301
|
+
return sys.modules["cctally"]._aggregate_monthly(*args, **kwargs)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def get_claude_session_entries(*args, **kwargs):
|
|
305
|
+
return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def get_latest_usage_for_week(*args, **kwargs):
|
|
309
|
+
return sys.modules["cctally"].get_latest_usage_for_week(*args, **kwargs)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def get_latest_cost_for_week(*args, **kwargs):
|
|
313
|
+
return sys.modules["cctally"].get_latest_cost_for_week(*args, **kwargs)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def get_milestones_for_week(*args, **kwargs):
|
|
317
|
+
return sys.modules["cctally"].get_milestones_for_week(*args, **kwargs)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _canonicalize_optional_iso(*args, **kwargs):
|
|
321
|
+
return sys.modules["cctally"]._canonicalize_optional_iso(*args, **kwargs)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def get_recent_weeks(*args, **kwargs):
|
|
325
|
+
return sys.modules["cctally"].get_recent_weeks(*args, **kwargs)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def sync_cache(*args, **kwargs):
|
|
329
|
+
return sys.modules["cctally"].sync_cache(*args, **kwargs)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# Forecast dataclass shims — used as bare-name constructors inside
|
|
333
|
+
# ``_tui_build_forecast``. The classes themselves stay in bin/cctally
|
|
334
|
+
# alongside the forecast subcommand (``cmd_forecast``); call-time
|
|
335
|
+
# resolution keeps monkeypatches in sync.
|
|
336
|
+
def ForecastInputs(*args, **kwargs):
|
|
337
|
+
return sys.modules["cctally"].ForecastInputs(*args, **kwargs)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def ForecastOutput(*args, **kwargs):
|
|
341
|
+
return sys.modules["cctally"].ForecastOutput(*args, **kwargs)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def BudgetRow(*args, **kwargs):
|
|
345
|
+
return sys.modules["cctally"].BudgetRow(*args, **kwargs)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# Dashboard back-refs consumed by the TUI's snapshot builders.
|
|
349
|
+
# These functions/classes live in bin/_cctally_dashboard.py (Phase F #22),
|
|
350
|
+
# re-exported through bin/cctally so the shim resolves correctly.
|
|
351
|
+
def _dashboard_build_blocks_panel(*args, **kwargs):
|
|
352
|
+
return sys.modules["cctally"]._dashboard_build_blocks_panel(*args, **kwargs)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _dashboard_build_daily_panel(*args, **kwargs):
|
|
356
|
+
return sys.modules["cctally"]._dashboard_build_daily_panel(*args, **kwargs)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _dashboard_build_monthly_periods(*args, **kwargs):
|
|
360
|
+
return sys.modules["cctally"]._dashboard_build_monthly_periods(*args, **kwargs)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _dashboard_build_weekly_periods(*args, **kwargs):
|
|
364
|
+
return sys.modules["cctally"]._dashboard_build_weekly_periods(*args, **kwargs)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _build_alerts_envelope_array(*args, **kwargs):
|
|
368
|
+
return sys.modules["cctally"]._build_alerts_envelope_array(*args, **kwargs)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _select_current_block_for_envelope(*args, **kwargs):
|
|
372
|
+
return sys.modules["cctally"]._select_current_block_for_envelope(*args, **kwargs)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _SnapshotRef(*args, **kwargs):
|
|
376
|
+
return sys.modules["cctally"]._SnapshotRef(*args, **kwargs)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# Alerts back-refs.
|
|
380
|
+
# Module-level __getattr__ — lazy-resolves cctally globals at attribute-access
|
|
381
|
+
# time. PEP 562 fires on ``module.X``-shaped access from outside this module;
|
|
382
|
+
# bare-name lookups in function bodies bypass it. Used here for the
|
|
383
|
+
# non-callable ``_AlertsConfigError`` exception class (cross-module class
|
|
384
|
+
# identity is required for any future ``except _AlertsConfigError:`` site)
|
|
385
|
+
# and for ``Block`` / ``SubWeek`` dataclass type references that might land
|
|
386
|
+
# in annotations.
|
|
387
|
+
_LAZY_ATTRS = (
|
|
388
|
+
"_AlertsConfigError",
|
|
389
|
+
"Block",
|
|
390
|
+
"SubWeek",
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def __getattr__(name): # pylint: disable=invalid-name
|
|
395
|
+
if name in _LAZY_ATTRS:
|
|
396
|
+
return getattr(sys.modules["cctally"], name)
|
|
397
|
+
raise AttributeError(name)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ============================================================
|
|
401
|
+
# ==== TUI ==== =
|
|
402
|
+
# ============================================================
|
|
403
|
+
# Live dashboard subcommand. Lazy rich import keeps the rest of the
|
|
404
|
+
# script dependency-free. All TUI-specific code lives in this block.
|
|
405
|
+
|
|
406
|
+
TUI_RICH_MISSING_MSG = (
|
|
407
|
+
"tui: this subcommand requires the 'rich' package.\n"
|
|
408
|
+
"install with: pip install rich\n"
|
|
409
|
+
"(or: pipx inject cctally rich)"
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# Palette — frozen TUI color values.
|
|
414
|
+
TUI_PALETTE = {
|
|
415
|
+
"term_bg": "#0a0b0d",
|
|
416
|
+
"fg": "#d7dce1",
|
|
417
|
+
"fg_dim": "#7a8290",
|
|
418
|
+
"fg_faint": "#4a5060",
|
|
419
|
+
"fg_bright": "#f4f6f8",
|
|
420
|
+
"accent": "#6fc5e0",
|
|
421
|
+
"accent_dim": "#3d7c92",
|
|
422
|
+
"ok": "#7bc47f",
|
|
423
|
+
"ok_dim": "#3f7f4e",
|
|
424
|
+
"warn": "#e8c76e",
|
|
425
|
+
"warn_dim": "#8a7735",
|
|
426
|
+
"bad": "#e07a7a",
|
|
427
|
+
"bad_dim": "#873f3f",
|
|
428
|
+
"magenta": "#c89acf",
|
|
429
|
+
"blue": "#8ab0d9",
|
|
430
|
+
# Badge fg colors — dark tones for high contrast against the bg-dim
|
|
431
|
+
# swatches.
|
|
432
|
+
"badge_warn_fg": "#1b1405",
|
|
433
|
+
"badge_ok_fg": "#0a1a0c",
|
|
434
|
+
"badge_bad_fg": "#1b0808",
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _tui_build_theme():
|
|
439
|
+
"""Build a rich.theme.Theme mapping named styles to TUI_PALETTE colors.
|
|
440
|
+
|
|
441
|
+
Named styles used by the renderer:
|
|
442
|
+
fg, dim, faint, bright, accent, accent.dim,
|
|
443
|
+
ok, warn, bad, magenta, blue,
|
|
444
|
+
badge.ok, badge.warn, badge.bad,
|
|
445
|
+
focused (alias of accent, but semantically the "focused pane border"),
|
|
446
|
+
bar.ok, bar.warn, bar.bad, bar.accent, bar.track
|
|
447
|
+
"""
|
|
448
|
+
from rich.style import Style
|
|
449
|
+
from rich.theme import Theme
|
|
450
|
+
p = TUI_PALETTE
|
|
451
|
+
styles_dict = {
|
|
452
|
+
"fg": Style(color=p["fg"]),
|
|
453
|
+
"dim": Style(color=p["fg_dim"]),
|
|
454
|
+
"faint": Style(color=p["fg_faint"]),
|
|
455
|
+
"bright": Style(color=p["fg_bright"], bold=True),
|
|
456
|
+
"accent": Style(color=p["accent"]),
|
|
457
|
+
"accent.dim": Style(color=p["accent_dim"]),
|
|
458
|
+
"ok": Style(color=p["ok"]),
|
|
459
|
+
"warn": Style(color=p["warn"]),
|
|
460
|
+
"bad": Style(color=p["bad"]),
|
|
461
|
+
"magenta": Style(color=p["magenta"]),
|
|
462
|
+
"blue": Style(color=p["blue"]),
|
|
463
|
+
"badge.ok": Style(color=p["badge_ok_fg"], bgcolor=p["ok_dim"], bold=True),
|
|
464
|
+
"badge.warn": Style(color=p["badge_warn_fg"], bgcolor=p["warn_dim"], bold=True),
|
|
465
|
+
"badge.bad": Style(color=p["badge_bad_fg"], bgcolor=p["bad_dim"], bold=True),
|
|
466
|
+
"focused": Style(color=p["accent"], bold=True),
|
|
467
|
+
"bar.ok": Style(color=p["ok"]),
|
|
468
|
+
"bar.warn": Style(color=p["warn"]),
|
|
469
|
+
"bar.bad": Style(color=p["bad"]),
|
|
470
|
+
"bar.accent": Style(color=p["accent"]),
|
|
471
|
+
"bar.track": Style(color=p["fg_faint"]),
|
|
472
|
+
# v2 additions (spec §5.2)
|
|
473
|
+
"chip": Style(color=p["term_bg"], bgcolor=p["accent"], bold=True),
|
|
474
|
+
"match": Style(color=p["term_bg"], bgcolor=p["warn"], bold=True),
|
|
475
|
+
"prompt": Style(color=p["accent"], bold=True),
|
|
476
|
+
"caret": Style(color=p["term_bg"], bgcolor=p["fg"]),
|
|
477
|
+
}
|
|
478
|
+
# Function-level cross-check: the literal style dict above must match the
|
|
479
|
+
# declarative _TUI_THEME_KEYS. Catches the case where someone edits the
|
|
480
|
+
# dict but forgets to update the module-level set (or vice versa). The
|
|
481
|
+
# module-level assert covers the validator↔keys axis; this covers the
|
|
482
|
+
# keys↔actual-theme axis. We check the pre-Theme dict rather than
|
|
483
|
+
# theme.styles because rich.Theme inherits DEFAULT_STYLES (markdown/log/
|
|
484
|
+
# progress/traceback/…) which would dilute the equality check.
|
|
485
|
+
assert frozenset(styles_dict.keys()) == _TUI_THEME_KEYS, (
|
|
486
|
+
"theme/keys drift: "
|
|
487
|
+
f"added={sorted(set(styles_dict) - _TUI_THEME_KEYS)} "
|
|
488
|
+
f"removed={sorted(_TUI_THEME_KEYS - set(styles_dict))}"
|
|
489
|
+
)
|
|
490
|
+
return Theme(styles_dict)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# Style-name shorthand -> rich-style keyword. 'b', 'u', 'pulse' are CSS
|
|
494
|
+
# shorthands from the reference HTML; map them to rich equivalents.
|
|
495
|
+
_TUI_TAG_SHORTHAND = {
|
|
496
|
+
"b": "bold",
|
|
497
|
+
"u": "underline",
|
|
498
|
+
"pulse": "blink", # terminal blink — approximates the CSS pulse animation.
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
# Theme-defined style names accepted by _tui_colortag (mirrors the keys of
|
|
502
|
+
# _tui_build_theme()). Must be kept in sync with that function. Any tag part
|
|
503
|
+
# not in this set and not in _TUI_TAG_SHORTHAND raises ValueError.
|
|
504
|
+
_TUI_VALID_STYLE_NAMES = frozenset({
|
|
505
|
+
"fg", "dim", "faint", "bright",
|
|
506
|
+
"accent", "accent.dim",
|
|
507
|
+
"ok", "warn", "bad", "magenta", "blue",
|
|
508
|
+
"badge.ok", "badge.warn", "badge.bad",
|
|
509
|
+
"focused",
|
|
510
|
+
"bar.ok", "bar.warn", "bar.bad", "bar.accent", "bar.track",
|
|
511
|
+
# v2 additions
|
|
512
|
+
"chip", "match", "prompt", "caret",
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
# Declarative enumeration of every style key produced by _tui_build_theme().
|
|
516
|
+
# Single source of truth — the theme builder and the module-level drift guard
|
|
517
|
+
# both consult it, so adding a theme style means editing this set (and the
|
|
518
|
+
# function's dict literal) in one place.
|
|
519
|
+
_TUI_THEME_KEYS = frozenset({
|
|
520
|
+
"fg", "dim", "faint", "bright",
|
|
521
|
+
"accent", "accent.dim",
|
|
522
|
+
"ok", "warn", "bad", "magenta", "blue",
|
|
523
|
+
"badge.ok", "badge.warn", "badge.bad",
|
|
524
|
+
"focused",
|
|
525
|
+
"bar.ok", "bar.warn", "bar.bad", "bar.accent", "bar.track",
|
|
526
|
+
# v2 additions
|
|
527
|
+
"chip", "match", "prompt", "caret",
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
# Module-level drift guard (no rich required): every name recognised by the
|
|
531
|
+
# validator must be provided by the theme. Fires at first import — so
|
|
532
|
+
# `python3 -m py_compile` followed by any import of the script catches the
|
|
533
|
+
# case where someone edits one side of the pair without the other, without
|
|
534
|
+
# needing to launch the `tui` subcommand.
|
|
535
|
+
assert _TUI_VALID_STYLE_NAMES <= _TUI_THEME_KEYS, (
|
|
536
|
+
"_TUI_VALID_STYLE_NAMES drift: "
|
|
537
|
+
f"{sorted(_TUI_VALID_STYLE_NAMES - _TUI_THEME_KEYS)} not in theme keys"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _tui_colortag(source: str):
|
|
542
|
+
"""Render a color-tag string to a rich.text.Text.
|
|
543
|
+
|
|
544
|
+
Grammar:
|
|
545
|
+
- "{name}...{/}" -> style 'name' over inner text
|
|
546
|
+
- "{n1.n2}...{/}" -> joined styles "n1 n2" (e.g. "{ok.b}" -> "ok bold")
|
|
547
|
+
- "{{" / "}}" -> literal "{" / "}"
|
|
548
|
+
- Styles must be defined in the theme (Task 3) OR be in
|
|
549
|
+
_TUI_TAG_SHORTHAND. Unknown style names raise ValueError.
|
|
550
|
+
|
|
551
|
+
The function returns a rich.text.Text (not a string) so the caller
|
|
552
|
+
can compose it into Layouts/Panels without double-escaping.
|
|
553
|
+
"""
|
|
554
|
+
from rich.text import Text
|
|
555
|
+
|
|
556
|
+
out = Text()
|
|
557
|
+
stack: list[str] = [] # active style stack; top = innermost
|
|
558
|
+
buf: list[str] = [] # pending chars for the current style run
|
|
559
|
+
|
|
560
|
+
def _flush():
|
|
561
|
+
if not buf:
|
|
562
|
+
return
|
|
563
|
+
style = " ".join(stack) if stack else ""
|
|
564
|
+
out.append("".join(buf), style=style)
|
|
565
|
+
buf.clear()
|
|
566
|
+
|
|
567
|
+
i = 0
|
|
568
|
+
n = len(source)
|
|
569
|
+
while i < n:
|
|
570
|
+
c = source[i]
|
|
571
|
+
if c == "{" and i + 1 < n and source[i + 1] == "{":
|
|
572
|
+
buf.append("{")
|
|
573
|
+
i += 2
|
|
574
|
+
continue
|
|
575
|
+
if c == "}" and i + 1 < n and source[i + 1] == "}":
|
|
576
|
+
buf.append("}")
|
|
577
|
+
i += 2
|
|
578
|
+
continue
|
|
579
|
+
if c == "{":
|
|
580
|
+
_flush()
|
|
581
|
+
end = source.find("}", i + 1)
|
|
582
|
+
if end < 0:
|
|
583
|
+
raise ValueError(f"unterminated tag at offset {i}")
|
|
584
|
+
tag = source[i + 1:end]
|
|
585
|
+
if tag == "/":
|
|
586
|
+
if not stack:
|
|
587
|
+
raise ValueError(f"unmatched closing tag at offset {i}")
|
|
588
|
+
stack.pop()
|
|
589
|
+
else:
|
|
590
|
+
# Tag name resolution: try longest whole-tag match, peeling
|
|
591
|
+
# trailing shorthands (.b/.u/.pulse) from the end. This supports
|
|
592
|
+
# both `{ok.b}` (split/compose) AND `{bar.ok.b}` (peel `.b`,
|
|
593
|
+
# then `bar.ok` is a valid whole theme key).
|
|
594
|
+
if tag in _TUI_VALID_STYLE_NAMES:
|
|
595
|
+
stack.append(tag)
|
|
596
|
+
else:
|
|
597
|
+
parts = tag.split(".")
|
|
598
|
+
resolved: str | None = None
|
|
599
|
+
# Try progressively shorter prefixes, peeling trailing
|
|
600
|
+
# shorthand parts off the back. `prefix` must be a valid
|
|
601
|
+
# whole theme key; all peeled parts must be in
|
|
602
|
+
# `_TUI_TAG_SHORTHAND`.
|
|
603
|
+
for k in range(len(parts) - 1, 0, -1):
|
|
604
|
+
prefix = ".".join(parts[:k])
|
|
605
|
+
suffix = parts[k:]
|
|
606
|
+
if prefix in _TUI_VALID_STYLE_NAMES and all(
|
|
607
|
+
s in _TUI_TAG_SHORTHAND for s in suffix
|
|
608
|
+
):
|
|
609
|
+
resolved = prefix + " " + " ".join(
|
|
610
|
+
_TUI_TAG_SHORTHAND[s] for s in suffix
|
|
611
|
+
)
|
|
612
|
+
break
|
|
613
|
+
if resolved is None:
|
|
614
|
+
# Fallback: split-and-compose; every part must be a
|
|
615
|
+
# known shorthand or valid style name. This supports
|
|
616
|
+
# {ok.b} and raises on unknown names.
|
|
617
|
+
for p in parts:
|
|
618
|
+
if (
|
|
619
|
+
p not in _TUI_TAG_SHORTHAND
|
|
620
|
+
and p not in _TUI_VALID_STYLE_NAMES
|
|
621
|
+
):
|
|
622
|
+
raise ValueError(
|
|
623
|
+
f"unknown style name {p!r} in tag "
|
|
624
|
+
f"{{{tag}}} at offset {i}"
|
|
625
|
+
)
|
|
626
|
+
resolved = " ".join(
|
|
627
|
+
_TUI_TAG_SHORTHAND.get(p, p) for p in parts
|
|
628
|
+
)
|
|
629
|
+
stack.append(resolved)
|
|
630
|
+
i = end + 1
|
|
631
|
+
continue
|
|
632
|
+
buf.append(c)
|
|
633
|
+
i += 1
|
|
634
|
+
|
|
635
|
+
_flush()
|
|
636
|
+
if stack:
|
|
637
|
+
raise ValueError(f"unclosed tags remaining: {stack}")
|
|
638
|
+
return out
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _tui_escape_tags(s: str) -> str:
|
|
642
|
+
"""Escape literal `{` and `}` so user input can be safely interpolated
|
|
643
|
+
into a colortag-formatted string without being parsed as style tags.
|
|
644
|
+
|
|
645
|
+
`_tui_colortag` treats `{name}…{/}` as style tags. Doubling `{` → `{{`
|
|
646
|
+
and `}` → `}}` is the colortag grammar's literal-brace escape and the
|
|
647
|
+
parser converts each pair back to a single brace. Apply this at the
|
|
648
|
+
render boundary on any string sourced from user input or external data.
|
|
649
|
+
"""
|
|
650
|
+
if not s:
|
|
651
|
+
return s
|
|
652
|
+
return s.replace("{", "{{").replace("}", "}}")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# Double-line box-drawing glyphs.
|
|
656
|
+
_TUI_BOX = {
|
|
657
|
+
"tl": "╔", "tr": "╗", "bl": "╚", "br": "╝",
|
|
658
|
+
"h": "═", "v": "║",
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _tui_box_lines(
|
|
663
|
+
*,
|
|
664
|
+
width: int,
|
|
665
|
+
body: list[str],
|
|
666
|
+
title: str | None = None,
|
|
667
|
+
pin: str | None = None,
|
|
668
|
+
) -> list[str]:
|
|
669
|
+
"""Return a list of length-`width` strings forming a double-line box.
|
|
670
|
+
|
|
671
|
+
Each body line is padded (right) or truncated to interior width (= width-2).
|
|
672
|
+
Title goes left: ╔═ title ═══╗. Pin goes right-adjacent: ╔═ title ═ pin ═╗.
|
|
673
|
+
If both won't fit, drop pin; if title won't fit, drop title.
|
|
674
|
+
|
|
675
|
+
Callers who need colored glyphs should wrap the returned strings via
|
|
676
|
+
_tui_colortag on the outside — this function emits plain text.
|
|
677
|
+
"""
|
|
678
|
+
if width < 4:
|
|
679
|
+
raise ValueError(f"box width too small: {width}")
|
|
680
|
+
H, V, TL, TR, BL, BR = (
|
|
681
|
+
_TUI_BOX["h"], _TUI_BOX["v"],
|
|
682
|
+
_TUI_BOX["tl"], _TUI_BOX["tr"],
|
|
683
|
+
_TUI_BOX["bl"], _TUI_BOX["br"],
|
|
684
|
+
)
|
|
685
|
+
interior = width - 2
|
|
686
|
+
|
|
687
|
+
# Top border assembly
|
|
688
|
+
def _top() -> str:
|
|
689
|
+
if title is None:
|
|
690
|
+
return TL + H * interior + TR
|
|
691
|
+
t_seg = f" {title} "
|
|
692
|
+
# Can we fit both title and pin?
|
|
693
|
+
if pin is not None:
|
|
694
|
+
p_seg = f" {pin} "
|
|
695
|
+
# Layout: TL + H + t_seg + H*fill + p_seg + H + TR
|
|
696
|
+
# width = 1 + 1 + len(t_seg) + fill + len(p_seg) + 1 + 1
|
|
697
|
+
# fill = width - 4 - len(t_seg) - len(p_seg)
|
|
698
|
+
fill = width - 4 - len(t_seg) - len(p_seg)
|
|
699
|
+
if fill >= 1:
|
|
700
|
+
return TL + H + t_seg + H * fill + p_seg + H + TR
|
|
701
|
+
# Pin dropped (or absent) — fit just the title.
|
|
702
|
+
# Layout: TL + H + t_seg + H*fill + TR
|
|
703
|
+
# width = 1 + 1 + len(t_seg) + fill + 1
|
|
704
|
+
fill = width - 3 - len(t_seg)
|
|
705
|
+
if fill >= 1:
|
|
706
|
+
return TL + H + t_seg + H * fill + TR
|
|
707
|
+
# Title too long — fall back to plain border.
|
|
708
|
+
return TL + H * interior + TR
|
|
709
|
+
|
|
710
|
+
top = _top()
|
|
711
|
+
bot = BL + H * interior + BR
|
|
712
|
+
body_rows = []
|
|
713
|
+
for line in body:
|
|
714
|
+
if len(line) > interior:
|
|
715
|
+
line = line[:interior - 1] + "…"
|
|
716
|
+
body_rows.append(V + line + " " * (interior - len(line)) + V)
|
|
717
|
+
return [top, *body_rows, bot]
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _tui_bar_string(pct: float, width: int) -> str:
|
|
721
|
+
"""Render a filled/empty bar as a string of `█` and `░`.
|
|
722
|
+
|
|
723
|
+
Coloring is the caller's job — wrap with _tui_colortag or Text.append(style=).
|
|
724
|
+
"""
|
|
725
|
+
if width <= 0:
|
|
726
|
+
return ""
|
|
727
|
+
p = max(0.0, min(100.0, float(pct)))
|
|
728
|
+
full = round((p / 100.0) * width)
|
|
729
|
+
return "█" * full + "░" * (width - full)
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _tui_bar_color(pct: float, *, thresholds=(70.0, 90.0)) -> str:
|
|
733
|
+
"""Return the theme style name for the bar based on usage thresholds.
|
|
734
|
+
|
|
735
|
+
Default thresholds match the reference design (green <70, yellow 70-90,
|
|
736
|
+
red >=90). Returns one of: 'bar.ok', 'bar.warn', 'bar.bad'.
|
|
737
|
+
"""
|
|
738
|
+
low, high = thresholds
|
|
739
|
+
if pct >= high:
|
|
740
|
+
return "bar.bad"
|
|
741
|
+
if pct >= low:
|
|
742
|
+
return "bar.warn"
|
|
743
|
+
return "bar.ok"
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
_TUI_SPARK_GLYPHS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _tui_sparkline_inline(points: list[int]) -> str:
|
|
750
|
+
"""Map 1..8 to `_TUI_SPARK_GLYPHS`; clamp out-of-range into the 0..7 index."""
|
|
751
|
+
if not points:
|
|
752
|
+
return ""
|
|
753
|
+
return "".join(_TUI_SPARK_GLYPHS[max(0, min(7, p - 1))] for p in points)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _tui_sparkline_big(points: list[int]) -> str:
|
|
757
|
+
"""Render a 3-row block chart, 2 chars wide per point, space-separated.
|
|
758
|
+
|
|
759
|
+
Point values 1..8 scaled to a 0..9 height; distributed top-down across
|
|
760
|
+
three segments each taking values 0..3; height-per-segment maps to
|
|
761
|
+
{0:' ', 1:'▂▂', 2:'▄▄', 3:'██'}.
|
|
762
|
+
"""
|
|
763
|
+
if not points:
|
|
764
|
+
return "\n\n"
|
|
765
|
+
rows: list[list[str]] = [[], [], []]
|
|
766
|
+
glyph_map = [" ", "▂▂", "▄▄", "██"]
|
|
767
|
+
for p in points:
|
|
768
|
+
h = max(1, min(8, int(p))) / 8 * 9 # 0..9
|
|
769
|
+
pieces = [0, 0, 0]
|
|
770
|
+
for i in (2, 1, 0):
|
|
771
|
+
if h >= 3:
|
|
772
|
+
pieces[i] = 3
|
|
773
|
+
h -= 3
|
|
774
|
+
elif h >= 2:
|
|
775
|
+
pieces[i] = 2
|
|
776
|
+
h = 0
|
|
777
|
+
elif h >= 1:
|
|
778
|
+
pieces[i] = 1
|
|
779
|
+
h = 0
|
|
780
|
+
for r_idx in range(3):
|
|
781
|
+
rows[r_idx].append(glyph_map[pieces[r_idx]])
|
|
782
|
+
return "\n".join(" ".join(r) for r in rows)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _tui_width_bucket(width: int) -> str:
|
|
786
|
+
"""Pick a layout bucket from terminal width.
|
|
787
|
+
|
|
788
|
+
- >= 120: 'wide' (full design, 120×36 as primary)
|
|
789
|
+
- 100..119: 'compact' (drops Model/Project in A sessions, 4wk trend in B)
|
|
790
|
+
- 80..99: 'narrow' (same rules as compact + shows a narrow-warning line)
|
|
791
|
+
- < 80: 'refuse' (error message, exit 1)
|
|
792
|
+
"""
|
|
793
|
+
if width >= 120:
|
|
794
|
+
return "wide"
|
|
795
|
+
if width >= 100:
|
|
796
|
+
return "compact"
|
|
797
|
+
if width >= 80:
|
|
798
|
+
return "narrow"
|
|
799
|
+
return "refuse"
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
# -------- data layer -----------------------------------------------------
|
|
803
|
+
# Dataclasses produced by the sync thread and consumed by the render
|
|
804
|
+
# thread. Treat DataSnapshot as immutable — the sync thread publishes a
|
|
805
|
+
# new instance and the renderer swaps the reference atomically.
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
@dataclass
|
|
809
|
+
class TuiCurrentWeek:
|
|
810
|
+
week_start_at: dt.datetime
|
|
811
|
+
week_end_at: dt.datetime
|
|
812
|
+
used_pct: float
|
|
813
|
+
five_hour_pct: float | None
|
|
814
|
+
five_hour_resets_at: dt.datetime | None
|
|
815
|
+
spent_usd: float
|
|
816
|
+
dollars_per_percent: float | None
|
|
817
|
+
latest_snapshot_at: dt.datetime
|
|
818
|
+
# Freshness fields (Task C6). Computed by `_tui_build_current_week` via
|
|
819
|
+
# `_freshness_label` against the configured oauth_usage thresholds. Default
|
|
820
|
+
# None so fixture modules that construct `TuiCurrentWeek` directly without
|
|
821
|
+
# populating these stay backwards-compatible — the renderer treats `None`
|
|
822
|
+
# the same as `"fresh"` and hides the chip. Refs spec §3.4.
|
|
823
|
+
freshness_label: str | None = None
|
|
824
|
+
freshness_age: int | None = None
|
|
825
|
+
# Current 5h block snapshot for the dashboard envelope (spec §4.1). Snake-case
|
|
826
|
+
# dict with keys: block_start_at, seven_day_pct_at_block_start,
|
|
827
|
+
# seven_day_pct_delta_pp, crossed_seven_day_reset. Populated by
|
|
828
|
+
# `_tui_build_current_week` via `_select_current_block_for_envelope`; the
|
|
829
|
+
# default `None` keeps fixture modules that construct TuiCurrentWeek
|
|
830
|
+
# directly (without this field) backwards-compatible.
|
|
831
|
+
five_hour_block: dict | None = None
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
@dataclass
|
|
835
|
+
class TuiTrendRow:
|
|
836
|
+
week_label: str # e.g. "Apr 14"
|
|
837
|
+
week_start_at: dt.datetime
|
|
838
|
+
used_pct: float | None # None when the week has a cost snapshot
|
|
839
|
+
# but no usage snapshot (phantom weeks)
|
|
840
|
+
dollars_per_percent: float | None
|
|
841
|
+
delta_dpp: float | None # vs prior week
|
|
842
|
+
spark_height: int # 1..8 normalized
|
|
843
|
+
is_current: bool
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
@dataclass
|
|
847
|
+
class WeeklyPeriodRow:
|
|
848
|
+
"""One subscription-week row for the dashboard's Weekly panel/modal.
|
|
849
|
+
|
|
850
|
+
`models` is a list of `{model, display, chip, cost_usd, cost_pct}`
|
|
851
|
+
dicts sorted by `cost_usd` descending. Pre-bucketed in Python so
|
|
852
|
+
the React layer never re-derives per-model coloring.
|
|
853
|
+
"""
|
|
854
|
+
label: str # "04-23" — MM-DD of the week start
|
|
855
|
+
cost_usd: float
|
|
856
|
+
total_tokens: int
|
|
857
|
+
input_tokens: int
|
|
858
|
+
output_tokens: int
|
|
859
|
+
cache_creation_tokens: int
|
|
860
|
+
cache_read_tokens: int
|
|
861
|
+
used_pct: float | None # from weekly_usage_snapshots overlay
|
|
862
|
+
dollar_per_pct: float | None # cost / used_pct when used_pct > 0
|
|
863
|
+
delta_cost_pct: float | None # (cost - prev_cost) / prev_cost
|
|
864
|
+
is_current: bool
|
|
865
|
+
models: list[dict[str, Any]]
|
|
866
|
+
week_start_at: str # ISO-8601 with tz, from SubWeek.start_ts
|
|
867
|
+
week_end_at: str # ISO-8601 with tz, from SubWeek.end_ts
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
@dataclass
|
|
871
|
+
class MonthlyPeriodRow:
|
|
872
|
+
"""One calendar-month row for the dashboard's Monthly panel/modal."""
|
|
873
|
+
label: str # "YYYY-MM"
|
|
874
|
+
cost_usd: float
|
|
875
|
+
total_tokens: int
|
|
876
|
+
input_tokens: int
|
|
877
|
+
output_tokens: int
|
|
878
|
+
cache_creation_tokens: int
|
|
879
|
+
cache_read_tokens: int
|
|
880
|
+
delta_cost_pct: float | None
|
|
881
|
+
is_current: bool
|
|
882
|
+
models: list[dict[str, Any]]
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
@dataclass
|
|
886
|
+
class BlocksPanelRow:
|
|
887
|
+
"""One row of the dashboard's Blocks panel.
|
|
888
|
+
|
|
889
|
+
Subset of the `Block` dataclass — drops token counts (panel is
|
|
890
|
+
cost-driven; tokens belong to a future modal), drops `entries_count`
|
|
891
|
+
/ `is_gap` / `burn_rate` / `projection` (panel doesn't render them),
|
|
892
|
+
and pre-formats `label` server-side for the local-tz "HH:MM MMM DD"
|
|
893
|
+
display.
|
|
894
|
+
"""
|
|
895
|
+
start_at: str # ISO-8601 UTC
|
|
896
|
+
end_at: str # ISO-8601 UTC, start_at + 5h
|
|
897
|
+
anchor: str # 'recorded' | 'heuristic'
|
|
898
|
+
is_active: bool # now_utc < end_at AND entries_count > 0
|
|
899
|
+
cost_usd: float
|
|
900
|
+
models: list[dict[str, Any]] # ModelCostRow shape, sorted desc by cost
|
|
901
|
+
label: str # "HH:MM MMM DD" in local tz, e.g. "14:00 Apr 26"
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
@dataclass
|
|
905
|
+
class DailyPanelRow:
|
|
906
|
+
"""One row of the dashboard's Daily heatmap panel.
|
|
907
|
+
|
|
908
|
+
`intensity_bucket` is the server-computed quintile bucket (0..5) —
|
|
909
|
+
bucket 0 is reserved for zero-cost days; buckets 1..5 are quintiles
|
|
910
|
+
over non-zero days.
|
|
911
|
+
|
|
912
|
+
v2.3: Added per-day token rollup + `cache_hit_pct` so the Daily
|
|
913
|
+
detail modal can surface the same fields the CLI's `daily` command
|
|
914
|
+
shows. Defaults preserve compatibility with `_empty_dashboard_snapshot`
|
|
915
|
+
and any pre-v2.3 fixture that omits the new fields.
|
|
916
|
+
"""
|
|
917
|
+
date: str # local-tz YYYY-MM-DD
|
|
918
|
+
label: str # "MM-DD" — pre-formatted, mirrors Weekly/Monthly idiom
|
|
919
|
+
cost_usd: float
|
|
920
|
+
is_today: bool
|
|
921
|
+
intensity_bucket: int # 0..5
|
|
922
|
+
models: list[dict[str, Any]] # ModelCostRow shape, sorted desc by cost
|
|
923
|
+
# ---- v2.3 additions: Daily modal token + cache rollup ----
|
|
924
|
+
input_tokens: int = 0
|
|
925
|
+
output_tokens: int = 0
|
|
926
|
+
cache_creation_tokens: int = 0
|
|
927
|
+
cache_read_tokens: int = 0
|
|
928
|
+
total_tokens: int = 0
|
|
929
|
+
cache_hit_pct: float | None = None
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
@dataclass
|
|
933
|
+
class TuiSessionRow:
|
|
934
|
+
started_at: dt.datetime
|
|
935
|
+
duration_minutes: float
|
|
936
|
+
model_primary: str # first model used in the session
|
|
937
|
+
cost_usd: float
|
|
938
|
+
cache_hit_pct: float | None
|
|
939
|
+
project_label: str # basename of project_path
|
|
940
|
+
session_id: str # full session UUID (v2: needed for session-detail modal)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
@dataclass
|
|
944
|
+
class TuiPercentMilestone:
|
|
945
|
+
"""One row in the Current-Week per-percent modal (spec §4.6.1)."""
|
|
946
|
+
percent: int # 1..100
|
|
947
|
+
crossed_at: dt.datetime # captured_at_utc
|
|
948
|
+
cumulative_cost_usd: float
|
|
949
|
+
marginal_cost_usd: float | None
|
|
950
|
+
five_hour_pct_at_crossing: float | None
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def _tui_build_percent_milestones(
|
|
954
|
+
conn: sqlite3.Connection,
|
|
955
|
+
) -> list[TuiPercentMilestone]:
|
|
956
|
+
"""Return per-percent crossings for the current week's ACTIVE
|
|
957
|
+
segment, ascending by percent.
|
|
958
|
+
|
|
959
|
+
Resolves `week_start_date` from the latest `weekly_usage_snapshots` row
|
|
960
|
+
— the same path `cmd_percent_breakdown` takes. The post-override
|
|
961
|
+
`TuiCurrentWeek.week_start_at` is NOT suitable here: after a mid-week
|
|
962
|
+
reset, `_apply_midweek_reset_override` shifts that datetime forward to
|
|
963
|
+
the reset instant, whose `.date()` no longer matches the `week_start_date`
|
|
964
|
+
under which milestones were recorded.
|
|
965
|
+
|
|
966
|
+
v1.7.2: when a `week_reset_events` row exists for the snapshot's
|
|
967
|
+
`week_end_at`, narrow to the active segment so the dashboard /
|
|
968
|
+
TUI milestone panel stays coherent with the already-credit-aware
|
|
969
|
+
header. ``active_segment = 0`` (sentinel) preserves legacy
|
|
970
|
+
behavior on un-credited weeks.
|
|
971
|
+
|
|
972
|
+
Returns [] if no usage snapshot exists, OR if the active segment
|
|
973
|
+
has no milestone rows yet (post-credit "fresh" state).
|
|
974
|
+
"""
|
|
975
|
+
latest = conn.execute(
|
|
976
|
+
"SELECT week_start_date, week_end_at FROM weekly_usage_snapshots "
|
|
977
|
+
"WHERE week_end_at IS NOT NULL "
|
|
978
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
|
|
979
|
+
).fetchone()
|
|
980
|
+
if latest is None:
|
|
981
|
+
# Legacy fallback: a snapshot without week_end_at can still have
|
|
982
|
+
# milestones — keep the prior behavior in that path.
|
|
983
|
+
latest = conn.execute(
|
|
984
|
+
"SELECT week_start_date, NULL AS week_end_at "
|
|
985
|
+
"FROM weekly_usage_snapshots "
|
|
986
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
|
|
987
|
+
).fetchone()
|
|
988
|
+
if latest is None:
|
|
989
|
+
return []
|
|
990
|
+
|
|
991
|
+
# Resolve active segment via the canonical end_at.
|
|
992
|
+
active_segment = 0
|
|
993
|
+
if latest["week_end_at"]:
|
|
994
|
+
try:
|
|
995
|
+
canon_end = _canonicalize_optional_iso(
|
|
996
|
+
latest["week_end_at"], "tui.pm.cur"
|
|
997
|
+
)
|
|
998
|
+
except (AttributeError, ValueError):
|
|
999
|
+
canon_end = None
|
|
1000
|
+
if canon_end:
|
|
1001
|
+
seg_row = conn.execute(
|
|
1002
|
+
"SELECT id FROM week_reset_events "
|
|
1003
|
+
"WHERE new_week_end_at = ? "
|
|
1004
|
+
"ORDER BY id DESC LIMIT 1",
|
|
1005
|
+
(canon_end,),
|
|
1006
|
+
).fetchone()
|
|
1007
|
+
if seg_row is not None:
|
|
1008
|
+
active_segment = int(seg_row["id"])
|
|
1009
|
+
|
|
1010
|
+
rows = [
|
|
1011
|
+
r for r in get_milestones_for_week(conn, latest["week_start_date"])
|
|
1012
|
+
if int(r["reset_event_id"] or 0) == active_segment
|
|
1013
|
+
]
|
|
1014
|
+
out: list[TuiPercentMilestone] = []
|
|
1015
|
+
for r in rows:
|
|
1016
|
+
try:
|
|
1017
|
+
crossed = parse_iso_datetime(r["captured_at_utc"], "captured_at_utc")
|
|
1018
|
+
except ValueError:
|
|
1019
|
+
continue
|
|
1020
|
+
out.append(TuiPercentMilestone(
|
|
1021
|
+
percent=int(r["percent_threshold"]),
|
|
1022
|
+
crossed_at=crossed,
|
|
1023
|
+
cumulative_cost_usd=float(r["cumulative_cost_usd"]),
|
|
1024
|
+
marginal_cost_usd=(float(r["marginal_cost_usd"])
|
|
1025
|
+
if r["marginal_cost_usd"] is not None else None),
|
|
1026
|
+
five_hour_pct_at_crossing=(float(r["five_hour_percent_at_crossing"])
|
|
1027
|
+
if r["five_hour_percent_at_crossing"] is not None
|
|
1028
|
+
else None),
|
|
1029
|
+
))
|
|
1030
|
+
return out
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
@dataclass
|
|
1034
|
+
class DataSnapshot:
|
|
1035
|
+
"""All data needed to render one TUI frame. Produced by sync thread,
|
|
1036
|
+
consumed by main thread. Treat as immutable."""
|
|
1037
|
+
current_week: TuiCurrentWeek | None
|
|
1038
|
+
forecast: Any | None # ForecastOutput from _compute_forecast
|
|
1039
|
+
trend: list[TuiTrendRow]
|
|
1040
|
+
sessions: list[TuiSessionRow]
|
|
1041
|
+
last_sync_at: float | None # monotonic (time.monotonic())
|
|
1042
|
+
last_sync_error: str | None
|
|
1043
|
+
generated_at: dt.datetime # wall-clock UTC for displayed timestamps
|
|
1044
|
+
# ---- v2 additions (spec §4.5) ----
|
|
1045
|
+
percent_milestones: list[TuiPercentMilestone] = field(default_factory=list)
|
|
1046
|
+
weekly_history: list[TuiTrendRow] = field(default_factory=list)
|
|
1047
|
+
# ---- v2.1 additions: dashboard Weekly / Monthly panels ----
|
|
1048
|
+
weekly_periods: list[WeeklyPeriodRow] = field(default_factory=list)
|
|
1049
|
+
monthly_periods: list[MonthlyPeriodRow] = field(default_factory=list)
|
|
1050
|
+
# ---- v2.2 additions: dashboard Blocks / Daily panels ----
|
|
1051
|
+
blocks_panel: list[BlocksPanelRow] = field(default_factory=list)
|
|
1052
|
+
daily_panel: list[DailyPanelRow] = field(default_factory=list)
|
|
1053
|
+
# ---- threshold-actions T5: snapshot alerts envelope array ----
|
|
1054
|
+
# Populated at sync-thread snapshot-build time by
|
|
1055
|
+
# `_build_alerts_envelope_array(conn)`. Single source of truth for
|
|
1056
|
+
# both the dashboard panel (slices to 10) and the modal (renders all
|
|
1057
|
+
# 100). Empty list when alerts feature is disabled, no rows have
|
|
1058
|
+
# `alerted_at` set, or DB read fails (sub-build catches the exception
|
|
1059
|
+
# and records it on `last_sync_error`). Stored as
|
|
1060
|
+
# already-envelope-shaped dicts so `snapshot_to_envelope` stays a
|
|
1061
|
+
# pure renderer (no DB I/O on the dashboard hot path; mirrors how
|
|
1062
|
+
# `current_week.five_hour_block` is precomputed via
|
|
1063
|
+
# `_select_current_block_for_envelope`).
|
|
1064
|
+
alerts: list[dict] = field(default_factory=list)
|
|
1065
|
+
|
|
1066
|
+
@classmethod
|
|
1067
|
+
def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
|
|
1068
|
+
"""Build a deterministic DataSnapshot for README screenshot pipelines.
|
|
1069
|
+
|
|
1070
|
+
Used by tests/fixtures/readme/tui_snapshot.py when run via
|
|
1071
|
+
`cctally tui --render-once --snapshot-module ...`. Numbers are
|
|
1072
|
+
narratively coherent with the marketing fixture's stats.db /
|
|
1073
|
+
cache.db so the TUI shot, the dashboard shots, and the report/
|
|
1074
|
+
forecast SVGs all tell the same story (current-week 53% used,
|
|
1075
|
+
$28.62 spent, in-progress Thursday with a WARN ~104% projection).
|
|
1076
|
+
|
|
1077
|
+
Mirrors the 8-week trend table seeded by build-readme-fixtures.py
|
|
1078
|
+
(`_populate_weeks`) so the TUI's Trend panel shows the same
|
|
1079
|
+
$/1% arc the dashboard's Trend modal does.
|
|
1080
|
+
|
|
1081
|
+
Dev-only — production code paths never invoke this. Kept here so
|
|
1082
|
+
the `DataSnapshot` shape stays the single source of truth (mirror
|
|
1083
|
+
any future field additions to keep marketing renders in sync).
|
|
1084
|
+
"""
|
|
1085
|
+
as_of = dt.datetime.strptime(
|
|
1086
|
+
as_of_iso, "%Y-%m-%dT%H:%M:%SZ"
|
|
1087
|
+
).replace(tzinfo=dt.timezone.utc)
|
|
1088
|
+
# Anchor the current subscription week to Monday 00:00 UTC of
|
|
1089
|
+
# `as_of`'s containing week so the marketing copy ("week of …")
|
|
1090
|
+
# lines up with the stats.db rows seeded by build-readme-fixtures.
|
|
1091
|
+
week_start = (as_of - dt.timedelta(days=as_of.weekday())).replace(
|
|
1092
|
+
hour=0, minute=0, second=0, microsecond=0,
|
|
1093
|
+
)
|
|
1094
|
+
week_end = week_start + dt.timedelta(days=7)
|
|
1095
|
+
used_pct = 53.0
|
|
1096
|
+
spent_usd = 28.62
|
|
1097
|
+
cw = TuiCurrentWeek(
|
|
1098
|
+
week_start_at=week_start,
|
|
1099
|
+
week_end_at=week_end,
|
|
1100
|
+
used_pct=used_pct,
|
|
1101
|
+
five_hour_pct=36.0,
|
|
1102
|
+
five_hour_resets_at=as_of.replace(minute=0, second=0, microsecond=0)
|
|
1103
|
+
+ dt.timedelta(hours=3),
|
|
1104
|
+
spent_usd=spent_usd,
|
|
1105
|
+
dollars_per_percent=spent_usd / used_pct,
|
|
1106
|
+
latest_snapshot_at=as_of,
|
|
1107
|
+
freshness_label="fresh",
|
|
1108
|
+
freshness_age=12,
|
|
1109
|
+
five_hour_block=None,
|
|
1110
|
+
)
|
|
1111
|
+
# ---- Forecast: WARN, ~98% projected, fits within modal width.
|
|
1112
|
+
# The TUI's verdict mapping (`_tui_verdict_of`) reads
|
|
1113
|
+
# `final_percent_high >= 100` as OVER, `>= 90` as WARN. We want
|
|
1114
|
+
# WARN here, so synthesize r_avg/r_recent that land both
|
|
1115
|
+
# projection bars in the 90s. Note: this is the TUI-only render
|
|
1116
|
+
# path; the dashboard re-derives forecast from the seeded
|
|
1117
|
+
# fixture DB via `snapshot_to_envelope` and lands at ~103%
|
|
1118
|
+
# there (which the dashboard's verdict map calls WARN already
|
|
1119
|
+
# via the `cap` enum, with no >=100 threshold split).
|
|
1120
|
+
elapsed_hours = (as_of - week_start).total_seconds() / 3600.0
|
|
1121
|
+
remaining_hours = max(0.0, (week_end - as_of).total_seconds() / 3600.0)
|
|
1122
|
+
remaining_days = remaining_hours / 24.0
|
|
1123
|
+
# Headline projection target: ~98% → r_avg = (98 - 53) / 82 ≈ 0.549.
|
|
1124
|
+
r_avg = (98.0 - used_pct) / remaining_hours if remaining_hours > 0 else 0.0
|
|
1125
|
+
# Recent 24h slightly lower: ~94% → r_recent = (94 - 53) / 82 ≈ 0.500.
|
|
1126
|
+
r_recent = (94.0 - used_pct) / remaining_hours if remaining_hours > 0 else 0.0
|
|
1127
|
+
p_24h_ago = max(0.0, used_pct - r_recent * 24.0)
|
|
1128
|
+
dpp = spent_usd / used_pct
|
|
1129
|
+
final_low = used_pct + r_recent * remaining_hours
|
|
1130
|
+
final_high = used_pct + r_avg * remaining_hours
|
|
1131
|
+
# Two BudgetRows mirroring the TUI's hard-coded targets [100, 90].
|
|
1132
|
+
budgets = [
|
|
1133
|
+
BudgetRow(
|
|
1134
|
+
target_percent=100,
|
|
1135
|
+
pct_headroom=100.0 - used_pct,
|
|
1136
|
+
dollars_per_day=((100.0 - used_pct) * dpp / remaining_days)
|
|
1137
|
+
if remaining_days > 0 else None,
|
|
1138
|
+
percent_per_day=((100.0 - used_pct) / remaining_days)
|
|
1139
|
+
if remaining_days > 0 else None,
|
|
1140
|
+
),
|
|
1141
|
+
BudgetRow(
|
|
1142
|
+
target_percent=90,
|
|
1143
|
+
pct_headroom=90.0 - used_pct,
|
|
1144
|
+
dollars_per_day=((90.0 - used_pct) * dpp / remaining_days)
|
|
1145
|
+
if remaining_days > 0 else None,
|
|
1146
|
+
percent_per_day=((90.0 - used_pct) / remaining_days)
|
|
1147
|
+
if remaining_days > 0 else None,
|
|
1148
|
+
),
|
|
1149
|
+
]
|
|
1150
|
+
forecast_inputs = ForecastInputs(
|
|
1151
|
+
now_utc=as_of,
|
|
1152
|
+
week_start_at=week_start,
|
|
1153
|
+
week_end_at=week_end,
|
|
1154
|
+
elapsed_hours=elapsed_hours,
|
|
1155
|
+
elapsed_fraction=elapsed_hours / 168.0,
|
|
1156
|
+
remaining_hours=remaining_hours,
|
|
1157
|
+
remaining_days=remaining_days,
|
|
1158
|
+
p_now=used_pct,
|
|
1159
|
+
five_hour_percent=36.0,
|
|
1160
|
+
spent_usd=spent_usd,
|
|
1161
|
+
snapshot_count=12,
|
|
1162
|
+
latest_snapshot_at=as_of,
|
|
1163
|
+
p_24h_ago=p_24h_ago,
|
|
1164
|
+
t_24h_actual_hours=24.0,
|
|
1165
|
+
dollars_per_percent=dpp,
|
|
1166
|
+
dollars_per_percent_source="this_week",
|
|
1167
|
+
confidence="high",
|
|
1168
|
+
low_confidence_reasons=[],
|
|
1169
|
+
)
|
|
1170
|
+
forecast = ForecastOutput(
|
|
1171
|
+
inputs=forecast_inputs,
|
|
1172
|
+
r_avg=r_avg,
|
|
1173
|
+
r_recent=r_recent,
|
|
1174
|
+
final_percent_low=final_low,
|
|
1175
|
+
final_percent_high=final_high,
|
|
1176
|
+
projected_cap=final_high >= 100.0,
|
|
1177
|
+
already_capped=False,
|
|
1178
|
+
cap_at=None,
|
|
1179
|
+
budgets=budgets,
|
|
1180
|
+
)
|
|
1181
|
+
# ---- Trend: 8 weeks oldest-first, mirroring the
|
|
1182
|
+
# `_populate_weeks` series in bin/build-readme-fixtures.py.
|
|
1183
|
+
# Spark heights computed the same way `_tui_build_trend` does
|
|
1184
|
+
# (normalize $/1% to 1..8 across the window).
|
|
1185
|
+
weekly_series = [
|
|
1186
|
+
(38.0, 24.70),
|
|
1187
|
+
(41.0, 25.83),
|
|
1188
|
+
(44.0, 25.96),
|
|
1189
|
+
(47.0, 24.91),
|
|
1190
|
+
(50.0, 25.00),
|
|
1191
|
+
(53.0, 22.79),
|
|
1192
|
+
(56.0, 25.20),
|
|
1193
|
+
(used_pct, spent_usd), # current week
|
|
1194
|
+
]
|
|
1195
|
+
dpps = [round(c / p, 4) for p, c in weekly_series]
|
|
1196
|
+
lo, hi = min(dpps), max(dpps)
|
|
1197
|
+
span = (hi - lo) or 1e-9
|
|
1198
|
+
trend: list[TuiTrendRow] = []
|
|
1199
|
+
prev_dpp: float | None = None
|
|
1200
|
+
for i, ((pct, cost), wd) in enumerate(zip(weekly_series, dpps)):
|
|
1201
|
+
offset = 7 - i
|
|
1202
|
+
wstart_dt = week_start - dt.timedelta(days=7 * offset)
|
|
1203
|
+
spark = max(1, min(8, int(round((wd - lo) / span * 7)) + 1))
|
|
1204
|
+
delta = (wd - prev_dpp) if prev_dpp is not None else None
|
|
1205
|
+
trend.append(TuiTrendRow(
|
|
1206
|
+
week_label=wstart_dt.strftime("%b %d"),
|
|
1207
|
+
week_start_at=wstart_dt,
|
|
1208
|
+
used_pct=pct,
|
|
1209
|
+
dollars_per_percent=wd,
|
|
1210
|
+
delta_dpp=delta,
|
|
1211
|
+
spark_height=spark,
|
|
1212
|
+
is_current=(i == 7),
|
|
1213
|
+
))
|
|
1214
|
+
prev_dpp = wd
|
|
1215
|
+
# ---- Sessions: 6 recent rows spanning 4 projects + 3 models,
|
|
1216
|
+
# ordered last-activity desc (matches the aggregator's natural
|
|
1217
|
+
# output, which the TUI's default sort preserves).
|
|
1218
|
+
sessions = [
|
|
1219
|
+
TuiSessionRow(
|
|
1220
|
+
started_at=as_of - dt.timedelta(hours=1, minutes=22),
|
|
1221
|
+
duration_minutes=46.0,
|
|
1222
|
+
model_primary="claude-sonnet-4-6",
|
|
1223
|
+
cost_usd=2.84,
|
|
1224
|
+
cache_hit_pct=87.5,
|
|
1225
|
+
project_label="web-app",
|
|
1226
|
+
session_id="sess-web-app-00",
|
|
1227
|
+
),
|
|
1228
|
+
TuiSessionRow(
|
|
1229
|
+
started_at=as_of - dt.timedelta(hours=3, minutes=10),
|
|
1230
|
+
duration_minutes=72.0,
|
|
1231
|
+
model_primary="claude-opus-4-7",
|
|
1232
|
+
cost_usd=4.97,
|
|
1233
|
+
cache_hit_pct=72.0,
|
|
1234
|
+
project_label="api-gateway",
|
|
1235
|
+
session_id="sess-api-gateway-01",
|
|
1236
|
+
),
|
|
1237
|
+
TuiSessionRow(
|
|
1238
|
+
started_at=as_of - dt.timedelta(hours=5, minutes=44),
|
|
1239
|
+
duration_minutes=33.0,
|
|
1240
|
+
model_primary="claude-haiku-4-5-20251001",
|
|
1241
|
+
cost_usd=0.62,
|
|
1242
|
+
cache_hit_pct=91.3,
|
|
1243
|
+
project_label="data-pipeline",
|
|
1244
|
+
session_id="sess-data-pipeline-02",
|
|
1245
|
+
),
|
|
1246
|
+
TuiSessionRow(
|
|
1247
|
+
started_at=as_of - dt.timedelta(hours=8, minutes=5),
|
|
1248
|
+
duration_minutes=58.0,
|
|
1249
|
+
model_primary="claude-sonnet-4-6",
|
|
1250
|
+
cost_usd=3.41,
|
|
1251
|
+
cache_hit_pct=79.8,
|
|
1252
|
+
project_label="mobile-client",
|
|
1253
|
+
session_id="sess-mobile-client-00",
|
|
1254
|
+
),
|
|
1255
|
+
TuiSessionRow(
|
|
1256
|
+
started_at=as_of - dt.timedelta(days=1, hours=2),
|
|
1257
|
+
duration_minutes=104.0,
|
|
1258
|
+
model_primary="claude-opus-4-7",
|
|
1259
|
+
cost_usd=6.18,
|
|
1260
|
+
cache_hit_pct=68.4,
|
|
1261
|
+
project_label="web-app",
|
|
1262
|
+
session_id="sess-web-app-01",
|
|
1263
|
+
),
|
|
1264
|
+
TuiSessionRow(
|
|
1265
|
+
started_at=as_of - dt.timedelta(days=1, hours=6, minutes=30),
|
|
1266
|
+
duration_minutes=29.0,
|
|
1267
|
+
model_primary="claude-sonnet-4-6",
|
|
1268
|
+
cost_usd=1.55,
|
|
1269
|
+
cache_hit_pct=84.1,
|
|
1270
|
+
project_label="api-gateway",
|
|
1271
|
+
session_id="sess-api-gateway-02",
|
|
1272
|
+
),
|
|
1273
|
+
]
|
|
1274
|
+
return cls(
|
|
1275
|
+
current_week=cw,
|
|
1276
|
+
forecast=forecast,
|
|
1277
|
+
trend=trend,
|
|
1278
|
+
sessions=sessions,
|
|
1279
|
+
last_sync_at=None,
|
|
1280
|
+
last_sync_error=None,
|
|
1281
|
+
generated_at=as_of,
|
|
1282
|
+
)
|
|
1283
|
+
|
|
1284
|
+
|
|
1285
|
+
@dataclass
|
|
1286
|
+
class RuntimeState:
|
|
1287
|
+
"""Main-thread-only UI state. Not shared with sync thread."""
|
|
1288
|
+
variant: str # 'conventional' | 'expressive'
|
|
1289
|
+
focus_index: int # 0..3 for A; always 3 (sessions) for B
|
|
1290
|
+
session_scroll: int # topmost visible session row index
|
|
1291
|
+
show_help: bool
|
|
1292
|
+
toast: tuple[str, float] | None # (message, monotonic_expiry)
|
|
1293
|
+
color_enabled: bool
|
|
1294
|
+
tz: str # 'utc' | 'local' | IANA name (legacy token; F4 moved _tui_format_started to consume display_tz directly. Field retained for back-compat call sites.)
|
|
1295
|
+
# Resolved display timezone (per spec §2: --tz flag > config.display.tz > host).
|
|
1296
|
+
# ZoneInfo means "render in this zone"; None means "host-local via bare
|
|
1297
|
+
# astimezone()". Threaded through renderers that call format_display_dt.
|
|
1298
|
+
display_tz: "ZoneInfo | None" = None
|
|
1299
|
+
# ---- v2 additions (spec §3.5, §4.4) ----
|
|
1300
|
+
sort_key: str = "last-activity" # 'last-activity'|'cost'|'duration'|'model'|'project'
|
|
1301
|
+
filter_term: str | None = None # None = no active filter
|
|
1302
|
+
search_term: str | None = None # None = no search; "" = active but empty buffer
|
|
1303
|
+
search_matches: list[int] = field(default_factory=list) # indices into post-filter+sort list
|
|
1304
|
+
search_index: int = 0 # current match in search_matches[]
|
|
1305
|
+
input_mode: str | None = None # 'filter' | 'search' | None
|
|
1306
|
+
input_buffer: str = "" # live typing during input mode
|
|
1307
|
+
modal_kind: str | None = None # 'current_week'|'forecast'|'trend'|'session'|None
|
|
1308
|
+
modal_scroll: int = 0 # topmost visible modal content line
|
|
1309
|
+
# One-shot "snap to bottom on first render" flag for modals that default to
|
|
1310
|
+
# the newest rows (trend, current_week). Set by modal openers; cleared by
|
|
1311
|
+
# the first builder call that performs the snap. Avoids reusing
|
|
1312
|
+
# modal_scroll==0 as a sentinel — otherwise scrolling to the top would
|
|
1313
|
+
# bounce the view back to the bottom on the next redraw.
|
|
1314
|
+
modal_snap_pending: bool = False
|
|
1315
|
+
# ---- v2.4.4 fixture-injection hook (dev-only) ----
|
|
1316
|
+
session_detail_override: Any = None # TuiSessionDetail | None — injected by fixtures only
|
|
1317
|
+
# Memoized session detail to avoid rebuilding (365-day rescan + re-aggregate)
|
|
1318
|
+
# on every modal redraw tick. Key: (session_id, snap.generated_at).
|
|
1319
|
+
session_detail_cache: Any = None # tuple[str, dt.datetime, TuiSessionDetail | None] | None
|
|
1320
|
+
|
|
1321
|
+
@classmethod
|
|
1322
|
+
def initial(cls, args) -> "RuntimeState":
|
|
1323
|
+
no_color_env = "NO_COLOR" in os.environ
|
|
1324
|
+
return cls(
|
|
1325
|
+
variant=args.variant,
|
|
1326
|
+
focus_index=3, # sessions focused by default (design choice)
|
|
1327
|
+
session_scroll=0,
|
|
1328
|
+
show_help=False,
|
|
1329
|
+
toast=None,
|
|
1330
|
+
color_enabled=not (args.no_color or no_color_env),
|
|
1331
|
+
tz=args.tz,
|
|
1332
|
+
display_tz=getattr(args, "_resolved_tz", None),
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def _tui_build_current_week(
|
|
1337
|
+
conn: sqlite3.Connection,
|
|
1338
|
+
now_utc: dt.datetime,
|
|
1339
|
+
*,
|
|
1340
|
+
skip_sync: bool = False,
|
|
1341
|
+
) -> TuiCurrentWeek | None:
|
|
1342
|
+
"""Build the TuiCurrentWeek from the latest snapshot + live cost.
|
|
1343
|
+
|
|
1344
|
+
Returns None when no current-week usage snapshot exists.
|
|
1345
|
+
"""
|
|
1346
|
+
fetched = _fetch_current_week_snapshots(conn, now_utc)
|
|
1347
|
+
if fetched is None:
|
|
1348
|
+
return None
|
|
1349
|
+
week_start_at, week_end_at, samples = fetched
|
|
1350
|
+
if not samples:
|
|
1351
|
+
return None
|
|
1352
|
+
# Mirror the reset override applied by `_load_forecast_inputs` so the
|
|
1353
|
+
# Current Week card's spent_usd and $/1% reflect the post-reset window.
|
|
1354
|
+
week_start_at, samples = _apply_midweek_reset_override(
|
|
1355
|
+
conn, week_start_at, week_end_at, samples
|
|
1356
|
+
)
|
|
1357
|
+
if not samples:
|
|
1358
|
+
return None
|
|
1359
|
+
# samples tuple shape: (captured_at_utc, weekly_percent, five_hour_percent).
|
|
1360
|
+
# See _fetch_current_week_snapshots at bin/cctally:9122
|
|
1361
|
+
# (lines ~9189-9194 and ~9221-9226). That helper does not surface
|
|
1362
|
+
# five_hour_resets_at, so do a targeted lookup here for the freshest
|
|
1363
|
+
# non-NULL reset timestamp on the current week.
|
|
1364
|
+
latest = samples[-1]
|
|
1365
|
+
used_pct = float(latest[1])
|
|
1366
|
+
five_hr_pct = float(latest[2]) if latest[2] is not None else None
|
|
1367
|
+
spent = _sum_cost_for_range(
|
|
1368
|
+
week_start_at, now_utc, mode="auto", skip_sync=skip_sync
|
|
1369
|
+
)
|
|
1370
|
+
dpp = (spent / used_pct) if used_pct > 0 else None
|
|
1371
|
+
# Collect every textual variant of week_start_at that parses to the same
|
|
1372
|
+
# instant — mirrors `_fetch_current_week_snapshots` lines 9199-9210 so
|
|
1373
|
+
# legacy local-offset rows and newly UTC-canonicalized rows both contribute.
|
|
1374
|
+
ws_texts = conn.execute(
|
|
1375
|
+
"SELECT DISTINCT week_start_at FROM weekly_usage_snapshots "
|
|
1376
|
+
"WHERE week_start_at IS NOT NULL"
|
|
1377
|
+
).fetchall()
|
|
1378
|
+
matching_ws_texts: list[str] = []
|
|
1379
|
+
for r in ws_texts:
|
|
1380
|
+
try:
|
|
1381
|
+
rws = parse_iso_datetime(r[0], "week_start_at")
|
|
1382
|
+
except ValueError:
|
|
1383
|
+
continue
|
|
1384
|
+
if rws == week_start_at:
|
|
1385
|
+
matching_ws_texts.append(r[0])
|
|
1386
|
+
five_hr_resets_at: dt.datetime | None = None
|
|
1387
|
+
if matching_ws_texts:
|
|
1388
|
+
placeholders = ",".join("?" * len(matching_ws_texts))
|
|
1389
|
+
reset_row = conn.execute(
|
|
1390
|
+
f"SELECT five_hour_resets_at FROM weekly_usage_snapshots "
|
|
1391
|
+
f"WHERE week_start_at IN ({placeholders}) "
|
|
1392
|
+
f" AND five_hour_resets_at IS NOT NULL "
|
|
1393
|
+
f"ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
|
|
1394
|
+
tuple(matching_ws_texts),
|
|
1395
|
+
).fetchone()
|
|
1396
|
+
if reset_row is not None:
|
|
1397
|
+
try:
|
|
1398
|
+
five_hr_resets_at = parse_iso_datetime(
|
|
1399
|
+
reset_row[0], "five_hour_resets_at"
|
|
1400
|
+
)
|
|
1401
|
+
except ValueError:
|
|
1402
|
+
five_hr_resets_at = None
|
|
1403
|
+
# Suppress stale resets that have already elapsed so renderers
|
|
1404
|
+
# don't show "resets 0h 00m" or a negative duration at the boundary.
|
|
1405
|
+
if five_hr_resets_at is not None and five_hr_resets_at <= now_utc:
|
|
1406
|
+
five_hr_resets_at = None
|
|
1407
|
+
# Freshness — compute label/age from latest snapshot vs. now using the
|
|
1408
|
+
# configured oauth_usage thresholds. Mirrors the dashboard envelope's
|
|
1409
|
+
# cw_freshness derivation in `snapshot_to_envelope`. Refs spec §3.4.
|
|
1410
|
+
captured = latest[0]
|
|
1411
|
+
if isinstance(captured, dt.datetime):
|
|
1412
|
+
if captured.tzinfo is None:
|
|
1413
|
+
captured = captured.replace(tzinfo=dt.timezone.utc)
|
|
1414
|
+
age_s = max(0.0, (now_utc - captured).total_seconds())
|
|
1415
|
+
try:
|
|
1416
|
+
_fresh_cfg = _get_oauth_usage_config(load_config())
|
|
1417
|
+
except Exception:
|
|
1418
|
+
_fresh_cfg = _OAUTH_USAGE_DEFAULTS
|
|
1419
|
+
freshness_label = _freshness_label(age_s, _fresh_cfg)
|
|
1420
|
+
freshness_age = int(age_s)
|
|
1421
|
+
else:
|
|
1422
|
+
freshness_label = None
|
|
1423
|
+
freshness_age = None
|
|
1424
|
+
return TuiCurrentWeek(
|
|
1425
|
+
week_start_at=week_start_at,
|
|
1426
|
+
week_end_at=week_end_at,
|
|
1427
|
+
used_pct=used_pct,
|
|
1428
|
+
five_hour_pct=five_hr_pct,
|
|
1429
|
+
five_hour_resets_at=five_hr_resets_at,
|
|
1430
|
+
spent_usd=float(spent),
|
|
1431
|
+
dollars_per_percent=dpp,
|
|
1432
|
+
latest_snapshot_at=latest[0],
|
|
1433
|
+
freshness_label=freshness_label,
|
|
1434
|
+
freshness_age=freshness_age,
|
|
1435
|
+
five_hour_block=_select_current_block_for_envelope(
|
|
1436
|
+
conn, current_used_pct=used_pct, now_utc=now_utc,
|
|
1437
|
+
),
|
|
1438
|
+
)
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _tui_build_forecast(
|
|
1442
|
+
conn: sqlite3.Connection,
|
|
1443
|
+
now_utc: dt.datetime,
|
|
1444
|
+
*,
|
|
1445
|
+
skip_sync: bool = False,
|
|
1446
|
+
):
|
|
1447
|
+
"""Call into existing forecast internals. Returns a ForecastOutput or None."""
|
|
1448
|
+
inputs = _load_forecast_inputs(conn, now_utc, skip_sync=skip_sync)
|
|
1449
|
+
if inputs is None:
|
|
1450
|
+
return None
|
|
1451
|
+
return _compute_forecast(inputs, [100, 90])
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _tui_build_trend(
|
|
1455
|
+
conn: sqlite3.Connection,
|
|
1456
|
+
now_utc: dt.datetime,
|
|
1457
|
+
*,
|
|
1458
|
+
skip_sync: bool = False, # noqa: ARG001 — unused today, kept for API symmetry
|
|
1459
|
+
count: int = 8,
|
|
1460
|
+
display_tz: "ZoneInfo | None" = None,
|
|
1461
|
+
) -> list[TuiTrendRow]:
|
|
1462
|
+
"""Build the last `count` trend rows, chronological (oldest first).
|
|
1463
|
+
|
|
1464
|
+
`cmd_report` inlines its row build rather than delegating to a helper,
|
|
1465
|
+
so instead of refactoring the subcommand we call the same underlying
|
|
1466
|
+
loaders (`get_recent_weeks` + `get_latest_usage_for_week` +
|
|
1467
|
+
`get_latest_cost_for_week`) directly here. Output for the shared
|
|
1468
|
+
columns (`week_start_at`, `used_pct`, `dollars_per_percent`) matches
|
|
1469
|
+
`cmd_report` byte-for-byte — verified in the bundle regression diff.
|
|
1470
|
+
"""
|
|
1471
|
+
# `get_recent_weeks` returns WeekRef rows DESC by week_start_date,
|
|
1472
|
+
# already routed through `_apply_reset_events_to_weekrefs` so credited
|
|
1473
|
+
# weeks come back as TWO refs (pre-credit + post-credit) sharing the
|
|
1474
|
+
# same `WeekRef.key`.
|
|
1475
|
+
week_refs = get_recent_weeks(conn, max(1, count))
|
|
1476
|
+
|
|
1477
|
+
# Figure out which week_ref corresponds to the current subscription week.
|
|
1478
|
+
# Mirrors `cmd_report`'s Bug D pattern: build a current_ref from the
|
|
1479
|
+
# latest usage snapshot, route it through `_apply_reset_events_to_weekrefs`
|
|
1480
|
+
# so its `week_start_at` reflects the post-credit segment (or the
|
|
1481
|
+
# original start for non-credit weeks), then disambiguate the
|
|
1482
|
+
# synthesized pre-credit ref from the live post-credit ref via BOTH
|
|
1483
|
+
# `key` AND `week_start_at`. Key-only equality marks both segments
|
|
1484
|
+
# as current, which is why the dashboard's trend panel previously
|
|
1485
|
+
# showed two adjacent rows both highlighted as "current" with
|
|
1486
|
+
# identical 4.0% values on the user's live in-place credit data.
|
|
1487
|
+
latest_usage = conn.execute(
|
|
1488
|
+
"SELECT week_start_date, week_end_date "
|
|
1489
|
+
"FROM weekly_usage_snapshots "
|
|
1490
|
+
"ORDER BY captured_at_utc DESC, id DESC LIMIT 1"
|
|
1491
|
+
).fetchone()
|
|
1492
|
+
current_key: str | None = None
|
|
1493
|
+
current_week_start_at: str | None = None
|
|
1494
|
+
if latest_usage is not None and latest_usage["week_start_date"] is not None:
|
|
1495
|
+
current_key = latest_usage["week_start_date"]
|
|
1496
|
+
try:
|
|
1497
|
+
c = _cctally()
|
|
1498
|
+
canon_start, canon_end = c._get_canonical_boundary_for_date(
|
|
1499
|
+
conn, latest_usage["week_start_date"]
|
|
1500
|
+
)
|
|
1501
|
+
current_ref = c.make_week_ref(
|
|
1502
|
+
week_start_date=latest_usage["week_start_date"],
|
|
1503
|
+
week_end_date=latest_usage["week_end_date"],
|
|
1504
|
+
week_start_at=canon_start,
|
|
1505
|
+
week_end_at=canon_end,
|
|
1506
|
+
)
|
|
1507
|
+
_adjusted = c._apply_reset_events_to_weekrefs(conn, [current_ref])
|
|
1508
|
+
if _adjusted:
|
|
1509
|
+
current_week_start_at = _adjusted[0].week_start_at
|
|
1510
|
+
except (ValueError, sqlite3.DatabaseError, AttributeError):
|
|
1511
|
+
current_week_start_at = None
|
|
1512
|
+
|
|
1513
|
+
# Build an intermediate list of (week_ref, used_pct, dpp) in oldest-first
|
|
1514
|
+
# chronological order.
|
|
1515
|
+
chrono = list(reversed(week_refs))
|
|
1516
|
+
# Split-key set (Bug D): credited weeks appear twice in `week_refs`
|
|
1517
|
+
# with identical `WeekRef.key`. For those keys ONLY, pin
|
|
1518
|
+
# `as_of_utc=week_ref.week_end_at` so each segment finds its own
|
|
1519
|
+
# latest snapshot — without this both segments collapse to the
|
|
1520
|
+
# post-credit snapshot's weekly_percent. Non-credit weeks (single
|
|
1521
|
+
# ref per key) keep the legacy unfiltered lookup.
|
|
1522
|
+
_split_keys = {
|
|
1523
|
+
r.key
|
|
1524
|
+
for r in week_refs
|
|
1525
|
+
if sum(1 for x in week_refs if x.key == r.key) > 1
|
|
1526
|
+
}
|
|
1527
|
+
intermediate: list[tuple[Any, float | None, float | None]] = []
|
|
1528
|
+
for week_ref in chrono:
|
|
1529
|
+
usage = get_latest_usage_for_week(
|
|
1530
|
+
conn,
|
|
1531
|
+
week_ref,
|
|
1532
|
+
as_of_utc=(
|
|
1533
|
+
week_ref.week_end_at if week_ref.key in _split_keys else None
|
|
1534
|
+
),
|
|
1535
|
+
)
|
|
1536
|
+
# See cmd_report for why reset-affected weeks skip the cost cache
|
|
1537
|
+
# and live-compute from session_entries over the effective range.
|
|
1538
|
+
if _week_ref_has_reset_event(conn, week_ref):
|
|
1539
|
+
cost_usd = _compute_cost_for_weekref(week_ref)
|
|
1540
|
+
else:
|
|
1541
|
+
cost = get_latest_cost_for_week(conn, week_ref)
|
|
1542
|
+
cost_usd = float(cost["cost_usd"]) if cost else None
|
|
1543
|
+
percent = float(usage["weekly_percent"]) if usage else None
|
|
1544
|
+
ratio = (cost_usd / percent) if (
|
|
1545
|
+
cost_usd is not None and percent and percent > 0
|
|
1546
|
+
) else None
|
|
1547
|
+
intermediate.append((week_ref, percent, ratio))
|
|
1548
|
+
|
|
1549
|
+
# Normalize dpp into spark heights 1..8 across the window.
|
|
1550
|
+
dpps = [d for _, _, d in intermediate if d is not None]
|
|
1551
|
+
if dpps:
|
|
1552
|
+
lo, hi = min(dpps), max(dpps)
|
|
1553
|
+
span = (hi - lo) or 1e-9
|
|
1554
|
+
else:
|
|
1555
|
+
lo, hi, span = 0.0, 1.0, 1e-9
|
|
1556
|
+
|
|
1557
|
+
out: list[TuiTrendRow] = []
|
|
1558
|
+
prev_dpp: float | None = None
|
|
1559
|
+
for week_ref, percent, dpp in intermediate:
|
|
1560
|
+
delta = (dpp - prev_dpp) if (dpp is not None and prev_dpp is not None) else None
|
|
1561
|
+
spark = 1
|
|
1562
|
+
if dpp is not None:
|
|
1563
|
+
spark = int(round((dpp - lo) / span * 7)) + 1
|
|
1564
|
+
spark = max(1, min(8, spark))
|
|
1565
|
+
# WeekRef.week_start is a date; synthesize a UTC datetime so
|
|
1566
|
+
# TuiTrendRow carries a timezone-aware instant (prefer the explicit
|
|
1567
|
+
# week_start_at if present).
|
|
1568
|
+
if week_ref.week_start_at:
|
|
1569
|
+
week_start_dt = parse_iso_datetime(
|
|
1570
|
+
week_ref.week_start_at, "week_start_at"
|
|
1571
|
+
)
|
|
1572
|
+
week_label = format_display_dt(
|
|
1573
|
+
week_start_dt, display_tz, fmt="%b %d", suffix=False,
|
|
1574
|
+
)
|
|
1575
|
+
else:
|
|
1576
|
+
week_start_dt = dt.datetime.combine(
|
|
1577
|
+
week_ref.week_start, dt.time(0, 0), dt.timezone.utc
|
|
1578
|
+
)
|
|
1579
|
+
# No real boundary instant — format the calendar date directly so
|
|
1580
|
+
# localizing midnight-UTC doesn't shift it to the prior day in
|
|
1581
|
+
# zones west of UTC (e.g. 2026-04-14 → "Apr 13" in America/New_York).
|
|
1582
|
+
week_label = week_ref.week_start.strftime("%b %d")
|
|
1583
|
+
# Bug G (v1.7.2 round-5): match on BOTH `key` AND `week_start_at`
|
|
1584
|
+
# for credited weeks so the pre-credit synthesized ref doesn't
|
|
1585
|
+
# also light up as "current" — both refs share `key`, only their
|
|
1586
|
+
# `week_start_at` differs (post-credit = effective reset moment,
|
|
1587
|
+
# pre-credit = original API-derived start). Non-credit weeks
|
|
1588
|
+
# have only one ref per key so `week_start_at` matching is
|
|
1589
|
+
# automatic. When `current_week_start_at` is None (no reset
|
|
1590
|
+
# event for the current week, or the resolution above failed),
|
|
1591
|
+
# falls back to legacy key-only matching.
|
|
1592
|
+
is_cur = (
|
|
1593
|
+
current_key is not None
|
|
1594
|
+
and week_ref.key == current_key
|
|
1595
|
+
and (
|
|
1596
|
+
current_week_start_at is None
|
|
1597
|
+
or week_ref.week_start_at == current_week_start_at
|
|
1598
|
+
)
|
|
1599
|
+
)
|
|
1600
|
+
out.append(TuiTrendRow(
|
|
1601
|
+
week_label=week_label,
|
|
1602
|
+
week_start_at=week_start_dt,
|
|
1603
|
+
# Preserve None when no usage snapshot exists for this week —
|
|
1604
|
+
# matches `cmd_report`'s "n/a" rendering (9980) and avoids
|
|
1605
|
+
# fabricating a 0.0% row for the phantom-week case (cost
|
|
1606
|
+
# snapshot present, usage snapshot absent).
|
|
1607
|
+
used_pct=float(percent) if percent is not None else None,
|
|
1608
|
+
dollars_per_percent=dpp,
|
|
1609
|
+
delta_dpp=delta,
|
|
1610
|
+
spark_height=spark,
|
|
1611
|
+
is_current=is_cur,
|
|
1612
|
+
))
|
|
1613
|
+
if dpp is not None:
|
|
1614
|
+
prev_dpp = dpp
|
|
1615
|
+
return out
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
def _tui_build_weekly_history(
|
|
1619
|
+
conn: sqlite3.Connection,
|
|
1620
|
+
now_utc: dt.datetime,
|
|
1621
|
+
*,
|
|
1622
|
+
skip_sync: bool = False,
|
|
1623
|
+
count: int = 12,
|
|
1624
|
+
display_tz: "ZoneInfo | None" = None,
|
|
1625
|
+
) -> list[TuiTrendRow]:
|
|
1626
|
+
"""Return the last `count` weeks for the Trend modal (spec §4.6.3).
|
|
1627
|
+
|
|
1628
|
+
Same data shape as `_tui_build_trend` (the panel) — just more rows.
|
|
1629
|
+
The panel renders 8; the modal renders up to 12. Wrapping rather
|
|
1630
|
+
than parameterising the call site keeps the snapshot fields
|
|
1631
|
+
semantically distinct (panel data vs. modal data) and avoids
|
|
1632
|
+
accidental cross-contamination.
|
|
1633
|
+
"""
|
|
1634
|
+
return _tui_build_trend(
|
|
1635
|
+
conn, now_utc, skip_sync=skip_sync, count=count, display_tz=display_tz,
|
|
1636
|
+
)
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
def _tui_build_sessions(
|
|
1640
|
+
now_utc: dt.datetime,
|
|
1641
|
+
*,
|
|
1642
|
+
limit: int = 100,
|
|
1643
|
+
skip_sync: bool = False,
|
|
1644
|
+
) -> list[TuiSessionRow]:
|
|
1645
|
+
"""Load the last `limit` Claude sessions (merged across resumes).
|
|
1646
|
+
|
|
1647
|
+
Started-time descending (matches `_aggregate_claude_sessions` —
|
|
1648
|
+
sorted by `last_activity` DESC). Uses the same aggregator as the
|
|
1649
|
+
`session` subcommand, so row identity and project labels match
|
|
1650
|
+
`cctally session --json` exactly.
|
|
1651
|
+
|
|
1652
|
+
When `skip_sync=True`, honors the parent's `--no-sync` intent: no
|
|
1653
|
+
ingest pass, just read whatever is already cached.
|
|
1654
|
+
"""
|
|
1655
|
+
# Bounded scan window — the sessions pane promises "last `limit`". A
|
|
1656
|
+
# 365-day scan covers virtually all users (even one-session-every-few-days
|
|
1657
|
+
# sparseness still nets the cap). Bounded rather than all-history so
|
|
1658
|
+
# sync-tick cost stays predictable on heavy DBs: the aggregator runs
|
|
1659
|
+
# on every entry in the window before slicing.
|
|
1660
|
+
range_start = now_utc - dt.timedelta(days=365)
|
|
1661
|
+
entries = get_claude_session_entries(range_start, now_utc, skip_sync=skip_sync)
|
|
1662
|
+
sessions = _aggregate_claude_sessions(entries) # last_activity desc
|
|
1663
|
+
out: list[TuiSessionRow] = []
|
|
1664
|
+
for s in sessions[:limit]:
|
|
1665
|
+
duration_min = (s.last_activity - s.first_activity).total_seconds() / 60.0
|
|
1666
|
+
total_read = s.cache_read_tokens
|
|
1667
|
+
total_io = s.input_tokens + s.cache_creation_tokens + s.cache_read_tokens
|
|
1668
|
+
cache_pct = (total_read / total_io * 100) if total_io > 0 else None
|
|
1669
|
+
out.append(TuiSessionRow(
|
|
1670
|
+
started_at=s.first_activity,
|
|
1671
|
+
duration_minutes=duration_min,
|
|
1672
|
+
model_primary=(s.models[0] if s.models else "—"),
|
|
1673
|
+
cost_usd=s.cost_usd,
|
|
1674
|
+
cache_hit_pct=cache_pct,
|
|
1675
|
+
project_label=os.path.basename(s.project_path) or s.project_path,
|
|
1676
|
+
session_id=s.session_id,
|
|
1677
|
+
))
|
|
1678
|
+
return out
|
|
1679
|
+
|
|
1680
|
+
|
|
1681
|
+
@dataclass
|
|
1682
|
+
class TuiSessionDetail:
|
|
1683
|
+
"""Detailed view for the Session modal (spec §4.6.4).
|
|
1684
|
+
|
|
1685
|
+
Built on demand when the modal opens — not part of DataSnapshot.
|
|
1686
|
+
"""
|
|
1687
|
+
session_id: str
|
|
1688
|
+
started_at: dt.datetime
|
|
1689
|
+
last_activity_at: dt.datetime
|
|
1690
|
+
duration_minutes: float
|
|
1691
|
+
project_label: str
|
|
1692
|
+
project_path: str # full cwd
|
|
1693
|
+
source_paths: list[str] # JSONL file paths (for resumed sessions, may be >1)
|
|
1694
|
+
models: list[tuple[str, str]] # [(model_name, role)] role in {"primary","secondary"}
|
|
1695
|
+
input_tokens: int
|
|
1696
|
+
cache_creation_tokens: int
|
|
1697
|
+
cache_read_tokens: int
|
|
1698
|
+
output_tokens: int
|
|
1699
|
+
cache_hit_pct: float | None
|
|
1700
|
+
cost_per_model: list[tuple[str, float]] # [(model_name, cost_usd)]
|
|
1701
|
+
cost_total_usd: float
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def _tui_build_session_detail(
|
|
1705
|
+
session_id: str,
|
|
1706
|
+
*,
|
|
1707
|
+
now_utc: dt.datetime | None = None,
|
|
1708
|
+
) -> TuiSessionDetail | None:
|
|
1709
|
+
"""Look up one session by ID; return None if not found.
|
|
1710
|
+
|
|
1711
|
+
Reuses the same `get_claude_session_entries` + `_aggregate_claude_sessions`
|
|
1712
|
+
pipeline as `_tui_build_sessions` but filters down to the matching ID.
|
|
1713
|
+
Bounded scan window matches the panel builder (365 days).
|
|
1714
|
+
"""
|
|
1715
|
+
now_utc = now_utc or dt.datetime.now(dt.timezone.utc)
|
|
1716
|
+
range_start = now_utc - dt.timedelta(days=365)
|
|
1717
|
+
entries = get_claude_session_entries(range_start, now_utc, skip_sync=True)
|
|
1718
|
+
sessions = _aggregate_claude_sessions(entries)
|
|
1719
|
+
match: Any | None = None
|
|
1720
|
+
for s in sessions:
|
|
1721
|
+
if s.session_id == session_id:
|
|
1722
|
+
match = s
|
|
1723
|
+
break
|
|
1724
|
+
if match is None:
|
|
1725
|
+
return None
|
|
1726
|
+
duration_min = (match.last_activity - match.first_activity).total_seconds() / 60.0
|
|
1727
|
+
total_read = match.cache_read_tokens
|
|
1728
|
+
total_io = match.input_tokens + match.cache_creation_tokens + match.cache_read_tokens
|
|
1729
|
+
cache_pct = (total_read / total_io * 100) if total_io > 0 else None
|
|
1730
|
+
# Build per-model rows. Role is "primary" for the first model seen in
|
|
1731
|
+
# this session (matches `_aggregate_claude_sessions` `models_order`),
|
|
1732
|
+
# "secondary" for the rest.
|
|
1733
|
+
models_with_role: list[tuple[str, str]] = []
|
|
1734
|
+
for i, m in enumerate(match.models):
|
|
1735
|
+
models_with_role.append((m, "primary" if i == 0 else "secondary"))
|
|
1736
|
+
# Per-model cost: prefer the aggregator's `model_breakdowns` (list of
|
|
1737
|
+
# dicts with `"model"` / `"cost"` keys, sorted by cost desc). Fall back
|
|
1738
|
+
# defensively to a single-row total if the attribute is missing or empty
|
|
1739
|
+
# so the modal stays renderable on any aggregator shape change.
|
|
1740
|
+
cost_per_model: list[tuple[str, float]] = []
|
|
1741
|
+
breakdowns = getattr(match, "model_breakdowns", None)
|
|
1742
|
+
if isinstance(breakdowns, list) and breakdowns:
|
|
1743
|
+
for mb in breakdowns:
|
|
1744
|
+
try:
|
|
1745
|
+
cost_per_model.append((str(mb["model"]), float(mb["cost"])))
|
|
1746
|
+
except (KeyError, TypeError, ValueError):
|
|
1747
|
+
continue
|
|
1748
|
+
if not cost_per_model and match.models:
|
|
1749
|
+
# Single-model fallback: attribute total to primary.
|
|
1750
|
+
cost_per_model.append((match.models[0], float(match.cost_usd)))
|
|
1751
|
+
return TuiSessionDetail(
|
|
1752
|
+
session_id=match.session_id,
|
|
1753
|
+
started_at=match.first_activity,
|
|
1754
|
+
last_activity_at=match.last_activity,
|
|
1755
|
+
duration_minutes=duration_min,
|
|
1756
|
+
project_label=os.path.basename(match.project_path) or match.project_path,
|
|
1757
|
+
project_path=match.project_path,
|
|
1758
|
+
source_paths=list(match.source_paths or []),
|
|
1759
|
+
models=models_with_role,
|
|
1760
|
+
input_tokens=int(match.input_tokens),
|
|
1761
|
+
cache_creation_tokens=int(match.cache_creation_tokens),
|
|
1762
|
+
cache_read_tokens=int(match.cache_read_tokens),
|
|
1763
|
+
output_tokens=int(match.output_tokens),
|
|
1764
|
+
cache_hit_pct=cache_pct,
|
|
1765
|
+
cost_per_model=cost_per_model,
|
|
1766
|
+
cost_total_usd=float(match.cost_usd),
|
|
1767
|
+
)
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def _tui_build_snapshot(
|
|
1771
|
+
*,
|
|
1772
|
+
now_utc: dt.datetime | None = None,
|
|
1773
|
+
skip_sync: bool = False,
|
|
1774
|
+
display_tz_pref_override: "str | None" = None,
|
|
1775
|
+
) -> DataSnapshot:
|
|
1776
|
+
"""Single-shot build of a DataSnapshot from the DB + cache.
|
|
1777
|
+
|
|
1778
|
+
Runs in the sync thread. Catches exceptions per sub-build and records
|
|
1779
|
+
them on `last_sync_error` so the UI can surface them without crashing.
|
|
1780
|
+
|
|
1781
|
+
``display_tz_pref_override`` (F3): a canonical tz token (``"local"``
|
|
1782
|
+
/ ``"utc"`` / IANA name) that overrides ``config.display.tz`` for
|
|
1783
|
+
the lifetime of this build. Used by ``cmd_dashboard`` when ``--tz``
|
|
1784
|
+
is supplied so the in-memory zone wins over the persisted config
|
|
1785
|
+
without modifying it. ``None`` means "respect config".
|
|
1786
|
+
"""
|
|
1787
|
+
import time
|
|
1788
|
+
now_utc = now_utc or dt.datetime.now(dt.timezone.utc)
|
|
1789
|
+
# Resolve the display tz once per snapshot so labels rendered into
|
|
1790
|
+
# BlocksPanelRow / future panel rows share a single zone with the
|
|
1791
|
+
# envelope's `display` block. Routed through the shared
|
|
1792
|
+
# `_resolve_display_tz_obj` helper so this site, `_compute_display_block`,
|
|
1793
|
+
# and `_handle_get_block_detail` share identical fallback semantics
|
|
1794
|
+
# (one-shot stderr warning on local-resolution failure). Not threaded
|
|
1795
|
+
# into label-FOR-LOOKUP paths like `_aggregate_monthly` keys (out of
|
|
1796
|
+
# scope for Task 11).
|
|
1797
|
+
_build_display_tz = _resolve_display_tz_obj(
|
|
1798
|
+
_apply_display_tz_override(load_config(), display_tz_pref_override)
|
|
1799
|
+
)
|
|
1800
|
+
conn = open_db()
|
|
1801
|
+
try:
|
|
1802
|
+
errors: list[str] = []
|
|
1803
|
+
cw: TuiCurrentWeek | None = None
|
|
1804
|
+
fc: Any | None = None
|
|
1805
|
+
trend: list[TuiTrendRow] = []
|
|
1806
|
+
sessions: list[TuiSessionRow] = []
|
|
1807
|
+
milestones: list[TuiPercentMilestone] = []
|
|
1808
|
+
history: list[TuiTrendRow] = []
|
|
1809
|
+
weekly_periods: list[WeeklyPeriodRow] = []
|
|
1810
|
+
monthly_periods: list[MonthlyPeriodRow] = []
|
|
1811
|
+
blocks_panel: list[BlocksPanelRow] = []
|
|
1812
|
+
daily_panel: list[DailyPanelRow] = []
|
|
1813
|
+
alerts: list[dict] = []
|
|
1814
|
+
try:
|
|
1815
|
+
cw = _tui_build_current_week(conn, now_utc, skip_sync=skip_sync)
|
|
1816
|
+
except Exception as exc:
|
|
1817
|
+
errors.append(f"current-week: {exc}")
|
|
1818
|
+
try:
|
|
1819
|
+
fc = _tui_build_forecast(conn, now_utc, skip_sync=skip_sync)
|
|
1820
|
+
except Exception as exc:
|
|
1821
|
+
errors.append(f"forecast: {exc}")
|
|
1822
|
+
try:
|
|
1823
|
+
trend = _tui_build_trend(
|
|
1824
|
+
conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
|
|
1825
|
+
)
|
|
1826
|
+
except Exception as exc:
|
|
1827
|
+
errors.append(f"trend: {exc}")
|
|
1828
|
+
try:
|
|
1829
|
+
# The sessions aggregator goes through
|
|
1830
|
+
# `get_claude_session_entries`, which runs `sync_cache` unless
|
|
1831
|
+
# `skip_sync=True` is threaded through. Honor the caller's
|
|
1832
|
+
# intent so `--no-sync` and the initial cache-only paint
|
|
1833
|
+
# both avoid ingest latency/lock contention.
|
|
1834
|
+
sessions = _tui_build_sessions(now_utc, skip_sync=skip_sync)
|
|
1835
|
+
except Exception as exc:
|
|
1836
|
+
errors.append(f"sessions: {exc}")
|
|
1837
|
+
# ---- v2 additions ----
|
|
1838
|
+
try:
|
|
1839
|
+
if cw is not None:
|
|
1840
|
+
milestones = _tui_build_percent_milestones(conn)
|
|
1841
|
+
except Exception as exc:
|
|
1842
|
+
errors.append(f"milestones: {exc}")
|
|
1843
|
+
try:
|
|
1844
|
+
history = _tui_build_weekly_history(
|
|
1845
|
+
conn, now_utc, skip_sync=skip_sync, display_tz=_build_display_tz,
|
|
1846
|
+
)
|
|
1847
|
+
except Exception as exc:
|
|
1848
|
+
errors.append(f"weekly-history: {exc}")
|
|
1849
|
+
# ---- v2.1 additions: dashboard Weekly / Monthly panels ----
|
|
1850
|
+
try:
|
|
1851
|
+
weekly_periods = _dashboard_build_weekly_periods(
|
|
1852
|
+
conn, now_utc, n=12, skip_sync=skip_sync
|
|
1853
|
+
)
|
|
1854
|
+
except Exception as exc:
|
|
1855
|
+
errors.append(f"weekly-periods: {exc}")
|
|
1856
|
+
try:
|
|
1857
|
+
monthly_periods = _dashboard_build_monthly_periods(
|
|
1858
|
+
conn, now_utc, n=12, skip_sync=skip_sync,
|
|
1859
|
+
display_tz=_build_display_tz,
|
|
1860
|
+
)
|
|
1861
|
+
except Exception as exc:
|
|
1862
|
+
errors.append(f"monthly-periods: {exc}")
|
|
1863
|
+
# ---- v2.2 additions: dashboard Blocks / Daily panels ----
|
|
1864
|
+
try:
|
|
1865
|
+
if cw is not None:
|
|
1866
|
+
blocks_panel = _dashboard_build_blocks_panel(
|
|
1867
|
+
conn, now_utc,
|
|
1868
|
+
week_start_at=cw.week_start_at,
|
|
1869
|
+
week_end_at=cw.week_end_at,
|
|
1870
|
+
skip_sync=skip_sync,
|
|
1871
|
+
display_tz=_build_display_tz,
|
|
1872
|
+
)
|
|
1873
|
+
except Exception as exc:
|
|
1874
|
+
errors.append(f"blocks-panel: {exc}")
|
|
1875
|
+
try:
|
|
1876
|
+
daily_panel = _dashboard_build_daily_panel(
|
|
1877
|
+
conn, now_utc, n=30, skip_sync=skip_sync,
|
|
1878
|
+
display_tz=_build_display_tz,
|
|
1879
|
+
)
|
|
1880
|
+
except Exception as exc:
|
|
1881
|
+
errors.append(f"daily-panel: {exc}")
|
|
1882
|
+
# ---- threshold-actions T5: alerts envelope array ----
|
|
1883
|
+
# Precomputed at sync time so `snapshot_to_envelope` stays a pure
|
|
1884
|
+
# renderer (no DB I/O on the dashboard hot path; mirrors how
|
|
1885
|
+
# `current_week.five_hour_block` is precomputed via
|
|
1886
|
+
# `_select_current_block_for_envelope`).
|
|
1887
|
+
try:
|
|
1888
|
+
alerts = _build_alerts_envelope_array(conn)
|
|
1889
|
+
except Exception as exc:
|
|
1890
|
+
errors.append(f"alerts: {exc}")
|
|
1891
|
+
return DataSnapshot(
|
|
1892
|
+
current_week=cw,
|
|
1893
|
+
forecast=fc,
|
|
1894
|
+
trend=trend,
|
|
1895
|
+
sessions=sessions,
|
|
1896
|
+
last_sync_at=time.monotonic(),
|
|
1897
|
+
last_sync_error=("; ".join(errors) if errors else None),
|
|
1898
|
+
generated_at=now_utc,
|
|
1899
|
+
percent_milestones=milestones,
|
|
1900
|
+
weekly_history=history,
|
|
1901
|
+
weekly_periods=weekly_periods,
|
|
1902
|
+
monthly_periods=monthly_periods,
|
|
1903
|
+
blocks_panel=blocks_panel,
|
|
1904
|
+
daily_panel=daily_panel,
|
|
1905
|
+
alerts=alerts,
|
|
1906
|
+
)
|
|
1907
|
+
finally:
|
|
1908
|
+
conn.close()
|
|
1909
|
+
|
|
1910
|
+
|
|
1911
|
+
def _tui_empty_snapshot(now_utc: dt.datetime) -> DataSnapshot:
|
|
1912
|
+
"""First-paint placeholder with no data loaded yet."""
|
|
1913
|
+
return DataSnapshot(
|
|
1914
|
+
current_week=None, forecast=None, trend=[], sessions=[],
|
|
1915
|
+
last_sync_at=None, last_sync_error=None, generated_at=now_utc,
|
|
1916
|
+
percent_milestones=[], weekly_history=[],
|
|
1917
|
+
weekly_periods=[], monthly_periods=[],
|
|
1918
|
+
blocks_panel=[], daily_panel=[],
|
|
1919
|
+
)
|
|
1920
|
+
|
|
1921
|
+
|
|
1922
|
+
class TuiKeyReader:
|
|
1923
|
+
"""Context manager for raw-mode stdin reads.
|
|
1924
|
+
|
|
1925
|
+
Non-TTY input degrades gracefully — read() always returns None.
|
|
1926
|
+
"""
|
|
1927
|
+
|
|
1928
|
+
_ESC_MAP = {
|
|
1929
|
+
"[A": "up", "[B": "down",
|
|
1930
|
+
"[C": "right", "[D": "left",
|
|
1931
|
+
"[5~": "pgup", "[6~": "pgdn",
|
|
1932
|
+
"[H": "home", "[F": "end",
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
def __init__(self) -> None:
|
|
1936
|
+
self._fd = None
|
|
1937
|
+
self._saved = None
|
|
1938
|
+
|
|
1939
|
+
def __enter__(self):
|
|
1940
|
+
try:
|
|
1941
|
+
import termios, tty
|
|
1942
|
+
except ImportError:
|
|
1943
|
+
return self # non-posix: degrade to null reader
|
|
1944
|
+
if not sys.stdin.isatty():
|
|
1945
|
+
return self
|
|
1946
|
+
try:
|
|
1947
|
+
self._fd = sys.stdin.fileno()
|
|
1948
|
+
self._saved = termios.tcgetattr(self._fd)
|
|
1949
|
+
tty.setcbreak(self._fd)
|
|
1950
|
+
except Exception:
|
|
1951
|
+
# Degrade gracefully on any setup error.
|
|
1952
|
+
self._fd = None
|
|
1953
|
+
self._saved = None
|
|
1954
|
+
return self
|
|
1955
|
+
|
|
1956
|
+
def __exit__(self, *exc):
|
|
1957
|
+
if self._fd is not None and self._saved is not None:
|
|
1958
|
+
try:
|
|
1959
|
+
import termios
|
|
1960
|
+
termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved)
|
|
1961
|
+
except Exception:
|
|
1962
|
+
pass
|
|
1963
|
+
|
|
1964
|
+
# SS3 arrows (ESC O X) — sent by terminals in DECCKM "application" mode.
|
|
1965
|
+
_SS3_MAP = {
|
|
1966
|
+
b"A": "up", b"B": "down",
|
|
1967
|
+
b"C": "right", b"D": "left",
|
|
1968
|
+
b"H": "home", b"F": "end",
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
def read(self, timeout: float) -> str | None:
|
|
1972
|
+
"""Blocking read up to `timeout` seconds. Returns a key name or char.
|
|
1973
|
+
|
|
1974
|
+
Reads via os.read on the raw fd rather than sys.stdin.read, because
|
|
1975
|
+
the TextIOWrapper on sys.stdin buffers ahead: sys.stdin.read(1) pulls
|
|
1976
|
+
an ESC sequence like b"\\x1b[B" into Python's buffer as a block, and
|
|
1977
|
+
the follow-up select() on the fd then sees nothing and times out —
|
|
1978
|
+
causing the reader to mis-return "esc" and the handler to quit.
|
|
1979
|
+
"""
|
|
1980
|
+
import select
|
|
1981
|
+
if not sys.stdin.isatty() or self._fd is None:
|
|
1982
|
+
return None
|
|
1983
|
+
fd = self._fd
|
|
1984
|
+
try:
|
|
1985
|
+
r, _, _ = select.select([fd], [], [], max(0.0, timeout))
|
|
1986
|
+
if not r:
|
|
1987
|
+
return None
|
|
1988
|
+
b = os.read(fd, 1)
|
|
1989
|
+
except Exception:
|
|
1990
|
+
return None
|
|
1991
|
+
if not b:
|
|
1992
|
+
return None
|
|
1993
|
+
if b == b"\x1b": # ESC; possibly start of CSI or SS3 sequence
|
|
1994
|
+
# 50ms grace to distinguish lone ESC from an arrow sequence.
|
|
1995
|
+
try:
|
|
1996
|
+
r2, _, _ = select.select([fd], [], [], 0.05)
|
|
1997
|
+
if not r2:
|
|
1998
|
+
return "esc"
|
|
1999
|
+
rest = os.read(fd, 1)
|
|
2000
|
+
except Exception:
|
|
2001
|
+
return "esc"
|
|
2002
|
+
if rest == b"O":
|
|
2003
|
+
# SS3 (application keypad): ESC O X
|
|
2004
|
+
try:
|
|
2005
|
+
r3, _, _ = select.select([fd], [], [], 0.05)
|
|
2006
|
+
if not r3:
|
|
2007
|
+
return "esc"
|
|
2008
|
+
ch = os.read(fd, 1)
|
|
2009
|
+
except Exception:
|
|
2010
|
+
return "esc"
|
|
2011
|
+
return self._SS3_MAP.get(ch, None)
|
|
2012
|
+
if rest != b"[":
|
|
2013
|
+
return "esc"
|
|
2014
|
+
seq = b"["
|
|
2015
|
+
# Read until terminator (letter, ~, or 4-char cap).
|
|
2016
|
+
for _ in range(4):
|
|
2017
|
+
try:
|
|
2018
|
+
r3, _, _ = select.select([fd], [], [], 0.05)
|
|
2019
|
+
if not r3:
|
|
2020
|
+
break
|
|
2021
|
+
ch = os.read(fd, 1)
|
|
2022
|
+
except Exception:
|
|
2023
|
+
break
|
|
2024
|
+
if not ch:
|
|
2025
|
+
break
|
|
2026
|
+
seq += ch
|
|
2027
|
+
if ch == b"~" or (b"A" <= ch <= b"Z") or (b"a" <= ch <= b"z"):
|
|
2028
|
+
break
|
|
2029
|
+
return self._ESC_MAP.get(seq.decode("ascii", errors="replace"), None)
|
|
2030
|
+
if b == b"\t":
|
|
2031
|
+
return "tab"
|
|
2032
|
+
if b == b"\n" or b == b"\r":
|
|
2033
|
+
return "enter"
|
|
2034
|
+
if b == b"\x7f" or b == b"\x08":
|
|
2035
|
+
return "backspace"
|
|
2036
|
+
if b == b"\x03":
|
|
2037
|
+
return "ctrl-c"
|
|
2038
|
+
try:
|
|
2039
|
+
return b.decode("utf-8", errors="replace")
|
|
2040
|
+
except Exception:
|
|
2041
|
+
return None
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
def _tui_handle_key(
|
|
2045
|
+
key: str,
|
|
2046
|
+
runtime: RuntimeState,
|
|
2047
|
+
snapshot_ref: "_SnapshotRef",
|
|
2048
|
+
) -> tuple[bool, bool]:
|
|
2049
|
+
"""Mutate `runtime` in place. Returns (should_redraw, should_quit).
|
|
2050
|
+
|
|
2051
|
+
`snapshot_ref` is the shared-state holder; key handler may request a
|
|
2052
|
+
force sync via snapshot_ref.request_sync().
|
|
2053
|
+
"""
|
|
2054
|
+
import time
|
|
2055
|
+
# Dismiss toast on any key.
|
|
2056
|
+
if runtime.toast is not None:
|
|
2057
|
+
runtime.toast = None
|
|
2058
|
+
|
|
2059
|
+
# v2: modal state — captures most keys (spec §2.3 Modal column).
|
|
2060
|
+
# Placed first so Esc dismisses the modal instead of falling
|
|
2061
|
+
# through to the dashboard's default Esc-quits. modal_kind and
|
|
2062
|
+
# input_mode are mutually exclusive (modal openers gate on
|
|
2063
|
+
# input_mode is None; input openers gate on modal_kind is None),
|
|
2064
|
+
# so this branch never collides with input-mode dispatch.
|
|
2065
|
+
if runtime.modal_kind is not None:
|
|
2066
|
+
if key == "esc":
|
|
2067
|
+
runtime.modal_kind = None
|
|
2068
|
+
runtime.modal_scroll = 0
|
|
2069
|
+
return True, False
|
|
2070
|
+
if key in ("q", "ctrl-c"):
|
|
2071
|
+
return False, True # quit always works
|
|
2072
|
+
if key in ("up", "k"):
|
|
2073
|
+
runtime.modal_scroll = max(0, runtime.modal_scroll - 1)
|
|
2074
|
+
return True, False
|
|
2075
|
+
if key in ("down", "j"):
|
|
2076
|
+
runtime.modal_scroll = runtime.modal_scroll + 1
|
|
2077
|
+
return True, False
|
|
2078
|
+
if key == "pgup":
|
|
2079
|
+
runtime.modal_scroll = max(0, runtime.modal_scroll - 10)
|
|
2080
|
+
return True, False
|
|
2081
|
+
if key == "pgdn":
|
|
2082
|
+
runtime.modal_scroll = runtime.modal_scroll + 10
|
|
2083
|
+
return True, False
|
|
2084
|
+
# All other dashboard-layer keys (Tab, s, f, /, v, r, ?, Enter, 1-4)
|
|
2085
|
+
# are silently swallowed per spec §2.4.
|
|
2086
|
+
return True, False
|
|
2087
|
+
|
|
2088
|
+
# In input mode, only ctrl-c quits; esc/q are handled by the input
|
|
2089
|
+
# mode dispatch (esc cancels, q is just a printable character to append).
|
|
2090
|
+
if runtime.input_mode is not None and key == "ctrl-c":
|
|
2091
|
+
return False, True
|
|
2092
|
+
if runtime.input_mode is None and key in ("q", "ctrl-c", "esc"):
|
|
2093
|
+
# Esc only quits when no help overlay is showing.
|
|
2094
|
+
if key == "esc" and runtime.show_help:
|
|
2095
|
+
runtime.show_help = False
|
|
2096
|
+
return True, False
|
|
2097
|
+
return False, True
|
|
2098
|
+
if runtime.input_mode is None and key == "?":
|
|
2099
|
+
runtime.show_help = not runtime.show_help
|
|
2100
|
+
return True, False
|
|
2101
|
+
if runtime.input_mode is None and key == "v":
|
|
2102
|
+
runtime.variant = ("expressive" if runtime.variant == "conventional"
|
|
2103
|
+
else "conventional")
|
|
2104
|
+
return True, False
|
|
2105
|
+
if runtime.input_mode is None and key == "r":
|
|
2106
|
+
snapshot_ref.request_sync()
|
|
2107
|
+
runtime.toast = ("syncing…", time.monotonic() + 1.0)
|
|
2108
|
+
return True, False
|
|
2109
|
+
if runtime.input_mode is None and key == "tab":
|
|
2110
|
+
runtime.focus_index = (runtime.focus_index + 1) % 4
|
|
2111
|
+
return True, False
|
|
2112
|
+
# Scroll (targets sessions when focused; in variant B, always sessions).
|
|
2113
|
+
is_sessions_focus = (runtime.variant == "expressive"
|
|
2114
|
+
or runtime.focus_index == 3)
|
|
2115
|
+
if runtime.input_mode is None and is_sessions_focus:
|
|
2116
|
+
# v2: n / N — navigate confirmed search matches (spec §3.3).
|
|
2117
|
+
if (key in ("n", "N")
|
|
2118
|
+
and runtime.search_term is not None
|
|
2119
|
+
and runtime.search_matches
|
|
2120
|
+
and runtime.modal_kind is None):
|
|
2121
|
+
if key == "n":
|
|
2122
|
+
runtime.search_index = (runtime.search_index + 1) % len(runtime.search_matches)
|
|
2123
|
+
else:
|
|
2124
|
+
runtime.search_index = (runtime.search_index - 1) % len(runtime.search_matches)
|
|
2125
|
+
runtime.session_scroll = runtime.search_matches[runtime.search_index]
|
|
2126
|
+
return True, False
|
|
2127
|
+
if key in ("up", "k"):
|
|
2128
|
+
runtime.session_scroll = max(0, runtime.session_scroll - 1)
|
|
2129
|
+
return True, False
|
|
2130
|
+
if key in ("down", "j"):
|
|
2131
|
+
runtime.session_scroll = runtime.session_scroll + 1
|
|
2132
|
+
return True, False
|
|
2133
|
+
if key == "pgup":
|
|
2134
|
+
runtime.session_scroll = max(0, runtime.session_scroll - 10)
|
|
2135
|
+
return True, False
|
|
2136
|
+
if key == "pgdn":
|
|
2137
|
+
runtime.session_scroll = runtime.session_scroll + 10
|
|
2138
|
+
return True, False
|
|
2139
|
+
# v2: sessions sort cycle (spec §3.1). Sessions-scoped regardless of focus.
|
|
2140
|
+
if key == "s" and runtime.input_mode is None and runtime.modal_kind is None:
|
|
2141
|
+
runtime.sort_key = _tui_next_sort_key(runtime.sort_key)
|
|
2142
|
+
# Spec §3.3: search clears when sort changes — match indices were
|
|
2143
|
+
# computed against the previous ordering and would jump to wrong rows.
|
|
2144
|
+
runtime.search_term = None
|
|
2145
|
+
runtime.search_matches = []
|
|
2146
|
+
runtime.search_index = 0
|
|
2147
|
+
return True, False
|
|
2148
|
+
|
|
2149
|
+
# v2: filter — open input mode (spec §3.2). Sessions-scoped regardless of focus.
|
|
2150
|
+
if key == "f" and runtime.input_mode is None and runtime.modal_kind is None:
|
|
2151
|
+
runtime.input_mode = "filter"
|
|
2152
|
+
# Edit-existing semantics: pre-load the buffer with current filter.
|
|
2153
|
+
runtime.input_buffer = runtime.filter_term or ""
|
|
2154
|
+
runtime.show_help = False # mirror Enter/1-4: state change closes help
|
|
2155
|
+
return True, False
|
|
2156
|
+
|
|
2157
|
+
# v2: filter input mode key dispatch (spec §2.3 + §3.2).
|
|
2158
|
+
if runtime.input_mode == "filter":
|
|
2159
|
+
if key == "esc":
|
|
2160
|
+
runtime.input_mode = None
|
|
2161
|
+
runtime.input_buffer = ""
|
|
2162
|
+
return True, False
|
|
2163
|
+
if key == "enter":
|
|
2164
|
+
buf = runtime.input_buffer.strip()
|
|
2165
|
+
runtime.filter_term = buf if buf else None
|
|
2166
|
+
runtime.input_mode = None
|
|
2167
|
+
runtime.input_buffer = ""
|
|
2168
|
+
# Reset session_scroll to top so user lands on first match.
|
|
2169
|
+
runtime.session_scroll = 0
|
|
2170
|
+
# Spec §3.3: search clears when filter changes — narrowing
|
|
2171
|
+
# invalidates the match index list.
|
|
2172
|
+
runtime.search_term = None
|
|
2173
|
+
runtime.search_matches = []
|
|
2174
|
+
runtime.search_index = 0
|
|
2175
|
+
return True, False
|
|
2176
|
+
if key == "backspace":
|
|
2177
|
+
runtime.input_buffer = runtime.input_buffer[:-1]
|
|
2178
|
+
return True, False
|
|
2179
|
+
# Printable: append. Multi-layer defense per memory:
|
|
2180
|
+
# clip on append (max 200), only printable, ignore unrecognised.
|
|
2181
|
+
if isinstance(key, str) and len(key) == 1 and key.isprintable():
|
|
2182
|
+
if len(runtime.input_buffer) < 200:
|
|
2183
|
+
runtime.input_buffer += key
|
|
2184
|
+
return True, False
|
|
2185
|
+
# All other keys swallowed silently in input mode.
|
|
2186
|
+
return True, False
|
|
2187
|
+
|
|
2188
|
+
# v2: search — open input mode (spec §3.3).
|
|
2189
|
+
if key == "/" and runtime.input_mode is None and runtime.modal_kind is None:
|
|
2190
|
+
runtime.input_mode = "search"
|
|
2191
|
+
runtime.input_buffer = "" # always start fresh per spec §3.3
|
|
2192
|
+
# Stale search_index from a prior query would make the first
|
|
2193
|
+
# post-confirm n/N wrap past the first matches of the new query.
|
|
2194
|
+
runtime.search_index = 0
|
|
2195
|
+
runtime.show_help = False # mirror Enter/1-4: state change closes help
|
|
2196
|
+
return True, False
|
|
2197
|
+
|
|
2198
|
+
# v2: search input mode key dispatch.
|
|
2199
|
+
if runtime.input_mode == "search":
|
|
2200
|
+
if key == "esc":
|
|
2201
|
+
runtime.input_mode = None
|
|
2202
|
+
runtime.input_buffer = ""
|
|
2203
|
+
# Cancel restores selection: clear matches/highlights.
|
|
2204
|
+
runtime.search_term = None
|
|
2205
|
+
runtime.search_matches = []
|
|
2206
|
+
runtime.search_index = 0
|
|
2207
|
+
return True, False
|
|
2208
|
+
if key == "enter":
|
|
2209
|
+
buf = runtime.input_buffer
|
|
2210
|
+
runtime.search_term = buf if buf else None
|
|
2211
|
+
runtime.input_mode = None
|
|
2212
|
+
runtime.input_buffer = ""
|
|
2213
|
+
# Match list will be populated by the renderer (it knows the
|
|
2214
|
+
# current post-filter+sort list). For now, scroll stays where
|
|
2215
|
+
# the live jump put it.
|
|
2216
|
+
return True, False
|
|
2217
|
+
if key == "backspace":
|
|
2218
|
+
runtime.input_buffer = runtime.input_buffer[:-1]
|
|
2219
|
+
return True, False
|
|
2220
|
+
if isinstance(key, str) and len(key) == 1 and key.isprintable():
|
|
2221
|
+
if len(runtime.input_buffer) < 200:
|
|
2222
|
+
runtime.input_buffer += key
|
|
2223
|
+
return True, False
|
|
2224
|
+
return True, False
|
|
2225
|
+
|
|
2226
|
+
# v2: Enter — open detail modal (spec §2.3 + §4.2).
|
|
2227
|
+
# Both variants: focus_index maps to modal kind.
|
|
2228
|
+
if key == "enter" and runtime.input_mode is None and runtime.modal_kind is None:
|
|
2229
|
+
target_kind = ("current_week", "forecast", "trend", "session")[runtime.focus_index]
|
|
2230
|
+
runtime.modal_kind = target_kind
|
|
2231
|
+
runtime.modal_scroll = 0
|
|
2232
|
+
runtime.modal_snap_pending = True # trend/current_week: snap to bottom on first render
|
|
2233
|
+
runtime.show_help = False # mutually exclusive (spec §4.2)
|
|
2234
|
+
return True, False
|
|
2235
|
+
|
|
2236
|
+
# v2: 1-4 universal modal shortcuts (spec §1, Q6a).
|
|
2237
|
+
if (key in ("1", "2", "3", "4")
|
|
2238
|
+
and runtime.input_mode is None
|
|
2239
|
+
and runtime.modal_kind is None):
|
|
2240
|
+
target_kind = ("current_week", "forecast", "trend", "session")[int(key) - 1]
|
|
2241
|
+
runtime.modal_kind = target_kind
|
|
2242
|
+
runtime.modal_scroll = 0
|
|
2243
|
+
runtime.modal_snap_pending = True
|
|
2244
|
+
runtime.show_help = False
|
|
2245
|
+
return True, False
|
|
2246
|
+
|
|
2247
|
+
return False, False
|
|
2248
|
+
|
|
2249
|
+
|
|
2250
|
+
class _TuiSyncThread:
|
|
2251
|
+
"""Daemon thread that periodically rebuilds the DataSnapshot.
|
|
2252
|
+
|
|
2253
|
+
Honors --no-sync by never syncing (only reading the current DB state).
|
|
2254
|
+
Force-refresh via `snapshot_ref.request_sync()` — thread interrupts its
|
|
2255
|
+
sleep and rebuilds immediately.
|
|
2256
|
+
|
|
2257
|
+
When ``now_utc`` is provided (propagated from cmd_tui when --as-of is
|
|
2258
|
+
set), every rebuild pins the snapshot clock to that value so live mode
|
|
2259
|
+
mirrors --render-once determinism. When None, rebuilds use wall clock.
|
|
2260
|
+
"""
|
|
2261
|
+
|
|
2262
|
+
def __init__(
|
|
2263
|
+
self,
|
|
2264
|
+
snapshot_ref: _SnapshotRef,
|
|
2265
|
+
interval: float,
|
|
2266
|
+
*,
|
|
2267
|
+
skip_sync: bool,
|
|
2268
|
+
now_utc: dt.datetime | None = None,
|
|
2269
|
+
display_tz_pref_override: "str | None" = None,
|
|
2270
|
+
) -> None:
|
|
2271
|
+
import threading
|
|
2272
|
+
self._ref = snapshot_ref
|
|
2273
|
+
self._interval = interval
|
|
2274
|
+
self._skip_sync = skip_sync
|
|
2275
|
+
self._now_utc = now_utc
|
|
2276
|
+
self._display_tz_pref_override = display_tz_pref_override
|
|
2277
|
+
self._stop = threading.Event()
|
|
2278
|
+
self._thread = threading.Thread(target=self._run, daemon=True,
|
|
2279
|
+
name="tui-sync")
|
|
2280
|
+
|
|
2281
|
+
def start(self) -> None:
|
|
2282
|
+
self._thread.start()
|
|
2283
|
+
|
|
2284
|
+
def stop(self) -> None:
|
|
2285
|
+
"""Signal the thread to exit and wait up to `interval + 0.5s`
|
|
2286
|
+
for it to finish. Because the thread is daemon=True, a failed
|
|
2287
|
+
join will not block process exit."""
|
|
2288
|
+
self._stop.set()
|
|
2289
|
+
self._thread.join(timeout=self._interval + 0.5)
|
|
2290
|
+
|
|
2291
|
+
def _run(self) -> None:
|
|
2292
|
+
import time
|
|
2293
|
+
while not self._stop.is_set():
|
|
2294
|
+
try:
|
|
2295
|
+
# Route through cctally so test monkeypatches on
|
|
2296
|
+
# ``_tui_build_snapshot`` propagate into the sync thread
|
|
2297
|
+
# (cf. _make_run_sync_now_locked above).
|
|
2298
|
+
snap = sys.modules["cctally"]._tui_build_snapshot(
|
|
2299
|
+
now_utc=self._now_utc, skip_sync=self._skip_sync,
|
|
2300
|
+
display_tz_pref_override=self._display_tz_pref_override,
|
|
2301
|
+
)
|
|
2302
|
+
self._ref.set(snap)
|
|
2303
|
+
except Exception as exc:
|
|
2304
|
+
# Don't crash the thread on unexpected errors — surface in UI.
|
|
2305
|
+
prev = self._ref.get()
|
|
2306
|
+
self._ref.set(DataSnapshot(
|
|
2307
|
+
current_week=prev.current_week,
|
|
2308
|
+
forecast=prev.forecast,
|
|
2309
|
+
trend=prev.trend,
|
|
2310
|
+
sessions=prev.sessions,
|
|
2311
|
+
last_sync_at=prev.last_sync_at,
|
|
2312
|
+
last_sync_error=f"sync crashed: {exc}",
|
|
2313
|
+
generated_at=dt.datetime.now(dt.timezone.utc),
|
|
2314
|
+
percent_milestones=prev.percent_milestones,
|
|
2315
|
+
weekly_history=prev.weekly_history,
|
|
2316
|
+
weekly_periods=prev.weekly_periods,
|
|
2317
|
+
monthly_periods=prev.monthly_periods,
|
|
2318
|
+
blocks_panel=prev.blocks_panel,
|
|
2319
|
+
daily_panel=prev.daily_panel,
|
|
2320
|
+
))
|
|
2321
|
+
# Wait up to interval, or until forced.
|
|
2322
|
+
for _ in range(int(max(1, self._interval * 10))):
|
|
2323
|
+
if self._stop.is_set():
|
|
2324
|
+
return
|
|
2325
|
+
if self._ref.take_sync_request():
|
|
2326
|
+
break
|
|
2327
|
+
time.sleep(0.1)
|
|
2328
|
+
|
|
2329
|
+
|
|
2330
|
+
def _tui_panel_current_week(
|
|
2331
|
+
snap: DataSnapshot,
|
|
2332
|
+
runtime: RuntimeState,
|
|
2333
|
+
width: int,
|
|
2334
|
+
*,
|
|
2335
|
+
focused: bool,
|
|
2336
|
+
) -> list[str]:
|
|
2337
|
+
"""Return the list of pre-box body lines (color-tagged) for Variant A.
|
|
2338
|
+
|
|
2339
|
+
Caller wraps in _tui_box_lines and color-tags + recolors the border.
|
|
2340
|
+
Width math is approximate here; the assembler strips tags before
|
|
2341
|
+
measuring.
|
|
2342
|
+
"""
|
|
2343
|
+
cw = snap.current_week
|
|
2344
|
+
if cw is None:
|
|
2345
|
+
return [
|
|
2346
|
+
"",
|
|
2347
|
+
" {dim}no current-week data yet{/}",
|
|
2348
|
+
" {dim}run `record-usage` to start capturing{/}",
|
|
2349
|
+
"",
|
|
2350
|
+
]
|
|
2351
|
+
# The panel interior is width - 2. The design uses leftW-20 as bar width.
|
|
2352
|
+
bar_w = max(10, width - 20)
|
|
2353
|
+
used_cls = _tui_bar_color(cw.used_pct)
|
|
2354
|
+
bar_fill = _tui_bar_string(cw.used_pct, bar_w)
|
|
2355
|
+
|
|
2356
|
+
five = cw.five_hour_pct or 0.0
|
|
2357
|
+
five_bar = _tui_bar_string(five, bar_w)
|
|
2358
|
+
|
|
2359
|
+
reset_delta = cw.week_end_at - snap.generated_at
|
|
2360
|
+
reset_days = max(0, reset_delta.days)
|
|
2361
|
+
reset_hrs = max(0, reset_delta.seconds // 3600)
|
|
2362
|
+
snap_age = int((snap.generated_at - cw.latest_snapshot_at).total_seconds())
|
|
2363
|
+
snap_age_m, snap_age_s = divmod(max(0, snap_age), 60)
|
|
2364
|
+
|
|
2365
|
+
# Five-hour reset-in text (h, m precision)
|
|
2366
|
+
if cw.five_hour_resets_at:
|
|
2367
|
+
fr_delta = cw.five_hour_resets_at - snap.generated_at
|
|
2368
|
+
fr_hr = max(0, int(fr_delta.total_seconds()) // 3600)
|
|
2369
|
+
fr_mn = max(0, (int(fr_delta.total_seconds()) % 3600) // 60)
|
|
2370
|
+
fr_str = f"resets in {fr_hr}h {fr_mn:02d}m"
|
|
2371
|
+
else:
|
|
2372
|
+
fr_str = ""
|
|
2373
|
+
|
|
2374
|
+
dpp_str = (
|
|
2375
|
+
f"${cw.dollars_per_percent:.2f}"
|
|
2376
|
+
if cw.dollars_per_percent is not None else "—"
|
|
2377
|
+
)
|
|
2378
|
+
lines = [
|
|
2379
|
+
"",
|
|
2380
|
+
f" Used {{{used_cls}}}{bar_fill}{{/}} {{{used_cls}.b}}{cw.used_pct:>5.1f}%{{/}}",
|
|
2381
|
+
"",
|
|
2382
|
+
f" 5-hour {{bar.accent}}{five_bar}{{/}} {{bright}}{int(five):>3d}%{{/}}",
|
|
2383
|
+
f" {{dim}}{fr_str}{{/}}" if fr_str else "",
|
|
2384
|
+
"",
|
|
2385
|
+
f" {{dim}}Spent{{/}} {{bright}}${cw.spent_usd:.2f}{{/}} "
|
|
2386
|
+
f"{{dim}}$/1%{{/}} {{bright}}{dpp_str}{{/}}",
|
|
2387
|
+
f" {{dim}}Reset{{/}} {{bright}}{format_display_dt(cw.week_end_at, runtime.display_tz, fmt='%b %d %H:%M', suffix=True)}{{/}} "
|
|
2388
|
+
f"{{dim}}(in {reset_days}d {reset_hrs}h){{/}}",
|
|
2389
|
+
"",
|
|
2390
|
+
f" {{faint}}· last snapshot: {snap_age_m}m {snap_age_s:02d}s ago{{/}}",
|
|
2391
|
+
]
|
|
2392
|
+
# Freshness chip (Task C6 / spec §3.4). Hidden when label is None or
|
|
2393
|
+
# 'fresh'; rendered dim for 'aging', warn (amber) for 'stale'. Mirrors
|
|
2394
|
+
# the dashboard CurrentWeekPanel chip in dashboard/web/src/panels/.
|
|
2395
|
+
if cw.freshness_label and cw.freshness_label != "fresh":
|
|
2396
|
+
captured_hms = format_display_dt(
|
|
2397
|
+
cw.latest_snapshot_at, runtime.display_tz,
|
|
2398
|
+
fmt="%H:%M:%S", suffix=False,
|
|
2399
|
+
)
|
|
2400
|
+
chip_style = "warn" if cw.freshness_label == "stale" else "dim"
|
|
2401
|
+
chip_age = cw.freshness_age if cw.freshness_age is not None else 0
|
|
2402
|
+
lines.append(
|
|
2403
|
+
f" {{{chip_style}}}⏱ as of {captured_hms} · {chip_age}s ago{{/}}"
|
|
2404
|
+
)
|
|
2405
|
+
return lines
|
|
2406
|
+
|
|
2407
|
+
|
|
2408
|
+
def _tui_panel_current_week_hero(
|
|
2409
|
+
snap: DataSnapshot,
|
|
2410
|
+
runtime: RuntimeState,
|
|
2411
|
+
width: int,
|
|
2412
|
+
) -> list[str]:
|
|
2413
|
+
"""Variant B hero meter for current week."""
|
|
2414
|
+
cw = snap.current_week
|
|
2415
|
+
if cw is None:
|
|
2416
|
+
return ["", " {dim}no data yet{/}", ""]
|
|
2417
|
+
bar_w = max(10, width - 10)
|
|
2418
|
+
used_cls = _tui_bar_color(cw.used_pct)
|
|
2419
|
+
big_bar = _tui_bar_string(cw.used_pct, bar_w)
|
|
2420
|
+
five_bar = _tui_bar_string(cw.five_hour_pct or 0.0, bar_w)
|
|
2421
|
+
snap_age_min = int((snap.generated_at - cw.latest_snapshot_at).total_seconds()) // 60
|
|
2422
|
+
|
|
2423
|
+
if cw.five_hour_resets_at:
|
|
2424
|
+
sec = int((cw.five_hour_resets_at - snap.generated_at).total_seconds())
|
|
2425
|
+
fr_hr = max(0, sec) // 3600
|
|
2426
|
+
fr_mn = (max(0, sec) % 3600) // 60
|
|
2427
|
+
reset_suffix = f" {{dim}}resets {fr_hr}h {fr_mn:02d}m{{/}}"
|
|
2428
|
+
else:
|
|
2429
|
+
reset_suffix = ""
|
|
2430
|
+
|
|
2431
|
+
if snap.last_sync_error:
|
|
2432
|
+
health = "{warn}daemon error{/}"
|
|
2433
|
+
elif snap.last_sync_at is None:
|
|
2434
|
+
health = "{dim}sync paused{/}"
|
|
2435
|
+
else:
|
|
2436
|
+
health = "{dim}daemon healthy{/}"
|
|
2437
|
+
|
|
2438
|
+
return [
|
|
2439
|
+
"",
|
|
2440
|
+
" {dim}WEEK USAGE{/}",
|
|
2441
|
+
"",
|
|
2442
|
+
f" {{{used_cls}.b}}{cw.used_pct:.1f}%{{/}} {{dim}}of allowance used{{/}}",
|
|
2443
|
+
"",
|
|
2444
|
+
f" {{{used_cls}}}{big_bar}{{/}}",
|
|
2445
|
+
f" {{faint}}0%{' ' * (bar_w - 6)}100%{{/}}",
|
|
2446
|
+
"",
|
|
2447
|
+
f" {{dim}}5-HOUR WINDOW{{/}} {{bright}}{int(cw.five_hour_pct or 0)}%{{/}}{reset_suffix}",
|
|
2448
|
+
f" {{bar.accent}}{five_bar}{{/}}",
|
|
2449
|
+
"",
|
|
2450
|
+
f" {{dim}}snapshot {snap_age_min}m ago{{/}} · {health}",
|
|
2451
|
+
"",
|
|
2452
|
+
]
|
|
2453
|
+
|
|
2454
|
+
|
|
2455
|
+
_TUI_VERDICT_CLS = {
|
|
2456
|
+
"GOOD": "ok", "WARN": "warn", "OVER": "bad", "LOW CONF": "warn",
|
|
2457
|
+
}
|
|
2458
|
+
_TUI_VERDICT_SHORT = {
|
|
2459
|
+
"GOOD": "comfortable headroom",
|
|
2460
|
+
"WARN": "on track, no slack",
|
|
2461
|
+
"OVER": "throttle immediately",
|
|
2462
|
+
"LOW CONF": "not enough data",
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
def _tui_verdict_of(forecast) -> str:
|
|
2467
|
+
"""Compute verdict name from a ForecastOutput. Matches design language."""
|
|
2468
|
+
if forecast is None or getattr(forecast.inputs, "confidence", "high") == "low":
|
|
2469
|
+
return "LOW CONF"
|
|
2470
|
+
high = forecast.final_percent_high
|
|
2471
|
+
if high >= 100:
|
|
2472
|
+
return "OVER"
|
|
2473
|
+
if high >= 90:
|
|
2474
|
+
return "WARN"
|
|
2475
|
+
return "GOOD"
|
|
2476
|
+
|
|
2477
|
+
|
|
2478
|
+
def _tui_panel_forecast(
|
|
2479
|
+
snap: DataSnapshot,
|
|
2480
|
+
runtime: RuntimeState,
|
|
2481
|
+
width: int,
|
|
2482
|
+
) -> list[str]:
|
|
2483
|
+
"""Variant A forecast panel body."""
|
|
2484
|
+
fc = snap.forecast
|
|
2485
|
+
if fc is None:
|
|
2486
|
+
return [
|
|
2487
|
+
"",
|
|
2488
|
+
" {badge.warn} [ LOW CONF ] {/} {dim}no current-week data{/}",
|
|
2489
|
+
"",
|
|
2490
|
+
" {dim}run record-usage first{/}",
|
|
2491
|
+
"",
|
|
2492
|
+
]
|
|
2493
|
+
verdict = _tui_verdict_of(fc)
|
|
2494
|
+
vcls = _TUI_VERDICT_CLS[verdict]
|
|
2495
|
+
vmsg = _TUI_VERDICT_SHORT[verdict]
|
|
2496
|
+
|
|
2497
|
+
bar_w = max(6, width - 36)
|
|
2498
|
+
|
|
2499
|
+
def bar_tagged(val: float) -> str:
|
|
2500
|
+
b = _tui_bar_string(min(val, 100), bar_w)
|
|
2501
|
+
cls = _tui_bar_color(val)
|
|
2502
|
+
return f"{{{cls}}}{b}{{/}}"
|
|
2503
|
+
|
|
2504
|
+
# Compute the two projection values DIRECTLY from the rate methods,
|
|
2505
|
+
# not from final_low/final_high which are min/max aggregates and
|
|
2506
|
+
# swap labels when the recent-24h rate is lower than week-avg.
|
|
2507
|
+
p_now = fc.inputs.p_now
|
|
2508
|
+
remaining = fc.inputs.remaining_hours
|
|
2509
|
+
wa = int(round(p_now + fc.r_avg * remaining))
|
|
2510
|
+
rc = wa if fc.r_recent is None else int(round(p_now + fc.r_recent * remaining))
|
|
2511
|
+
# Budget table row values
|
|
2512
|
+
b100 = next((r for r in fc.budgets if r.target_percent == 100), None)
|
|
2513
|
+
b90 = next((r for r in fc.budgets if r.target_percent == 90), None)
|
|
2514
|
+
b100_str = f"${b100.dollars_per_day:.2f}/day" if b100 and b100.dollars_per_day is not None else "—"
|
|
2515
|
+
b90_str = f"${b90.dollars_per_day:.2f}/day" if b90 and b90.dollars_per_day is not None else "—"
|
|
2516
|
+
conf = "low" if verdict == "LOW CONF" else "high"
|
|
2517
|
+
|
|
2518
|
+
return [
|
|
2519
|
+
"",
|
|
2520
|
+
f" {{badge.{vcls}}} [ {verdict} ] {{/}} {{dim}}{vmsg}{{/}}",
|
|
2521
|
+
"",
|
|
2522
|
+
f" {{dim}}Projection by week-avg{{/}} {bar_tagged(wa)} {{bright}}{wa:>3d}%{{/}}",
|
|
2523
|
+
f" {{dim}}Projection by recent 24h{{/}} {bar_tagged(rc)} {{bright}}{rc:>3d}%{{/}}",
|
|
2524
|
+
"",
|
|
2525
|
+
f" {{dim}}Budget to stay ≤100%{{/}} {{bright}}{b100_str}{{/}}",
|
|
2526
|
+
f" {{dim}}Budget to stay ≤90%{{/}} {{bright}}{b90_str}{{/}}",
|
|
2527
|
+
"",
|
|
2528
|
+
f" {{faint}}confidence: {conf} · based on 7-day rate{{/}}",
|
|
2529
|
+
]
|
|
2530
|
+
|
|
2531
|
+
|
|
2532
|
+
def _tui_panel_trend(
|
|
2533
|
+
snap: DataSnapshot,
|
|
2534
|
+
runtime: RuntimeState,
|
|
2535
|
+
width: int,
|
|
2536
|
+
*,
|
|
2537
|
+
compact: bool = False,
|
|
2538
|
+
) -> list[str]:
|
|
2539
|
+
"""Variant A trend panel: 8-row table + inline sparkline row.
|
|
2540
|
+
|
|
2541
|
+
When ``compact=True``, the leading blank, the pre-sparkline blank, and
|
|
2542
|
+
the trailing blank are skipped (3 rows recovered) so callers with tight
|
|
2543
|
+
vertical budgets can use the panel without the default padding.
|
|
2544
|
+
"""
|
|
2545
|
+
rows = snap.trend
|
|
2546
|
+
if not rows:
|
|
2547
|
+
return ["", " {dim}no trend data yet{/}", ""]
|
|
2548
|
+
lines: list[str] = []
|
|
2549
|
+
if not compact:
|
|
2550
|
+
lines.append("")
|
|
2551
|
+
lines.append(" {dim.b}Week Used% $/1% Δ{/}")
|
|
2552
|
+
lines.append(" {faint}────────── ───── ──────── ──────{/}")
|
|
2553
|
+
for r in rows:
|
|
2554
|
+
marker = "{accent}▶{/}" if r.is_current else " "
|
|
2555
|
+
if r.used_pct is None:
|
|
2556
|
+
used_cls = "dim"
|
|
2557
|
+
used_fmt = " — " # 6 cols, matches "{:>5.1f}%" width
|
|
2558
|
+
else:
|
|
2559
|
+
used_cls = _tui_bar_color(r.used_pct)
|
|
2560
|
+
used_fmt = f"{r.used_pct:>5.1f}%"
|
|
2561
|
+
rate_str = (
|
|
2562
|
+
f"${r.dollars_per_percent:.2f}"
|
|
2563
|
+
if r.dollars_per_percent is not None else " —"
|
|
2564
|
+
)
|
|
2565
|
+
if r.delta_dpp is None:
|
|
2566
|
+
delta_str = " — "
|
|
2567
|
+
delta_cls = "dim"
|
|
2568
|
+
else:
|
|
2569
|
+
sign = "+" if r.delta_dpp >= 0 else ""
|
|
2570
|
+
delta_str = f"{sign}{r.delta_dpp:.2f}"
|
|
2571
|
+
delta_cls = ("dim" if abs(r.delta_dpp) < 0.02
|
|
2572
|
+
else ("warn" if r.delta_dpp > 0 else "ok"))
|
|
2573
|
+
lines.append(
|
|
2574
|
+
f" {marker} {{bright}}{r.week_label:<9}{{/}} "
|
|
2575
|
+
f"{{{used_cls}}}{used_fmt}{{/}} "
|
|
2576
|
+
f"{{bright}}{rate_str:<6}{{/}} {{{delta_cls}}}{delta_str:<5}{{/}}"
|
|
2577
|
+
)
|
|
2578
|
+
# Sparkline row
|
|
2579
|
+
if not compact:
|
|
2580
|
+
lines.append("")
|
|
2581
|
+
heights = [r.spark_height for r in rows]
|
|
2582
|
+
spark = _tui_sparkline_inline(heights)
|
|
2583
|
+
lines.append(f" {{dim}}spark $/1%{{/}} {{accent.b}}{spark}{{/}}")
|
|
2584
|
+
if not compact:
|
|
2585
|
+
lines.append("")
|
|
2586
|
+
return lines
|
|
2587
|
+
|
|
2588
|
+
|
|
2589
|
+
def _tui_session_model_cls(model: str) -> str:
|
|
2590
|
+
"""Map primary model name to a color class for the Model column."""
|
|
2591
|
+
m = (model or "").lower()
|
|
2592
|
+
if m.startswith("opus"):
|
|
2593
|
+
return "magenta"
|
|
2594
|
+
if m.startswith("haiku"):
|
|
2595
|
+
return "blue"
|
|
2596
|
+
return "bright"
|
|
2597
|
+
|
|
2598
|
+
|
|
2599
|
+
def _tui_format_started(
|
|
2600
|
+
ts: dt.datetime,
|
|
2601
|
+
now: dt.datetime,
|
|
2602
|
+
tz: "ZoneInfo | None",
|
|
2603
|
+
) -> str:
|
|
2604
|
+
"""Today -> 'HH:MM:SS', else 'Mon DD HH:MM'.
|
|
2605
|
+
|
|
2606
|
+
F4 fix: takes a resolved ``ZoneInfo | None`` (as carried on
|
|
2607
|
+
``RuntimeState.display_tz``) instead of the legacy "utc" / "local"
|
|
2608
|
+
string token. Previously, an explicit IANA zone like
|
|
2609
|
+
``America/New_York`` reached this helper as a string, took the else
|
|
2610
|
+
branch, and rendered the raw UTC clock — so session rows displayed
|
|
2611
|
+
UTC even when reset/session-detail fields used the resolved zone.
|
|
2612
|
+
"""
|
|
2613
|
+
# internal fallback: host-local intentional — picks the calendar bucket;
|
|
2614
|
+
# the actual rendered string flows through `format_display_dt`.
|
|
2615
|
+
disp = ts.astimezone(tz) if tz is not None else ts.astimezone()
|
|
2616
|
+
today = now.astimezone(disp.tzinfo).date()
|
|
2617
|
+
if disp.date() == today:
|
|
2618
|
+
return format_display_dt(ts, tz, fmt="%H:%M:%S", suffix=False)
|
|
2619
|
+
return format_display_dt(ts, tz, fmt="%b %d %H:%M", suffix=False)
|
|
2620
|
+
|
|
2621
|
+
|
|
2622
|
+
def _tui_format_dur(minutes: float) -> str:
|
|
2623
|
+
"""Human-friendly duration: '42m' or '3h 07m'."""
|
|
2624
|
+
if minutes < 60:
|
|
2625
|
+
return f"{int(minutes)}m"
|
|
2626
|
+
h = int(minutes // 60)
|
|
2627
|
+
m = int(minutes % 60)
|
|
2628
|
+
return f"{h}h {m:02d}m"
|
|
2629
|
+
|
|
2630
|
+
|
|
2631
|
+
# v2 sort: cycle order + direction (spec §3.1).
|
|
2632
|
+
_TUI_SORT_KEYS = ("last-activity", "cost", "duration", "model", "project")
|
|
2633
|
+
_TUI_SORT_ASC = frozenset({"model", "project"}) # ascending; rest descending
|
|
2634
|
+
|
|
2635
|
+
|
|
2636
|
+
def _tui_sort_sessions(sessions: list[TuiSessionRow], key: str) -> list[TuiSessionRow]:
|
|
2637
|
+
"""Return a new list sorted per spec §3.1.
|
|
2638
|
+
|
|
2639
|
+
Default key 'last-activity' is pass-through — preserves the order
|
|
2640
|
+
`_aggregate_claude_sessions` already produces (last_activity desc).
|
|
2641
|
+
Other keys: hard-coded direction by type (numeric/recency desc;
|
|
2642
|
+
text asc) with a stable secondary on last-activity desc.
|
|
2643
|
+
"""
|
|
2644
|
+
if not sessions:
|
|
2645
|
+
return []
|
|
2646
|
+
if key == "last-activity":
|
|
2647
|
+
return list(sessions) # already sorted by aggregator — pass-through
|
|
2648
|
+
if key == "cost":
|
|
2649
|
+
primary = lambda s: -s.cost_usd
|
|
2650
|
+
elif key == "duration":
|
|
2651
|
+
primary = lambda s: -s.duration_minutes
|
|
2652
|
+
elif key == "model":
|
|
2653
|
+
primary = lambda s: s.model_primary.lower()
|
|
2654
|
+
elif key == "project":
|
|
2655
|
+
primary = lambda s: s.project_label.lower()
|
|
2656
|
+
else:
|
|
2657
|
+
return list(sessions)
|
|
2658
|
+
return sorted(sessions, key=lambda s: (primary(s), -s.started_at.timestamp()))
|
|
2659
|
+
|
|
2660
|
+
|
|
2661
|
+
def _tui_next_sort_key(current: str) -> str:
|
|
2662
|
+
"""Cycle to the next key. Wraps."""
|
|
2663
|
+
try:
|
|
2664
|
+
idx = _TUI_SORT_KEYS.index(current)
|
|
2665
|
+
except ValueError:
|
|
2666
|
+
return _TUI_SORT_KEYS[0]
|
|
2667
|
+
return _TUI_SORT_KEYS[(idx + 1) % len(_TUI_SORT_KEYS)]
|
|
2668
|
+
|
|
2669
|
+
|
|
2670
|
+
def _tui_apply_session_filter(sessions, active_filter):
|
|
2671
|
+
"""Narrow sessions by a filter substring (project_label|model_primary).
|
|
2672
|
+
Returns `sessions` unchanged when `active_filter` is None/empty. Mirrors
|
|
2673
|
+
the filter logic in `_tui_panel_sessions` so live match counts in the
|
|
2674
|
+
input prompt reflect the post-filter navigable list."""
|
|
2675
|
+
if not active_filter:
|
|
2676
|
+
return sessions
|
|
2677
|
+
af_lower = active_filter.lower()
|
|
2678
|
+
return [
|
|
2679
|
+
s for s in sessions
|
|
2680
|
+
if af_lower in s.project_label.lower()
|
|
2681
|
+
or af_lower in s.model_primary.lower()
|
|
2682
|
+
]
|
|
2683
|
+
|
|
2684
|
+
|
|
2685
|
+
def _tui_sessions_title(runtime: RuntimeState, *, narrow: bool) -> str:
|
|
2686
|
+
"""Build the Sessions panel title with sort indicator and filter chip.
|
|
2687
|
+
|
|
2688
|
+
Spec §3.1 (sort indicator) + §3.2 (filter chip). Narrow bucket
|
|
2689
|
+
abbreviates the sort key and chip per spec §5.1.
|
|
2690
|
+
|
|
2691
|
+
Returns a tagged string (uses {name}…{/} markup); the caller passes
|
|
2692
|
+
it straight to `_tui_tagged_box_lines(title=…)` which materializes
|
|
2693
|
+
the tags via `_tui_colortag` downstream.
|
|
2694
|
+
"""
|
|
2695
|
+
arrow = "↑" if runtime.sort_key in _TUI_SORT_ASC else "↓"
|
|
2696
|
+
if narrow:
|
|
2697
|
+
sort_part = f"{{dim}} · {runtime.sort_key}{arrow}{{/}}"
|
|
2698
|
+
else:
|
|
2699
|
+
sort_part = f"{{dim}} · sort: {runtime.sort_key} {arrow}{{/}}"
|
|
2700
|
+
chip_part = ""
|
|
2701
|
+
if runtime.filter_term is not None:
|
|
2702
|
+
if narrow:
|
|
2703
|
+
shown = _tui_escape_tags(runtime.filter_term[:8])
|
|
2704
|
+
chip_part = f" {{chip}} ▼{shown} {{/}}"
|
|
2705
|
+
else:
|
|
2706
|
+
shown = _tui_escape_tags(runtime.filter_term)
|
|
2707
|
+
chip_part = f" {{chip}} filter: {shown} {{/}}"
|
|
2708
|
+
return f"{{focused.b}}Recent Sessions{{/}}{sort_part}{chip_part}"
|
|
2709
|
+
|
|
2710
|
+
|
|
2711
|
+
def _tui_panel_sessions(
|
|
2712
|
+
snap: DataSnapshot,
|
|
2713
|
+
runtime: RuntimeState,
|
|
2714
|
+
width: int,
|
|
2715
|
+
*,
|
|
2716
|
+
rows_visible: int,
|
|
2717
|
+
show_project_col: bool,
|
|
2718
|
+
) -> list[str]:
|
|
2719
|
+
"""Variant A + B sessions panel body.
|
|
2720
|
+
|
|
2721
|
+
Caller drives layout:
|
|
2722
|
+
- Variant A (right half of the 2x2) passes rightW as `width` and
|
|
2723
|
+
`show_project_col=False` or True depending on space.
|
|
2724
|
+
- Variant B (full-width) passes the terminal width as `width` and
|
|
2725
|
+
typically `show_project_col=True`.
|
|
2726
|
+
"""
|
|
2727
|
+
sessions = _tui_sort_sessions(snap.sessions, runtime.sort_key)
|
|
2728
|
+
|
|
2729
|
+
# v2: apply filter (spec §3.2). Use input_buffer as the live preview when
|
|
2730
|
+
# in filter input mode (incremental narrowing); otherwise use the
|
|
2731
|
+
# committed filter_term.
|
|
2732
|
+
active_filter: str | None
|
|
2733
|
+
if runtime.input_mode == "filter":
|
|
2734
|
+
active_filter = runtime.input_buffer or None
|
|
2735
|
+
else:
|
|
2736
|
+
active_filter = runtime.filter_term
|
|
2737
|
+
if active_filter:
|
|
2738
|
+
af_lower = active_filter.lower()
|
|
2739
|
+
sessions = [
|
|
2740
|
+
s for s in sessions
|
|
2741
|
+
if af_lower in s.project_label.lower()
|
|
2742
|
+
or af_lower in s.model_primary.lower()
|
|
2743
|
+
]
|
|
2744
|
+
|
|
2745
|
+
# Spec §3.2 empty-narrow result.
|
|
2746
|
+
if active_filter and not sessions:
|
|
2747
|
+
empty_lines: list[str] = [
|
|
2748
|
+
"",
|
|
2749
|
+
"",
|
|
2750
|
+
f" {{dim}}no sessions match \"{_tui_escape_tags(active_filter)}\" · f to edit{{/}}",
|
|
2751
|
+
"",
|
|
2752
|
+
]
|
|
2753
|
+
# Pad to expected rows_visible (header+ruler+rows+trailing chrome).
|
|
2754
|
+
while len(empty_lines) < rows_visible + 5:
|
|
2755
|
+
empty_lines.append("")
|
|
2756
|
+
empty_lines.append("")
|
|
2757
|
+
empty_lines.append(
|
|
2758
|
+
f" {{dim}}↑↓ scroll · 0 of {len(snap.sessions)} match · 0 below{{/}}"
|
|
2759
|
+
)
|
|
2760
|
+
return empty_lines
|
|
2761
|
+
|
|
2762
|
+
# v2: compute search matches (spec §3.3). Active term is input_buffer
|
|
2763
|
+
# while typing, search_term once confirmed, None otherwise.
|
|
2764
|
+
if runtime.input_mode == "search":
|
|
2765
|
+
active_search = runtime.input_buffer or None
|
|
2766
|
+
auto_jump = True # live-type: reset scroll to first match each tick
|
|
2767
|
+
else:
|
|
2768
|
+
active_search = runtime.search_term
|
|
2769
|
+
auto_jump = False # confirmed: preserve scroll (lets n/N work)
|
|
2770
|
+
match_indices: list[int] = []
|
|
2771
|
+
if active_search:
|
|
2772
|
+
as_lower = active_search.lower()
|
|
2773
|
+
for i, s in enumerate(sessions):
|
|
2774
|
+
haystack = (
|
|
2775
|
+
s.project_label.lower() + "|"
|
|
2776
|
+
+ s.model_primary.lower() + "|"
|
|
2777
|
+
+ _tui_format_started(s.started_at, snap.generated_at, runtime.display_tz).lower()
|
|
2778
|
+
)
|
|
2779
|
+
if as_lower in haystack:
|
|
2780
|
+
match_indices.append(i)
|
|
2781
|
+
# Live jump only while typing — n/N drives scroll after confirm.
|
|
2782
|
+
if match_indices and auto_jump:
|
|
2783
|
+
runtime.session_scroll = match_indices[0]
|
|
2784
|
+
# Persist for n/N when in confirmed state.
|
|
2785
|
+
runtime.search_matches = match_indices
|
|
2786
|
+
if runtime.search_index >= len(match_indices):
|
|
2787
|
+
runtime.search_index = 0
|
|
2788
|
+
|
|
2789
|
+
def _hl(text: str, term: str | None) -> str:
|
|
2790
|
+
if not term:
|
|
2791
|
+
return text
|
|
2792
|
+
# Search and slice on UNESCAPED text (so positions are correct), but
|
|
2793
|
+
# emit ESCAPED segments so any user-source `{` `}` chars survive
|
|
2794
|
+
# the colortag pipeline literally.
|
|
2795
|
+
idx = text.lower().find(term.lower())
|
|
2796
|
+
if idx < 0:
|
|
2797
|
+
return text
|
|
2798
|
+
before = _tui_escape_tags(text[:idx])
|
|
2799
|
+
match = _tui_escape_tags(text[idx:idx + len(term)])
|
|
2800
|
+
after = _tui_escape_tags(text[idx + len(term):])
|
|
2801
|
+
return before + "{match}" + match + "{/}" + after
|
|
2802
|
+
|
|
2803
|
+
interior = width - 2
|
|
2804
|
+
|
|
2805
|
+
# Column widths derived from the design's 120-col and 100-col tables.
|
|
2806
|
+
# Cost column is 8 at the wide bucket so $1000+ session values don't
|
|
2807
|
+
# overflow (e.g., "$1234.56" is 8 chars). Medium/narrow buckets keep 7/6
|
|
2808
|
+
# — the layouts are already tight and four-digit costs are uncommon.
|
|
2809
|
+
if interior >= 70:
|
|
2810
|
+
c_start, c_dur, c_model, c_cost = 14, 6, 11, 8
|
|
2811
|
+
elif interior >= 55:
|
|
2812
|
+
c_start, c_dur, c_model, c_cost = 12, 6, 10, 7
|
|
2813
|
+
else:
|
|
2814
|
+
c_start, c_dur, c_model, c_cost = 8, 5, 10, 6
|
|
2815
|
+
show_project_col = False
|
|
2816
|
+
|
|
2817
|
+
fixed = 8 + c_start + c_dur + c_model + c_cost
|
|
2818
|
+
last_avail = interior - fixed
|
|
2819
|
+
if show_project_col and last_avail >= 10:
|
|
2820
|
+
c_last = last_avail
|
|
2821
|
+
last_title = "Project"
|
|
2822
|
+
use_project = True
|
|
2823
|
+
else:
|
|
2824
|
+
c_last = min(5, max(0, last_avail))
|
|
2825
|
+
last_title = "Cache"
|
|
2826
|
+
use_project = False
|
|
2827
|
+
|
|
2828
|
+
def _truncpad(s: str, n: int) -> str:
|
|
2829
|
+
if n <= 0:
|
|
2830
|
+
return ""
|
|
2831
|
+
if len(s) > n:
|
|
2832
|
+
return s[: n - 1] + "…"
|
|
2833
|
+
return s + " " * (n - len(s))
|
|
2834
|
+
|
|
2835
|
+
lines: list[str] = [""]
|
|
2836
|
+
# Header row
|
|
2837
|
+
lines.append(
|
|
2838
|
+
f" {{dim.b}}"
|
|
2839
|
+
f"{_truncpad('Started', c_start)} "
|
|
2840
|
+
f"{_truncpad('Dur', c_dur)} "
|
|
2841
|
+
f"{_truncpad('Model', c_model)} "
|
|
2842
|
+
f"{('Cost').rjust(c_cost)} "
|
|
2843
|
+
f"{_truncpad(last_title, c_last)}"
|
|
2844
|
+
f"{{/}}"
|
|
2845
|
+
)
|
|
2846
|
+
# Ruler
|
|
2847
|
+
lines.append(
|
|
2848
|
+
f" {{faint}}"
|
|
2849
|
+
f"{'─' * c_start} {'─' * c_dur} {'─' * c_model} "
|
|
2850
|
+
f"{'─' * c_cost} {'─' * c_last}"
|
|
2851
|
+
f"{{/}}"
|
|
2852
|
+
)
|
|
2853
|
+
|
|
2854
|
+
# Clamp scroll. Cap at len-1 (not len - rows_visible) so any session —
|
|
2855
|
+
# including search matches landing in the last rows_visible-1 positions —
|
|
2856
|
+
# can become the topmost-visible row. Spec §3.3 (Live jump) requires the
|
|
2857
|
+
# matched row to reach topmost; with the tighter clamp, selection (=
|
|
2858
|
+
# topmost per the "is_selected = i == 0" convention) would diverge from
|
|
2859
|
+
# the highlighted match and Enter would open the wrong session detail.
|
|
2860
|
+
# The existing padding loop below fills blank rows when the slice is short.
|
|
2861
|
+
max_scroll = max(0, len(sessions) - 1)
|
|
2862
|
+
scroll = min(runtime.session_scroll, max_scroll)
|
|
2863
|
+
runtime.session_scroll = scroll # write clamped back
|
|
2864
|
+
|
|
2865
|
+
visible = sessions[scroll: scroll + rows_visible]
|
|
2866
|
+
for i, s in enumerate(visible):
|
|
2867
|
+
is_selected = (i == 0) # topmost = selected (design convention)
|
|
2868
|
+
start_s = _truncpad(_tui_format_started(s.started_at, snap.generated_at, runtime.display_tz), c_start)
|
|
2869
|
+
dur_s = _truncpad(_tui_format_dur(s.duration_minutes), c_dur)
|
|
2870
|
+
model_s = _truncpad(s.model_primary, c_model)
|
|
2871
|
+
cost_s = f"${s.cost_usd:.2f}".rjust(c_cost)
|
|
2872
|
+
if use_project:
|
|
2873
|
+
last_s = _truncpad(s.project_label, c_last)
|
|
2874
|
+
else:
|
|
2875
|
+
last_s = (f"{int(s.cache_hit_pct)}%" if s.cache_hit_pct is not None else "—").rjust(c_last)
|
|
2876
|
+
model_cls = _tui_session_model_cls(s.model_primary)
|
|
2877
|
+
cache_cls = "ok" if (s.cache_hit_pct or 0) >= 70 else ("warn" if (s.cache_hit_pct or 0) >= 50 else "dim")
|
|
2878
|
+
|
|
2879
|
+
# v2: search highlight (spec §3.3). Wraps matched substring in
|
|
2880
|
+
# {match} tags; nests safely inside the row's outer style.
|
|
2881
|
+
model_h = _hl(model_s, active_search)
|
|
2882
|
+
last_h = _hl(last_s, active_search) if use_project else last_s
|
|
2883
|
+
start_h = _hl(start_s, active_search)
|
|
2884
|
+
|
|
2885
|
+
if is_selected:
|
|
2886
|
+
body = (
|
|
2887
|
+
f"▸ {start_h} {dur_s} {model_h} {cost_s} {last_h}"
|
|
2888
|
+
)
|
|
2889
|
+
lines.append(f" {{focused.b}}{body}{{/}}")
|
|
2890
|
+
elif use_project:
|
|
2891
|
+
lines.append(
|
|
2892
|
+
f" {{bright}}{start_h}{{/}} {{dim}}{dur_s}{{/}} "
|
|
2893
|
+
f"{{{model_cls}}}{model_h}{{/}} {{bright}}{cost_s}{{/}} "
|
|
2894
|
+
f"{{dim}}{last_h}{{/}}"
|
|
2895
|
+
)
|
|
2896
|
+
else:
|
|
2897
|
+
lines.append(
|
|
2898
|
+
f" {{bright}}{start_h}{{/}} {{dim}}{dur_s}{{/}} "
|
|
2899
|
+
f"{{{model_cls}}}{model_h}{{/}} {{bright}}{cost_s}{{/}} "
|
|
2900
|
+
f"{{{cache_cls}}}{last_s}{{/}}"
|
|
2901
|
+
)
|
|
2902
|
+
# Pad to rows_visible
|
|
2903
|
+
while len(lines) - 3 < rows_visible:
|
|
2904
|
+
lines.append("")
|
|
2905
|
+
|
|
2906
|
+
below = max(0, len(sessions) - scroll - rows_visible)
|
|
2907
|
+
lines.append("")
|
|
2908
|
+
lines.append(f" {{dim}}↑↓ scroll · {below} below{{/}}")
|
|
2909
|
+
return lines
|
|
2910
|
+
|
|
2911
|
+
|
|
2912
|
+
def _tui_header_strip_a(
|
|
2913
|
+
snap: DataSnapshot, runtime: RuntimeState, width: int,
|
|
2914
|
+
) -> list[str]:
|
|
2915
|
+
"""Variant A header strip: top rule + summary line + bottom rule.
|
|
2916
|
+
|
|
2917
|
+
Appends a one-line error banner below the bottom rule when
|
|
2918
|
+
snap.last_sync_error is set.
|
|
2919
|
+
"""
|
|
2920
|
+
import time
|
|
2921
|
+
cw = snap.current_week
|
|
2922
|
+
fc = snap.forecast
|
|
2923
|
+
verdict = _tui_verdict_of(fc) if fc else "LOW CONF"
|
|
2924
|
+
vcls = _TUI_VERDICT_CLS[verdict]
|
|
2925
|
+
sync_age = 0
|
|
2926
|
+
if snap.last_sync_at is not None:
|
|
2927
|
+
sync_age = int(time.monotonic() - snap.last_sync_at)
|
|
2928
|
+
sync_txt = f"synced {sync_age}s ago" if snap.last_sync_at is not None else "synced —"
|
|
2929
|
+
err = snap.last_sync_error
|
|
2930
|
+
if cw is None:
|
|
2931
|
+
hdr = (
|
|
2932
|
+
f"{{bright.b}}Week — {{/}} {{faint}}│{{/}} "
|
|
2933
|
+
f"{{dim}}no data yet — run record-usage first{{/}}"
|
|
2934
|
+
)
|
|
2935
|
+
else:
|
|
2936
|
+
used_cls = _tui_bar_color(cw.used_pct)
|
|
2937
|
+
dpp_str = (
|
|
2938
|
+
f"${cw.dollars_per_percent:.2f}"
|
|
2939
|
+
if cw.dollars_per_percent is not None else "—"
|
|
2940
|
+
)
|
|
2941
|
+
fcst_pct = "—"
|
|
2942
|
+
if fc:
|
|
2943
|
+
# Use the measure that drives the verdict (_tui_verdict_of keys
|
|
2944
|
+
# on final_percent_high). Using low here would display e.g.
|
|
2945
|
+
# "Fcst 74% WARN" where the WARN comes from a >=90% high
|
|
2946
|
+
# projection, understating risk in the most glanceable line.
|
|
2947
|
+
fcst_pct = f"{int(round(fc.final_percent_high))}%"
|
|
2948
|
+
hdr = (
|
|
2949
|
+
f"{{bright.b}}Week {format_display_dt(cw.week_start_at, runtime.display_tz, fmt='%b %d', suffix=False)}–{format_display_dt(cw.week_end_at, runtime.display_tz, fmt='%b %d', suffix=False)}{{/}} "
|
|
2950
|
+
f"{{faint}}│{{/}} Used {{{used_cls}.b}}{cw.used_pct:.1f}%{{/}} "
|
|
2951
|
+
f"{{dim}}(5h {int(cw.five_hour_pct or 0)}%){{/}} {{faint}}│{{/}} "
|
|
2952
|
+
f"$/1% {{bright}}{dpp_str}{{/}} {{faint}}│{{/}} "
|
|
2953
|
+
f"Fcst {{{vcls}.b}}{fcst_pct}{{/}} {{{vcls}.b}}{verdict}{{/}} {{faint}}│{{/}} "
|
|
2954
|
+
f"{{dim.pulse}}● {sync_txt}{{/}}"
|
|
2955
|
+
)
|
|
2956
|
+
# Top/bottom rules framing the header.
|
|
2957
|
+
return [
|
|
2958
|
+
"{faint}" + ("═" * width) + "{/}",
|
|
2959
|
+
" " + hdr,
|
|
2960
|
+
"{faint}" + ("═" * width) + "{/}",
|
|
2961
|
+
*(["{warn}⚠ sync failed: " + err + "{/}"] if err else []),
|
|
2962
|
+
]
|
|
2963
|
+
|
|
2964
|
+
|
|
2965
|
+
def _tui_footer_keys(width: int) -> list[str]:
|
|
2966
|
+
"""Variant A footer: top rule + keys legend."""
|
|
2967
|
+
return [
|
|
2968
|
+
"{faint}" + ("═" * width) + "{/}",
|
|
2969
|
+
(" {bright}Tab{/} {dim}focus{/} "
|
|
2970
|
+
"{bright}↑↓{/} {dim}scroll{/} "
|
|
2971
|
+
"{bright}r{/} {dim}refresh{/} "
|
|
2972
|
+
"{bright}s{/} {dim}sort{/} "
|
|
2973
|
+
"{bright}f{/} {dim}filter{/} "
|
|
2974
|
+
"{bright}/{/} {dim}search{/} "
|
|
2975
|
+
"{bright}Enter{/} {dim}open{/} "
|
|
2976
|
+
"{bright}v{/} {dim}variant{/} "
|
|
2977
|
+
"{bright}?{/} {dim}help{/} "
|
|
2978
|
+
"{bright}q{/} {dim}quit{/}"),
|
|
2979
|
+
]
|
|
2980
|
+
|
|
2981
|
+
|
|
2982
|
+
def _tui_render_input_prompt(
|
|
2983
|
+
runtime: RuntimeState, width: int, *, match_count: int | None = None,
|
|
2984
|
+
) -> list[str]:
|
|
2985
|
+
"""Render the bottom-row input prompt. Replaces the keys-legend row
|
|
2986
|
+
while runtime.input_mode is set. Spec §3.2 (filter), §3.3 (search).
|
|
2987
|
+
"""
|
|
2988
|
+
buf = runtime.input_buffer
|
|
2989
|
+
# Truncate displayed buffer if it would overflow.
|
|
2990
|
+
max_buf = max(10, width - 60)
|
|
2991
|
+
shown = buf if len(buf) <= max_buf else buf[-max_buf:]
|
|
2992
|
+
# Escape user input so a stray `{` or `}` doesn't get parsed as a
|
|
2993
|
+
# style tag by _tui_colortag (would crash the live render loop).
|
|
2994
|
+
shown = _tui_escape_tags(shown)
|
|
2995
|
+
if runtime.input_mode == "filter":
|
|
2996
|
+
prefix = "filter (project|model)"
|
|
2997
|
+
contract = "enter apply · esc cancel"
|
|
2998
|
+
else: # 'search'
|
|
2999
|
+
prefix = "search"
|
|
3000
|
+
contract = "enter confirm · esc cancel · n/N next/prev"
|
|
3001
|
+
match_suffix = ""
|
|
3002
|
+
if match_count is not None:
|
|
3003
|
+
cls = "bad" if match_count == 0 else "dim"
|
|
3004
|
+
match_suffix = f" {{{cls}}}· {match_count} matches{{/}}"
|
|
3005
|
+
body = (f" {{prompt}}{prefix}:{{/}} {{bright}}{shown}{{/}}{{caret}} {{/}}"
|
|
3006
|
+
f"{match_suffix} {{faint}}{contract}{{/}}")
|
|
3007
|
+
return [
|
|
3008
|
+
"{faint}" + ("═" * width) + "{/}",
|
|
3009
|
+
body,
|
|
3010
|
+
]
|
|
3011
|
+
|
|
3012
|
+
|
|
3013
|
+
# Tag matcher used to strip color tags for plain-text width math. Matches
|
|
3014
|
+
# both opening tags ({name} or {name.mod}) and closing tags ({/}).
|
|
3015
|
+
_TUI_TAG_RE = re.compile(r"\{(?:/|[a-zA-Z.]+)\}")
|
|
3016
|
+
|
|
3017
|
+
|
|
3018
|
+
def _tui_strip_tags(s: str) -> str:
|
|
3019
|
+
"""Return ``s`` with all color-tag markup removed (for width math)."""
|
|
3020
|
+
return _TUI_TAG_RE.sub("", s)
|
|
3021
|
+
|
|
3022
|
+
|
|
3023
|
+
def _tui_tagged_box_lines(
|
|
3024
|
+
*,
|
|
3025
|
+
width: int,
|
|
3026
|
+
body_tagged: list[str],
|
|
3027
|
+
title: str | None,
|
|
3028
|
+
pin: str | None,
|
|
3029
|
+
border_style: str = "faint",
|
|
3030
|
+
) -> list[str]:
|
|
3031
|
+
"""Return a list of tagged strings forming a double-line box.
|
|
3032
|
+
|
|
3033
|
+
Body lines may contain color tags. Width math strips tags before padding
|
|
3034
|
+
so that color markup does not inflate the visible length. ``border_style``
|
|
3035
|
+
is a theme style name applied to all border glyphs.
|
|
3036
|
+
|
|
3037
|
+
When a body line's plain length exceeds the interior width it is truncated
|
|
3038
|
+
with ``{/}`` appended as a safety net — callers should size their content
|
|
3039
|
+
to avoid this branch.
|
|
3040
|
+
"""
|
|
3041
|
+
H, V = _TUI_BOX["h"], _TUI_BOX["v"]
|
|
3042
|
+
TL, TR, BL, BR = _TUI_BOX["tl"], _TUI_BOX["tr"], _TUI_BOX["bl"], _TUI_BOX["br"]
|
|
3043
|
+
interior = width - 2
|
|
3044
|
+
|
|
3045
|
+
def _wrap_border(s: str) -> str:
|
|
3046
|
+
return f"{{{border_style}}}{s}{{/}}"
|
|
3047
|
+
|
|
3048
|
+
def _top() -> str:
|
|
3049
|
+
if title is None and pin is None:
|
|
3050
|
+
return _wrap_border(TL + H * interior + TR)
|
|
3051
|
+
t_seg = f" {title} " if title else ""
|
|
3052
|
+
p_seg = f" {pin} " if pin else ""
|
|
3053
|
+
if title and pin:
|
|
3054
|
+
fill = width - 4 - len(_tui_strip_tags(t_seg)) - len(_tui_strip_tags(p_seg))
|
|
3055
|
+
if fill >= 1:
|
|
3056
|
+
return (_wrap_border(TL + H) + t_seg
|
|
3057
|
+
+ _wrap_border(H * fill) + p_seg + _wrap_border(H + TR))
|
|
3058
|
+
if title:
|
|
3059
|
+
fill = width - 3 - len(_tui_strip_tags(t_seg))
|
|
3060
|
+
if fill >= 1:
|
|
3061
|
+
return _wrap_border(TL + H) + t_seg + _wrap_border(H * fill + TR)
|
|
3062
|
+
return _wrap_border(TL + H * interior + TR)
|
|
3063
|
+
|
|
3064
|
+
lines: list[str] = [_top()]
|
|
3065
|
+
for line in body_tagged:
|
|
3066
|
+
plain = _tui_strip_tags(line)
|
|
3067
|
+
if len(plain) > interior:
|
|
3068
|
+
# On overflow, drop color markup entirely — partial-tag truncation
|
|
3069
|
+
# would produce malformed tokens that crash _tui_colortag. Plain-
|
|
3070
|
+
# text is always safe, and callers are expected to size content
|
|
3071
|
+
# to fit (this branch is a safety net only).
|
|
3072
|
+
line = plain[: max(0, interior - 1)] + "…"
|
|
3073
|
+
plain = line
|
|
3074
|
+
pad = interior - len(plain)
|
|
3075
|
+
lines.append(_wrap_border(V) + line + " " * pad + _wrap_border(V))
|
|
3076
|
+
lines.append(_wrap_border(BL + H * interior + BR))
|
|
3077
|
+
return lines
|
|
3078
|
+
|
|
3079
|
+
|
|
3080
|
+
def _tui_lines_to_text(lines: list[str]):
|
|
3081
|
+
"""Join a list of tagged strings into a single rich.text.Text blob.
|
|
3082
|
+
|
|
3083
|
+
Each line is passed through ``_tui_colortag`` to materialize the style
|
|
3084
|
+
tags; adjacent lines are separated by a literal ``"\\n"``.
|
|
3085
|
+
"""
|
|
3086
|
+
from rich.text import Text
|
|
3087
|
+
out = Text()
|
|
3088
|
+
for i, l in enumerate(lines):
|
|
3089
|
+
if i:
|
|
3090
|
+
out.append("\n")
|
|
3091
|
+
out.append(_tui_colortag(l))
|
|
3092
|
+
return out
|
|
3093
|
+
|
|
3094
|
+
|
|
3095
|
+
def _tui_render_variant_a(
|
|
3096
|
+
snap: DataSnapshot, runtime: RuntimeState,
|
|
3097
|
+
width: int, height: int, bucket: str,
|
|
3098
|
+
*, overlay_panel: Panel | None = None,
|
|
3099
|
+
) -> Layout:
|
|
3100
|
+
"""Return a ``rich.layout.Layout`` for the whole Variant A frame.
|
|
3101
|
+
|
|
3102
|
+
Assembles the 2x2 grid (Current Week | Forecast / Trend | Sessions)
|
|
3103
|
+
with header and footer strips.
|
|
3104
|
+
|
|
3105
|
+
The returned Layout has real sub-regions:
|
|
3106
|
+
root (split_column):
|
|
3107
|
+
- header (size = len(header_lines))
|
|
3108
|
+
- warn? (size = 1, only when bucket == "narrow")
|
|
3109
|
+
- top_row (split_row current_week + forecast)
|
|
3110
|
+
- sep (size = 1)
|
|
3111
|
+
- bottom_row (split_row trend + sessions)
|
|
3112
|
+
- footer (size = len(footer_lines))
|
|
3113
|
+
|
|
3114
|
+
When ``overlay_panel`` is provided, the body regions (top_row/sep/bottom_row)
|
|
3115
|
+
are replaced with a single centered Align(overlay_panel) so the help
|
|
3116
|
+
overlay or v2 detail modal appears in the dashboard's body area.
|
|
3117
|
+
Header and footer remain visible. This is the body-region-swap overlay
|
|
3118
|
+
composition (Fallback A).
|
|
3119
|
+
"""
|
|
3120
|
+
from rich.layout import Layout
|
|
3121
|
+
from rich.align import Align
|
|
3122
|
+
|
|
3123
|
+
left_w = width // 2
|
|
3124
|
+
right_w = width - left_w
|
|
3125
|
+
|
|
3126
|
+
header = _tui_header_strip_a(snap, runtime, width)
|
|
3127
|
+
if runtime.input_mode is not None:
|
|
3128
|
+
match_count = None
|
|
3129
|
+
if runtime.input_mode == "filter":
|
|
3130
|
+
match_count = sum(
|
|
3131
|
+
1 for s in snap.sessions
|
|
3132
|
+
if (runtime.input_buffer.lower() in s.project_label.lower()
|
|
3133
|
+
or runtime.input_buffer.lower() in s.model_primary.lower())
|
|
3134
|
+
) if runtime.input_buffer else None
|
|
3135
|
+
elif runtime.input_mode == "search":
|
|
3136
|
+
if runtime.input_buffer:
|
|
3137
|
+
needle = runtime.input_buffer.lower()
|
|
3138
|
+
# Count against the post-filter list: confirmed search matches
|
|
3139
|
+
# are computed against the already-filtered sessions in
|
|
3140
|
+
# _tui_panel_sessions, so the live count must use the same set
|
|
3141
|
+
# or the prompt overstates what n/N can reach.
|
|
3142
|
+
pool = _tui_apply_session_filter(snap.sessions, runtime.filter_term)
|
|
3143
|
+
count = 0
|
|
3144
|
+
for s in pool:
|
|
3145
|
+
hay = (
|
|
3146
|
+
s.project_label.lower() + "|"
|
|
3147
|
+
+ s.model_primary.lower() + "|"
|
|
3148
|
+
+ _tui_format_started(s.started_at, snap.generated_at, runtime.display_tz).lower()
|
|
3149
|
+
)
|
|
3150
|
+
if needle in hay:
|
|
3151
|
+
count += 1
|
|
3152
|
+
match_count = count
|
|
3153
|
+
else:
|
|
3154
|
+
match_count = None
|
|
3155
|
+
footer = _tui_render_input_prompt(runtime, width, match_count=match_count)
|
|
3156
|
+
else:
|
|
3157
|
+
footer = _tui_footer_keys(width)
|
|
3158
|
+
|
|
3159
|
+
warn_line = (
|
|
3160
|
+
"{warn}⚠ narrow terminal — some columns hidden{/}"
|
|
3161
|
+
if bucket == "narrow" else None
|
|
3162
|
+
)
|
|
3163
|
+
|
|
3164
|
+
# Compute the body region (between header and footer).
|
|
3165
|
+
# When a narrow-warning line is present it occupies one extra row that
|
|
3166
|
+
# would otherwise belong to the body, matching the legacy behavior of
|
|
3167
|
+
# inserting the warn line at frame-row index 3.
|
|
3168
|
+
warn_rows = 1 if warn_line is not None else 0
|
|
3169
|
+
body_height = max(
|
|
3170
|
+
10,
|
|
3171
|
+
height - len(header) - warn_rows - len(footer) - 1,
|
|
3172
|
+
)
|
|
3173
|
+
top_h = body_height // 2
|
|
3174
|
+
bot_h = body_height - top_h
|
|
3175
|
+
|
|
3176
|
+
# TOP: current week (left) | forecast (right)
|
|
3177
|
+
cw_body = _tui_panel_current_week(
|
|
3178
|
+
snap, runtime, left_w, focused=runtime.focus_index == 0
|
|
3179
|
+
)
|
|
3180
|
+
fc_body = _tui_panel_forecast(snap, runtime, right_w)
|
|
3181
|
+
cw_box = _tui_tagged_box_lines(
|
|
3182
|
+
width=left_w, body_tagged=cw_body,
|
|
3183
|
+
title="{accent.b}Current Week{/}", pin="{dim}[1]{/}",
|
|
3184
|
+
border_style=("focused" if runtime.focus_index == 0 else "faint"),
|
|
3185
|
+
)
|
|
3186
|
+
fc_box = _tui_tagged_box_lines(
|
|
3187
|
+
width=right_w, body_tagged=fc_body,
|
|
3188
|
+
title="{accent.b}Forecast{/}", pin="{dim}[2]{/}",
|
|
3189
|
+
border_style=("focused" if runtime.focus_index == 1 else "faint"),
|
|
3190
|
+
)
|
|
3191
|
+
# Pad shorter box with blank interior rows up to the taller one.
|
|
3192
|
+
maxlen = max(len(cw_box), len(fc_box))
|
|
3193
|
+
|
|
3194
|
+
def _pad_box(lines: list[str], w: int, style: str) -> list[str]:
|
|
3195
|
+
while len(lines) < maxlen:
|
|
3196
|
+
lines.insert(-1, f"{{{style}}}║{{/}}{' ' * (w - 2)}{{{style}}}║{{/}}")
|
|
3197
|
+
return lines
|
|
3198
|
+
|
|
3199
|
+
cw_box = _pad_box(cw_box, left_w, "focused" if runtime.focus_index == 0 else "faint")
|
|
3200
|
+
fc_box = _pad_box(fc_box, right_w, "focused" if runtime.focus_index == 1 else "faint")
|
|
3201
|
+
|
|
3202
|
+
# BOTTOM: trend (left) | sessions (right). Use compact trend when the
|
|
3203
|
+
# cell height is tight (saves 3 rows of padding).
|
|
3204
|
+
trend_compact = (bot_h - 2) < 14
|
|
3205
|
+
trend_body = _tui_panel_trend(snap, runtime, left_w, compact=trend_compact)
|
|
3206
|
+
# Sessions chrome: 1 leading blank + 1 header + 1 ruler + 1 trailing blank
|
|
3207
|
+
# + 1 scroll footer = 5 rows around the data rows. Interior = bot_h - 2.
|
|
3208
|
+
rows_visible = max(3, bot_h - 2 - 5)
|
|
3209
|
+
show_proj = bucket == "wide"
|
|
3210
|
+
sess_body = _tui_panel_sessions(
|
|
3211
|
+
snap, runtime, right_w,
|
|
3212
|
+
rows_visible=rows_visible,
|
|
3213
|
+
show_project_col=show_proj,
|
|
3214
|
+
)
|
|
3215
|
+
trend_box = _tui_tagged_box_lines(
|
|
3216
|
+
width=left_w, body_tagged=trend_body,
|
|
3217
|
+
title="{accent.b}$/1% Trend{/} {dim}· 8 weeks{/}", pin="{dim}[3]{/}",
|
|
3218
|
+
border_style=("focused" if runtime.focus_index == 2 else "faint"),
|
|
3219
|
+
)
|
|
3220
|
+
sess_box = _tui_tagged_box_lines(
|
|
3221
|
+
width=right_w, body_tagged=sess_body,
|
|
3222
|
+
# Variant A puts Sessions in a half-pane; compact bucket needs narrow form
|
|
3223
|
+
# to keep the `focus` pin visible alongside the sort indicator. When a
|
|
3224
|
+
# filter chip is active, use narrow form even at the wide bucket because
|
|
3225
|
+
# the chip pushes the wide form past the half-pane width.
|
|
3226
|
+
title=_tui_sessions_title(
|
|
3227
|
+
runtime,
|
|
3228
|
+
narrow=(bucket in ("narrow", "compact") or runtime.filter_term is not None),
|
|
3229
|
+
),
|
|
3230
|
+
pin=("{focused}focus{/}" if runtime.focus_index == 3 else "{dim}[4]{/}"),
|
|
3231
|
+
border_style=("focused" if runtime.focus_index == 3 else "faint"),
|
|
3232
|
+
)
|
|
3233
|
+
maxlen2 = max(len(trend_box), len(sess_box))
|
|
3234
|
+
|
|
3235
|
+
def _pad_box2(lines: list[str], w: int, style: str) -> list[str]:
|
|
3236
|
+
while len(lines) < maxlen2:
|
|
3237
|
+
lines.insert(-1, f"{{{style}}}║{{/}}{' ' * (w - 2)}{{{style}}}║{{/}}")
|
|
3238
|
+
return lines
|
|
3239
|
+
|
|
3240
|
+
trend_box = _pad_box2(trend_box, left_w, "focused" if runtime.focus_index == 2 else "faint")
|
|
3241
|
+
sess_box = _pad_box2(sess_box, right_w, "focused" if runtime.focus_index == 3 else "faint")
|
|
3242
|
+
|
|
3243
|
+
# Build per-region Text blobs. Each row band renders as a single Text
|
|
3244
|
+
# so intra-band line breaks are not padded to the full width by
|
|
3245
|
+
# Layout's line-pad; only the region-tail gets padded.
|
|
3246
|
+
header_text = _tui_lines_to_text(header)
|
|
3247
|
+
footer_text = _tui_lines_to_text(footer)
|
|
3248
|
+
cw_text = _tui_lines_to_text(cw_box)
|
|
3249
|
+
fc_text = _tui_lines_to_text(fc_box)
|
|
3250
|
+
trend_text = _tui_lines_to_text(trend_box)
|
|
3251
|
+
sess_text = _tui_lines_to_text(sess_box)
|
|
3252
|
+
|
|
3253
|
+
root = Layout()
|
|
3254
|
+
regions: list[Layout] = [Layout(name="header", size=len(header))]
|
|
3255
|
+
if warn_line is not None:
|
|
3256
|
+
regions.append(Layout(name="warn", size=1))
|
|
3257
|
+
|
|
3258
|
+
if overlay_panel is not None:
|
|
3259
|
+
# Fallback A: collapse the body bands into a single region containing
|
|
3260
|
+
# a vertically-centered overlay Panel. Preserve header + footer.
|
|
3261
|
+
body_rows = maxlen + 1 + maxlen2
|
|
3262
|
+
regions.append(Layout(name="body", size=body_rows))
|
|
3263
|
+
regions.append(Layout(name="footer", size=len(footer)))
|
|
3264
|
+
root.split_column(*regions)
|
|
3265
|
+
root["header"].update(header_text)
|
|
3266
|
+
if warn_line is not None:
|
|
3267
|
+
root["warn"].update(_tui_lines_to_text([warn_line]))
|
|
3268
|
+
root["body"].update(Align.center(overlay_panel, vertical="middle"))
|
|
3269
|
+
root["footer"].update(footer_text)
|
|
3270
|
+
root._tui_natural_height = sum(r.size or 0 for r in regions)
|
|
3271
|
+
return root
|
|
3272
|
+
|
|
3273
|
+
regions.extend([
|
|
3274
|
+
Layout(name="top", size=maxlen),
|
|
3275
|
+
Layout(name="sep", size=1),
|
|
3276
|
+
Layout(name="bot", size=maxlen2),
|
|
3277
|
+
Layout(name="footer", size=len(footer)),
|
|
3278
|
+
])
|
|
3279
|
+
root.split_column(*regions)
|
|
3280
|
+
|
|
3281
|
+
root["header"].update(header_text)
|
|
3282
|
+
if warn_line is not None:
|
|
3283
|
+
root["warn"].update(_tui_lines_to_text([warn_line]))
|
|
3284
|
+
|
|
3285
|
+
root["top"].split_row(
|
|
3286
|
+
Layout(name="cw", size=left_w),
|
|
3287
|
+
Layout(name="fc", size=right_w),
|
|
3288
|
+
)
|
|
3289
|
+
root["top"]["cw"].update(cw_text)
|
|
3290
|
+
root["top"]["fc"].update(fc_text)
|
|
3291
|
+
|
|
3292
|
+
# The separator is intentionally a single blank row. The leading rich
|
|
3293
|
+
# Text of a single empty string becomes a zero-column line; Layout pads
|
|
3294
|
+
# it to the full width (that padding is the trailing-whitespace drift
|
|
3295
|
+
# accepted by the scoped-relaxation protocol).
|
|
3296
|
+
root["sep"].update(_tui_lines_to_text([""]))
|
|
3297
|
+
|
|
3298
|
+
root["bot"].split_row(
|
|
3299
|
+
Layout(name="trend", size=left_w),
|
|
3300
|
+
Layout(name="sess", size=right_w),
|
|
3301
|
+
)
|
|
3302
|
+
root["bot"]["trend"].update(trend_text)
|
|
3303
|
+
root["bot"]["sess"].update(sess_text)
|
|
3304
|
+
root["footer"].update(footer_text)
|
|
3305
|
+
# Stash the natural (content-filling) height so ``_tui_render_once``
|
|
3306
|
+
# can recover the pre-refactor row count without padding to the full
|
|
3307
|
+
# terminal. Live mode ignores this attribute — it lets Layout fill the
|
|
3308
|
+
# actual terminal height, which is the desired TUI behavior.
|
|
3309
|
+
root._tui_natural_height = sum(r.size or 0 for r in regions)
|
|
3310
|
+
return root
|
|
3311
|
+
|
|
3312
|
+
|
|
3313
|
+
def _tui_render_variant_b(
|
|
3314
|
+
snap: DataSnapshot, runtime: RuntimeState,
|
|
3315
|
+
width: int, height: int, bucket: str,
|
|
3316
|
+
*, overlay_panel: Panel | None = None,
|
|
3317
|
+
) -> Layout:
|
|
3318
|
+
"""Return a ``rich.layout.Layout`` for the whole Variant B frame.
|
|
3319
|
+
|
|
3320
|
+
Structure: ribbon -> subheader -> hero row (big meter + promoted
|
|
3321
|
+
sparkline) -> forecast-budget strip -> full-width sessions -> footer.
|
|
3322
|
+
|
|
3323
|
+
Vertical bands as Layout regions:
|
|
3324
|
+
ribbon (size=1), sub (size=1), rule (size=1),
|
|
3325
|
+
warn? (size=1, narrow only), blank1 (size=1),
|
|
3326
|
+
hero_row (split_row hero + trend, size=len(hero_box)),
|
|
3327
|
+
blank2 (size=1), fc_strip (size=len(fc_strip_box)),
|
|
3328
|
+
blank3 (size=1),
|
|
3329
|
+
sessions (size=len(sess_box)), footer (size=len(footer_lines))
|
|
3330
|
+
|
|
3331
|
+
When ``overlay_panel`` is provided, the body bands collapse into one
|
|
3332
|
+
``body`` region filled with a centered Align(overlay_panel) so the
|
|
3333
|
+
help overlay or v2 detail modal appears in the dashboard's body area
|
|
3334
|
+
(Fallback A overlay). Ribbon / subheader / rule / footer remain visible.
|
|
3335
|
+
"""
|
|
3336
|
+
from rich.layout import Layout
|
|
3337
|
+
from rich.align import Align
|
|
3338
|
+
|
|
3339
|
+
# --- Ribbon ---------------------------------------------------------
|
|
3340
|
+
verdict = _tui_verdict_of(snap.forecast) if snap.forecast else "LOW CONF"
|
|
3341
|
+
vcls = _TUI_VERDICT_CLS[verdict]
|
|
3342
|
+
# _TUI_VERDICT_CLS always maps to ok/warn/bad after the SSoT fix, so vcls
|
|
3343
|
+
# is guaranteed to be a valid badge class here.
|
|
3344
|
+
if snap.forecast:
|
|
3345
|
+
# Compute projections directly from rate methods — final_low/final_high
|
|
3346
|
+
# are min/max aggregates and swap labels when recent-24h rate is lower
|
|
3347
|
+
# than week-average (mirrors the Variant A fix in commit 15b6fab).
|
|
3348
|
+
p_now = snap.forecast.inputs.p_now
|
|
3349
|
+
remaining = snap.forecast.inputs.remaining_hours
|
|
3350
|
+
wa = int(round(p_now + snap.forecast.r_avg * remaining))
|
|
3351
|
+
rc = wa if snap.forecast.r_recent is None else int(round(p_now + snap.forecast.r_recent * remaining))
|
|
3352
|
+
else:
|
|
3353
|
+
wa, rc = 0, 0
|
|
3354
|
+
vmsg = _TUI_VERDICT_SHORT[verdict]
|
|
3355
|
+
ribbon_text = f" [ {verdict} ] {vmsg} · week-avg {wa}% · recent-24h {rc}%"
|
|
3356
|
+
ribbon_pad = max(0, width - len(ribbon_text))
|
|
3357
|
+
ribbon = f"{{badge.{vcls}}}{ribbon_text}{' ' * ribbon_pad}{{/}}"
|
|
3358
|
+
|
|
3359
|
+
# --- Subheader ------------------------------------------------------
|
|
3360
|
+
cw = snap.current_week
|
|
3361
|
+
if cw:
|
|
3362
|
+
import time as _t
|
|
3363
|
+
sync_age = 0
|
|
3364
|
+
if snap.last_sync_at is not None:
|
|
3365
|
+
sync_age = int(_t.monotonic() - snap.last_sync_at)
|
|
3366
|
+
sync_txt = f"synced {sync_age}s ago" if snap.last_sync_at is not None else "synced —"
|
|
3367
|
+
# Pre-compute interpolated fragments so nothing inside the f-string
|
|
3368
|
+
# uses a nested conditional format spec (which Python rejects).
|
|
3369
|
+
dpp_str = (
|
|
3370
|
+
f"${cw.dollars_per_percent:.2f}"
|
|
3371
|
+
if cw.dollars_per_percent is not None else "—"
|
|
3372
|
+
)
|
|
3373
|
+
reset_delta = cw.week_end_at - snap.generated_at
|
|
3374
|
+
reset_secs = max(0, int(reset_delta.total_seconds()))
|
|
3375
|
+
reset_days = reset_secs // 86400
|
|
3376
|
+
reset_hrs = (reset_secs % 86400) // 3600
|
|
3377
|
+
sub = (
|
|
3378
|
+
f" {{bright.b}}Week {format_display_dt(cw.week_start_at, runtime.display_tz, fmt='%b %d', suffix=False)}–"
|
|
3379
|
+
f"{format_display_dt(cw.week_end_at, runtime.display_tz, fmt='%b %d', suffix=False)}{{/}} "
|
|
3380
|
+
f"{{dim}}${cw.spent_usd:.2f} spent · $/1% {dpp_str} · "
|
|
3381
|
+
f"resets in {reset_days}d {reset_hrs}h{{/}}"
|
|
3382
|
+
f" · {{dim.pulse}}● {sync_txt}{{/}}"
|
|
3383
|
+
)
|
|
3384
|
+
else:
|
|
3385
|
+
sub = " {dim}no current-week data yet{/}"
|
|
3386
|
+
|
|
3387
|
+
hero_left_w = 55 if bucket != "wide" else int(width * 0.56)
|
|
3388
|
+
hero_right_w = width - hero_left_w
|
|
3389
|
+
|
|
3390
|
+
hero_body = _tui_panel_current_week_hero(snap, runtime, hero_left_w)
|
|
3391
|
+
|
|
3392
|
+
# Big sparkline on the right (promoted trend view).
|
|
3393
|
+
heights = [r.spark_height for r in snap.trend] or [1]
|
|
3394
|
+
big = _tui_sparkline_big(heights).split("\n")
|
|
3395
|
+
cur_rate = (snap.trend[-1].dollars_per_percent if snap.trend else None)
|
|
3396
|
+
cur_delta = (snap.trend[-1].delta_dpp if snap.trend else None)
|
|
3397
|
+
rate_str = f"${cur_rate:.2f}" if cur_rate is not None else "—"
|
|
3398
|
+
if cur_delta is None:
|
|
3399
|
+
delta_str = "—"
|
|
3400
|
+
else:
|
|
3401
|
+
sign = "+" if cur_delta >= 0 else ""
|
|
3402
|
+
delta_str = f"{sign}{cur_delta:.2f}"
|
|
3403
|
+
|
|
3404
|
+
trend_title_text = "this week" if bucket != "wide" else "$/1% OVER 8 WEEKS"
|
|
3405
|
+
trend_body = [
|
|
3406
|
+
"",
|
|
3407
|
+
f" {{dim}}{trend_title_text}{{/}} "
|
|
3408
|
+
f"{{bright.b}}{rate_str}{{/}} {{ok}}{delta_str}{{/}}",
|
|
3409
|
+
"",
|
|
3410
|
+
f" {{accent}}{big[0] if len(big) > 0 else ''}{{/}}",
|
|
3411
|
+
f" {{accent}}{big[1] if len(big) > 1 else ''}{{/}}",
|
|
3412
|
+
f" {{accent}}{big[2] if len(big) > 2 else ''}{{/}}",
|
|
3413
|
+
f" {{faint}}{'─' * min(hero_right_w - 4, 24)}{{/}}",
|
|
3414
|
+
"",
|
|
3415
|
+
]
|
|
3416
|
+
|
|
3417
|
+
hero_box = _tui_tagged_box_lines(
|
|
3418
|
+
width=hero_left_w, body_tagged=hero_body,
|
|
3419
|
+
title="{accent.b}Current Week{/}",
|
|
3420
|
+
pin=("{focused}focus{/}" if runtime.focus_index == 0 else "{dim}[1]{/}"),
|
|
3421
|
+
border_style=("focused" if runtime.focus_index == 0 else "faint"),
|
|
3422
|
+
)
|
|
3423
|
+
trend_box = _tui_tagged_box_lines(
|
|
3424
|
+
width=hero_right_w, body_tagged=trend_body,
|
|
3425
|
+
title="{accent.b}$/1% Trend{/} {dim}· 8 weeks{/}",
|
|
3426
|
+
pin=("{focused}focus{/}" if runtime.focus_index == 2 else "{dim}[3]{/}"),
|
|
3427
|
+
border_style=("focused" if runtime.focus_index == 2 else "faint"),
|
|
3428
|
+
)
|
|
3429
|
+
m = max(len(hero_box), len(trend_box))
|
|
3430
|
+
|
|
3431
|
+
def _pad(lines: list[str], w: int, style: str) -> list[str]:
|
|
3432
|
+
while len(lines) < m:
|
|
3433
|
+
lines.insert(-1, f"{{{style}}}║{{/}}{' ' * (w - 2)}{{{style}}}║{{/}}")
|
|
3434
|
+
return lines
|
|
3435
|
+
|
|
3436
|
+
hero_box = _pad(hero_box, hero_left_w, "focused" if runtime.focus_index == 0 else "faint")
|
|
3437
|
+
trend_box = _pad(trend_box, hero_right_w, "focused" if runtime.focus_index == 2 else "faint")
|
|
3438
|
+
|
|
3439
|
+
# --- Forecast & Budget strip ----------------------------------------
|
|
3440
|
+
if snap.forecast:
|
|
3441
|
+
b100 = next((r for r in snap.forecast.budgets if r.target_percent == 100), None)
|
|
3442
|
+
b90 = next((r for r in snap.forecast.budgets if r.target_percent == 90), None)
|
|
3443
|
+
b100_s = (
|
|
3444
|
+
f"${b100.dollars_per_day:.2f}/d"
|
|
3445
|
+
if b100 and b100.dollars_per_day is not None else "—"
|
|
3446
|
+
)
|
|
3447
|
+
b90_s = (
|
|
3448
|
+
f"${b90.dollars_per_day:.2f}/d"
|
|
3449
|
+
if b90 and b90.dollars_per_day is not None else "—"
|
|
3450
|
+
)
|
|
3451
|
+
reset_str = (
|
|
3452
|
+
format_display_dt(cw.week_end_at, runtime.display_tz, fmt="%b %d %H:%M", suffix=True)
|
|
3453
|
+
if cw else "—"
|
|
3454
|
+
)
|
|
3455
|
+
fcstrip_body = [
|
|
3456
|
+
"",
|
|
3457
|
+
(f" {{dim}}wk-avg{{/}} {{{_tui_bar_color(wa)}.b}}{wa}%{{/}} "
|
|
3458
|
+
f"{{dim}}24h{{/}} {{{_tui_bar_color(rc)}.b}}{rc}%{{/}} "
|
|
3459
|
+
f"{{faint}}│{{/}} "
|
|
3460
|
+
f"{{dim}}≤100%{{/}} {{bright.b}}{b100_s}{{/}} "
|
|
3461
|
+
f"{{dim}}≤90%{{/}} {{bright.b}}{b90_s}{{/}} "
|
|
3462
|
+
f"{{faint}}│{{/}} "
|
|
3463
|
+
f"{{dim}}reset{{/}} {{bright}}{reset_str}{{/}}"),
|
|
3464
|
+
"",
|
|
3465
|
+
]
|
|
3466
|
+
else:
|
|
3467
|
+
fcstrip_body = ["", " {dim}forecast unavailable{/}", ""]
|
|
3468
|
+
fc_strip_box = _tui_tagged_box_lines(
|
|
3469
|
+
width=width, body_tagged=fcstrip_body,
|
|
3470
|
+
title="{accent.b}Forecast & Budget{/}",
|
|
3471
|
+
pin=("{focused}focus{/}" if runtime.focus_index == 1 else "{dim}[2]{/}"),
|
|
3472
|
+
border_style=("focused" if runtime.focus_index == 1 else "faint"),
|
|
3473
|
+
)
|
|
3474
|
+
|
|
3475
|
+
# --- Sessions (full-width, always focused in B) --------------------
|
|
3476
|
+
# Chrome around the sessions panel:
|
|
3477
|
+
# ribbon(1) + sub(1) + rule(1) + 1 blank
|
|
3478
|
+
# + hero_box lines
|
|
3479
|
+
# + 1 blank after hero
|
|
3480
|
+
# + fc_strip_box lines
|
|
3481
|
+
# + 1 blank after fc strip
|
|
3482
|
+
# + 2 lines for the sessions box borders (top + bottom)
|
|
3483
|
+
# + 5 sessions-panel chrome lines (leading blank + header + ruler
|
|
3484
|
+
# + trailing blank + "↑↓ scroll · N below")
|
|
3485
|
+
# + footer_lines(2)
|
|
3486
|
+
if runtime.input_mode is not None:
|
|
3487
|
+
match_count = None
|
|
3488
|
+
if runtime.input_mode == "filter":
|
|
3489
|
+
match_count = sum(
|
|
3490
|
+
1 for s in snap.sessions
|
|
3491
|
+
if (runtime.input_buffer.lower() in s.project_label.lower()
|
|
3492
|
+
or runtime.input_buffer.lower() in s.model_primary.lower())
|
|
3493
|
+
) if runtime.input_buffer else None
|
|
3494
|
+
elif runtime.input_mode == "search":
|
|
3495
|
+
if runtime.input_buffer:
|
|
3496
|
+
needle = runtime.input_buffer.lower()
|
|
3497
|
+
# See _tui_render_variant_a: count must match the navigable
|
|
3498
|
+
# post-filter set used by _tui_panel_sessions.
|
|
3499
|
+
pool = _tui_apply_session_filter(snap.sessions, runtime.filter_term)
|
|
3500
|
+
count = 0
|
|
3501
|
+
for s in pool:
|
|
3502
|
+
hay = (
|
|
3503
|
+
s.project_label.lower() + "|"
|
|
3504
|
+
+ s.model_primary.lower() + "|"
|
|
3505
|
+
+ _tui_format_started(s.started_at, snap.generated_at, runtime.display_tz).lower()
|
|
3506
|
+
)
|
|
3507
|
+
if needle in hay:
|
|
3508
|
+
count += 1
|
|
3509
|
+
match_count = count
|
|
3510
|
+
else:
|
|
3511
|
+
match_count = None
|
|
3512
|
+
footer_lines = _tui_render_input_prompt(runtime, width, match_count=match_count)
|
|
3513
|
+
else:
|
|
3514
|
+
footer_lines = _tui_footer_keys(width)
|
|
3515
|
+
sessions_chrome = 5 # "" + header + ruler + "" + "↑↓ scroll · N below"
|
|
3516
|
+
box_borders = 2 # sessions box top + bottom
|
|
3517
|
+
non_session_rows = (
|
|
3518
|
+
4 # ribbon + sub + rule + blank
|
|
3519
|
+
+ len(hero_box)
|
|
3520
|
+
+ 1 # blank after hero
|
|
3521
|
+
+ len(fc_strip_box)
|
|
3522
|
+
+ 1 # blank after fc strip
|
|
3523
|
+
+ box_borders
|
|
3524
|
+
+ sessions_chrome
|
|
3525
|
+
+ len(footer_lines)
|
|
3526
|
+
)
|
|
3527
|
+
rows_visible = max(3, height - non_session_rows)
|
|
3528
|
+
sess_body = _tui_panel_sessions(
|
|
3529
|
+
snap, runtime, width,
|
|
3530
|
+
rows_visible=rows_visible,
|
|
3531
|
+
show_project_col=(bucket == "wide"),
|
|
3532
|
+
)
|
|
3533
|
+
sess_box = _tui_tagged_box_lines(
|
|
3534
|
+
width=width, body_tagged=sess_body,
|
|
3535
|
+
title=_tui_sessions_title(runtime, narrow=(bucket == "narrow")),
|
|
3536
|
+
pin=("{focused}focus{/}" if runtime.focus_index == 3 else "{dim}[4]{/}"),
|
|
3537
|
+
border_style=("focused" if runtime.focus_index == 3 else "faint"),
|
|
3538
|
+
)
|
|
3539
|
+
|
|
3540
|
+
# --- Assemble -------------------------------------------------------
|
|
3541
|
+
rule_line = "{faint}" + ("═" * width) + "{/}"
|
|
3542
|
+
warn_line = (
|
|
3543
|
+
"{warn}⚠ narrow terminal — some columns hidden{/}"
|
|
3544
|
+
if bucket == "narrow" else None
|
|
3545
|
+
)
|
|
3546
|
+
|
|
3547
|
+
hero_text = _tui_lines_to_text(hero_box)
|
|
3548
|
+
trend_text = _tui_lines_to_text(trend_box)
|
|
3549
|
+
fc_strip_text = _tui_lines_to_text(fc_strip_box)
|
|
3550
|
+
sess_text = _tui_lines_to_text(sess_box)
|
|
3551
|
+
footer_text = _tui_lines_to_text(footer_lines)
|
|
3552
|
+
|
|
3553
|
+
root = Layout()
|
|
3554
|
+
regions: list[Layout] = [
|
|
3555
|
+
Layout(name="ribbon", size=1),
|
|
3556
|
+
Layout(name="sub", size=1),
|
|
3557
|
+
Layout(name="rule", size=1),
|
|
3558
|
+
]
|
|
3559
|
+
if warn_line is not None:
|
|
3560
|
+
regions.append(Layout(name="warn", size=1))
|
|
3561
|
+
|
|
3562
|
+
if overlay_panel is not None:
|
|
3563
|
+
# Fallback A: collapse the body bands into one ``body`` region
|
|
3564
|
+
# holding a centered overlay Panel. Ribbon/sub/rule/footer remain.
|
|
3565
|
+
body_rows = (
|
|
3566
|
+
1 # blank after rule
|
|
3567
|
+
+ len(hero_box)
|
|
3568
|
+
+ 1 # blank after hero
|
|
3569
|
+
+ len(fc_strip_box)
|
|
3570
|
+
+ 1 # blank after fc strip
|
|
3571
|
+
+ len(sess_box)
|
|
3572
|
+
)
|
|
3573
|
+
regions.append(Layout(name="body", size=body_rows))
|
|
3574
|
+
regions.append(Layout(name="footer", size=len(footer_lines)))
|
|
3575
|
+
root.split_column(*regions)
|
|
3576
|
+
root["ribbon"].update(_tui_lines_to_text([ribbon]))
|
|
3577
|
+
root["sub"].update(_tui_lines_to_text([sub]))
|
|
3578
|
+
root["rule"].update(_tui_lines_to_text([rule_line]))
|
|
3579
|
+
if warn_line is not None:
|
|
3580
|
+
root["warn"].update(_tui_lines_to_text([warn_line]))
|
|
3581
|
+
root["body"].update(Align.center(overlay_panel, vertical="middle"))
|
|
3582
|
+
root["footer"].update(footer_text)
|
|
3583
|
+
root._tui_natural_height = sum(r.size or 0 for r in regions)
|
|
3584
|
+
return root
|
|
3585
|
+
|
|
3586
|
+
regions.extend([
|
|
3587
|
+
Layout(name="blank1", size=1),
|
|
3588
|
+
Layout(name="hero_row", size=len(hero_box)),
|
|
3589
|
+
Layout(name="blank2", size=1),
|
|
3590
|
+
Layout(name="fc_strip", size=len(fc_strip_box)),
|
|
3591
|
+
Layout(name="blank3", size=1),
|
|
3592
|
+
Layout(name="sessions", size=len(sess_box)),
|
|
3593
|
+
Layout(name="footer", size=len(footer_lines)),
|
|
3594
|
+
])
|
|
3595
|
+
root.split_column(*regions)
|
|
3596
|
+
|
|
3597
|
+
root["ribbon"].update(_tui_lines_to_text([ribbon]))
|
|
3598
|
+
root["sub"].update(_tui_lines_to_text([sub]))
|
|
3599
|
+
root["rule"].update(_tui_lines_to_text([rule_line]))
|
|
3600
|
+
if warn_line is not None:
|
|
3601
|
+
root["warn"].update(_tui_lines_to_text([warn_line]))
|
|
3602
|
+
|
|
3603
|
+
root["blank1"].update(_tui_lines_to_text([""]))
|
|
3604
|
+
root["hero_row"].split_row(
|
|
3605
|
+
Layout(name="hero", size=hero_left_w),
|
|
3606
|
+
Layout(name="trend", size=hero_right_w),
|
|
3607
|
+
)
|
|
3608
|
+
root["hero_row"]["hero"].update(hero_text)
|
|
3609
|
+
root["hero_row"]["trend"].update(trend_text)
|
|
3610
|
+
root["blank2"].update(_tui_lines_to_text([""]))
|
|
3611
|
+
root["fc_strip"].update(fc_strip_text)
|
|
3612
|
+
root["blank3"].update(_tui_lines_to_text([""]))
|
|
3613
|
+
root["sessions"].update(sess_text)
|
|
3614
|
+
root["footer"].update(footer_text)
|
|
3615
|
+
root._tui_natural_height = sum(r.size or 0 for r in regions)
|
|
3616
|
+
return root
|
|
3617
|
+
|
|
3618
|
+
|
|
3619
|
+
_TUI_HELP_LINES = [
|
|
3620
|
+
"",
|
|
3621
|
+
" {accent.b}Dashboard{/}",
|
|
3622
|
+
"",
|
|
3623
|
+
" {bright}q{/} {dim}·{/} {fg}quit{/}",
|
|
3624
|
+
" {bright}r{/} {dim}·{/} {fg}force refresh{/}",
|
|
3625
|
+
" {bright}v{/} {dim}·{/} {fg}toggle variant (conventional/expressive){/}",
|
|
3626
|
+
" {bright}Tab{/} {dim}·{/} {fg}cycle focus across panels{/}",
|
|
3627
|
+
" {bright}↑↓ / j k{/} {dim}·{/} {fg}scroll sessions or modal{/}",
|
|
3628
|
+
" {bright}PgUp/PgDn{/} {dim}·{/} {fg}page scroll{/}",
|
|
3629
|
+
"",
|
|
3630
|
+
" {accent.b}Sessions panel{/}",
|
|
3631
|
+
" {bright}s{/} {dim}·{/} {fg}cycle sort key{/}",
|
|
3632
|
+
" {bright}f{/} {dim}·{/} {fg}filter (project|model substring){/}",
|
|
3633
|
+
" {bright}/{/} {dim}·{/} {fg}search (highlight + jump){/}",
|
|
3634
|
+
" {bright}n / N{/} {dim}·{/} {fg}next/prev search match{/}",
|
|
3635
|
+
"",
|
|
3636
|
+
" {accent.b}Detail modals{/}",
|
|
3637
|
+
" {bright}Enter{/} {dim}·{/} {fg}open detail of focused panel{/}",
|
|
3638
|
+
" {bright}1 2 3 4{/} {dim}·{/} {fg}open Current/Forecast/Trend/Sessions detail{/}",
|
|
3639
|
+
" {bright}Esc{/} {dim}·{/} {fg}close modal · cancel input · close help{/}",
|
|
3640
|
+
"",
|
|
3641
|
+
" {bright}?{/} {dim}·{/} {fg}toggle help{/}",
|
|
3642
|
+
"",
|
|
3643
|
+
]
|
|
3644
|
+
|
|
3645
|
+
|
|
3646
|
+
def _tui_render_help(width: int, height: int) -> Panel:
|
|
3647
|
+
"""Return a ``rich.panel.Panel`` for the help overlay.
|
|
3648
|
+
|
|
3649
|
+
The Panel lists the keybindings; the caller is responsible for
|
|
3650
|
+
centering it via ``rich.align.Align.center`` and composing it over
|
|
3651
|
+
the variant Layout (see the body-region-swap overlay in
|
|
3652
|
+
``_tui_render_variant_a`` / ``_tui_render_variant_b``).
|
|
3653
|
+
|
|
3654
|
+
``height`` is accepted for API symmetry but not currently used —
|
|
3655
|
+
the Panel auto-sizes to its content.
|
|
3656
|
+
"""
|
|
3657
|
+
from rich import box as _rich_box
|
|
3658
|
+
from rich.panel import Panel as _Panel
|
|
3659
|
+
|
|
3660
|
+
# Build body Text from _TUI_HELP_LINES verbatim (tags unchanged).
|
|
3661
|
+
body = _tui_lines_to_text(_TUI_HELP_LINES)
|
|
3662
|
+
panel_w = min(max(width - 4, 20), 60)
|
|
3663
|
+
return _Panel(
|
|
3664
|
+
body,
|
|
3665
|
+
box=_rich_box.DOUBLE,
|
|
3666
|
+
title=_tui_colortag("{accent.b}Help{/}"),
|
|
3667
|
+
subtitle=_tui_colortag("{dim}? to close{/}"),
|
|
3668
|
+
border_style="accent",
|
|
3669
|
+
width=panel_w,
|
|
3670
|
+
)
|
|
3671
|
+
|
|
3672
|
+
|
|
3673
|
+
def _tui_modal_max_width(width: int) -> int:
|
|
3674
|
+
"""Per-bucket modal width (spec §5.1)."""
|
|
3675
|
+
bucket = _tui_width_bucket(width)
|
|
3676
|
+
if bucket == "wide":
|
|
3677
|
+
return min(width - 4, 90)
|
|
3678
|
+
if bucket == "compact":
|
|
3679
|
+
return min(width - 4, 70)
|
|
3680
|
+
# narrow
|
|
3681
|
+
return max(60, width - 2)
|
|
3682
|
+
|
|
3683
|
+
|
|
3684
|
+
def _tui_render_modal(
|
|
3685
|
+
snap: DataSnapshot,
|
|
3686
|
+
runtime: RuntimeState,
|
|
3687
|
+
width: int,
|
|
3688
|
+
height: int,
|
|
3689
|
+
) -> Panel:
|
|
3690
|
+
"""Render the active detail modal as a centered Panel.
|
|
3691
|
+
|
|
3692
|
+
Dispatches on runtime.modal_kind to a per-kind content builder.
|
|
3693
|
+
Per-kind builders return list[str] (color-tagged lines); this
|
|
3694
|
+
function slices them by runtime.modal_scroll, wraps in a Panel
|
|
3695
|
+
with the shared chrome, and returns it for body-region-swap
|
|
3696
|
+
composition.
|
|
3697
|
+
|
|
3698
|
+
Spec §4.1 (chrome) + §4.6 (per-kind content).
|
|
3699
|
+
"""
|
|
3700
|
+
from rich import box as _rich_box
|
|
3701
|
+
from rich.panel import Panel as _Panel
|
|
3702
|
+
|
|
3703
|
+
kind = runtime.modal_kind or "current_week"
|
|
3704
|
+
if kind == "current_week":
|
|
3705
|
+
title, content_lines = _tui_modal_current_week(snap, runtime, width)
|
|
3706
|
+
elif kind == "forecast":
|
|
3707
|
+
title, content_lines = _tui_modal_forecast(snap, runtime, width)
|
|
3708
|
+
elif kind == "trend":
|
|
3709
|
+
title, content_lines = _tui_modal_trend(snap, runtime, width)
|
|
3710
|
+
elif kind == "session":
|
|
3711
|
+
title, content_lines = _tui_modal_session(snap, runtime, width)
|
|
3712
|
+
else:
|
|
3713
|
+
title = "Modal"
|
|
3714
|
+
content_lines = ["", " {dim}placeholder{/}", ""]
|
|
3715
|
+
|
|
3716
|
+
panel_w = _tui_modal_max_width(width)
|
|
3717
|
+
panel_h = min(height - 4, 30)
|
|
3718
|
+
viewport = max(5, panel_h - 4) # subtract title + subtitle + padding
|
|
3719
|
+
|
|
3720
|
+
total = len(content_lines)
|
|
3721
|
+
scroll = max(0, min(runtime.modal_scroll, max(0, total - viewport)))
|
|
3722
|
+
runtime.modal_scroll = scroll # write back the clamp
|
|
3723
|
+
visible = content_lines[scroll : scroll + viewport]
|
|
3724
|
+
# Pad to viewport so the panel height is stable across scrolls.
|
|
3725
|
+
while len(visible) < viewport:
|
|
3726
|
+
visible.append("")
|
|
3727
|
+
|
|
3728
|
+
body = _tui_lines_to_text(visible)
|
|
3729
|
+
subtitle = "{dim}Esc back{/}"
|
|
3730
|
+
if total > viewport:
|
|
3731
|
+
subtitle = f"{{dim}}Esc back · {scroll + 1}-{scroll + viewport}/{total} ↓{{/}}"
|
|
3732
|
+
|
|
3733
|
+
return _Panel(
|
|
3734
|
+
body,
|
|
3735
|
+
box=_rich_box.DOUBLE,
|
|
3736
|
+
title=_tui_colortag(title),
|
|
3737
|
+
subtitle=_tui_colortag(subtitle),
|
|
3738
|
+
border_style="accent",
|
|
3739
|
+
width=panel_w,
|
|
3740
|
+
)
|
|
3741
|
+
|
|
3742
|
+
|
|
3743
|
+
# ---- per-kind modal content builders (spec §4.6) ----
|
|
3744
|
+
def _tui_modal_current_week(snap, runtime, width):
|
|
3745
|
+
"""Per-percent milestones for the current week (spec §4.6.1)."""
|
|
3746
|
+
cw = snap.current_week
|
|
3747
|
+
milestones = snap.percent_milestones
|
|
3748
|
+
if cw is None:
|
|
3749
|
+
return ("{accent.b}Current Week · per-percent{/}",
|
|
3750
|
+
["", " {dim}No current week — run record-usage{/}", ""])
|
|
3751
|
+
if not milestones:
|
|
3752
|
+
return ("{accent.b}Current Week · per-percent{/}",
|
|
3753
|
+
["", " {dim}No milestones yet — keep recording usage.{/}", ""])
|
|
3754
|
+
avg_dpp = (cw.dollars_per_percent or 0.0)
|
|
3755
|
+
cumul = cw.spent_usd
|
|
3756
|
+
header = [
|
|
3757
|
+
"",
|
|
3758
|
+
f" {{dim}}Week{{/}} {{b}}{format_display_dt(cw.week_start_at, runtime.display_tz, fmt='%b %d', suffix=False)} – {format_display_dt(cw.week_end_at, runtime.display_tz, fmt='%b %d', suffix=False)}{{/}} "
|
|
3759
|
+
f"{{dim}}milestones reached{{/}} {{warn.b}}{len(milestones)}{{/}}",
|
|
3760
|
+
f" {{dim}}avg $/1%{{/}} {{b}}${avg_dpp:.2f}{{/}} {{dim}}cumulative{{/}} {{b}}${cumul:.2f}{{/}}",
|
|
3761
|
+
"",
|
|
3762
|
+
]
|
|
3763
|
+
bucket = _tui_width_bucket(width)
|
|
3764
|
+
show_5h = bucket != "narrow"
|
|
3765
|
+
if show_5h:
|
|
3766
|
+
header.append(" {dim.b} % Crossed at Cumul Marginal 5-hr{/}")
|
|
3767
|
+
header.append(" {faint}─── ────────────────────── ──────── ────────── ──────{/}")
|
|
3768
|
+
else:
|
|
3769
|
+
header.append(" {dim.b} % Crossed at Cumul Marginal{/}")
|
|
3770
|
+
header.append(" {faint}─── ────────────────────── ──────── ──────────{/}")
|
|
3771
|
+
rows = []
|
|
3772
|
+
for ms in milestones:
|
|
3773
|
+
ts_str = format_display_dt(
|
|
3774
|
+
ms.crossed_at, runtime.display_tz,
|
|
3775
|
+
fmt="%b %d %H:%M:%S", suffix=True,
|
|
3776
|
+
)
|
|
3777
|
+
cumul_str = f"${ms.cumulative_cost_usd:.2f}".ljust(8)
|
|
3778
|
+
marg_str = (f"${ms.marginal_cost_usd:.2f}" if ms.marginal_cost_usd is not None else "—").ljust(10)
|
|
3779
|
+
line = f" {{b}}{ms.percent:>3}{{/}} {{bright}}{ts_str:<22}{{/}} {{b}}{cumul_str}{{/}} {{b}}{marg_str}{{/}}"
|
|
3780
|
+
if show_5h:
|
|
3781
|
+
five_str = (f"{int(ms.five_hour_pct_at_crossing)}%"
|
|
3782
|
+
if ms.five_hour_pct_at_crossing is not None else "—")
|
|
3783
|
+
line += f" {{dim}}{five_str:<5}{{/}}"
|
|
3784
|
+
rows.append(line)
|
|
3785
|
+
if runtime.modal_snap_pending:
|
|
3786
|
+
if len(rows) > 10:
|
|
3787
|
+
runtime.modal_scroll = len(rows) + len(header) - 10
|
|
3788
|
+
runtime.modal_snap_pending = False
|
|
3789
|
+
return ("{accent.b}Current Week · per-percent{/}", header + rows)
|
|
3790
|
+
|
|
3791
|
+
def _tui_modal_forecast(snap, runtime, width):
|
|
3792
|
+
"""Forecast --explain content (spec §4.6.2)."""
|
|
3793
|
+
fc = snap.forecast
|
|
3794
|
+
if fc is None or getattr(fc, "inputs", None) is None:
|
|
3795
|
+
return ("{accent.b}Forecast · explain{/}",
|
|
3796
|
+
["", " {dim}Forecast unavailable — current week is empty.{/}", ""])
|
|
3797
|
+
inp = fc.inputs
|
|
3798
|
+
verdict = _tui_verdict_of(fc)
|
|
3799
|
+
vcls = _TUI_VERDICT_CLS[verdict]
|
|
3800
|
+
# Hero label band right-pads to 15 chars so value columns align:
|
|
3801
|
+
# "Now" + 12, "Week elapsed" + 3, "Used now" + 7, "Used 24h ago" + 3.
|
|
3802
|
+
lines = [
|
|
3803
|
+
"",
|
|
3804
|
+
f" {{dim}}Now{{/}} {{b}}{format_display_dt(inp.now_utc, runtime.display_tz, fmt='%Y-%m-%d %H:%M', suffix=True)}{{/}}",
|
|
3805
|
+
f" {{dim}}Week elapsed{{/}} {{b}}{inp.elapsed_hours:.1f}h / 168h{{/}} "
|
|
3806
|
+
f"{{dim}}({(inp.elapsed_hours / 168 * 100):.1f}%){{/}}",
|
|
3807
|
+
f" {{dim}}Used now{{/}} {{warn.b}}{inp.p_now:.1f}%{{/}}",
|
|
3808
|
+
]
|
|
3809
|
+
if inp.p_24h_ago is not None:
|
|
3810
|
+
lines.append(f" {{dim}}Used 24h ago{{/}} {{dim}}{inp.p_24h_ago:.1f}%{{/}}")
|
|
3811
|
+
else:
|
|
3812
|
+
lines.append(" {dim}Used 24h ago{/} {dim}— (insufficient history){/}")
|
|
3813
|
+
lines.append("")
|
|
3814
|
+
lines.append(" {dim.b}Two rate paths{/}")
|
|
3815
|
+
lines.append(f" {{dim}} r_avg {inp.p_now:.1f} / {inp.elapsed_hours:.1f} = {{/}}{{b}}{fc.r_avg:.4f} %/h{{/}}")
|
|
3816
|
+
if fc.r_recent is not None and inp.p_24h_ago is not None:
|
|
3817
|
+
lines.append(f" {{dim}} r_recent ({inp.p_now:.1f}-{inp.p_24h_ago:.1f}) / {inp.t_24h_actual_hours:.1f} = {{/}}{{b}}{fc.r_recent:.4f} %/h{{/}}")
|
|
3818
|
+
else:
|
|
3819
|
+
lines.append(" {dim} r_recent unavailable — no 24h-prior sample{/}")
|
|
3820
|
+
lines.append("")
|
|
3821
|
+
lines.append(f" {{dim.b}}Project to week end ({inp.remaining_hours:.1f}h remaining){{/}}")
|
|
3822
|
+
if fc.r_recent is not None:
|
|
3823
|
+
wa = inp.p_now + fc.r_avg * inp.remaining_hours
|
|
3824
|
+
rc = inp.p_now + fc.r_recent * inp.remaining_hours
|
|
3825
|
+
lines.append(f" {{dim}} by week-avg = {{/}}{{warn}}{wa:.1f}%{{/}}")
|
|
3826
|
+
lines.append(f" {{dim}} by recent-24h = {{/}}{{ok}}{rc:.1f}%{{/}}")
|
|
3827
|
+
lines.append(f" {{dim}} high = {{/}}{{{vcls}.b}}{fc.final_percent_high:.1f}%{{/}} {{dim}}verdict:{{/}} {{{vcls}.b}}{verdict}{{/}}")
|
|
3828
|
+
else:
|
|
3829
|
+
lines.append(f" {{dim}} projection = {{/}}{{{vcls}.b}}{fc.final_percent_high:.1f}%{{/}} {{dim}}verdict:{{/}} {{{vcls}.b}}{verdict}{{/}}")
|
|
3830
|
+
lines.append("")
|
|
3831
|
+
lines.append(f" {{dim.b}}Daily $ budgets ({inp.remaining_days:.3f} days remaining){{/}}")
|
|
3832
|
+
for b in fc.budgets:
|
|
3833
|
+
if b.dollars_per_day is not None:
|
|
3834
|
+
lines.append(f" {{dim}} ≤{b.target_percent}% {{/}}{{b}}${b.dollars_per_day:.2f}/day{{/}}")
|
|
3835
|
+
else:
|
|
3836
|
+
lines.append(f" {{dim}} ≤{b.target_percent}% {{/}}{{dim}}— (already past){{/}}")
|
|
3837
|
+
lines.append("")
|
|
3838
|
+
confidence = inp.confidence
|
|
3839
|
+
lines.append(f" {{dim}}confidence: {confidence} · based on 7-day rate{{/}}")
|
|
3840
|
+
return ("{accent.b}Forecast · explain{/}", lines)
|
|
3841
|
+
|
|
3842
|
+
def _tui_modal_trend(snap, runtime, width):
|
|
3843
|
+
"""Weekly history for the Trend modal (spec §4.6.3)."""
|
|
3844
|
+
history = snap.weekly_history
|
|
3845
|
+
if not history:
|
|
3846
|
+
return ("{accent.b}Trend · weekly history{/}",
|
|
3847
|
+
["", " {dim}No weekly history available yet.{/}", ""])
|
|
3848
|
+
bucket = _tui_width_bucket(width)
|
|
3849
|
+
show_age = bucket != "narrow"
|
|
3850
|
+
# Header
|
|
3851
|
+
valid = [h for h in history if h.dollars_per_percent is not None]
|
|
3852
|
+
avg_dpp = sum(h.dollars_per_percent for h in valid) / len(valid) if valid else 0.0
|
|
3853
|
+
if len(valid) >= 2:
|
|
3854
|
+
first_dpp = valid[0].dollars_per_percent
|
|
3855
|
+
last_dpp = valid[-1].dollars_per_percent
|
|
3856
|
+
trend_pct = ((last_dpp - first_dpp) / first_dpp * 100) if first_dpp else 0.0
|
|
3857
|
+
else:
|
|
3858
|
+
trend_pct = 0.0
|
|
3859
|
+
trend_sign = "+" if trend_pct >= 0 else ""
|
|
3860
|
+
trend_cls = "warn" if abs(trend_pct) >= 5 else "ok"
|
|
3861
|
+
lines = [
|
|
3862
|
+
"",
|
|
3863
|
+
f" {{dim}}Last{{/}} {{b}}{len(history)} weeks{{/}} "
|
|
3864
|
+
f"{{dim}}avg $/1%{{/}} {{b}}${avg_dpp:.2f}{{/}} "
|
|
3865
|
+
f"{{dim}}trend{{/}} {{{trend_cls}}}{trend_sign}{trend_pct:.0f}%{{/}}",
|
|
3866
|
+
"",
|
|
3867
|
+
]
|
|
3868
|
+
if show_age:
|
|
3869
|
+
lines.append(" {dim.b} Week starting Used% Cost $/1% Δ{/}")
|
|
3870
|
+
lines.append(" {faint}───────────────── ────── ──────── ────── ──────{/}")
|
|
3871
|
+
else:
|
|
3872
|
+
lines.append(" {dim.b} Week Used% Cost $/1%{/}")
|
|
3873
|
+
lines.append(" {faint}───────── ────── ──────── ──────{/}")
|
|
3874
|
+
n = len(history)
|
|
3875
|
+
for i, h in enumerate(history):
|
|
3876
|
+
ago = n - 1 - i
|
|
3877
|
+
marker = "▶" if h.is_current else " "
|
|
3878
|
+
if h.used_pct is None:
|
|
3879
|
+
used_cell = "—"
|
|
3880
|
+
used_cls = "dim"
|
|
3881
|
+
else:
|
|
3882
|
+
used_cell = f"{h.used_pct:.1f}%"
|
|
3883
|
+
used_cls = "ok" if h.used_pct < 70 else ("warn" if h.used_pct < 90 else "bad")
|
|
3884
|
+
dpp_cell = f"${h.dollars_per_percent:.2f}" if h.dollars_per_percent is not None else "—"
|
|
3885
|
+
delta_cell = ""
|
|
3886
|
+
if h.delta_dpp is not None:
|
|
3887
|
+
sign = "+" if h.delta_dpp >= 0 else ""
|
|
3888
|
+
cls = "ok" if h.delta_dpp >= 0 else "dim"
|
|
3889
|
+
delta_cell = f"{{{cls}}}{sign}{h.delta_dpp:.2f}{{/}}"
|
|
3890
|
+
else:
|
|
3891
|
+
delta_cell = "{dim} —{/}"
|
|
3892
|
+
cost_cell = (f"${(h.dollars_per_percent or 0) * (h.used_pct or 0):.2f}"
|
|
3893
|
+
if h.used_pct is not None and h.dollars_per_percent else "—")
|
|
3894
|
+
if show_age:
|
|
3895
|
+
label = f"{h.week_label} ({ago:>2}w ago)" if ago > 0 else f"{h.week_label} (now)"
|
|
3896
|
+
lines.append(
|
|
3897
|
+
f" {{focused.b}}{marker}{{/}} {{b}}{label:<17}{{/}} "
|
|
3898
|
+
f"{{{used_cls}}}{used_cell:>5}{{/}} {{b}}{cost_cell:>7}{{/}} "
|
|
3899
|
+
f"{{b}}{dpp_cell:>5}{{/}} {delta_cell}"
|
|
3900
|
+
)
|
|
3901
|
+
else:
|
|
3902
|
+
lines.append(
|
|
3903
|
+
f" {{focused.b}}{marker}{{/}} {{b}}{h.week_label:<7}{{/}} "
|
|
3904
|
+
f"{{{used_cls}}}{used_cell:>5}{{/}} {{b}}{cost_cell:>7}{{/}} {{b}}{dpp_cell:>5}{{/}}"
|
|
3905
|
+
)
|
|
3906
|
+
lines.append("")
|
|
3907
|
+
# Default scroll: bottom (current week is most relevant).
|
|
3908
|
+
if runtime.modal_snap_pending:
|
|
3909
|
+
if len(lines) > 12:
|
|
3910
|
+
runtime.modal_scroll = len(lines) - 12
|
|
3911
|
+
runtime.modal_snap_pending = False
|
|
3912
|
+
return ("{accent.b}Trend · weekly history{/}", lines)
|
|
3913
|
+
|
|
3914
|
+
def _tui_modal_session(snap, runtime, width):
|
|
3915
|
+
"""Session detail modal (spec §4.6.4).
|
|
3916
|
+
|
|
3917
|
+
Looks up the topmost-visible session by index and queries
|
|
3918
|
+
_tui_build_session_detail on demand. Fixture goldens may inject
|
|
3919
|
+
a deterministic detail via runtime.session_detail_override.
|
|
3920
|
+
"""
|
|
3921
|
+
sessions = _tui_sort_sessions(snap.sessions, runtime.sort_key)
|
|
3922
|
+
if runtime.filter_term:
|
|
3923
|
+
af_lower = runtime.filter_term.lower()
|
|
3924
|
+
sessions = [
|
|
3925
|
+
s for s in sessions
|
|
3926
|
+
if af_lower in s.project_label.lower()
|
|
3927
|
+
or af_lower in s.model_primary.lower()
|
|
3928
|
+
]
|
|
3929
|
+
if not sessions:
|
|
3930
|
+
return ("{accent.b}Session · detail{/}",
|
|
3931
|
+
["", " {dim}No session selected.{/}", ""])
|
|
3932
|
+
idx = max(0, min(runtime.session_scroll, len(sessions) - 1))
|
|
3933
|
+
sel = sessions[idx]
|
|
3934
|
+
# Fixture-injection hook (dev-only, spec §5.5)
|
|
3935
|
+
detail = getattr(runtime, "session_detail_override", None)
|
|
3936
|
+
if detail is None:
|
|
3937
|
+
cache = runtime.session_detail_cache
|
|
3938
|
+
if (cache is not None
|
|
3939
|
+
and cache[0] == sel.session_id
|
|
3940
|
+
and cache[1] == snap.generated_at):
|
|
3941
|
+
detail = cache[2]
|
|
3942
|
+
else:
|
|
3943
|
+
detail = _tui_build_session_detail(sel.session_id, now_utc=snap.generated_at)
|
|
3944
|
+
runtime.session_detail_cache = (sel.session_id, snap.generated_at, detail)
|
|
3945
|
+
if detail is None:
|
|
3946
|
+
return ("{accent.b}Session · detail{/}",
|
|
3947
|
+
["", " {warn}Session no longer available · Esc to return{/}", ""])
|
|
3948
|
+
bucket = _tui_width_bucket(width)
|
|
3949
|
+
show_cwd = bucket != "narrow"
|
|
3950
|
+
show_full_id = bucket != "narrow"
|
|
3951
|
+
sid_display = detail.session_id if show_full_id else detail.session_id[:8]
|
|
3952
|
+
|
|
3953
|
+
title = f"{{accent.b}}Session · {format_display_dt(detail.started_at, runtime.display_tz, fmt='%H:%M:%S', suffix=True)} ({_tui_escape_tags(detail.project_label)}){{/}}"
|
|
3954
|
+
lines = [
|
|
3955
|
+
"",
|
|
3956
|
+
f" {{dim}}Session ID{{/}} {{b}}{sid_display}{{/}}",
|
|
3957
|
+
f" {{dim}}Started{{/}} {{b}}{format_display_dt(detail.started_at, runtime.display_tz, fmt='%Y-%m-%d %H:%M:%S', suffix=True)}{{/}}",
|
|
3958
|
+
f" {{dim}}Last activity{{/}} {{b}}{format_display_dt(detail.last_activity_at, runtime.display_tz, fmt='%Y-%m-%d %H:%M:%S', suffix=True)}{{/}}",
|
|
3959
|
+
f" {{dim}}Duration{{/}} {{b}}{_tui_format_dur(detail.duration_minutes)}{{/}}",
|
|
3960
|
+
f" {{dim}}Project{{/}} {{b}}{_tui_escape_tags(detail.project_label)}{{/}}",
|
|
3961
|
+
]
|
|
3962
|
+
if show_cwd:
|
|
3963
|
+
cwd_max = max(20, _tui_modal_max_width(width) - 18)
|
|
3964
|
+
cwd_shown = detail.project_path
|
|
3965
|
+
if len(cwd_shown) > cwd_max:
|
|
3966
|
+
cwd_shown = "…" + cwd_shown[-(cwd_max - 1):]
|
|
3967
|
+
lines.append(f" {{dim}} cwd{{/}} {{dim}}{_tui_escape_tags(cwd_shown)}{{/}}")
|
|
3968
|
+
src_count = len(detail.source_paths)
|
|
3969
|
+
src_note = "1 (no resumes across files)" if src_count == 1 else f"{src_count} (resumed across files)"
|
|
3970
|
+
lines.append(f" {{dim}}Source files{{/}} {{b}}{src_note}{{/}}")
|
|
3971
|
+
lines.append("")
|
|
3972
|
+
lines.append(" {dim.b}Models{/}")
|
|
3973
|
+
for model_name, role in detail.models:
|
|
3974
|
+
padded = f"{model_name:<16}"
|
|
3975
|
+
lines.append(f" {{dim}} {{/}}{{b}}{_tui_escape_tags(padded)}{{/}}{{dim}}{role}{{/}}")
|
|
3976
|
+
lines.append("")
|
|
3977
|
+
lines.append(" {dim.b}Tokens{/}")
|
|
3978
|
+
lines.append(f" {{dim}} Input {{/}} {{b}}{detail.input_tokens:>10,}{{/}}")
|
|
3979
|
+
lines.append(f" {{dim}} Cache create {{/}} {{b}}{detail.cache_creation_tokens:>10,}{{/}}")
|
|
3980
|
+
cache_pct_str = (f" {{ok}}{int(detail.cache_hit_pct)}% cache hit{{/}}"
|
|
3981
|
+
if detail.cache_hit_pct is not None else "")
|
|
3982
|
+
lines.append(f" {{dim}} Cache read {{/}} {{b}}{detail.cache_read_tokens:>10,}{{/}}{cache_pct_str}")
|
|
3983
|
+
lines.append(f" {{dim}} Output {{/}} {{b}}{detail.output_tokens:>10,}{{/}}")
|
|
3984
|
+
lines.append("")
|
|
3985
|
+
lines.append(" {dim.b}Cost{/}")
|
|
3986
|
+
for model_name, cost in detail.cost_per_model:
|
|
3987
|
+
padded = f"{model_name:<13}"
|
|
3988
|
+
lines.append(f" {{dim}} {_tui_escape_tags(padded)}{{/}} {{b}}${cost:.2f}{{/}}")
|
|
3989
|
+
lines.append(" {faint} ─────────────────────{/}")
|
|
3990
|
+
lines.append(f" {{dim}} Total {{/}} {{b}}${detail.cost_total_usd:.2f}{{/}}")
|
|
3991
|
+
return (title, lines)
|
|
3992
|
+
|
|
3993
|
+
|
|
3994
|
+
def _tui_render_toast(msg: str, width: int):
|
|
3995
|
+
"""Render a one-line deferred-feature toast as a rich.text.Text.
|
|
3996
|
+
|
|
3997
|
+
The toast surfaces the message in a warn-badge style. `width` is
|
|
3998
|
+
accepted for symmetry with the other renderers but is not used for
|
|
3999
|
+
padding — the toast sits inline wherever the caller places it.
|
|
4000
|
+
"""
|
|
4001
|
+
content = f" {msg} "
|
|
4002
|
+
padded = f" {{badge.warn}}{content}{{/}} "
|
|
4003
|
+
return _tui_colortag(padded)
|
|
4004
|
+
|
|
4005
|
+
|
|
4006
|
+
def _tui_sync_interval_type(s: str) -> float:
|
|
4007
|
+
"""argparse type validator for --sync-interval: float >= 1.0."""
|
|
4008
|
+
try:
|
|
4009
|
+
v = float(s)
|
|
4010
|
+
except ValueError:
|
|
4011
|
+
raise argparse.ArgumentTypeError(
|
|
4012
|
+
f"--sync-interval must be a number (got {s!r})"
|
|
4013
|
+
)
|
|
4014
|
+
if v < 1.0:
|
|
4015
|
+
raise argparse.ArgumentTypeError(
|
|
4016
|
+
f"--sync-interval must be >= 1.0 seconds (got {v})"
|
|
4017
|
+
)
|
|
4018
|
+
return v
|
|
4019
|
+
|
|
4020
|
+
|
|
4021
|
+
def _tui_refresh_interval_type(s: str) -> float:
|
|
4022
|
+
"""argparse type validator for --refresh: float > 0.0.
|
|
4023
|
+
|
|
4024
|
+
Non-positive values would make the keyboard-poll select() return
|
|
4025
|
+
immediately every iteration, busy-spinning the redraw loop.
|
|
4026
|
+
"""
|
|
4027
|
+
try:
|
|
4028
|
+
v = float(s)
|
|
4029
|
+
except ValueError:
|
|
4030
|
+
raise argparse.ArgumentTypeError(
|
|
4031
|
+
f"--refresh must be a number (got {s!r})"
|
|
4032
|
+
)
|
|
4033
|
+
if v <= 0.0:
|
|
4034
|
+
raise argparse.ArgumentTypeError(
|
|
4035
|
+
f"--refresh must be > 0 seconds (got {v})"
|
|
4036
|
+
)
|
|
4037
|
+
return v
|
|
4038
|
+
|
|
4039
|
+
|
|
4040
|
+
def _make_run_sync_now_locked(*, ref, hub, pinned_now, display_tz_pref_override):
|
|
4041
|
+
"""Return a closure that does the snapshot-rebuild + SSE-publish work.
|
|
4042
|
+
|
|
4043
|
+
Caller MUST hold sync_lock around the call. The naming convention
|
|
4044
|
+
(``_locked`` suffix) is the contract; threading.Lock has no
|
|
4045
|
+
"is_held_by_current_thread" check, so we don't introspect.
|
|
4046
|
+
|
|
4047
|
+
Splitting the locked body out of the public wrapper lets ``/api/sync``
|
|
4048
|
+
callers that already hold ``sync_lock`` (e.g. so they can refresh OAuth
|
|
4049
|
+
+ rebuild snapshot atomically without releasing between steps) reuse
|
|
4050
|
+
this body without recursive-acquire / self-deadlock.
|
|
4051
|
+
"""
|
|
4052
|
+
def _locked(skip_sync: bool) -> None:
|
|
4053
|
+
try:
|
|
4054
|
+
# Resolve _tui_build_snapshot via cctally's namespace so the
|
|
4055
|
+
# eager re-export AND ``monkeypatch.setitem(ns, "_tui_build_snapshot", spy)``
|
|
4056
|
+
# in tests/test_dashboard_api_sync_refresh.py propagate into
|
|
4057
|
+
# this closure body (the bare-name lookup would resolve in
|
|
4058
|
+
# this sibling's __dict__ and miss the cctally-side patch).
|
|
4059
|
+
snap = sys.modules["cctally"]._tui_build_snapshot(
|
|
4060
|
+
now_utc=pinned_now, skip_sync=skip_sync,
|
|
4061
|
+
display_tz_pref_override=display_tz_pref_override,
|
|
4062
|
+
)
|
|
4063
|
+
if skip_sync:
|
|
4064
|
+
# Mirror the startup override: suppress the monotonic sync
|
|
4065
|
+
# stamp so the envelope keeps emitting sync_age_s=None and
|
|
4066
|
+
# the client keeps rendering "sync paused" after the user
|
|
4067
|
+
# hits r / clicks the sync chip.
|
|
4068
|
+
snap = dataclasses.replace(snap, last_sync_at=None)
|
|
4069
|
+
ref.set(snap)
|
|
4070
|
+
hub.publish(snap)
|
|
4071
|
+
except Exception as exc:
|
|
4072
|
+
prev = ref.get()
|
|
4073
|
+
crashed = dataclasses.replace(
|
|
4074
|
+
prev,
|
|
4075
|
+
last_sync_error=f"sync crashed: {exc}",
|
|
4076
|
+
generated_at=dt.datetime.now(dt.timezone.utc),
|
|
4077
|
+
)
|
|
4078
|
+
ref.set(crashed)
|
|
4079
|
+
hub.publish(crashed)
|
|
4080
|
+
return _locked
|
|
4081
|
+
|
|
4082
|
+
|
|
4083
|
+
def _make_run_sync_now(*, sync_lock, ref, hub, pinned_now,
|
|
4084
|
+
display_tz_pref_override):
|
|
4085
|
+
"""Return a closure that acquires sync_lock then runs the locked variant.
|
|
4086
|
+
|
|
4087
|
+
Used by the periodic background thread and by anything else that needs
|
|
4088
|
+
full lifecycle (acquire-do-release) in one call. ``/api/sync`` paths
|
|
4089
|
+
that compose multiple lock-protected steps should use the locked variant
|
|
4090
|
+
directly instead of nesting ``with sync_lock:`` (re-entrant acquire on
|
|
4091
|
+
a non-recursive ``threading.Lock`` self-deadlocks).
|
|
4092
|
+
"""
|
|
4093
|
+
locked = _make_run_sync_now_locked(
|
|
4094
|
+
ref=ref, hub=hub, pinned_now=pinned_now,
|
|
4095
|
+
display_tz_pref_override=display_tz_pref_override,
|
|
4096
|
+
)
|
|
4097
|
+
def _public(skip_sync: bool) -> None:
|
|
4098
|
+
with sync_lock:
|
|
4099
|
+
locked(skip_sync)
|
|
4100
|
+
return _public
|
|
4101
|
+
|
|
4102
|
+
|
|
4103
|
+
|
|
4104
|
+
def cmd_tui(args: argparse.Namespace) -> int:
|
|
4105
|
+
"""Launch the live TUI dashboard. See docs/commands/tui.md.
|
|
4106
|
+
|
|
4107
|
+
Live-path state machine:
|
|
4108
|
+
1. Resolve `now_utc` (honor --as-of).
|
|
4109
|
+
2. Build RuntimeState from args (honors NO_COLOR + --no-color).
|
|
4110
|
+
3. If --render-once: defer to `_tui_render_once` and return.
|
|
4111
|
+
4. Build theme, construct Console(theme, no_color=...).
|
|
4112
|
+
5. Refuse if terminal width < 80 columns.
|
|
4113
|
+
6. Build initial snapshot (skip_sync=True, non-blocking).
|
|
4114
|
+
7. Wrap in _SnapshotRef.
|
|
4115
|
+
8. Start _TuiSyncThread unless --no-sync.
|
|
4116
|
+
9. rich.live.Live with alternate screen, auto_refresh=False.
|
|
4117
|
+
10. TuiKeyReader (raw mode, cbreak).
|
|
4118
|
+
11. SIGINT → should_exit flag (no raising into Live).
|
|
4119
|
+
SIGWINCH → no-op (next tick picks up console.size).
|
|
4120
|
+
12. On every tick: read key → mutate runtime → live.update().
|
|
4121
|
+
On no key: natural tick redraw.
|
|
4122
|
+
13. Finally: stop sync thread.
|
|
4123
|
+
"""
|
|
4124
|
+
try:
|
|
4125
|
+
import rich # noqa: F401
|
|
4126
|
+
except ImportError:
|
|
4127
|
+
print(TUI_RICH_MISSING_MSG, file=sys.stderr)
|
|
4128
|
+
return 1
|
|
4129
|
+
|
|
4130
|
+
# --- 1. Resolve now ----------------------------------------------------
|
|
4131
|
+
now_utc = _resolve_forecast_now(getattr(args, "as_of", None))
|
|
4132
|
+
|
|
4133
|
+
# --- 2a. Resolve display tz via the unified --tz / config.display.tz.
|
|
4134
|
+
# RuntimeState.tz keeps the legacy token shape for any string-keyed
|
|
4135
|
+
# call sites; F4 moved _tui_format_started to consume display_tz
|
|
4136
|
+
# (ZoneInfo | None) directly so non-"local" values now localize
|
|
4137
|
+
# correctly instead of falling back to UTC. Normalize args.tz back to
|
|
4138
|
+
# a token shape: None -> "local"; Etc/UTC -> "utc"; explicit IANA ->
|
|
4139
|
+
# the verbatim IANA name.
|
|
4140
|
+
config = load_config()
|
|
4141
|
+
# Capture the raw `--tz` flag BEFORE resolution rewrites args.tz, so
|
|
4142
|
+
# `_tui_build_snapshot` can apply the same persisted-config override
|
|
4143
|
+
# that `cmd_dashboard` uses (parallel to lines 24927-24936). Without
|
|
4144
|
+
# this, panels that precompute labels at snapshot-build time (trend,
|
|
4145
|
+
# weekly-history) render the persisted `config.display.tz` instead of
|
|
4146
|
+
# honoring the explicit per-call `--tz` override.
|
|
4147
|
+
raw_tz_flag = getattr(args, "tz", None)
|
|
4148
|
+
if raw_tz_flag is not None and str(raw_tz_flag).strip() != "":
|
|
4149
|
+
try:
|
|
4150
|
+
display_tz_pref_override = normalize_display_tz_value(raw_tz_flag)
|
|
4151
|
+
except ValueError:
|
|
4152
|
+
display_tz_pref_override = None
|
|
4153
|
+
else:
|
|
4154
|
+
display_tz_pref_override = None
|
|
4155
|
+
tz_obj = resolve_display_tz(args, config)
|
|
4156
|
+
args._resolved_tz = tz_obj
|
|
4157
|
+
if tz_obj is None:
|
|
4158
|
+
args.tz = "local"
|
|
4159
|
+
elif tz_obj.key == "Etc/UTC":
|
|
4160
|
+
args.tz = "utc"
|
|
4161
|
+
else:
|
|
4162
|
+
args.tz = tz_obj.key
|
|
4163
|
+
# Stash the override on `args` so `_tui_render_once` (the dev path)
|
|
4164
|
+
# can pick it up uniformly without a separate kwarg.
|
|
4165
|
+
args._display_tz_pref_override = display_tz_pref_override
|
|
4166
|
+
|
|
4167
|
+
# --- 2. Runtime state --------------------------------------------------
|
|
4168
|
+
runtime = RuntimeState.initial(args)
|
|
4169
|
+
|
|
4170
|
+
# --- 3. Dev path: one-shot render -------------------------------------
|
|
4171
|
+
if getattr(args, "render_once", False):
|
|
4172
|
+
return _tui_render_once(args, runtime, now_utc=now_utc)
|
|
4173
|
+
|
|
4174
|
+
# --- 3b. Require an interactive terminal ------------------------------
|
|
4175
|
+
# Live mode drives alt-screen via rich.live.Live(screen=True) and reads
|
|
4176
|
+
# keys from stdin in cbreak mode. Without a TTY on both ends, there is
|
|
4177
|
+
# no quit path short of SIGINT and Live's escape sequences are
|
|
4178
|
+
# meaningless. Refuse fast so cron/CI invocations fail instead of
|
|
4179
|
+
# wedging. `--render-once` (above) is the scriptable alternative.
|
|
4180
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
4181
|
+
print(
|
|
4182
|
+
"tui: requires an interactive terminal "
|
|
4183
|
+
"(stdin and stdout must be TTYs). "
|
|
4184
|
+
"For scripted use, try `report`, `forecast`, or "
|
|
4185
|
+
"`tui --render-once --snapshot-module PATH`.",
|
|
4186
|
+
file=sys.stderr,
|
|
4187
|
+
)
|
|
4188
|
+
return 2
|
|
4189
|
+
|
|
4190
|
+
# --- 4. Theme ----------------------------------------------------------
|
|
4191
|
+
# Drift guard moved to module scope (see _TUI_THEME_KEYS) and to
|
|
4192
|
+
# _tui_build_theme itself, so both axes fire at import time / theme
|
|
4193
|
+
# construction rather than on subcommand invocation.
|
|
4194
|
+
theme = _tui_build_theme()
|
|
4195
|
+
|
|
4196
|
+
# --- 5. Console + width refuse ----------------------------------------
|
|
4197
|
+
from rich.console import Console
|
|
4198
|
+
from rich.live import Live
|
|
4199
|
+
console = Console(theme=theme, no_color=not runtime.color_enabled)
|
|
4200
|
+
width = console.size.width
|
|
4201
|
+
if _tui_width_bucket(width) == "refuse":
|
|
4202
|
+
print(
|
|
4203
|
+
f"tui: terminal too narrow, need >=80 cols (got {width})",
|
|
4204
|
+
file=sys.stderr,
|
|
4205
|
+
)
|
|
4206
|
+
return 1
|
|
4207
|
+
|
|
4208
|
+
# --- 6. Initial snapshot ----------------------------------------------
|
|
4209
|
+
try:
|
|
4210
|
+
initial_snap = _tui_build_snapshot(
|
|
4211
|
+
now_utc=now_utc, skip_sync=True,
|
|
4212
|
+
display_tz_pref_override=display_tz_pref_override,
|
|
4213
|
+
)
|
|
4214
|
+
except Exception:
|
|
4215
|
+
initial_snap = _tui_empty_snapshot(now_utc)
|
|
4216
|
+
|
|
4217
|
+
# --- 7. Shared ref -----------------------------------------------------
|
|
4218
|
+
ref = _SnapshotRef(initial_snap)
|
|
4219
|
+
|
|
4220
|
+
# --- 11. Signal handlers ----------------------------------------------
|
|
4221
|
+
# Install signal handlers BEFORE sync.start() so a SIGINT during
|
|
4222
|
+
# thread startup is caught by our flag-setter rather than the default
|
|
4223
|
+
# handler (which would unwind past the finally block that calls
|
|
4224
|
+
# sync.stop(), leaking a daemon thread). See Task 26 review I3.
|
|
4225
|
+
import signal
|
|
4226
|
+
import time as _time
|
|
4227
|
+
should_exit = {"flag": False}
|
|
4228
|
+
|
|
4229
|
+
def _on_sigint(_signum, _frame):
|
|
4230
|
+
should_exit["flag"] = True
|
|
4231
|
+
|
|
4232
|
+
prev_sigint = signal.getsignal(signal.SIGINT)
|
|
4233
|
+
signal.signal(signal.SIGINT, _on_sigint)
|
|
4234
|
+
prev_sigwinch = None
|
|
4235
|
+
if hasattr(signal, "SIGWINCH"):
|
|
4236
|
+
# No-op — Live's next tick reads console.size fresh.
|
|
4237
|
+
prev_sigwinch = signal.getsignal(signal.SIGWINCH)
|
|
4238
|
+
signal.signal(signal.SIGWINCH, lambda *_: None)
|
|
4239
|
+
# SIGCONT (resume from Ctrl-Z): same idea — just let next tick redraw.
|
|
4240
|
+
prev_sigcont = None
|
|
4241
|
+
if hasattr(signal, "SIGCONT"):
|
|
4242
|
+
prev_sigcont = signal.getsignal(signal.SIGCONT)
|
|
4243
|
+
signal.signal(signal.SIGCONT, lambda *_: None)
|
|
4244
|
+
|
|
4245
|
+
# --- 8. Sync thread ----------------------------------------------------
|
|
4246
|
+
# Always start the rebuild thread — even under --no-sync we need
|
|
4247
|
+
# periodic refreshes so countdowns, "synced Xs ago", and external DB
|
|
4248
|
+
# writes keep the dashboard live. The thread's own skip_sync flag
|
|
4249
|
+
# gates the JSONL ingest pass; the rebuild itself is always cheap
|
|
4250
|
+
# (SQLite SELECTs only, no JSONL scan when skip_sync=True).
|
|
4251
|
+
# Only pin the sync thread's clock when a clock override was actually
|
|
4252
|
+
# supplied — either via --as-of (CLI) or CCTALLY_AS_OF (env). Without
|
|
4253
|
+
# one of those, `now_utc` is just "now captured once at startup";
|
|
4254
|
+
# feeding that to the sync thread would freeze every subsequent
|
|
4255
|
+
# rebuild on that instant. Mirroring the same check that
|
|
4256
|
+
# _resolve_forecast_now() performed above keeps the hidden test hook
|
|
4257
|
+
# consistent across the first frame and every subsequent tick.
|
|
4258
|
+
pinned_now = now_utc if (
|
|
4259
|
+
getattr(args, "as_of", None) or os.environ.get("CCTALLY_AS_OF")
|
|
4260
|
+
) else None
|
|
4261
|
+
sync = _TuiSyncThread(
|
|
4262
|
+
ref, float(args.sync_interval),
|
|
4263
|
+
skip_sync=bool(getattr(args, "no_sync", False)),
|
|
4264
|
+
now_utc=pinned_now,
|
|
4265
|
+
display_tz_pref_override=display_tz_pref_override,
|
|
4266
|
+
)
|
|
4267
|
+
sync.start()
|
|
4268
|
+
|
|
4269
|
+
# --- 10. Render closure -----------------------------------------------
|
|
4270
|
+
from rich.console import Group
|
|
4271
|
+
def render():
|
|
4272
|
+
snap = ref.get()
|
|
4273
|
+
w = console.size.width
|
|
4274
|
+
h = console.size.height
|
|
4275
|
+
bucket = _tui_width_bucket(w)
|
|
4276
|
+
# Build the help Panel once per render so both variants can receive
|
|
4277
|
+
# it via the body-region-swap overlay path (Fallback A). When
|
|
4278
|
+
# show_help is False, help_panel stays None and the variants render
|
|
4279
|
+
# the normal 2x2 / hero layout.
|
|
4280
|
+
help_panel = _tui_render_help(w, h) if runtime.show_help else None
|
|
4281
|
+
modal_panel = (_tui_render_modal(snap, runtime, w, h)
|
|
4282
|
+
if runtime.modal_kind else None)
|
|
4283
|
+
# v2: modal wins when both would show. Spec §4.7.
|
|
4284
|
+
overlay = modal_panel or help_panel
|
|
4285
|
+
if runtime.variant == "expressive":
|
|
4286
|
+
frame = _tui_render_variant_b(
|
|
4287
|
+
snap, runtime, w, h, bucket, overlay_panel=overlay,
|
|
4288
|
+
)
|
|
4289
|
+
else:
|
|
4290
|
+
frame = _tui_render_variant_a(
|
|
4291
|
+
snap, runtime, w, h, bucket, overlay_panel=overlay,
|
|
4292
|
+
)
|
|
4293
|
+
# Toast handling: expire when clock passes expiry; else stack below
|
|
4294
|
+
# the frame via ``rich.console.Group`` (the rich-native stacking
|
|
4295
|
+
# primitive required by constraint #4).
|
|
4296
|
+
if runtime.toast is not None:
|
|
4297
|
+
msg, expiry = runtime.toast
|
|
4298
|
+
if _time.monotonic() < expiry:
|
|
4299
|
+
toast_frame = _tui_render_toast(msg, w)
|
|
4300
|
+
return Group(frame, toast_frame)
|
|
4301
|
+
runtime.toast = None
|
|
4302
|
+
return frame
|
|
4303
|
+
|
|
4304
|
+
# --- 12. Main loop ----------------------------------------------------
|
|
4305
|
+
reader = TuiKeyReader()
|
|
4306
|
+
try:
|
|
4307
|
+
with reader, Live(
|
|
4308
|
+
render(), console=console, screen=True,
|
|
4309
|
+
auto_refresh=False, transient=False,
|
|
4310
|
+
) as live:
|
|
4311
|
+
while not should_exit["flag"]:
|
|
4312
|
+
key = reader.read(timeout=float(args.refresh))
|
|
4313
|
+
if key is not None:
|
|
4314
|
+
redraw, quit_ = _tui_handle_key(key, runtime, ref)
|
|
4315
|
+
if quit_:
|
|
4316
|
+
break
|
|
4317
|
+
if redraw:
|
|
4318
|
+
live.update(render(), refresh=True)
|
|
4319
|
+
continue
|
|
4320
|
+
# No key (or key with no redraw) — natural tick redraw.
|
|
4321
|
+
live.update(render(), refresh=True)
|
|
4322
|
+
finally:
|
|
4323
|
+
# --- 13. Teardown -------------------------------------------------
|
|
4324
|
+
if sync is not None:
|
|
4325
|
+
sync.stop()
|
|
4326
|
+
# Restore previous signal handlers.
|
|
4327
|
+
try:
|
|
4328
|
+
signal.signal(signal.SIGINT, prev_sigint)
|
|
4329
|
+
except Exception:
|
|
4330
|
+
pass
|
|
4331
|
+
if hasattr(signal, "SIGWINCH") and prev_sigwinch is not None:
|
|
4332
|
+
try:
|
|
4333
|
+
signal.signal(signal.SIGWINCH, prev_sigwinch)
|
|
4334
|
+
except Exception:
|
|
4335
|
+
pass
|
|
4336
|
+
if hasattr(signal, "SIGCONT") and prev_sigcont is not None:
|
|
4337
|
+
try:
|
|
4338
|
+
signal.signal(signal.SIGCONT, prev_sigcont)
|
|
4339
|
+
except Exception:
|
|
4340
|
+
pass
|
|
4341
|
+
return 0
|
|
4342
|
+
|
|
4343
|
+
|
|
4344
|
+
def _tui_render_once(
|
|
4345
|
+
args: argparse.Namespace,
|
|
4346
|
+
runtime: "RuntimeState",
|
|
4347
|
+
*,
|
|
4348
|
+
now_utc: dt.datetime | None = None,
|
|
4349
|
+
) -> int:
|
|
4350
|
+
"""Dev-only: render one frame and emit plain text to stdout.
|
|
4351
|
+
|
|
4352
|
+
Used by fixture goldens (later Tasks 28-29). Honors:
|
|
4353
|
+
--snapshot-module (load SNAPSHOT from a Python module for deterministic data)
|
|
4354
|
+
--force-size WxH (default 120x36 if unset / malformed)
|
|
4355
|
+
|
|
4356
|
+
Does NOT check the width-refuse bucket — --render-once is a dev path
|
|
4357
|
+
expected to work at any size so authors can capture narrow-width goldens.
|
|
4358
|
+
Returns 0 on success, 2 on malformed --force-size.
|
|
4359
|
+
"""
|
|
4360
|
+
now_utc = now_utc or _resolve_forecast_now(getattr(args, "as_of", None))
|
|
4361
|
+
|
|
4362
|
+
# --- Parse --force-size -----------------------------------------------
|
|
4363
|
+
force_size = getattr(args, "force_size", None)
|
|
4364
|
+
w, h = 120, 36
|
|
4365
|
+
if force_size:
|
|
4366
|
+
parts = force_size.lower().split("x", 1)
|
|
4367
|
+
if len(parts) != 2:
|
|
4368
|
+
print(
|
|
4369
|
+
f"tui: --force-size must be WxH (got {force_size!r})",
|
|
4370
|
+
file=sys.stderr,
|
|
4371
|
+
)
|
|
4372
|
+
return 2
|
|
4373
|
+
try:
|
|
4374
|
+
w = int(parts[0])
|
|
4375
|
+
h = int(parts[1])
|
|
4376
|
+
except ValueError:
|
|
4377
|
+
print(
|
|
4378
|
+
f"tui: --force-size W/H must be integers (got {force_size!r})",
|
|
4379
|
+
file=sys.stderr,
|
|
4380
|
+
)
|
|
4381
|
+
return 2
|
|
4382
|
+
if w <= 0 or h <= 0:
|
|
4383
|
+
print(
|
|
4384
|
+
f"tui: --force-size W/H must be positive (got {force_size!r})",
|
|
4385
|
+
file=sys.stderr,
|
|
4386
|
+
)
|
|
4387
|
+
return 2
|
|
4388
|
+
|
|
4389
|
+
# --- Load snapshot ----------------------------------------------------
|
|
4390
|
+
snapshot_module = getattr(args, "snapshot_module", None)
|
|
4391
|
+
snap: DataSnapshot
|
|
4392
|
+
if snapshot_module:
|
|
4393
|
+
try:
|
|
4394
|
+
import importlib
|
|
4395
|
+
import importlib.util
|
|
4396
|
+
if snapshot_module.endswith(".py") or "/" in snapshot_module:
|
|
4397
|
+
# Treat as file path.
|
|
4398
|
+
spec = importlib.util.spec_from_file_location(
|
|
4399
|
+
"_tui_snapshot_fixture", snapshot_module
|
|
4400
|
+
)
|
|
4401
|
+
if spec is None or spec.loader is None:
|
|
4402
|
+
raise ImportError(f"cannot load {snapshot_module}")
|
|
4403
|
+
mod = importlib.util.module_from_spec(spec)
|
|
4404
|
+
spec.loader.exec_module(mod)
|
|
4405
|
+
else:
|
|
4406
|
+
mod = importlib.import_module(snapshot_module)
|
|
4407
|
+
snap = getattr(mod, "SNAPSHOT")
|
|
4408
|
+
# v2 dev-only: snapshot modules may export a dict of RuntimeState
|
|
4409
|
+
# field overrides. Lets fixture goldens exercise sort/filter/search/
|
|
4410
|
+
# modal states without adding CLI flags. Spec §5.5.
|
|
4411
|
+
overrides = getattr(mod, "RUNTIME_OVERRIDES", None)
|
|
4412
|
+
if isinstance(overrides, dict):
|
|
4413
|
+
_ALLOWED_OVERRIDES = {
|
|
4414
|
+
"sort_key", "filter_term", "search_term",
|
|
4415
|
+
"search_matches", "search_index",
|
|
4416
|
+
"modal_kind", "modal_scroll", "modal_snap_pending",
|
|
4417
|
+
"focus_index", "session_scroll",
|
|
4418
|
+
"input_mode", "input_buffer",
|
|
4419
|
+
"session_detail_override",
|
|
4420
|
+
}
|
|
4421
|
+
for k, v in overrides.items():
|
|
4422
|
+
if k in _ALLOWED_OVERRIDES:
|
|
4423
|
+
setattr(runtime, k, v)
|
|
4424
|
+
except FileNotFoundError as exc:
|
|
4425
|
+
print(f"tui: snapshot module not found: {exc}", file=sys.stderr)
|
|
4426
|
+
return 2
|
|
4427
|
+
except (ImportError, AttributeError) as exc:
|
|
4428
|
+
print(
|
|
4429
|
+
f"tui: failed to load snapshot from {snapshot_module!r}: {exc}",
|
|
4430
|
+
file=sys.stderr,
|
|
4431
|
+
)
|
|
4432
|
+
return 2
|
|
4433
|
+
else:
|
|
4434
|
+
try:
|
|
4435
|
+
snap = _tui_build_snapshot(
|
|
4436
|
+
now_utc=now_utc, skip_sync=True,
|
|
4437
|
+
display_tz_pref_override=getattr(
|
|
4438
|
+
args, "_display_tz_pref_override", None
|
|
4439
|
+
),
|
|
4440
|
+
)
|
|
4441
|
+
except Exception:
|
|
4442
|
+
snap = _tui_empty_snapshot(now_utc)
|
|
4443
|
+
|
|
4444
|
+
# --- Render -----------------------------------------------------------
|
|
4445
|
+
# Drift guards run at module import + inside _tui_build_theme itself,
|
|
4446
|
+
# so the explicit inline check has been removed.
|
|
4447
|
+
theme = _tui_build_theme()
|
|
4448
|
+
import io
|
|
4449
|
+
from rich.console import Console
|
|
4450
|
+
# file=StringIO() so console.print() writes into the recording buffer only
|
|
4451
|
+
# (not twice to stdout). export_text() then emits the clean captured copy.
|
|
4452
|
+
console = Console(
|
|
4453
|
+
theme=theme,
|
|
4454
|
+
record=True,
|
|
4455
|
+
width=w,
|
|
4456
|
+
height=h,
|
|
4457
|
+
no_color=not runtime.color_enabled,
|
|
4458
|
+
force_terminal=True,
|
|
4459
|
+
file=io.StringIO(),
|
|
4460
|
+
)
|
|
4461
|
+
bucket = _tui_width_bucket(w)
|
|
4462
|
+
help_panel = _tui_render_help(w, h) if runtime.show_help else None
|
|
4463
|
+
modal_panel = (_tui_render_modal(snap, runtime, w, h)
|
|
4464
|
+
if runtime.modal_kind else None)
|
|
4465
|
+
# v2: modal wins when both would show. Spec §4.7.
|
|
4466
|
+
overlay = modal_panel or help_panel
|
|
4467
|
+
if runtime.variant == "expressive":
|
|
4468
|
+
frame = _tui_render_variant_b(snap, runtime, w, h, bucket, overlay_panel=overlay)
|
|
4469
|
+
else:
|
|
4470
|
+
frame = _tui_render_variant_a(snap, runtime, w, h, bucket, overlay_panel=overlay)
|
|
4471
|
+
# Layout fills the requested render height with blank rows if the
|
|
4472
|
+
# natural content is shorter, and truncates if taller. Use the
|
|
4473
|
+
# ``_tui_natural_height`` stashed by the variant renderers so the
|
|
4474
|
+
# recorded frame matches the pre-refactor row count (only trailing
|
|
4475
|
+
# whitespace on individual lines is expected to drift; line count
|
|
4476
|
+
# stays identical). Live mode ignores this and renders at terminal
|
|
4477
|
+
# height, which is the desired TUI fill behavior.
|
|
4478
|
+
render_h = getattr(frame, "_tui_natural_height", h) or h
|
|
4479
|
+
console.print(frame, height=render_h)
|
|
4480
|
+
# Default behavior: plain text (matches existing fixture-golden
|
|
4481
|
+
# expectations). FORCE_COLOR=1 opts in to ANSI escapes — used by the
|
|
4482
|
+
# README screenshot pipeline so freeze can render the TUI as a
|
|
4483
|
+
# colored SVG. Goldens never set FORCE_COLOR, so this is byte-safe
|
|
4484
|
+
# for the existing harness.
|
|
4485
|
+
include_styles = os.environ.get("FORCE_COLOR") == "1"
|
|
4486
|
+
sys.stdout.write(console.export_text(styles=include_styles))
|
|
4487
|
+
return 0
|