copilot-cli-trace-deck 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (20) hide show
  1. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/PKG-INFO +3 -1
  2. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/README.md +2 -0
  3. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/pyproject.toml +1 -1
  4. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck/data/sessions.py +379 -26
  5. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck/models.py +23 -0
  6. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck/web/pages.py +267 -16
  7. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck.egg-info/PKG-INFO +3 -1
  8. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/LICENSE +0 -0
  9. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/setup.cfg +0 -0
  10. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck/__init__.py +0 -0
  11. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck/__main__.py +0 -0
  12. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck/data/__init__.py +0 -0
  13. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck/server.py +0 -0
  14. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck/web/__init__.py +0 -0
  15. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck/web/server.py +0 -0
  16. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck.egg-info/SOURCES.txt +0 -0
  17. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck.egg-info/dependency_links.txt +0 -0
  18. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck.egg-info/entry_points.txt +0 -0
  19. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck.egg-info/requires.txt +0 -0
  20. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.2.0}/src/copilot_cli_trace_deck.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: copilot-cli-trace-deck
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -41,6 +41,8 @@ By default the server listens on `http://127.0.0.1:9887` and opens that URL in y
41
41
 
42
42
  Live updates are pushed over Server-Sent Events and triggered by file-system changes in the session-state directory, so the home, summary, logs, and flow pages update without browser polling.
43
43
 
44
+ The summary view also estimates per-session GitHub AI Credits / USD cost from shutdown metrics, aggregating token usage across model switches within the same session.
45
+
44
46
  You can also pass the session-state source and server options:
45
47
 
46
48
  ```bash
@@ -31,6 +31,8 @@ By default the server listens on `http://127.0.0.1:9887` and opens that URL in y
31
31
 
32
32
  Live updates are pushed over Server-Sent Events and triggered by file-system changes in the session-state directory, so the home, summary, logs, and flow pages update without browser polling.
33
33
 
34
+ The summary view also estimates per-session GitHub AI Credits / USD cost from shutdown metrics, aggregating token usage across model switches within the same session.
35
+
34
36
  You can also pass the session-state source and server options:
35
37
 
36
38
  ```bash
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "copilot-cli-trace-deck"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,10 +1,48 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ from dataclasses import dataclass
4
5
  from datetime import datetime
6
+ from decimal import Decimal
5
7
  from pathlib import Path
6
8
 
7
- from ..models import SessionFlowNode, SessionLogEntry, SessionLogSection, SessionPreview, SessionSummary
9
+ from ..models import SessionFlowNode, SessionLogEntry, SessionLogSection, SessionModelUsage, SessionPreview, SessionSummary
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ModelPricing:
14
+ input_usd_per_million: Decimal
15
+ cached_input_usd_per_million: Decimal
16
+ output_usd_per_million: Decimal
17
+ cache_write_usd_per_million: Decimal = Decimal("0")
18
+
19
+
20
+ AI_CREDIT_USD = Decimal("0.01")
21
+ TOKENS_PER_MILLION = Decimal("1000000")
22
+ MODEL_PRICING: dict[str, ModelPricing] = {
23
+ "gpt-4.1": ModelPricing(Decimal("2.00"), Decimal("0.50"), Decimal("8.00")),
24
+ "gpt-5-mini": ModelPricing(Decimal("0.25"), Decimal("0.025"), Decimal("2.00")),
25
+ "gpt-5.2": ModelPricing(Decimal("1.75"), Decimal("0.175"), Decimal("14.00")),
26
+ "gpt-5.2-codex": ModelPricing(Decimal("1.75"), Decimal("0.175"), Decimal("14.00")),
27
+ "gpt-5.3-codex": ModelPricing(Decimal("1.75"), Decimal("0.175"), Decimal("14.00")),
28
+ "gpt-5.4": ModelPricing(Decimal("2.50"), Decimal("0.25"), Decimal("15.00")),
29
+ "gpt-5.4-mini": ModelPricing(Decimal("0.75"), Decimal("0.075"), Decimal("4.50")),
30
+ "gpt-5.4-nano": ModelPricing(Decimal("0.20"), Decimal("0.02"), Decimal("1.25")),
31
+ "gpt-5.5": ModelPricing(Decimal("5.00"), Decimal("0.50"), Decimal("30.00")),
32
+ "claude-haiku-4.5": ModelPricing(Decimal("1.00"), Decimal("0.10"), Decimal("5.00"), Decimal("1.25")),
33
+ "claude-sonnet-4": ModelPricing(Decimal("3.00"), Decimal("0.30"), Decimal("15.00"), Decimal("3.75")),
34
+ "claude-sonnet-4.5": ModelPricing(Decimal("3.00"), Decimal("0.30"), Decimal("15.00"), Decimal("3.75")),
35
+ "claude-sonnet-4.6": ModelPricing(Decimal("3.00"), Decimal("0.30"), Decimal("15.00"), Decimal("3.75")),
36
+ "claude-opus-4.5": ModelPricing(Decimal("5.00"), Decimal("0.50"), Decimal("25.00"), Decimal("6.25")),
37
+ "claude-opus-4.6": ModelPricing(Decimal("5.00"), Decimal("0.50"), Decimal("25.00"), Decimal("6.25")),
38
+ "claude-opus-4.7": ModelPricing(Decimal("5.00"), Decimal("0.50"), Decimal("25.00"), Decimal("6.25")),
39
+ "gemini-2.5-pro": ModelPricing(Decimal("1.25"), Decimal("0.125"), Decimal("10.00")),
40
+ "gemini-3-flash": ModelPricing(Decimal("0.50"), Decimal("0.05"), Decimal("3.00")),
41
+ "gemini-3.1-pro": ModelPricing(Decimal("2.00"), Decimal("0.20"), Decimal("12.00")),
42
+ "gemini-3.5-flash": ModelPricing(Decimal("1.50"), Decimal("0.15"), Decimal("9.00")),
43
+ "raptor-mini": ModelPricing(Decimal("0.25"), Decimal("0.025"), Decimal("2.00")),
44
+ "goldeneye": ModelPricing(Decimal("1.25"), Decimal("0.125"), Decimal("10.00")),
45
+ }
8
46
 
9
47
 
10
48
  def load_session_previews(session_root: Path) -> list[SessionPreview]:
@@ -21,20 +59,28 @@ def load_session_previews(session_root: Path) -> list[SessionPreview]:
21
59
  if not title:
22
60
  continue
23
61
 
24
- updated_at = metadata.get("updated_at") or metadata.get("created_at") or ""
62
+ events = read_jsonl_events(session_dir / "events.jsonl")
63
+ shutdown_event = find_current_shutdown_event(events)
64
+ model_name = find_current_model(events, shutdown_event)
65
+ updated_at = last_event_timestamp(events) or metadata.get("updated_at") or metadata.get("created_at") or first_event_timestamp(events)
25
66
  session_rows.append(
26
67
  (
27
68
  updated_at,
28
- SessionPreview(session_id=session_dir.name, title=title),
69
+ SessionPreview(
70
+ session_id=session_dir.name,
71
+ title=title,
72
+ status="Idle" if shutdown_event else "Active",
73
+ model_name=model_name,
74
+ repository=repository_name(metadata.get("repository", "")),
75
+ branch=metadata.get("branch", ""),
76
+ updated_label=format_timestamp(updated_at) if updated_at else "",
77
+ is_active=shutdown_event is None,
78
+ ),
29
79
  )
30
80
  )
31
81
 
32
82
  session_rows.sort(key=lambda item: item[0], reverse=True)
33
- previews = [session for _, session in session_rows]
34
- if previews:
35
- active = previews[0]
36
- previews[0] = SessionPreview(session_id=active.session_id, title=active.title, is_active=True)
37
- return previews
83
+ return [session for _, session in session_rows]
38
84
 
39
85
 
40
86
  def load_session_summary(session_root: Path, session_id: str) -> SessionSummary | None:
@@ -48,11 +94,13 @@ def load_session_summary(session_root: Path, session_id: str) -> SessionSummary
48
94
  return None
49
95
 
50
96
  events = read_jsonl_events(session_dir / "events.jsonl")
51
- shutdown_event = next((event for event in reversed(events) if event.get("type") == "session.shutdown"), None)
97
+ shutdown_event = find_current_shutdown_event(events)
52
98
  model_name = find_current_model(events, shutdown_event)
53
- usage = extract_usage(shutdown_event, model_name)
99
+ model_usages, billing_stage = extract_model_usages(events, shutdown_event)
100
+ usage = aggregate_usage(model_usages)
101
+ estimated_cost_usd, estimated_ai_credits, billing_note = build_billing_estimate(model_usages, billing_stage)
54
102
  created_value = metadata.get("created_at") or first_event_timestamp(events)
55
- updated_value = metadata.get("updated_at") or last_event_timestamp(events) or created_value
103
+ updated_value = last_event_timestamp(events) or metadata.get("updated_at") or created_value
56
104
 
57
105
  return SessionSummary(
58
106
  session_id=session_id,
@@ -63,6 +111,7 @@ def load_session_summary(session_root: Path, session_id: str) -> SessionSummary
63
111
  location="CLI",
64
112
  status="Idle" if shutdown_event else "Active",
65
113
  model_name=model_name or "Unknown",
114
+ models_used_label=build_models_used_label(model_usages, model_name),
66
115
  repository=repository_name(metadata.get("repository", "")),
67
116
  branch=metadata.get("branch", ""),
68
117
  model_turns=count_events(events, "assistant.turn_start"),
@@ -70,7 +119,12 @@ def load_session_summary(session_root: Path, session_id: str) -> SessionSummary
70
119
  total_input_tokens=usage.get("inputTokens", 0),
71
120
  total_output_tokens=usage.get("outputTokens", 0),
72
121
  total_cached_input_tokens=usage.get("cacheReadTokens", 0),
122
+ total_cache_write_tokens=usage.get("cacheWriteTokens", 0),
73
123
  total_tokens=usage.get("inputTokens", 0) + usage.get("outputTokens", 0),
124
+ estimated_cost_usd=estimated_cost_usd,
125
+ estimated_ai_credits=estimated_ai_credits,
126
+ billing_note=billing_note,
127
+ model_usages=model_usages,
74
128
  error_count=count_errors(events),
75
129
  )
76
130
 
@@ -90,7 +144,7 @@ def load_session_flow(session_root: Path, session_id: str) -> list[SessionFlowNo
90
144
  return None
91
145
 
92
146
  events = read_jsonl_events(session_dir / "events.jsonl")
93
- shutdown_event = next((event for event in reversed(events) if event.get("type") == "session.shutdown"), None)
147
+ shutdown_event = find_current_shutdown_event(events)
94
148
  model_name = find_current_model(events, shutdown_event) or "Unknown"
95
149
  return build_flow_nodes(events, model_name)
96
150
 
@@ -143,30 +197,308 @@ def find_current_model(events: list[dict], shutdown_event: dict | None) -> str:
143
197
  return ""
144
198
 
145
199
 
146
- def extract_usage(shutdown_event: dict | None, current_model: str) -> dict[str, int]:
200
+ def find_current_shutdown_event(events: list[dict]) -> dict | None:
201
+ latest_shutdown: dict | None = None
202
+ latest_shutdown_index = -1
203
+ latest_lifecycle_resume_index = -1
204
+
205
+ for index, event in enumerate(events):
206
+ event_type = event.get("type")
207
+ if event_type == "session.shutdown":
208
+ latest_shutdown = event
209
+ latest_shutdown_index = index
210
+ continue
211
+ if event_type in {"session.start", "session.resume"}:
212
+ latest_lifecycle_resume_index = index
213
+
214
+ if latest_shutdown_index == -1:
215
+ return None
216
+ if latest_lifecycle_resume_index > latest_shutdown_index:
217
+ return None
218
+ return latest_shutdown
219
+
220
+
221
+ def extract_model_usages(events: list[dict], shutdown_event: dict | None) -> tuple[list[SessionModelUsage], str]:
222
+ shutdown_model_usages = extract_all_shutdown_model_usages(events)
223
+ if shutdown_event:
224
+ return shutdown_model_usages, "shutdown"
225
+
226
+ live_model_usages = extract_live_model_usages_since_last_shutdown(events)
227
+ merged_model_usages = merge_model_usages(shutdown_model_usages, live_model_usages)
228
+ if merged_model_usages:
229
+ return merged_model_usages, "live"
230
+ return [], "pending"
231
+
232
+
233
+ def extract_all_shutdown_model_usages(events: list[dict]) -> list[SessionModelUsage]:
234
+ merged_model_usages: list[SessionModelUsage] = []
235
+ for event in events:
236
+ if event.get("type") != "session.shutdown":
237
+ continue
238
+ merged_model_usages = merge_model_usages(merged_model_usages, extract_shutdown_model_usages(event))
239
+ return merged_model_usages
240
+
241
+
242
+ def extract_shutdown_model_usages(shutdown_event: dict | None) -> list[SessionModelUsage]:
147
243
  if not shutdown_event:
148
- return {}
244
+ return []
149
245
 
150
246
  model_metrics = shutdown_event.get("data", {}).get("modelMetrics", {})
151
- if not isinstance(model_metrics, dict) or not model_metrics:
152
- return {}
247
+ if not isinstance(model_metrics, dict):
248
+ return []
153
249
 
154
- if current_model and current_model in model_metrics:
155
- usage = model_metrics[current_model].get("usage", {})
156
- else:
157
- first_metrics = next(iter(model_metrics.values()))
158
- usage = first_metrics.get("usage", {}) if isinstance(first_metrics, dict) else {}
250
+ model_usages: list[SessionModelUsage] = []
251
+ for model_name, metrics in model_metrics.items():
252
+ if not isinstance(model_name, str) or not isinstance(metrics, dict):
253
+ continue
254
+ usage = metrics.get("usage", {})
255
+ if not isinstance(usage, dict):
256
+ continue
159
257
 
160
- if not isinstance(usage, dict):
161
- return {}
258
+ model_usage = build_model_usage(model_name, usage)
259
+ if model_usage is None:
260
+ continue
261
+ model_usages.append(model_usage)
262
+
263
+ model_usages.sort(key=model_usage_sort_key, reverse=True)
264
+ return model_usages
265
+
266
+
267
+ def extract_live_model_usages(events: list[dict]) -> list[SessionModelUsage]:
268
+ usage_by_model: dict[str, dict[str, int]] = {}
269
+ current_model = ""
270
+ for event in events:
271
+ event_type = str(event.get("type") or "")
272
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
273
+ if event_type == "session.model_change":
274
+ model_name = data.get("newModel")
275
+ if isinstance(model_name, str) and model_name:
276
+ current_model = model_name
277
+ continue
278
+
279
+ if event_type != "assistant.message":
280
+ continue
162
281
 
282
+ model_name = data.get("model")
283
+ if not isinstance(model_name, str) or not model_name:
284
+ model_name = current_model
285
+ if not model_name:
286
+ continue
287
+
288
+ usage = usage_by_model.setdefault(
289
+ model_name,
290
+ {
291
+ "inputTokens": 0,
292
+ "outputTokens": 0,
293
+ "cacheReadTokens": 0,
294
+ "cacheWriteTokens": 0,
295
+ },
296
+ )
297
+ usage["inputTokens"] += parse_token_count(data.get("inputTokens"))
298
+ usage["outputTokens"] += parse_token_count(data.get("outputTokens"))
299
+ usage["cacheReadTokens"] += parse_token_count(data.get("cacheReadTokens"))
300
+ usage["cacheWriteTokens"] += parse_token_count(data.get("cacheWriteTokens"))
301
+
302
+ model_usages = [
303
+ model_usage
304
+ for model_name, usage in usage_by_model.items()
305
+ if (model_usage := build_model_usage(model_name, usage)) is not None
306
+ ]
307
+ model_usages.sort(key=model_usage_sort_key, reverse=True)
308
+ return model_usages
309
+
310
+
311
+ def extract_live_model_usages_since_last_shutdown(events: list[dict]) -> list[SessionModelUsage]:
312
+ last_shutdown_index = -1
313
+ for index, event in enumerate(events):
314
+ if event.get("type") == "session.shutdown":
315
+ last_shutdown_index = index
316
+ return extract_live_model_usages(events[last_shutdown_index + 1 :])
317
+
318
+
319
+ def build_model_usage(model_name: str, usage: dict[str, object]) -> SessionModelUsage | None:
320
+ if not model_name:
321
+ return None
322
+
323
+ input_tokens = parse_token_count(usage.get("inputTokens"))
324
+ output_tokens = parse_token_count(usage.get("outputTokens"))
325
+ cached_input_tokens = parse_token_count(usage.get("cacheReadTokens"))
326
+ cache_write_tokens = parse_token_count(usage.get("cacheWriteTokens"))
327
+ total_tokens = input_tokens + output_tokens + cached_input_tokens + cache_write_tokens
328
+ estimated_cost_usd = estimate_model_cost(
329
+ model_name,
330
+ input_tokens=input_tokens,
331
+ cached_input_tokens=cached_input_tokens,
332
+ cache_write_tokens=cache_write_tokens,
333
+ output_tokens=output_tokens,
334
+ )
335
+
336
+ return SessionModelUsage(
337
+ model_name=model_name,
338
+ input_tokens=input_tokens,
339
+ cached_input_tokens=cached_input_tokens,
340
+ cache_write_tokens=cache_write_tokens,
341
+ output_tokens=output_tokens,
342
+ total_tokens=total_tokens,
343
+ estimated_cost_usd=estimated_cost_usd,
344
+ )
345
+
346
+
347
+ def merge_model_usages(*usage_lists: list[SessionModelUsage]) -> list[SessionModelUsage]:
348
+ merged_usage: dict[str, dict[str, int]] = {}
349
+ for usage_list in usage_lists:
350
+ for item in usage_list:
351
+ usage = merged_usage.setdefault(
352
+ item.model_name,
353
+ {
354
+ "inputTokens": 0,
355
+ "outputTokens": 0,
356
+ "cacheReadTokens": 0,
357
+ "cacheWriteTokens": 0,
358
+ },
359
+ )
360
+ usage["inputTokens"] += item.input_tokens
361
+ usage["outputTokens"] += item.output_tokens
362
+ usage["cacheReadTokens"] += item.cached_input_tokens
363
+ usage["cacheWriteTokens"] += item.cache_write_tokens
364
+
365
+ model_usages = [
366
+ model_usage
367
+ for model_name, usage in merged_usage.items()
368
+ if (model_usage := build_model_usage(model_name, usage)) is not None
369
+ ]
370
+ model_usages.sort(key=model_usage_sort_key, reverse=True)
371
+ return model_usages
372
+
373
+
374
+ def aggregate_usage(model_usages: list[SessionModelUsage]) -> dict[str, int]:
163
375
  return {
164
- "inputTokens": int(usage.get("inputTokens", 0) or 0),
165
- "outputTokens": int(usage.get("outputTokens", 0) or 0),
166
- "cacheReadTokens": int(usage.get("cacheReadTokens", 0) or 0),
376
+ "inputTokens": sum(item.input_tokens for item in model_usages),
377
+ "outputTokens": sum(item.output_tokens for item in model_usages),
378
+ "cacheReadTokens": sum(item.cached_input_tokens for item in model_usages),
379
+ "cacheWriteTokens": sum(item.cache_write_tokens for item in model_usages),
167
380
  }
168
381
 
169
382
 
383
+ def parse_token_count(raw_value: object) -> int:
384
+ try:
385
+ return int(raw_value or 0)
386
+ except (TypeError, ValueError):
387
+ return 0
388
+
389
+
390
+ def estimate_model_cost(
391
+ model_name: str,
392
+ *,
393
+ input_tokens: int,
394
+ cached_input_tokens: int,
395
+ cache_write_tokens: int,
396
+ output_tokens: int,
397
+ ) -> Decimal | None:
398
+ pricing = lookup_model_pricing(model_name)
399
+ if not pricing:
400
+ return Decimal("0") if not any((input_tokens, cached_input_tokens, cache_write_tokens, output_tokens)) else None
401
+
402
+ return (
403
+ Decimal(input_tokens) * pricing.input_usd_per_million
404
+ + Decimal(cached_input_tokens) * pricing.cached_input_usd_per_million
405
+ + Decimal(cache_write_tokens) * pricing.cache_write_usd_per_million
406
+ + Decimal(output_tokens) * pricing.output_usd_per_million
407
+ ) / TOKENS_PER_MILLION
408
+
409
+
410
+ def build_billing_estimate(model_usages: list[SessionModelUsage], billing_stage: str) -> tuple[Decimal | None, Decimal | None, str]:
411
+ if not model_usages:
412
+ if billing_stage == "live":
413
+ return None, None, "Live billing estimate will appear after the first assistant response. Copilot only publishes live output tokens; input and cache token counters refresh on session shutdown."
414
+ return None, None, "Billing estimate appears after session shutdown publishes model metrics."
415
+
416
+ missing_pricing = [item.model_name for item in model_usages if item.total_tokens and item.estimated_cost_usd is None]
417
+ known_costs = [item.estimated_cost_usd for item in model_usages if item.estimated_cost_usd is not None]
418
+ if not known_costs and missing_pricing:
419
+ missing_models = ", ".join(missing_pricing)
420
+ return None, None, f"Billing estimate unavailable because pricing is missing for: {missing_models}."
421
+
422
+ estimated_cost_usd = sum(known_costs, Decimal("0"))
423
+ estimated_ai_credits = estimated_cost_usd / AI_CREDIT_USD
424
+ if missing_pricing:
425
+ missing_models = ", ".join(missing_pricing)
426
+ if billing_stage == "live":
427
+ note = f"Live partial estimate. Completed shutdown metrics are included when available; the active segment contributes output tokens only because Copilot does not publish live input or cache token counts. Unpriced models excluded: {missing_models}. Final shutdown metrics may increase totals."
428
+ else:
429
+ note = f"Partial estimate. Unpriced models excluded: {missing_models}."
430
+ elif billing_stage == "live":
431
+ note = "Live estimate includes completed shutdown metrics plus live output tokens from the active event log tail. Input and cache token totals refresh on the next session shutdown."
432
+ else:
433
+ note = "Estimate aggregates all models recorded in session shutdown metrics."
434
+ return estimated_cost_usd, estimated_ai_credits, note
435
+
436
+
437
+ def build_models_used_label(model_usages: list[SessionModelUsage], current_model: str) -> str:
438
+ model_names = [item.model_name for item in model_usages if item.model_name]
439
+ if not model_names:
440
+ return current_model or "Unknown"
441
+ if len(model_names) == 1:
442
+ return model_names[0]
443
+
444
+ primary_model = current_model if current_model in model_names else model_names[0]
445
+ remaining_models = len(model_names) - 1
446
+ return f"{primary_model} + {remaining_models} more"
447
+
448
+
449
+ def model_usage_sort_key(item: SessionModelUsage) -> tuple[int, Decimal, int, str]:
450
+ estimated_cost = item.estimated_cost_usd if item.estimated_cost_usd is not None else Decimal("-1")
451
+ has_pricing = 1 if item.estimated_cost_usd is not None else 0
452
+ return has_pricing, estimated_cost, item.total_tokens, item.model_name.lower()
453
+
454
+
455
+ def lookup_model_pricing(model_name: str) -> ModelPricing | None:
456
+ for candidate in model_key_candidates(model_name):
457
+ pricing = MODEL_PRICING.get(candidate)
458
+ if pricing:
459
+ return pricing
460
+ return None
461
+
462
+
463
+ def model_key_candidates(model_name: str) -> list[str]:
464
+ raw_candidates = {model_name}
465
+ for separator in ("/", ":", "@"):
466
+ if separator in model_name:
467
+ raw_candidates.add(model_name.rsplit(separator, 1)[-1])
468
+
469
+ normalized_candidates: list[str] = []
470
+ seen: set[str] = set()
471
+ for raw_candidate in raw_candidates:
472
+ candidate = normalize_model_key(raw_candidate)
473
+ for variant in candidate_variants(candidate):
474
+ if variant and variant not in seen:
475
+ seen.add(variant)
476
+ normalized_candidates.append(variant)
477
+ return normalized_candidates
478
+
479
+
480
+ def candidate_variants(candidate: str) -> list[str]:
481
+ variants = [candidate]
482
+ for suffix in ("-public-preview", "-preview", "-ga"):
483
+ if candidate.endswith(suffix):
484
+ variants.append(candidate[: -len(suffix)])
485
+ return variants
486
+
487
+
488
+ def normalize_model_key(value: str) -> str:
489
+ normalized_chars: list[str] = []
490
+ previous_is_separator = False
491
+ for char in value.strip().lower():
492
+ if char.isalnum() or char == ".":
493
+ normalized_chars.append(char)
494
+ previous_is_separator = False
495
+ continue
496
+ if not previous_is_separator:
497
+ normalized_chars.append("-")
498
+ previous_is_separator = True
499
+ return "".join(normalized_chars).strip("-")
500
+
501
+
170
502
  def count_events(events: list[dict], event_type: str) -> int:
171
503
  return sum(1 for event in events if event.get("type") == event_type)
172
504
 
@@ -379,6 +711,11 @@ def build_flow_nodes(events: list[dict], model_name: str) -> list[SessionFlowNod
379
711
  pending_tools[tool_call_id] = data
380
712
  continue
381
713
 
714
+ if event_type == "session.resume":
715
+ nodes.append(build_resume_flow_node(next_index, event, event_index))
716
+ next_index += 1
717
+ continue
718
+
382
719
  if event_type in {"hook.start", "hook.end", "assistant.turn_start", "assistant.turn_end", "session.start", "session.model_change", "system.message", "session.shutdown"}:
383
720
  continue
384
721
 
@@ -611,11 +948,27 @@ def build_abort_flow_node(index: int, event: dict, event_index: int) -> SessionF
611
948
  )
612
949
 
613
950
 
951
+ def build_resume_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
952
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
953
+ return SessionFlowNode(
954
+ index=index,
955
+ kind="state",
956
+ title="Session Resumed",
957
+ subtitle=format_log_timestamp(str(event.get("timestamp") or "")),
958
+ detail=compact_text(pretty_value(data), 180) if data else "Resumed previous Copilot CLI session",
959
+ meta="",
960
+ log_index=event_index,
961
+ status="muted",
962
+ )
963
+
964
+
614
965
  def flow_event_label(event: dict) -> str:
615
966
  event_type = str(event.get("type") or "event")
616
967
  data = event.get("data") if isinstance(event.get("data"), dict) else {}
617
968
  if event_type == "subagent.selected":
618
969
  return str(data.get("agentDisplayName") or data.get("agentName") or "Subagent")
970
+ if event_type == "session.resume":
971
+ return "Session Resumed"
619
972
  if event_type == "session.model_change":
620
973
  return str(data.get("newModel") or "Model selected")
621
974
  if event_type == "user.message" and is_internal_user_message(data):
@@ -1,12 +1,18 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
+ from decimal import Decimal
4
5
 
5
6
 
6
7
  @dataclass(frozen=True)
7
8
  class SessionPreview:
8
9
  session_id: str
9
10
  title: str
11
+ status: str = ""
12
+ model_name: str = ""
13
+ repository: str = ""
14
+ branch: str = ""
15
+ updated_label: str = ""
10
16
  is_active: bool = False
11
17
 
12
18
 
@@ -27,10 +33,27 @@ class SessionSummary:
27
33
  total_input_tokens: int
28
34
  total_output_tokens: int
29
35
  total_cached_input_tokens: int
36
+ total_cache_write_tokens: int
30
37
  total_tokens: int
38
+ estimated_cost_usd: Decimal | None
39
+ estimated_ai_credits: Decimal | None
40
+ billing_note: str
41
+ models_used_label: str
42
+ model_usages: list["SessionModelUsage"]
31
43
  error_count: int
32
44
 
33
45
 
46
+ @dataclass(frozen=True)
47
+ class SessionModelUsage:
48
+ model_name: str
49
+ input_tokens: int
50
+ cached_input_tokens: int
51
+ cache_write_tokens: int
52
+ output_tokens: int
53
+ total_tokens: int
54
+ estimated_cost_usd: Decimal | None
55
+
56
+
34
57
  @dataclass(frozen=True)
35
58
  class SessionLogSection:
36
59
  title: str
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import html
4
4
  import json
5
+ from decimal import Decimal
5
6
 
6
7
  from ..models import SessionFlowNode, SessionLogEntry, SessionLogSection, SessionPreview, SessionSummary
7
8
 
@@ -199,6 +200,13 @@ def render_document(page_title: str, body: str) -> str:
199
200
  min-width: 0;
200
201
  }}
201
202
 
203
+ .session-copy {{
204
+ min-width: 0;
205
+ display: flex;
206
+ flex-direction: column;
207
+ gap: 6px;
208
+ }}
209
+
202
210
  .session-title {{
203
211
  margin: 0;
204
212
  font-size: clamp(0.96rem, 1.2vw, 1.28rem);
@@ -211,6 +219,20 @@ def render_document(page_title: str, body: str) -> str:
211
219
  text-overflow: ellipsis;
212
220
  }}
213
221
 
222
+ .session-meta {{
223
+ display: flex;
224
+ flex-wrap: wrap;
225
+ gap: 6px 10px;
226
+ color: #8d97a6;
227
+ font-size: 0.84rem;
228
+ line-height: 1.35;
229
+ letter-spacing: -0.015em;
230
+ }}
231
+
232
+ .session-meta-item {{
233
+ white-space: nowrap;
234
+ }}
235
+
214
236
  .icon {{
215
237
  flex: none;
216
238
  width: 24px;
@@ -338,13 +360,13 @@ def render_document(page_title: str, body: str) -> str:
338
360
 
339
361
  .card-grid {{
340
362
  display: grid;
341
- grid-template-columns: repeat(6, minmax(180px, 1fr));
363
+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
342
364
  gap: 18px;
343
365
  }}
344
366
 
345
367
  .stat-card {{
346
368
  min-height: 102px;
347
- padding: 20px 24px 18px;
369
+ padding: 20px 18px 18px;
348
370
  border: 1px solid var(--line);
349
371
  border-radius: 12px;
350
372
  background: rgba(9, 13, 20, 0.68);
@@ -354,12 +376,11 @@ def render_document(page_title: str, body: str) -> str:
354
376
  .stat-label {{
355
377
  display: block;
356
378
  color: var(--muted);
357
- font-size: 0.9rem;
379
+ font-size: clamp(0.76rem, 0.72rem + 0.18vw, 0.88rem);
358
380
  line-height: 1.25;
359
381
  font-weight: 600;
382
+ letter-spacing: -0.02em;
360
383
  white-space: nowrap;
361
- overflow: hidden;
362
- text-overflow: ellipsis;
363
384
  }}
364
385
 
365
386
  .stat-value {{
@@ -373,6 +394,75 @@ def render_document(page_title: str, body: str) -> str:
373
394
  color: var(--text);
374
395
  }}
375
396
 
397
+ .billing-note {{
398
+ margin: 16px 0 0;
399
+ color: var(--muted);
400
+ font-size: 0.94rem;
401
+ line-height: 1.5;
402
+ }}
403
+
404
+ .model-usage-panel {{
405
+ margin-top: 18px;
406
+ border: 1px solid var(--line);
407
+ border-radius: 12px;
408
+ background: rgba(9, 13, 20, 0.68);
409
+ overflow: hidden;
410
+ }}
411
+
412
+ .model-usage-title {{
413
+ padding: 16px 18px 0;
414
+ color: var(--text);
415
+ font-size: 0.96rem;
416
+ line-height: 1.3;
417
+ font-weight: 600;
418
+ }}
419
+
420
+ .model-usage-scroller {{
421
+ overflow-x: auto;
422
+ padding: 12px 18px 18px;
423
+ }}
424
+
425
+ .model-usage-table {{
426
+ width: 100%;
427
+ min-width: 760px;
428
+ border-collapse: collapse;
429
+ font-variant-numeric: tabular-nums;
430
+ }}
431
+
432
+ .model-usage-table th,
433
+ .model-usage-table td {{
434
+ padding: 12px 0;
435
+ border-bottom: 1px solid rgba(255, 255, 255, 0.07);
436
+ text-align: left;
437
+ }}
438
+
439
+ .model-usage-table th {{
440
+ color: var(--muted);
441
+ font-size: 0.82rem;
442
+ line-height: 1.2;
443
+ font-weight: 700;
444
+ letter-spacing: 0.02em;
445
+ text-transform: uppercase;
446
+ }}
447
+
448
+ .model-usage-table tbody tr:last-child td {{
449
+ border-bottom: 0;
450
+ }}
451
+
452
+ .model-usage-table td {{
453
+ color: var(--text);
454
+ font-size: 0.95rem;
455
+ line-height: 1.35;
456
+ font-weight: 500;
457
+ }}
458
+
459
+ .model-usage-empty {{
460
+ padding: 16px 18px 18px;
461
+ color: var(--muted);
462
+ font-size: 0.94rem;
463
+ line-height: 1.5;
464
+ }}
465
+
376
466
  .action-row {{
377
467
  display: flex;
378
468
  flex-wrap: wrap;
@@ -525,6 +615,45 @@ def build_index_page(session_previews: list[SessionPreview]) -> str:
525
615
  let isPolling = false;
526
616
  let lastMarkup = sessionList ? sessionList.innerHTML.trim() : '';
527
617
 
618
+ function parseSessionItems(markup) {{
619
+ const template = document.createElement('template');
620
+ template.innerHTML = '<ul>' + markup + '</ul>';
621
+ return Array.from(template.content.querySelectorAll('li[data-session-id]'));
622
+ }}
623
+
624
+ function syncSessionList(markup) {{
625
+ if (!sessionList) {{
626
+ return;
627
+ }}
628
+
629
+ const nextItems = parseSessionItems(markup);
630
+ const existingItems = new Map(
631
+ Array.from(sessionList.querySelectorAll('li[data-session-id]')).map((item) => [item.dataset.sessionId || '', item]),
632
+ );
633
+
634
+ nextItems.forEach((nextItem, index) => {{
635
+ const sessionId = nextItem.dataset.sessionId || '';
636
+ const currentItem = existingItems.get(sessionId);
637
+ let itemToPlace = nextItem;
638
+
639
+ if (currentItem) {{
640
+ existingItems.delete(sessionId);
641
+ if (currentItem.outerHTML === nextItem.outerHTML) {{
642
+ itemToPlace = currentItem;
643
+ }} else {{
644
+ currentItem.replaceWith(nextItem);
645
+ }}
646
+ }}
647
+
648
+ const currentAtIndex = sessionList.children[index] || null;
649
+ if (itemToPlace !== currentAtIndex) {{
650
+ sessionList.insertBefore(itemToPlace, currentAtIndex);
651
+ }}
652
+ }});
653
+
654
+ existingItems.forEach((item) => item.remove());
655
+ }}
656
+
528
657
  async function refreshIndex() {{
529
658
  if (isPolling || document.hidden || !sessionList) {{
530
659
  return;
@@ -545,7 +674,7 @@ def build_index_page(session_previews: list[SessionPreview]) -> str:
545
674
  return;
546
675
  }}
547
676
 
548
- sessionList.innerHTML = itemsHtml;
677
+ syncSessionList(itemsHtml);
549
678
  lastMarkup = itemsHtml;
550
679
  }} catch (_error) {{
551
680
  return;
@@ -599,6 +728,7 @@ def build_index_page(session_previews: list[SessionPreview]) -> str:
599
728
  def build_session_page(summary: SessionSummary) -> str:
600
729
  title = html.escape(summary.title)
601
730
  detail_meta = build_session_detail_meta(summary)
731
+ stat_labels = build_summary_stat_labels(summary)
602
732
  body = f"""
603
733
  <main class="page detail-page">
604
734
  <div class="shell app-shell detail-shell">
@@ -635,12 +765,19 @@ def build_session_page(summary: SessionSummary) -> str:
635
765
  <div class="card-grid">
636
766
  {render_stat_card('Model Turns', summary.model_turns, value_id='session-model-turns-value')}
637
767
  {render_stat_card('Tool Calls', summary.tool_calls, value_id='session-tool-calls-value')}
638
- {render_stat_card('Total Input Tokens', summary.total_input_tokens, value_id='session-total-input-value')}
768
+ {render_stat_card(stat_labels['totalInputLabel'], summary.total_input_tokens, value_id='session-total-input-value', label_id='session-total-input-label')}
639
769
  {render_stat_card('Total Output Tokens', summary.total_output_tokens, value_id='session-total-output-value')}
640
- {render_stat_card('Total Cached Input Tokens', summary.total_cached_input_tokens, value_id='session-total-cached-input-value')}
770
+ {render_stat_card(stat_labels['totalCachedInputLabel'], summary.total_cached_input_tokens, value_id='session-total-cached-input-value', label_id='session-total-cached-input-label')}
771
+ {render_stat_card(stat_labels['totalCacheWriteLabel'], summary.total_cache_write_tokens, value_id='session-total-cache-write-value', label_id='session-total-cache-write-label')}
641
772
  {render_stat_card('Total Tokens', summary.total_tokens, value_id='session-total-tokens-value')}
773
+ {render_stat_card('Estimated AI Credits', format_ai_credits(summary.estimated_ai_credits), value_id='session-estimated-ai-credits-value')}
774
+ {render_stat_card('Estimated Cost (USD)', format_currency(summary.estimated_cost_usd), value_id='session-estimated-cost-usd-value')}
642
775
  {render_stat_card('Errors', summary.error_count, value_id='session-error-count-value')}
643
776
  </div>
777
+ <p class="billing-note" id="session-billing-note">{html.escape(summary.billing_note)}</p>
778
+ <div class="model-usage-panel" id="session-model-usage-breakdown">
779
+ {render_model_usage_breakdown(summary)}
780
+ </div>
644
781
  </div>
645
782
 
646
783
  <div class="explore-block">
@@ -667,6 +804,13 @@ def build_session_page(summary: SessionSummary) -> str:
667
804
  }}
668
805
  }}
669
806
 
807
+ function setHTML(id, value) {{
808
+ const node = document.getElementById(id);
809
+ if (node) {{
810
+ node.innerHTML = value;
811
+ }}
812
+ }}
813
+
670
814
  async function refreshSummary() {{
671
815
  if (isPolling || document.hidden) {{
672
816
  return;
@@ -688,13 +832,21 @@ def build_session_page(summary: SessionSummary) -> str:
688
832
  setText('session-status-value', summary.status || '');
689
833
  setText('session-created-value', summary.createdLabel || '');
690
834
  setText('session-updated-value', summary.updatedLabel || '');
835
+ setText('session-total-input-label', summary.totalInputLabel || 'Total Input Tokens');
836
+ setText('session-total-cached-input-label', summary.totalCachedInputLabel || 'Total Cached Input Tokens');
837
+ setText('session-total-cache-write-label', summary.totalCacheWriteLabel || 'Total Cache Write Tokens');
691
838
  setText('session-model-turns-value', summary.modelTurns || '0');
692
839
  setText('session-tool-calls-value', summary.toolCalls || '0');
693
840
  setText('session-total-input-value', summary.totalInputTokens || '0');
694
841
  setText('session-total-output-value', summary.totalOutputTokens || '0');
695
842
  setText('session-total-cached-input-value', summary.totalCachedInputTokens || '0');
843
+ setText('session-total-cache-write-value', summary.totalCacheWriteTokens || '0');
696
844
  setText('session-total-tokens-value', summary.totalTokens || '0');
845
+ setText('session-estimated-ai-credits-value', summary.estimatedAiCredits || '-');
846
+ setText('session-estimated-cost-usd-value', summary.estimatedCostUsd || '-');
697
847
  setText('session-error-count-value', summary.errorCount || '0');
848
+ setText('session-billing-note', summary.billingNote || '');
849
+ setHTML('session-model-usage-breakdown', summary.modelUsageBreakdownHtml || '');
698
850
  }} catch (_error) {{
699
851
  return;
700
852
  }} finally {{
@@ -2123,31 +2275,65 @@ def render_session_list_markup(session_previews: list[SessionPreview]) -> str:
2123
2275
  return "\n".join(render_session_item(session) for session in session_previews)
2124
2276
 
2125
2277
 
2278
+ def build_session_preview_meta(session: SessionPreview) -> str:
2279
+ items: list[str] = []
2280
+ if session.repository:
2281
+ items.append(session.repository)
2282
+ if session.branch:
2283
+ items.append(session.branch)
2284
+ if session.model_name:
2285
+ items.append(session.model_name)
2286
+
2287
+ if session.updated_label:
2288
+ items.append(f"Updated {session.updated_label}")
2289
+
2290
+ return "".join(f'<span class="session-meta-item">{html.escape(item)}</span>' for item in items)
2291
+
2292
+
2126
2293
  def render_session_item(session: SessionPreview) -> str:
2127
2294
  title = html.escape(session.title)
2128
2295
  badge = '<span class="badge">Active</span>' if session.is_active else ""
2296
+ meta = build_session_preview_meta(session)
2129
2297
  return f"""
2130
- <li>
2298
+ <li data-session-id="{html.escape(session.session_id, quote=True)}">
2131
2299
  <a class="session-link" href="/sessions/{session.session_id}" aria-label="Open {title}">
2132
2300
  <div class="session-main">
2133
2301
  {chat_icon()}
2134
- <p class="session-title">{title}</p>
2302
+ <div class="session-copy">
2303
+ <p class="session-title">{title}</p>
2304
+ <div class="session-meta">{meta}</div>
2305
+ </div>
2135
2306
  </div>
2136
2307
  {badge}
2137
2308
  </a>
2138
2309
  </li>"""
2139
2310
 
2140
2311
 
2141
- def render_stat_card(label: str, value: int, value_id: str | None = None) -> str:
2142
- return render_live_stat_card(label, value, value_id=value_id)
2312
+ def build_summary_stat_labels(summary: SessionSummary) -> dict[str, str]:
2313
+ if summary.status == "Active":
2314
+ return {
2315
+ "totalInputLabel": "Input Tokens (Finalized)",
2316
+ "totalCachedInputLabel": "Cached Input (Finalized)",
2317
+ "totalCacheWriteLabel": "Cache Write (Finalized)",
2318
+ }
2319
+ return {
2320
+ "totalInputLabel": "Total Input Tokens",
2321
+ "totalCachedInputLabel": "Total Cached Input Tokens",
2322
+ "totalCacheWriteLabel": "Total Cache Write Tokens",
2323
+ }
2324
+
2325
+
2326
+ def render_stat_card(label: str, value: int | str, value_id: str | None = None, label_id: str | None = None) -> str:
2327
+ return render_live_stat_card(label, value, value_id=value_id, label_id=label_id)
2143
2328
 
2144
2329
 
2145
- def render_live_stat_card(label: str, value: int, value_id: str | None = None) -> str:
2330
+ def render_live_stat_card(label: str, value: int | str, value_id: str | None = None, label_id: str | None = None) -> str:
2331
+ label_id_attr = f' id="{html.escape(label_id, quote=True)}"' if label_id else ''
2146
2332
  value_id_attr = f' id="{html.escape(value_id, quote=True)}"' if value_id else ''
2147
2333
  return f"""
2148
2334
  <article class="stat-card">
2149
- <span class="stat-label">{html.escape(label)}</span>
2150
- <span class="stat-value"{value_id_attr}>{format_number(value)}</span>
2335
+ <span class="stat-label"{label_id_attr}>{html.escape(label)}</span>
2336
+ <span class="stat-value"{value_id_attr}>{html.escape(format_stat_value(value))}</span>
2151
2337
  </article>"""
2152
2338
 
2153
2339
 
@@ -2195,6 +2381,7 @@ def render_empty_flow_state() -> str:
2195
2381
 
2196
2382
 
2197
2383
  def build_session_snapshot_payload(summary: SessionSummary) -> dict[str, str]:
2384
+ stat_labels = build_summary_stat_labels(summary)
2198
2385
  return {
2199
2386
  "detailMeta": build_session_detail_meta(summary),
2200
2387
  "sessionType": summary.session_type,
@@ -2202,12 +2389,20 @@ def build_session_snapshot_payload(summary: SessionSummary) -> dict[str, str]:
2202
2389
  "status": summary.status,
2203
2390
  "createdLabel": summary.created_label,
2204
2391
  "updatedLabel": summary.updated_label,
2392
+ "totalInputLabel": stat_labels["totalInputLabel"],
2393
+ "totalCachedInputLabel": stat_labels["totalCachedInputLabel"],
2394
+ "totalCacheWriteLabel": stat_labels["totalCacheWriteLabel"],
2205
2395
  "modelTurns": format_number(summary.model_turns),
2206
2396
  "toolCalls": format_number(summary.tool_calls),
2207
2397
  "totalInputTokens": format_number(summary.total_input_tokens),
2208
2398
  "totalOutputTokens": format_number(summary.total_output_tokens),
2209
2399
  "totalCachedInputTokens": format_number(summary.total_cached_input_tokens),
2400
+ "totalCacheWriteTokens": format_number(summary.total_cache_write_tokens),
2210
2401
  "totalTokens": format_number(summary.total_tokens),
2402
+ "estimatedAiCredits": format_ai_credits(summary.estimated_ai_credits),
2403
+ "estimatedCostUsd": format_currency(summary.estimated_cost_usd),
2404
+ "billingNote": summary.billing_note,
2405
+ "modelUsageBreakdownHtml": render_model_usage_breakdown(summary),
2211
2406
  "errorCount": format_number(summary.error_count),
2212
2407
  }
2213
2408
 
@@ -2292,7 +2487,8 @@ def render_empty_log_list() -> str:
2292
2487
 
2293
2488
 
2294
2489
  def build_session_detail_meta(summary: SessionSummary) -> str:
2295
- return f"Model: {summary.model_name or 'Unknown'} · Repository: {summary.repository or '-'} · Branch: {summary.branch or '-'}"
2490
+ label = "Models" if len(summary.model_usages) > 1 else "Model"
2491
+ return f"{label}: {summary.models_used_label or 'Unknown'} · Repository: {summary.repository or '-'} · Branch: {summary.branch or '-'}"
2296
2492
 
2297
2493
 
2298
2494
  def chat_icon() -> str:
@@ -2303,3 +2499,58 @@ def chat_icon() -> str:
2303
2499
 
2304
2500
  def format_number(value: int) -> str:
2305
2501
  return f"{value:,}"
2502
+
2503
+
2504
+ def format_stat_value(value: int | str) -> str:
2505
+ return format_number(value) if isinstance(value, int) else value
2506
+
2507
+
2508
+ def format_currency(value: Decimal | None) -> str:
2509
+ if value is None:
2510
+ return "-"
2511
+ return f"${value.quantize(Decimal('0.000001')):,.6f}".rstrip("0").rstrip(".")
2512
+
2513
+
2514
+ def format_ai_credits(value: Decimal | None) -> str:
2515
+ if value is None:
2516
+ return "-"
2517
+ return f"{value.quantize(Decimal('0.0001')):,.4f}".rstrip("0").rstrip(".")
2518
+
2519
+
2520
+ def render_model_usage_breakdown(summary: SessionSummary) -> str:
2521
+ if not summary.model_usages:
2522
+ return '<div class="model-usage-empty">No per-model usage metrics available yet.</div>'
2523
+
2524
+ rows = "\n".join(
2525
+ f"""
2526
+ <tr>
2527
+ <td>{html.escape(item.model_name)}</td>
2528
+ <td>{format_number(item.input_tokens)}</td>
2529
+ <td>{format_number(item.cached_input_tokens)}</td>
2530
+ <td>{format_number(item.cache_write_tokens)}</td>
2531
+ <td>{format_number(item.output_tokens)}</td>
2532
+ <td>{format_number(item.total_tokens)}</td>
2533
+ <td>{html.escape(format_currency(item.estimated_cost_usd))}</td>
2534
+ </tr>"""
2535
+ for item in summary.model_usages
2536
+ )
2537
+ return f"""
2538
+ <div class="model-usage-title">Model Usage Breakdown</div>
2539
+ <div class="model-usage-scroller">
2540
+ <table class="model-usage-table">
2541
+ <thead>
2542
+ <tr>
2543
+ <th>Model</th>
2544
+ <th>Input</th>
2545
+ <th>Cached Input</th>
2546
+ <th>Cache Write</th>
2547
+ <th>Output</th>
2548
+ <th>Total</th>
2549
+ <th>Est. USD</th>
2550
+ </tr>
2551
+ </thead>
2552
+ <tbody>
2553
+ {rows}
2554
+ </tbody>
2555
+ </table>
2556
+ </div>"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: copilot-cli-trace-deck
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -41,6 +41,8 @@ By default the server listens on `http://127.0.0.1:9887` and opens that URL in y
41
41
 
42
42
  Live updates are pushed over Server-Sent Events and triggered by file-system changes in the session-state directory, so the home, summary, logs, and flow pages update without browser polling.
43
43
 
44
+ The summary view also estimates per-session GitHub AI Credits / USD cost from shutdown metrics, aggregating token usage across model switches within the same session.
45
+
44
46
  You can also pass the session-state source and server options:
45
47
 
46
48
  ```bash