cctally 1.9.0 → 1.10.0

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.
@@ -1061,6 +1061,16 @@ class DataSnapshot:
1061
1061
  # through ``_tui_build_snapshot``; the envelope adapter falls
1062
1062
  # back to the legacy inline routing in that case.
1063
1063
  forecast_view: Any | None = None
1064
+ # Projects panel + modal envelope block (spec §5.2 /
1065
+ # 2026-05-19-projects-panel-design.md). Populated on the sync
1066
+ # thread by ``_build_projects_envelope`` (per-tick DB-touching
1067
+ # aggregation that runs alongside the existing per-panel builds);
1068
+ # the dashboard's pure ``snapshot_to_envelope`` reads this back
1069
+ # unchanged and assigns it to ``envelope["projects"]``. ``None``
1070
+ # on first tick before sync completes — the TS envelope mirror
1071
+ # declares ``ProjectsEnvelope | null`` and the client renders the
1072
+ # panel-empty state until the next tick replaces it.
1073
+ projects_envelope: dict | None = None
1064
1074
 
1065
1075
  @classmethod
1066
1076
  def synthesize_for_marketing(cls, *, as_of_iso: str) -> "DataSnapshot":
@@ -1612,6 +1622,95 @@ class TuiSessionDetail:
1612
1622
  cost_total_usd: float
1613
1623
 
1614
1624
 
