copilot-cli-trace-deck 0.1.0__tar.gz → 0.3.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.3.0}/PKG-INFO +6 -4
  2. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/README.md +5 -3
  3. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/pyproject.toml +1 -1
  4. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/data/sessions.py +443 -26
  5. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/models.py +24 -0
  6. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/web/pages.py +258 -16
  7. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck.egg-info/PKG-INFO +6 -4
  8. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/LICENSE +0 -0
  9. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/setup.cfg +0 -0
  10. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/__init__.py +0 -0
  11. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/__main__.py +0 -0
  12. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/data/__init__.py +0 -0
  13. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/server.py +0 -0
  14. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/web/__init__.py +0 -0
  15. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.0}/src/copilot_cli_trace_deck/web/server.py +0 -0
  16. {copilot_cli_trace_deck-0.1.0 → copilot_cli_trace_deck-0.3.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.3.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.3.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.3.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.3.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.3.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
@@ -34,23 +34,25 @@ Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view.
34
34
  Run the app directly from the workspace with `uv`:
35
35
 
36
36
  ```bash
37
- uv run copilot-cli-trace-deck
37
+ uvx copilot-cli-trace-deck@latest
38
38
  ```
39
39
 
40
40
  By default the server listens on `http://127.0.0.1:9887` and opens that URL in your browser.
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
47
- uv run copilot-cli-trace-deck ~/.copilot/session-state --host 127.0.0.1 --port 9887
49
+ uvx copilot-cli-trace-deck ~/.copilot/session-state --host 127.0.0.1 --port 9887
48
50
  ```
49
51
 
50
52
  To skip opening a browser while still printing the local URL:
51
53
 
52
54
  ```bash
53
- uv run copilot-cli-trace-deck --quiet
55
+ uvx copilot-cli-trace-deck --quiet
54
56
  ```
55
57
 
56
58
  ## Install As A Command
@@ -24,23 +24,25 @@ Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view.
24
24
  Run the app directly from the workspace with `uv`:
25
25
 
26
26
  ```bash
27
- uv run copilot-cli-trace-deck
27
+ uvx copilot-cli-trace-deck@latest
28
28
  ```
29
29
 
30
30
  By default the server listens on `http://127.0.0.1:9887` and opens that URL in your browser.
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
37
- uv run copilot-cli-trace-deck ~/.copilot/session-state --host 127.0.0.1 --port 9887
39
+ uvx copilot-cli-trace-deck ~/.copilot/session-state --host 127.0.0.1 --port 9887
38
40
  ```
39
41
 
40
42
  To skip opening a browser while still printing the local URL:
41
43
 
42
44
  ```bash
43
- uv run copilot-cli-trace-deck --quiet
45
+ uvx copilot-cli-trace-deck --quiet
44
46
  ```
45
47
 
46
48
  ## Install As A Command
@@ -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.3.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,49 @@
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
+ NANO_AIU_PER_AI_CREDIT = Decimal("1000000000")
22
+ TOKENS_PER_MILLION = Decimal("1000000")
23
+ MODEL_PRICING: dict[str, ModelPricing] = {
24
+ "gpt-4.1": ModelPricing(Decimal("2.00"), Decimal("0.50"), Decimal("8.00")),
25
+ "gpt-5-mini": ModelPricing(Decimal("0.25"), Decimal("0.025"), Decimal("2.00")),
26
+ "gpt-5.2": ModelPricing(Decimal("1.75"), Decimal("0.175"), Decimal("14.00")),
27
+ "gpt-5.2-codex": ModelPricing(Decimal("1.75"), Decimal("0.175"), Decimal("14.00")),
28
+ "gpt-5.3-codex": ModelPricing(Decimal("1.75"), Decimal("0.175"), Decimal("14.00")),
29
+ "gpt-5.4": ModelPricing(Decimal("2.50"), Decimal("0.25"), Decimal("15.00")),
30
+ "gpt-5.4-mini": ModelPricing(Decimal("0.75"), Decimal("0.075"), Decimal("4.50")),
31
+ "gpt-5.4-nano": ModelPricing(Decimal("0.20"), Decimal("0.02"), Decimal("1.25")),
32
+ "gpt-5.5": ModelPricing(Decimal("5.00"), Decimal("0.50"), Decimal("30.00")),
33
+ "claude-haiku-4.5": ModelPricing(Decimal("1.00"), Decimal("0.10"), Decimal("5.00"), Decimal("1.25")),
34
+ "claude-sonnet-4": ModelPricing(Decimal("3.00"), Decimal("0.30"), Decimal("15.00"), Decimal("3.75")),
35
+ "claude-sonnet-4.5": ModelPricing(Decimal("3.00"), Decimal("0.30"), Decimal("15.00"), Decimal("3.75")),
36
+ "claude-sonnet-4.6": ModelPricing(Decimal("3.00"), Decimal("0.30"), Decimal("15.00"), Decimal("3.75")),
37
+ "claude-opus-4.5": ModelPricing(Decimal("5.00"), Decimal("0.50"), Decimal("25.00"), Decimal("6.25")),
38
+ "claude-opus-4.6": ModelPricing(Decimal("5.00"), Decimal("0.50"), Decimal("25.00"), Decimal("6.25")),
39
+ "claude-opus-4.7": ModelPricing(Decimal("5.00"), Decimal("0.50"), Decimal("25.00"), Decimal("6.25")),
40
+ "gemini-2.5-pro": ModelPricing(Decimal("1.25"), Decimal("0.125"), Decimal("10.00")),
41
+ "gemini-3-flash": ModelPricing(Decimal("0.50"), Decimal("0.05"), Decimal("3.00")),
42
+ "gemini-3.1-pro": ModelPricing(Decimal("2.00"), Decimal("0.20"), Decimal("12.00")),
43
+ "gemini-3.5-flash": ModelPricing(Decimal("1.50"), Decimal("0.15"), Decimal("9.00")),
44
+ "raptor-mini": ModelPricing(Decimal("0.25"), Decimal("0.025"), Decimal("2.00")),
45
+ "goldeneye": ModelPricing(Decimal("1.25"), Decimal("0.125"), Decimal("10.00")),
46
+ }
8
47
 
9
48
 
10
49
  def load_session_previews(session_root: Path) -> list[SessionPreview]:
@@ -21,20 +60,28 @@ def load_session_previews(session_root: Path) -> list[SessionPreview]:
21
60
  if not title:
22
61
  continue
23
62
 
24
- updated_at = metadata.get("updated_at") or metadata.get("created_at") or ""
63
+ events = read_jsonl_events(session_dir / "events.jsonl")
64
+ shutdown_event = find_current_shutdown_event(events)
65
+ model_name = find_current_model(events, shutdown_event)
66
+ updated_at = last_event_timestamp(events) or metadata.get("updated_at") or metadata.get("created_at") or first_event_timestamp(events)
25
67
  session_rows.append(
26
68
  (
27
69
  updated_at,
28
- SessionPreview(session_id=session_dir.name, title=title),
70
+ SessionPreview(
71
+ session_id=session_dir.name,
72
+ title=title,
73
+ status="Idle" if shutdown_event else "Active",
74
+ model_name=model_name,
75
+ repository=repository_name(metadata.get("repository", "")),
76
+ branch=metadata.get("branch", ""),
77
+ updated_label=format_timestamp(updated_at) if updated_at else "",
78
+ is_active=shutdown_event is None,
79
+ ),
29
80
  )
30
81
  )
31
82
 
32
83
  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
84
+ return [session for _, session in session_rows]
38
85
 
39
86
 
40
87
  def load_session_summary(session_root: Path, session_id: str) -> SessionSummary | None:
@@ -48,11 +95,18 @@ def load_session_summary(session_root: Path, session_id: str) -> SessionSummary
48
95
  return None
49
96
 
50
97
  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)
98
+ shutdown_event = find_current_shutdown_event(events)
52
99
  model_name = find_current_model(events, shutdown_event)
53
- usage = extract_usage(shutdown_event, model_name)
100
+ model_usages, billing_stage = extract_model_usages(events, shutdown_event)
101
+ usage = aggregate_usage(model_usages)
102
+ shutdown_total_nano_aiu = extract_shutdown_total_nano_aiu(events)
103
+ estimated_cost_usd, estimated_ai_credits, billing_note = build_billing_estimate(
104
+ model_usages,
105
+ billing_stage,
106
+ shutdown_total_nano_aiu=shutdown_total_nano_aiu,
107
+ )
54
108
  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
109
+ updated_value = last_event_timestamp(events) or metadata.get("updated_at") or created_value
56
110
 
57
111
  return SessionSummary(
58
112
  session_id=session_id,
@@ -63,6 +117,7 @@ def load_session_summary(session_root: Path, session_id: str) -> SessionSummary
63
117
  location="CLI",
64
118
  status="Idle" if shutdown_event else "Active",
65
119
  model_name=model_name or "Unknown",
120
+ models_used_label=build_models_used_label(model_usages, model_name),
66
121
  repository=repository_name(metadata.get("repository", "")),
67
122
  branch=metadata.get("branch", ""),
68
123
  model_turns=count_events(events, "assistant.turn_start"),
@@ -70,7 +125,12 @@ def load_session_summary(session_root: Path, session_id: str) -> SessionSummary
70
125
  total_input_tokens=usage.get("inputTokens", 0),
71
126
  total_output_tokens=usage.get("outputTokens", 0),
72
127
  total_cached_input_tokens=usage.get("cacheReadTokens", 0),
128
+ total_cache_write_tokens=usage.get("cacheWriteTokens", 0),
73
129
  total_tokens=usage.get("inputTokens", 0) + usage.get("outputTokens", 0),
130
+ estimated_cost_usd=estimated_cost_usd,
131
+ estimated_ai_credits=estimated_ai_credits,
132
+ billing_note=billing_note,
133
+ model_usages=model_usages,
74
134
  error_count=count_errors(events),
75
135
  )
76
136
 
@@ -90,7 +150,7 @@ def load_session_flow(session_root: Path, session_id: str) -> list[SessionFlowNo
90
150
  return None
91
151
 
92
152
  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)
153
+ shutdown_event = find_current_shutdown_event(events)
94
154
  model_name = find_current_model(events, shutdown_event) or "Unknown"
95
155
  return build_flow_nodes(events, model_name)
96
156
 
@@ -143,30 +203,366 @@ def find_current_model(events: list[dict], shutdown_event: dict | None) -> str:
143
203
  return ""
144
204
 
145
205
 
146
- def extract_usage(shutdown_event: dict | None, current_model: str) -> dict[str, int]:
206
+ def find_current_shutdown_event(events: list[dict]) -> dict | None:
207
+ latest_shutdown: dict | None = None
208
+ latest_shutdown_index = -1
209
+ latest_lifecycle_resume_index = -1
210
+
211
+ for index, event in enumerate(events):
212
+ event_type = event.get("type")
213
+ if event_type == "session.shutdown":
214
+ latest_shutdown = event
215
+ latest_shutdown_index = index
216
+ continue
217
+ if event_type in {"session.start", "session.resume"}:
218
+ latest_lifecycle_resume_index = index
219
+
220
+ if latest_shutdown_index == -1:
221
+ return None
222
+ if latest_lifecycle_resume_index > latest_shutdown_index:
223
+ return None
224
+ return latest_shutdown
225
+
226
+
227
+ def extract_model_usages(events: list[dict], shutdown_event: dict | None) -> tuple[list[SessionModelUsage], str]:
228
+ shutdown_model_usages = extract_all_shutdown_model_usages(events)
229
+ if shutdown_event:
230
+ return shutdown_model_usages, "shutdown"
231
+
232
+ live_model_usages = extract_live_model_usages_since_last_shutdown(events)
233
+ merged_model_usages = merge_model_usages(shutdown_model_usages, live_model_usages)
234
+ if merged_model_usages:
235
+ return merged_model_usages, "live"
236
+ return [], "pending"
237
+
238
+
239
+ def extract_all_shutdown_model_usages(events: list[dict]) -> list[SessionModelUsage]:
240
+ merged_model_usages: list[SessionModelUsage] = []
241
+ for event in events:
242
+ if event.get("type") != "session.shutdown":
243
+ continue
244
+ merged_model_usages = merge_model_usages(merged_model_usages, extract_shutdown_model_usages(event))
245
+ return merged_model_usages
246
+
247
+
248
+ def extract_shutdown_total_nano_aiu(events: list[dict]) -> int | None:
249
+ total_nano_aiu = 0
250
+ found_billing = False
251
+ for event in events:
252
+ if event.get("type") != "session.shutdown":
253
+ continue
254
+
255
+ raw_total_nano_aiu = event.get("data", {}).get("totalNanoAiu")
256
+ try:
257
+ total_nano_aiu += int(raw_total_nano_aiu)
258
+ except (TypeError, ValueError):
259
+ continue
260
+ found_billing = True
261
+
262
+ if not found_billing:
263
+ return None
264
+ return total_nano_aiu
265
+
266
+
267
+ def extract_shutdown_model_usages(shutdown_event: dict | None) -> list[SessionModelUsage]:
147
268
  if not shutdown_event:
148
- return {}
269
+ return []
149
270
 
150
271
  model_metrics = shutdown_event.get("data", {}).get("modelMetrics", {})
151
- if not isinstance(model_metrics, dict) or not model_metrics:
152
- return {}
272
+ if not isinstance(model_metrics, dict):
273
+ return []
153
274
 
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 {}
275
+ model_usages: list[SessionModelUsage] = []
276
+ for model_name, metrics in model_metrics.items():
277
+ if not isinstance(model_name, str) or not isinstance(metrics, dict):
278
+ continue
279
+ usage = metrics.get("usage", {})
280
+ if not isinstance(usage, dict):
281
+ continue
159
282
 
160
- if not isinstance(usage, dict):
161
- return {}
283
+ estimated_ai_credits = parse_nano_aiu_as_ai_credits(metrics.get("totalNanoAiu"))
284
+
285
+ model_usage = build_model_usage(model_name, usage, estimated_ai_credits=estimated_ai_credits)
286
+ if model_usage is None:
287
+ continue
288
+ model_usages.append(model_usage)
289
+
290
+ model_usages.sort(key=model_usage_sort_key, reverse=True)
291
+ return model_usages
292
+
293
+
294
+ def extract_live_model_usages(events: list[dict]) -> list[SessionModelUsage]:
295
+ usage_by_model: dict[str, dict[str, int]] = {}
296
+ current_model = ""
297
+ for event in events:
298
+ event_type = str(event.get("type") or "")
299
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
300
+ if event_type == "session.model_change":
301
+ model_name = data.get("newModel")
302
+ if isinstance(model_name, str) and model_name:
303
+ current_model = model_name
304
+ continue
305
+
306
+ if event_type != "assistant.message":
307
+ continue
162
308
 
309
+ model_name = data.get("model")
310
+ if not isinstance(model_name, str) or not model_name:
311
+ model_name = current_model
312
+ if not model_name:
313
+ continue
314
+
315
+ usage = usage_by_model.setdefault(
316
+ model_name,
317
+ {
318
+ "inputTokens": 0,
319
+ "outputTokens": 0,
320
+ "cacheReadTokens": 0,
321
+ "cacheWriteTokens": 0,
322
+ },
323
+ )
324
+ usage["inputTokens"] += parse_token_count(data.get("inputTokens"))
325
+ usage["outputTokens"] += parse_token_count(data.get("outputTokens"))
326
+ usage["cacheReadTokens"] += parse_token_count(data.get("cacheReadTokens"))
327
+ usage["cacheWriteTokens"] += parse_token_count(data.get("cacheWriteTokens"))
328
+
329
+ model_usages = [
330
+ model_usage
331
+ for model_name, usage in usage_by_model.items()
332
+ if (model_usage := build_model_usage(model_name, usage)) is not None
333
+ ]
334
+ model_usages.sort(key=model_usage_sort_key, reverse=True)
335
+ return model_usages
336
+
337
+
338
+ def extract_live_model_usages_since_last_shutdown(events: list[dict]) -> list[SessionModelUsage]:
339
+ last_shutdown_index = -1
340
+ for index, event in enumerate(events):
341
+ if event.get("type") == "session.shutdown":
342
+ last_shutdown_index = index
343
+ return extract_live_model_usages(events[last_shutdown_index + 1 :])
344
+
345
+
346
+ def build_model_usage(
347
+ model_name: str,
348
+ usage: dict[str, object],
349
+ *,
350
+ estimated_ai_credits: Decimal | None = None,
351
+ ) -> SessionModelUsage | None:
352
+ if not model_name:
353
+ return None
354
+
355
+ input_tokens = parse_token_count(usage.get("inputTokens"))
356
+ output_tokens = parse_token_count(usage.get("outputTokens"))
357
+ cached_input_tokens = parse_token_count(usage.get("cacheReadTokens"))
358
+ cache_write_tokens = parse_token_count(usage.get("cacheWriteTokens"))
359
+ total_tokens = input_tokens + output_tokens + cached_input_tokens + cache_write_tokens
360
+ estimated_cost_usd = estimate_model_cost(
361
+ model_name,
362
+ input_tokens=input_tokens,
363
+ cached_input_tokens=cached_input_tokens,
364
+ cache_write_tokens=cache_write_tokens,
365
+ output_tokens=output_tokens,
366
+ )
367
+ if estimated_ai_credits is None and estimated_cost_usd is not None:
368
+ estimated_ai_credits = estimated_cost_usd / AI_CREDIT_USD
369
+
370
+ return SessionModelUsage(
371
+ model_name=model_name,
372
+ input_tokens=input_tokens,
373
+ cached_input_tokens=cached_input_tokens,
374
+ cache_write_tokens=cache_write_tokens,
375
+ output_tokens=output_tokens,
376
+ total_tokens=total_tokens,
377
+ estimated_cost_usd=estimated_cost_usd,
378
+ estimated_ai_credits=estimated_ai_credits,
379
+ )
380
+
381
+
382
+ def merge_model_usages(*usage_lists: list[SessionModelUsage]) -> list[SessionModelUsage]:
383
+ merged_usage: dict[str, dict[str, object]] = {}
384
+ for usage_list in usage_lists:
385
+ for item in usage_list:
386
+ usage = merged_usage.setdefault(
387
+ item.model_name,
388
+ {
389
+ "inputTokens": 0,
390
+ "outputTokens": 0,
391
+ "cacheReadTokens": 0,
392
+ "cacheWriteTokens": 0,
393
+ "estimatedAiCredits": None,
394
+ },
395
+ )
396
+ usage["inputTokens"] += item.input_tokens
397
+ usage["outputTokens"] += item.output_tokens
398
+ usage["cacheReadTokens"] += item.cached_input_tokens
399
+ usage["cacheWriteTokens"] += item.cache_write_tokens
400
+ if item.estimated_ai_credits is not None:
401
+ current_estimated_ai_credits = usage.get("estimatedAiCredits")
402
+ if not isinstance(current_estimated_ai_credits, Decimal):
403
+ current_estimated_ai_credits = Decimal("0")
404
+ usage["estimatedAiCredits"] = current_estimated_ai_credits + item.estimated_ai_credits
405
+
406
+ model_usages = [
407
+ model_usage
408
+ for model_name, usage in merged_usage.items()
409
+ if (
410
+ model_usage := build_model_usage(
411
+ model_name,
412
+ usage,
413
+ estimated_ai_credits=usage.get("estimatedAiCredits") if isinstance(usage.get("estimatedAiCredits"), Decimal) else None,
414
+ )
415
+ ) is not None
416
+ ]
417
+ model_usages.sort(key=model_usage_sort_key, reverse=True)
418
+ return model_usages
419
+
420
+
421
+ def aggregate_usage(model_usages: list[SessionModelUsage]) -> dict[str, int]:
163
422
  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),
423
+ "inputTokens": sum(item.input_tokens for item in model_usages),
424
+ "outputTokens": sum(item.output_tokens for item in model_usages),
425
+ "cacheReadTokens": sum(item.cached_input_tokens for item in model_usages),
426
+ "cacheWriteTokens": sum(item.cache_write_tokens for item in model_usages),
167
427
  }
168
428
 
169
429
 
430
+ def parse_nano_aiu_as_ai_credits(raw_value: object) -> Decimal | None:
431
+ try:
432
+ return Decimal(int(raw_value)) / NANO_AIU_PER_AI_CREDIT
433
+ except (TypeError, ValueError):
434
+ return None
435
+
436
+
437
+ def parse_token_count(raw_value: object) -> int:
438
+ try:
439
+ return int(raw_value or 0)
440
+ except (TypeError, ValueError):
441
+ return 0
442
+
443
+
444
+ def estimate_model_cost(
445
+ model_name: str,
446
+ *,
447
+ input_tokens: int,
448
+ cached_input_tokens: int,
449
+ cache_write_tokens: int,
450
+ output_tokens: int,
451
+ ) -> Decimal | None:
452
+ pricing = lookup_model_pricing(model_name)
453
+ if not pricing:
454
+ return Decimal("0") if not any((input_tokens, cached_input_tokens, cache_write_tokens, output_tokens)) else None
455
+
456
+ return (
457
+ Decimal(input_tokens) * pricing.input_usd_per_million
458
+ + Decimal(cached_input_tokens) * pricing.cached_input_usd_per_million
459
+ + Decimal(cache_write_tokens) * pricing.cache_write_usd_per_million
460
+ + Decimal(output_tokens) * pricing.output_usd_per_million
461
+ ) / TOKENS_PER_MILLION
462
+
463
+
464
+ def build_billing_estimate(
465
+ model_usages: list[SessionModelUsage],
466
+ billing_stage: str,
467
+ *,
468
+ shutdown_total_nano_aiu: int | None = None,
469
+ ) -> tuple[Decimal | None, Decimal | None, str]:
470
+ if not model_usages:
471
+ if billing_stage == "live":
472
+ 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."
473
+ return None, None, "Billing estimate appears after session shutdown publishes model metrics."
474
+
475
+ if billing_stage == "shutdown" and shutdown_total_nano_aiu is not None:
476
+ estimated_ai_credits = Decimal(shutdown_total_nano_aiu) / NANO_AIU_PER_AI_CREDIT
477
+ estimated_cost_usd = estimated_ai_credits * AI_CREDIT_USD
478
+ return estimated_cost_usd, estimated_ai_credits, "Estimate uses Copilot session shutdown billing fields."
479
+
480
+ missing_pricing = [item.model_name for item in model_usages if item.total_tokens and item.estimated_cost_usd is None]
481
+ known_costs = [item.estimated_cost_usd for item in model_usages if item.estimated_cost_usd is not None]
482
+ if not known_costs and missing_pricing:
483
+ missing_models = ", ".join(missing_pricing)
484
+ return None, None, f"Billing estimate unavailable because pricing is missing for: {missing_models}."
485
+
486
+ estimated_cost_usd = sum(known_costs, Decimal("0"))
487
+ estimated_ai_credits = estimated_cost_usd / AI_CREDIT_USD
488
+ if missing_pricing:
489
+ missing_models = ", ".join(missing_pricing)
490
+ if billing_stage == "live":
491
+ 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."
492
+ else:
493
+ note = f"Partial estimate. Unpriced models excluded: {missing_models}."
494
+ elif billing_stage == "live":
495
+ 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."
496
+ else:
497
+ note = "Estimate aggregates all models recorded in session shutdown metrics."
498
+ return estimated_cost_usd, estimated_ai_credits, note
499
+
500
+
501
+ def build_models_used_label(model_usages: list[SessionModelUsage], current_model: str) -> str:
502
+ model_names = [item.model_name for item in model_usages if item.model_name]
503
+ if not model_names:
504
+ return current_model or "Unknown"
505
+ if len(model_names) == 1:
506
+ return model_names[0]
507
+
508
+ primary_model = current_model if current_model in model_names else model_names[0]
509
+ remaining_models = len(model_names) - 1
510
+ return f"{primary_model} + {remaining_models} more"
511
+
512
+
513
+ def model_usage_sort_key(item: SessionModelUsage) -> tuple[int, Decimal, int, str]:
514
+ estimated_cost = item.estimated_cost_usd if item.estimated_cost_usd is not None else Decimal("-1")
515
+ has_pricing = 1 if item.estimated_cost_usd is not None else 0
516
+ return has_pricing, estimated_cost, item.total_tokens, item.model_name.lower()
517
+
518
+
519
+ def lookup_model_pricing(model_name: str) -> ModelPricing | None:
520
+ for candidate in model_key_candidates(model_name):
521
+ pricing = MODEL_PRICING.get(candidate)
522
+ if pricing:
523
+ return pricing
524
+ return None
525
+
526
+
527
+ def model_key_candidates(model_name: str) -> list[str]:
528
+ raw_candidates = {model_name}
529
+ for separator in ("/", ":", "@"):
530
+ if separator in model_name:
531
+ raw_candidates.add(model_name.rsplit(separator, 1)[-1])
532
+
533
+ normalized_candidates: list[str] = []
534
+ seen: set[str] = set()
535
+ for raw_candidate in raw_candidates:
536
+ candidate = normalize_model_key(raw_candidate)
537
+ for variant in candidate_variants(candidate):
538
+ if variant and variant not in seen:
539
+ seen.add(variant)
540
+ normalized_candidates.append(variant)
541
+ return normalized_candidates
542
+
543
+
544
+ def candidate_variants(candidate: str) -> list[str]:
545
+ variants = [candidate]
546
+ for suffix in ("-public-preview", "-preview", "-ga"):
547
+ if candidate.endswith(suffix):
548
+ variants.append(candidate[: -len(suffix)])
549
+ return variants
550
+
551
+
552
+ def normalize_model_key(value: str) -> str:
553
+ normalized_chars: list[str] = []
554
+ previous_is_separator = False
555
+ for char in value.strip().lower():
556
+ if char.isalnum() or char == ".":
557
+ normalized_chars.append(char)
558
+ previous_is_separator = False
559
+ continue
560
+ if not previous_is_separator:
561
+ normalized_chars.append("-")
562
+ previous_is_separator = True
563
+ return "".join(normalized_chars).strip("-")
564
+
565
+
170
566
  def count_events(events: list[dict], event_type: str) -> int:
171
567
  return sum(1 for event in events if event.get("type") == event_type)
172
568
 
@@ -379,6 +775,11 @@ def build_flow_nodes(events: list[dict], model_name: str) -> list[SessionFlowNod
379
775
  pending_tools[tool_call_id] = data
380
776
  continue
381
777
 
778
+ if event_type == "session.resume":
779
+ nodes.append(build_resume_flow_node(next_index, event, event_index))
780
+ next_index += 1
781
+ continue
782
+
382
783
  if event_type in {"hook.start", "hook.end", "assistant.turn_start", "assistant.turn_end", "session.start", "session.model_change", "system.message", "session.shutdown"}:
383
784
  continue
384
785
 
@@ -611,11 +1012,27 @@ def build_abort_flow_node(index: int, event: dict, event_index: int) -> SessionF
611
1012
  )
612
1013
 
613
1014
 
1015
+ def build_resume_flow_node(index: int, event: dict, event_index: int) -> SessionFlowNode:
1016
+ data = event.get("data") if isinstance(event.get("data"), dict) else {}
1017
+ return SessionFlowNode(
1018
+ index=index,
1019
+ kind="state",
1020
+ title="Session Resumed",
1021
+ subtitle=format_log_timestamp(str(event.get("timestamp") or "")),
1022
+ detail=compact_text(pretty_value(data), 180) if data else "Resumed previous Copilot CLI session",
1023
+ meta="",
1024
+ log_index=event_index,
1025
+ status="muted",
1026
+ )
1027
+
1028
+
614
1029
  def flow_event_label(event: dict) -> str:
615
1030
  event_type = str(event.get("type") or "event")
616
1031
  data = event.get("data") if isinstance(event.get("data"), dict) else {}
617
1032
  if event_type == "subagent.selected":
618
1033
  return str(data.get("agentDisplayName") or data.get("agentName") or "Subagent")
1034
+ if event_type == "session.resume":
1035
+ return "Session Resumed"
619
1036
  if event_type == "session.model_change":
620
1037
  return str(data.get("newModel") or "Model selected")
621
1038
  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,28 @@ 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
+ estimated_ai_credits: Decimal | None
56
+
57
+
34
58
  @dataclass(frozen=True)
35
59
  class SessionLogSection:
36
60
  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,18 @@ 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')}
642
774
  {render_stat_card('Errors', summary.error_count, value_id='session-error-count-value')}
643
775
  </div>
776
+ <p class="billing-note" id="session-billing-note">{html.escape(summary.billing_note)}</p>
777
+ <div class="model-usage-panel" id="session-model-usage-breakdown">
778
+ {render_model_usage_breakdown(summary)}
779
+ </div>
644
780
  </div>
645
781
 
646
782
  <div class="explore-block">
@@ -667,6 +803,13 @@ def build_session_page(summary: SessionSummary) -> str:
667
803
  }}
668
804
  }}
669
805
 
806
+ function setHTML(id, value) {{
807
+ const node = document.getElementById(id);
808
+ if (node) {{
809
+ node.innerHTML = value;
810
+ }}
811
+ }}
812
+
670
813
  async function refreshSummary() {{
671
814
  if (isPolling || document.hidden) {{
672
815
  return;
@@ -688,13 +831,20 @@ def build_session_page(summary: SessionSummary) -> str:
688
831
  setText('session-status-value', summary.status || '');
689
832
  setText('session-created-value', summary.createdLabel || '');
690
833
  setText('session-updated-value', summary.updatedLabel || '');
834
+ setText('session-total-input-label', summary.totalInputLabel || 'Total Input Tokens');
835
+ setText('session-total-cached-input-label', summary.totalCachedInputLabel || 'Total Cached Input Tokens');
836
+ setText('session-total-cache-write-label', summary.totalCacheWriteLabel || 'Total Cache Write Tokens');
691
837
  setText('session-model-turns-value', summary.modelTurns || '0');
692
838
  setText('session-tool-calls-value', summary.toolCalls || '0');
693
839
  setText('session-total-input-value', summary.totalInputTokens || '0');
694
840
  setText('session-total-output-value', summary.totalOutputTokens || '0');
695
841
  setText('session-total-cached-input-value', summary.totalCachedInputTokens || '0');
842
+ setText('session-total-cache-write-value', summary.totalCacheWriteTokens || '0');
696
843
  setText('session-total-tokens-value', summary.totalTokens || '0');
844
+ setText('session-estimated-ai-credits-value', summary.estimatedAiCredits || '-');
697
845
  setText('session-error-count-value', summary.errorCount || '0');
846
+ setText('session-billing-note', summary.billingNote || '');
847
+ setHTML('session-model-usage-breakdown', summary.modelUsageBreakdownHtml || '');
698
848
  }} catch (_error) {{
699
849
  return;
700
850
  }} finally {{
@@ -2123,31 +2273,65 @@ def render_session_list_markup(session_previews: list[SessionPreview]) -> str:
2123
2273
  return "\n".join(render_session_item(session) for session in session_previews)
2124
2274
 
2125
2275
 
2276
+ def build_session_preview_meta(session: SessionPreview) -> str:
2277
+ items: list[str] = []
2278
+ if session.repository:
2279
+ items.append(session.repository)
2280
+ if session.branch:
2281
+ items.append(session.branch)
2282
+ if session.model_name:
2283
+ items.append(session.model_name)
2284
+
2285
+ if session.updated_label:
2286
+ items.append(f"Updated {session.updated_label}")
2287
+
2288
+ return "".join(f'<span class="session-meta-item">{html.escape(item)}</span>' for item in items)
2289
+
2290
+
2126
2291
  def render_session_item(session: SessionPreview) -> str:
2127
2292
  title = html.escape(session.title)
2128
2293
  badge = '<span class="badge">Active</span>' if session.is_active else ""
2294
+ meta = build_session_preview_meta(session)
2129
2295
  return f"""
2130
- <li>
2296
+ <li data-session-id="{html.escape(session.session_id, quote=True)}">
2131
2297
  <a class="session-link" href="/sessions/{session.session_id}" aria-label="Open {title}">
2132
2298
  <div class="session-main">
2133
2299
  {chat_icon()}
2134
- <p class="session-title">{title}</p>
2300
+ <div class="session-copy">
2301
+ <p class="session-title">{title}</p>
2302
+ <div class="session-meta">{meta}</div>
2303
+ </div>
2135
2304
  </div>
2136
2305
  {badge}
2137
2306
  </a>
2138
2307
  </li>"""
2139
2308
 
2140
2309
 
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)
2310
+ def build_summary_stat_labels(summary: SessionSummary) -> dict[str, str]:
2311
+ if summary.status == "Active":
2312
+ return {
2313
+ "totalInputLabel": "Input Tokens (Finalized)",
2314
+ "totalCachedInputLabel": "Cached Input (Finalized)",
2315
+ "totalCacheWriteLabel": "Cache Write (Finalized)",
2316
+ }
2317
+ return {
2318
+ "totalInputLabel": "Total Input Tokens",
2319
+ "totalCachedInputLabel": "Total Cached Input Tokens",
2320
+ "totalCacheWriteLabel": "Total Cache Write Tokens",
2321
+ }
2322
+
2323
+
2324
+ def render_stat_card(label: str, value: int | str, value_id: str | None = None, label_id: str | None = None) -> str:
2325
+ return render_live_stat_card(label, value, value_id=value_id, label_id=label_id)
2143
2326
 
2144
2327
 
2145
- def render_live_stat_card(label: str, value: int, value_id: str | None = None) -> str:
2328
+ def render_live_stat_card(label: str, value: int | str, value_id: str | None = None, label_id: str | None = None) -> str:
2329
+ label_id_attr = f' id="{html.escape(label_id, quote=True)}"' if label_id else ''
2146
2330
  value_id_attr = f' id="{html.escape(value_id, quote=True)}"' if value_id else ''
2147
2331
  return f"""
2148
2332
  <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>
2333
+ <span class="stat-label"{label_id_attr}>{html.escape(label)}</span>
2334
+ <span class="stat-value"{value_id_attr}>{html.escape(format_stat_value(value))}</span>
2151
2335
  </article>"""
2152
2336
 
2153
2337
 
@@ -2195,6 +2379,7 @@ def render_empty_flow_state() -> str:
2195
2379
 
2196
2380
 
2197
2381
  def build_session_snapshot_payload(summary: SessionSummary) -> dict[str, str]:
2382
+ stat_labels = build_summary_stat_labels(summary)
2198
2383
  return {
2199
2384
  "detailMeta": build_session_detail_meta(summary),
2200
2385
  "sessionType": summary.session_type,
@@ -2202,12 +2387,19 @@ def build_session_snapshot_payload(summary: SessionSummary) -> dict[str, str]:
2202
2387
  "status": summary.status,
2203
2388
  "createdLabel": summary.created_label,
2204
2389
  "updatedLabel": summary.updated_label,
2390
+ "totalInputLabel": stat_labels["totalInputLabel"],
2391
+ "totalCachedInputLabel": stat_labels["totalCachedInputLabel"],
2392
+ "totalCacheWriteLabel": stat_labels["totalCacheWriteLabel"],
2205
2393
  "modelTurns": format_number(summary.model_turns),
2206
2394
  "toolCalls": format_number(summary.tool_calls),
2207
2395
  "totalInputTokens": format_number(summary.total_input_tokens),
2208
2396
  "totalOutputTokens": format_number(summary.total_output_tokens),
2209
2397
  "totalCachedInputTokens": format_number(summary.total_cached_input_tokens),
2398
+ "totalCacheWriteTokens": format_number(summary.total_cache_write_tokens),
2210
2399
  "totalTokens": format_number(summary.total_tokens),
2400
+ "estimatedAiCredits": format_ai_credits(summary.estimated_ai_credits),
2401
+ "billingNote": summary.billing_note,
2402
+ "modelUsageBreakdownHtml": render_model_usage_breakdown(summary),
2211
2403
  "errorCount": format_number(summary.error_count),
2212
2404
  }
2213
2405
 
@@ -2292,7 +2484,8 @@ def render_empty_log_list() -> str:
2292
2484
 
2293
2485
 
2294
2486
  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 '-'}"
2487
+ label = "Models" if len(summary.model_usages) > 1 else "Model"
2488
+ return f"{label}: {summary.models_used_label or 'Unknown'} · Repository: {summary.repository or '-'} · Branch: {summary.branch or '-'}"
2296
2489
 
2297
2490
 
2298
2491
  def chat_icon() -> str:
@@ -2303,3 +2496,52 @@ def chat_icon() -> str:
2303
2496
 
2304
2497
  def format_number(value: int) -> str:
2305
2498
  return f"{value:,}"
2499
+
2500
+
2501
+ def format_stat_value(value: int | str) -> str:
2502
+ return format_number(value) if isinstance(value, int) else value
2503
+
2504
+
2505
+ def format_ai_credits(value: Decimal | None) -> str:
2506
+ if value is None:
2507
+ return "-"
2508
+ return f"{value.quantize(Decimal('0.01')):,.2f}".rstrip("0").rstrip(".")
2509
+
2510
+
2511
+ def render_model_usage_breakdown(summary: SessionSummary) -> str:
2512
+ if not summary.model_usages:
2513
+ return '<div class="model-usage-empty">No per-model usage metrics available yet.</div>'
2514
+
2515
+ rows = "\n".join(
2516
+ f"""
2517
+ <tr>
2518
+ <td>{html.escape(item.model_name)}</td>
2519
+ <td>{format_number(item.input_tokens)}</td>
2520
+ <td>{format_number(item.cached_input_tokens)}</td>
2521
+ <td>{format_number(item.cache_write_tokens)}</td>
2522
+ <td>{format_number(item.output_tokens)}</td>
2523
+ <td>{format_number(item.total_tokens)}</td>
2524
+ <td>{html.escape(format_ai_credits(item.estimated_ai_credits))}</td>
2525
+ </tr>"""
2526
+ for item in summary.model_usages
2527
+ )
2528
+ return f"""
2529
+ <div class="model-usage-title">Model Usage Breakdown</div>
2530
+ <div class="model-usage-scroller">
2531
+ <table class="model-usage-table">
2532
+ <thead>
2533
+ <tr>
2534
+ <th>Model</th>
2535
+ <th>Input</th>
2536
+ <th>Cached Input</th>
2537
+ <th>Cache Write</th>
2538
+ <th>Output</th>
2539
+ <th>Total</th>
2540
+ <th>AIC</th>
2541
+ </tr>
2542
+ </thead>
2543
+ <tbody>
2544
+ {rows}
2545
+ </tbody>
2546
+ </table>
2547
+ </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.3.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
@@ -34,23 +34,25 @@ Render GitHub Copilot CLI agent debug logs into a clear, inspectable trace view.
34
34
  Run the app directly from the workspace with `uv`:
35
35
 
36
36
  ```bash
37
- uv run copilot-cli-trace-deck
37
+ uvx copilot-cli-trace-deck@latest
38
38
  ```
39
39
 
40
40
  By default the server listens on `http://127.0.0.1:9887` and opens that URL in your browser.
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
47
- uv run copilot-cli-trace-deck ~/.copilot/session-state --host 127.0.0.1 --port 9887
49
+ uvx copilot-cli-trace-deck ~/.copilot/session-state --host 127.0.0.1 --port 9887
48
50
  ```
49
51
 
50
52
  To skip opening a browser while still printing the local URL:
51
53
 
52
54
  ```bash
53
- uv run copilot-cli-trace-deck --quiet
55
+ uvx copilot-cli-trace-deck --quiet
54
56
  ```
55
57
 
56
58
  ## Install As A Command