scrollback 0.1.1__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.1 → scrollback-0.2.0}/CHANGELOG.md +33 -1
  2. {scrollback-0.1.1 → scrollback-0.2.0}/PKG-INFO +20 -3
  3. {scrollback-0.1.1 → scrollback-0.2.0}/README.md +19 -2
  4. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/__init__.py +1 -1
  5. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/cli.py +91 -0
  6. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/export.py +35 -0
  7. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/launcher_install.py +40 -0
  8. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/models.py +8 -1
  9. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/serialize.py +3 -0
  10. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/claudecode.py +37 -0
  11. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/codex.py +65 -0
  12. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/opencode.py +18 -9
  13. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/store.py +15 -0
  14. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/app.js +3 -1
  15. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_launcher_install.py +26 -0
  16. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_stats_resume.py +4 -0
  17. scrollback-0.2.0/tests/test_usage.py +266 -0
  18. {scrollback-0.1.1 → scrollback-0.2.0}/.github/workflows/ci.yml +0 -0
  19. {scrollback-0.1.1 → scrollback-0.2.0}/.github/workflows/publish.yml +0 -0
  20. {scrollback-0.1.1 → scrollback-0.2.0}/.gitignore +0 -0
  21. {scrollback-0.1.1 → scrollback-0.2.0}/CONTRIBUTING.md +0 -0
  22. {scrollback-0.1.1 → scrollback-0.2.0}/LICENSE +0 -0
  23. {scrollback-0.1.1 → scrollback-0.2.0}/ROADMAP.md +0 -0
  24. {scrollback-0.1.1 → scrollback-0.2.0}/assets/icon-512.png +0 -0
  25. {scrollback-0.1.1 → scrollback-0.2.0}/assets/icon.icns +0 -0
  26. {scrollback-0.1.1 → scrollback-0.2.0}/assets/icon.svg +0 -0
  27. {scrollback-0.1.1 → scrollback-0.2.0}/assets/screenshots/cli.png +0 -0
  28. {scrollback-0.1.1 → scrollback-0.2.0}/assets/screenshots/cli.svg +0 -0
  29. {scrollback-0.1.1 → scrollback-0.2.0}/assets/screenshots/web.png +0 -0
  30. {scrollback-0.1.1 → scrollback-0.2.0}/pyproject.toml +0 -0
  31. {scrollback-0.1.1 → scrollback-0.2.0}/scripts/demo_data.py +0 -0
  32. {scrollback-0.1.1 → scrollback-0.2.0}/scripts/screenshots.py +0 -0
  33. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/assets/icon-256.png +0 -0
  34. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/assets/icon.icns +0 -0
  35. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/clipboard.py +0 -0
  36. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/fts.py +0 -0
  37. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/highlight.py +0 -0
  38. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/katexbundle.py +0 -0
  39. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.bat +0 -0
  40. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.command +0 -0
  41. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.desktop +0 -0
  42. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/launchers/scrollback.sh +0 -0
  43. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/mathspan.py +0 -0
  44. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/minimd.py +0 -0
  45. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/serverconfig.py +0 -0
  46. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/__init__.py +0 -0
  47. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/aider.py +0 -0
  48. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/base.py +0 -0
  49. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/sources/registry.py +0 -0
  50. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/termrender.py +0 -0
  51. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/__init__.py +0 -0
  52. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/app.py +0 -0
  53. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/apple-touch-icon.png +0 -0
  54. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/favicon.png +0 -0
  55. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/favicon.svg +0 -0
  56. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/index.html +0 -0
  57. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/style.css +0 -0
  58. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/highlight.min.js +0 -0
  59. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/hljs-dark.min.css +0 -0
  60. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/hljs-light.min.css +0 -0
  61. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  62. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  63. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  64. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  65. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  66. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  67. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  68. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  69. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  70. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  71. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  72. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  73. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  74. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  75. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  76. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  77. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  78. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  79. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  80. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  81. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/katex.min.css +0 -0
  82. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/katex/katex.min.js +0 -0
  83. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/marked.min.js +0 -0
  84. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/web/static/vendor/purify.min.js +0 -0
  85. {scrollback-0.1.1 → scrollback-0.2.0}/src/scrollback/webopen.py +0 -0
  86. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_aider.py +0 -0
  87. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_claude_paging.py +0 -0
  88. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_claude_subagents.py +0 -0
  89. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_cli_helpers.py +0 -0
  90. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_codex.py +0 -0
  91. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_export.py +0 -0
  92. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_fts.py +0 -0
  93. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_highlight.py +0 -0
  94. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_minimd.py +0 -0
  95. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_models.py +0 -0
  96. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_serverconfig.py +0 -0
  97. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_sources_live.py +0 -0
  98. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_store_filters.py +0 -0
  99. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_web_api.py +0 -0
  100. {scrollback-0.1.1 → scrollback-0.2.0}/tests/test_webopen.py +0 -0