1625
+ def _tui_build_session_detail_indexed(
1626
+ session_id: str,
1627
+ range_start: dt.datetime,
1628
+ range_end: dt.datetime,
1629
+ ) -> Any | None:
1630
+ """Indexed direct lookup for one session by id.
1631
+
1632
+ Walks ``session_files`` (indexed by ``session_id`` — migration
1633
+ ``idx_session_files_session_id``) for the 1-3 source_paths the
1634
+ session lives in, then fetches ONLY the entries from those paths
1635
+ in the supplied range. Aggregates the filtered list and returns
1636
+ the single matching ``ClaudeSessionUsage`` row.
1637
+
1638
+ Returns ``None`` on three indistinguishable misses (the caller's
1639
+ fallback path handles them all):
1640
+
1641
+ 1. session_files row hasn't been backfilled yet for this id
1642
+ (CLAUDE.md "session_files is populated lazily" — first run
1643
+ after deploy).
1644
+ 2. cache DB unavailable (open / lock contention).
1645
+ 3. session_id is genuinely unknown.
1646
+
1647
+ Falling back uniformly preserves correctness without distinguishing
1648
+ the cases; if the slow path also misses, the modal renders 404.
1649
+ """
1650
+ c = _cctally()
1651
+ open_cache_db = c.open_cache_db
1652
+ _JoinedClaudeEntry = c._JoinedClaudeEntry
1653
+ try:
1654
+ conn = open_cache_db()
1655
+ except (sqlite3.DatabaseError, OSError):
1656
+ return None
1657
+ try:
1658
+ # 1) Source paths for this session id (indexed lookup).
1659
+ rows = conn.execute(
1660
+ "SELECT path FROM session_files WHERE session_id = ?",
1661
+ (session_id,),
1662
+ ).fetchall()
1663
+ if not rows:
1664
+ return None
1665
+ paths = [r[0] for r in rows]
1666
+ # 2) Entries restricted to those paths in the range. Typical
1667
+ # path-count is 1-3 (resume across files), well below SQLite's
1668
+ # 999 parameter cap.
1669
+ start_iso = range_start.astimezone(dt.timezone.utc).isoformat()
1670
+ end_iso = range_end.astimezone(dt.timezone.utc).isoformat()
1671
+ placeholders = ",".join("?" * len(paths))
1672
+ cur = conn.execute(
1673
+ f"SELECT se.timestamp_utc, se.model, "
1674
+ f" se.input_tokens, se.output_tokens, "
1675
+ f" se.cache_create_tokens, se.cache_read_tokens, "
1676
+ f" se.source_path, sf.session_id, sf.project_path, "
1677
+ f" se.cost_usd_raw "
1678
+ f"FROM session_entries se "
1679
+ f"LEFT JOIN session_files sf ON sf.path = se.source_path "
1680
+ f"WHERE se.timestamp_utc >= ? AND se.timestamp_utc <= ? "
1681
+ f" AND se.source_path IN ({placeholders}) "
1682
+ f"ORDER BY se.timestamp_utc ASC",
1683
+ [start_iso, end_iso, *paths],
1684
+ )
1685
+ entries = [
1686
+ _JoinedClaudeEntry(
1687
+ timestamp=dt.datetime.fromisoformat(row[0]),
1688
+ model=row[1],
1689
+ input_tokens=row[2],
1690
+ output_tokens=row[3],
1691
+ cache_creation_tokens=row[4],
1692
+ cache_read_tokens=row[5],
1693
+ source_path=row[6],
1694
+ session_id=row[7],
1695
+ project_path=row[8],
1696
+ cost_usd=row[9],
1697
+ )
1698
+ for row in cur
1699
+ ]
1700
+ if not entries:
1701
+ return None
1702
+ sessions = _aggregate_claude_sessions(entries)
1703
+ for s in sessions:
1704
+ if s.session_id == session_id:
1705
+ return s
1706
+ return None
1707
+ finally:
1708
+ try:
1709
+ conn.close()
1710
+ except sqlite3.Error:
1711
+ pass
1712
+
1713
+
1615
1714
  def _tui_build_session_detail(
1616
1715
  session_id: str,
1617
1716
  *,
@@ -1619,19 +1718,34 @@ def _tui_build_session_detail(
1619
1718
  ) -> TuiSessionDetail | None:
1620
1719
  """Look up one session by ID; return None if not found.
1621
1720
 
1622
- Reuses the same `get_claude_session_entries` + `_aggregate_claude_sessions`
1623
- pipeline as `_tui_build_sessions` but filters down to the matching ID.
1624
- Bounded scan window matches the panel builder (365 days).
1721
+ Fast path: ``_tui_build_session_detail_indexed`` reads
1722
+ ``session_files`` by id, scopes the entries SELECT to the matching
1723
+ source_paths, and aggregates only those rows turning the lookup
1724
+ from "build every session in 365 days" into an indexed direct
1725
+ fetch (~3000× fewer rows on real DBs).
1726
+
1727
+ Slow-path fallback: when the indexed lookup misses (session_files
1728
+ not yet backfilled, cache unavailable, or genuinely unknown), the
1729
+ legacy bulk-fetch + linear scan still runs so the modal renders
1730
+ consistently with the panel's session list during the lazy-
1731
+ backfill window.
1625
1732
  """
1626
1733
  now_utc = now_utc or dt.datetime.now(dt.timezone.utc)
1627
1734
  range_start = now_utc - dt.timedelta(days=365)
1628
- entries = get_claude_session_entries(range_start, now_utc, skip_sync=True)
1629
- sessions = _aggregate_claude_sessions(entries)
1630
- match: Any | None = None
1631
- for s in sessions:
1632
- if s.session_id == session_id:
1633
- match = s
1634
- break
1735
+ match: Any | None = _tui_build_session_detail_indexed(
1736
+ session_id, range_start, now_utc,
1737
+ )
1738
+ if match is None:
1739
+ # Fall back to the bulk-aggregate path. Same shape as before,
1740
+ # used only when the index lookup couldn't conclude.
1741
+ entries = get_claude_session_entries(
1742
+ range_start, now_utc, skip_sync=True,
1743
+ )
1744
+ sessions = _aggregate_claude_sessions(entries)
1745
+ for s in sessions:
1746
+ if s.session_id == session_id:
1747
+ match = s
1748
+ break
1635
1749
  if match is None:
1636
1750
  return None
1637
1751
  duration_min = (match.last_activity - match.first_activity).total_seconds() / 60.0
@@ -1896,6 +2010,109 @@ def _tui_build_snapshot(
1896
2010
  fh_milestones = _tui_build_five_hour_milestones(conn, win_key)
1897
2011
  except Exception as exc:
1898
2012
  errors.append(f"five-hour-milestones: {exc}")
2013
+ # ---- Projects panel + modal envelope (spec §5.2, plan Task 1) -----
2014
+ # Per-tick aggregation lives on the sync thread; the dashboard's
2015
+ # pure ``snapshot_to_envelope`` reads ``snap.projects_envelope``
2016
+ # back unchanged. Errors are recorded on ``last_sync_error`` —
2017
+ # the client renders the panel-empty state when the field is
2018
+ # None (first tick, or sub-build failure).
2019
+ #
2020
+ # ATTACH cache.db onto the open stats conn so
2021
+ # ``_build_projects_envelope`` (which reads ``session_entries`` +
2022
+ # ``session_files`` + ``weekly_usage_snapshots`` off one conn —
2023
+ # the test contract per tests/test_projects_envelope.py) sees
2024
+ # all three tables. ATTACH/DETACH is cheap and scoped to this
2025
+ # sub-build; no schema migration / lock acquisition is needed.
2026
+ projects_envelope_block: dict | None = None
2027
+ try:
2028
+ c = _cctally()
2029
+ cache_db_path = c.CACHE_DB_PATH
2030
+ conn.execute(
2031
+ "ATTACH DATABASE ? AS cache_db",
2032
+ (str(cache_db_path),),
2033
+ )
2034
+ # session_entries / session_files live in cache.db; the
2035
+ # builder reads them via raw SQL keyed by the unqualified
2036
+ # table names. SQLite's name resolution prefers the `main`
2037
+ # schema, so create temporary views in `main` that point
2038
+ # at the attached schema's tables. Aliasing via VIEWs keeps
2039
+ # the builder portable: unit tests pass one conn carrying
2040
+ # both schemas; production wiring uses an attached cache.
2041
+ conn.execute(
2042
+ "CREATE TEMP VIEW IF NOT EXISTS session_entries AS "
2043
+ "SELECT * FROM cache_db.session_entries"
2044
+ )
2045
+ conn.execute(
2046
+ "CREATE TEMP VIEW IF NOT EXISTS session_files AS "
2047
+ "SELECT * FROM cache_db.session_files"
2048
+ )
2049
+ projects_envelope_block = c._build_projects_envelope(
2050
+ conn,
2051
+ now_utc=now_utc,
2052
+ current_week=cw,
2053
+ weeks_back=12,
2054
+ )
2055
+ except Exception as exc:
2056
+ errors.append(f"projects-envelope: {exc}")
2057
+ finally:
2058
+ try:
2059
+ conn.execute("DROP VIEW IF EXISTS session_entries")
2060
+ conn.execute("DROP VIEW IF EXISTS session_files")
2061
+ except Exception:
2062
+ pass
2063
+ try:
2064
+ conn.execute("DETACH DATABASE cache_db")
2065
+ except Exception:
2066
+ pass
2067
+ # Late-bind disambiguated `project_key` onto each SessionsPanel
2068
+ # row so the SessionsPanel → ProjectsModal cross-nav (spec §4.1)
2069
+ # routes by the same identity the Projects envelope emits.
2070
+ # Cheap dict-lookup per row; no second aggregation pass.
2071
+ #
2072
+ # `key_by_bucket_path` is indexed by git-root bucket_path (the
2073
+ # envelope builder calls `_resolve_project_key(..., "git-root")`
2074
+ # in `_build_projects_envelope`), but `srow.project_path` is the
2075
+ # raw cwd from `_aggregate_claude_sessions` — typically a
2076
+ # subdirectory of the repo root for monorepo sessions. We must
2077
+ # resolve each cwd through the same production resolver so the
2078
+ # lookup hits; otherwise `project_key` stays None and the
2079
+ # cross-nav button degrades to plain text.
2080
+ if projects_envelope_block is not None:
2081
+ try:
2082
+ key_by_bucket_path: dict[str, str] = {}
2083
+ for r in projects_envelope_block.get(
2084
+ "current_week", {}
2085
+ ).get("rows", []):
2086
+ bp = r.get("bucket_path")
2087
+ k = r.get("key")
2088
+ if bp and k:
2089
+ key_by_bucket_path[bp] = k
2090
+ for r in projects_envelope_block.get(
2091
+ "trend", {}
2092
+ ).get("projects", []):
2093
+ bp = r.get("bucket_path")
2094
+ k = r.get("key")
2095
+ if bp and k and bp not in key_by_bucket_path:
2096
+ key_by_bucket_path[bp] = k
2097
+ _resolve = _cctally()._resolve_project_key
2098
+ resolver_cache: dict = {}
2099
+ annotated: list[TuiSessionRow] = []
2100
+ for srow in sessions:
2101
+ pkey = None
2102
+ if srow.project_path:
2103
+ bp = _resolve(
2104
+ srow.project_path, "git-root", resolver_cache,
2105
+ ).bucket_path
2106
+ pkey = key_by_bucket_path.get(bp)
2107
+ if pkey is None:
2108
+ annotated.append(srow)
2109
+ else:
2110
+ annotated.append(
2111
+ dataclasses.replace(srow, project_key=pkey)
2112
+ )
2113
+ sessions = annotated
2114
+ except Exception as exc:
2115
+ errors.append(f"projects-cross-nav-bind: {exc}")
1899
2116
  return DataSnapshot(
1900
2117
  current_week=cw,
1901
2118
  forecast=fc,
@@ -1923,6 +2140,7 @@ def _tui_build_snapshot(
1923
2140
  trend_avg_dollars_per_pct=trend_avg_dpp,
1924
2141
  trend_history_median_dpp=history_median_dpp,
1925
2142
  forecast_view=fc_view,
2143
+ projects_envelope=projects_envelope_block,
1926
2144
  )
1927
2145
  finally:
1928
2146
  conn.close()
@@ -37,6 +37,9 @@ SHARE_CAPABLE_PANELS: frozenset[str] = frozenset({
37
37
  "blocks",
38
38
  "forecast",
39
39
  "sessions",
40
+ # Projects panel + modal (spec 2026-05-19-projects-panel-design.md).
41
+ # 9th share-capable panel — three templates land in this module.
42
+ "projects",
40
43
  })
41
44
 
42
45
 
@@ -1552,6 +1555,176 @@ def _build_sessions_detail(*, panel_data, options):
1552
1555
  )
1553
1556
 
1554
1557
 
1558
+ # --- Projects panel + modal builders (spec §7.6) ---
1559
+ #
1560
+ # The three Projects-panel templates share a single internal kernel so
1561
+ # the table / chart / KPI layout stays consistent across archetypes:
1562
+ # only the "what's emphasized" shape differs (recap = top-N table,
1563
+ # visual = chart-forward, detail = full table + chart). All three
1564
+ # consume `panel_data` from `_build_projects_share_panel_data` in
1565
+ # `bin/_cctally_dashboard.py`, NOT `_build_project_snapshot` directly —
1566
+ # that kernel takes `ProjectKey` objects, but the dashboard envelope
1567
+ # emits already-disambiguated string `key`s. Both paths end up at the
1568
+ # same column shape: `Project | $ Cost | % Used | Sessions`.
1569
+
1570
+ _PROJECTS_TABLE_COLUMNS = (
1571
+ _LS.ColumnSpec(key="project", label="Project", align="left"),
1572
+ _LS.ColumnSpec(key="cost", label="$ Cost", align="right", emphasis=True),
1573
+ _LS.ColumnSpec(key="used", label="% Used", align="right"),
1574
+ _LS.ColumnSpec(key="sessions", label="Sessions", align="right"),
1575
+ )
1576
+
1577
+
1578
+ def _projects_rows_for_template(rows: list[dict], cap: int) -> tuple:
1579
+ """Build `Row` tuple for the Projects table from the dashboard
1580
+ panel_data row shape.
1581
+
1582
+ `rows` is a list of dicts {key, bucket_path, cost_usd,
1583
+ attributed_pct, sessions_count} (post-disambiguation, sorted by
1584
+ caller). `cap` truncates to top-N by caller order. Mirrors
1585
+ `_build_project_snapshot`'s row layout in bin/cctally:11774 but
1586
+ with string keys (no ProjectKey objects).
1587
+ """
1588
+ out: list = []
1589
+ for r in (rows or [])[:cap]:
1590
+ attr = r.get("attributed_pct")
1591
+ out.append(_LS.Row(cells={
1592
+ "project": _LS.ProjectCell(label=str(r["key"])),
1593
+ "cost": _LS.MoneyCell(usd=float(r["cost_usd"])),
1594
+ "used": (
1595
+ _LS.PercentCell(float(attr)) if attr is not None
1596
+ else _LS.TextCell("—")
1597
+ ),
1598
+ "sessions": _LS.TextCell(str(int(r.get("sessions_count", 0) or 0))),
1599
+ }))
1600
+ return tuple(out)
1601
+
1602
+
1603
+ def _projects_chart_for_template(rows: list[dict], cap: int):
1604
+ """Build the HorizontalBarChart for the Projects share artifact.
1605
+ Cap=12 mirrors `_build_project_snapshot` (bin/cctally:11813).
1606
+ """
1607
+ if not rows:
1608
+ return None
1609
+ points = []
1610
+ for r in rows[:cap]:
1611
+ label = str(r["key"])
1612
+ cost = float(r["cost_usd"])
1613
+ points.append(_LS.ChartPoint(
1614
+ x_label=label,
1615
+ x_value=cost,
1616
+ y_value=cost,
1617
+ project_label=label,
1618
+ ))
1619
+ return _LS.HorizontalBarChart(
1620
+ points=tuple(points), x_label="$", cap=cap,
1621
+ )
1622
+
1623
+
1624
+ def _projects_period(panel_data: dict, options: dict):
1625
+ start = panel_data["period_start"]
1626
+ end = panel_data["period_end"]
1627
+ weeks = int(panel_data.get("window_weeks", 1) or 1)
1628
+ label = "This week" if weeks == 1 else f"Last {weeks} weeks"
1629
+ return _period(start, end, label=label,
1630
+ display_tz=_display_tz(options))
1631
+
1632
+
1633
+ def _projects_subtitle(options: dict, total_rows: int) -> str:
1634
+ reveal = options.get("reveal_projects", True)
1635
+ return " · ".join([
1636
+ f"{total_rows} project{'' if total_rows == 1 else 's'}",
1637
+ "real projects" if reveal else "projects anonymized",
1638
+ ])
1639
+
1640
+
1641
+ def _build_projects_recap(*, panel_data, options):
1642
+ """Projects recap — top-N table + KPI strip + HBar chart.
1643
+
1644
+ Mirrors `weekly-recap`'s balanced shape (table + chart + KPIs).
1645
+ Top-N defaults to 5 (matches the panel's top-5 rows from spec §2.3).
1646
+ """
1647
+ rows = panel_data.get("rows") or []
1648
+ cap = int(options.get("top_n", 5))
1649
+ total = float(panel_data.get("total_cost_usd", 0.0) or 0.0)
1650
+ return _LS.ShareSnapshot(
1651
+ cmd="projects",
1652
+ title=f"Projects recap — top {min(cap, len(rows))}",
1653
+ subtitle=_projects_subtitle(options, len(rows)),
1654
+ period=_projects_period(panel_data, options),
1655
+ columns=_PROJECTS_TABLE_COLUMNS,
1656
+ rows=_projects_rows_for_template(rows, cap),
1657
+ chart=_projects_chart_for_template(rows, cap=12),
1658
+ totals=_kpi_strip(
1659
+ ("$ spent", f"${total:,.2f}"),
1660
+ ("Projects", str(len(rows))),
1661
+ ),
1662
+ notes=(),
1663
+ generated_at=_utc_now(),
1664
+ version=_release_version(),
1665
+ )
1666
+
1667
+
1668
+ def _build_projects_visual(*, panel_data, options):
1669
+ """Projects visual — chart-forward HBar of top-12 projects by cost.
1670
+
1671
+ Mirrors `weekly-visual` / `current-week-visual` — chart-led, no
1672
+ table. Spec §7.6 routes the visual archetype to a HorizontalBarChart
1673
+ of the top-12 projects.
1674
+ """
1675
+ rows = panel_data.get("rows") or []
1676
+ cap_chart = 12
1677
+ total = float(panel_data.get("total_cost_usd", 0.0) or 0.0)
1678
+ return _LS.ShareSnapshot(
1679
+ cmd="projects",
1680
+ title=f"Projects — top {min(cap_chart, len(rows))} by cost",
1681
+ subtitle=_projects_subtitle(options, len(rows)),
1682
+ period=_projects_period(panel_data, options),
1683
+ columns=_PROJECTS_TABLE_COLUMNS,
1684
+ rows=(), # visual archetype: empty table
1685
+ chart=_projects_chart_for_template(rows, cap=cap_chart),
1686
+ totals=_kpi_strip(
1687
+ ("$ spent", f"${total:,.2f}"),
1688
+ ("Projects", str(len(rows))),
1689
+ ),
1690
+ notes=(),
1691
+ generated_at=_utc_now(),
1692
+ version=_release_version(),
1693
+ )
1694
+
1695
+
1696
+ def _build_projects_detail(*, panel_data, options):
1697
+ """Projects detail — full table + chart. Closest fit to today's CLI
1698
+ `cctally project --format` output (spec §7.6 routes detail here).
1699
+
1700
+ Default top_n=50 means "all rows" for typical caches.
1701
+ """
1702
+ rows = panel_data.get("rows") or []
1703
+ cap = int(options.get("top_n", 50))
1704
+ total = float(panel_data.get("total_cost_usd", 0.0) or 0.0)
1705
+ notes: tuple[str, ...] = ()
1706
+ if len(rows) > 12:
1707
+ notes = (
1708
+ f"Showing top 12 in chart; table includes all {len(rows)}.",
1709
+ )
1710
+ return _LS.ShareSnapshot(
1711
+ cmd="projects",
1712
+ title=f"Projects detail — {len(rows)} project{'' if len(rows) == 1 else 's'}",
1713
+ subtitle=_projects_subtitle(options, len(rows)),
1714
+ period=_projects_period(panel_data, options),
1715
+ columns=_PROJECTS_TABLE_COLUMNS,
1716
+ rows=_projects_rows_for_template(rows, cap),
1717
+ chart=_projects_chart_for_template(rows, cap=12),
1718
+ totals=_kpi_strip(
1719
+ ("$ spent", f"${total:,.2f}"),
1720
+ ("Projects", str(len(rows))),
1721
+ ),
1722
+ notes=notes,
1723
+ generated_at=_utc_now(),
1724
+ version=_release_version(),
1725
+ )
1726
+
1727
+
1555
1728
  # --- Register Recap templates ---
1556
1729
 
1557
1730
  _RECAP = (
@@ -1587,6 +1760,11 @@ _RECAP = (
1587
1760
  description="Top-N sessions table + total",
1588
1761
  default_options={"top_n": 15, "show_chart": False, "show_table": True},
1589
1762
  builder=_build_sessions_recap),
1763
+ # Projects panel + modal (spec §7.6).
1764
+ ShareTemplate(id="projects-recap", panel="projects", label="Recap",
1765
+ description="Top-N projects table + total + HBar chart",
1766
+ default_options={"top_n": 5, "show_chart": True, "show_table": True},
1767
+ builder=_build_projects_recap),
1590
1768
  )
1591
1769
 
1592
1770
  SHARE_TEMPLATES = SHARE_TEMPLATES + _RECAP
@@ -1627,6 +1805,11 @@ _VISUAL = (
1627
1805
  description="Horizontal bar of top-N sessions by cost",
1628
1806
  default_options={"top_n": 8, "show_chart": True, "show_table": False},
1629
1807
  builder=_build_sessions_visual),
1808
+ # Projects panel + modal (spec §7.6).
1809
+ ShareTemplate(id="projects-visual", panel="projects", label="Visual",
1810
+ description="Chart-forward HBar of top-12 projects by cost",
1811
+ default_options={"top_n": 12, "show_chart": True, "show_table": False},
1812
+ builder=_build_projects_visual),
1630
1813
  )
1631
1814
 
1632
1815
 
@@ -1665,6 +1848,13 @@ _DETAIL = (
1665
1848
  description="Top-50 sessions with full columns",
1666
1849
  default_options={"top_n": 50, "show_chart": False, "show_table": True},
1667
1850
  builder=_build_sessions_detail),
1851
+ # Projects panel + modal (spec §7.6). Default top_n=50 means "all
1852
+ # rows" for typical caches; matches today's CLI `cctally project
1853
+ # --format` table shape.
1854
+ ShareTemplate(id="projects-detail", panel="projects", label="Detail",
1855
+ description="Full projects table + HBar chart (top-12)",
1856
+ default_options={"top_n": 50, "show_chart": True, "show_table": True},
1857
+ builder=_build_projects_detail),
1668
1858
  )
