cctally 1.7.0 → 1.7.2

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