scrollback 0.1.2__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 (100) hide show
  1. {scrollback-0.1.2 → scrollback-0.2.0}/CHANGELOG.md +22 -1
  2. {scrollback-0.1.2 → scrollback-0.2.0}/PKG-INFO +13 -3
  3. {scrollback-0.1.2 → scrollback-0.2.0}/README.md +12 -2
  4. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/__init__.py +1 -1
  5. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/cli.py +11 -0
  6. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/export.py +35 -0
  7. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/models.py +8 -1
  8. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/serialize.py +3 -0
  9. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/claudecode.py +37 -0
  10. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/codex.py +65 -0
  11. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/opencode.py +18 -9
  12. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/store.py +15 -0
  13. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/app.js +3 -1
  14. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_stats_resume.py +4 -0
  15. scrollback-0.2.0/tests/test_usage.py +266 -0
  16. {scrollback-0.1.2 → scrollback-0.2.0}/.github/workflows/ci.yml +0 -0
  17. {scrollback-0.1.2 → scrollback-0.2.0}/.github/workflows/publish.yml +0 -0
  18. {scrollback-0.1.2 → scrollback-0.2.0}/.gitignore +0 -0
  19. {scrollback-0.1.2 → scrollback-0.2.0}/CONTRIBUTING.md +0 -0
  20. {scrollback-0.1.2 → scrollback-0.2.0}/LICENSE +0 -0
  21. {scrollback-0.1.2 → scrollback-0.2.0}/ROADMAP.md +0 -0
  22. {scrollback-0.1.2 → scrollback-0.2.0}/assets/icon-512.png +0 -0
  23. {scrollback-0.1.2 → scrollback-0.2.0}/assets/icon.icns +0 -0
  24. {scrollback-0.1.2 → scrollback-0.2.0}/assets/icon.svg +0 -0
  25. {scrollback-0.1.2 → scrollback-0.2.0}/assets/screenshots/cli.png +0 -0
  26. {scrollback-0.1.2 → scrollback-0.2.0}/assets/screenshots/cli.svg +0 -0
  27. {scrollback-0.1.2 → scrollback-0.2.0}/assets/screenshots/web.png +0 -0
  28. {scrollback-0.1.2 → scrollback-0.2.0}/pyproject.toml +0 -0
  29. {scrollback-0.1.2 → scrollback-0.2.0}/scripts/demo_data.py +0 -0
  30. {scrollback-0.1.2 → scrollback-0.2.0}/scripts/screenshots.py +0 -0
  31. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/assets/icon-256.png +0 -0
  32. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/assets/icon.icns +0 -0
  33. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/clipboard.py +0 -0
  34. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/fts.py +0 -0
  35. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/highlight.py +0 -0
  36. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/katexbundle.py +0 -0
  37. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/launcher_install.py +0 -0
  38. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.bat +0 -0
  39. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.command +0 -0
  40. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.desktop +0 -0
  41. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.sh +0 -0
  42. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/mathspan.py +0 -0
  43. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/minimd.py +0 -0
  44. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/serverconfig.py +0 -0
  45. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/__init__.py +0 -0
  46. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/aider.py +0 -0
  47. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/base.py +0 -0
  48. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/sources/registry.py +0 -0
  49. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/termrender.py +0 -0
  50. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/__init__.py +0 -0
  51. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/app.py +0 -0
  52. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/apple-touch-icon.png +0 -0
  53. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/favicon.png +0 -0
  54. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/favicon.svg +0 -0
  55. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/index.html +0 -0
  56. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/style.css +0 -0
  57. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/highlight.min.js +0 -0
  58. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/hljs-dark.min.css +0 -0
  59. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/hljs-light.min.css +0 -0
  60. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  61. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  62. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  63. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  64. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  65. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  66. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  67. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  68. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  69. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  70. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  71. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  72. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  73. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  74. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  75. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  76. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  77. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  78. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  79. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  80. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/katex.min.css +0 -0
  81. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/katex.min.js +0 -0
  82. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/marked.min.js +0 -0
  83. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/web/static/vendor/purify.min.js +0 -0
  84. {scrollback-0.1.2 → scrollback-0.2.0}/src/scrollback/webopen.py +0 -0
  85. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_aider.py +0 -0
  86. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_claude_paging.py +0 -0
  87. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_claude_subagents.py +0 -0
  88. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_cli_helpers.py +0 -0
  89. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_codex.py +0 -0
  90. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_export.py +0 -0
  91. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_fts.py +0 -0
  92. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_highlight.py +0 -0
  93. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_launcher_install.py +0 -0
  94. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_minimd.py +0 -0
  95. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_models.py +0 -0
  96. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_serverconfig.py +0 -0
  97. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_sources_live.py +0 -0
  98. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_store_filters.py +0 -0
  99. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_web_api.py +0 -0
  100. {scrollback-0.1.2 → scrollback-0.2.0}/tests/test_webopen.py +0 -0
