cctally 1.6.3 → 1.7.1

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