1669
1859
 
1670
1860
  SHARE_TEMPLATES = SHARE_TEMPLATES + _VISUAL + _DETAIL
@@ -243,6 +243,20 @@ class TuiSessionRow:
243
243
  cache_hit_pct: float | None
244
244
  project_label: str # basename of project_path
245
245
  session_id: str # full session UUID (v2: needed for session-detail modal)
246
+ # Disambiguated display key (matches the Projects panel envelope's
247
+ # `current_week.rows[].key` / `trend.projects[].key`). Populated by
248
+ # the sync-thread builder after `_build_projects_envelope` runs so
249
+ # the SessionsPanel → ProjectsModal cross-nav (spec §4.1) routes by
250
+ # a stable identity. Defaults to ``None`` for fixture modules that
251
+ # construct ``TuiSessionRow`` positionally without the Bundle 6 /
252
+ # projects-panel additions; the client renders the cell as plain
253
+ # text in that case per spec §4.1 stopgap.
254
+ project_key: str | None = None
255
+ # Absolute project_path (NULL ⇒ ``(unknown)`` resolved upstream).
256
+ # Carried so the sync-thread builder can compute ``project_key``
257
+ # without re-reading ``session_files`` and so the share path can
258
+ # privacy-scrub via ``_lib_share._scrub``.
259
+ project_path: str | None = None
246
260
 
