cli-consumption 0.0.1__tar.gz → 0.0.2__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 (46) hide show
  1. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.github/workflows/release.yaml +37 -10
  2. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/CONTRIBUTING.md +4 -0
  3. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/PKG-INFO +4 -4
  4. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/README.md +3 -3
  5. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/docs/architecture.md +3 -2
  6. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/docs/privacy.md +2 -1
  7. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/docs/roadmap.md +1 -1
  8. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/pyproject.toml +1 -1
  9. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/cli.py +14 -3
  10. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/dashboard.py +31 -20
  11. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/tests/test_cli.py +38 -2
  12. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/tests/test_storage_and_exports.py +7 -0
  13. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/uv.lock +1 -1
  14. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/add-cli-adapter/SKILL.md +0 -0
  15. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/add-cli-adapter/agents/openai.yaml +0 -0
  16. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/audit-usage-privacy/SKILL.md +0 -0
  17. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/audit-usage-privacy/agents/openai.yaml +0 -0
  18. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/evolve-storage-schema/SKILL.md +0 -0
  19. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/evolve-storage-schema/agents/openai.yaml +0 -0
  20. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/yeet-github/SKILL.md +0 -0
  21. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/yeet-github/agents/openai.yaml +0 -0
  22. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/yolo/SKILL.md +0 -0
  23. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.agents/skills/yolo/agents/openai.yaml +0 -0
  24. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.github/workflows/ci.yml +0 -0
  25. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.gitignore +0 -0
  26. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.pre-commit-config.yaml +0 -0
  27. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/.python-version +0 -0
  28. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/AGENTS.md +0 -0
  29. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/LICENSE +0 -0
  30. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/NOTICE +0 -0
  31. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/docs/provider-support.md +0 -0
  32. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/__init__.py +0 -0
  33. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/__main__.py +0 -0
  34. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/adapters/__init__.py +0 -0
  35. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/adapters/base.py +0 -0
  36. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/adapters/codex.py +0 -0
  37. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/api.py +0 -0
  38. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/exporting.py +0 -0
  39. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/models.py +0 -0
  40. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/py.typed +0 -0
  41. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/storage.py +0 -0
  42. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/src/cli_consumption/sync.py +0 -0
  43. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/tests/conftest.py +0 -0
  44. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/tests/test_api.py +0 -0
  45. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/tests/test_codex_adapter.py +0 -0
  46. {cli_consumption-0.0.1 → cli_consumption-0.0.2}/tests/test_sync.py +0 -0
@@ -4,6 +4,12 @@ on:
4
4
  push:
5
5
  branches: [main]
6
6
  paths: [pyproject.toml]
7
+ workflow_dispatch:
8
+ inputs:
9
+ release_sha:
10
+ description: Commit on main where the version first appeared
11
+ required: true
12
+ type: string
7
13
 
8
14
  concurrency:
9
15
  group: release
@@ -16,32 +22,49 @@ jobs:
16
22
  contents: read
17
23
  outputs:
18
24
  changed: ${{ steps.version.outputs.changed }}
25
+ release-sha: ${{ steps.version.outputs.release-sha }}
19
26
  version: ${{ steps.version.outputs.version }}
20
27
  steps:
21
28
  - uses: actions/checkout@v4
22
29
  with:
23
30
  fetch-depth: 0
24
- - uses: astral-sh/setup-uv@v6
25
- with:
26
- python-version: "3.14"
31
+ ref: ${{ inputs.release_sha || github.sha }}
27
32
  - name: Detect project version change
28
33
  id: version
29
34
  env:
30
35
  BEFORE_SHA: ${{ github.event.before }}
36
+ EVENT_NAME: ${{ github.event_name }}
31
37
  run: |
32
38
  set -euo pipefail
33
39
 
34
- current_version="$(uv version --short)"
40
+ release_commit="$(git rev-parse HEAD)"
41
+ if ! git merge-base --is-ancestor "${release_commit}" origin/main; then
42
+ echo "Release commit ${release_commit} is not on main." >&2
43
+ exit 1
44
+ fi
45
+
46
+ current_version="$(
47
+ python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])'
48
+ )"
35
49
  previous_version=""
36
- if git cat-file -e "${BEFORE_SHA}:pyproject.toml" 2>/dev/null; then
50
+ comparison_sha="${BEFORE_SHA}"
51
+ if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
52
+ comparison_sha="${release_commit}^"
53
+ fi
54
+ if git cat-file -e "${comparison_sha}:pyproject.toml" 2>/dev/null; then
37
55
  previous_version="$(
38
- git show "${BEFORE_SHA}:pyproject.toml" |
39
- python -c 'import sys, tomllib; print(tomllib.load(sys.stdin.buffer)["project"]["version"])'
56
+ git show "${comparison_sha}:pyproject.toml" |
57
+ python3 -c 'import sys, tomllib; print(tomllib.load(sys.stdin.buffer)["project"]["version"])'
40
58
  )"
41
59
  fi
42
60
 
61
+ echo "release-sha=${release_commit}" >> "${GITHUB_OUTPUT}"
43
62
  echo "version=${current_version}" >> "${GITHUB_OUTPUT}"
44
63
  if [[ "${current_version}" == "${previous_version}" ]]; then
64
+ if [[ "${EVENT_NAME}" == "workflow_dispatch" ]]; then
65
+ echo "Version ${current_version} did not first appear in ${release_commit}." >&2
66
+ exit 1
67
+ fi
45
68
  echo "changed=false" >> "${GITHUB_OUTPUT}"
46
69
  else
47
70
  echo "changed=true" >> "${GITHUB_OUTPUT}"
@@ -55,6 +78,8 @@ jobs:
55
78
  contents: read
56
79
  steps:
57
80
  - uses: actions/checkout@v4
81
+ with:
82
+ ref: ${{ needs.detect-version.outputs.release-sha }}
58
83
  - uses: astral-sh/setup-uv@v6
59
84
  with:
60
85
  enable-cache: true
@@ -83,8 +108,10 @@ jobs:
83
108
  - uses: actions/checkout@v4
84
109
  with:
85
110
  fetch-depth: 0
111
+ ref: ${{ needs.detect-version.outputs.release-sha }}
86
112
  - name: Tag version commit
87
113
  env:
114
+ RELEASE_SHA: ${{ needs.detect-version.outputs.release-sha }}
88
115
  RELEASE_VERSION: ${{ needs.detect-version.outputs.version }}
89
116
  run: |
90
117
  set -euo pipefail
