diff --git a/README.md b/README.md index 602757e..8a817dc 100644 --- a/README.md +++ b/README.md @@ -1,655 +1,176 @@ -

Tokmeter

- # Tokmeter -**See where your AI coding usage goes—across projects, models, and agents.** - -Tokmeter turns local coding-agent usage into a daily view of tokens, estimated API cost, and the projects driving it. Your history stays on your machine, including saved daily totals after old session logs are removed. - -Start with Claude Code and Codex on macOS. Other integrations have different levels of evidence; see the [compatibility table](docs/compatibility.md). - -## See it - -

Tokmeter showing today's tokens, estimated API cost, models, and projects with synthetic demo data

- -[Watch the 20-second walkthrough](docs/assets/demo/tokmeter-demo.mp4) · [How the numbers work](docs/how-the-numbers-work.md) +Tokmeter parses local AI coding-agent session files and aggregates token usage and cost by project, model, provider, and day. It provides a CLI, TypeScript API, terminal UI, web workspace, MCP server, local daemon, and macOS app. -The walkthrough renders the **1.10.0** macOS views using synthetic data; it is not a recording of a customer's usage. See the [release page](https://github.com/sriinnu/tokmeter/releases/tag/v1.10.0) for downloads. +Claude Code and Codex are the primary validation targets. See [provider compatibility](docs/compatibility.md) for the other parsers and their known limits. -## Try one report +## Requirements and installation -Requires Node.js 18+ and local usage from a supported coding agent: +The npm packages require Node.js 18+. Reading Claude Code and Codex session files does not require provider credentials. Pricing lookups can fetch public catalog data; `--light` skips them. ```sh +# Run a report without a global install npx @sriinnu/tokmeter --today -``` - -No provider API key is needed to read Claude Code or Codex's local usage. Pricing lookup can fetch public catalog data. Session contents are not sent to a service. To skip pricing: - -```sh npx @sriinnu/tokmeter --today --light -``` - -## Keep it in your macOS menu bar - -Requires macOS 14+ and the local daemon: - -1. Install the daemon: `npm install -g @sriinnu/drishti` -2. Start it: `drishti daemon start` -3. Download **TokmeterBar** from [GitHub Releases](https://github.com/sriinnu/tokmeter/releases/latest), move it into Applications, and open it. - -The menu bar shows today's tokens. Open it for estimated API cost, any tool-reported cost, and today's models and projects. Expand **Usage details** for trends and other metrics. The [macOS guide](packages/macos-bar/README.md) covers building locally. - -## Understand the dollars - -- **Estimated API cost** values recorded usage at model rates. It is not your ChatGPT or Claude subscription bill. -- **Tool-reported cost** is an amount already present in local tool telemetry. It is not independently verified against an invoice. -- **Unavailable** means the price, token breakdown, or source information is missing. A missing price is not a free request. -- Historical totals can combine estimates and tool reports. Older saved days may lack enough information to separate them; normal refreshes preserve those days. - -## Help us test it - -The first trial focuses on macOS developers using both Claude Code and Codex. [The one-week trial guide](docs/trial/guide.md) explains what to try and how to report a mismatch without sharing a transcript. - -For developers integrating Tokmeter: the CLI, TUI, web dashboard, MCP server, and [daemon/relay architecture](docs/architecture.md) share the same accounting core. Details follow. - -## Packages - -Two packages ship to npm; everything else is bundled inside `@sriinnu/tokmeter`. - -| Package | What | Install | -|---------|------|---------| -| [`@sriinnu/tokmeter`](packages/tokmeter/) | Umbrella distribution - bundles CLI + TUI + core + parsers | `npx @sriinnu/tokmeter` | -| [`@sriinnu/drishti`](packages/mcp/) | MCP server + live TUI + statusline + cross-provider daemon | `npx @sriinnu/drishti` | -The following packages are workspace-internal - they're built and bundled into -the umbrella above, not published as standalone npm packages: - -- `@sriinnu/tokmeter-core` - session parsers, aggregator, pricing, public API -- `@sriinnu/tokmeter-cli` - CLI entry point (table + JSON output + cost digest) -- `@sriinnu/tokmeter-tui` - interactive terminal UI with charts -- `@sriinnu/tokmeter-web` - React + Plotly web dashboard with live mode (run locally; see [Web App](#web-app)) - -## Consume Tokmeter From Other Apps - -Use the surface that matches the job: - -| Need | Use | Notes | -| --- | --- | --- | -| Shell / CI automation | `npx @sriinnu/tokmeter --json` | Stable machine-readable contract for scripts | -| Convenience helpers without shelling out | `@sriinnu/tokmeter` imports | Exposes summary/project/model/stats helpers plus digest/cleanup/restore entrypoints | -| Live in-session token/cost answers | `@sriinnu/drishti` | MCP + daemon + statusline + live tracker | - -### Programmatic convenience helpers - -```ts -import { - loadTokmeterSummary, - loadTokmeterProjects, - loadTokmeterModels, - loadTokmeterStats, - lookupTokmeterPricing, -} from "@sriinnu/tokmeter"; - -const summary = await loadTokmeterSummary({ month: true }); -const projects = await loadTokmeterProjects({ project: "tokmeter" }); -const models = await loadTokmeterModels({ providers: ["codex"] }); -const stats = await loadTokmeterStats({ week: true, light: true }); -const pricing = await lookupTokmeterPricing("claude-sonnet-4-20250514"); -``` - -### Stable shell contract - -```bash -npx @sriinnu/tokmeter --json -npx @sriinnu/tokmeter models --json --project tokmeter -npx @sriinnu/tokmeter digest --json --period week -``` - -For a deeper integration guide, see [`docs/consuming-tokmeter.md`](docs/consuming-tokmeter.md). - -## CLI Usage - -```bash -tokmeter # overview (all projects) -tokmeter models # per-model cost breakdown -tokmeter daily # daily usage over time -tokmeter projects # per-project summary -tokmeter stats # overall statistics -tokmeter pricing sonnet # lookup model pricing -tokmeter digest # weekly cost digest with optimization score -tokmeter digest --period today # today's digest -tokmeter digest --period month # monthly digest - -# Live & Daemon -tokmeter live # TUI dashboard -tokmeter statusline # Statusline mode -tokmeter daemon start # Start aggregation daemon -tokmeter daemon stop # Stop daemon -tokmeter daemon status # Check daemon status - -# Pricing maintenance -tokmeter update # Refresh kosha pricing on demand -tokmeter pricing-audit # Audit pricing coverage across observed models -tokmeter install-cron # Install daily kosha-refresh cron (macOS launchd) -tokmeter uninstall-cron # Remove the daily kosha-refresh cron -tokmeter cron-status # Show daily-cron install + last-run state - -# Backup / Restore (see docs/backup-restore.md) -tokmeter cleanup # interactive: pick projects → dates → confirm -tokmeter snapshot # non-destructive portable backup -tokmeter restore --latest # restore the most recent backup - -# Installer (all editors) -tokmeter install-statusline # Install statusline for ALL editors -tokmeter install-mcp # Install MCP for ALL editors -tokmeter editors # List supported editors - -# Filters -tokmeter --project my-app # specific project -tokmeter --claude --opencode # specific providers -tokmeter --today # today only -tokmeter --week # last 7 days -tokmeter --month # current month -tokmeter --since 2025-01-01 --until 2025-12-31 - -# Output -tokmeter --json # JSON output (for piping/CI) -tokmeter --light # skip pricing (faster) +# Install the CLI and local daemon/MCP server +npm install -g @sriinnu/tokmeter @sriinnu/drishti ``` -### Backup, Snapshot & Restore +Two packages are published: -`tokmeter cleanup`, `tokmeter snapshot`, and `tokmeter restore` handle disk -reclaim and cross-machine portability with automatic tar backups and -homedir-aware path remapping. See [docs/backup-restore.md](docs/backup-restore.md) -for the full walkthrough. +| Package | Contents | +| --- | --- | +| [`@sriinnu/tokmeter`](packages/tokmeter/README.md) | Core API, CLI, and terminal UI | +| [`@sriinnu/drishti`](packages/mcp/README.md) | MCP server, daemon, statusline, and live terminal UI | -### Project Aliases +`packages/core`, `packages/cli`, and `packages/tui` are private workspace packages bundled into `@sriinnu/tokmeter`. `packages/web` is a separate private workspace app run from source. -Collapse variants of the same project (e.g. `Vortex` on Mac and `vortex` on -Linux become one row), rename noisy canonical names -(`weather-app/frontend` → `weather-app`), tag projects (`work`, -`client`, `self`), or hide archived ones. +## CLI usage -File: `~/.tokmeter/aliases.json`. Included in `snapshot` bundles automatically, -so your project renames and tags travel across machines with the rest of your -data. - -```bash -tokmeter alias list # show current aliases -tokmeter alias set "Vortex" "Vortex" # single rename -tokmeter alias merge "Vortex" "Vortex" "vortex" # group keys under one display -tokmeter alias tag add "weather-app" work client -tokmeter alias hide "old-scratch" # drop from per-project tables -tokmeter alias suggest # interactive auto-detect -``` - -Every entry carries `modifiedBy: "user" | "tokmeter"`. Auto-suggest only -proposes for unaliased keys; it **never overwrites** a user-flagged entry. User -confirmations flip the flag so future scans leave it alone. - -### Cost Digest - -The `digest` command gives you a cost report card: - -``` -+==========================================+ -| Weekly Digest: Mar 30 - Apr 5, 2026 | -+==========================================+ - - Total Spend: $2,847.32 - vs Last Week: $2,102.55 (+35.4%) - Daily Average: $406.76 - Busiest Day: Thursday ($892.11) - - Cache Efficiency: 98.2% hit rate - Est. Savings: $977.52 - - Optimization Score: B (85/100) - Cache: A (100) - Model Selection: A (100) - Discipline: F (40) - - Tips: - - The same recorded token counts estimate to $620 on model A and $124 on model B; task quality is not evaluated - - Cache efficiency is solid at 98% - keep sessions active +```sh +tokmeter --today +tokmeter models --project my-app --json +tokmeter daily --week +tokmeter projects +tokmeter stats --month +tokmeter digest --period week +tokmeter pricing sonnet ``` -Aliases: `tokmeter weekly`, `tokmeter report` +Filters include `--project`, `--claude`, `--codex`, `--week`, `--month`, and `--since YYYY-MM-DD --until YYYY-MM-DD`. Use `--json` for machine-readable output and `--light` for token-only reports. -### Example Output +Project naming and backup operations have separate guides: -``` -+---------------------------+------------+--------+--------+----------+---------+ -| Project | Tokens | Cost | Models | Providers| Days | -+---------------------------+------------+--------+--------+----------+---------+ -| myapp | 2.4M | $24.20 | 3 | 2 | 14 | -| api-server | 800.0K | $8.50 | 2 | 1 | 7 | -| scripts | 120.5K | $1.44 | 1 | 1 | 3 | -+---------------------------+------------+--------+--------+----------+---------+ - -Total: 3.3M tokens | $34.14 | 24 active days -``` +- [Aliases](docs/aliases.md): merge display names, tag projects, or hide them from lists. +- [Backup and restore](docs/backup-restore.md): create portable snapshots, preview cleanup, and restore backups. Cleanup deletes source files; keep the confirmation and backup steps. +- [CLI reference](packages/cli/README.md): commands and examples. -## Drishti -- MCP Server + Live Observatory + Daemon +## TypeScript API -[`@sriinnu/drishti`](packages/mcp/) is the observability layer. It provides: +The root export provides the core API. Convenience query helpers use the `/cli` subpath. -### MCP Server +```ts +import { TokmeterCore } from "@sriinnu/tokmeter"; +import { loadTokmeterSummary } from "@sriinnu/tokmeter/cli"; -Exposes **24 tools** to Claude Code, Codex, Cursor, and any MCP client. All -tools are prefixed `drishti_*` so they don't collide with other servers in the -same client. +const core = new TokmeterCore(); +await core.scan({ providers: ["codex", "claude-code"], today: true }); +const models = core.getModelCosts(); +const daily = core.getDailyBreakdown(); -```json -// ~/.claude/settings.json -{ - "mcpServers": { - "drishti": { - "command": "npx", - "args": ["-y", "@sriinnu/drishti", "mcp"] - } - } -} +const summary = await loadTokmeterSummary({ week: true, light: true }); ``` -**Snapshot & breakdowns:** `drishti_pulse`, `drishti_models`, `drishti_providers`, `drishti_projects`, `drishti_timeline`, `drishti_heatmap` +Reuse one core scan when querying several breakdowns. See [integration guidance](docs/consuming-tokmeter.md), [core API usage](packages/core/README.md), and [SKILL.md](SKILL.md) for agent-facing integration instructions. -**Search, compare, export:** `drishti_search`, `drishti_compare`, `drishti_export` +## Daemon, MCP, and statusline -**Cost intelligence:** `drishti_cache_efficiency`, `drishti_model_advisor`, `drishti_budget_alert`, `drishti_cost_optimization_tips`, `drishti_efficiency`, `drishti_anomaly`, `drishti_forecast`, `drishti_budget` - -**Reports & behavior:** `drishti_digest`, `drishti_streaks`, `drishti_leaderboard` - -**Storage hygiene:** `drishti_cleanup_preview`, `drishti_cleanup_execute`, `drishti_backups`, `drishti_restore` - -Every tool output carries a transparency footer (record count, scan duration, -warnings for models with missing pricing) so you can audit the math. - -### Statusline Hook - -Live animated status bar inside Claude Code with cache hit rate: - -```json -// ~/.claude/settings.json -{ - "statusLine": { - "type": "command", - "command": "npx -y @sriinnu/drishti statusline" - } -} -``` - -``` -【♾️】 ○ ❯ 📂myproject ❯ 🌿main ❯ ⚡$5.97 ❯ sonnet-4 ❯ ↑42.5K ↓18.2K ❯ ⚡98.2% ❯ 🔥$4.55/hr ❯ 📈 today $37.8 +```sh +drishti daemon start +drishti daemon status +drishti serve # MCP server over stdio +drishti statusline # one statusline tick +drishti live # live terminal UI ``` -Features: -- Rainbow animated infinity logo -- Real-time token counts with intensity bars -- Live cost tracking with hourly burn rate -- Cache hit rate indicator (green >80%, yellow 50-80%, red <50%) -- Today's total across all providers -- Cross-provider aggregation when daemon is running -- Four fallback layers, so it still prints a valid line if pricing or the daemon is unavailable +The daemon uses local HTTP port `9877` for queries and WebSocket port `9876` for live registration. The macOS app, statusline, and MCP server consume its shared state. Daemon commands belong to `@sriinnu/drishti`; install it for these surfaces. -### Cross-Provider Aggregation Daemon +MCP tools use the `drishti_` prefix. They include usage queries, comparisons, forecasts, export, and confirmed cleanup/restore operations. See the [Drishti reference](packages/mcp/README.md) for names, configuration, and programmatic exports. -The daemon aggregates token usage across **multiple AI coding assistants running simultaneously**: +`drishti editors` lists installer targets. `drishti install-mcp` and `drishti install-statusline` write editor configuration; inspect the generated settings for your editor. See [architecture](docs/architecture.md) for registration, authentication, refresh, storage, and daemon lifecycle details. -```bash -# Start the daemon -tokmeter daemon start +## Terminal and web interfaces -# Check status -tokmeter daemon status +```sh +# After installing @sriinnu/tokmeter +tokmeter-tui -# Stop the daemon -tokmeter daemon stop +# Without a global install +npx -p @sriinnu/tokmeter tokmeter-tui ``` -When multiple Claude Code, Codex, or OpenCode instances are running, the statusline shows **aggregated totals** across all of them in real-time via WebSocket. - -The daemon is the **single source of truth**: it holds usage warm in memory and -every consumer (macOS bar, statusline, MCP) is a thin **reader** of it. Opening -the macOS bar starts the daemon if it's down (singleton - only one ever runs), -and nothing else scans the corpus on a hot path. This keeps reads fast and -memory bounded. See [docs/architecture.md](docs/architecture.md) for the data -freshness, immutability, and memory model. +The terminal UI supports overview, model, daily, and statistics views. See its [keys and commands](packages/tui/README.md). -### Live TUI +Run the web workspace from a source checkout: -```bash -npx @sriinnu/drishti live -# or -tokmeter live +```sh +bun install +bun run dev:web ``` -Real-time terminal dashboard with 2-second refresh. - -## Universal Installer +Open `http://localhost:3000`. See [web setup and data sources](packages/web/README.md). -Install statusline and MCP across **all supported editors** at once: +## macOS app -```bash -# Install statusline for Claude Code, OpenCode, Codex -tokmeter install-statusline +Release builds target Apple silicon and macOS 14+. Node.js 18+ with npx is also required for the local daemon. -# Install MCP server for all editors -tokmeter install-mcp +For published 1.10.0, install `@sriinnu/drishti`, run `drishti daemon start`, and open TokmeterBar from `/Applications`. Download the app from [GitHub Releases](https://github.com/sriinnu/tokmeter/releases). The current source improves automatic startup by resolving paired Node/npx and invoking version-matched Drishti, with prerequisite and retry controls on failure. -# List supported editors -tokmeter editors -``` +The popup shows today's tokens, cost, models, and projects. **Usage details** expands lifetime totals, trends, and signals; the view scrolls when it exceeds the available height. Six themes are selectable: Terminal, Paper, Nebula, Aurora, Nocturne, and Glass. Glass uses native light frost and explicit theme-based text/status colors. The Hub provides larger breakdowns and settings. -Supported editors: -- **Claude Code** -- statusline + MCP -- **OpenCode** -- statusline + MCP -- **Codex** -- statusline + MCP -- **Cursor** -- MCP -- **Windsurf** -- MCP -- **Zed** -- MCP +See [macOS build and runtime details](packages/macos-bar/README.md), [first-use checks](docs/macos/first-use.md), and [popover validation](docs/macos/popover-usability.md). The [completion tracker](docs/macos-completion.md) records remaining fresh-machine, reliability, update, accounting, accessibility, and trial gates. -## TUI +The [synthetic walkthrough](docs/assets/demo/README.md) documents how the 1.10.0 example images were generated; it is not a capture of the current local candidate. -Interactive terminal UI with bar charts, sparklines, and contribution heatmaps. - -```bash -# After `npm install -g @sriinnu/tokmeter` -tokmeter-tui - -# Or one-shot -npx -p @sriinnu/tokmeter tokmeter-tui -``` +## Accounting and limitations -| View | Key | Description | -|------|-----|-------------| -| Overview | `1` | Bar charts, sparklines, provider breakdown | -| Models | `2` | Sortable table with inline charts | -| Daily | `3` | Sparkline + heatmap | -| Stats | `4` | Streaks, averages, contribution calendar | +- Estimated API cost applies model rates to recorded usage. It is not a subscription bill. +- Tool-reported cost comes from local telemetry and has not been independently reconciled to an invoice. +- Missing prices or usage fields remain unavailable; missing price does not mean zero cost. +- Earlier saved days can lack provenance needed to separate estimates from tool reports. Ordinary refreshes preserve those aggregates. +- Sealed daily aggregates retain totals after raw logs are removed. They do not preserve every transcript or per-request detail. -## Web App +See [how the numbers work](docs/how-the-numbers-work.md) for token buckets, pricing sources, and reconciliation examples. Performance and integration coverage depend on local history and provider formats; the [validation record](docs/release/validation.md) states what was checked. -React + Plotly dashboard with rich visualizations and **live mode**. +## Development -```bash -cd packages/web +```sh +git clone https://github.com/sriinnu/tokmeter.git +cd tokmeter bun install -bun run dev +bun run build +bun run lint +bun run test ``` -Open http://localhost:3000 - -When the daemon is running, the web dashboard connects via WebSocket and shows **live session data** alongside historical charts: -- Green pulsing "Live" indicator when connected -- Real-time cost, token counts, and active sessions -- Per-provider and per-model live breakdowns -- Falls back to static data when daemon is offline +Useful workspace commands: -| Chart | Description | -|-------|-------------| -| Model cost bars | Horizontal bar chart comparing model costs | -| Provider pie | Donut chart of cost split by provider | -| Daily trend | Dual-axis line chart (tokens + cost) | -| Token breakdown | Stacked bars (input/output/cache per model) | -| Contribution heatmap | GitHub-style calendar heatmap | +| Command | Purpose | +| --- | --- | +| `bun run cli` | CLI from source | +| `bun run tui` | Terminal UI from source | +| `bun run dev:web` | Web development server | +| `bun run drishti:serve` | MCP server from source | +| `bun run daemon:start` | Start the source daemon | +| `bun run daemon:status` | Check daemon status | +| `bun run daemon:stop` | Stop the daemon | +| `bun run bar:build` | Build an ad-hoc macOS bundle without installing | +| `bun run bar` | Build, install, and launch the macOS app | -Export data: `tokmeter --json > packages/web/public/data.json` +Native tests require macOS and Xcode: -## macOS Menu Bar - -A native SwiftUI menubar app that surfaces your live token spend without ever -leaving the menubar. Reads from the daemon when it's running, falls back to -the CLI on disk when it isn't. - -The menu bar icon itself is a live health gauge: it tints **green → yellow → red** -as your most-loaded session approaches its ceiling (worst-session-wins across every -connected provider). Pick which ceiling drives the color in settings - **context -window** fill, the Claude **5-hour block**, or a **daily budget** - so the signal -works whether or not a provider reports a context window. Turn it off for a plain -monochrome icon. - -

- TokmeterBar popover -