247
261
 
248
262
  # === Internal helpers ======================================================
@@ -1066,6 +1080,12 @@ def build_sessions_view(entries, *, now_utc, limit=None, display_tz=None):
1066
1080
  _os.path.basename(s.project_path) or s.project_path
1067
1081
  ),
1068
1082
  session_id=s.session_id,
1083
+ # `project_key` is populated downstream by `_tui_build_snapshot`
1084
+ # after `_build_projects_envelope` runs (so the disambiguated
1085
+ # display_key matches the Projects envelope exactly). Stash
1086
+ # the absolute path here so that pass can map back without
1087
+ # re-reading session_files.
1088
+ project_path=s.project_path or None,
1069
1089
  ))
1070
1090
  total_cost += s.cost_usd
1071
1091
 
package/bin/cctally CHANGED
@@ -766,6 +766,7 @@ _build_monthly_share_panel_data = _cctally_dashboard._build_monthly_share_panel_
766
766
  _build_forecast_share_panel_data = _cctally_dashboard._build_forecast_share_panel_data
767
767
  _build_blocks_share_panel_data = _cctally_dashboard._build_blocks_share_panel_data
768
768
  _build_sessions_share_panel_data = _cctally_dashboard._build_sessions_share_panel_data
769
+ _build_projects_share_panel_data = _cctally_dashboard._build_projects_share_panel_data
769
770
  _SnapshotRef = _cctally_dashboard._SnapshotRef