@@ -93,14 +120,14 @@ jobs:
93
120
  git fetch --tags origin
94
121
  if git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then
95
122
  tagged_commit="$(git rev-list -n 1 "${tag}")"
96
- if [[ "${tagged_commit}" != "${GITHUB_SHA}" ]]; then
97
- echo "Tag ${tag} already points to ${tagged_commit}, not ${GITHUB_SHA}." >&2
123
+ if [[ "${tagged_commit}" != "${RELEASE_SHA}" ]]; then
124
+ echo "Tag ${tag} already points to ${tagged_commit}, not ${RELEASE_SHA}." >&2
98
125
  exit 1
99
126
  fi
100
127
  else
101
128
  git config user.name "github-actions[bot]"
102
129
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
103
- git tag --annotate "${tag}" --message "Release ${RELEASE_VERSION}" "${GITHUB_SHA}"
130
+ git tag --annotate "${tag}" --message "Release ${RELEASE_VERSION}" "${RELEASE_SHA}"
104
131
  git push origin "refs/tags/${tag}"
105
132
  fi
106
133
 
@@ -36,3 +36,7 @@ Update the project version with `uv version <version>` and include the resulting
36
36
  squash-merged into `main`, the release workflow reruns the quality gates, builds the
37
37
  distributions, tags the merge commit as `v<version>`, and publishes to PyPI through
38
38
  Trusted Publishing. Do not create the release tag manually.
39
+
40
+ If a release fails before publication, rerun it for the original version commit with
41
+ `gh workflow run release.yaml --ref main -f release_sha=<merge-commit>`. The workflow
42
+ rejects commits outside `main` and conflicting existing tags.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cli-consumption
3
- Version: 0.0.1
3
+ Version: 0.0.2
4
4
  Summary: Analyze and consolidate AI coding CLI consumption across machines.
5
5
  Project-URL: Homepage, https://github.com/Guillaume-Lombardo/cli-consumption
6
6
  Project-URL: Documentation, https://github.com/Guillaume-Lombardo/cli-consumption#readme
@@ -53,7 +53,7 @@ uv run cli-consumption export --output reports
53
53
  ```
54
54
 
55
55
  Open `reports/dashboard.html` locally. The generated dashboard is self-contained and
56
- makes no network requests.
56
+ makes no network requests. Detailed normalized CSV tables are opt-in with `--csv`.
57
57
 
58
58
  The dashboard supports time, provider, machine, project, and model filters. It reports
59
59
  period-over-period activity, token composition, cache efficiency, turn latency and
@@ -62,7 +62,7 @@ duration and reliability, configuration cohorts, compactions, subagent delegatio
62
62
  ingestion quality. Token events are local usage metadata rather than billing records,
63
63
  and a technically completed turn is not a measure of task quality or productivity.
64
64
 
65
- For a dashboard that is safer to share, omit the detailed CSV exports, pseudonymize
65
+ For a dashboard that is safer to share, pseudonymize
66
66
  machine, project, model, and role labels, group tool names, round timestamps to days,
67
67
  and hide small rows in the cohort-comparison view:
68
68
 
@@ -162,7 +162,7 @@ server. See [Architecture](docs/architecture.md) for trade-offs.
162
162
  cli-consumption collect Collect local or copied provider data into SQL
163
163
  cli-consumption sync Collect and send a snapshot to a central API
164
164
  cli-consumption serve Run the central collection API
165
- cli-consumption export Write CSV tables and the HTML dashboard
165
+ cli-consumption export Write the HTML dashboard and optional CSV tables
166
166
  cli-consumption providers Show supported and planned providers
167
167
  ```
168
168
 
@@ -25,7 +25,7 @@ uv run cli-consumption export --output reports
25
25
  ```
26
26
 
27
27
  Open `reports/dashboard.html` locally. The generated dashboard is self-contained and
28
- makes no network requests.
28
+ makes no network requests. Detailed normalized CSV tables are opt-in with `--csv`.
29
29
 
30
30
  The dashboard supports time, provider, machine, project, and model filters. It reports
31
31
  period-over-period activity, token composition, cache efficiency, turn latency and
@@ -34,7 +34,7 @@ duration and reliability, configuration cohorts, compactions, subagent delegatio
34
34
  ingestion quality. Token events are local usage metadata rather than billing records,
35
35
  and a technically completed turn is not a measure of task quality or productivity.
36
36
 
37
- For a dashboard that is safer to share, omit the detailed CSV exports, pseudonymize
37
+ For a dashboard that is safer to share, pseudonymize
38
38
  machine, project, model, and role labels, group tool names, round timestamps to days,
39
39
  and hide small rows in the cohort-comparison view:
40
40
 
@@ -134,7 +134,7 @@ server. See [Architecture](docs/architecture.md) for trade-offs.
134
134
  cli-consumption collect Collect local or copied provider data into SQL
135
135
  cli-consumption sync Collect and send a snapshot to a central API
136
136
  cli-consumption serve Run the central collection API
137
- cli-consumption export Write CSV tables and the HTML dashboard
137
+ cli-consumption export Write the HTML dashboard and optional CSV tables
138
138
  cli-consumption providers Show supported and planned providers
139
139
  ```
140
140
 
@@ -7,7 +7,7 @@ and reporting. This lets users compare multiple AI coding CLIs without forcing t
7
7
  local formats into one parser.
8
8
 
9
9
  ```text
10
- provider files -> adapter -> metadata-only snapshot -> SQL storage -> CSV/dashboard
10
+ provider files -> adapter -> metadata-only snapshot -> SQL storage -> dashboard/CSV
11
11
  |
12
12
  +-> HTTPS collector -> central SQL storage
13
13
  ```
@@ -21,7 +21,8 @@ provider files -> adapter -> metadata-only snapshot -> SQL storage -> CSV/dashbo
21
21
  - `storage`: owns the normalized schema, idempotent replacement rules, SQLite, and
22
22
  PostgreSQL engine creation.
23
23
  - `api` and `sync`: offer an optional push workflow for recurring multi-machine use.
24
- - `exporting` and `dashboard`: provide portable CSV files and an offline HTML view.
24
+ - `dashboard` and `exporting`: provide an offline HTML view by default and portable CSV
25
+ tables when explicitly requested.
25
26
  - `cli`: exposes the same capabilities through one executable.
26
27
 
27
28
  When Codex exposes its local thread graph, the adapter also records metadata-only
@@ -59,7 +59,8 @@ and period comparisons are computed inside the document. The remaining project n
59
59
  machine labels, model names, tool names, roles, token counters, statuses, and activity