@@ -6,6 +6,36 @@ 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
+
29
+ ## [0.1.2] - 2026-06-30
30
+
31
+ ### Added
32
+
33
+ - `scrollback uninstall`: removes the artifacts scrollback created (Desktop
34
+ launcher, macOS `.app`, optional search index, launcher log) with a
35
+ confirmation prompt (`--yes` / `--dry-run`). It never touches agent data
36
+ and never self-removes the package; it prints the right `pip`/`pipx
37
+ uninstall` command instead.
38
+
9
39
  ## [0.1.1] - 2026-06-30
10
40
 
11
41
  ### Fixed
@@ -90,6 +120,8 @@ export it from a CLI and a local web app.
90
120
  - Negative pagination arguments are rejected; clearer errors for unknown
91
121
  sources, failed exports, and unavailable data sources.
92
122
 
93
- [Unreleased]: https://github.com/a-attia/scrollback/compare/v0.1.1...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
125
+ [0.1.2]: https://github.com/a-attia/scrollback/compare/v0.1.1...v0.1.2
94
126
  [0.1.1]: https://github.com/a-attia/scrollback/compare/v0.1.0...v0.1.1
95
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.1
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.1/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.1/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
@@ -299,6 +309,13 @@ window closes. All of this behaviour is decided in Python, so the launcher
299
309
  scripts stay free of OS-specific assumptions and ship inside the package
300
310
  for `pip install` users.
301
311
 
312
+ To clean up, `scrollback uninstall` removes the artifacts scrollback
313
+ created — the launchers, the macOS `.app`, the optional search index, and the
314
+ launcher log — after a confirmation (`--yes` to skip it, `--dry-run` to
315
+ preview). It never touches your agent data, and it does not remove the Python
316
+ package itself: it prints the right `pip`/`pipx uninstall` command to finish
317
+ the job (a program can't reliably uninstall the package it is running from).
318
+
302
319
  ## Fast search (optional index)
303
320
 
304
321
  By default, search is a lexical scan over your live data: zero setup,
@@ -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.1/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.1/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
@@ -240,6 +250,13 @@ window closes. All of this behaviour is decided in Python, so the launcher
240
250
  scripts stay free of OS-specific assumptions and ship inside the package
241
251
  for `pip install` users.
242
252
 
253
+ To clean up, `scrollback uninstall` removes the artifacts scrollback
254
+ created — the launchers, the macOS `.app`, the optional search index, and the
255
+ launcher log — after a confirmation (`--yes` to skip it, `--dry-run` to
256
+ preview). It never touches your agent data, and it does not remove the Python
257
+ package itself: it prints the right `pip`/`pipx uninstall` command to finish
258
+ the job (a program can't reliably uninstall the package it is running from).
259
+
243
260
  ## Fast search (optional index)
244
261
 
245
262
  By default, search is a lexical scan over your live data: zero setup,
@@ -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.1"
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
  }
@@ -844,6 +855,72 @@ def cmd_install_launcher(args: argparse.Namespace) -> int:
844
855
  return 0
845
856
 
846
857
 
858
+ def _detect_install_tool() -> str:
859
+ """Best-effort guess of how scrollback was installed, for the hint.
860
+
861
+ Returns the command the user should run to remove the package itself.
862
+ We never run it: a process cannot reliably uninstall the package it is
863
+ executing from, and the right tool (pip / pipx / conda) depends on how
864
+ it was installed.
865
+ """
866
+ exe = (sys.executable or "").replace("\\", "/")
867
+ if "/pipx/" in exe or "/.local/pipx/" in exe:
868
+ return "pipx uninstall scrollback"
869
+ return "pip uninstall scrollback"
870
+
871
+
872
+ def cmd_uninstall(args: argparse.Namespace) -> int:
873
+ """Remove scrollback-created artifacts; explain how to remove the package.
874
+
875
+ Removes only files scrollback itself created (launchers, the macOS .app,
876
+ the optional search index, the launcher log). It never touches your agent
877
+ data, and it never tries to uninstall the Python package -- that is left
878
+ to pip/pipx, with the exact command printed at the end.
879
+ """
880
+ from . import fts, launcher_install
881
+
882
+ targets: list = list(launcher_install.installed_artifacts())
883
+ index_path = fts.default_index_path()
884
+ if index_path.exists():
885
+ targets.append(index_path)
886
+
887
+ if not targets:
888
+ _eprint("no scrollback-created artifacts found.")
889
+ _eprint(f"to remove the package itself, run:\n {_detect_install_tool()}")
890
+ return 0
891
+
892
+ label = "would remove" if args.dry_run else "about to remove"
893
+ _eprint(f"{label}:")
894
+ for p in targets:
895
+ _eprint(f" {p}")
896
+
897
+ if args.dry_run:
898
+ return 0
899
+
900
+ if not args.yes:
901
+ try:
902
+ reply = input("remove these? [y/N] ").strip().lower()
903
+ except (EOFError, KeyboardInterrupt):
904
+ reply = ""
905
+ if reply not in ("y", "yes"):
906
+ _eprint("aborted; nothing removed.")
907
+ return 1
908
+
909
+ removed, failed = 0, 0
910
+ for p in targets:
911
+ try:
912
+ launcher_install.remove_path(p)
913
+ _eprint(f"removed {p}")
914
+ removed += 1
915
+ except OSError as exc:
916
+ _eprint(f"could not remove {p}: {exc}")
917
+ failed += 1
918
+
919
+ _eprint(f"\nremoved {removed} item(s)" + (f", {failed} failed" if failed else ""))
920
+ _eprint(f"to remove the package itself, run:\n {_detect_install_tool()}")
921
+ return 1 if failed else 0
922
+
923
+
847
924
  class _AppBridge:
848
925
  """JS<->Python API exposed to the pywebview window.
849
926
 
@@ -1093,6 +1170,20 @@ def build_parser() -> argparse.ArgumentParser:
1093
1170
  "falls back to the Desktop launcher elsewhere)")
1094
1171
  sp.set_defaults(func=cmd_install_launcher)
1095
1172
 
1173
+ # uninstall
1174
+ sp = sub.add_parser(
1175
+ "uninstall",
1176
+ help="remove scrollback-created artifacts (launchers, app, index)",
1177
+ description="Remove files scrollback created (Desktop launcher, macOS "
1178
+ ".app, search index, launcher log). Your agent data is never "
1179
+ "touched. The Python package itself is removed with pip/pipx "
1180
+ "-- the exact command is printed at the end.",
1181
+ )
1182
+ sp.add_argument("-y", "--yes", action="store_true", help="skip the confirmation prompt")
1183
+ sp.add_argument("--dry-run", action="store_true",
1184
+ help="show what would be removed, then exit")
1185
+ sp.set_defaults(func=cmd_uninstall)
1186
+
1096
1187
  return p
1097
1188
 
1098
1189
 
@@ -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] = []
@@ -207,3 +207,43 @@ def _install_linux(dest: Path | None) -> list[Path]:
207
207
  _write(sh, _read_bundled("scrollback.sh"), executable=True)
208
208
  created.append(sh)
209
209
  return created
210
+
211
+
212
+ # -- uninstall: locate + remove the artifacts install creates --------------
213
+
214
+ def installed_artifacts() -> list[Path]:
215
+ """Return the launcher/app/log paths scrollback may have created.
216
+
217
+ Covers the default install locations on every platform (a custom
218
+ ``--dest`` is not tracked, so those must be removed by hand). Only paths
219
+ that currently exist are returned. Never includes the user's agent data.
220
+ """
221
+ home = Path.home()
222
+ candidates: list[Path] = []
223
+
224
+ if sys.platform == "darwin":
225
+ candidates += [
226
+ _desktop_dir() / "scrollback.command",
227
+ home / "Applications" / "scrollback.app",
228
+ home / "Library" / "Logs" / "scrollback-launcher.log",
229
+ ]
230
+ elif sys.platform == "win32":
231
+ candidates += [_desktop_dir() / "scrollback.bat"]
232
+ else:
233
+ apps = Path(os.environ.get("XDG_DATA_HOME", home / ".local" / "share")) / "applications"
234
+ candidates += [
235
+ apps / "scrollback.desktop",
236
+ _desktop_dir() / "scrollback.sh",
237
+ ]
238
+
239
+ return [p for p in candidates if p.exists()]
240
+
241
+
242
+ def remove_path(path: Path) -> None:
243
+ """Delete a file or directory (used to remove a .app bundle)."""
244
+ import shutil
245
+
246
+ if path.is_dir() and not path.is_symlink():
247
+ shutil.rmtree(path)
248
+ else:
249
+ path.unlink()
@@ -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 {}