770
771
  SSEHub = _cctally_dashboard.SSEHub
771
772
  STATIC_DIR = _cctally_dashboard.STATIC_DIR
@@ -781,6 +782,13 @@ _dashboard_build_blocks_view = _cctally_dashboard._dashboard_build_blocks_view
781
782
  _dashboard_build_daily_panel = _cctally_dashboard._dashboard_build_daily_panel
782
783
  _empty_dashboard_snapshot = _cctally_dashboard._empty_dashboard_snapshot
783
784
  _iso_z = _cctally_dashboard._iso_z
785
+ # Projects panel + modal (spec 2026-05-19-projects-panel-design.md).
786
+ # Re-export so the sync-thread builder at `_cctally_tui._tui_build_snapshot`
787
+ # can reach the dashboard sibling's aggregator via `c = _cctally()`.
788
+ _build_projects_envelope = _cctally_dashboard._build_projects_envelope
789
+ _projects_reset_memo = _cctally_dashboard._projects_reset_memo
790
+ _project_detail_for_window = _cctally_dashboard._project_detail_for_window
791
+ _handle_get_project_detail_impl = _cctally_dashboard._handle_get_project_detail_impl
784
792
  _select_current_block_for_envelope = _cctally_dashboard._select_current_block_for_envelope
785
793
  _build_alerts_envelope_array = _cctally_dashboard._build_alerts_envelope_array
786
794
  snapshot_to_envelope = _cctally_dashboard.snapshot_to_envelope
@@ -12442,6 +12450,7 @@ _tui_build_trend = _cctally_tui._tui_build_trend
12442
12450
  _tui_build_weekly_history = _cctally_tui._tui_build_weekly_history
12443
12451
  _tui_build_sessions = _cctally_tui._tui_build_sessions
12444
12452
  _tui_build_session_detail = _cctally_tui._tui_build_session_detail
12453
+ _tui_build_session_detail_indexed = _cctally_tui._tui_build_session_detail_indexed
12445
12454
  _tui_build_snapshot = _cctally_tui._tui_build_snapshot
12446
12455
  _tui_empty_snapshot = _cctally_tui._tui_empty_snapshot
12447
12456
  # Key reader + dispatcher + sync thread base class