60
60
  timestamps are still private operational metadata.
61
61
 
62
- `export --share-safe` writes only the HTML dashboard and omits CSV tables. Within that
62
+ The normal `export` command writes only the HTML dashboard; detailed CSV tables require
63
+ the explicit `--csv` option. `export --share-safe` rejects that option. Within its HTML
63
64
  document, machine, project, model, and subagent-role labels are replaced with local
64
65
  aliases; tools are reduced to broad categories; timestamps are rounded to UTC days;
65
66
  and the comparison table hides cohorts smaller than five closed turns. The underlying
@@ -6,7 +6,7 @@
6
6
  - Offline multi-machine deduplication
7
7
  - SQLite and PostgreSQL storage
8
8
  - Metadata-only central collection API
9
- - CSV exports, a self-contained dashboard, and a share-safe dashboard profile
9
+ - Opt-in CSV exports, a self-contained dashboard, and a share-safe dashboard profile
10
10
  - Context-pressure, work-item reliability, configuration-cohort, and delegation views
11
11
 
12
12
  ## Next provider increments
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "cli-consumption"
7
- version = "0.0.1"
7
+ version = "0.0.2"
8
8
  description = "Analyze and consolidate AI coding CLI consumption across machines."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -132,17 +132,28 @@ def export_command(
132
132
  ] = "cli-consumption.sqlite",
133
133
  output: Annotated[Path, typer.Option("--output", "-o")] = Path("reports"),
134
134
  dashboard: Annotated[bool, typer.Option("--dashboard/--no-dashboard")] = True,
135
+ csv_exports: Annotated[
136
+ bool,
137
+ typer.Option(
138
+ "--csv/--no-csv",
139
+ help="Also write detailed normalized SQL tables as CSV files.",
140
+ ),
141
+ ] = False,
135
142
  share_safe: Annotated[
136
143
  bool,
137
144
  typer.Option(
138
145
  "--share-safe",
139
- help=("Write only a pseudonymized dashboard; omit detailed CSV exports."),
146
+ help="Write a pseudonymized dashboard and reject detailed CSV exports.",
140
147
  ),
141
148
  ] = False,
142
149
  ) -> None:
143
- """Export normalized SQL tables to CSV and a self-contained HTML dashboard."""
150
+ """Write a self-contained HTML dashboard and optional detailed CSV tables."""
144
151
  if share_safe and not dashboard:
145
152
  raise typer.BadParameter("--share-safe requires --dashboard")
153
+ if share_safe and csv_exports:
154
+ raise typer.BadParameter("--share-safe cannot be combined with --csv")
155
+ if not dashboard and not csv_exports:
156
+ raise typer.BadParameter("enable --dashboard or --csv")
146
157
  if (
147
158
  share_safe
148
159
  and output.is_dir()
@@ -154,7 +165,7 @@ def export_command(
154
165
  engine = create_database_engine(database)
155
166
  try:
156
167
  initialize_database(engine)
157
- paths = [] if share_safe else export_csv(engine, output)
168
+ paths = export_csv(engine, output) if csv_exports else []
158
169
  if dashboard:
159
170
  dashboard_path = output / "dashboard.html"
160
171
  generate_dashboard(engine, dashboard_path, share_safe=share_safe)
@@ -309,45 +309,55 @@ def _document(payload: str) -> str:
309
309
  <meta name="viewport" content="width=device-width,initial-scale=1">
310
310
  <title>CLI Consumption</title>
311
311
  <style>
312
- :root {{ color-scheme:dark; --bg:#07111f; --panel:#101e31; --panel2:#0c1929; --ink:#eef6ff; --muted:#91a7bf; --accent:#5eead4; --blue:#60a5fa; --violet:#a78bfa; --amber:#fbbf24; --red:#fb7185; --line:#233b55; }}
312
+ :root {{ color-scheme:light; --bg:#f7f8fa; --ink:#172033; --muted:#64748b; --accent:#087f6d; --blue:#2563eb; --violet:#7c3aed; --amber:#b45309; --red:#be123c; --line:#d7dde7; --control:#fff; --track:#e3e8ef; --hover:#edf1f6; }}
313
+ @media (prefers-color-scheme:dark) {{ :root:not([data-theme]) {{ color-scheme:dark; --bg:#09111d; --ink:#edf4fc; --muted:#94a3b8; --accent:#5eead4; --blue:#60a5fa; --violet:#a78bfa; --amber:#fbbf24; --red:#fb7185; --line:#26364a; --control:#111c2b; --track:#1b2a3d; --hover:#142235; }} }}
314
+ :root[data-theme="dark"] {{ color-scheme:dark; --bg:#09111d; --ink:#edf4fc; --muted:#94a3b8; --accent:#5eead4; --blue:#60a5fa; --violet:#a78bfa; --amber:#fbbf24; --red:#fb7185; --line:#26364a; --control:#111c2b; --track:#1b2a3d; --hover:#142235; }}
315
+ :root[data-theme="light"] {{ color-scheme:light; --bg:#f7f8fa; --ink:#172033; --muted:#64748b; --accent:#087f6d; --blue:#2563eb; --violet:#7c3aed; --amber:#b45309; --red:#be123c; --line:#d7dde7; --control:#fff; --track:#e3e8ef; --hover:#edf1f6; }}
313
316
  * {{ box-sizing:border-box }}
314
- body {{ margin:0; background:radial-gradient(circle at 85% 0,#12335a 0,transparent 32%),var(--bg); color:var(--ink); font:15px/1.5 Inter,ui-sans-serif,system-ui,sans-serif }}
315
- main {{ max-width:1440px; margin:auto; padding:40px 24px 64px }}
316
- h1 {{ margin:0; font-size:clamp(32px,5vw,58px); letter-spacing:-.04em }}
317
+ body {{ margin:0; background:var(--bg); color:var(--ink); font:15px/1.5 Inter,ui-sans-serif,system-ui,sans-serif }}
318
+ main {{ max-width:1440px; margin:auto; padding:30px 24px 64px }}
319
+ h1 {{ margin:0; font-size:clamp(30px,4vw,48px); letter-spacing:-.04em; white-space:nowrap }}
317
320
  h2 {{ margin:0; font-size:18px }} h3 {{ margin:0 0 12px; font-size:14px; color:var(--muted) }}
321
+ .header {{ display:grid; grid-template-columns:auto minmax(280px,1fr) auto; gap:28px; align-items:center }}
322
+ .header-copy {{ max-width:760px }}
318
323
  .eyebrow {{ color:var(--accent); text-transform:uppercase; letter-spacing:.15em; font-size:12px; font-weight:800 }}
319
- .subtitle,.muted,.definition {{ color:var(--muted) }} .subtitle {{ max-width:780px }}
324
+ .subtitle,.muted,.definition {{ color:var(--muted) }} .subtitle {{ margin:4px 0 0 }}
325
+ .theme-toggle {{ border:1px solid var(--line); background:var(--control); color:var(--ink); border-radius:999px; padding:9px 13px; cursor:pointer; font:inherit; white-space:nowrap }}
326
+ .theme-toggle:hover {{ background:var(--hover) }}
320
327
  .filters,.cards,.grid,.metric-grid,.quality-grid {{ display:grid; gap:14px }}
321
328
  .filters {{ grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); margin:28px 0 14px }}
322
329
  .custom-dates {{ display:none; grid-template-columns:1fr 1fr; gap:14px; margin-bottom:14px }} .custom-dates.visible {{ display:grid }}
323
330
  .cards {{ grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); margin:18px 0 }}
324
331
  .grid {{ grid-template-columns:repeat(2,minmax(0,1fr)); margin-bottom:14px }}
325
332
  .metric-grid,.quality-grid {{ grid-template-columns:repeat(2,minmax(0,1fr)) }}
326
- .panel,.card,select,input {{ border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 93%,transparent); border-radius:16px }}
327
- .panel {{ padding:21px; overflow:auto }} .panel.full {{ margin-bottom:14px }} .card {{ padding:17px }}
333
+ .panel,.card {{ border:0; background:var(--bg) }}
334
+ select,input {{ border:1px solid var(--line); background:var(--control); border-radius:12px }}
335
+ .panel {{ padding:18px 0; overflow:auto }} .panel.full {{ margin-bottom:14px }} .card {{ padding:14px 0 }}
328
336
  .panel-head {{ display:flex; align-items:baseline; justify-content:space-between; gap:12px; margin-bottom:17px }}
329
- .card strong {{ display:block; font-size:27px; letter-spacing:-.03em }} .card span,label {{ color:var(--muted) }}
337
+ .card-label {{ display:flex; align-items:center; gap:6px; color:var(--muted) }}
338
+ .card strong {{ display:block; font-size:27px; letter-spacing:-.03em }} label {{ color:var(--muted) }}
339
+ .info {{ position:relative; width:18px; height:18px; border:1px solid var(--line); border-radius:50%; background:transparent; color:var(--muted); padding:0; cursor:help; font:700 11px/16px Inter,ui-sans-serif,system-ui,sans-serif }}
340
+ .info::after {{ content:attr(data-tooltip); position:absolute; z-index:5; left:50%; bottom:calc(100% + 8px); width:min(280px,70vw); padding:9px 11px; border:1px solid var(--line); border-radius:9px; background:var(--control); color:var(--ink); font:12px/1.4 Inter,ui-sans-serif,system-ui,sans-serif; text-align:left; text-transform:none; letter-spacing:0; box-shadow:0 8px 24px rgb(0 0 0 / .16); opacity:0; pointer-events:none; transform:translate(-50%,4px); transition:opacity .12s,transform .12s }}
341
+ .info:hover::after,.info:focus-visible::after {{ opacity:1; transform:translate(-50%,0) }}
330
342
  .delta {{ font-size:12px; margin-top:4px }} .delta.better {{ color:var(--accent) }} .delta.worse {{ color:var(--red) }} .delta.neutral {{ color:var(--muted) }}
331
343
  .badge {{ display:none; width:max-content; margin-top:10px; padding:5px 9px; border:1px solid var(--accent); border-radius:99px; color:var(--accent); font-size:11px; text-transform:uppercase; letter-spacing:.08em }} .badge.visible {{ display:block }}
332
344
  label {{ display:grid; gap:6px; font-size:11px; text-transform:uppercase; letter-spacing:.08em }}
333
345
  select,input {{ color:var(--ink); padding:10px 12px; width:100% }}
334
346
  table {{ width:100%; border-collapse:collapse; white-space:nowrap }} th,td {{ padding:9px 8px; border-bottom:1px solid var(--line); text-align:left }}
335
- th {{ color:var(--muted); font-size:11px; text-transform:uppercase; cursor:pointer }} tbody tr:hover {{ background:#13243a }}
347
+ th {{ color:var(--muted); font-size:11px; text-transform:uppercase; cursor:pointer }} tbody tr:hover {{ background:var(--hover) }}
336
348
  .bars {{ display:grid; gap:10px }} .bar {{ display:grid; grid-template-columns:minmax(110px,1.2fr) 4fr auto; gap:10px; align-items:center }}
337
- .track {{ height:11px; display:flex; background:#1a2d43; border-radius:99px; overflow:hidden }} .fill {{ height:100%; background:linear-gradient(90deg,var(--blue),var(--accent)) }}
349
+ .track {{ height:11px; display:flex; background:var(--track); border-radius:99px; overflow:hidden }} .fill {{ height:100%; background:var(--blue) }}
338
350
  .seg-cache {{ background:var(--blue) }} .seg-uncached {{ background:var(--violet) }} .seg-visible {{ background:var(--accent) }} .seg-reasoning {{ background:var(--amber) }} .seg-other {{ background:var(--red) }}
339
351
  .legend {{ display:flex; flex-wrap:wrap; gap:12px; margin:0 0 15px; color:var(--muted); font-size:12px }} .legend i {{ display:inline-block; width:9px; height:9px; border-radius:2px; margin-right:5px }}
340
- .stat {{ padding:12px; border:1px solid var(--line); border-radius:12px; background:var(--panel2) }} .stat b {{ display:block; font-size:20px }}
341
- .status {{ display:flex; height:13px; border-radius:99px; overflow:hidden; background:#1a2d43; margin:12px 0 }} .completed {{ background:var(--accent) }} .aborted {{ background:var(--red) }} .progress {{ background:var(--amber) }}
352
+ .stat {{ padding:12px 0; border:0; background:var(--bg) }} .stat b {{ display:block; font-size:20px }}
353
+ .status {{ display:flex; height:13px; border-radius:99px; overflow:hidden; background:var(--track); margin:12px 0 }} .completed {{ background:var(--accent) }} .aborted {{ background:var(--red) }} .progress {{ background:var(--amber) }}
342
354
  .empty {{ color:var(--muted); padding:26px 0 }} .definition {{ font-size:12px; margin:12px 0 0 }} footer {{ color:var(--muted); margin-top:26px }}
343
355
  details {{ border-top:1px solid var(--line); margin-top:18px; padding-top:14px }} summary {{ cursor:pointer; color:var(--muted) }}
344
- @media (max-width:850px) {{ .grid {{ grid-template-columns:1fr }} .bar {{ grid-template-columns:minmax(90px,1fr) 2fr auto }} }}
356
+ @media (max-width:850px) {{ .header {{ grid-template-columns:1fr auto; gap:12px }} .header-copy {{ grid-column:1 / -1; grid-row:2 }} .grid {{ grid-template-columns:1fr }} .bar {{ grid-template-columns:minmax(90px,1fr) 2fr auto }} }}
345
357
  </style>
346
358
  </head>
347
359
  <body><main>
348
- <div class="eyebrow">Local-first AI CLI observability</div>
349
- <h1>CLI Consumption</h1>
350
- <p class="subtitle">Understand activity, responsiveness, token composition, and workflows without exporting prompts, responses, or tool arguments.</p>
360
+ <header class="header"><h1>CLI Consumption</h1><div class="header-copy"><div class="eyebrow">Local-first AI CLI observability</div><p class="subtitle">Understand activity, responsiveness, token composition, and workflows without exporting prompts, responses, or tool arguments.</p></div><button class="theme-toggle" id="themeToggle" type="button" aria-label="Switch color theme">Theme</button></header>
351
361
  <div class="badge" id="privacyBadge">Share-safe dashboard</div>
352
362
  <section class="filters">
353
363
  <label>Period<select id="period"><option value="all">All history</option><option value="7">Latest 7 days</option><option value="30">Latest 30 days</option><option value="90">Latest 90 days</option><option value="custom">Custom range</option></select></label>
@@ -360,11 +370,11 @@ def _document(payload: str) -> str:
360
370
  <section class="cards" id="cards"></section>
361
371
  <section class="grid">
362
372
  <article class="panel"><div class="panel-head"><h2>Activity and token composition</h2><span class="muted" id="activityCaption"></span></div><div class="legend"><span><i class="seg-cache"></i>Cached input</span><span><i class="seg-uncached"></i>Uncached input</span><span><i class="seg-visible"></i>Visible output</span><span><i class="seg-reasoning"></i>Reasoning</span><span><i class="seg-other"></i>Unattributed</span></div><div class="bars" id="activity"></div></article>
363
- <article class="panel"><div class="panel-head"><h2>Turn performance</h2><span class="muted">p50 / p75 / p95</span></div><div id="performance"></div><p class="definition">Duration is provider-reported. Technical throughput is closed turns per active hour: overlapping intervals are merged in detailed reports, while share-safe reports sum turn durations. It is not a productivity score.</p></article>
373
+ <article class="panel"><div class="panel-head"><h2>Tools</h2><span class="muted">names only</span></div><h3>Top tools</h3><div class="bars" id="tools"></div><h3 style="margin-top:20px">Categories</h3><div class="bars" id="toolCategories"></div><h3 style="margin-top:20px">Frequent transitions</h3><div class="bars" id="toolTransitions"></div></article>
364
374
  </section>
365
375
  <section class="grid">
366
376
  <article class="panel"><div class="panel-head"><h2>Models</h2><span class="muted">tokens and efficiency</span></div><div id="models"></div></article>
367
- <article class="panel"><div class="panel-head"><h2>Tools</h2><span class="muted">names only</span></div><h3>Top tools</h3><div class="bars" id="tools"></div><h3 style="margin-top:20px">Categories</h3><div class="bars" id="toolCategories"></div><h3 style="margin-top:20px">Frequent transitions</h3><div class="bars" id="toolTransitions"></div></article>
377
+ <article class="panel"><div class="panel-head"><h2>Turn performance</h2><span class="muted">p50 / p75 / p95</span></div><div id="performance"></div><p class="definition">Duration is provider-reported. Technical throughput is closed turns per active hour: overlapping intervals are merged in detailed reports, while share-safe reports sum turn durations. It is not a productivity score.</p></article>
368
378
  </section>
369
379
  <section class="grid">
370
380
  <article class="panel"><div class="panel-head"><h2>Workflow complexity</h2><span class="muted">turns, context, delegation</span></div><div class="metric-grid" id="workflow"></div><h3 style="margin-top:20px">Subagent roles</h3><div class="bars" id="agentRoles"></div></article>
@@ -403,7 +413,7 @@ function activeMs(turns){{if(data.meta.shareSafe)return total(turns,'durationMs'
403
413
  function maxConcurrent(turns){{if(data.meta.shareSafe)return null;const byMachine={{}};turns.forEach(t=>{{const c=convByKey[t.conversationKey];if(!c||!validDate(t.startedAt)||!validDate(t.endedAt))return;(byMachine[c.machine]??=[]).push([Date.parse(t.startedAt),1],[Date.parse(t.endedAt),-1]);}});let peak=0;Object.values(byMachine).forEach(points=>{{points.sort((a,b)=>a[0]-b[0]||a[1]-b[1]);let active=0;points.forEach(([,change])=>{{active+=change;peak=Math.max(peak,active);}});}});return peak;}}
404
414
  function metrics(s){{const closed=s.turns.filter(t=>t.status==='completed'||t.status==='aborted'),closedKeys=new Set(closed.map(t=>t.key)),closedCalls=s.calls.filter(c=>c.turnKey!==null&&closedKeys.has(c.turnKey)),durations=closed.map(t=>t.durationMs).filter(x=>x!==null),ttfts=closed.map(t=>t.ttftMs).filter(x=>x!==null),turnTokens=closed.map(t=>t.total_tokens),turnTools=closed.map(t=>t.toolCalls),input=total(closedCalls,'input_tokens'),cached=total(closedCalls,'cached_input_tokens'),output=total(closedCalls,'output_tokens'),tokens=total(closedCalls,'total_tokens'),active=activeMs(closed),pressures=s.contexts.map(c=>100*Number(c.inputTokens)/Number(c.contextWindowTokens)).filter(Number.isFinite),activeDays=new Set(s.turns.map(t=>day(t.startedAt)).filter(x=>x!=='unknown')).size;return{{turns:s.turns.length,completed:s.turns.filter(t=>t.status==='completed').length,aborted:s.turns.filter(t=>t.status==='aborted').length,tokens,tokensPerTurn:percentile(turnTokens,.5),toolsPerTurn:percentile(turnTools,.5),cacheRate:ratio(cached,input),durationP50:percentile(durations,.5),durationP75:percentile(durations,.75),durationP95:percentile(durations,.95),ttftP50:percentile(ttfts,.5),ttftP75:percentile(ttfts,.75),ttftP95:percentile(ttfts,.95),tokenP75:percentile(turnTokens,.75),tokenP95:percentile(turnTokens,.95),toolP75:percentile(turnTools,.75),toolP95:percentile(turnTools,.95),abortRate:ratio(s.turns.filter(t=>t.status==='aborted').length,closed.length),reasoningShare:ratio(total(closedCalls,'reasoning_output_tokens'),output),activeMs:active,throughput:active?3600000*closed.length/active:0,pressureP50:percentile(pressures,.5),pressureP95:percentile(pressures,.95),activeDays}};}}
405
415
  function delta(current,previous,preference='neutral'){{if(previous===null||previous===undefined||!Number.isFinite(previous)||previous===0)return'';const change=100*(current-previous)/Math.abs(previous),better=preference==='higher'?change>0:preference==='lower'?change<0:null,style=better===null?'neutral':better?'better':'worse';return`<div class="delta ${{style}}">${{change>=0?'+':''}}${{change.toFixed(1)}}% vs previous period</div>`;}}
406
- function card(label,value,current,previous,preference='neutral'){{return`<div class="card"><span>${{label}}</span><strong>${{value}}</strong>${{delta(current,previous,preference)}}</div>`;}}
416
+ function card(label,value,current,previous,tooltip,preference='neutral'){{return`<div class="card"><div class="card-label"><span>${{label}}</span><button class="info" type="button" aria-label="${{escapeHtml(label)}}: ${{escapeHtml(tooltip)}}" data-tooltip="${{escapeHtml(tooltip)}}" title="${{escapeHtml(tooltip)}}">i</button></div><strong>${{value}}</strong>${{delta(current,previous,preference)}}</div>`;}}
407
417
  function stat(label,value,detail=''){{return`<div class="stat"><span class="muted">${{label}}</span><b>${{value}}</b>${{detail?`<small class="muted">${{detail}}</small>`:''}}</div>`;}}
408
418
  function group(rows,key,value=null){{const out={{}};rows.forEach(r=>{{const label=r[key]||'unknown';out[label]=(out[label]||0)+(value?(Number(r[value])||0):1);}});return Object.entries(out).sort((a,b)=>b[1]-a[1]);}}
409
419
  function toolCategory(name){{const categories=['Agent coordination','Shell and processes','Files and workspace','Web','Planning','Media','Integrations','Other'];if(categories.includes(name))return name;if(['spawn_agent','wait_agent','list_agents','send_message','followup_task','interrupt_agent'].includes(name))return'Agent coordination';if(['exec_command','write_stdin','wait'].includes(name))return'Shell and processes';if(['apply_patch','view_image'].includes(name))return'Files and workspace';if(name==='web__run')return'Web';if(['update_plan','create_goal','get_goal','update_goal'].includes(name))return'Planning';if(name.includes('imagegen'))return'Media';if(name.startsWith('mcp__')||name.includes('mcp_resource'))return'Integrations';return'Other';}}
@@ -423,9 +433,10 @@ function sortExplorer(key){{if(tableSort.key===key)tableSort.direction*=-1;else
423
433
  function renderTable(conversations,turns){{const statuses={{}};turns.forEach(t=>{{const s=statuses[t.conversationKey]??={{completed:0,aborted:0,progress:0}};if(t.status==='completed')s.completed++;else if(t.status==='aborted')s.aborted++;else s.progress++;}});const outcome=c=>{{const s=statuses[c.key];return s?.aborted?'contains abort':s?.progress?'in progress':s?.completed?'technically closed':'unknown';}},value=(c,key)=>key==='models'?c.models.join(', '):key==='outcome'?outcome(c):c[key];const rows=conversations.slice().sort((a,b)=>{{const av=value(a,tableSort.key)??'',bv=value(b,tableSort.key)??'';return tableSort.direction*(typeof av==='number'&&typeof bv==='number'?av-bv:String(av).localeCompare(String(bv)));}});$('conversationCount').textContent=`${{rows.length}} conversations`;$('conversationDetail').innerHTML='';$('table').innerHTML=rows.length?`<table><thead><tr><th onclick="sortExplorer('startedAt')">Started</th><th onclick="sortExplorer('provider')">Provider</th><th onclick="sortExplorer('machine')">Machine</th><th onclick="sortExplorer('project')">Project</th><th onclick="sortExplorer('models')">Models</th><th onclick="sortExplorer('turns')">Turns</th><th onclick="sortExplorer('outcome')">Technical status</th><th onclick="sortExplorer('compactions')">Compactions</th><th onclick="sortExplorer('total_tokens')">Tokens</th><th onclick="sortExplorer('durationSeconds')">Wall duration</th></tr></thead><tbody>${{rows.map(c=>`<tr style="cursor:pointer" onclick="showConversation(${{c.key}})"><td>${{escapeHtml(c.startedAt||'')}}</td><td>${{escapeHtml(c.provider)}}</td><td>${{escapeHtml(c.machine)}}</td><td>${{escapeHtml(c.project)}}</td><td>${{escapeHtml(c.models.join(', '))}}</td><td>${{fmt(c.turns)}}</td><td>${{outcome(c)}}</td><td>${{fmt(c.compactions)}}</td><td>${{fmt(c.total_tokens)}}</td><td>${{formatDuration((Number(c.durationSeconds)||0)*1000)}}</td></tr>`).join('')}}</tbody></table>`:'<div class="empty">No conversations match these filters.</div>';}}
424
434
  function showConversation(key){{if(!currentSlice)return;const c=convByKey[key],turns=currentSlice.turns.filter(t=>t.conversationKey===key),calls=currentSlice.calls.filter(x=>x.conversationKey===key),tools=currentSlice.tools.filter(x=>x.conversationKey===key),work=currentSlice.work.filter(x=>x.conversationKey===key),contexts=currentSlice.contexts.filter(x=>x.conversationKey===key),closed=turns.filter(t=>t.status==='completed'||t.status==='aborted'),durations=closed.map(t=>t.durationMs).filter(x=>x!==null),ttfts=closed.map(t=>t.ttftMs).filter(x=>x!==null),pressures=contexts.map(x=>100*Number(x.inputTokens)/Number(x.contextWindowTokens));$('conversationDetail').innerHTML=`<details open><summary>${{escapeHtml(c.project)}} · ${{escapeHtml(c.startedAt||'')}}</summary><div class="quality-grid" style="margin-top:14px">${{stat('Turns in range',fmt(turns.length))}}${{stat('Tokens in range',fmt(total(calls,'total_tokens')))}}${{stat('Median TTFT',formatDuration(percentile(ttfts,.5)))}}${{stat('Median duration',formatDuration(percentile(durations,.5)))}}${{stat('Context p95',pct(percentile(pressures,.95)))}}${{stat('Technical work items',fmt(work.length))}}</div><h3 style="margin-top:16px">Tools in this conversation</h3><div class="bars">${{group(tools,'tool').slice(0,8).map(([name,count])=>`<div class="bar"><span>${{escapeHtml(name)}}</span><div class="track"><div class="fill" style="width:${{100*count/Math.max(tools.length,1)}}%"></div></div><b>${{fmt(count)}}</b></div>`).join('')||'<div class="empty">No tool calls.</div>'}}</div></details>`;}}
425
435
  function formatDuration(ms){{if(ms===null||ms===undefined||!Number.isFinite(Number(ms)))return'—';const seconds=Number(ms)/1000;if(seconds<60)return`${{seconds.toFixed(1)}}s`;if(seconds<3600)return`${{(seconds/60).toFixed(1)}}m`;return`${{(seconds/3600).toFixed(1)}}h`;}}
426
- function render(){{const f=selected(),s=slice(f),m=metrics(s),previous=f.range?.previous?metrics(slice(f,f.range.previous)):null,closed=s.turns.filter(t=>t.status==='completed'||t.status==='aborted'),delegation=delegationStats(s),peak=maxConcurrent(closed),compacted=new Set(s.compactions.map(c=>c.conversationKey)).size,delegated=new Set(s.subagents.map(x=>x.conversationKey)).size,subTokens=total(s.subagents,'tokens');currentSlice=s;$('cards').innerHTML=card('Closed turns',fmt(m.completed+m.aborted),m.completed+m.aborted,previous?previous.completed+previous.aborted:null)+card('Active days',fmt(m.activeDays),m.activeDays,previous?.activeDays,'higher')+card('Total tokens',short(m.tokens),m.tokens,previous?.tokens)+card('Median tokens / turn',short(m.tokensPerTurn),m.tokensPerTurn,previous?.tokensPerTurn)+card('Median TTFT',formatDuration(m.ttftP50),m.ttftP50,previous?.ttftP50,'lower')+card('Median duration',formatDuration(m.durationP50),m.durationP50,previous?.durationP50,'lower')+card('Technical throughput',m.throughput.toFixed(1)+'/h',m.throughput,previous?.throughput,'higher')+card('Context pressure p95',pct(m.pressureP95),m.pressureP95,previous?.pressureP95,'lower')+card(data.meta.shareSafe?'Summed turn time':'Active time',formatDuration(m.activeMs),m.activeMs,previous?.activeMs);drawActivity(s.calls,f.range);renderPerformance(m);renderModels(s.calls);drawBars('tools',group(s.tools,'tool'));drawBars('toolCategories',group(s.tools.map(t=>({{category:toolCategory(t.tool)}})),'category'));drawBars('toolTransitions',toolTransitions(s.tools));$('workflow').innerHTML=stat('Turns using tools',pct(ratio(s.turns.filter(t=>t.toolCalls>0).length,s.turns.length)))+stat('Compacted conversations',pct(ratio(compacted,s.conversations.length)))+stat('Delegating conversations',pct(ratio(delegated,s.conversations.length)),`${{fmt(s.subagents.length)}} edges`)+stat('Mapped child threads',fmt(delegation.mapped),`${{fmt(delegation.closed)}} technically closed`)+stat('Max delegation fan-out',fmt(delegation.maxFanout))+stat('Max delegation depth',fmt(delegation.maxDepth))+stat('Reported subagent tokens',short(subTokens),'may overlap parent totals')+stat('Peak concurrent turns',peak===null?'Hidden':fmt(peak),data.meta.shareSafe?'exact times rounded':'per-machine peak');drawBars('agentRoles',group(s.subagents,'role'));renderOutcomes(s.turns);renderContext(s,m);renderWork(s);renderCohorts(s);renderQuality(s,f);renderTable(s.conversations,s.turns);}}
436
+ function render(){{const f=selected(),s=slice(f),m=metrics(s),previous=f.range?.previous?metrics(slice(f,f.range.previous)):null,closed=s.turns.filter(t=>t.status==='completed'||t.status==='aborted'),delegation=delegationStats(s),peak=maxConcurrent(closed),compacted=new Set(s.compactions.map(c=>c.conversationKey)).size,delegated=new Set(s.subagents.map(x=>x.conversationKey)).size,subTokens=total(s.subagents,'tokens'),activeLabel=data.meta.shareSafe?'Summed turn time':'Active time',activeHelp=data.meta.shareSafe?'Sum of provider-reported durations for closed turns; exact overlap cannot be recovered from day-rounded timestamps.':'Union of provider-reported closed-turn intervals per machine, so overlapping turns are counted once.';currentSlice=s;$('cards').innerHTML=card('Closed turns',fmt(m.completed+m.aborted),m.completed+m.aborted,previous?previous.completed+previous.aborted:null,'Turns whose provider status is completed or aborted in the selected range.')+card('Active days',fmt(m.activeDays),m.activeDays,previous?.activeDays,'Distinct UTC calendar days containing at least one turn in the selected range.','higher')+card('Total tokens',short(m.tokens),m.tokens,previous?.tokens,'Provider-reported total tokens from model usage events attributed to closed turns; this is not billing data.')+card('Median tokens / turn',short(m.tokensPerTurn),m.tokensPerTurn,previous?.tokensPerTurn,'Median provider-reported total-token count across completed and aborted turns.')+card('Median TTFT',formatDuration(m.ttftP50),m.ttftP50,previous?.ttftP50,'Median provider-reported time to first token across closed turns with TTFT data.','lower')+card('Median duration',formatDuration(m.durationP50),m.durationP50,previous?.durationP50,'Median provider-reported elapsed duration across completed and aborted turns with duration data.','lower')+card('Technical throughput',m.throughput.toFixed(1)+'/h',m.throughput,previous?.throughput,'Completed and aborted turns divided by active hours. This is a technical activity rate, not a productivity score.','higher')+card('Context pressure p95',pct(m.pressureP95),m.pressureP95,previous?.pressureP95,'95th percentile of latest model-call input tokens divided by the reported context-window size.','lower')+card(activeLabel,formatDuration(m.activeMs),m.activeMs,previous?.activeMs,activeHelp);drawActivity(s.calls,f.range);renderPerformance(m);renderModels(s.calls);drawBars('tools',group(s.tools,'tool'));drawBars('toolCategories',group(s.tools.map(t=>({{category:toolCategory(t.tool)}})),'category'));drawBars('toolTransitions',toolTransitions(s.tools));$('workflow').innerHTML=stat('Turns using tools',pct(ratio(s.turns.filter(t=>t.toolCalls>0).length,s.turns.length)))+stat('Compacted conversations',pct(ratio(compacted,s.conversations.length)))+stat('Delegating conversations',pct(ratio(delegated,s.conversations.length)),`${{fmt(s.subagents.length)}} edges`)+stat('Mapped child threads',fmt(delegation.mapped),`${{fmt(delegation.closed)}} technically closed`)+stat('Max delegation fan-out',fmt(delegation.maxFanout))+stat('Max delegation depth',fmt(delegation.maxDepth))+stat('Reported subagent tokens',short(subTokens),'may overlap parent totals')+stat('Peak concurrent turns',peak===null?'Hidden':fmt(peak),data.meta.shareSafe?'exact times rounded':'per-machine peak');drawBars('agentRoles',group(s.subagents,'role'));renderOutcomes(s.turns);renderContext(s,m);renderWork(s);renderCohorts(s);renderQuality(s,f);renderTable(s.conversations,s.turns);}}
427
437
  options('provider',data.conversations.map(c=>c.provider));options('machine',data.conversations.map(c=>c.machine));options('project',data.conversations.map(c=>c.project));options('model',data.modelCalls.map(c=>c.model));
428
438
  const dates=data.conversations.map(c=>c.startedAt).filter(validDate).sort();if(dates.length){{$('from').value=dates[0].slice(0,10);$('to').value=dates.at(-1).slice(0,10);}}
429
439
  if(data.meta.shareSafe)$('privacyBadge').classList.add('visible');
440
+ const themeButton=$('themeToggle');let savedTheme=null;try{{savedTheme=localStorage.getItem('cli-consumption-theme');}}catch{{}}if(savedTheme==='light'||savedTheme==='dark')document.documentElement.dataset.theme=savedTheme;function currentTheme(){{return document.documentElement.dataset.theme||(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');}}function updateThemeButton(){{const theme=currentTheme(),next=theme==='dark'?'light':'dark';themeButton.textContent=theme==='dark'?'Light mode':'Dark mode';themeButton.setAttribute('aria-label',`Switch to ${{next}} mode`);}}themeButton.addEventListener('click',()=>{{const next=currentTheme()==='dark'?'light':'dark';document.documentElement.dataset.theme=next;try{{localStorage.setItem('cli-consumption-theme',next);}}catch{{}}updateThemeButton();}});updateThemeButton();
430
441
  document.querySelectorAll('select,input').forEach(e=>e.addEventListener('change',()=>{{$('customDates').classList.toggle('visible',$('period').value==='custom');render();}}));render();
431
442
  </script></body></html>"""
@@ -10,6 +10,11 @@ from cli_consumption.cli import app
10
10
  runner = CliRunner()
11
11
 
12
12
 
13
+ def normalized_cli_output(output: str) -> str:
14
+ """Remove terminal formatting and wrapping from CLI output assertions."""
15
+ return " ".join(strip_ansi(output).split())
16
+
17
+
13
18
  def test_provider_status_is_explicit() -> None:
14
19
  result = runner.invoke(app, ["providers"])
15
20
  assert result.exit_code == 0
@@ -54,6 +59,24 @@ def test_collect_and_export(tmp_path: Path, rollout_factory) -> None:
54
59
  )
55
60
  assert result.exit_code == 0, result.output
56
61
  assert (reports / "dashboard.html").is_file()
62
+ assert [path.name for path in reports.iterdir()] == ["dashboard.html"]
63
+
64
+ csv_reports = tmp_path / "csv-reports"
65
+ result = runner.invoke(
66
+ app,
67
+ [
68
+ "export",
69
+ "--database",
70
+ str(database),
71
+ "--output",
72
+ str(csv_reports),
73
+ "--csv",
74
+ "--no-dashboard",
75
+ ],
76
+ )
77
+ assert result.exit_code == 0, result.output
78
+ assert (csv_reports / "conversations.csv").is_file()
79
+ assert not (csv_reports / "dashboard.html").exists()
57
80
 
58
81
  safe_reports = tmp_path / "safe-reports"
59
82
  result = runner.invoke(
@@ -97,5 +120,18 @@ def test_collect_and_export(tmp_path: Path, rollout_factory) -> None:
97
120
  ],
98
121
  )
99
122
  assert result.exit_code == 2
100
- normalized_output = " ".join(strip_ansi(result.output).split())
101
- assert "requires --dashboard" in normalized_output
123
+ assert "requires --dashboard" in normalized_cli_output(result.output)
124
+
125
+ result = runner.invoke(
126
+ app,
127
+ ["export", "--database", str(database), "--share-safe", "--csv"],
128
+ )
129
+ assert result.exit_code == 2
130
+ assert "cannot be combined" in normalized_cli_output(result.output)
131
+
132
+ result = runner.invoke(
133
+ app,
134
+ ["export", "--database", str(database), "--no-dashboard"],
135
+ )
136
+ assert result.exit_code == 2
137
+ assert "enable --dashboard or --csv" in normalized_cli_output(result.output)
@@ -56,6 +56,13 @@ def test_ingestion_is_idempotent_and_exports_are_self_contained(
56
56
  html = dashboard.read_text(encoding="utf-8")
57
57
  assert "CLI Consumption" in html
58
58
  assert "Turn performance" in html
59
+ assert 'id="themeToggle"' in html
60
+ assert "localStorage.getItem('cli-consumption-theme')" in html
61
+ assert "radial-gradient" not in html
62
+ assert "linear-gradient" not in html
63
+ assert html.index("<h2>Models</h2>") < html.index("<h2>Turn performance</h2>")
64
+ assert "Median provider-reported total-token count" in html
65
+ assert html.count('class="info"') == 1
59
66
  assert "Data quality" in html
60
67
  assert "https://" not in html
61
68
  assert "secret value" not in html
@@ -56,7 +56,7 @@ wheels = [
56
56
 
57
57
  [[package]]
58
58
  name = "cli-consumption"
59
- version = "0.0.1"
59
+ version = "0.0.2"
60
60
  source = { editable = "." }
61
61
  dependencies = [
62
62
  { name = "fastapi" },
File without changes
File without changes