- -```bash -bun run bar # build, install to /Applications, launch +```sh +swift test --package-path packages/macos-bar ``` -Beyond the standard totals (today's cost, week sparkline, top models, per-project -sessions), the bar surfaces a set of live "right now" signals so the surface reads -as a speedometer, not a scoreboard. The statbar foregrounds these six; the rest -(subagent share, reasoning share, per-tool cost, Claude 5-hour billing window, -per-project context pressure) live in the Hub. All eleven are computed in one pass -in [`packages/core/src/signals.ts`](packages/core/src/signals.ts): - -| Signal | What it tells you | -|--------|-------------------| -| **Burn rate** | $/hr over the last 60 min - color ramps green → amber → red as you heat up. | -| **Cache hit %** today | Read tokens served from cache. Two denominators are tracked: the *canonical* rate `cacheRead / (input + cacheRead + cacheWrite)` (counts cache writes as a cost, the honest number) and a *legacy read-share* `cacheRead / (input + cacheRead)` for back-compat. The bar shows the read-share; `missRate + cacheWriteShare + canonicalRate` always sums to exactly 1. | -| **Pace** | Today's cost-by-this-hour vs. the median of your last 7 active days. Tortoise / hare / equal icon. | -| **Compaction tax** | % of today's spend going to `/compact` overhead (Claude Code-specific signal). | -| **Context pressure** | How much the latest request's input has grown over the session's early baseline (the "drag" cache reads add) - the lever behind when to `/compact`. | -| **Live session pill** | The project + age of the most recent record when something's run in the last 5 min. | - -Seven themes (Terminal / Paper / Nebula / Aurora / Noise / Nocturne / Glass) and -a companion "Hub" full-window with project drilldown, command palette, and -settings. Aurora uses a slow-drifting gradient - motion as identity. Noise is -neobrutalist (canary yellow + sticky-note cards with hard offset shadows). - -## Supported Providers - -| Provider | Data Location | -|----------|--------------| -| Claude Code | `~/.claude/projects/**/*.jsonl` | -| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite) + legacy JSON | -| Codex CLI | `~/.codex/sessions/*.jsonl` | -| Gemini CLI | `~/.gemini/tmp/*/chats/*.json` | -| Cursor IDE | Local SQLite (`cursorDiskKV` in Cursor's own state.vscdb) by default; `~/.config/tokscale/cursor-cache/` (external API sync) takes priority when present | -| Amp | `~/.local/share/amp/threads/` | -| Droid | `~/.factory/sessions/` | -| OpenClaw | `~/.openclaw/agents/` + legacy paths | -| Pi | `~/.pi/agent/sessions/` | -| Kimi CLI | `~/.kimi/sessions/` | -| Qwen CLI | `~/.qwen/projects/` | -| Roo Code | VS Code globalStorage | -| Kilo | VS Code globalStorage | -| Kilo CLI | `~/.local/share/kilo/kilo.db` (SQLite) | -| Mux | `~/.mux/sessions/` | -| VS Code (Copilot Chat) | VS Code chat session store (model + request volume only — Copilot bills via quota, no local token/cost data) | -| Antigravity | Local trajectory store (no public schema; session timestamps + touched-file paths only — no model/token/cost data) | -| Zed | `~/Library/Application Support/Zed/threads/threads.db` (SQLite, real token counts + model) | -| Synthetic | Re-attributed from other sources | - -OpenRouter models (free and paid) are automatically detected via model ID format and priced through kosha-discovery's OpenRouter integration. - -## Pricing - -Pricing is resolved entirely through [`@sriinnu/kosha-discovery`](https://github.com/sriinnu/kosha-discovery) - - one source of truth, refreshed daily, no stale hardcoded fallback. - -Resolution chain (see `packages/core/src/pricing.ts`): - -1. **In-memory cache** - per-process, invalidated on kosha registry mtime change. -2. **User overrides** - `~/.tokmeter/pricing-overrides.json` for negotiated rates, free internal deployments, or per-model corrections. Keyed by exact model id; partial `ModelPricing` shapes accepted. -3. **kosha direct** - `registry.model(id)` for canonical model IDs. Prefers `originPricing` (direct-provider rate) over `pricing` (proxy/gateway rate) when both are usable. -4. **kosha fuzzy** - searches the full discovered catalog with an exact-first scorer; covers the long tail (including 300+ OpenRouter models). -5. **Manifest fallback** - direct read of `~/.kosha/registry.json` when the runtime state is missing models the manifest knows about. - -Reasoning tokens get their own rate when kosha publishes one (o1/o3/gemini-thinking/deepseek-r1 and equivalents); otherwise they fall back to the output rate with the cell flagged. - -Covers Anthropic, OpenAI, Google, DeepSeek, xAI, Mistral, Meta, Moonshot, Cohere, Perplexity, Qwen, and 10+ more - whatever kosha is currently tracking. - -Historical records are immutable: prices freeze at write time. Only today reprices when kosha updates. The `tokmeter routes` CLI extends this into a cost-surface explorer - projects today's exact token shape against every model in your lifetime lineup using kosha's live registry, with Δ-vs-actual and honest exclusion of unpriced models. Run `tokmeter routes` for a table or `tokmeter routes --json` for piping. Layer 1 (pure pricing translation) ships in v1.3.0; the full multi-layer design is in [`docs/designs/routes.md`](docs/designs/routes.md). - -All formatters are NaN/Infinity-safe - malformed data never leaks into output. - -## Performance - -The daemon scans your sessions once, persists them as per-day immutable aggregate files, and serves every reader (CLI, statusline, bar, web) from in-memory state. - -**Storage layout** (`~/.cache/tokmeter/aggregates/`): - -| | before (v2 monolith) | after (v3 relay) | delta | -|---|---|---|---| -| History store on disk | 1 file × 187 MB | ~190 files × ~6 KB each (~1.1 MB total) | **~170× smaller** | -| Lifetime `TokenRecord[]` in heap | held warm | structurally eliminated | gone | -| Cold-start I/O | parse 187 MB JSON + dispatch | load the sealed day files + a today-only scan | bounded to today, not the corpus | - -**Hot-path query latency** (measured against a 77 GB / 319k-record corpus, daemon HTTP port): - -| endpoint | cache hit (within 12s TTL) | cache miss (TTL refresh) | response size | -|---|---|---|---| -| `/api/stats` | ~1 - 6 ms | ~5 s (today scan) | 341 B | -| `/api/today` | ~1 ms | - | ~700 B | -| `/api/projects` | ~2 - 4 ms | - | 170 KB | -| `/api/models` | ~1 ms | - | 7.5 KB | -| `/api/daily` | ~1 ms | - | 30 KB | -| `/api/statbar-signals` | ~38 - 58 ms | - | 4.6 KB | -| `/api/cross-tool` | ~2 ms | - | 600 B | - -The statusline polls inside the 12 s TTL, so every visible query is a cache hit. One query per TTL window pays the today-scan cost; that scan reads only today's files - mtime-pruned, then confirmed by each file's newest event timestamp so a touched-but-old file can't masquerade as today (typically a handful of active session files). - -**Memory - the honest version:** RSS is noisy on macOS (V8 retains arenas after GC) and the parser-level scan cache (`~/.cache/tokmeter/scan-cache.json`, ~34 MB on disk, several× that in heap) is real weight, so any single `ps` sample is meaningless. Sampled over a warm session the daemon sits in a **~700 MB - 1.1 GB steady-state band**, dips briefly toward ~30 MB right after a GC, and spikes toward ~1.5 GB mid-scan. That is *modestly* better than the pre-v1.5 daemon's stable ~1 GB+ - not the dramatic reduction the early numbers suggested. The win that actually holds is **structural, not the RSS figure**: the daemon no longer pins lifetime records in heap (it holds today plus the sealed day aggregates instead - past days are never re-parsed), and the on-disk store is ~170× smaller. The remaining heap bulk is the parser scan cache (Claude Code's per-file record cache); bounding it is the next memory fight. - -**Reproduce locally:** - -```bash -# Stop daemon, restart, sample RSS over warmup -node packages/cli/dist/cli.js daemon stop -node packages/cli/dist/cli.js daemon start -PID=$(cat ~/.tokmeter/daemon/daemon.pid) -for i in 1 2 3 4 5; do ps -o rss,pcpu -p $PID | tail -1; sleep 2; done - -# Endpoint latency battery -TOK=$(cat ~/.tokmeter/daemon/daemon.token) -for ep in api/stats api/today api/projects api/models api/daily; do - curl -s -H "Authorization: Bearer $TOK" -o /dev/null \ - -w "$ep %{time_total}s %{size_download}B\n" \ - http://127.0.0.1:9877/$ep -done - -# Relay store inspection -ls -1 ~/.cache/tokmeter/aggregates/ | wc -l # day count -du -sh ~/.cache/tokmeter/aggregates/ # total size -``` +Optional UI fixtures use `TOKMETER_UI_QA_DIR`; the walkthrough renderer uses `TOKMETER_DEMO_DIR`. Create the output directory before running. The JavaScript suite and native suite are separate; neither replaces live accessibility or sustained runtime checks. -## Architecture +## Packaging and release +```sh +# After bun run build: prepare local npm tarballs without publishing +bash scripts/prepare-packages.sh /tmp/tokmeter-candidate +bun run check:secrets ``` -Session Files (local disk) - | -@sriinnu/tokmeter-core (parsers -> aggregation -> pricing via @sriinnu/kosha-discovery) - | - +-- per-day relay store (~/.cache/tokmeter/aggregates/YYYY-MM-DD.json, immutable) - +-- today's records + per-day costByHour curves (signals + pace) - +-- live today accumulator (DailyAccumulator, sealed at midnight) - +-- StatbarSignals (burn/cache/pace/compaction/live) - | -+----------+----------+----------+----------+-----------+----------+ -| CLI | TUI | Web App | Drishti | Daemon | macOS | -| (table) | (Ink) | (Plotly) | (MCP) | (WebSocket)| menu bar | -| (digest) | | (live) | (24 tools)| | (Swift) | -+----------+----------+----------+----------+-----------+----------+ -``` - -### Relay store (history persistence) - -History is a relay race of **per-day immutable aggregate files** at -`~/.cache/tokmeter/aggregates/YYYY-MM-DD.json` (~6 KB each). Each sealed day is -write-once-ever - no code path rewrites an existing day file. "Today" lives only -in an in-memory `DailyAccumulator`; at the midnight rollover (or the first scan -after it) the accumulator seals to its own day file and a fresh one starts. The -raw session JSONL is just the *source* today is re-derived from - once a day is -sealed, **deleting the underlying JSONL loses nothing**, because the sealed -aggregate already holds that day's counts. Cross-machine sync is plain `rsync`: -each day file is a self-contained unit, so union-merging two machines' -`aggregates/` directories yields unified history with no coordination protocol. - -### Daemon lifecycle (and why the bar reads `/tmp`) - -The daemon binds two localhost ports - `9876` (WebSocket, live registration) and -`9877` (HTTP REST, every reader's query path). On a successful bind it writes its -PID and an auth token to **two** locations: - -- **Canonical** - `~/.tokmeter/daemon/daemon.{pid,token}` (the source of truth; - the daemon's own singleton guard reads this). -- **Legacy shim** - `/tmp/drishti-daemon.{pid,token}` (what the macOS bar reads). - -The bar treats "is the daemon up?" as `fileExists(/tmp/drishti-daemon.pid)` + -`kill(pid, 0)` + a `proc_name` check, *before* it ever hits HTTP - this avoids a -60 s URLSession hang when the daemon is genuinely down. The catch: macOS reaps -`/tmp` files untouched for ~3 days. A daemon that stays up for days writes the -shim once at startup and never again, so the reaper eventually deletes it - after -which the bar reports **"offline" against a perfectly healthy daemon**, and a -naive restart can't recover (the live daemon still owns the *canonical* pidfile, -so the singleton guard makes the new start bow out). The fix: the daemon -re-asserts the `/tmp` shims on its 10 s state-save tick whenever they go missing, -so the reaper can never outlast it (`reassertLegacyShims` in -`packages/mcp/src/daemon/server.ts`). -## Development - -```bash -git clone https://github.com/sriinnu/tokmeter.git -cd tokmeter -bun install -bun run build - -# Run surfaces -bun run cli # CLI overview -bun run cli:models # Model breakdown -bun run cli:daily # Daily usage -bun run cli:projects # Project breakdown -bun run cli:stats # Statistics -bun run cli:digest # Cost digest report -bun run cli:pricing # Model pricing lookup -bun run tui # Interactive TUI -bun run dev:web # Web dashboard (dev server) -bun run drishti:live # Live TUI dashboard -bun run drishti:serve # MCP server -bun run drishti:statusline # Statusline hook - -# Daemon -bun run daemon:start # Start aggregation daemon -bun run daemon:stop # Stop daemon -bun run daemon:status # Check daemon status - -# Installer -bun run install:statusline # Install statusline for all editors -bun run install:mcp # Install MCP for all editors -bun run list:editors # List supported editors - -# macOS menu bar (Swift app) -bun run bar # Build + install to /Applications + launch (ad-hoc signed) -bun run bar:build # Build only - produces ./packages/macos-bar/TokmeterBar.app -bun run bar:signed # Developer ID signed - Gatekeeper-friendly for AirDrop -bun run bar:release # Signed + notarized + stapled + appcast.xml updated - # Requires packages/macos-bar/.env with Apple credentials. - # See packages/macos-bar/RELEASE.md for the full pipeline. -bun run bar:publish # Upload the built TokmeterBar-.zip to a - # GitHub release v. Run after bar:release. -bun run bar:ship # One-shot: clean → bar:release → bar:publish. - # Bump CFBundleShortVersionString in bundle.sh first. - -# Cleanup -bun run clean # Remove dist/, *.tsbuildinfo, .build/, *.app, *.zip, *.dSYM, - # plus any leaked tsc emit (.js/.d.ts) inside src/ dirs - -# Quality -bun run test # Run tests (230 passing + 11 todo across the monorepo) -bun run lint # Lint -bun run format # Format -``` +Native release scripts support Developer ID signing, notarization, stapling, Sparkle metadata, and ZIP packaging. `bun run bar:signed` and `bun run bar:release` require configured distribution credentials; publishing is a separate action. Follow the [native release pipeline](packages/macos-bar/RELEASE.md) and [release validation](docs/release/validation.md). ## License -- Application - AGPL-3.0-only: [LICENSE](./LICENSE) -- Core library `@sriinnu/tokmeter-core` - MPL-2.0: [packages/core/LICENSE](./packages/core/LICENSE) +- Applications: [AGPL-3.0-only](LICENSE). +- Core source under `packages/core`: [MPL-2.0](packages/core/LICENSE), including when bundled into an application. -Release artifacts include the license texts and source snapshot. See [licenses and source](docs/licensing.md) for scope, bundled notices, and build instructions. +Release artifacts include license texts and a source snapshot. The macOS bundle also includes Sparkle notices. See [licenses and source](docs/licensing.md) for artifact contents and rebuild instructions. -Copyright (c) 2026 Srinivas Pendela. +Copyright (c) 2026 Srinivas Pendela and contributors. diff --git a/SKILL.md b/SKILL.md index c13ed78..81ea788 100644 --- a/SKILL.md +++ b/SKILL.md @@ -4,39 +4,35 @@ Use Tokmeter when another app, agent, or automation needs local token/cost telem ## Canonical package names -Always use the published npm scope below. - -- `@sriinnu/tokmeter-core` -- `@sriinnu/tokmeter-cli` -- `@sriinnu/tokmeter-tui` -- `@sriinnu/tokmeter-web` -- `@sriinnu/drishti` +The published packages are `@sriinnu/tokmeter` and `@sriinnu/drishti`. +Use `@sriinnu/tokmeter` for the core API and `@sriinnu/tokmeter/cli` for convenience helpers. +The core, CLI, TUI, and web workspace packages are private implementation packages. ## Choose the right surface | Need | Use | Why | | --- | --- | --- | -| Embedded programmatic access in Node/Bun | `@sriinnu/tokmeter-core` | Lowest-level API with scan, aggregation, pricing, cleanup, and restore support | -| Shell / CI / script automation | `@sriinnu/tokmeter-cli --json` | Stable machine-readable contract without writing parser code | -| Convenience wrappers around common queries | `@sriinnu/tokmeter-cli` imports | Exposes summary, project, model, daily, stats, pricing, digest, cleanup, and restore helpers | +| Embedded programmatic access in Node/Bun | `@sriinnu/tokmeter` | Lowest-level API with scan, aggregation, pricing, cleanup, and restore support | +| Shell / CI / script automation | `npx @sriinnu/tokmeter --json` | Stable machine-readable contract without writing parser code | +| Convenience wrappers around common queries | `@sriinnu/tokmeter/cli` imports | Exposes summary, project, model, daily, stats, pricing, digest, cleanup, and restore helpers | | Live token/cost answers inside an AI workflow | `@sriinnu/drishti` | MCP server, daemon, statusline, and live tracker APIs | -| Human exploration | `@sriinnu/tokmeter-tui` or `@sriinnu/tokmeter-web` | Best for interactive/manual inspection | +| Human exploration | `npx -p @sriinnu/tokmeter tokmeter-tui` or the web workspace | Best for interactive/manual inspection | ## Recommended integration order 1. If your AI platform can speak MCP, use `@sriinnu/drishti`. -2. If you need batch automation or CI checks, call `@sriinnu/tokmeter-cli --json`. -3. If you need one reusable in-process scan, use `@sriinnu/tokmeter-core`. -4. If you want convenience helpers without shelling out, import from `@sriinnu/tokmeter-cli`. +2. If you need batch automation or CI checks, call `npx @sriinnu/tokmeter --json`. +3. If you need one reusable in-process scan, use `@sriinnu/tokmeter`. +4. If you want convenience helpers without shelling out, import from `@sriinnu/tokmeter/cli`. ## Quick examples ### Shell / CI ```bash -npx @sriinnu/tokmeter-cli --json -npx @sriinnu/tokmeter-cli models --json --project tokmeter -npx @sriinnu/tokmeter-cli digest --json --period week +npx @sriinnu/tokmeter --json +npx @sriinnu/tokmeter models --json --project tokmeter +npx @sriinnu/tokmeter digest --json --period week ``` ### Convenience methods @@ -47,7 +43,7 @@ import { loadTokmeterProjects, loadTokmeterStats, lookupTokmeterPricing, -} from "@sriinnu/tokmeter-cli"; +} from "@sriinnu/tokmeter/cli"; const summary = await loadTokmeterSummary({ month: true }); const projects = await loadTokmeterProjects({ project: "command-relay" }); @@ -58,7 +54,7 @@ const pricing = await lookupTokmeterPricing("claude-sonnet-4-20250514"); ### Direct core usage ```ts -import { TokmeterCore } from "@sriinnu/tokmeter-core"; +import { TokmeterCore } from "@sriinnu/tokmeter"; const core = new TokmeterCore(); await core.scan({ providers: ["codex", "claude-code"], since: "2026-04-01" }); @@ -79,3 +75,7 @@ const summary = core.getSummary(); - `packages/core/src/index.ts` - `packages/cli/src/index.ts` - `packages/mcp/src/index.ts` + +## Licenses + +Applications use AGPL-3.0-only; core source uses MPL-2.0. See [licenses and source](docs/licensing.md). diff --git a/docs/consuming-tokmeter.md b/docs/consuming-tokmeter.md index ae381a8..fa15af5 100644 --- a/docs/consuming-tokmeter.md +++ b/docs/consuming-tokmeter.md @@ -6,35 +6,31 @@ Use this guide when another AI project, CLI, service, or editor integration need | Need | Use | Why | | --- | --- | --- | -| Local programmatic access in Node/Bun | `@sriinnu/tokmeter-core` | Lowest-level API with full scan, aggregation, filtering, cleanup, and pricing access | -| Shell automation / CI / scripting | `@sriinnu/tokmeter-cli` with `--json` | Stable shell entrypoint that emits machine-readable JSON | -| Convenience helpers without shelling out | `@sriinnu/tokmeter-cli` imports | Wraps the common summary/project/model/stats queries | +| Local programmatic access in Node/Bun | `@sriinnu/tokmeter` | Lowest-level API with full scan, aggregation, filtering, cleanup, and pricing access | +| Shell automation / CI / scripting | `npx @sriinnu/tokmeter --json` | Stable shell entrypoint that emits machine-readable JSON | +| Convenience helpers without shelling out | `@sriinnu/tokmeter/cli` imports | Wraps the common summary/project/model/stats queries | | Live token telemetry from an AI agent | `@sriinnu/drishti` | MCP server, daemon, live tracker, and statusline surface | -| Human exploration | `@sriinnu/tokmeter-tui` or `@sriinnu/tokmeter-web` | Best for interactive/manual use, not for automation | +| Human exploration | `npx -p @sriinnu/tokmeter tokmeter-tui` or the web workspace | Best for interactive/manual use, not for automation | ## Canonical published package names -Always use the published names below. Older shorthand like `@tokmeter/*` is not the canonical npm scope. - -- `@sriinnu/tokmeter-core` -- `@sriinnu/tokmeter-cli` -- `@sriinnu/tokmeter-tui` -- `@sriinnu/tokmeter-web` -- `@sriinnu/drishti` +The published packages are `@sriinnu/tokmeter` and `@sriinnu/drishti`. +Use `@sriinnu/tokmeter` for the core API and `@sriinnu/tokmeter/cli` for convenience helpers. +The core, CLI, TUI, and web workspace packages are private implementation packages. ## Recommended integration order 1. If your tool can speak MCP, use `@sriinnu/drishti`. -2. If you need batch automation or CI checks, use `@sriinnu/tokmeter-cli --json`. -3. If you need embedded logic in Node/Bun code, use `@sriinnu/tokmeter-core` directly. -4. If you want convenience wrappers around common queries, import from `@sriinnu/tokmeter-cli`. +2. If you need batch automation or CI checks, use `npx @sriinnu/tokmeter --json`. +3. If you need embedded logic in Node/Bun code, use `@sriinnu/tokmeter` directly. +4. If you want convenience wrappers around common queries, import from `@sriinnu/tokmeter/cli`. ## Shell / CI integration ### Full summary ```bash -npx @sriinnu/tokmeter-cli --json +npx @sriinnu/tokmeter --json ``` This returns the same summary shape used by the web dashboard: @@ -49,13 +45,13 @@ This returns the same summary shape used by the web dashboard: ### Focused queries ```bash -npx @sriinnu/tokmeter-cli projects --json -npx @sriinnu/tokmeter-cli models --json --project tokmeter -npx @sriinnu/tokmeter-cli stats --json --month -npx @sriinnu/tokmeter-cli digest --json --period week +npx @sriinnu/tokmeter projects --json +npx @sriinnu/tokmeter models --json --project tokmeter +npx @sriinnu/tokmeter stats --json --month +npx @sriinnu/tokmeter digest --json --period week ``` -## Convenience helpers from `@sriinnu/tokmeter-cli` +## Convenience helpers from `@sriinnu/tokmeter/cli` ```ts import { @@ -67,7 +63,7 @@ import { runDigest, runCleanup, runRestore, -} from "@sriinnu/tokmeter-cli"; +} from "@sriinnu/tokmeter/cli"; const summary = await loadTokmeterSummary({ month: true }); const projects = await loadTokmeterProjects({ project: "command-relay" }); @@ -81,7 +77,7 @@ Use these wrappers when you want the convenience of the CLI package but not the ## Direct core usage ```ts -import { TokmeterCore } from "@sriinnu/tokmeter-core"; +import { TokmeterCore } from "@sriinnu/tokmeter"; const core = new TokmeterCore(); await core.scan({ since: "2026-04-01", providers: ["codex", "claude-code"] }); @@ -121,3 +117,7 @@ Use `@sriinnu/drishti` when an AI assistant should answer token/cost questions d - `SKILL.md` - `packages/core/src/index.ts` - `packages/mcp/src/index.ts` + +## Licenses + +Applications use AGPL-3.0-only; core source uses MPL-2.0. See [licenses and source](licensing.md). diff --git a/docs/licensing.md b/docs/licensing.md index 58351ac..d7bdfe6 100644 --- a/docs/licensing.md +++ b/docs/licensing.md @@ -6,14 +6,14 @@ Tokmeter's applications (CLI, TUI, web dashboard, daemon/MCP server, and macOS a ## Included materials -The npm distributions include `dist/licenses/`. The macOS app includes `Contents/Resources/Licenses/`, accessible using **Licenses & source** in the popup footer. Each contains: +The npm distributions include `dist/licenses/`. The macOS app includes `Contents/Resources/Licenses/`, accessible using **Licenses** in the popup footer. Each contains: - `AGPL-3.0-only.txt` — the application license. - `MPL-2.0.txt` — the core source license, including when the core is bundled in `@sriinnu/tokmeter`. - `tokmeter-source.tar.gz` — local source and build inputs collected when this artifact was packaged. The application and core source retain the licenses described above. - In the macOS app, `Sparkle.txt` — the complete notices supplied with the bundled Sparkle artifact, including its embedded third-party components. -JavaScript dependencies are installed separately by the package manager; their notices reside in their respective installed packages. The source snapshot includes the dependency manifests and lockfile. Build tools and platform SDKs are obtained separately. +JavaScript dependencies are installed separately by the package manager; their notices reside in their respective installed packages. The source snapshot includes the dependency manifests, lockfile, integration skills, and synthetic fixture used by native contrast tests. Build tools and platform SDKs are obtained separately. Extract the source archive, install Bun and Node.js, and run `bun install --frozen-lockfile` followed by `bun run build` from its root. For the native app, install Xcode on macOS and run `swift build -c release --package-path packages/macos-bar`. Use `bash packages/macos-bar/bundle.sh --no-install` for a local ad-hoc bundle. Apple distribution credentials are not needed for a local build and are never included. diff --git a/docs/macos-completion.md b/docs/macos-completion.md new file mode 100644 index 0000000..b08a904 --- /dev/null +++ b/docs/macos-completion.md @@ -0,0 +1,38 @@ +--- +type: Note +status: Active +--- + +# macOS completion + +Owner: Srinivas + Codex. Started 2026-09-06 after the 1.10.0 release. Workflow: branch → reviewed PR → main. **This work is complete only when all six rows are closed with the evidence below.** Passing source tests, publishing a release, and preparing a trial kit do not close the corresponding real-world checks. + +| ID | Area | Status | Completion evidence | +|---|---|---|---| +| MAC-01 | Clean installation and first use | In progress | Published app installed on a fresh macOS account/VM or another Mac, with no Tokmeter checkout/global package; supported Node installation discovered; missing prerequisites explained; daemon starts once and real Claude/Codex usage appears. Record app, daemon, OS, architecture, and install path. | +| MAC-02 | Sustained reliability | Open | At least 72 elapsed hours on one identified installed build, recording app/daemon identity, readiness, RSS/CPU, and recovery. Include natural sleep/wake, a reboot, local midnight, and a controlled daemon restart. Fix unexplained failures and rerun affected evidence. | +| MAC-03 | Real Sparkle upgrade | Open | An installed, notarized prior version discovers the published update, downloads/verifies/replaces/relaunches through Sparkle, and resumes usage. Record before/after versions and preservation of settings/history. Signature-only checks do not close this. | +| MAC-04 | Accounting coverage | Open | Independently reconcile numeric live samples for the primary Claude Code/Codex paths, cover resets/replays/mixed formats/cache/reasoning/day boundaries, and qualify every other advertised integration by observed evidence. Explicitly list unavailable data and un-audited historical ranges. | +| MAC-05 | macOS usability and accessibility | Open | Keyboard-only and VoiceOver walkthroughs; small-screen/text clipping checks; Today/history projects discoverable; empty/offline/error states actionable. Fix issues and record the actual app/build used. | +| MAC-06 | Five-person week-long trial | Needs participants | Five consenting macOS users complete seven days of ordinary use and supply the trial feedback. Log install problems, accounting mismatches, repeat use, and fixes. Invitations require selected recipients and explicit send authorization. No recruitment or trial completion is claimed yet. | + +## Current checkpoint + +- Baseline: published 1.10.0 (46), Apple Silicon, macOS 14+. Release source tag `v1.10.0`; release and distribution PRs #73/#74 merged. +- Active branch: `fix/macos-first-run-and-reliability`. +- MAC-01 finding: auto-start uses `npx @sriinnu/tokmeter daemon start`, but that package dynamically imports Drishti without installing it. The monorepo masks this missing dependency. Auto-start must invoke the published daemon package directly. +- MAC-01 finding: toolchain discovery only checks `/opt/homebrew/bin/npx` and `/usr/local/bin/npx`; managed Node installations and missing prerequisites need explicit handling. +- MAC-02 finding: generic API/decode/version errors enter the same auto-start path as an unreachable daemon. Recovery and incompatible-data states need distinct treatment. +- Spare Mac/VM availability and participant selection requested; independent implementation continues while those are identified. +- First-use fixes implemented on the active branch: invoke the version-matched Drishti package, discover paired Node/npx in system and managed installations, provide Install Node/Retry actions, preserve protocol errors, and drain bounded subprocess output continuously. Native checks: 22 passed, one optional demo render skipped. Fresh-machine acceptance remains open. +- Reproduced the published CLI-only failure from an isolated npm installation: `tokmeter daemon status` cannot resolve `@sriinnu/drishti`. No daemon or usage data was modified by this reproduction. +- MAC-05 source fixes: content-sized popup, explicit disclosure text color, readable Paper model costs, wrapping signal readings, and Noise retired from the picker. The first local build collapsed its body; the failure was reproduced with the full popup and corrected using direct geometry observations. [Review and validation](macos/popover-usability.md): 26 native tests passed, one optional render skipped, including full-popup first-layout and live disclosure-binding regressions. Installed-app interaction, keyboard, and VoiceOver acceptance remains open. +- Signed commit/PR handoff is pending the configured hardware signing key, which was unavailable on the last attempt. These branch changes have not reached main. Corrected local test build 1.10.0 (46.2) replaced 46.1 and was relaunched from `/Applications` on 2026-09-08; binary identity and local signature verified. The Git signing key is not required for local installation. +- Latest local build: 1.10.0 (46.6), installed and running from `/Applications`. Status colors and badge backgrounds now follow the selected theme explicitly. The user's 46.5 screenshot proved the previous adaptive-color fix still failed in the live popup. Opposing-host native widget captures now assert selected-theme pixels; 27 native tests passed. Binary/signature identity verified; the user accepted 46.6 contrast on 2026-09-08. Remaining interaction/accessibility acceptance stays open. + +## Evidence and closure rules + +- Record commands/results and build identity in focused documents under `docs/macos/`; keep raw usage, paths identifying private projects, transcripts, and credentials out of committed evidence. +- Long-running observations record elapsed time and identity changes; a restart or changed build starts a new segment and cannot silently inherit a completed soak. +- Source/fixture checks may close substeps, never the fresh-Mac, real-update, observed accessibility, or week-long user criteria by themselves. +- Keep this note current in each PR. Completed rows link to evidence and the merged fix. Unresolved rows stay open with the next action and dependency. diff --git a/docs/macos/first-use.md b/docs/macos/first-use.md new file mode 100644 index 0000000..45abd63 --- /dev/null +++ b/docs/macos/first-use.md @@ -0,0 +1,35 @@ +--- +type: Note +status: Active +--- + +# MAC-01: first use + +Status: implementation and local regression checks complete; fresh-Mac acceptance open. Changes start from released 1.10.0 and are tracked in [macOS completion](../macos-completion.md). + +## Reproduction and fixes + +A fresh temporary npm prefix containing only `@sriinnu/tokmeter@1.10.0` fails on `tokmeter daemon status`: its dynamic import of `@sriinnu/drishti` cannot resolve. The app used this same package to start the daemon. A workspace or global installation containing both packages masked the defect. + +The app now invokes the Drishti package matching its own version. Drishti declares its dependency on Tokmeter, so npm resolves the complete daemon installation. A first download has a bounded 120-second budget. Subprocess stdout/stderr are drained concurrently with bounded retained output, preventing a full pipe from wedging npm until timeout. + +Node discovery requires executable Node and npx in the same installation. It supports the standard Homebrew/local paths and conventional Volta, nvm, fnm, mise, and asdf installation directories without sourcing shell profiles. Missing/old Node presents installation and retry actions. Protocol/decode errors from a running service remain visible instead of triggering another daemon launch. + +## Local evidence + +- Published CLI-only reproduction failed with the expected missing Drishti import; no daemon was started or stopped. +- Managed-installation fixtures cover absent Node, unpaired npx, numeric version selection, and the GUI subprocess PATH. +- Runner tests cover 512 KiB on each output pipe, bounded retention, nonzero exit, a missing executable, and timeout. +- Protocol mismatch test checks that warming/fresh flags and live color claims clear without starting a second service. +- The real error views are rendered at 320 points for text/action layout inspection. This is visual fixture evidence, not a VoiceOver or fresh-Mac result. +- Native suite: 22 passed; one unrelated optional walkthrough render skipped. Repository lint and whitespace checks passed. + +## Fresh-Mac acceptance, still required + +1. Use another Apple Silicon Mac, a fresh macOS account, or a macOS VM. Record OS and published app/daemon versions. +2. With Node absent, verify the prerequisite explanation and installation action. Install supported Node using a normal method; do not add a Tokmeter checkout or global Drishti package. +3. Open the app and retry. Verify one daemon starts, the app moves out of warming, and the expected Claude/Codex usage appears. +4. Restart the app and verify it attaches to the same daemon without another scan process. +5. Exercise offline download failure and recovery; confirm errors are actionable and retry is bounded. + +Record observed results before closing MAC-01. A temporary npm directory on the development Mac is not a substitute for this check. diff --git a/docs/macos/popover-usability.md b/docs/macos/popover-usability.md new file mode 100644 index 0000000..84d818a --- /dev/null +++ b/docs/macos/popover-usability.md @@ -0,0 +1,66 @@ +--- +type: Note +status: Active +--- + +# Popover usability + +Source checkpoint: 2026-09-08, branch `fix/macos-first-run-and-reliability`. + +The installed 1.10.0 (46) screenshots showed a large empty area below collapsed Usage details. The root imposed a 520-point minimum height while its scroll view consumed spare space. The source now measures the scroll content and header/footer, fits short content, and caps long content against the screen's visible height (780 points maximum). A hosting-view regression exercises small → overflowing → small content and verifies the panel returns to its original height. + +Usage details now uses the theme's explicit primary text color. Paper's model cost numbers also use dark text; bright tier colors remain in its bars. Signal readings wrap intact instead of competing for one truncated row. + +## Theme review + +Reviewed production hero and usage views with synthetic demo data, expanded and collapsed, across the seven previously selectable themes. Keep Terminal, Paper, Nebula, Aurora, Nocturne, and Glass. Remove Noise from the picker: its yellow background, white cards, and bright cost colors compete with the readings. Its enum case remains readable for existing saved preferences, consistent with the other retired themes. + +Render artifacts are local, under `/tmp/tokmeter-ui-qa`. These are fixture views, not screenshots of an installed build. Material appearance also depends on the live desktop. Aggregate KPI values remain zero in this fixture because it supplies today's model/project data only. + +## Validation + +From `packages/macos-bar`: + +```sh +TOKMETER_UI_QA_DIR=/tmp/tokmeter-ui-qa CLANG_MODULE_CACHE_PATH=/tmp/tokmeter-clang-cache swift test --disable-sandbox +``` + +Initial result: 24 passed, one optional public walkthrough render skipped, zero failures. Theme and startup renders ran. That first layout regression exercised an isolated scroll area and missed the full-popup failure described below. MAC-05 remains open. + +Local installation: 2026-09-08, `/Applications/TokmeterBar.app` version 1.10.0 (46.1), ad-hoc signed development build. The installed executable matches the branch build (SHA-256 `59507f0367e6b3b6fa74d9c8c691e4a9a4fdc7fb374d4e131479fad691c58c83`), deep/strict signature verification passed, and the installed process was observed running. The previous app was backed up locally. This is not a published/notarized release or proof of the pending interaction checks. + +## First-layout regression and correction + +The user observed that build 46.1 displayed only the hero and footer. Reproduced with the full production `TokmeterBarView` in an initially one-point-high AppKit hosting window: the entire popup remained 158 points high. The earlier isolated scroll test passed against the same broken implementation. + +Replace propagated height preferences with direct geometry observations for the scroll content, hero, error area, and footer. The first layout starts with the available screen budget so content can be measured; later layouts use its actual height. The cap follows the hosting window's screen and screen-configuration changes. This keeps the popup content-sized and scrolls only when required. + +Expanded and collapsed full-popup tests now verify that the body is present on first layout, grows when model/project records arrive, and shrinks when those records disappear. A separate test drives the production Usage details binding through expand → smaller viewport → larger viewport → collapse and verifies the resulting heights. Full production-view renders were inspected in both states. Updated native result: 26 passed, one optional walkthrough render skipped, zero failures. Actual installed MenuBarExtra interaction, multiple-monitor positioning, overlays, keyboard navigation, and VoiceOver remain unverified; the native automation service was unavailable during this correction. + +Corrected local installation: 1.10.0 (46.2) installed and relaunched from `/Applications` on 2026-09-08. Installed executable SHA-256 `aea32ed7092eb78b11c363c8a7e2eb68987062dfdebf6594eafe8915280124a0` matches the new branch build; deep/strict ad-hoc signature verification passed and the installed process was observed running. Build 46.1 has been replaced. + +## Full-row control and frosted Glass + +Usage details now has one full-width button containing its chevron and label. Clicking either, or the space beside the label, runs the same toggle action. The button also supports keyboard activation and exposes its expanded/collapsed state to accessibility. + +Glass now uses one native `NSVisualEffectView` behind-window blur, a subtle frost tint, and translucent cards shared by the popup and Hub. Nested material layers and animated header gloss were removed; icy high-contrast values replace the muted slate/beige palette. Reduce Transparency uses an opaque fallback. Full production popup renders for Glass and Nebula were inspected, collapsed and expanded; 26 native tests passed and one optional walkthrough render was skipped. Wallpaper-dependent appearance and actual pointer/VoiceOver interaction still require live observation. + +Installed local build 1.10.0 (46.3) in `/Applications` on 2026-09-08. Executable SHA-256 `8d64d7f70545c7c07b87e78f138a5c88f147aca0d79a489e8e04b925abb39854` matches the branch build; deep/strict ad-hoc signature verification passed and the installed process was observed running. Lint, whitespace, and staged secret checks passed. + +## Light frost and footer hierarchy + +Glass now uses the light native popover material, a pale frost tint, dark ink text, and low-contrast translucent cards. The header blends into the main surface instead of ending in a separate rounded slab. Its opaque accessibility fallback uses the same light palette. + +The footer separates app details (credit, exact version/build, licenses) from pricing freshness and warnings. All labels stay on one line in the inspected fixtures, including simultaneous unpriced and repriced badges. The credit, license link, and repricing badge are native buttons. Full popup renders were reviewed in Glass, Terminal, Paper, and Nebula, expanded and collapsed, with all pricing indicators present. Native checks: 26 passed, one optional walkthrough render skipped. Live wallpaper-dependent appearance and pointer/VoiceOver checks remain open. + +Installed local build 1.10.0 (46.4) in `/Applications` on 2026-09-08; executable SHA-256 `e5d009f85c03d574b4e98027597724e961a9c4b35491dc981cf63e3d634c6bdc`. Binary identity, deep/strict ad-hoc signature, and running installed process verified. Lint, whitespace, and staged secret checks passed. + +## Contrast on light surfaces + +User screenshots of installed builds 46.4 and 46.5 showed washed-out yellow Pace text and green percentage badges. The 46.5 dynamic `NSColor` fix passed preview tests but failed in the live menu window: status colors still followed the host appearance. Those tests did not cover the actual mismatch. + +The replacement resolves warning/success/danger colors directly from `AppTheme`. Light surfaces use deep amber, green, and red ink; dark surfaces retain luminous accents. Percentage badges also choose their pale backing directly from the theme. The popup and Hub set an explicit SwiftUI color-scheme environment for native controls. Signal icons, cache bars, footer status, and pricing warnings use the same explicit palette. + +Regression checks render both light and dark themes under opposing color schemes and assert invariant status ink with at least 4.5:1 contrast against representative surfaces. Production Pace and percentage widgets are captured inside a Dark Aqua native window with a dark SwiftUI environment, even for Glass and Paper; pixel assertions require the selected theme's actual ink. Full production popup fixtures also run under a dark host. Glass, Paper, and Terminal widget captures were visually inspected. This checks the host mismatch and representative backgrounds; it is not a blanket accessibility claim for every wallpaper. + +Native result: 27 passed, one optional walkthrough render skipped. Lint, whitespace, and the full repository secret guard passed. Installed local build 1.10.0 (46.6) in `/Applications` on 2026-09-08; executable SHA-256 `a3f59a1fd671205cfe7494e6325dbd5c6c100c833da99a79e951c8269f5f4326`. Binary identity, deep/strict ad-hoc signature, and running installed process verified. The user accepted the installed 46.6 contrast revision on 2026-09-08. Agent inspection covered the native-host fixture captures; automated live capture remained unavailable. Keyboard, VoiceOver, and broader usability gates stay open. diff --git a/packages/cli/README.md b/packages/cli/README.md index b03b06b..991d81f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,23 +1,17 @@ -

- tokmeter -

- -

@sriinnu/tokmeter-cli

- -

Token usage tracker CLI -- table and JSON output

- ---- +# CLI Command-line interface for tokmeter. Scans all local AI agent sessions and displays usage in formatted tables or JSON. ## Install +This is a private workspace package. Install the public `@sriinnu/tokmeter` distribution. + ```bash # Run directly -npx @sriinnu/tokmeter-cli +npx @sriinnu/tokmeter # Or install globally -npm install -g @sriinnu/tokmeter-cli +npm install -g @sriinnu/tokmeter tokmeter ``` @@ -36,7 +30,7 @@ tokmeter restore [--latest|--id] # restore from ~/.cache/tokmeter/backups/ tokmeter alias ... # manage project display names, tags, hidden (see below) ``` -See the top-level README for the full cross-machine backup/restore workflow. +See [backup and restore](../../docs/backup-restore.md) for the cross-machine workflow. ## Aliases @@ -108,4 +102,4 @@ tokmeter --light # skip pricing (faster) ## License -MIT +AGPL-3.0-only — [license text](../../LICENSE). See [licenses and source](../../docs/licensing.md). diff --git a/packages/cli/SKILL.md b/packages/cli/SKILL.md index 72358f7..e7e6313 100644 --- a/packages/cli/SKILL.md +++ b/packages/cli/SKILL.md @@ -1,5 +1,7 @@ # @sriinnu/tokmeter-cli +Private workspace package. Use the public `@sriinnu/tokmeter` distribution. + Command-line interface for token usage tracking. Displays usage in formatted tables or JSON. ## Capabilities @@ -21,3 +23,7 @@ tokmeter pricing sonnet # lookup pricing tokmeter --json # JSON output tokmeter --today # today only ``` + +## License + +AGPL-3.0-only; see [licenses and source](../../docs/licensing.md). diff --git a/packages/core/README.md b/packages/core/README.md index d82de44..64520ca 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,25 +1,19 @@ -

- tokmeter -

+# Core API -

@sriinnu/tokmeter-core

- -

Session parsers, aggregation, and pricing for 16+ AI coding agents

- ---- - -The engine behind tokmeter. Scans local session files, parses token records from 16+ AI agent formats, enriches them with model pricing via [`@sriinnu/kosha-discovery`](https://www.npmjs.com/package/@sriinnu/kosha-discovery), and exposes a clean API for aggregation. +The engine behind tokmeter. Scans local session files, parses token records from 16+ AI agent formats, enriches them with model pricing via [`@sriinnu/kosha-discovery`](https://www.npmjs.com/package/@sriinnu/kosha-discovery), and exposes an API for aggregation. ## Install +This is a private workspace package. Install the public `@sriinnu/tokmeter` distribution. + ```bash -npm install @sriinnu/tokmeter-core +npm install @sriinnu/tokmeter ``` ## Usage ```typescript -import { TokmeterCore, sumUsage } from "@sriinnu/tokmeter-core"; +import { TokmeterCore, sumUsage } from "@sriinnu/tokmeter"; const core = new TokmeterCore(); const records = await core.scan(); @@ -88,4 +82,4 @@ immutability, and daemon model. ## License -MIT +MPL-2.0 — [license text](LICENSE). See [licenses and source](../../docs/licensing.md). diff --git a/packages/core/SKILL.md b/packages/core/SKILL.md index a239394..0af17a7 100644 --- a/packages/core/SKILL.md +++ b/packages/core/SKILL.md @@ -1,22 +1,28 @@ # @sriinnu/tokmeter-core +Private workspace package. Use the public `@sriinnu/tokmeter` distribution. + Core engine for token usage tracking. Provides session parsers for 16+ AI agent formats, token aggregation, and model pricing via kosha-discovery. ## Capabilities - Parse session files from Claude Code, Codex, Cursor, Gemini, OpenCode, and 11 more providers - Aggregate tokens by project, model, provider, and time period -- Enrich records with accurate pricing (input, output, cache, reasoning tokens) +- Enrich records with estimated API pricing (input, output, cache, reasoning tokens) - 4-tier pricing: kosha direct, static table, kosha fuzzy, null - Filter by date range, provider, project ## API ```typescript -import { TokmeterCore } from "@sriinnu/tokmeter-core"; +import { TokmeterCore } from "@sriinnu/tokmeter"; const core = new TokmeterCore(); const records = await core.scan({ today: true }); const stats = core.getStats(); const models = core.getModelCosts(); const daily = core.getDailyBreakdown(); ``` + +## License + +MPL-2.0; see [licenses and source](../../docs/licensing.md). diff --git a/packages/macos-bar/README.md b/packages/macos-bar/README.md index 027a4a5..6316297 100644 --- a/packages/macos-bar/README.md +++ b/packages/macos-bar/README.md @@ -1,102 +1,66 @@ # TokmeterBar — macOS menubar companion -A native menubar app that shows live token usage and cost from the Drishti -daemon. Built with SwiftUI's `MenuBarExtra`. +A native SwiftUI `MenuBarExtra` and companion Hub for local token usage and cost telemetry. Release builds target Apple silicon and macOS 14+. -## Architecture +## Start -``` - ┌─────────────────────┐ - │ TokmeterBar.app │ - │ (SwiftUI menubar) │ - └──────────┬──────────┘ - │ HTTP GET - │ http://127.0.0.1:9877/api/* - ▼ - ┌─────────────────────┐ - │ Drishti Daemon │ - │ (Node.js) │ - └──────────┬──────────┘ - │ scans - ▼ - ~/.claude /.codex / etc. -``` +Install Node.js 18+ with npx, then open TokmeterBar from `/Applications`. +The current source discovers paired Node/npx in common system and managed installations and starts the version-matched `@sriinnu/drishti` daemon when it is unavailable. The first download needs network access. Missing prerequisites and startup failures show an explanation and Retry control. -The app connects to the Drishti daemon's HTTP REST API on `localhost:9877`. -The daemon must be running: +For the published 1.10.0 build, install and start the daemon explicitly: ```sh +npm install -g @sriinnu/drishti drishti daemon start +open /Applications/TokmeterBar.app ``` -## Build - -```sh -cd packages/macos-bar -./bundle.sh # build + create TokmeterBar.app -./bundle.sh --install # also copy to /Applications -``` +The app reads HTTP telemetry from `http://127.0.0.1:9877`. It does not run a separate full-history CLI scan for each refresh. See [first-use validation](../../docs/macos/first-use.md). -This builds with `swift build -c release`, wraps the binary in a proper `.app` -bundle with `Info.plist` (`LSUIElement=true` so it has no Dock icon), and -ad-hoc signs it so macOS Gatekeeper allows local execution. +## Use -## Run +The menubar shows today's tokens. Open it for estimated API cost, tool-reported cost when available, and today's models and projects. The full **Usage details** row expands lifetime totals, trends, and signals. The popup fits its content and scrolls when it reaches the available height. The Hub offers larger breakdowns and settings. -```sh -# Start the daemon if it isn't already -drishti daemon start +Six themes are selectable: Terminal, Paper, Nebula, Aurora, Nocturne, and Glass. Glass uses native light desktop frost, dark ink, and explicit theme-based status colors; Reduce Transparency selects an opaque fallback. The footer separates version and licensing from pricing status. -# Launch the app -open /Applications/TokmeterBar.app -``` +Refresh frequency is configurable. Costs are not a verified subscription bill; missing cost data is shown as unavailable. See [how the numbers work](../../docs/how-the-numbers-work.md) and [popover validation](../../docs/macos/popover-usability.md). -The menubar icon shows today's tokens. Click it to see: -- Today's tokens, estimated API cost, and tool-reported cost separately -- Today's models, with Today/All and Show all controls -- Today's projects -- Usage details: lifetime totals, trends, cache and other signals +## Build and test -Costs are not a verified subscription bill. Missing cost data is shown as unavailable. -See [how the numbers work](../../docs/how-the-numbers-work.md). +Install Xcode, then run from the repository root: -It refreshes every 30 seconds. +```sh +bun run bar:build # build an ad-hoc signed .app without installing +bun run bar # build, install to /Applications, and launch +swift test --package-path packages/macos-bar +``` -## File layout +The bundle script creates `packages/macos-bar/TokmeterBar.app`, includes license texts and source, and signs it. Local ad-hoc signing does not provide Developer ID or notarization. -``` -packages/macos-bar/ -├── Package.swift — SPM manifest -├── bundle.sh — build + bundle script -├── Sources/TokmeterBar/ -│ ├── TokmeterBarApp.swift — @main App entry point -│ ├── TokmeterBarView.swift — SwiftUI popover content -│ ├── TokmeterLoader.swift — observable loader (timer + async fetch) -│ ├── DaemonClient.swift — HTTP client for the daemon REST API -│ └── Models.swift — data shapes -└── README.md — this file -``` +For optional synthetic UI captures, create an output directory and set `TOKMETER_UI_QA_DIR` before running the native tests. These fixtures read no local usage. Live pointer, keyboard, and VoiceOver checks remain separate. -## Daemon API endpoints used +## Architecture -| Method | Path | Purpose | -|--------|------------------|---------------------------------------------| -| GET | `/api/stats` | Total cost, total tokens, projects, streak | -| GET | `/api/daily` | Daily breakdown (used for today + 7-day) | -| GET | `/api/models` | Per-model cost ranking (top 3 displayed) | +- `TokmeterBarView.swift` and `UsageOverview.swift`: popup layout and disclosure. +- `TokmeterLoader.swift`: observable telemetry, refresh, and connection state. +- `NodeToolchain.swift` and `SubprocessRunner.swift`: Node discovery and bounded startup commands. +- `DaemonClient.swift`: version-checked REST client. +- `Theme.swift`, `Theme+Modes.swift`, and `FrostedGlass.swift`: colors and native surfaces. +- `HubView.swift`: full-window companion. -GET endpoints are read-only and require no authentication. POST endpoints -(cleanup, restore) require a bearer token from `/tmp/drishti-daemon.token` -but the menubar app doesn't currently use them. +GET endpoints cover quick/readiness state, stats, daily usage, models, sessions/projects, signals, pricing, and health. User-triggered pricing updates, deep rescans, and live Antigravity fetches use POST requests authenticated by the daemon's local bearer token. See `DaemonClient.swift` for the exact routes. ## Distribution -For local use, the ad-hoc signature in `bundle.sh` is enough. For -distribution outside your machine you'd need: +The existing scripts support Developer ID signing, notarization, stapling, Sparkle update metadata, and ZIP packaging: + +```sh +bun run bar:signed # Developer ID signed bundle +bun run bar:release # signed, notarized, stapled ZIP and appcast entry +``` + +These commands require the configured Apple distribution credentials and Sparkle signing key. See [the release pipeline](RELEASE.md). Publishing is a separate step. The [six-area completion tracker](../../docs/macos-completion.md) records acceptance work still open; a successful build does not close it. -1. An Apple Developer ID certificate -2. Replace `codesign --sign -` with `codesign --sign "Developer ID Application: Your Name"` -3. Run `xcrun notarytool submit` to notarize -4. Build a DMG with `create-dmg` or similar +## License -Not implemented yet — only ad-hoc local builds. +AGPL-3.0-only — [license text](../../LICENSE). Core source retains MPL-2.0. The footer's **Licenses** button opens `Contents/Resources/Licenses/`, containing both texts, Sparkle notices, and the build source snapshot. See [licenses and source](../../docs/licensing.md). diff --git a/packages/macos-bar/Sources/TokmeterBar/AnomalyDetail.swift b/packages/macos-bar/Sources/TokmeterBar/AnomalyDetail.swift index 95b6f18..96484b4 100644 --- a/packages/macos-bar/Sources/TokmeterBar/AnomalyDetail.swift +++ b/packages/macos-bar/Sources/TokmeterBar/AnomalyDetail.swift @@ -7,9 +7,7 @@ import AppKit import SwiftUI -/// Tappable footer pill. Pressed state squashes 0.97 with a quick spring -/// (anticipation), releases on tap, then triggers `onTap`. Pixar: motion -/// confirms the gesture before the sheet starts to rise. +/// A compact native button opens the pricing breakdown on click or keyboard activation. struct AnomalyPill: View { let text: String let detailCount: Int @@ -17,38 +15,28 @@ struct AnomalyPill: View { let theme: AppTheme let onTap: () -> Void - @State private var pressed = false @State private var hovered = false var body: some View { - Text(text + " ›") - .font(.system(size: 10, weight: .medium, design: theme.fonts.bodyDesign)) - .foregroundColor(.red) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 5) - .fill(Color.red.opacity(hovered ? 0.12 : 0.06)) - ) - // Squash 0.92 instead of 0.96 — at ~80pt wide / 10pt text, the - // smaller deformation was below the perceptual floor. 90ms dwell - // (was 120ms) keeps the confirm snappy. The press feels like - // touching a real button instead of "did it register?" - .scaleEffect(pressed ? 0.92 : 1.0) - .animation(.spring(response: 0.22, dampingFraction: 0.6), value: pressed) - .animation(.easeInOut(duration: 0.15), value: hovered) - .onHover { hovered = $0 } - .onTapGesture { - pressed = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.09) { - pressed = false - onTap() - } - } - .help( - "Click for the per-field breakdown — \(detailCount) field " - + "movement(s) across \(modelCount) model(s)." - ) + Button(action: onTap) { + Text(text + " ›") + .font(.system(size: 10, weight: .medium, design: theme.fonts.bodyDesign)) + .foregroundColor(theme.statusDanger) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(theme.statusDanger.opacity(hovered ? 0.12 : 0.06)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .animation(.easeInOut(duration: 0.15), value: hovered) + .onHover { hovered = $0 } + .help( + "Click for the per-field breakdown — \(detailCount) field " + + "movement(s) across \(modelCount) model(s)." + ) } } @@ -247,7 +235,7 @@ private struct AnomalyFieldRow: View { private var bg: BackgroundMode { theme.backgroundMode } private var sign: String { row.deltaPct > 0 ? "↑" : "↓" } private var deltaColor: Color { - row.deltaPct > 0 ? Color.tokDanger : Color.tokSuccess + row.deltaPct > 0 ? theme.statusDanger : theme.statusSuccess } var body: some View { diff --git a/packages/macos-bar/Sources/TokmeterBar/CardBackground.swift b/packages/macos-bar/Sources/TokmeterBar/CardBackground.swift index 563bf4a..41a887c 100644 --- a/packages/macos-bar/Sources/TokmeterBar/CardBackground.swift +++ b/packages/macos-bar/Sources/TokmeterBar/CardBackground.swift @@ -104,16 +104,7 @@ struct CardBackground: View { } case .glassFrost: - ZStack { - RoundedRectangle(cornerRadius: radius).fill(.ultraThinMaterial) - RoundedRectangle(cornerRadius: radius).fill(role.opacity(0.08)) - RoundedRectangle(cornerRadius: radius) - .strokeBorder(LinearGradient( - colors: [Color.white.opacity(0.35), Color.white.opacity(0.05)], - startPoint: .top, endPoint: .bottom - ), lineWidth: 1) - } - .shadow(color: Color.black.opacity(0.15), radius: 8, x: 0, y: 4) + FrostedGlassPanel(cornerRadius: radius, tint: role) case .auroraGlass: // Thin material that lets the drifting bg show through, with a diff --git a/packages/macos-bar/Sources/TokmeterBar/CompositionFill.swift b/packages/macos-bar/Sources/TokmeterBar/CompositionFill.swift index 2223304..1f5e53b 100644 --- a/packages/macos-bar/Sources/TokmeterBar/CompositionFill.swift +++ b/packages/macos-bar/Sources/TokmeterBar/CompositionFill.swift @@ -27,7 +27,7 @@ func tierColor(_ tier: TokenTier, theme: AppTheme) -> Color { let c = theme.colors switch tier { case .output: return c.warm - case .cacheRead: return Color.tokSuccess + case .cacheRead: return theme.statusSuccess case .cacheWrite: return c.accent case .input: return c.secondary case .reasoning: return c.tertiary diff --git a/packages/macos-bar/Sources/TokmeterBar/ConnectionIssueView.swift b/packages/macos-bar/Sources/TokmeterBar/ConnectionIssueView.swift new file mode 100644 index 0000000..f277cff --- /dev/null +++ b/packages/macos-bar/Sources/TokmeterBar/ConnectionIssueView.swift @@ -0,0 +1,36 @@ +import AppKit +import SwiftUI + +struct ConnectionIssueView: View { + let error: String + let needsNodeSetup: Bool + let isRetrying: Bool + let retry: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "bolt.trianglebadge.exclamationmark.fill") + .foregroundColor(.orange) + .font(.system(size: 12)) + Text(error) + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundColor(.primary.opacity(0.8)) + .fixedSize(horizontal: false, vertical: true) + } + HStack(spacing: 12) { + if needsNodeSetup { + Button("Install Node.js") { + NSWorkspace.shared.open(URL(string: "https://nodejs.org/en/download")!) + } + } + Button("Retry", action: retry).disabled(isRetrying) + } + .font(.system(size: 11, weight: .medium)) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.orange.opacity(0.12))) + .accessibilityElement(children: .contain) + } +} diff --git a/packages/macos-bar/Sources/TokmeterBar/ContentSizedScrollView.swift b/packages/macos-bar/Sources/TokmeterBar/ContentSizedScrollView.swift new file mode 100644 index 0000000..01ed2d5 --- /dev/null +++ b/packages/macos-bar/Sources/TokmeterBar/ContentSizedScrollView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +/// Shrinks when disclosure content closes, while keeping long content scrollable. +struct ContentSizedScrollView: View { + let maximumHeight: CGFloat + @ViewBuilder var content: Content + @State private var contentHeight: CGFloat? + + var body: some View { + ScrollView(.vertical, showsIndicators: true) { + content + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in + guard height > 0 else { return } + contentHeight = height + } + } + // Give the initial layout room to measure its content. A one-point + // bootstrap can prevent a popover's scroll content from ever mounting. + .frame(height: min(contentHeight ?? maximumHeight, maximumHeight)) + } +} diff --git a/packages/macos-bar/Sources/TokmeterBar/ContextTelemetryPanel.swift b/packages/macos-bar/Sources/TokmeterBar/ContextTelemetryPanel.swift index b927b09..3ef0d46 100644 --- a/packages/macos-bar/Sources/TokmeterBar/ContextTelemetryPanel.swift +++ b/packages/macos-bar/Sources/TokmeterBar/ContextTelemetryPanel.swift @@ -45,7 +45,7 @@ struct ContextTelemetryPanel: View { value: "\(pct(miss))%", tokens: cache.inputTokens, fill: miss, - color: miss >= 0.35 ? Color.tokWarning : bg.secondaryTextColor, + color: miss >= 0.35 ? theme.statusWarning : bg.secondaryTextColor, icon: "tray", theme: theme ) @@ -109,7 +109,7 @@ struct ContextTelemetryPanel: View { /// so it stops duplicating MISS when WRITE is 0%. Warning tint past 45%. private var freshSummary: some View { let freshTokens = cache.freshInputTokens ?? cache.inputTokens + (cache.cacheWriteTokens ?? 0) - let tint = fresh >= 0.45 ? Color.tokWarning : c.tertiary + let tint = fresh >= 0.45 ? theme.statusWarning : c.tertiary return HStack(spacing: 6) { Image(systemName: "plus.rectangle.fill") .font(.system(size: 9, weight: .semibold)) @@ -151,9 +151,9 @@ struct ContextTelemetryPanel: View { } private func cacheColor(_ rate: Double) -> Color { - if rate >= 0.90 { return Color.tokSuccess } - if rate >= 0.60 { return Color.tokWarning } - return Color.tokDanger + if rate >= 0.90 { return theme.statusSuccess } + if rate >= 0.60 { return theme.statusWarning } + return theme.statusDanger } } @@ -276,7 +276,7 @@ private struct ProjectContextRow: View { .help(project.project) Spacer(minLength: 4) mini("H", project.cacheHitRate, color: cacheColor(project.cacheHitRate)) - mini("M", project.missRate, color: project.missRate >= 0.35 ? Color.tokWarning : bg.secondaryTextColor) + mini("M", project.missRate, color: project.missRate >= 0.35 ? theme.statusWarning : bg.secondaryTextColor) mini("D", project.dragShare, color: accent) } .padding(.vertical, 3) @@ -305,17 +305,17 @@ private struct ProjectContextRow: View { } private func cacheColor(_ rate: Double) -> Color { - if rate >= 0.90 { return Color.tokSuccess } - if rate >= 0.60 { return Color.tokWarning } - return Color.tokDanger + if rate >= 0.90 { return theme.statusSuccess } + if rate >= 0.60 { return theme.statusWarning } + return theme.statusDanger } private var accent: Color { switch project.contextStatus { case "critical": - return Color.tokDanger + return theme.statusDanger case "high": - return Color.tokWarning + return theme.statusWarning case "medium": return theme.colors.tertiary default: @@ -444,9 +444,9 @@ private struct ContextDragRow: View { private var accent: Color { switch pressure.status { case "critical": - return Color.tokDanger + return theme.statusDanger case "high": - return Color.tokWarning + return theme.statusWarning case "medium": return theme.colors.tertiary default: diff --git a/packages/macos-bar/Sources/TokmeterBar/DataSections.swift b/packages/macos-bar/Sources/TokmeterBar/DataSections.swift index 2da1b9c..4daf4fd 100644 --- a/packages/macos-bar/Sources/TokmeterBar/DataSections.swift +++ b/packages/macos-bar/Sources/TokmeterBar/DataSections.swift @@ -204,7 +204,9 @@ struct ModelsSection: View { /// Tied to the bar's own threshold via the shared helper so a mixed-color /// bar always pairs with a neutral $. private func costTint(for model: ModelUsage) -> Color { - dominantTierColor( + // Bright tier colors work as bars on cream, but wash out small numbers. + if theme.backgroundMode.isLight { return theme.backgroundMode.primaryTextColor } + return dominantTierColor( output: model.outputTokens, cacheRead: model.cacheReadTokens, cacheWrite: model.cacheWriteTokens, @@ -502,10 +504,7 @@ struct SessionsSection: View { .overlay(RoundedRectangle(cornerRadius: radius) .strokeBorder(c.primary.opacity(0.35), lineWidth: 0.8)) case .glassFrost: - RoundedRectangle(cornerRadius: radius) - .fill(.ultraThinMaterial) - .overlay(RoundedRectangle(cornerRadius: radius) - .strokeBorder(Color.white.opacity(0.14), lineWidth: 0.5)) + FrostedGlassPanel(cornerRadius: radius) default: RoundedRectangle(cornerRadius: radius).fill(Color.gray.opacity(0.10)) } diff --git a/packages/macos-bar/Sources/TokmeterBar/FooterBar.swift b/packages/macos-bar/Sources/TokmeterBar/FooterBar.swift index 7f9a12e..e5c425d 100644 --- a/packages/macos-bar/Sources/TokmeterBar/FooterBar.swift +++ b/packages/macos-bar/Sources/TokmeterBar/FooterBar.swift @@ -1,8 +1,6 @@ // FooterBar.swift — bottom strip of the popover with status + controls. // -// Two rows: -// 1. Attribution ("Built by sriinnu · v0.4.0") -// 2. Live heartbeat dot + Refresh + Settings + Update + Quit +// App details and pricing status have separate lines above the controls. // // The live heartbeat already exists in the hero as a richer ECG. Here we // keep a compact dot-pulse to show the daemon is reachable even when the @@ -40,82 +38,72 @@ struct FooterBar: View { } var body: some View { - VStack(spacing: 6) { + VStack(spacing: 8) { attributionRow + pricingRow controlsRow } } private var attributionRow: some View { - HStack(spacing: 4) { - Text("Built by sriinnu") - .font(.system(size: 10, design: theme.fonts.bodyDesign)) - .foregroundColor(theme.backgroundMode.secondaryTextColor) - .onTapGesture { - NSWorkspace.shared.open(URL(string: "https://github.com/sriinnu")!) - } - Text("·") - .font(.system(size: 10)) - .foregroundColor(theme.backgroundMode.secondaryTextColor) + HStack(spacing: 8) { + Button("by sriinnu") { + NSWorkspace.shared.open(URL(string: "https://github.com/sriinnu")!) + } + .buttonStyle(.plain) + .help("Built by sriinnu — open GitHub profile") + Spacer(minLength: 8) Text("v\(appVersion)") - .font(.system(size: 10, design: theme.fonts.bodyDesign)) - .foregroundColor(theme.backgroundMode.secondaryTextColor) + .monospacedDigit() if let resources = Bundle.main.resourceURL { - Button("Licenses & source") { + Text("·").opacity(0.5) + Button("Licenses") { NSWorkspace.shared.open(resources.appendingPathComponent("Licenses")) } .buttonStyle(.plain) - .font(.system(size: 9, design: theme.fonts.bodyDesign)) - .foregroundColor(theme.backgroundMode.secondaryTextColor) .help("Open license texts, third-party notices, and the source archive") } - Spacer() - // Amber pill when today's records contain models with no resolved - // pricing — silent $0 leaks would otherwise hide in the totals. - if let health = loader.healthStatus, !health.unpricedModels.isEmpty { - let count = health.unpricedModels.count - Text("⚠︎ \(count) unpriced") - .font(.system(size: 10, weight: .medium, design: theme.fonts.bodyDesign)) - .foregroundColor(.orange) - .help( - "Models with token usage but no pricing: \(health.unpricedModels.joined(separator: ", ")). Run `tokmeter update` or check ~/.kosha/registry.json." + } + .font(.system(size: 10, design: theme.fonts.bodyDesign)) + .foregroundStyle(theme.backgroundMode.secondaryTextColor) + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + } + + @ViewBuilder + private var pricingRow: some View { + if loader.pricingMtime > 0 || !(loader.healthStatus?.unpricedModels.isEmpty ?? true) + || (loader.pricingAnomalies?.total ?? 0) > 0 { + HStack(spacing: 8) { + if loader.pricingMtime > 0 { + TimelineView(.periodic(from: .now, by: 60)) { _ in + Text("Pricing \(relativeTime(loader.pricingMtime))") + .help("When model prices were last updated. Rates older than 24 hours may be stale.") + } + } else { + Text("Pricing") + } + Spacer(minLength: 0) + if let health = loader.healthStatus, !health.unpricedModels.isEmpty { + Label("\(health.unpricedModels.count) unpriced", systemImage: "exclamationmark.triangle") + .foregroundStyle(theme.statusWarning) + .help("Models with usage but no price: \(health.unpricedModels.joined(separator: ", "))") + } + if let anomalies = loader.pricingAnomalies, anomalies.total > 0 { + let collapsed = collapseAnomalies(anomalies.anomalies) + AnomalyPill( + text: "\(collapsed.count) \(collapsed.count == 1 ? "model" : "models") repriced", + detailCount: anomalies.total, + modelCount: collapsed.count, + theme: theme, + onTap: { showAnomalyPanel = true } ) - } - // Red pill when kosha logged a pricing anomaly in the last 24h. - // Catches rate-regression failures (wrong number, not null) — the - // worst class because every other defense makes the wrong number - // stickier, not less stuck. - // - // Kosha emits one anomaly per (model × pricing field). A single - // provider price update typically moves input + output + cacheRead - // together, so the raw count overstates events 2-3×. Collapse to - // one row per model in the pill; keep the per-field breakdown in - // the tooltip. - if let anomalies = loader.pricingAnomalies, anomalies.total > 0 { - let collapsed = collapseAnomalies(anomalies.anomalies) - let modelLabel = collapsed.count == 1 ? "model" : "models" - AnomalyPill( - text: "⚠︎ \(collapsed.count) \(modelLabel) repriced", - detailCount: anomalies.total, - modelCount: collapsed.count, - theme: theme, - onTap: { showAnomalyPanel = true } - ) - } - if loader.pricingMtime > 0 { - // TimelineView ticks every 60s so "2h ago" stays accurate while - // the popover is open — without it, the badge only refreshes on - // the loader's 30s data poll, which is fine for live data but - // makes a "1m ago" / "2m ago" / "3m ago" string look frozen. - TimelineView(.periodic(from: .now, by: 60)) { _ in - Text("Pricing: \(relativeTime(loader.pricingMtime))") - .font(.system(size: 10, design: theme.fonts.bodyDesign)) - .foregroundColor(theme.backgroundMode.secondaryTextColor) - .help( - "Last kosha registry fetch — older than 24h means today's reprice may be using stale rates." - ) } } + .font(.system(size: 10, design: theme.fonts.bodyDesign)) + .foregroundStyle(theme.backgroundMode.secondaryTextColor) + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) } } @@ -247,20 +235,20 @@ struct LiveHeartbeat: View { if isAlive { // Expanding ring — fades as it scales outward Circle() - .stroke(Color.green.opacity(0.8 - 0.8 * Double(phase)), lineWidth: 1) + .stroke(theme.statusSuccess.opacity(0.8 - 0.8 * Double(phase)), lineWidth: 1) .frame(width: 7, height: 7) .scaleEffect(1.0 + phase * 2.4) } Circle() - .fill(isAlive ? Color.green : Color.red) + .fill(isAlive ? theme.statusSuccess : theme.statusDanger) .frame(width: 7, height: 7) - .shadow(color: isAlive ? .green.opacity(0.6) : .clear, radius: 4) + .shadow(color: isAlive ? theme.statusSuccess.opacity(0.6) : .clear, radius: 4) .scaleEffect(isAlive ? (1.0 + phase * 0.3) : 1.0) } .frame(width: 24, height: 24) Text(isAlive ? "Live" : "Offline") .font(.system(size: 9, weight: .semibold, design: theme.fonts.bodyDesign)) - .foregroundColor(isAlive ? .green : .red.opacity(0.8)) + .foregroundColor(theme.backgroundMode.isLight ? theme.backgroundMode.primaryTextColor : (isAlive ? theme.statusSuccess : theme.statusDanger.opacity(0.8))) } .accessibilityLabel(isAlive ? "Daemon running" : "Daemon offline") } diff --git a/packages/macos-bar/Sources/TokmeterBar/FrostedGlass.swift b/packages/macos-bar/Sources/TokmeterBar/FrostedGlass.swift new file mode 100644 index 0000000..1725c49 --- /dev/null +++ b/packages/macos-bar/Sources/TokmeterBar/FrostedGlass.swift @@ -0,0 +1,56 @@ +import AppKit +import SwiftUI + +/// A single native blur samples the desktop. Surfaces above it use translucent +/// fills, so nested materials don't muddy the background or blur the readings. +struct FrostedGlassBackground: View { + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + var body: some View { + ZStack { + if reduceTransparency { + Color(red: 0.92, green: 0.95, blue: 0.97) + } else { + DesktopFrost() + } + LinearGradient( + colors: [Color.white.opacity(0.24), Color.white.opacity(0.06), + Color(red: 0.62, green: 0.77, blue: 0.88).opacity(0.10)], + startPoint: .topLeading, endPoint: .bottomTrailing + ) + Rectangle().strokeBorder(Color.white.opacity(0.52), lineWidth: 0.5) + } + .allowsHitTesting(false) + } +} + +struct FrostedGlassPanel: View { + var cornerRadius: CGFloat = 14 + var tint: Color = .white + + var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + shape + .fill(LinearGradient(colors: [Color.white.opacity(0.28), Color.white.opacity(0.10)], + startPoint: .topLeading, endPoint: .bottomTrailing)) + .overlay(shape.fill(tint.opacity(0.018))) + .overlay(shape.strokeBorder( + LinearGradient(colors: [Color.white.opacity(0.65), Color.white.opacity(0.12), + Color(red: 0.26, green: 0.38, blue: 0.48).opacity(0.09)], + startPoint: .topLeading, endPoint: .bottomTrailing), lineWidth: 0.5)) + .shadow(color: Color(red: 0.14, green: 0.24, blue: 0.32).opacity(0.035), radius: 8, x: 0, y: 3) + } +} + +private struct DesktopFrost: NSViewRepresentable { + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = .popover + view.blendingMode = .behindWindow + view.state = .active + view.appearance = NSAppearance(named: .aqua) + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} +} diff --git a/packages/macos-bar/Sources/TokmeterBar/HeroBackground.swift b/packages/macos-bar/Sources/TokmeterBar/HeroBackground.swift index 27f2c3f..ff0cfce 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HeroBackground.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HeroBackground.swift @@ -221,26 +221,9 @@ struct HeroBackground: View { } // MARK: - Glass - /// Translucent regular-material + color tint + a top gloss that gently - /// shimmers — the glass appears to catch and lose light over a slow cycle. + /// The header is a thin frosted surface over the shared desktop blur. private var glass: some View { - ZStack { - Rectangle().fill(.regularMaterial) - LinearGradient( - colors: [ - c.primary.opacity(0.22), - c.secondary.opacity(0.12), - c.accent.opacity(0.10), - ], - startPoint: .topLeading, endPoint: .bottomTrailing - ) - // Top gloss with breathing intensity — opacity oscillates 0.14↔0.28 - // so the glass plate "catches the light" subtly over 6s. - LinearGradient( - colors: [Color.white.opacity(breathToggle ? 0.28 : 0.14), Color.clear], - startPoint: .top, endPoint: .center - ) - .animation(.easeInOut(duration: 6).repeatForever(autoreverses: true), value: breathToggle) - } + LinearGradient(colors: [Color.white.opacity(0.20), Color.white.opacity(0.06), Color.clear], + startPoint: .topLeading, endPoint: .bottomTrailing) } } diff --git a/packages/macos-bar/Sources/TokmeterBar/HeroHeader.swift b/packages/macos-bar/Sources/TokmeterBar/HeroHeader.swift index 7741683..e635b39 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HeroHeader.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HeroHeader.swift @@ -89,8 +89,8 @@ struct HeroHeader: View { let pressured = status == "critical" || status == "high" let tint: Color = { switch status { - case "critical": return Color.tokDanger - case "high": return Color.tokWarning + case "critical": return theme.statusDanger + case "high": return theme.statusWarning default: return foreground.opacity(0.7) } }() @@ -151,7 +151,7 @@ struct HeroHeader: View { } if basis.unavailableRecords > 0 { Text("Cost unavailable for some usage") - .foregroundColor(Color.tokWarning) + .foregroundColor(theme.statusWarning) } else if basis.estimatedRecords + basis.reportedRecords == 0 { Text("No usage recorded today") } @@ -205,7 +205,7 @@ struct HeroHeader: View { /// "something is happening right now and here's what." Tooltip shows the /// model + last-record cost for the user who wants the detail. private func liveSessionPill(_ live: LiveSession) -> some View { - let dotColor = Color.tokSuccess + let dotColor = theme.statusSuccess let project = Fmt.projectBasename(live.project) return HStack(spacing: 5) { Circle() @@ -313,7 +313,7 @@ struct HeroHeader: View { // is hidden from picker but kept here for consistency. return Color.black.opacity(0.92) case .hud, .terminal: return c.secondary - case .glass: return Color.white.opacity(0.95) + case .glass: return Color(red: 0.14, green: 0.20, blue: 0.28) default: return Color.white } } @@ -336,7 +336,7 @@ struct HeroHeader: View { case .hud: return c.secondary.opacity(0.30) case .terminal: return c.secondary.opacity(0.40) case .paper: return Color.black.opacity(0.08) - case .glass: return Color.black.opacity(0.18) + case .glass: return Color.clear case .aurora: return c.accent.opacity(0.35) case .blueprint: return Color.black.opacity(0.10) case .noise: return Color.black.opacity(0.40) // hard offset reads as "stuck on" @@ -345,7 +345,8 @@ struct HeroHeader: View { } private var contactShadow: Color { - theme.backgroundMode.isLight ? Color.black.opacity(0.08) : Color.black.opacity(0.30) + if theme == .glass { return Color.clear } + return theme.backgroundMode.isLight ? Color.black.opacity(0.08) : Color.black.opacity(0.30) } // MARK: - Shapes + overlays @@ -354,7 +355,8 @@ struct HeroHeader: View { /// to match the menubar chrome; bottom corners tuck inward. private var notchShape: UnevenRoundedRectangle { UnevenRoundedRectangle( - cornerRadii: .init(topLeading: 0, bottomLeading: 26, bottomTrailing: 26, topTrailing: 0), + cornerRadii: .init(topLeading: 0, bottomLeading: theme == .glass ? 0 : 26, + bottomTrailing: theme == .glass ? 0 : 26, topTrailing: 0), style: .continuous ) } @@ -364,7 +366,7 @@ struct HeroHeader: View { @ViewBuilder private var innerHighlight: some View { switch theme { - case .daylight, .hud, .terminal, .paper, .blueprint, .noise, .mint: + case .daylight, .hud, .terminal, .paper, .blueprint, .noise, .mint, .glass: EmptyView() default: notchShape.strokeBorder( @@ -385,7 +387,7 @@ struct HeroHeader: View { case .hud: return c.secondary.opacity(0.30) case .terminal: return c.secondary.opacity(0.40) case .paper: return Color.black.opacity(0.18) - case .glass: return Color.white.opacity(0.25) + case .glass: return Color.clear case .noise: return Color.black.opacity(0.45) // ink frame case .mint: return Color.black.opacity(0.12) // hairline case .blueprint: return Color.black.opacity(0.18) diff --git a/packages/macos-bar/Sources/TokmeterBar/HubCard.swift b/packages/macos-bar/Sources/TokmeterBar/HubCard.swift index 0457cb3..ffc320c 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubCard.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubCard.swift @@ -22,14 +22,18 @@ struct HubCard: View { content() .padding(14) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 14) - .fill(Color.primary.opacity(bg.isLight ? 0.03 : 0.05)) - .overlay( - RoundedRectangle(cornerRadius: 14) - .stroke(c.accent.opacity(0.12), lineWidth: 1) - ) - ) + .background { + if bg.usesMaterial { + FrostedGlassPanel() + } else { + RoundedRectangle(cornerRadius: 14) + .fill(Color.primary.opacity(bg.isLight ? 0.03 : 0.05)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(c.accent.opacity(0.12), lineWidth: 1) + ) + } + } } } diff --git a/packages/macos-bar/Sources/TokmeterBar/HubCrossToolCard.swift b/packages/macos-bar/Sources/TokmeterBar/HubCrossToolCard.swift index bd0060a..9019341 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubCrossToolCard.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubCrossToolCard.swift @@ -61,8 +61,8 @@ struct HubCrossToolCard: View { let isSavings = delta < -0.005 let isUpcharge = delta > 0.005 let deltaColor: Color = - isSavings ? Color.tokSuccess - : isUpcharge ? Color.tokDanger + isSavings ? theme.statusSuccess + : isUpcharge ? theme.statusDanger : bg.secondaryTextColor return HStack(spacing: 10) { Image(systemName: glyphFor(provider: p.provider)) diff --git a/packages/macos-bar/Sources/TokmeterBar/HubPulseCard.swift b/packages/macos-bar/Sources/TokmeterBar/HubPulseCard.swift index e650302..8878ecf 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubPulseCard.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubPulseCard.swift @@ -279,9 +279,9 @@ struct ContextPressureStrip: View { private var accent: Color { switch pressure.status { case "critical": - return Color.tokDanger + return theme.statusDanger case "high": - return Color.tokWarning + return theme.statusWarning case "medium": return theme.colors.tertiary default: @@ -348,8 +348,8 @@ struct BillingStrip: View { } private var accent: Color { - if window.elapsedPct >= 90 { return Color.tokDanger } - if window.elapsedPct >= 75 { return Color.tokWarning } + if window.elapsedPct >= 90 { return theme.statusDanger } + if window.elapsedPct >= 75 { return theme.statusWarning } return c.secondary } diff --git a/packages/macos-bar/Sources/TokmeterBar/HubSidebar.swift b/packages/macos-bar/Sources/TokmeterBar/HubSidebar.swift index 8faf308..b5875fd 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubSidebar.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubSidebar.swift @@ -162,10 +162,10 @@ struct HubSidebar: View { Spacer(minLength: 0) if let live = loader.statbarSignals?.liveSession { HStack(spacing: 4) { - PulseDot(color: .tokSuccess) + PulseDot(color: theme.statusSuccess) Text(Fmt.liveAge(live.ageSeconds)) .font(.system(size: 9, weight: .semibold, design: theme.fonts.bodyDesign)) - .foregroundColor(.tokSuccess) + .foregroundColor(theme.statusSuccess) } .transition(.opacity) } @@ -198,7 +198,7 @@ struct HubSidebar: View { miniPill( icon: "bolt.horizontal.fill", text: "\(Int((cache * 100).rounded()))%", - tint: cache >= 0.9 ? .tokSuccess : (cache >= 0.6 ? .tokWarning : .tokDanger) + tint: cache >= 0.9 ? theme.statusSuccess : (cache >= 0.6 ? theme.statusWarning : theme.statusDanger) ) } } @@ -248,7 +248,7 @@ struct HubSidebar: View { private var footer: some View { HStack(spacing: 6) { Circle() - .fill(connection.color) + .fill(connection.color(theme: theme)) .frame(width: 6, height: 6) Text(connection.label) .font(.system(size: 10, weight: .medium, design: theme.fonts.bodyDesign)) @@ -278,11 +278,11 @@ struct HubSidebar: View { enum ConnectionStatus { case live, warming, offline - var color: Color { + func color(theme: AppTheme) -> Color { switch self { - case .live: return .tokSuccess - case .warming: return .tokWarning - case .offline: return .tokDanger + case .live: return theme.statusSuccess + case .warming: return theme.statusWarning + case .offline: return theme.statusDanger } } @@ -307,15 +307,15 @@ struct ConnectionDot: View { ZStack { if status.pulses { Circle() - .stroke(status.color.opacity(0.5), lineWidth: 1.5) + .stroke(status.color(theme: theme).opacity(0.5), lineWidth: 1.5) .frame(width: 14, height: 14) .scaleEffect(pulse ? 1.6 : 1.0) .opacity(pulse ? 0 : 0.8) } Circle() - .fill(status.color) + .fill(status.color(theme: theme)) .frame(width: 8, height: 8) - .shadow(color: status.color.opacity(0.6), radius: pulse ? 4 : 2) + .shadow(color: status.color(theme: theme).opacity(0.6), radius: pulse ? 4 : 2) } .help(status.label) .onAppear { diff --git a/packages/macos-bar/Sources/TokmeterBar/HubView.swift b/packages/macos-bar/Sources/TokmeterBar/HubView.swift index e48b2fa..155e7df 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubView.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubView.swift @@ -81,6 +81,7 @@ struct HubView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(hubBackground) } + .environment(\.colorScheme, bg.isLight ? .light : .dark) .preferredColorScheme(bg.isLight ? .light : .dark) } @@ -116,7 +117,7 @@ struct HubView: View { @ViewBuilder private var sidebarBackground: some View { if bg.usesMaterial { - Rectangle().fill(.thinMaterial) + Color.black.opacity(0.08) } else { LinearGradient( colors: [ @@ -133,13 +134,7 @@ struct HubView: View { @ViewBuilder private var hubBackground: some View { if bg.usesMaterial { - ZStack { - Rectangle().fill(.regularMaterial) - LinearGradient( - colors: bg.gradientColors(), - startPoint: .top, endPoint: .bottom - ) - } + FrostedGlassBackground() } else { LinearGradient( colors: bg.gradientColors(), diff --git a/packages/macos-bar/Sources/TokmeterBar/NodeToolchain.swift b/packages/macos-bar/Sources/TokmeterBar/NodeToolchain.swift new file mode 100644 index 0000000..eda8766 --- /dev/null +++ b/packages/macos-bar/Sources/TokmeterBar/NodeToolchain.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Resolve a paired Node/npx installation without executing shell profiles. +/// These are user-installed programs, not a sandbox or root-ownership proof. +struct NodeToolchain: Equatable { + let binDirectory: String + var node: String { binDirectory + "/node" } + var npx: String { binDirectory + "/npx" } + + static func resolve(home: String = NSHomeDirectory(), fileManager: FileManager = .default, + systemDirectories: [String] = ["/opt/homebrew/bin", "/usr/local/bin"]) -> NodeToolchain? { + let fixed = systemDirectories + [home + "/.volta/bin"] + let managed = [ + (home + "/.nvm/versions/node", "/bin"), + (home + "/.local/share/fnm/node-versions", "/installation/bin"), + (home + "/Library/Application Support/fnm/node-versions", "/installation/bin"), + (home + "/.local/share/mise/installs/node", "/bin"), + (home + "/.asdf/installs/nodejs", "/bin"), + ] + let directories = fixed + managed.flatMap { root, suffix in + (try? fileManager.contentsOfDirectory(atPath: root))? + .filter { majorVersion($0).map { $0 >= 18 } ?? false } + .sorted { $0.compare($1, options: .numeric) == .orderedDescending } + .map { root + "/" + $0 + suffix } ?? [] + } + return firstAvailable(directories: directories, isExecutable: fileManager.isExecutableFile(atPath:)) + } + + static func firstAvailable(directories: [String], isExecutable: (String) -> Bool) -> NodeToolchain? { + directories.first { isExecutable($0 + "/node") && isExecutable($0 + "/npx") } + .map { NodeToolchain(binDirectory: $0) } + } + + static func majorVersion(_ version: String) -> Int? { + let value = version.trimmingCharacters(in: .whitespacesAndNewlines) + let digits = value.hasPrefix("v") ? value.dropFirst() : Substring(value) + return digits.split(separator: ".").first.flatMap { Int($0) } + } + + func environment(base: [String: String]) -> [String: String] { + var environment = base + let paths = [binDirectory, "/opt/homebrew/bin", "/usr/local/bin"] + + (base["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin").split(separator: ":").map(String.init) + var seen = Set() + environment["PATH"] = paths.filter { !$0.isEmpty && seen.insert($0).inserted }.joined(separator: ":") + return environment + } + + /// Drishti owns the daemon and depends on Tokmeter. Installing Tokmeter + /// alone does not install Drishti, so it cannot bootstrap the daemon. + static func daemonArguments(version: String?) -> [String] { + let package = version.map { "@sriinnu/drishti@\($0)" } ?? "@sriinnu/drishti" + return ["--yes", package, "daemon", "start"] + } +} diff --git a/packages/macos-bar/Sources/TokmeterBar/PanelVisibility.swift b/packages/macos-bar/Sources/TokmeterBar/PanelVisibility.swift index a6965b8..844e962 100644 --- a/packages/macos-bar/Sources/TokmeterBar/PanelVisibility.swift +++ b/packages/macos-bar/Sources/TokmeterBar/PanelVisibility.swift @@ -20,8 +20,10 @@ import SwiftUI @MainActor final class PanelVisibility: ObservableObject { @Published var isVisible: Bool = false + @Published var screenHeight: CGFloat = NSScreen.main?.visibleFrame.height ?? 812 private var observer: NSObjectProtocol? + private var screenObservers: [NSObjectProtocol] = [] private weak var window: NSWindow? func attach(to window: NSWindow) { @@ -29,6 +31,7 @@ final class PanelVisibility: ObservableObject { detach() self.window = window isVisible = window.occlusionState.contains(.visible) + screenHeight = window.screen?.visibleFrame.height ?? screenHeight observer = NotificationCenter.default.addObserver( forName: NSWindow.didChangeOcclusionStateNotification, object: window, @@ -40,8 +43,19 @@ final class PanelVisibility: ObservableObject { Task { @MainActor [weak self, weak window] in guard let self, let window else { return } self.isVisible = window.occlusionState.contains(.visible) + self.screenHeight = window.screen?.visibleFrame.height ?? self.screenHeight } } + for name in [NSWindow.didChangeScreenNotification, NSApplication.didChangeScreenParametersNotification] { + screenObservers.append(NotificationCenter.default.addObserver( + forName: name, object: name == NSWindow.didChangeScreenNotification ? window : nil, queue: .main + ) { _ in + Task { @MainActor [weak self, weak window] in + guard let self, let window else { return } + self.screenHeight = window.screen?.visibleFrame.height ?? self.screenHeight + } + }) + } } private func detach() { @@ -49,12 +63,15 @@ final class PanelVisibility: ObservableObject { NotificationCenter.default.removeObserver(observer) } observer = nil + screenObservers.forEach(NotificationCenter.default.removeObserver) + screenObservers = [] } deinit { if let observer { NotificationCenter.default.removeObserver(observer) } + screenObservers.forEach(NotificationCenter.default.removeObserver) } } diff --git a/packages/macos-bar/Sources/TokmeterBar/SignalsRibbon.swift b/packages/macos-bar/Sources/TokmeterBar/SignalsRibbon.swift index 8e951fd..6c9aa56 100644 --- a/packages/macos-bar/Sources/TokmeterBar/SignalsRibbon.swift +++ b/packages/macos-bar/Sources/TokmeterBar/SignalsRibbon.swift @@ -2,7 +2,7 @@ // // 🔥 $3.20/hr · 🪣 92% cache · 🗜 12% compact · 🧠 60% reasoning · ⏱ 2h12m · $4.20 // -// One row, up to five chips, each a different signal: +// Chips wrap onto additional rows instead of truncating their readings: // - burn → dollars per hour over the last 60 min (motion indicator) // - cache → % of read tokens served from cache today (efficiency) // - compact → % of today's spend going to /compact overhead (hygiene) @@ -31,10 +31,9 @@ struct SignalsRibbon: View { var body: some View { if let signals = loader.statbarSignals, shouldShow(signals) { - HStack(spacing: 0) { + SignalFlowLayout() { if signals.burnRate.recordsInWindow > 0 { burnChip(signals.burnRate) - divider } let cacheHit = signals.cacheHitToday.canonicalRate ?? signals.cacheHitToday.rate let cacheMiss = signals.cacheHitToday.missRate ?? max(0, 1 - cacheHit) @@ -48,7 +47,6 @@ struct SignalsRibbon: View { + "\(signals.cacheHitToday.inputTokens) missed." ) if let pressure = signals.contextPressure, pressure.status != "none" { - divider chip( icon: "memorychip.fill", iconColor: contextColor(pressure.status), @@ -60,7 +58,6 @@ struct SignalsRibbon: View { ) } if signals.compactionToday.events > 0 { - divider chip( icon: "rectangle.compress.vertical", iconColor: c.tertiary, @@ -72,7 +69,6 @@ struct SignalsRibbon: View { ) } if signals.reasoningToday.records > 0 { - divider chip( icon: "brain", iconColor: reasoningColor(signals.reasoningToday.share), @@ -89,7 +85,6 @@ struct SignalsRibbon: View { ) } if let billing = signals.billingWindow { - divider chip( icon: "timer", iconColor: billingColor(billing.elapsedPct), @@ -106,7 +101,6 @@ struct SignalsRibbon: View { ) ) } - Spacer(minLength: 0) } .padding(.horizontal, 12) .padding(.vertical, 6) @@ -146,13 +140,6 @@ struct SignalsRibbon: View { Int((max(0, min(1, value)) * 100).rounded()) } - private var divider: some View { - Text("·") - .font(.system(size: 11, weight: .bold)) - .foregroundColor(theme.backgroundMode.secondaryTextColor.opacity(0.5)) - .padding(.horizontal, 5) - } - /// Generic chip. Numbers in `text` roll instead of snapping thanks to /// `.contentTransition(.numericText())` — when the daemon's next scan /// shifts a percentage from 18% → 19%, the digits animate. Same Apple @@ -228,26 +215,26 @@ struct SignalsRibbon: View { /// Thresholds are deliberately gentle — $2/hr is normal work, $10/hr is /// a fire-hose session, $20/hr is "are you OK". private func burnColor(_ costPerHour: Double) -> Color { - if costPerHour >= 20 { return Color.tokDanger } - if costPerHour >= 10 { return Color.tokWarning } + if costPerHour >= 20 { return theme.statusDanger } + if costPerHour >= 10 { return theme.statusWarning } if costPerHour >= 2 { return c.secondary } - return Color.tokSuccess + return theme.statusSuccess } /// Cache-hit color: green when the cache is doing its job (≥90%), /// amber when partial, red when something's wrong. private func cacheColor(_ rate: Double) -> Color { - if rate >= 0.90 { return Color.tokSuccess } - if rate >= 0.60 { return Color.tokWarning } - return Color.tokDanger + if rate >= 0.90 { return theme.statusSuccess } + if rate >= 0.60 { return theme.statusWarning } + return theme.statusDanger } private func contextColor(_ status: String) -> Color { switch status { case "critical": - return Color.tokDanger + return theme.statusDanger case "high": - return Color.tokWarning + return theme.statusWarning case "medium": return c.tertiary default: @@ -260,7 +247,7 @@ struct SignalsRibbon: View { /// output is invisible thinking), amber past 80% (most of the cost isn't /// visible to the caller — worth questioning the routing choice). private func reasoningColor(_ share: Double) -> Color { - if share >= 0.80 { return Color.tokWarning } + if share >= 0.80 { return theme.statusWarning } if share >= 0.50 { return c.tertiary } return theme.backgroundMode.secondaryTextColor } @@ -270,8 +257,8 @@ struct SignalsRibbon: View { /// At 90% you have ~30 min in the 5h block, which is roughly when "head /// up, plan your last thing" becomes "this is closing now". private func billingColor(_ elapsedPct: Double) -> Color { - if elapsedPct >= 90 { return Color.tokDanger } - if elapsedPct >= 75 { return Color.tokWarning } + if elapsedPct >= 90 { return theme.statusDanger } + if elapsedPct >= 75 { return theme.statusWarning } return c.secondary } @@ -294,3 +281,40 @@ struct SignalsRibbon: View { return "\(m)m" } } + +/// Each reading keeps its intrinsic width; additional signals start a new row. +private struct SignalFlowLayout: Layout { + private func arrangement(width: CGFloat, subviews: Subviews) -> (CGSize, [CGPoint]) { + var points: [CGPoint] = [] + var x: CGFloat = 0 + var y: CGFloat = 0 + var rowHeight: CGFloat = 0 + var usedWidth: CGFloat = 0 + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + if x > 0 && x + size.width > width { + x = 0 + y += rowHeight + 8 + rowHeight = 0 + } + points.append(CGPoint(x: x, y: y)) + usedWidth = max(usedWidth, x + size.width) + x += size.width + 12 + rowHeight = max(rowHeight, size.height) + } + return (CGSize(width: usedWidth, height: y + rowHeight), points) + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let (size, _) = arrangement(width: proposal.width ?? .infinity, subviews: subviews) + return CGSize(width: proposal.width ?? size.width, height: size.height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let (_, points) = arrangement(width: bounds.width, subviews: subviews) + for (subview, point) in zip(subviews, points) { + subview.place(at: CGPoint(x: bounds.minX + point.x, y: bounds.minY + point.y), + anchor: .topLeading, proposal: .unspecified) + } + } +} diff --git a/packages/macos-bar/Sources/TokmeterBar/StatCards.swift b/packages/macos-bar/Sources/TokmeterBar/StatCards.swift index 95446e9..a6539c0 100644 --- a/packages/macos-bar/Sources/TokmeterBar/StatCards.swift +++ b/packages/macos-bar/Sources/TokmeterBar/StatCards.swift @@ -122,8 +122,8 @@ struct StatsGrid: View { /// Pace role color — amber when burning hot, green when easy day, neutral at par. private func paceRole(for multiple: Double) -> Color { - if multiple >= 1.25 { return Color.tokWarning } - if multiple <= 0.75 { return Color.tokSuccess } + if multiple >= 1.25 { return theme.statusWarning } + if multiple <= 0.75 { return theme.statusSuccess } return c.tertiary } @@ -184,7 +184,7 @@ struct StatCard: View { IconBadge(symbol: icon, role: role, cardMode: theme.cardMode) Spacer(minLength: 0) if let d = delta, !isWarming { - DeltaPill(percent: d) + DeltaPill(percent: d, theme: theme) } } .padding(.horizontal, 10) @@ -302,6 +302,7 @@ struct IconBadge: View { /// light and dark surfaces. struct DeltaPill: View { let percent: Double + let theme: AppTheme /// Signs-flipped detector: when the sign changes (e.g. trend reversed), /// we briefly scale the pill so the user's eye catches the shift. @@ -309,7 +310,7 @@ struct DeltaPill: View { var body: some View { let positive = percent >= 0 - let color: Color = positive ? Color.tokSuccess : Color.tokDanger + let color: Color = positive ? theme.statusSuccess : theme.statusDanger HStack(spacing: 2) { Image(systemName: positive ? "arrow.up" : "arrow.down") .font(.system(size: 7, weight: .bold)) @@ -319,7 +320,10 @@ struct DeltaPill: View { .foregroundColor(color) .padding(.horizontal, 5) .padding(.vertical, 2) - .background(Capsule().fill(color.opacity(0.18))) + .background { + Capsule().fill(Color.white.opacity(theme.backgroundMode.isLight ? 0.5 : 0)) + .overlay(Capsule().fill(color.opacity(theme.backgroundMode.isLight ? 0.12 : 0.18))) + } .scaleEffect(pulseScale) // Bump scale → spring back on any sign change (positive flag toggles). .onChange(of: positive) { _, _ in diff --git a/packages/macos-bar/Sources/TokmeterBar/SubprocessRunner.swift b/packages/macos-bar/Sources/TokmeterBar/SubprocessRunner.swift new file mode 100644 index 0000000..aa70fea --- /dev/null +++ b/packages/macos-bar/Sources/TokmeterBar/SubprocessRunner.swift @@ -0,0 +1,124 @@ +import Darwin +import Foundation + +enum SubprocessRunner { + static func run(executable: String, arguments: [String], environment: [String: String], timeout: TimeInterval) async throws -> String { + try await withCheckedThrowingContinuation { continuation in + Execution(executable: executable, arguments: arguments, environment: environment, + timeout: timeout, continuation: continuation).start() + } + } + + /// Drain both pipes while the child runs. Waiting until termination to + /// read them deadlocks a noisy npm install once an OS pipe fills. + private final class Execution: @unchecked Sendable { + let process = Process() + let stdout = Pipe() + let stderr = Pipe() + let queue = DispatchQueue(label: "tokmeter.subprocess") + let timeout: TimeInterval + var continuation: CheckedContinuation? + var sources: [DispatchSourceRead] = [] + var output = Data() + var errorOutput = Data() + var timer: DispatchWorkItem? + var finished = false + var ended = [false, false] + + init(executable: String, arguments: [String], environment: [String: String], timeout: TimeInterval, + continuation: CheckedContinuation) { + process.executableURL = URL(fileURLWithPath: executable) + process.arguments = arguments + process.environment = environment + process.standardOutput = stdout + process.standardError = stderr + self.timeout = timeout + self.continuation = continuation + } + + func start() { + queue.async { self.launch() } + } + + private func launch() { + for (index, pipe) in [stdout, stderr].enumerated() { + let handle = pipe.fileHandleForReading + let fd = handle.fileDescriptor + _ = fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK) + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + source.setEventHandler { self.drain(error: index == 1) } + source.setCancelHandler { try? handle.close() } + sources.append(source) + source.resume() + } + process.terminationHandler = { process in + self.queue.async { + guard !self.finished else { return } + self.drain(error: false) + self.drain(error: true) + if process.terminationStatus != 0 { + let line = String(decoding: self.errorOutput, as: UTF8.self) + .split(separator: "\n").first.map(String.init) ?? "" + let detail = line.isEmpty ? "" : ": \(line)" + self.finish(.failure(DaemonError.networkError("exit \(process.terminationStatus)\(detail)"))) + } else if let text = String(data: self.output, encoding: .utf8) { + self.finish(.success(text)) + } else { + self.finish(.failure(DaemonError.decodingError("non-UTF8 CLI output"))) + } + } + } + do { + try process.run() + } catch { + queue.async { self.finish(.failure(error)) } + return + } + let timer = DispatchWorkItem { + guard !self.finished else { return } + if self.process.isRunning { self.process.terminate() } + // A child that ignores SIGTERM must not outlive its command + // budget indefinitely. This affects this child only. + let process = self.process + self.queue.asyncAfter(deadline: .now() + 1) { + if process.isRunning { kill(process.processIdentifier, SIGKILL) } + } + self.finish(.failure(DaemonError.networkError("CLI timed out after \(Int(self.timeout))s"))) + } + self.timer = timer + queue.asyncAfter(deadline: .now() + timeout, execute: timer) + } + + func drain(error: Bool) { + let index = error ? 1 : 0 + guard !finished, !ended[index] else { return } + let fd = (error ? stderr : stdout).fileHandleForReading.fileDescriptor + var bytes = [UInt8](repeating: 0, count: 8192) + while true { + let count = Darwin.read(fd, &bytes, bytes.count) + if count == 0 { + ended[index] = true + sources[index].cancel() + return + } + if count < 0 { return } + // Bound retained output while continuing to drain all bytes. + let available = max(0, (error ? 16_384 : 262_144) - (error ? errorOutput.count : output.count)) + if error { errorOutput.append(contentsOf: bytes.prefix(min(count, available))) } + else { output.append(contentsOf: bytes.prefix(min(count, available))) } + } + } + + func finish(_ result: Result) { + guard !finished else { return } + finished = true + timer?.cancel() + timer = nil + for source in sources { source.cancel() } + sources.removeAll() + process.terminationHandler = nil + continuation?.resume(with: result) + continuation = nil + } + } +} diff --git a/packages/macos-bar/Sources/TokmeterBar/Theme+Modes.swift b/packages/macos-bar/Sources/TokmeterBar/Theme+Modes.swift index b473e14..0914b5a 100644 --- a/packages/macos-bar/Sources/TokmeterBar/Theme+Modes.swift +++ b/packages/macos-bar/Sources/TokmeterBar/Theme+Modes.swift @@ -44,7 +44,7 @@ enum BackgroundMode { case .paperWarm: return Color(red: 0.962, green: 0.943, blue: 0.904) case .glassBlur: - return Color(red: 0.18, green: 0.20, blue: 0.26).opacity(0.35) + return Color(red: 0.86, green: 0.92, blue: 0.96).opacity(0.42) case .auroraDrift: return Color(red: 0.02, green: 0.03, blue: 0.08) case .blueprintGrid: @@ -59,13 +59,13 @@ enum BackgroundMode { /// Whether this surface is light (drives text color inversion). var isLight: Bool { switch self { - case .lightCream, .paperWarm, .blueprintGrid, .noiseYellow, .mintPeach: return true + case .lightCream, .paperWarm, .blueprintGrid, .noiseYellow, .mintPeach, .glassBlur: return true default: return false } } /// Whether this surface uses a translucent material layer (Glass). - /// The view renders a regular-material background + tint instead of a solid fill. + /// Glass uses a native desktop blur with a pale frost tint. var usesMaterial: Bool { if case .glassBlur = self { return true } return false @@ -78,7 +78,7 @@ enum BackgroundMode { /// Secondary/label text color. var secondaryTextColor: Color { - isLight ? Color.black.opacity(0.55) : Color.white.opacity(0.55) + isLight ? Color.black.opacity(0.65) : Color.white.opacity(0.55) } /// The subtle gradient pair applied to the outer background. diff --git a/packages/macos-bar/Sources/TokmeterBar/Theme.swift b/packages/macos-bar/Sources/TokmeterBar/Theme.swift index 6e1399c..9289a49 100644 --- a/packages/macos-bar/Sources/TokmeterBar/Theme.swift +++ b/packages/macos-bar/Sources/TokmeterBar/Theme.swift @@ -37,24 +37,27 @@ struct ThemeColors { // MARK: - Semantic status colors -/// Status-tier colors shared across every theme. These encode meaning, not -/// brand — red is "this needs your attention", amber is "approaching a -/// limit", green is "things are working." Theme-tinted palettes still pick -/// these for status signals; a future high-contrast/accessibility theme can -/// promote them to ThemeColors if it needs to override. -/// -/// Centralized here because they used to live as RGB triples in 5+ files -/// (SignalsRibbon, HubPulseCard, AnomalyDetail, StatCards…). One source of -/// truth means tuning the red once tunes it everywhere. -extension Color { - /// Red — kosha anomaly direction, late billing window, overspend pace. - static let tokDanger = Color(red: 0.96, green: 0.42, blue: 0.42) - /// Amber — approaching a limit (cache <60%, billing >75% elapsed, etc). - static let tokWarning = Color(red: 0.95, green: 0.70, blue: 0.30) - /// Green — healthy (cache ≥90%, anomaly going down, low burn). - static let tokSuccess = Color(red: 0.13, green: 0.80, blue: 0.47) -} +/// Resolve from the selected theme, independently of the menu window's native +/// appearance. MenuBarExtra can retain Dark Aqua while displaying light Glass. +extension AppTheme { + var statusDanger: Color { + backgroundMode.isLight + ? Color(.sRGB, red: 0.35, green: 0.025, blue: 0.04) + : Color(.sRGB, red: 0.96, green: 0.42, blue: 0.42) + } + var statusWarning: Color { + backgroundMode.isLight + ? Color(.sRGB, red: 0.29, green: 0.13, blue: 0.005) + : Color(.sRGB, red: 0.95, green: 0.70, blue: 0.30) + } + + var statusSuccess: Color { + backgroundMode.isLight + ? Color(.sRGB, red: 0.025, green: 0.205, blue: 0.10) + : Color(.sRGB, red: 0.13, green: 0.80, blue: 0.47) + } +} // MARK: - Theme enum @@ -80,9 +83,10 @@ enum AppTheme: String, CaseIterable, Identifiable { /// - HUD: even amber-rework couldn't carry it. Terminal owns the /// instrument-panel space already. /// - Synthwave: costume that scrolling-grid couldn't save. + /// - Noise: yellow surfaces and white cards compete with usage colors. static var allCases: [AppTheme] = [ .terminal, .paper, .nebula, .aurora, - .noise, .nocturne, .glass, + .nocturne, .glass, ] var id: String { rawValue } @@ -113,7 +117,7 @@ enum AppTheme: String, CaseIterable, Identifiable { case .hud: return "Tactical panel" case .terminal: return "CRT phosphor retro" case .paper: return "Editorial serif" - case .glass: return "Translucent glass" + case .glass: return "Frosted glass" case .aurora: return "Northern lights, drifting" case .blueprint: return "Drafting paper, cyan grid" case .noise: return "Neobrutalist canary yellow" @@ -233,10 +237,9 @@ enum AppTheme: String, CaseIterable, Identifiable { valueDesign: .serif, valueWeight: .bold, labelDesign: .default, bodyDesign: .default) case .glass: - // Light weights read as "glass" — airy, not heavy - return ThemeFonts(heroDesign: .rounded, heroWeight: .medium, - valueDesign: .rounded, valueWeight: .semibold, - labelDesign: .rounded, bodyDesign: .rounded) + return ThemeFonts(heroDesign: .default, heroWeight: .medium, + valueDesign: .default, valueWeight: .semibold, + labelDesign: .default, bodyDesign: .default) case .aurora: // Soft rounded — the bg is doing the heavy visual lifting return ThemeFonts(heroDesign: .rounded, heroWeight: .semibold, diff --git a/packages/macos-bar/Sources/TokmeterBar/ThemePalettes.swift b/packages/macos-bar/Sources/TokmeterBar/ThemePalettes.swift index 2e6ab23..5a5360b 100644 --- a/packages/macos-bar/Sources/TokmeterBar/ThemePalettes.swift +++ b/packages/macos-bar/Sources/TokmeterBar/ThemePalettes.swift @@ -101,14 +101,14 @@ extension AppTheme { ) case .glass: - // Cool neutral palette — slate, ice, sage. Reads as "Apple frost". + // Ink, steel, and teal on pale frosted glass. return ThemeColors( - primary: Color(red: 0.420, green: 0.486, blue: 0.710), // #6b7cb5 slate blue - secondary: Color(red: 0.596, green: 0.659, blue: 0.820), // #98a8d1 ice blue - accent: Color(red: 0.490, green: 0.765, blue: 0.910), // #7dc3e8 bright ice - highlight: Color(red: 0.780, green: 0.647, blue: 0.537), // #c7a589 warm beige - warm: Color(red: 0.710, green: 0.643, blue: 0.757), // #b5a4c1 lavender-slate - tertiary: Color(red: 0.627, green: 0.773, blue: 0.706) // #a0c5b4 sage + primary: Color(red: 0.220, green: 0.380, blue: 0.550), + secondary: Color(red: 0.300, green: 0.430, blue: 0.570), + accent: Color(red: 0.140, green: 0.420, blue: 0.570), + highlight: Color(red: 0.150, green: 0.290, blue: 0.400), + warm: Color(red: 0.480, green: 0.530, blue: 0.670), + tertiary: Color(red: 0.200, green: 0.450, blue: 0.400) ) case .aurora: diff --git a/packages/macos-bar/Sources/TokmeterBar/TokmeterBarView.swift b/packages/macos-bar/Sources/TokmeterBar/TokmeterBarView.swift index c1f8063..986d771 100644 --- a/packages/macos-bar/Sources/TokmeterBar/TokmeterBarView.swift +++ b/packages/macos-bar/Sources/TokmeterBar/TokmeterBarView.swift @@ -31,6 +31,7 @@ struct TokmeterBarView: View { /// Local UI state — never persisted. @State private var breathToggle = false + @State var usageDetailsExpanded = false /// Tracks whether this popover's window is actually on screen — see /// PanelVisibility.swift. Every ambient animation in the hero/footer is /// gated on this so they stop burning CPU while the panel is closed. @@ -45,6 +46,13 @@ struct TokmeterBarView: View { /// Top-anchored gradient ripple flashed briefly on theme change so the /// transition reads as deliberate, not a glitch. @State private var themeRipple: Bool = false + @State private var heroHeight: CGFloat = 110 + @State private var errorHeight: CGFloat = 8 + @State private var footerHeight: CGFloat = 80 + + private var maximumPanelHeight: CGFloat { + min(780, panelVisibility.screenHeight - 32) + } private var c: ThemeColors { theme.colors } private var bg: BackgroundMode { theme.backgroundMode } @@ -59,14 +67,16 @@ struct TokmeterBarView: View { showCachePanel: $showCachePanel ) .cascadeIn(delay: 0.02) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { heroHeight = $0 } errorBanner .padding(.horizontal, 16) .padding(.top, 8) .cascadeIn(delay: 0.08) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { errorHeight = $0 } - ScrollView(.vertical, showsIndicators: true) { - UsageOverview(loader: loader, theme: theme) + ContentSizedScrollView(maximumHeight: max(80, maximumPanelHeight - heroHeight - errorHeight - footerHeight - 1)) { + UsageOverview(loader: loader, theme: theme, showUsageDetails: $usageDetailsExpanded) .padding(.horizontal, 16) .padding(.top, 14) .padding(.bottom, 10) @@ -85,9 +95,10 @@ struct TokmeterBarView: View { .padding(.horizontal, 16) .padding(.vertical, 8) .cascadeIn(delay: 0.46) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { footerHeight = $0 } } .frame(width: 400) - .frame(minHeight: 520, maxHeight: 780) + .fixedSize(horizontal: false, vertical: true) .background(popoverBackground) .trackPanelVisibility(panelVisibility) // Cache "wallet" drawer — slides in from the trailing edge over the @@ -156,6 +167,7 @@ struct TokmeterBarView: View { .animation(.spring(response: 0.50, dampingFraction: 0.82), value: theme) // Force the color scheme to match the theme's surface so built-in // SwiftUI chrome (Divider, .secondary, system sheets) reads correctly. + .environment(\.colorScheme, bg.isLight ? .light : .dark) .preferredColorScheme(bg.isLight ? .light : .dark) // NOT just `breathToggle = visible` — every `.animation(curve.repeatForever(...), // value: breathToggle)` site (hero pulse, shimmer bars, glow scale effects, @@ -195,13 +207,7 @@ struct TokmeterBarView: View { @ViewBuilder private var popoverBackground: some View { if bg.usesMaterial { - ZStack { - Rectangle().fill(.regularMaterial) - LinearGradient( - colors: bg.gradientColors(), - startPoint: .top, endPoint: .bottom - ) - } + FrostedGlassBackground() } else { LinearGradient( colors: bg.gradientColors(), @@ -219,21 +225,11 @@ struct TokmeterBarView: View { @ViewBuilder private var errorBanner: some View { if let error = loader.lastError, !loader.isWarming { - HStack(spacing: 6) { - Image(systemName: "bolt.trianglebadge.exclamationmark.fill") - .foregroundColor(.orange) - .font(.system(size: 12)) - Text(Fmt.shortError(error)) - .font(.system(size: 10, weight: .medium, design: .rounded)) - .foregroundColor(.primary.opacity(0.8)) - .lineLimit(1) - .help(error) + ConnectionIssueView(error: error, needsNodeSetup: loader.needsNodeSetup, + isRetrying: loader.isLoading || loader.isStartingDaemon) { + Task { await loader.loadData() } } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Capsule().fill(Color.orange.opacity(0.12))) .padding(.bottom, 4) - .accessibilityElement(children: .combine) .transition( .asymmetric( insertion: .move(edge: .top).combined(with: .opacity), diff --git a/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader+CLIFallback.swift b/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader+CLIFallback.swift index 555163c..f8ff963 100644 --- a/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader+CLIFallback.swift +++ b/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader+CLIFallback.swift @@ -6,7 +6,7 @@ // exhaust RAM and panic the kernel. // // The only subprocesses the bar spawns are intentional one-shots: -// • `tokmeter daemon start` — singleton auto-start (debounced, idempotent) +// • `drishti daemon start` — singleton auto-start (debounced, idempotent) // • `tokmeter update` — user-triggered pricing refresh // • `tokmeter install-cron` — user-triggered cron install (in TokmeterLoader) // All of them are bounded, single invocations — never one-per-fetch. @@ -15,36 +15,13 @@ import Foundation extension TokmeterLoader { - // ─── Node toolchain resolution ─────────────────────────────────── - - /// Resolve `npx` at known root-owned system paths. We deliberately DO NOT - /// shell out via `$SHELL -l -c` — that loads the user's dotfiles, a - /// code-execution path any malicious config can abuse. Exec directly with - /// a fixed argv: no user-writable PATH entries, no shell metacharacters. - private func resolveNpxPath() -> String? { - let npxCandidates = [ - "/opt/homebrew/bin/npx", - "/usr/local/bin/npx", - ] - return npxCandidates.first(where: { FileManager.default.fileExists(atPath: $0) }) - } - - /// PATH for spawned subprocesses. A GUI-launched app inherits launchd's - /// minimal PATH (`/usr/bin:/bin:/usr/sbin:/sbin`), so `/opt/homebrew/bin` - /// is absent — and `npx`'s shebang is `#!/usr/bin/env node`, which means - /// `env` searches PATH for `node` and fails (exit 127) when Homebrew node - /// isn't there. Result: the daemon never starts, the bar shows "warming" - /// forever. Prepend the well-known Homebrew/local bins so spawned scripts - /// can find their interpreter regardless of how the bar was launched. - private func subprocessEnvironment() -> [String: String] { - var env = ProcessInfo.processInfo.environment - let prepend = ["/opt/homebrew/bin", "/usr/local/bin"] - let current = env["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin" - // Prepend only those that aren't already present, preserving order. - let parts = current.split(separator: ":").map(String.init) - let needed = prepend.filter { !parts.contains($0) } - env["PATH"] = (needed + parts).joined(separator: ":") - return env + func recordConnectionFailure(_ error: Error) { + needsNodeSetup = false + isWarming = false + hasFreshData = false + liveContextFillPct = nil + blockPct = nil + lastError = error.localizedDescription } // ─── Daemon offline handler (no CLI scan, ever) ────────────────── @@ -56,6 +33,7 @@ extension TokmeterLoader { /// is still surfaced so the badges stay honest while the daemon warms. func handleDaemonOffline() async { self.isDaemonAlive = false + self.hasFreshData = false self.isWarming = true self.lastError = nil // Clear the live menubar-color inputs: with the daemon down we have no @@ -68,7 +46,7 @@ extension TokmeterLoader { ensureDaemonStarted() } - /// Spawn `tokmeter daemon start` exactly once, detached. The daemon CLI + /// Spawn `drishti daemon start` exactly once, detached. The daemon CLI /// itself enforces a PID singleton (it no-ops with "already running" if a /// live daemon exists), so the worst case from a redundant call is a quick /// no-op child. We still debounce with `isStartingDaemon` so concurrent @@ -77,31 +55,37 @@ extension TokmeterLoader { /// after forking the real daemon). func ensureDaemonStarted() { guard !isStartingDaemon else { return } - guard let npxPath = resolveNpxPath() else { + guard let toolchain = NodeToolchain.resolve() else { self.lastError = - "Daemon offline; no node toolchain found at /opt/homebrew or /usr/local." + "Install Node.js 18 or later, then choose Retry. Tokmeter needs Node to run its local usage service." self.isWarming = false self.hasFreshData = false + self.needsNodeSetup = true return } + needsNodeSetup = false isStartingDaemon = true Task { [weak self] in - defer { Task { @MainActor in self?.isStartingDaemon = false } } + guard let self else { return } + defer { self.isStartingDaemon = false } do { + let version = try await self.runProcess(executable: toolchain.node, arguments: ["--version"], timeout: 5) + guard let major = NodeToolchain.majorVersion(version), major >= 18 else { + self.needsNodeSetup = true + throw DaemonError.networkError("Node.js 18 or later is required. Update Node and choose Retry.") + } // `daemon start` forks a detached child and returns fast; the // child becomes the long-lived daemon. This invocation never // scans — it just launches (or no-ops on) the singleton. - _ = try await self?.runProcess( - executable: npxPath, - arguments: ["-y", "@sriinnu/tokmeter", "daemon", "start"], - timeout: 30 + _ = try await self.runProcess( + executable: toolchain.npx, + arguments: NodeToolchain.daemonArguments(version: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String), + timeout: 120 ) } catch { - await MainActor.run { - self?.lastError = - "Couldn't start daemon: \(error.localizedDescription)" - self?.isWarming = false - } + self.lastError = "Couldn't start the usage service: \(error.localizedDescription)" + self.isWarming = false + self.hasFreshData = false } } } @@ -109,12 +93,12 @@ extension TokmeterLoader { // ─── Pricing refresh via CLI (user-triggered, one-shot) ────────── func refreshPricingViaCLI() async { - guard let npxPath = resolveNpxPath() else { + guard let toolchain = NodeToolchain.resolve() else { pricingRefreshError = "No node toolchain found — run `tokmeter update` manually." return } do { - _ = try await runProcess(executable: npxPath, + _ = try await runProcess(executable: toolchain.npx, arguments: ["-y", "@sriinnu/tokmeter", "update"], timeout: 30) await loadData() @@ -154,79 +138,10 @@ extension TokmeterLoader { // ─── Subprocess runner ─────────────────────────────────────────── func runProcess(executable: String, arguments: [String], timeout: TimeInterval) async throws -> String { - let env = subprocessEnvironment() - return try await withCheckedThrowingContinuation { continuation in - let proc = Process() - proc.executableURL = URL(fileURLWithPath: executable) - proc.arguments = arguments - // Augment PATH so a GUI-launched bar's spawned scripts can find - // node/bun even though launchd's PATH doesn't include Homebrew. - proc.environment = env - - let outPipe = Pipe() - let errPipe = Pipe() - proc.standardOutput = outPipe - // Capture stderr instead of /dev/null'ing it — we need it to - // surface the real failure (e.g. "env: node: No such file or - // directory" from npx exit-127). Silent success on exit != 0 was - // the bug that left the bar stuck on "warming" forever. - proc.standardError = errPipe - - // Double-resume guard: termination handler and timeout both race - // to resume the continuation. First caller wins. Boxed in a class - // so @Sendable closures capture a reference, not a mutable var. - final class ResumeGuard: @unchecked Sendable { - var resumed = false - let lock = NSLock() - } - let guardBox = ResumeGuard() - @Sendable func finish(_ result: Result) { - guardBox.lock.lock() - defer { guardBox.lock.unlock() } - guard !guardBox.resumed else { return } - guardBox.resumed = true - switch result { - case .success(let output): continuation.resume(returning: output) - case .failure(let error): continuation.resume(throwing: error) - } - } - - proc.terminationHandler = { p in - let outData = outPipe.fileHandleForReading.readDataToEndOfFile() - let errData = errPipe.fileHandleForReading.readDataToEndOfFile() - guard let output = String(data: outData, encoding: .utf8) else { - finish(.failure(DaemonError.decodingError("non-UTF8 CLI output"))) - return - } - // Surface non-zero exit as a real failure with the first line - // of stderr (or a generic message if stderr is empty). Without - // this, exit-127 from the npx PATH gotcha was silently - // resolved as success and the bar showed "warming" forever. - if p.terminationStatus != 0 { - let errStr = String(data: errData, encoding: .utf8) ?? "" - let firstLine = errStr.split(separator: "\n", maxSplits: 1) - .first.map(String.init)?.trimmingCharacters(in: .whitespaces) ?? "" - let msg = firstLine.isEmpty - ? "exit \(p.terminationStatus)" - : "exit \(p.terminationStatus): \(firstLine)" - finish(.failure(DaemonError.networkError(msg))) - return - } - finish(.success(output)) - } - - do { - try proc.run() - } catch { - finish(.failure(error)) - return - } - - DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + timeout) { - guard proc.isRunning else { return } - proc.terminate() - finish(.failure(DaemonError.networkError("CLI timed out after \(Int(timeout))s"))) - } - } + let toolchain = NodeToolchain(binDirectory: URL(fileURLWithPath: executable).deletingLastPathComponent().path) + return try await SubprocessRunner.run( + executable: executable, arguments: arguments, + environment: toolchain.environment(base: ProcessInfo.processInfo.environment), timeout: timeout + ) } } diff --git a/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader.swift b/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader.swift index 934ca11..a545faa 100644 --- a/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader.swift +++ b/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader.swift @@ -111,7 +111,8 @@ final class TokmeterLoader: ObservableObject { /// `tokmeter daemon start` spawn is in flight so concurrent poll ticks / /// fetches can't launch a stampede of starts. The daemon itself enforces /// a PID singleton on disk; this just stops the bar from spamming spawns. - var isStartingDaemon: Bool = false + @Published var isStartingDaemon: Bool = false + @Published var needsNodeSetup = false init(startPolling: Bool = true) { guard startPolling else { return } @@ -227,6 +228,7 @@ final class TokmeterLoader: ObservableObject { self.blockPct = quick.blockElapsedPct self.isWarming = !quick.ready self.lastError = nil + self.needsNodeSetup = false if quick.ready { self.hasFreshData = true } @@ -240,10 +242,10 @@ final class TokmeterLoader: ObservableObject { await handleDaemonOffline() return } catch { - // Network error or decode failure — the daemon may be mid-restart - // or warming. Surface a warming skeleton and try again next tick. - // Still no CLI scan: reads are daemon-only. - await handleDaemonOffline() + // A live service with incompatible/malformed data cannot be + // repaired by launching another copy. Keep the actual failure + // visible and retry the read on the normal poll cadence. + recordConnectionFailure(error) return } @@ -486,20 +488,14 @@ final class TokmeterLoader: ObservableObject { cronInstallError = nil defer { isInstallingCron = false } - let npxCandidates = [ - "/opt/homebrew/bin/npx", - "/usr/local/bin/npx", - ] - guard let npxPath = npxCandidates.first(where: { - FileManager.default.fileExists(atPath: $0) - }) else { + guard let toolchain = NodeToolchain.resolve() else { cronInstallError = "No node toolchain found — run `tokmeter \(subcommand)` manually." return } do { _ = try await runProcess( - executable: npxPath, + executable: toolchain.npx, arguments: ["-y", "@sriinnu/tokmeter", subcommand], timeout: 30 ) diff --git a/packages/macos-bar/Sources/TokmeterBar/UpdaterController.swift b/packages/macos-bar/Sources/TokmeterBar/UpdaterController.swift index 178fce8..924ea97 100644 --- a/packages/macos-bar/Sources/TokmeterBar/UpdaterController.swift +++ b/packages/macos-bar/Sources/TokmeterBar/UpdaterController.swift @@ -28,12 +28,12 @@ final class UpdaterController: ObservableObject { /// for a frame when the popover first opens. @Published var canCheckForUpdates: Bool = true - init() { + init(startingUpdater: Bool = true) { // startingUpdater: true means Sparkle starts polling immediately. // updaterDelegate: nil — we accept all of Sparkle's defaults. // userDriverDelegate: nil — we use the default UI for prompts. self.updater = SPUStandardUpdaterController( - startingUpdater: true, + startingUpdater: startingUpdater, updaterDelegate: nil, userDriverDelegate: nil ) diff --git a/packages/macos-bar/Sources/TokmeterBar/UsageOverview.swift b/packages/macos-bar/Sources/TokmeterBar/UsageOverview.swift index 1954bb3..9e51583 100644 --- a/packages/macos-bar/Sources/TokmeterBar/UsageOverview.swift +++ b/packages/macos-bar/Sources/TokmeterBar/UsageOverview.swift @@ -5,7 +5,13 @@ struct UsageOverview: View { @ObservedObject var loader: TokmeterLoader let theme: AppTheme @State private var showAllSessions = false - @State private var showUsageDetails = false + @Binding var showUsageDetails: Bool + + init(loader: TokmeterLoader, theme: AppTheme, showUsageDetails: Binding = .constant(false)) { + self.loader = loader + self.theme = theme + _showUsageDetails = showUsageDetails + } var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -18,7 +24,7 @@ struct UsageOverview: View { Text("Model and project costs may combine estimates and tool reports.") .font(.system(size: 9, design: theme.fonts.bodyDesign)) .foregroundColor(theme.backgroundMode.secondaryTextColor) - DisclosureGroup("Usage details", isExpanded: $showUsageDetails) { + DisclosureGroup(isExpanded: $showUsageDetails) { VStack(spacing: 14) { SignalsRibbon(loader: loader, theme: theme) StatsGrid(loader: loader, theme: theme) @@ -27,9 +33,42 @@ struct UsageOverview: View { } } .padding(.top, 10) + } label: { + Text("Usage details") + .foregroundStyle(theme.backgroundMode.primaryTextColor) } + .disclosureGroupStyle(FullRowDisclosureStyle()) .font(.system(size: 11, weight: .medium, design: theme.fonts.bodyDesign)) .tint(theme.backgroundMode.secondaryTextColor) } } } + +/// One button owns the chevron, label, and remaining row width, so every +/// click toggles once and keyboard activation uses the same action. +private struct FullRowDisclosureStyle: DisclosureGroupStyle { + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 0) { + Button { + configuration.isExpanded.toggle() + } label: { + HStack(spacing: 5) { + Image(systemName: configuration.isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + configuration.label + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityValue(configuration.isExpanded ? "Expanded" : "Collapsed") + if configuration.isExpanded { + configuration.content + } + } + } +} diff --git a/packages/macos-bar/Tests/TokmeterBarTests/DemoRenderTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/DemoRenderTests.swift index 4c4fe89..8b1aef5 100644 --- a/packages/macos-bar/Tests/TokmeterBarTests/DemoRenderTests.swift +++ b/packages/macos-bar/Tests/TokmeterBarTests/DemoRenderTests.swift @@ -12,6 +12,48 @@ private struct DemoScene: Decodable { } final class DemoRenderTests: XCTestCase { + @MainActor + func testRenderThemeReview() throws { + guard let directory = ProcessInfo.processInfo.environment["TOKMETER_UI_QA_DIR"] else { + throw XCTSkip("Set TOKMETER_UI_QA_DIR for theme inspection") + } + var repository = URL(fileURLWithPath: #filePath) + for _ in 0..<5 { repository.deleteLastPathComponent() } + let scenes = try JSONDecoder().decode([DemoScene].self, from: Data(contentsOf: + repository.appendingPathComponent("docs/assets/demo/snapshots.json"))) + let scene = try XCTUnwrap(scenes.last) + let root = URL(fileURLWithPath: directory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let loader = TokmeterLoader(startPolling: false) + loader.isWarming = false + loader.hasFreshData = true + loader.todayTokens = scene.tokens + loader.statbarSignals = scene.signals + loader.todayModels = scene.models.map(TokmeterLoader.toUsage) + loader.topModels = loader.todayModels + loader.todayProjects = scene.projects + for theme in AppTheme.allCases { + for expanded in [false, true] { + let view = VStack(alignment: .leading, spacing: 0) { + HeroHeader(loader: loader, theme: theme, breathToggle: false, + isVisible: false, showCachePanel: .constant(false)) + UsageOverview(loader: loader, theme: theme, showUsageDetails: .constant(expanded)) + .padding(16) + } + .frame(width: 400) + .background(LinearGradient(colors: theme.backgroundMode.gradientColors(), + startPoint: .topLeading, endPoint: .bottomTrailing)) + .environment(\.colorScheme, theme.backgroundMode.isLight ? .light : .dark) + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + let image = try XCTUnwrap(renderer.nsImage) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: XCTUnwrap(image.tiffRepresentation))) + let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + try png.write(to: root.appendingPathComponent("\(theme.rawValue)-\(expanded ? "expanded" : "collapsed").png")) + } + } + } + /// Opt-in artifact rendering uses the production SwiftUI views and synthetic data. @MainActor func testRenderWalkthrough() throws { diff --git a/packages/macos-bar/Tests/TokmeterBarTests/PopoverLayoutTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/PopoverLayoutTests.swift new file mode 100644 index 0000000..bc41302 --- /dev/null +++ b/packages/macos-bar/Tests/TokmeterBarTests/PopoverLayoutTests.swift @@ -0,0 +1,161 @@ +import AppKit +import SwiftUI +import XCTest +@testable import TokmeterBar + +final class PopoverLayoutTests: XCTestCase { + @MainActor + func testFullPopoverHasUsageContentOnFirstLayout() throws { + for theme in [AppTheme.nebula, .glass, .terminal, .paper] { + for expanded in [false, true] { + try checkFullPopover(expanded: expanded, theme: theme) + } + } + } + + @MainActor + private func checkFullPopover(expanded: Bool, theme: AppTheme) throws { + let loader = TokmeterLoader(startPolling: false) + loader.isWarming = false + loader.pricingMtime = Date().timeIntervalSince1970 * 1000 + loader.healthStatus = HealthStatus(unpricedModels: ["sample-missing-a", "sample-missing-b"], unpricedRecords: 2) + loader.pricingAnomalies = AnomaliesResponse(anomalies: (1...5).map { + PricingAnomaly(ts: 0, key: "sample-model-\($0)", field: "input", side: "increase", + previous: 1, current: 2, deltaPct: 100) + }, total: 5, cappedAt: 100) + let preferences = try XCTUnwrap(UserDefaults(suiteName: "TokmeterPopoverLayoutTests")) + let host = NSHostingView(rootView: + TokmeterBarView(loader: loader, updater: UpdaterController(startingUpdater: false), + theme: theme, usageDetailsExpanded: expanded) + .defaultAppStorage(preferences) + .environment(\.colorScheme, .dark)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 1), + styleMask: [.borderless], backing: .buffered, defer: false) + window.appearance = NSAppearance(named: .darkAqua) + window.contentView = host + func settle() -> CGFloat { + for _ in 0..<10 { + host.layoutSubtreeIfNeeded() + window.setContentSize(host.fittingSize) + RunLoop.main.run(until: Date().addingTimeInterval(0.03)) + } + return host.fittingSize.height + } + // Even with no usage records, the explanation and disclosure must fit + // between the actual production hero and footer on the first opening. + let emptyHeight = settle() + XCTAssertGreaterThan(emptyHeight, 200) + XCTAssertLessThan(emptyHeight, 450) + loader.todayModels = [ModelUsage(model: "sample-model", provider: "codex", cost: 1, + tokens: 100, inputTokens: 80, outputTokens: 20, + cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0)] + loader.topModels = loader.todayModels + loader.todayProjects = [ProjectData(project: "sample-project", totalCost: 1, + totalTokens: 100, activeDays: 1, lastUsed: nil)] + let populatedHeight = settle() + XCTAssertGreaterThan(populatedHeight, emptyHeight + 100) + if let directory = ProcessInfo.processInfo.environment["TOKMETER_UI_QA_DIR"] { + let bitmap = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + .write(to: URL(fileURLWithPath: directory) + .appendingPathComponent("full-\(theme.rawValue)-popover-\(expanded ? "expanded" : "collapsed").png")) + } + loader.todayModels = [] + loader.topModels = [] + loader.todayProjects = [] + XCTAssertEqual(settle(), emptyHeight, accuracy: 1) + window.contentView = nil + } + + @MainActor + func testUsageDisclosureResizesAndAdaptsToAvailableHeight() { + let model = DisclosureModel() + let loader = TokmeterLoader(startPolling: false) + loader.isWarming = false + let host = NSHostingView(rootView: DisclosureFixture(model: model, loader: loader)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 1), + styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = host + func settle() -> CGFloat { + for _ in 0..<10 { + host.layoutSubtreeIfNeeded() + window.setContentSize(host.fittingSize) + RunLoop.main.run(until: Date().addingTimeInterval(0.03)) + } + return host.fittingSize.height + } + let collapsed = settle() + XCTAssertGreaterThan(collapsed, 30) + model.expanded = true + let expanded = settle() + XCTAssertGreaterThan(expanded, collapsed + 80) + model.maximumHeight = 100 + XCTAssertEqual(settle(), 100, accuracy: 1) + model.maximumHeight = 600 + XCTAssertEqual(settle(), expanded, accuracy: 1) + model.expanded = false + XCTAssertEqual(settle(), collapsed, accuracy: 1) + window.contentView = nil + } + + @MainActor + func testScrollAreaGrowsCapsAndShrinksWithContent() { + let model = HeightModel() + let host = NSHostingView(rootView: LayoutFixture(model: model)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 600), + styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = host + func measuredHeight() -> CGFloat { + // Geometry preferences commit on the next layout/run-loop turn. + for _ in 0..<5 { + host.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.02)) + } + return host.fittingSize.height + } + XCTAssertEqual(measuredHeight(), 160, accuracy: 1) + model.height = 900 + XCTAssertEqual(measuredHeight(), 360, accuracy: 1) + model.height = 100 + XCTAssertEqual(measuredHeight(), 160, accuracy: 1) + window.contentView = nil + } +} + +private final class DisclosureModel: ObservableObject { + @Published var expanded = false + @Published var maximumHeight: CGFloat = 600 +} + +private struct DisclosureFixture: View { + @ObservedObject var model: DisclosureModel + @ObservedObject var loader: TokmeterLoader + var body: some View { + ContentSizedScrollView(maximumHeight: model.maximumHeight) { + UsageOverview(loader: loader, theme: .terminal, showUsageDetails: $model.expanded) + .padding(16) + } + .frame(width: 400) + .fixedSize(horizontal: false, vertical: true) + } +} + +private final class HeightModel: ObservableObject { + @Published var height: CGFloat = 100 +} + +private struct LayoutFixture: View { + @ObservedObject var model: HeightModel + var body: some View { + VStack(spacing: 0) { + Color.clear.frame(height: 40) + ContentSizedScrollView(maximumHeight: 300) { + Color.blue.frame(height: model.height) + } + Color.clear.frame(height: 20) + } + .frame(width: 400) + .fixedSize(horizontal: false, vertical: true) + } +} diff --git a/packages/macos-bar/Tests/TokmeterBarTests/StartupRenderTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/StartupRenderTests.swift new file mode 100644 index 0000000..87ed108 --- /dev/null +++ b/packages/macos-bar/Tests/TokmeterBarTests/StartupRenderTests.swift @@ -0,0 +1,33 @@ +import AppKit +import SwiftUI +import XCTest +@testable import TokmeterBar + +final class StartupRenderTests: XCTestCase { + @MainActor + func testRenderActionableStartupErrors() throws { + guard let directory = ProcessInfo.processInfo.environment["TOKMETER_UI_QA_DIR"] else { + throw XCTSkip("Set TOKMETER_UI_QA_DIR for startup layout inspection") + } + let root = URL(fileURLWithPath: directory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let view = VStack(alignment: .leading, spacing: 16) { + ConnectionIssueView(error: "Install Node.js 18 or later, then choose Retry. Tokmeter needs Node to run its local usage service.", + needsNodeSetup: true, isRetrying: false, retry: {}) + ConnectionIssueView(error: DaemonError.versionMismatch(2).localizedDescription, + needsNodeSetup: false, isRetrying: false, retry: {}) + ConnectionIssueView(error: "Couldn't start the usage service: the request timed out. Check your connection, then retry.", + needsNodeSetup: false, isRetrying: false, retry: {}) + } + .padding(12) + .frame(width: 320) + .background(Color(red: 0.035, green: 0.04, blue: 0.07)) + .environment(\.colorScheme, .dark) + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + let image = try XCTUnwrap(renderer.nsImage) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: XCTUnwrap(image.tiffRepresentation))) + let png = try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + try png.write(to: root.appendingPathComponent("startup-errors.png")) + } +} diff --git a/packages/macos-bar/Tests/TokmeterBarTests/StartupTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/StartupTests.swift new file mode 100644 index 0000000..f843006 --- /dev/null +++ b/packages/macos-bar/Tests/TokmeterBarTests/StartupTests.swift @@ -0,0 +1,99 @@ +import XCTest +@testable import TokmeterBar + +final class StartupTests: XCTestCase { + func testDaemonBootstrapUsesPackageThatActuallyContainsDaemon() { + XCTAssertEqual(NodeToolchain.daemonArguments(version: "1.10.0"), + ["--yes", "@sriinnu/drishti@1.10.0", "daemon", "start"]) + } + + func testNpxWithoutPairedNodeIsNotAnInstallation() { + let files: Set = ["/broken/npx", "/working/npx", "/working/node"] + XCTAssertEqual(NodeToolchain.firstAvailable(directories: ["/broken", "/working"], + isExecutable: files.contains)?.binDirectory, "/working") + } + + func testManagedNodeIsFoundWithoutShellPathOrDotfiles() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + for version in ["v16.20.0", "v22.9.0", "v22.10.0"] { + let bin = root.appendingPathComponent(".nvm/versions/node/\(version)/bin") + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + for name in ["node", "npx"] { + let file = bin.appendingPathComponent(name) + try Data().write(to: file) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: file.path) + } + } + let toolchain = NodeToolchain.resolve(home: root.path, systemDirectories: []) + XCTAssertEqual(toolchain?.binDirectory, root.path + "/.nvm/versions/node/v22.10.0/bin") + let environment = try XCTUnwrap(toolchain).environment(base: ["PATH": "/usr/bin:/bin", "LANG": "en_US.UTF-8"]) + XCTAssertEqual(environment["PATH"]?.split(separator: ":").first.map(String.init), toolchain?.binDirectory) + XCTAssertEqual(environment["LANG"], "en_US.UTF-8") + } + + func testMissingNodeIsRepresentableWithoutAttemptingAnInstall() { + XCTAssertNil(NodeToolchain.firstAvailable(directories: ["/missing"], isExecutable: { _ in false })) + XCTAssertEqual(NodeToolchain.majorVersion("v22.0.0\n"), 22) + XCTAssertNil(NodeToolchain.majorVersion("not node")) + } + + @MainActor + func testProtocolFailureStopsWarmingAndClearsLiveClaims() { + let loader = TokmeterLoader(startPolling: false) + loader.isWarming = true + loader.hasFreshData = true + loader.liveContextFillPct = 90 + loader.blockPct = 50 + loader.recordConnectionFailure(DaemonError.versionMismatch(2)) + XCTAssertFalse(loader.isWarming) + XCTAssertFalse(loader.hasFreshData) + XCTAssertFalse(loader.isStartingDaemon) + XCTAssertNil(loader.liveContextFillPct) + XCTAssertNil(loader.blockPct) + XCTAssertTrue(loader.lastError?.contains("incompatible") == true) + } +} + +final class SubprocessRunnerTests: XCTestCase { + private let environment = ["PATH": "/usr/bin:/bin"] + + func testDrainsNoisyStdoutAndStderrWithoutPipeDeadlock() async throws { + let output = try await SubprocessRunner.run( + executable: "/usr/bin/python3", + arguments: ["-c", "import sys; sys.stdout.write('x'*524288); sys.stdout.flush(); sys.stderr.write('e'*524288)"], + environment: environment, timeout: 10) + XCTAssertEqual(output.count, 262_144) + XCTAssertTrue(output.allSatisfy { $0 == "x" }) + } + + func testNonzeroExitIncludesBoundedFailureReason() async { + do { + _ = try await SubprocessRunner.run(executable: "/bin/sh", arguments: ["-c", "echo install-failed >&2; exit 9"], + environment: environment, timeout: 5) + XCTFail("Expected nonzero exit") + } catch { + XCTAssertTrue(error.localizedDescription.contains("exit 9: install-failed")) + } + } + + func testTimeoutReturnsWithoutWaitingForChild() async { + let start = Date() + do { + _ = try await SubprocessRunner.run(executable: "/bin/sleep", arguments: ["10"], + environment: environment, timeout: 0.1) + XCTFail("Expected timeout") + } catch { + XCTAssertTrue(error.localizedDescription.contains("timed out")) + XCTAssertLessThan(Date().timeIntervalSince(start), 3) + } + } + + func testMissingExecutableFailsPromptly() async { + do { + _ = try await SubprocessRunner.run(executable: "/does/not/exist", arguments: [], + environment: environment, timeout: 5) + XCTFail("Expected launch failure") + } catch { } + } +} diff --git a/packages/macos-bar/Tests/TokmeterBarTests/ThemeContrastTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/ThemeContrastTests.swift new file mode 100644 index 0000000..9b0c9e1 --- /dev/null +++ b/packages/macos-bar/Tests/TokmeterBarTests/ThemeContrastTests.swift @@ -0,0 +1,112 @@ +import AppKit +import SwiftUI +import XCTest +@testable import TokmeterBar + +final class ThemeContrastTests: XCTestCase { + @MainActor + func testStatusInkContrastAndThemeAppearance() throws { + for theme in [AppTheme.glass, .paper, .terminal, .nebula] { + for color in [theme.statusWarning, theme.statusSuccess, theme.statusDanger] { + let lightHost = try renderedRGB(color, scheme: .light) + let darkHost = try renderedRGB(color, scheme: .dark) + for (a, b) in zip(lightHost, darkHost) { XCTAssertEqual(a, b, accuracy: 0.01) } + if theme.backgroundMode.isLight { + for gray in [0.65, 0.8, 0.95] { + XCTAssertGreaterThanOrEqual(contrast(darkHost, [gray, gray, gray]), 4.5) + } + } else { + XCTAssertGreaterThan(contrast(lightHost, [0.06, 0.07, 0.10]), 4.5) + } + } + } + try renderContrastWidgets() + } + + @MainActor + private func renderContrastWidgets() throws { + let directory = ProcessInfo.processInfo.environment["TOKMETER_UI_QA_DIR"] + var repository = URL(fileURLWithPath: #filePath) + for _ in 0..<5 { repository.deleteLastPathComponent() } + let scenes = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: + repository.appendingPathComponent("docs/assets/demo/snapshots.json"))) as? [[String: Any]]) + var signals = try XCTUnwrap(scenes.last?["signals"] as? [String: Any]) + var burn = try XCTUnwrap(signals["burnRate"] as? [String: Any]) + burn["costPerHour"] = 145 + signals["burnRate"] = burn + var pace = try XCTUnwrap(signals["pace"] as? [String: Any]) + pace["multiple"] = 15 + pace["daysOfHistory"] = 7 + signals["pace"] = pace + let loader = TokmeterLoader(startPolling: false) + loader.isWarming = false + loader.totalTokens = 3_159_000 + loader.totalCost = 2701 + loader.recentDaily = [DailyUsage(date: "2020-01-01", tokens: 1_000_000, cost: 1000), + DailyUsage(date: "2020-01-02", tokens: 3_159_000, cost: 2701)] + loader.statbarSignals = try JSONDecoder().decode(StatbarSignals.self, + from: JSONSerialization.data(withJSONObject: signals)) + for theme in [AppTheme.glass, .paper, .terminal] { + let host = NSHostingView(rootView: VStack(spacing: 14) { + SignalsRibbon(loader: loader, theme: theme) + StatsGrid(loader: loader, theme: theme) + } + .padding(16) + .frame(width: 400) + .fixedSize(horizontal: false, vertical: true) + .background(theme == .glass ? Color(red: 0.70, green: 0.72, blue: 0.76) : theme.backgroundMode.surfaceColor) + .environment(\.colorScheme, .dark)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 400, height: 220), + styleMask: [.borderless], backing: .buffered, defer: false) + window.appearance = NSAppearance(named: .darkAqua) + window.contentView = host + for _ in 0..<20 { + host.layoutSubtreeIfNeeded() + window.setContentSize(host.fittingSize) + RunLoop.main.run(until: Date().addingTimeInterval(0.03)) + } + let bitmap = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + // Exercise production widgets inside a Dark Aqua native host even + // when the selected theme is light. Both pace and delta text must + // contain the selected theme's opaque ink in the captured pixels. + for color in [theme.statusWarning, theme.statusSuccess] { + let expected = try renderedRGB(color, scheme: .dark) + var matches = 0 + for y in 0.. [Double] { + let renderer = ImageRenderer(content: color.frame(width: 10, height: 10) + .environment(\.colorScheme, scheme)) + let image = try XCTUnwrap(renderer.nsImage) + let bitmap = try XCTUnwrap(NSBitmapImageRep(data: XCTUnwrap(image.tiffRepresentation))) + let pixel = try XCTUnwrap(bitmap.colorAt(x: 5, y: 5)?.usingColorSpace(.sRGB)) + return [pixel.redComponent, pixel.greenComponent, pixel.blueComponent] + } + + private func luminance(_ rgb: [Double]) -> Double { + let linear = rgb.map { $0 <= 0.04045 ? $0 / 12.92 : pow(($0 + 0.055) / 1.055, 2.4) } + return zip(linear, [0.2126, 0.7152, 0.0722]).map(*).reduce(0, +) + } + + private func contrast(_ a: [Double], _ b: [Double]) -> Double { + let x = luminance(a), y = luminance(b) + return (max(x, y) + 0.05) / (min(x, y) + 0.05) + } +} diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 9022695..dfed4d1 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1,20 +1,6 @@ -

+# Drishti -

@sriinnu/drishti

- -

- दृष्टि — MCP server + live token observatory for AI coding agents -

- -

- npm version - MIT License - node >= 18 -

- ---- - -**@sriinnu/drishti** (दृष्टि — "vision") is the observation layer for token usage across AI coding agents. It provides an MCP server that exposes token data as tools, a live TUI dashboard, a statusline for editor hooks, and a cross-provider aggregation daemon. +`@sriinnu/drishti` provides an MCP server for usage queries, a local aggregation daemon, a live terminal UI, and editor statusline hooks. Pairs with [`@sriinnu/tokmeter`](https://www.npmjs.com/package/@sriinnu/tokmeter) for the core parsing engine. Works with Claude Code, Cursor, OpenCode, Codex CLI, Windsurf, Zed, VS Code Copilot, and more. @@ -52,7 +38,7 @@ npx @sriinnu/drishti ### Live Dashboard ```bash -# Launch the real-time TUI observatory +# Launch the live terminal UI drishti live ``` @@ -103,15 +89,17 @@ drishti install-mcp Once connected, drishti exposes these tools to the AI agent: -| Tool | Description | -| ------------------- | ---------------------------------------------- | -| `token_usage` | Token usage summary (today / week / month / all-time) | -| `cost_breakdown` | Cost breakdown by model, provider, or project | -| `daily_trend` | Daily usage trend with sparkline | -| `session_cost` | Current session cost and burn rate | -| `budget_check` | Check remaining budget against a limit | -| `compare_models` | Compare cost-efficiency across models | -| `export_csv` | Export usage data as CSV | +| Tool | Purpose | +| --- | --- | +| `drishti_pulse` | Usage snapshot | +| `drishti_models`, `drishti_providers`, `drishti_projects` | Cost and token breakdowns | +| `drishti_timeline`, `drishti_heatmap` | Usage over time | +| `drishti_search`, `drishti_compare`, `drishti_export` | Search, compare, and export | +| `drishti_budget`, `drishti_budget_alert`, `drishti_forecast` | Budget and forecast estimates | +| `drishti_cache_efficiency`, `drishti_efficiency`, `drishti_anomaly` | Efficiency and anomalies | +| `drishti_model_advisor`, `drishti_cost_optimization_tips` | Cost suggestions | +| `drishti_leaderboard`, `drishti_digest`, `drishti_streaks` | Reports and usage patterns | +| `drishti_cleanup_preview`, `drishti_cleanup_execute`, `drishti_backups`, `drishti_restore` | Preview cleanup, delete with confirmation, and restore backups | ### Statusline @@ -185,4 +173,4 @@ Claude Code, OpenCode, Codex CLI, Cursor, Windsurf, Zed, VS Code Copilot, and mo ## License -[MIT](https://opensource.org/licenses/MIT) +AGPL-3.0-only. Core source retains MPL-2.0. License texts and the build source snapshot are included in `dist/licenses/`; see [licenses and source](https://github.com/sriinnu/tokmeter/blob/main/docs/licensing.md). diff --git a/packages/mcp/SKILL.md b/packages/mcp/SKILL.md index 394f46a..a7aff0d 100644 --- a/packages/mcp/SKILL.md +++ b/packages/mcp/SKILL.md @@ -1,17 +1,22 @@ # @sriinnu/drishti -MCP server + live token observatory for AI coding agents. +MCP server, local daemon, and live usage reporting for AI coding agents. ## Capabilities -### MCP Tools -- `token_usage` -- usage summary (today/week/month/all-time) -- `cost_breakdown` -- cost by model, provider, or project -- `daily_trend` -- daily usage with sparkline -- `session_cost` -- current session cost and burn rate -- `budget_check` -- check against a spending limit -- `compare_models` -- compare cost-efficiency across models -- `export_csv` -- export as CSV +### MCP tools + +| Tool | Purpose | +| --- | --- | +| `drishti_pulse` | Usage snapshot | +| `drishti_models`, `drishti_providers`, `drishti_projects` | Cost and token breakdowns | +| `drishti_timeline`, `drishti_heatmap` | Usage over time | +| `drishti_search`, `drishti_compare`, `drishti_export` | Search, compare, and export | +| `drishti_budget`, `drishti_budget_alert`, `drishti_forecast` | Budget and forecast estimates | +| `drishti_cache_efficiency`, `drishti_efficiency`, `drishti_anomaly` | Efficiency and anomalies | +| `drishti_model_advisor`, `drishti_cost_optimization_tips` | Cost suggestions | +| `drishti_leaderboard`, `drishti_digest`, `drishti_streaks` | Reports and usage patterns | +| `drishti_cleanup_preview`, `drishti_cleanup_execute`, `drishti_backups`, `drishti_restore` | Preview cleanup, delete with confirmation, and restore backups | ### Statusline Shows project name, session cost, model, token flow (input/output/cache), context window %, burn rate, daily total, per-model breakdown. @@ -29,5 +34,8 @@ drishti statusline # statusline hook ## Integration -Claude Code: `~/.claude/.mcp.json` and `~/.claude/settings.json` statusLine -Codex: `~/.codex/config.toml` [mcp_servers.drishti] +Use `drishti install-mcp` for the repository's supported editor setup; inspect the generated configuration for your editor. See the [README](README.md) for stdio configuration and APIs. + +## License + +AGPL-3.0-only; see [licenses and source](../../docs/licensing.md). diff --git a/packages/tokmeter/README.md b/packages/tokmeter/README.md index 90c22a3..59c7a0c 100644 --- a/packages/tokmeter/README.md +++ b/packages/tokmeter/README.md @@ -1,20 +1,6 @@ -

+# @sriinnu/tokmeter -

@sriinnu/tokmeter

- -

- Token usage tracking for AI coding agents — parsers, CLI, and TUI -

- -

- npm version - MIT License - node >= 18 -

- ---- - -**@sriinnu/tokmeter** is the unified package for tracking token consumption across 16+ AI coding agents. It bundles the core parsing/aggregation engine, the CLI, and the interactive TUI into a single install with subpath exports. +`@sriinnu/tokmeter` bundles the core parsing/aggregation engine, the CLI, and the interactive TUI into a single install with subpath exports. Scans local session files from Claude Code, Cursor, Codex CLI, Gemini CLI, OpenCode, Amp, Roo Code, Kilo Code, and more. Breaks down usage by project, model, provider, and day. Computes real-time cost estimates powered by [`@sriinnu/kosha-discovery`](https://www.npmjs.com/package/@sriinnu/kosha-discovery). @@ -173,4 +159,4 @@ Claude Code, OpenCode, Codex CLI, Gemini CLI, Cursor, Amp, Droid, OpenClaw, Pi, ## License -[MIT](https://opensource.org/licenses/MIT) +AGPL-3.0-only. Core source retains MPL-2.0. License texts and the build source snapshot are included in `dist/licenses/`; see [licenses and source](https://github.com/sriinnu/tokmeter/blob/main/docs/licensing.md). diff --git a/packages/tui/README.md b/packages/tui/README.md index 4c7cf06..876e557 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -1,19 +1,13 @@ -

- tokmeter -

- -

@sriinnu/tokmeter-tui

- -

Interactive terminal UI with charts, sparklines, and heatmaps

- ---- +# Terminal UI A full-screen terminal dashboard for exploring token usage. Built with [Ink](https://github.com/vadimdemedes/ink) (React for CLIs). ## Install +This is a private workspace package. Install the public `@sriinnu/tokmeter` distribution. + ```bash -npx @sriinnu/tokmeter-tui +npx -p @sriinnu/tokmeter tokmeter-tui ``` ## Views @@ -35,4 +29,4 @@ npx @sriinnu/tokmeter-tui ## License -MIT +AGPL-3.0-only — [license text](../../LICENSE). See [licenses and source](../../docs/licensing.md). diff --git a/packages/tui/SKILL.md b/packages/tui/SKILL.md index 05aa394..329adef 100644 --- a/packages/tui/SKILL.md +++ b/packages/tui/SKILL.md @@ -1,5 +1,7 @@ # @sriinnu/tokmeter-tui +Private workspace package. Use the public `@sriinnu/tokmeter` distribution. + Interactive terminal UI for token usage tracking. Full-screen dashboard with charts, sparklines, and heatmaps. ## Capabilities @@ -13,5 +15,9 @@ Interactive terminal UI for token usage tracking. Full-screen dashboard with cha ## Usage ```bash -npx @sriinnu/tokmeter-tui +npx -p @sriinnu/tokmeter tokmeter-tui ``` + +## License + +AGPL-3.0-only; see [licenses and source](../../docs/licensing.md). diff --git a/packages/web/README.md b/packages/web/README.md index 328ec08..c6dec1f 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -1,21 +1,15 @@ -

- tokmeter -

+# Web dashboard -

@sriinnu/tokmeter-web

- -

React + Plotly web dashboard for token usage visualization

- ---- - -Rich browser-based dashboard with interactive charts. Built with React and Plotly.js. +Browser dashboard for token and cost data. Built with React and Plotly.js. ## Setup +This is a private workspace app, run from a source checkout. + ```bash -cd packages/web +# From the repository root bun install -bun run dev +bun run dev:web ``` Open http://localhost:3000 @@ -41,4 +35,4 @@ tokmeter --json > packages/web/public/data.json ## License -MIT +AGPL-3.0-only — [license text](../../LICENSE). See [licenses and source](../../docs/licensing.md). diff --git a/packages/web/SKILL.md b/packages/web/SKILL.md index d5383ef..7328240 100644 --- a/packages/web/SKILL.md +++ b/packages/web/SKILL.md @@ -1,5 +1,7 @@ # @sriinnu/tokmeter-web +Private workspace package. Run the web dashboard from this source checkout. + React + Plotly web dashboard for token usage visualization. ## Capabilities @@ -15,3 +17,7 @@ React + Plotly web dashboard for token usage visualization. tokmeter --json > packages/web/public/data.json cd packages/web && bun run dev ``` + +## License + +AGPL-3.0-only; see [licenses and source](../../docs/licensing.md). diff --git a/scripts/prepare-license-materials.py b/scripts/prepare-license-materials.py index 25f25a7..51b8074 100644 --- a/scripts/prepare-license-materials.py +++ b/scripts/prepare-license-materials.py @@ -14,9 +14,9 @@ def source_files(): # Explicit source/build inputs only: never sweep the working directory, # credentials, local usage, personal notes, or generated build trees. files = set() - for name in ("LICENSE", "README.md", "CHANGELOG.md", "package.json", "bun.lock", + for name in ("LICENSE", "README.md", "SKILL.md", "CHANGELOG.md", "package.json", "bun.lock", "tsconfig.base.json", "biome.json", "vitest.config.ts", - "docs/licensing.md"): + "docs/licensing.md", "docs/assets/demo/snapshots.json"): path = ROOT / name if path.is_file(): files.add(path) @@ -35,7 +35,7 @@ def source_files(): ): files.add(path) for pattern in ("package.json", "tsconfig*.json", "vite.config.*", "index.html", - "LICENSE", "README.md", "Package.swift", "Package.resolved", + "LICENSE", "README.md", "SKILL.md", "Package.swift", "Package.resolved", "*.sh", "entitlements.plist", "AppIcon.icns"): files.update(path for path in package.glob(pattern) if path.is_file()) for path in sorted(files):