@@ -6,6 +6,26 @@ follow [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-06-30
10
+
11
+ ### Added
12
+
13
+ - **Cache & reasoning token accounting.** Sessions now carry
14
+ `tokens_cache_read`, `tokens_cache_write`, and `tokens_reasoning` in
15
+ addition to input/output/cost. In agentic sessions cache reads often
16
+ dominate total token volume, so this makes scrollback's usage numbers
17
+ reconcilable with the agents' own reports:
18
+ - **opencode** reads the corresponding SQLite columns (tolerant of older
19
+ databases that lack them).
20
+ - **Claude Code** now reports usage at all — summed per-turn from each
21
+ assistant message's `usage` block (previously blank).
22
+ - **Codex** parses token-count records where the rollout format includes
23
+ them (best-effort; `None` when absent).
24
+ - **Aider** has no token data on disk and stays `None`.
25
+ - `stats` shows a `cache` (read/write) line and a `reasoning` line; the web
26
+ transcript header shows a cache figure; Markdown/HTML/JSON exports include
27
+ a usage summary.
28
+
9
29
  ## [0.1.2] - 2026-06-30
10
30
 
11
31
  ### Added
@@ -100,7 +120,8 @@ export it from a CLI and a local web app.
100
120
  - Negative pagination arguments are rejected; clearer errors for unknown
101
121
  sources, failed exports, and unavailable data sources.
102
122
 
103
- [Unreleased]: https://github.com/a-attia/scrollback/compare/v0.1.2...HEAD
123
+ [Unreleased]: https://github.com/a-attia/scrollback/compare/v0.2.0...HEAD
124
+ [0.2.0]: https://github.com/a-attia/scrollback/compare/v0.1.2...v0.2.0
104
125
  [0.1.2]: https://github.com/a-attia/scrollback/compare/v0.1.1...v0.1.2
105
126
  [0.1.1]: https://github.com/a-attia/scrollback/compare/v0.1.0...v0.1.1
106
127
  [0.1.0]: https://github.com/a-attia/scrollback/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrollback
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Navigate, search, copy, and export your AI coding-agent sessions (opencode, Claude Code, ...) from one local, read-only tool.
5
5
  Project-URL: Homepage, https://github.com/a-attia/scrollback
6
6
  Project-URL: Repository, https://github.com/a-attia/scrollback
@@ -73,13 +73,13 @@ modifies, locks for writing, or uploads your data.
73
73
  You can use it two ways. From the **command line**, list, search, and export
74
74
  your sessions in a single scriptable tool:
75
75
 
76
- ![scrollback listing recent sessions in the terminal.](https://raw.githubusercontent.com/a-attia/scrollback/v0.1.2/assets/screenshots/cli.png)
76
+ ![scrollback listing recent sessions in the terminal.](https://raw.githubusercontent.com/a-attia/scrollback/v0.2.0/assets/screenshots/cli.png)
77
77
 
78
78
  Or open the **local web app** to read a transcript in full — with rendered
79
79
  Markdown, syntax-highlighted code, and typeset LaTeX math:
80
80
 
81
81
  ![The scrollback web app showing a session list beside a transcript with
82
- rendered Markdown, highlighted code, and typeset equations.](https://raw.githubusercontent.com/a-attia/scrollback/v0.1.2/assets/screenshots/web.png)
82
+ rendered Markdown, highlighted code, and typeset equations.](https://raw.githubusercontent.com/a-attia/scrollback/v0.2.0/assets/screenshots/web.png)
83
83
 
84
84
  Both views read the same on-disk session stores, so you can jump between
85
85
  them freely. (The screenshots above use synthetic demo data.)
@@ -235,6 +235,16 @@ busiest projects. `resume` prints the command to continue a session in its
235
235
  own agent (for example `opencode --session <id>` or `claude --resume <id>`),
236
236
  with a `cd` into the session's project directory.
237
237
 
238
+ **A note on token figures.** Where the source records it, scrollback reports
239
+ tokens in four buckets — *input*, *output*, *cache read*, and *cache write* —
240
+ because they mean different things and are priced very differently. In
241
+ agentic sessions the conversation context is re-sent every turn but served
242
+ from the prompt cache, so **cache reads usually dominate total volume** while
243
+ costing a fraction of fresh input. "Total tokens" is therefore not one
244
+ number; the cost figure (when available) is the most faithful summary of
245
+ consumption. Sources that don't record a given figure show it as blank
246
+ rather than a misleading zero.
247
+
238
248
  ## The web app
239
249
 
240
250
  `scrollback web` starts a local, read-only browser UI — FastAPI plus a
@@ -14,13 +14,13 @@ modifies, locks for writing, or uploads your data.
14
14
  You can use it two ways. From the **command line**, list, search, and export
15
15
  your sessions in a single scriptable tool:
16
16
 
17
- ![scrollback listing recent sessions in the terminal.](https://raw.githubusercontent.com/a-attia/scrollback/v0.1.2/assets/screenshots/cli.png)
17
+ ![scrollback listing recent sessions in the terminal.](https://raw.githubusercontent.com/a-attia/scrollback/v0.2.0/assets/screenshots/cli.png)
18
18
 
19
19
  Or open the **local web app** to read a transcript in full — with rendered
20
20
  Markdown, syntax-highlighted code, and typeset LaTeX math:
21
21
 
22
22
  ![The scrollback web app showing a session list beside a transcript with
23
- rendered Markdown, highlighted code, and typeset equations.](https://raw.githubusercontent.com/a-attia/scrollback/v0.1.2/assets/screenshots/web.png)
23
+ rendered Markdown, highlighted code, and typeset equations.](https://raw.githubusercontent.com/a-attia/scrollback/v0.2.0/assets/screenshots/web.png)
24
24
 
25
25
  Both views read the same on-disk session stores, so you can jump between
26
26
  them freely. (The screenshots above use synthetic demo data.)
@@ -176,6 +176,16 @@ busiest projects. `resume` prints the command to continue a session in its
176
176
  own agent (for example `opencode --session <id>` or `claude --resume <id>`),
177
177
  with a `cd` into the session's project directory.
178
178
 
179
+ **A note on token figures.** Where the source records it, scrollback reports
180
+ tokens in four buckets — *input*, *output*, *cache read*, and *cache write* —
181
+ because they mean different things and are priced very differently. In
182
+ agentic sessions the conversation context is re-sent every turn but served
183
+ from the prompt cache, so **cache reads usually dominate total volume** while
184
+ costing a fraction of fresh input. "Total tokens" is therefore not one
185
+ number; the cost figure (when available) is the most faithful summary of
186
+ consumption. Sources that don't record a given figure show it as blank
187
+ rather than a misleading zero.
188
+
179
189
  ## The web app
180
190
 
181
191
  `scrollback web` starts a local, read-only browser UI — FastAPI plus a
@@ -5,4 +5,4 @@ coding agents (opencode, Claude Code, ...) through a common adapter
5
5
  interface and lets you list, view, search, and export those sessions.
6
6
  """
7
7
 
8
- __version__ = "0.1.2"
8
+ __version__ = "0.2.0"
@@ -211,6 +211,9 @@ def cmd_stats(args: argparse.Namespace) -> int:
211
211
  "total_messages": st.total_messages,
212
212
  "total_tokens_input": st.total_tokens_input,
213
213
  "total_tokens_output": st.total_tokens_output,
214
+ "total_tokens_cache_read": st.total_tokens_cache_read,
215
+ "total_tokens_cache_write": st.total_tokens_cache_write,
216
+ "total_tokens_reasoning": st.total_tokens_reasoning,
214
217
  "total_cost": st.total_cost,
215
218
  "oldest": st.oldest.isoformat() if st.oldest else None,
216
219
  "newest": st.newest.isoformat() if st.newest else None,
@@ -228,6 +231,11 @@ def cmd_stats(args: argparse.Namespace) -> int:
228
231
  if st.total_tokens_input or st.total_tokens_output:
229
232
  print(f"tokens: {_fmt_tokens(st.total_tokens_input)} in / "
230
233
  f"{_fmt_tokens(st.total_tokens_output)} out")
234
+ if st.total_tokens_cache_read or st.total_tokens_cache_write:
235
+ print(f"cache: {_fmt_tokens(st.total_tokens_cache_read)} read / "
236
+ f"{_fmt_tokens(st.total_tokens_cache_write)} write")
237
+ if st.total_tokens_reasoning:
238
+ print(f"reasoning:{_fmt_tokens(st.total_tokens_reasoning)} tokens")
231
239
  if st.total_cost:
232
240
  print(f"cost: ${st.total_cost:.2f}")
233
241
  print()
@@ -339,6 +347,9 @@ def cmd_list(args: argparse.Namespace) -> int:
339
347
  "cost": s.cost,
340
348
  "tokens_input": s.tokens_input,
341
349
  "tokens_output": s.tokens_output,
350
+ "tokens_cache_read": s.tokens_cache_read,
351
+ "tokens_cache_write": s.tokens_cache_write,
352
+ "tokens_reasoning": s.tokens_reasoning,
342
353
  "parent_id": s.parent_id,
343
354
  "children": [row(c) for c in s.children],
344
355
  }
@@ -26,6 +26,35 @@ def _fmt_dt(dt: datetime | None) -> str:
26
26
  return dt.strftime("%Y-%m-%d %H:%M:%S %Z").strip() if dt else "?"
27
27
 
28
28
 
29
+ def _fmt_tokens(n: int | None) -> str:
30
+ if not n:
31
+ return "0"
32
+ if n < 1000:
33
+ return str(n)
34
+ if n < 1_000_000:
35
+ return f"{n / 1e3:.1f}k"
36
+ return f"{n / 1e6:.1f}M"
37
+
38
+
39
+ def _usage_summary(session: Session) -> str:
40
+ """A compact one-line usage string, or '' when the source reports none."""
41
+ s = session
42
+ if not any((s.tokens_input, s.tokens_output, s.tokens_cache_read,
43
+ s.tokens_cache_write, s.tokens_reasoning, s.cost)):
44
+ return ""
45
+ bits = []
46
+ if s.tokens_input or s.tokens_output:
47
+ bits.append(f"{_fmt_tokens(s.tokens_input)} in / {_fmt_tokens(s.tokens_output)} out")
48
+ if s.tokens_cache_read or s.tokens_cache_write:
49
+ bits.append(f"cache {_fmt_tokens(s.tokens_cache_read)} read / "
50
+ f"{_fmt_tokens(s.tokens_cache_write)} write")
51
+ if s.tokens_reasoning:
52
+ bits.append(f"{_fmt_tokens(s.tokens_reasoning)} reasoning")
53
+ if s.cost:
54
+ bits.append(f"${s.cost:.2f}")
55
+ return "; ".join(bits)
56
+
57
+
29
58
  # -- markdown --------------------------------------------------------------
30
59
 
31
60
 
@@ -50,6 +79,9 @@ def to_markdown(session: Session, *, include_reasoning: bool = True,
50
79
  lines.append(f"- **Created**: {_fmt_dt(session.created)}")
51
80
  lines.append(f"- **Updated**: {_fmt_dt(session.updated)}")
52
81
  lines.append(f"- **Messages**: {len(session.messages)}")
82
+ usage = _usage_summary(session)
83
+ if usage:
84
+ lines.append(f"- **Usage**: {usage}")
53
85
  lines.append("")
54
86
  lines.append("---")
55
87
  lines.append("")
@@ -211,6 +243,9 @@ def to_html(session: Session, *, include_reasoning: bool = True,
211
243
  meta_bits.append(f"model: {session.model}")
212
244
  meta_bits.append(f"created: {_fmt_dt(session.created)}")
213
245
  meta_bits.append(f"messages: {len(session.messages)}")
246
+ usage = _usage_summary(session)
247
+ if usage:
248
+ meta_bits.append(f"usage: {usage}")
214
249
  meta = " &middot; ".join(_html.escape(b) for b in meta_bits)
215
250
 
216
251
  body_parts: list[str] = []
@@ -111,10 +111,17 @@ class Session:
111
111
  agent: str | None = None
112
112
  parent_id: str | None = None
113
113
  message_count: int | None = None
114
- # Usage accounting (opencode tracks these; None when unknown).
114
+ # Usage accounting (None when the source does not report a given figure).
115
+ # `tokens_input` / `tokens_output` are fresh (uncached) prompt + generated
116
+ # tokens; the cache figures track prompt-cache reuse (large in agentic
117
+ # sessions and priced very differently); `tokens_reasoning` is the portion
118
+ # of output spent on hidden reasoning, where the source distinguishes it.
115
119
  cost: float | None = None
116
120
  tokens_input: int | None = None
117
121
  tokens_output: int | None = None
122
+ tokens_cache_read: int | None = None
123
+ tokens_cache_write: int | None = None
124
+ tokens_reasoning: int | None = None
118
125
  # Children populated when subagent folding is enabled.
119
126
  children: tuple["Session", ...] = ()
120
127
  messages: tuple[Message, ...] = ()
@@ -36,6 +36,9 @@ def session_summary(s: Session) -> dict[str, Any]:
36
36
  "cost": s.cost,
37
37
  "tokens_input": s.tokens_input,
38
38
  "tokens_output": s.tokens_output,
39
+ "tokens_cache_read": s.tokens_cache_read,
40
+ "tokens_cache_write": s.tokens_cache_write,
41
+ "tokens_reasoning": s.tokens_reasoning,
39
42
  "git_branch": (s.raw or {}).get("git_branch"),
40
43
  "children": [session_summary(c) for c in s.children],
41
44
  }
@@ -228,6 +228,7 @@ class ClaudeCodeSource(Source):
228
228
  message_count=meta["msg_count"],
229
229
  children=children,
230
230
  raw={"path": str(f), "git_branch": meta["git_branch"]},
231
+ **_usage_from_meta(meta),
231
232
  )
232
233
 
233
234
  def _subagent_summary(self, parent_path: Path, sub_path: Path) -> Session:
@@ -252,6 +253,7 @@ class ClaudeCodeSource(Source):
252
253
  parent_id=parent_id,
253
254
  message_count=(sm or {}).get("msg_count", 0),
254
255
  raw={"path": str(sub_path)},
256
+ **_usage_from_meta(sm or {}),
255
257
  )
256
258
 
257
259
  # -- single session -----------------------------------------------------
@@ -338,6 +340,7 @@ class ClaudeCodeSource(Source):
338
340
  message_count=meta["msg_count"],
339
341
  messages=(),
340
342
  raw={"path": str(path), "git_branch": meta["git_branch"]},
343
+ **_usage_from_meta(meta),
341
344
  )
342
345
 
343
346
  def load_messages(
@@ -411,6 +414,10 @@ def _scan_metadata(path: Path) -> dict[str, Any] | None:
411
414
  first_user_text: str | None = None
412
415
  msg_count = 0
413
416
  seen = False
417
+ # Usage accumulators. Claude Code records per-turn `usage` on assistant
418
+ # messages; summing across turns matches the agent's own session totals.
419
+ tok_in = tok_out = tok_cache_read = tok_cache_write = 0
420
+ any_usage = False
414
421
 
415
422
  for obj in _iter_lines(path):
416
423
  seen = True
@@ -439,6 +446,14 @@ def _scan_metadata(path: Path) -> dict[str, Any] | None:
439
446
  mv = m.get("model")
440
447
  if mv and mv != "<synthetic>":
441
448
  model = mv
449
+ if isinstance(m, dict):
450
+ u = m.get("usage")
451
+ if isinstance(u, dict):
452
+ any_usage = True
453
+ tok_in += _int(u.get("input_tokens"))
454
+ tok_out += _int(u.get("output_tokens"))
455
+ tok_cache_read += _int(u.get("cache_read_input_tokens"))
456
+ tok_cache_write += _int(u.get("cache_creation_input_tokens"))
442
457
  if (
443
458
  first_user_text is None
444
459
  and t == "user"
@@ -458,6 +473,27 @@ def _scan_metadata(path: Path) -> dict[str, Any] | None:
458
473
  "first_ts": first_ts,
459
474
  "last_ts": last_ts,
460
475
  "msg_count": msg_count,
476
+ # Only report usage when the transcript actually carried any, so
477
+ # sessions with no usage records stay None (not a misleading 0).
478
+ "tokens_input": tok_in if any_usage else None,
479
+ "tokens_output": tok_out if any_usage else None,
480
+ "tokens_cache_read": tok_cache_read if any_usage else None,
481
+ "tokens_cache_write": tok_cache_write if any_usage else None,
482
+ }
483
+
484
+
485
+ def _int(v: Any) -> int:
486
+ """Coerce a usage value to int, treating missing/garbage as 0."""
487
+ return v if isinstance(v, int) else 0
488
+
489
+
490
+ def _usage_from_meta(meta: dict[str, Any]) -> dict[str, Any]:
491
+ """Pull the usage fields out of a scan-metadata dict (all may be None)."""
492
+ return {
493
+ "tokens_input": meta.get("tokens_input"),
494
+ "tokens_output": meta.get("tokens_output"),
495
+ "tokens_cache_read": meta.get("tokens_cache_read"),
496
+ "tokens_cache_write": meta.get("tokens_cache_write"),
461
497
  }
462
498
 
463
499
 
@@ -560,6 +596,7 @@ def _parse_session(
560
596
  message_count=len(messages),
561
597
  messages=tuple(messages),
562
598
  raw={"path": str(path), "git_branch": meta["git_branch"]},
599
+ **_usage_from_meta(meta),
563
600
  )
564
601
 
565
602
 
@@ -118,6 +118,7 @@ class CodexSource(Source):
118
118
  model=meta["model"],
119
119
  message_count=meta["msg_count"],
120
120
  raw={"path": str(f)},
121
+ **_usage_from_meta(meta),
121
122
  )
122
123
 
123
124
  # -- single session -----------------------------------------------------
@@ -141,12 +142,23 @@ class CodexSource(Source):
141
142
  message_count=len(messages),
142
143
  messages=tuple(messages),
143
144
  raw={"path": str(path)},
145
+ **_usage_from_meta(meta),
144
146
  )
145
147
 
146
148
 
147
149
  # -- parsing helpers -------------------------------------------------------
148
150
 
149
151
 
152
+ def _usage_from_meta(meta: dict[str, Any]) -> dict[str, Any]:
153
+ """Pull usage fields out of a scan-metadata dict (all may be None)."""
154
+ return {
155
+ "tokens_input": meta.get("tokens_input"),
156
+ "tokens_output": meta.get("tokens_output"),
157
+ "tokens_cache_read": meta.get("tokens_cache_read"),
158
+ "tokens_cache_write": meta.get("tokens_cache_write"),
159
+ }
160
+
161
+
150
162
  def _iter_lines(path: Path) -> Iterator[dict[str, Any]]:
151
163
  try:
152
164
  with path.open("r", encoding="utf-8", errors="replace") as fh:
@@ -198,6 +210,7 @@ def _scan_meta(path: Path) -> dict[str, Any] | None:
198
210
  last_ts: str | int | None = None
199
211
  msg_count = 0
200
212
  seen = False
213
+ usage: dict[str, int] | None = None
201
214
 
202
215
  for obj in _iter_lines(path):
203
216
  seen = True
@@ -219,6 +232,12 @@ def _scan_meta(path: Path) -> dict[str, Any] | None:
219
232
  txt = _record_text(p).strip()
220
233
  if txt and not txt.startswith("<"):
221
234
  title = " ".join(txt.split())[:60]
235
+ # Best-effort usage: Codex emits token counts on some record types
236
+ # (e.g. a `token_count` event). Take the LAST cumulative reading we
237
+ # see. Absent in older/other formats -> usage stays None.
238
+ latest = _token_usage(obj, p)
239
+ if latest is not None:
240
+ usage = latest
222
241
 
223
242
  if not seen:
224
243
  return None
@@ -231,6 +250,52 @@ def _scan_meta(path: Path) -> dict[str, Any] | None:
231
250
  "first_ts": first_ts,
232
251
  "last_ts": last_ts,
233
252
  "msg_count": msg_count,
253
+ "tokens_input": (usage or {}).get("input"),
254
+ "tokens_output": (usage or {}).get("output"),
255
+ "tokens_cache_read": (usage or {}).get("cache_read"),
256
+ "tokens_cache_write": (usage or {}).get("cache_write"),
257
+ }
258
+
259
+
260
+ def _token_usage(obj: dict[str, Any], payload: dict[str, Any]) -> dict[str, int] | None:
261
+ """Best-effort extraction of a token-usage reading from a Codex record.
262
+
263
+ Codex's rollout format has changed across versions; this looks for a
264
+ `token_count` / `usage` object in a few known shapes and returns a
265
+ normalized {input, output, cache_read, cache_write} dict, or None. It is
266
+ intentionally forgiving: unknown shapes yield None rather than raising.
267
+ """
268
+ src = None
269
+ # A record whose own type is token_count/usage carries the fields inline.
270
+ if payload.get("type") in ("token_count", "usage"):
271
+ src = payload
272
+ else:
273
+ for cand in (payload.get("token_count"), payload.get("usage"),
274
+ obj.get("token_count"), obj.get("usage")):
275
+ if isinstance(cand, dict):
276
+ src = cand
277
+ break
278
+ if src is None:
279
+ return None
280
+ # Some versions nest the counts under an "info"/"total"/"last" object.
281
+ for nest in ("info", "total", "last"):
282
+ if isinstance(src.get(nest), dict):
283
+ src = src[nest]
284
+ break
285
+
286
+ def g(*keys: str) -> int:
287
+ for k in keys:
288
+ v = src.get(k)
289
+ if isinstance(v, int):
290
+ return v
291
+ return 0
292
+
293
+ read = g("cached_input_tokens", "cache_read_input_tokens", "cache_read")
294
+ return {
295
+ "input": g("input_tokens", "input", "prompt_tokens"),
296
+ "output": g("output_tokens", "output", "completion_tokens"),
297
+ "cache_read": read,
298
+ "cache_write": g("cache_creation_input_tokens", "cache_write"),
234
299
  }
235
300
 
236
301
 
@@ -80,12 +80,13 @@ class OpenCodeSource(Source):
80
80
  return self._list_sessions()
81
81
 
82
82
  def _list_sessions(self) -> Iterator[Session]:
83
+ # `SELECT s.*` + `_col` keeps us tolerant of schema drift: newer usage
84
+ # columns (cache/reasoning) are read when present and default to None
85
+ # on older opencode databases that lack them.
83
86
  with self._connect() as conn:
84
87
  rows = conn.execute(
85
88
  """
86
- SELECT s.id, s.title, s.directory, s.time_created,
87
- s.time_updated, s.model, s.agent, s.parent_id,
88
- s.cost, s.tokens_input, s.tokens_output,
89
+ SELECT s.*,
89
90
  (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id)
90
91
  AS msg_count
91
92
  FROM session s
@@ -104,9 +105,7 @@ class OpenCodeSource(Source):
104
105
  agent=r["agent"],
105
106
  parent_id=r["parent_id"],
106
107
  message_count=r["msg_count"],
107
- cost=r["cost"],
108
- tokens_input=r["tokens_input"],
109
- tokens_output=r["tokens_output"],
108
+ **_usage_from_row(r),
110
109
  )
111
110
 
112
111
  # -- single session -----------------------------------------------------
@@ -175,10 +174,8 @@ class OpenCodeSource(Source):
175
174
  agent=srow["agent"],
176
175
  parent_id=srow["parent_id"],
177
176
  message_count=message_count,
178
- cost=_col(srow, "cost"),
179
- tokens_input=_col(srow, "tokens_input"),
180
- tokens_output=_col(srow, "tokens_output"),
181
177
  messages=messages,
178
+ **_usage_from_row(srow),
182
179
  )
183
180
 
184
181
  # -- windowed loading ---------------------------------------------------
@@ -271,6 +268,18 @@ def _col(row: sqlite3.Row, key: str) -> Any:
271
268
  return None
272
269
 
273
270
 
271
+ def _usage_from_row(row: sqlite3.Row) -> dict[str, Any]:
272
+ """Extract the usage fields from a session row (missing columns -> None)."""
273
+ return {
274
+ "cost": _col(row, "cost"),
275
+ "tokens_input": _col(row, "tokens_input"),
276
+ "tokens_output": _col(row, "tokens_output"),
277
+ "tokens_cache_read": _col(row, "tokens_cache_read"),
278
+ "tokens_cache_write": _col(row, "tokens_cache_write"),
279
+ "tokens_reasoning": _col(row, "tokens_reasoning"),
280
+ }
281
+
282
+
274
283
  def _loads(s: str | None) -> dict[str, Any]:
275
284
  if not s:
276
285
  return {}
@@ -40,6 +40,9 @@ class Stats:
40
40
  total_messages: int
41
41
  total_tokens_input: int
42
42
  total_tokens_output: int
43
+ total_tokens_cache_read: int
44
+ total_tokens_cache_write: int
45
+ total_tokens_reasoning: int
43
46
  total_cost: float
44
47
  oldest: datetime | None
45
48
  newest: datetime | None
@@ -82,6 +85,9 @@ class Store:
82
85
  total_messages = 0
83
86
  total_tokens_in = 0
84
87
  total_tokens_out = 0
88
+ total_cache_read = 0
89
+ total_cache_write = 0
90
+ total_reasoning = 0
85
91
  total_cost = 0.0
86
92
  oldest: datetime | None = None
87
93
  newest: datetime | None = None
@@ -98,6 +104,12 @@ class Store:
98
104
  total_tokens_in += s.tokens_input
99
105
  if s.tokens_output:
100
106
  total_tokens_out += s.tokens_output
107
+ if s.tokens_cache_read:
108
+ total_cache_read += s.tokens_cache_read
109
+ if s.tokens_cache_write:
110
+ total_cache_write += s.tokens_cache_write
111
+ if s.tokens_reasoning:
112
+ total_reasoning += s.tokens_reasoning
101
113
  if s.cost:
102
114
  total_cost += s.cost
103
115
  when = s.updated or s.created
@@ -112,6 +124,9 @@ class Store:
112
124
  total_messages=total_messages,
113
125
  total_tokens_input=total_tokens_in,
114
126
  total_tokens_output=total_tokens_out,
127
+ total_tokens_cache_read=total_cache_read,
128
+ total_tokens_cache_write=total_cache_write,
129
+ total_tokens_reasoning=total_reasoning,
115
130
  total_cost=total_cost,
116
131
  oldest=oldest,
117
132
  newest=newest,
@@ -690,7 +690,9 @@ function renderHeader(meta) {
690
690
  copyId,
691
691
  meta.model ? el("span", {}, "model: " + meta.model) : null,
692
692
  meta.git_branch ? el("span", {}, "branch: " + meta.git_branch) : null,
693
- meta.tokens_input != null ? el("span", {}, `tokens ${fmtTokens(meta.tokens_input)}/${fmtTokens(meta.tokens_output)}`) : null,
693
+ meta.tokens_input != null ? el("span", { title: "input / output tokens" }, `tokens ${fmtTokens(meta.tokens_input)}/${fmtTokens(meta.tokens_output)}`) : null,
694
+ meta.tokens_cache_read != null && (meta.tokens_cache_read || meta.tokens_cache_write)
695
+ ? el("span", { title: "prompt cache read / write" }, `cache ${fmtTokens(meta.tokens_cache_read)}/${fmtTokens(meta.tokens_cache_write)}`) : null,
694
696
  el("span", {}, fmtDate(meta.created)),
695
697
  el("span", {}, `${meta.message_count} messages`),
696
698
  meta.directory ? el("span", {}, meta.directory) : null
@@ -59,6 +59,10 @@ def test_stats_aggregates():
59
59
  assert st.total_messages == 22
60
60
  assert st.total_tokens_input == 220
61
61
  assert st.total_tokens_output == 37
62
+ # These sessions carry no cache/reasoning usage, so the new totals are 0.
63
+ assert st.total_tokens_cache_read == 0
64
+ assert st.total_tokens_cache_write == 0
65
+ assert st.total_tokens_reasoning == 0
62
66
  assert abs(st.total_cost - 0.75) < 1e-9
63
67
  assert st.oldest.day == 1 and st.newest.day == 5
64
68
 
@@ -0,0 +1,266 @@
1
+ """Usage/token accounting across adapters, stats, and export.
2
+
3
+ Covers the input/output/cache-read/cache-write/reasoning fields end to end:
4
+ each adapter parses what its format exposes, sessions with no usage stay
5
+ None (not a misleading 0), Store.stats() sums them, and the export/summary
6
+ surfaces render them.
7
+ """
8
+
9
+ import json
10
+ import sqlite3
11
+ from pathlib import Path
12
+
13
+ from scrollback import export
14
+ from scrollback.models import Message, Part, Session
15
+ from scrollback.sources.claudecode import ClaudeCodeSource
16
+ from scrollback.sources.codex import CodexSource
17
+ from scrollback.sources.opencode import OpenCodeSource
18
+ from scrollback.store import Store
19
+
20
+
21
+ # -- opencode (SQLite columns) --------------------------------------------
22
+
23
+ def _make_opencode_db(path: Path, *, with_cache_cols: bool) -> None:
24
+ conn = sqlite3.connect(path)
25
+ cache_cols = (
26
+ ", tokens_cache_read INTEGER, tokens_cache_write INTEGER, "
27
+ "tokens_reasoning INTEGER"
28
+ if with_cache_cols else ""
29
+ )
30
+ conn.executescript(
31
+ f"""
32
+ CREATE TABLE session (
33
+ id TEXT PRIMARY KEY, project_id TEXT, parent_id TEXT,
34
+ directory TEXT, title TEXT, time_created INTEGER,
35
+ time_updated INTEGER, agent TEXT, model TEXT,
36
+ cost REAL, tokens_input INTEGER, tokens_output INTEGER{cache_cols}
37
+ );
38
+ CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT,
39
+ time_created INTEGER, data TEXT);
40
+ CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT,
41
+ session_id TEXT, time_created INTEGER, data TEXT);
42
+ """
43
+ )
44
+ if with_cache_cols:
45
+ conn.execute(
46
+ "INSERT INTO session (id, title, time_created, time_updated, cost, "
47
+ "tokens_input, tokens_output, tokens_cache_read, tokens_cache_write, "
48
+ "tokens_reasoning) VALUES (?,?,?,?,?,?,?,?,?,?)",
49
+ ("s1", "Heat eqn", 1000, 2000, 0.42, 1500, 3000, 250000, 40000, 800),
50
+ )
51
+ else:
52
+ conn.execute(
53
+ "INSERT INTO session (id, title, time_created, time_updated, cost, "
54
+ "tokens_input, tokens_output) VALUES (?,?,?,?,?,?,?)",
55
+ ("s1", "Heat eqn", 1000, 2000, 0.42, 1500, 3000),
56
+ )
57
+ conn.commit()
58
+ conn.close()
59
+
60
+
61
+ def test_opencode_reads_cache_columns(tmp_path):
62
+ db = tmp_path / "opencode.db"
63
+ _make_opencode_db(db, with_cache_cols=True)
64
+ src = OpenCodeSource(db_path=db)
65
+
66
+ listed = next(iter(src.list_sessions()))
67
+ assert listed.tokens_input == 1500
68
+ assert listed.tokens_output == 3000
69
+ assert listed.tokens_cache_read == 250000
70
+ assert listed.tokens_cache_write == 40000
71
+ assert listed.tokens_reasoning == 800
72
+
73
+ full = src.load_session("s1")
74
+ assert full.tokens_cache_read == 250000
75
+ assert full.tokens_cache_write == 40000
76
+
77
+
78
+ def test_opencode_tolerates_db_without_cache_columns(tmp_path):
79
+ # Older opencode DBs lack the cache columns; the adapter must not crash and
80
+ # should report them as None.
81
+ db = tmp_path / "opencode.db"
82
+ _make_opencode_db(db, with_cache_cols=False)
83
+ src = OpenCodeSource(db_path=db)
84
+
85
+ listed = next(iter(src.list_sessions()))
86
+ assert listed.tokens_input == 1500
87
+ assert listed.tokens_cache_read is None
88
+ assert listed.tokens_cache_write is None
89
+ assert listed.tokens_reasoning is None
90
+
91
+
92
+ # -- Claude Code (per-turn usage in JSONL) --------------------------------
93
+
94
+ def _cc_assistant(text, ts, sid, usage=None):
95
+ msg = {"role": "assistant", "model": "claude-x",
96
+ "content": [{"type": "text", "text": text}]}
97
+ if usage is not None:
98
+ msg["usage"] = usage
99
+ return {"type": "assistant", "timestamp": ts, "sessionId": sid, "message": msg}
100
+
101
+
102
+ def _cc_user(text, ts, sid):
103
+ return {"type": "user", "timestamp": ts, "sessionId": sid, "cwd": "/proj",
104
+ "message": {"role": "user", "content": text}}
105
+
106
+
107
+ def test_claudecode_sums_per_turn_usage(tmp_path):
108
+ root = tmp_path / "projects" # adapter root is the `projects/` dir
109
+ sid = "aaaaaaaa-1111-2222-3333-444444444444"
110
+ f = root / "-proj" / f"{sid}.jsonl"
111
+ f.parent.mkdir(parents=True)
112
+ rows = [
113
+ _cc_user("q1", "2026-03-14T09:30:00Z", sid),
114
+ _cc_assistant("a1", "2026-03-14T09:30:05Z", sid, usage={
115
+ "input_tokens": 100, "output_tokens": 200,
116
+ "cache_read_input_tokens": 5000, "cache_creation_input_tokens": 300,
117
+ }),
118
+ _cc_user("q2", "2026-03-14T09:31:00Z", sid),
119
+ _cc_assistant("a2", "2026-03-14T09:31:05Z", sid, usage={
120
+ "input_tokens": 50, "output_tokens": 400,
121
+ "cache_read_input_tokens": 8000, "cache_creation_input_tokens": 100,
122
+ }),
123
+ ]
124
+ with f.open("w") as fh:
125
+ for r in rows:
126
+ fh.write(json.dumps(r) + "\n")
127
+
128
+ src = ClaudeCodeSource(root=root)
129
+ s = next(iter(src.list_sessions()))
130
+ assert s.tokens_input == 150 # 100 + 50
131
+ assert s.tokens_output == 600 # 200 + 400
132
+ assert s.tokens_cache_read == 13000 # 5000 + 8000
133
+ assert s.tokens_cache_write == 400 # 300 + 100
134
+
135
+ # load_session path agrees with the listing path.
136
+ full = src.load_session(s.id)
137
+ assert full.tokens_cache_read == 13000
138
+
139
+
140
+ def test_claudecode_usage_none_when_absent(tmp_path):
141
+ root = tmp_path / "projects"
142
+ sid = "bbbbbbbb-1111-2222-3333-444444444444"
143
+ f = root / "-proj" / f"{sid}.jsonl"
144
+ f.parent.mkdir(parents=True)
145
+ with f.open("w") as fh:
146
+ fh.write(json.dumps(_cc_user("hi", "2026-03-14T09:30:00Z", sid)) + "\n")
147
+ fh.write(json.dumps(_cc_assistant("yo", "2026-03-14T09:30:05Z", sid)) + "\n")
148
+
149
+ s = next(iter(ClaudeCodeSource(root=root).list_sessions()))
150
+ assert s.tokens_input is None
151
+ assert s.tokens_cache_read is None
152
+
153
+
154
+ # -- Codex (best-effort; None when the format carries no usage) ------------
155
+
156
+ def _codex_rollout(tmp_path: Path, rows: list[dict]) -> Path:
157
+ d = tmp_path / "sessions" / "2025" / "01" / "31"
158
+ d.mkdir(parents=True)
159
+ f = d / "rollout-2025-01-31T12-34-56-abcd1234-ef56-7890-abcd-ef1234567890.jsonl"
160
+ f.write_text("\n".join(json.dumps(r) for r in rows) + "\n")
161
+ return f
162
+
163
+
164
+ def test_codex_usage_none_without_token_records(tmp_path):
165
+ _codex_rollout(tmp_path, [
166
+ {"type": "session_meta", "timestamp": "2025-01-31T12:34:56Z",
167
+ "cwd": "/proj", "model": "gpt-5-codex"},
168
+ {"type": "response_item", "timestamp": "2025-01-31T12:35:00Z",
169
+ "payload": {"type": "message", "role": "user",
170
+ "content": [{"type": "input_text", "text": "hi"}]}},
171
+ ])
172
+ s = next(iter(CodexSource(root=tmp_path / "sessions").list_sessions()))
173
+ assert s.tokens_input is None
174
+ assert s.tokens_cache_read is None
175
+
176
+
177
+ def test_codex_parses_token_count_when_present(tmp_path):
178
+ _codex_rollout(tmp_path, [
179
+ {"type": "session_meta", "timestamp": "2025-01-31T12:34:56Z",
180
+ "cwd": "/proj", "model": "gpt-5-codex"},
181
+ {"type": "response_item", "timestamp": "2025-01-31T12:35:00Z",
182
+ "payload": {"type": "message", "role": "user",
183
+ "content": [{"type": "input_text", "text": "hi"}]}},
184
+ {"type": "event_msg", "timestamp": "2025-01-31T12:35:10Z",
185
+ "payload": {"type": "token_count", "input_tokens": 900,
186
+ "output_tokens": 120, "cached_input_tokens": 4000}},
187
+ ])
188
+ s = next(iter(CodexSource(root=tmp_path / "sessions").list_sessions()))
189
+ assert s.tokens_input == 900
190
+ assert s.tokens_output == 120
191
+ assert s.tokens_cache_read == 4000
192
+
193
+
194
+ # -- stats aggregation -----------------------------------------------------
195
+
196
+ class _FakeSource:
197
+ name = "fake"
198
+ label = "Fake"
199
+
200
+ def __init__(self, sessions):
201
+ self._s = sessions
202
+
203
+ def is_available(self):
204
+ return True
205
+
206
+ def location(self):
207
+ return Path("/tmp/fake")
208
+
209
+ def list_sessions(self):
210
+ return iter(self._s)
211
+
212
+ def load_session(self, sid):
213
+ return next((s for s in self._s if s.id == sid), None)
214
+
215
+
216
+ def _sess(sid, **usage):
217
+ from datetime import datetime, timezone
218
+ dt = datetime(2026, 3, 14, tzinfo=timezone.utc)
219
+ return Session(id=sid, source="fake", title=sid, directory=None,
220
+ created=dt, updated=dt, message_count=1, **usage)
221
+
222
+
223
+ def test_stats_sums_cache_and_reasoning():
224
+ store = Store([_FakeSource([
225
+ _sess("a", tokens_input=100, tokens_output=200,
226
+ tokens_cache_read=1000, tokens_cache_write=50, tokens_reasoning=10),
227
+ _sess("b", tokens_input=5, tokens_output=7,
228
+ tokens_cache_read=2000, tokens_cache_write=25, tokens_reasoning=3),
229
+ _sess("c"), # no usage -> contributes nothing
230
+ ])])
231
+ st = store.stats()
232
+ assert st.total_tokens_input == 105
233
+ assert st.total_tokens_output == 207
234
+ assert st.total_tokens_cache_read == 3000
235
+ assert st.total_tokens_cache_write == 75
236
+ assert st.total_tokens_reasoning == 13
237
+
238
+
239
+ # -- export surfacing ------------------------------------------------------
240
+
241
+ def _msg():
242
+ return Message(id="m", role="assistant", created=None,
243
+ parts=(Part(id="p", type="text", text="hi"),))
244
+
245
+
246
+ def test_export_usage_summary_in_markdown_and_html():
247
+ s = Session(id="s", source="opencode", title="t", directory=None,
248
+ created=None, updated=None, messages=(_msg(),), message_count=1,
249
+ tokens_input=1500, tokens_output=3000,
250
+ tokens_cache_read=250000, tokens_cache_write=40000, cost=0.42)
251
+ md = export.to_markdown(s)
252
+ assert "**Usage**" in md
253
+ assert "1.5k in / 3.0k out" in md
254
+ assert "cache 250.0k read / 40.0k write" in md
255
+ assert "$0.42" in md
256
+
257
+ html = export.to_html(s)
258
+ assert "usage:" in html
259
+ assert "250.0k read" in html
260
+
261
+
262
+ def test_export_no_usage_line_when_absent():
263
+ s = Session(id="s", source="aider", title="t", directory=None,
264
+ created=None, updated=None, messages=(_msg(),), message_count=1)
265
+ assert "**Usage**" not in export.to_markdown(s)
266
+ assert "usage:" not in export.to_html(s)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes