From ab455195306d01bf035084e1273e327e36a1afb6 Mon Sep 17 00:00:00 2001 From: Sriinnu Date: Tue, 8 Sep 2026 21:47:28 +0200 Subject: [PATCH 1/3] feat: ship 1.11.0 Hub, themes, scoped queries and on-demand dashboard --- .github/workflows/ci.yml | 14 + CHANGELOG.md | 17 + README.md | 2 +- SKILL.md | 8 +- biome.json | 2 +- bun.lock | 12 +- docs/consuming-tokmeter.md | 18 +- docs/macos-completion.md | 16 +- docs/macos/themes.md | 62 ++++ docs/macos/web-dashboard.md | 25 ++ docs/reviews/2026-09-08.md | 101 ++++++ packages/cli/README.md | 4 +- packages/cli/package.json | 2 +- packages/cli/src/cli.ts | 51 +-- packages/cli/src/daemon-read.test.ts | 1 + packages/cli/src/daemon-read.ts | 2 +- packages/cli/src/index.ts | 26 +- packages/cli/src/query-filters.test.ts | 183 +++++++++++ packages/core/README.md | 21 +- packages/core/SKILL.md | 2 +- packages/core/package.json | 2 +- packages/core/src/summary-query.test.ts | 171 ++++++++++ packages/core/src/summary-query.ts | 155 +++++++++ packages/core/src/tokmeter-core.ts | 39 ++- packages/macos-bar/README.md | 11 +- packages/macos-bar/RELEASE.md | 198 ++---------- .../Sources/TokmeterBar/BalancedGrid.swift | 45 +++ .../Sources/TokmeterBar/CardBackground.swift | 34 +- .../Sources/TokmeterBar/DaemonClient.swift | 18 +- .../TokmeterBar/DailyUsageTooltip.swift | 55 ++++ .../Sources/TokmeterBar/DataSections.swift | 40 ++- .../Sources/TokmeterBar/HeroBackground.swift | 116 +------ .../Sources/TokmeterBar/HeroHeader.swift | 66 ++-- .../TokmeterBar/HubActivityChart.swift | 27 +- .../Sources/TokmeterBar/HubCard.swift | 22 +- .../TokmeterBar/HubCommandsCatalog.swift | 14 +- .../Sources/TokmeterBar/HubConfigStore.swift | 49 ++- .../Sources/TokmeterBar/HubKpiTile.swift | 27 +- .../Sources/TokmeterBar/HubOverview.swift | 17 +- .../TokmeterBar/HubProjectCliActions.swift | 14 +- .../Sources/TokmeterBar/HubPulseCard.swift | 14 +- .../Sources/TokmeterBar/HubSettings.swift | 8 +- .../Sources/TokmeterBar/HubSidebar.swift | 25 +- .../TokmeterBar/HubToolCallsCard.swift | 6 +- .../Sources/TokmeterBar/HubView.swift | 13 +- .../Sources/TokmeterBar/HubYearHeatmap.swift | 73 ++++- .../Sources/TokmeterBar/NodeToolchain.swift | 40 ++- .../Sources/TokmeterBar/PrismSurface.swift | 64 ++++ .../Sources/TokmeterBar/SettingsPopover.swift | 21 +- .../Sources/TokmeterBar/ShellArgument.swift | 8 + .../Sources/TokmeterBar/SignalsRibbon.swift | 46 +-- .../Sources/TokmeterBar/StatCards.swift | 64 ++-- .../Sources/TokmeterBar/Theme+Modes.swift | 31 +- .../macos-bar/Sources/TokmeterBar/Theme.swift | 58 ++-- .../Sources/TokmeterBar/ThemePalettes.swift | 44 ++- .../TokmeterLoader+CLIFallback.swift | 9 +- .../TokmeterBar/WebDashboardController.swift | 121 +++++++ .../DaemonClientURLTests.swift | 36 +++ .../TokmeterBarTests/DemoRenderTests.swift | 16 + .../HeatmapDailyValuesTests.swift | 85 +++++ .../TokmeterBarTests/HubActionsTests.swift | 63 ++++ .../HubResponsiveLayoutTests.swift | 78 +++++ .../TokmeterBarTests/PopoverLayoutTests.swift | 2 +- .../Tests/TokmeterBarTests/StartupTests.swift | 47 +++ .../TokmeterBarTests/ThemeContrastTests.swift | 27 +- .../TokmeterBarTests/WebDashboardTests.swift | 106 +++++++ packages/macos-bar/bundle.sh | 11 +- packages/mcp/README.md | 6 + packages/mcp/SKILL.md | 4 + packages/mcp/package.json | 2 +- packages/mcp/src/daemon/identity.test.ts | 95 ++++++ packages/mcp/src/daemon/identity.ts | 137 ++++++++ packages/mcp/src/daemon/protocol.ts | 1 + .../src/daemon/refresh-coordinator.test.ts | 67 ++++ .../mcp/src/daemon/refresh-coordinator.ts | 35 ++ .../mcp/src/daemon/server-lifecycle.test.ts | 112 +++++++ packages/mcp/src/daemon/server.ts | 298 ++++++------------ packages/tokmeter/README.md | 24 +- packages/tokmeter/package.json | 2 +- packages/tui/README.md | 9 +- packages/tui/SKILL.md | 3 +- packages/tui/package.json | 2 +- packages/web/README.md | 15 +- packages/web/SKILL.md | 9 +- packages/web/package.json | 2 +- packages/web/scripts/dashboard-server.mjs | 116 +++++++ packages/web/src/main.tsx | 3 + .../web/src/server/dashboard-server.test.ts | 159 ++++++++++ packages/web/vite.config.ts | 1 + scripts/bump-version.sh | 11 +- scripts/prepare-license-materials.py | 5 +- scripts/prepare-packages.sh | 2 + 92 files changed, 3024 insertions(+), 901 deletions(-) create mode 100644 docs/macos/themes.md create mode 100644 docs/macos/web-dashboard.md create mode 100644 docs/reviews/2026-09-08.md create mode 100644 packages/cli/src/query-filters.test.ts create mode 100644 packages/core/src/summary-query.test.ts create mode 100644 packages/core/src/summary-query.ts create mode 100644 packages/macos-bar/Sources/TokmeterBar/BalancedGrid.swift create mode 100644 packages/macos-bar/Sources/TokmeterBar/DailyUsageTooltip.swift create mode 100644 packages/macos-bar/Sources/TokmeterBar/PrismSurface.swift create mode 100644 packages/macos-bar/Sources/TokmeterBar/ShellArgument.swift create mode 100644 packages/macos-bar/Sources/TokmeterBar/WebDashboardController.swift create mode 100644 packages/macos-bar/Tests/TokmeterBarTests/DaemonClientURLTests.swift create mode 100644 packages/macos-bar/Tests/TokmeterBarTests/HeatmapDailyValuesTests.swift create mode 100644 packages/macos-bar/Tests/TokmeterBarTests/HubActionsTests.swift create mode 100644 packages/macos-bar/Tests/TokmeterBarTests/HubResponsiveLayoutTests.swift create mode 100644 packages/macos-bar/Tests/TokmeterBarTests/WebDashboardTests.swift create mode 100644 packages/mcp/src/daemon/identity.test.ts create mode 100644 packages/mcp/src/daemon/identity.ts create mode 100644 packages/mcp/src/daemon/refresh-coordinator.test.ts create mode 100644 packages/mcp/src/daemon/refresh-coordinator.ts create mode 100644 packages/mcp/src/daemon/server-lifecycle.test.ts create mode 100644 packages/web/scripts/dashboard-server.mjs create mode 100644 packages/web/src/server/dashboard-server.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2aca68b..e79b1dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,17 @@ jobs: - name: Test run: bun run test + + native-build-and-test: + runs-on: macos-15 + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Build, test, and render native fixtures + env: + TOKMETER_UI_QA_DIR: ${{ runner.temp }}/tokmeter-native-qa + run: | + mkdir -p "$TOKMETER_UI_QA_DIR" + swift test --package-path packages/macos-bar diff --git a/CHANGELOG.md b/CHANGELOG.md index c444b0d..7d87315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),\ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.11.0] - 2026-09-08 + +### Changed + +- Give today's tokens and estimated API cost equal prominence in the macOS popup, with exact daily token/cost hover details. +- Add Prism, Lagoon, and Carbon styles while preserving existing saved theme identifiers; share theme renderers across popup and Hub and document their modules. +- Refine responsive Hub layouts, heatmap keyboard navigation, chart details, and readable status/cost colors. +- Align README and skill guidance with current query, provider, pricing, and web behavior; add native build, test, and fixture rendering to CI. + +### Fixed + +- Start the bundled web dashboard on demand from macOS Settings, verify readiness before opening it, and provide stop/cancel and app-quit cleanup; exclude private build-machine usage exports. +- Intersect date, project, and provider filters over saved daily buckets in CLI/API summaries, including sealed-only history. +- Preserve encoded project names and query strings in native daemon requests; continue Node discovery past unsupported, broken, or hung candidates. +- Quote copied Hub project commands as literal shell arguments, correct Drishti command examples, and report settings save failures without discarding the previous configuration. +- Verify daemon process identity before signalling, publish credentials only after listener ownership, and coalesce concurrent forced rescans. + ## [1.10.0] — 2026-09-06 ### Changed diff --git a/README.md b/README.md index 8a817dc..8fac862 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ Release builds target Apple silicon and macOS 14+. Node.js 18+ with npx is also 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. -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. +The popup gives today's tokens and estimated API cost equal prominence, with models and projects below. Chart hover cards show exact daily tokens and cost. **Usage details** expands lifetime totals, trends, and signals; the view scrolls when it exceeds the available height. Six themes are selectable: Terminal, Paper, Prism, Lagoon, Carbon, and Glass. Glass uses native light frost and explicit theme-based text/status colors. The Hub provides larger breakdowns and settings. Settings → **Open web dashboard** starts its local server on demand; **Stop web dashboard** or quitting the app stops it. 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. diff --git a/SKILL.md b/SKILL.md index 81ea788..b22e7e1 100644 --- a/SKILL.md +++ b/SKILL.md @@ -57,14 +57,16 @@ const pricing = await lookupTokmeterPricing("claude-sonnet-4-20250514"); import { TokmeterCore } from "@sriinnu/tokmeter"; const core = new TokmeterCore(); -await core.scan({ providers: ["codex", "claude-code"], since: "2026-04-01" }); -const summary = core.getSummary(); +await core.scan(); +const summary = core.getSummary({ providers: ["codex", "claude-code"], since: "2026-04-01" }); ``` ## Integration notes - Tokmeter reads local session files; there is no hosted backend requirement. -- `TokmeterSummary` is the best high-level contract for downstream apps and dashboards. +- `TokmeterSummary` is the high-level contract for downstream apps and dashboards. +- Use `getSummary(options)` to scope aggregate reports; `scan(options)` alone does not filter subsequent no-argument getters. Reports use inclusive local calendar days (`week`: today plus six days); timestamps are rejected. +- Summary `records` is recent raw evidence, not a complete historical ledger. - `light` / `--light` skips pricing lookups when token counts are enough. - `@sriinnu/drishti` is the preferred live surface for other AI assistants. diff --git a/biome.json b/biome.json index 51c65a8..96f293d 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": true, - "include": ["*.ts", "*.tsx", "*.js", "*.jsx", "*.json"], + "include": ["*.ts", "*.tsx", "*.js", "*.mjs", "*.jsx", "*.json"], "ignore": ["**/dist/**", "**/node_modules/**", "**/*.d.ts", "**/*.min.js", "**/*.map"], "maxSize": 10485760 }, diff --git a/bun.lock b/bun.lock index 5e1b03d..769bebf 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ }, "packages/cli": { "name": "@sriinnu/tokmeter-cli", - "version": "1.8.0", + "version": "1.11.0", "bin": { "tokmeter": "dist/cli.js", }, @@ -28,7 +28,7 @@ }, "packages/core": { "name": "@sriinnu/tokmeter-core", - "version": "1.8.0", + "version": "1.11.0", "dependencies": { "@sriinnu/kosha-discovery": "^1.2.0", }, @@ -40,7 +40,7 @@ }, "packages/mcp": { "name": "@sriinnu/drishti", - "version": "1.8.0", + "version": "1.11.0", "bin": { "drishti": "dist/cli.js", }, @@ -62,7 +62,7 @@ }, "packages/tokmeter": { "name": "@sriinnu/tokmeter", - "version": "1.8.0", + "version": "1.11.0", "bin": { "tokmeter": "dist/cli/cli.js", "tokmeter-tui": "dist/tui/index.js", @@ -82,7 +82,7 @@ }, "packages/tui": { "name": "@sriinnu/tokmeter-tui", - "version": "1.8.0", + "version": "1.11.0", "bin": { "tokmeter-tui": "dist/index.js", }, @@ -99,7 +99,7 @@ }, "packages/web": { "name": "@sriinnu/tokmeter-web", - "version": "1.8.0", + "version": "1.11.0", "dependencies": { "plotly.js": "^2.35.0", "react": "^18.3.0", diff --git a/docs/consuming-tokmeter.md b/docs/consuming-tokmeter.md index fa15af5..3e29680 100644 --- a/docs/consuming-tokmeter.md +++ b/docs/consuming-tokmeter.md @@ -80,11 +80,11 @@ Use these wrappers when you want the convenience of the CLI package but not the import { TokmeterCore } from "@sriinnu/tokmeter"; const core = new TokmeterCore(); -await core.scan({ since: "2026-04-01", providers: ["codex", "claude-code"] }); +await core.scan(); -const summary = core.getSummary(); -const projects = core.getAllProjects(); -const stats = core.getStats(); +const summary = core.getSummary({ since: "2026-04-01", providers: ["codex", "claude-code"] }); +const projects = summary.projects; +const stats = summary.stats; ``` Use core directly when you need: @@ -94,6 +94,16 @@ Use core directly when you need: - low-level filtering/aggregation helpers - control over caching and pricing lifecycle +## Report filters and retained history + +CLI reports, the convenience helpers, and `core.getSummary(options)` filter saved daily aggregates together with today's live aggregate. Calling `scan(options)` alone does not scope later no-argument getters. Those getters keep their all-time view. + +Report dates use the machine's local calendar: `week` means today and the previous six days, `month` means the current month through today, and `year` means the selected calendar year. `since` and `until` accept inclusive `YYYY-MM-DD` bounds. Intraday timestamps are rejected because saved daily history cannot reconstruct partial days. Report `--older-than` selects complete days before the cutoff date; destructive cleanup retains its timestamp cutoff. The lower-level raw `scan()` API retains its separate timestamp filtering. + +Project filters match raw names or alias display names; provider and date filters intersect with that selection. Hidden projects stay out of project lists but remain in totals. `records` contains only the available recent raw records and their original provenance; it is not a reconstruction of all historical records contributing to the totals. + +Scan metadata describes the full refresh. Narrowed reports omit rolling live signals, whose time windows differ from the report. With project/provider filtering, first/last timestamps retain the original project-day bounds; per-provider intraday boundaries are not available in the saved cross-cut buckets. Costs retain their saved values and may lack provenance for a retrospective estimate/report split. + ## MCP / live integrations Use `@sriinnu/drishti` when an AI assistant should answer token/cost questions during a session. diff --git a/docs/macos-completion.md b/docs/macos-completion.md index b08a904..01085b4 100644 --- a/docs/macos-completion.md +++ b/docs/macos-completion.md @@ -19,17 +19,21 @@ Owner: Srinivas + Codex. Started 2026-09-06 after the 1.10.0 release. Workflow: ## 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. +- Source checkpoint: first-run and popover fixes merged to main in PR #75 (`e2aee8c`). The fresh-machine and observed acceptance gates below remain open. +- MAC-01 finding in published 1.10.0: 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 in published 1.10.0: 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 in published 1.10.0: 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. +- First-use fixes merged in PR #75: 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. +- Historical local-install checkpoint before the PR #75 merge: 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. +- Review follow-up candidate: 1.10.0 (46.10), installed on 2026-09-08 from `fix/review-terminal-and-cli`. Three-agent [review and remaining findings](reviews/2026-09-08.md) records responsive Terminal/Glass layouts, explicit trend/date semantics, keyboard daily values, URL/Node startup fixes, and source CLI filter corrections. Local gates: 390 JavaScript tests passed before the native-only follow-up; the latest native run passed 35 tests with one timing failure that passed on focused rerun. Build 46.8 adds prominent estimated cost and chart hover values. Build 46.9 replaces Nocturne/Aurora with Carbon/Lagoon. Build 46.10 replaces Nebula with Prism using shared popup/Hub renderers and adds a [theme development guide](macos/themes.md); seven focused native layout/render/contrast tests pass. Live candidate acceptance remains open. This follow-up branch has not merged or published a new npm release. + +- 1.11.0 (48) release candidate: on-demand dashboard startup/stop, literal Hub copy commands, settings-save errors, daemon identity/ownership and rescan coalescing are fixed in source. Final local checks: 404 JavaScript tests and 41 native tests passed, with 11 existing JavaScript todos and one optional native walkthrough skipped. All five bundled web views loaded in Chrome with live data. See the [release review](reviews/2026-09-08.md). Publication and installation remain pending at this checkpoint; none of the six acceptance rows is closed by these checks. + ## 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. diff --git a/docs/macos/themes.md b/docs/macos/themes.md new file mode 100644 index 0000000..615b576 --- /dev/null +++ b/docs/macos/themes.md @@ -0,0 +1,62 @@ +# Native theme development + +The popup and Hub share `AppTheme`, palettes, typography, and surface renderers. Themes change presentation; telemetry, cost provenance, refresh, and navigation remain in their existing models and views. + +## Files and ownership + +Paths below are relative to `packages/macos-bar/Sources/TokmeterBar/`. + +| File | Responsibility | +| --- | --- | +| `Theme.swift` | Persisted identity, picker names/order, mode selection, typography, semantic status and monetary ink | +| `ThemePalettes.swift` | Six palette roles for each theme | +| `Theme+Modes.swift` | Background, header, card, and font descriptors | +| `HeroBackground.swift` | Dispatch from a header mode to its renderer | +| `CardBackground.swift` | Popup card dispatch | +| `HubCard.swift` | Hub content spacing and shared panel dispatch | +| `PrismSurface.swift` | Stateless `PrismPanel` and `PrismHeroBackdrop`; the panel is reused by popup and Hub | +| `FrostedGlass.swift` | Glass material, opacity, and accessibility fallbacks | + +`PrismPanel` accepts palette colors and a corner radius. `PrismHeroBackdrop` accepts palette colors and draws facets relative to the available size. Neither reads settings, loads telemetry, starts timers, or owns interaction state. + +Use the existing wrappers when adding a surface. Do not copy a complete card or dashboard to introduce a theme. Keep a distinctive surface renderer in its own file when it is shared or would enlarge a dispatch view substantially. + +## Saved identifiers + +The picker has six entries. Three styles were replaced while retaining their stored identifiers: + +| Display name | Stored value | +| --- | --- | +| Terminal | `terminal` | +| Paper | `paper` | +| Prism | `nebula` | +| Lagoon | `aurora` | +| Carbon | `nocturne` | +| Glass | `glass` | + +The stored value is the enum raw value used by `@AppStorage("appTheme")`. Legacy mode names also remain in source. Renaming a display label does not require changing stored values. Hidden enum cases remain decodable but are omitted from the explicitly curated `AppTheme.allCases` picker list. + +## Add or revise a theme + +1. Choose whether this is a new persisted theme or a replacement for an existing style. For a new theme, add a unique enum case and deliberately add it to the picker list; for a replacement, retain its raw value. +2. Define its six palette roles, display label, icon, typography, and background/header/card modes. Reuse an existing mode when its rendering is sufficient. +3. Put custom drawing in a small view with explicit inputs. Connect it through the popup and Hub wrappers. Avoid theme-specific copies of data views. +4. Set text and monetary/status ink for the selected theme's surface, independently of the host macOS appearance. Use named semantic status colors for warnings, success, and danger. Check long values, unavailable values, and tooltip legibility. +5. Update the theme table and macOS README. State whether the result is source-only, locally installed, or released. + +## Verify + +From the repository root, render synthetic fixtures and run the focused native checks: + +```sh +mkdir -p /tmp/tokmeter-theme-review +TOKMETER_UI_QA_DIR=/tmp/tokmeter-theme-review \ +swift test --package-path packages/macos-bar \ + --filter 'DemoRenderTests/testRenderThemeReview|HubResponsiveLayoutTests|ThemeContrastTests|PopoverLayoutTests' +``` + +The demo renderer uses the picker list. The production Hub/layout/contrast tests have explicit theme lists; add a genuinely new theme there too. Current Hub captures cover widths of 860, 1100, and 1500 points. File names use stored identifiers, so Prism images have the `nebula` prefix. + +Inspect the generated popup, expanded details, tooltip, and Hub images. Check header and value clipping, chart contrast, panel edges, and large-window spacing. Contrast tests compare semantic ink and native widget pixels; their reference swatches use the same AppKit capture path as the widgets to avoid cross-profile comparisons. + +These checks use fixtures. They do not establish live pointer/VoiceOver acceptance, sustained performance, or release readiness. Keep those results separate in the [completion tracker](../macos-completion.md). diff --git a/docs/macos/web-dashboard.md b/docs/macos/web-dashboard.md new file mode 100644 index 0000000..c216350 --- /dev/null +++ b/docs/macos/web-dashboard.md @@ -0,0 +1,25 @@ +# On-demand web dashboard + +Settings → **Open web dashboard** starts a child server owned by TokmeterBar, waits for that child's readiness response, then opens `http://127.0.0.1:3000/`. Reopening reuses the child. **Stop web dashboard** closes it; **Cancel dashboard startup** cancels an in-progress launch. Quitting the app closes the child too. Closing a browser tab alone does not stop it. + +The dashboard needs Node.js 18+ and the usage daemon used by the menubar. Stopping the dashboard leaves the daemon and menubar updates running. Missing assets, an unavailable Node runtime, and startup/port failures appear in Settings. If another program owns port 3000, Tokmeter refuses to open or stop that program. + +## Module ownership + +| Module | Responsibility | +| --- | --- | +| `WebDashboardController.swift` | Observable native lifecycle, supported Node discovery, owned child/stdin, nonce readiness, browser opening, stop/cancel and app-quit cleanup | +| `SettingsPopover.swift` | Start/open, stop/cancel and error controls | +| `packages/web/scripts/dashboard-server.mjs` | Node standard-library HTTP server, local read-only routing, summary proxy, asset bounds and stdin/signal shutdown | +| `packages/web/vite.config.ts` | App-build mode disables public-file copying | +| `packages/macos-bar/bundle.sh` | Builds and bundles only dashboard HTML, hashed assets, and the server script before signing | + +The optional server binds IPv4 loopback, accepts only its own localhost Host/Origin, and exposes GET requests. `/api/summary` forwards to the existing daemon on port 9877 with a timeout and bounded response. SPA routes render the app shell; realpath checks keep asset reads within the bundled dashboard directory. It does not expose daemon mutations or serve `data.json`. These are application checks, not OS sandbox containment. + +The build deliberately excludes the developer's `public/data.json`. A failed live summary displays an error instead of falling back to somebody else's packaged usage history. Development/preview and intentional static exports retain the separate behavior described in the [web README](../../packages/web/README.md). + +## Verify a change + +Run `bunx vitest run packages/web/src/server/dashboard-server.test.ts` and `swift test --package-path packages/macos-bar --filter WebDashboardTests`. Tests use temporary assets, an ephemeral fixture upstream, and actual child/listener lifecycle checks; native tests open through an injected browser callback. They cover route reloads, summary provenance/failure, foreign requests, path escape/private-export refusal, port contention, EOF stop/restart, and cancellation. + +Build the app, inspect `Contents/Resources/Dashboard` for only the intended code assets, then open its server in a real browser. Check the overview, projects, models, timeline and 3D views; reload a subpage and confirm current data, stop/restart, and app-quit behavior. Browser rendering and native Settings clicks are distinct from the automated callback tests. diff --git a/docs/reviews/2026-09-08.md b/docs/reviews/2026-09-08.md new file mode 100644 index 0000000..6273786 --- /dev/null +++ b/docs/reviews/2026-09-08.md @@ -0,0 +1,101 @@ +# Design, architecture, and documentation review — 2026-09-08 + +Three independent reviewers examined main `e2aee8c`: design/usability, architecture/reliability, and README/docs. The design review also used the user's Terminal popup and Hub screenshots. Follow-up implementation is on `fix/review-terminal-and-cli`. + +## Assessment + +Terminal's green monospace palette is coherent. The main problems were information loss at ordinary window sizes and misleading data scopes, rather than a need to redesign the theme. The core/daemon split is useful, but process ownership is not yet reliable enough to call the daemon unattended-operation ready. Documentation is more useful after the developer-focused rewrite, but source verification exposed remaining factual errors. + +## Findings addressed in this branch + +| Finding | Change and evidence | +| --- | --- | +| Hub KPI labels and pulse captions truncate | Width-driven grids reflow at fixed thresholds. KPI values get the full card width; captions wrap. Production Hub fixtures inspected at 860, 1100, and 1500 points in Terminal and Glass. | +| Light Glass sidebar has dark text over an unpainted surface | One root material now covers sidebar and detail. Native pixel checks require a light sidebar backing. | +| Green cost growth implies success; comparison dates are absent | Rising cost uses amber; token trends are neutral. Tooltip/accessibility text names both completed recorded dates and distinguishes the lifetime card value. | +| Burn-rate colors differ between surfaces and flame animation dims the glyph | Popup, Hub, and sidebar share semantic thresholds; the small flame stays at full contrast. | +| Seven/30-day charts actually select recorded rows | Labels now state recorded days. Missing calendar dates are not invented as zeroes. The fabricated fallback streak sparkline was removed. | +| Heatmap day details are pointer-only | Native Daily values disclosure/table adds keyboard row navigation and exact date/cost/token values. Fixture key events pass; live VoiceOver remains open. | +| Native requests encode query syntax and project names incorrectly | URL resolution preserves queries and one path-component encoding. Three pure URL regressions pass. | +| Old or broken Node shadows a valid managed installation | Bounded probes continue through candidates; synthetic old, broken, hung, supported, and timeout cases pass. | +| CLI/API filters return broader totals than requested | Scoped summaries intersect calendar dates, projects, and providers over saved daily buckets. Sealed-only data no longer produces false empty output. No-argument all-time getters and persistence are unchanged. | +| Docs describe old pricing, provider support, web data sources, and merge state | Core/API, web, package, TUI, skill, and completion references corrected against source. | + +Aggregate reports use inclusive local calendar dates; week means today plus six preceding days. They cannot reconstruct intraday history from sealed daily buckets. Returned raw records remain partial recent evidence; scan metadata describes the full refresh. Scoped live signals are omitted rather than presented as if they matched the report filter. + +## Daemon findings and release follow-up + +| Priority | Finding | Evidence and next step | +| --- | --- | --- | +| P1 | Stale/recycled PID | Added start-time and command-hash identity checks; malformed or mismatched identity fails closed. Legacy fallback verifies the installed CLI entrypoint. Synthetic identity tests pass. | +| P1 | Competing starts overwrite credentials | Credential publication follows listener acquisition. A real ephemeral WebSocket contention test confirms the losing server leaves the winner's token and ownership files unchanged. | +| P2 | Repeated concurrent full scans | A shared refresh coordinator serializes incremental scans and coalesces full requests; call-count and failure/recovery tests pass. | +| P2 | Missing native CI | Added a macOS Swift build/test/render job. Remote results are recorded with the release verification below. | + +Process inspection and signalling remain separate OS operations; Windows identity inspection has not been runtime-tested. Subprocess timeout terminates the immediate child, not a guaranteed process group. HTTP size checks happen after response buffering. These are containment limits, not a sandbox. + +The final Hub audit also fixed literal shell quoting in copied project commands, executable names in the command catalog, and surfaced atomic settings-save failures. Tests execute the copied commands against a harmless shell function and exercise temporary config persistence and recovery. + +## Validation + +- Workspace build and lint pass; 390 JavaScript tests pass, 11 existing todo cases remain. +- 36 native tests pass; one optional walkthrough render is skipped. Native captures include minimum-width Terminal/Glass Hub layouts and opposing-host status colors. +- Candidate npm entrypoints `tokmeter --help` and `drishti --help` pass in an installation outside the workspace. +- Eight actual Node CLI subprocess query cases pass with an explicit fixture loader. TUI PTY launch, navigation through all five views, and quit pass; terminal modes are restored. These fixtures replace scan I/O; they do not establish sandbox containment or independently reconcile real user history. +- Published npm 1.10.0 is unchanged. Query fixes are source/candidate changes until a release is published. + +Fresh-machine use, sustained reliability, real Sparkle upgrade, independent accounting, live keyboard/VoiceOver traversal, and the five-person trial remain in [the completion tracker](../macos-completion.md). + +## Local installed candidate + +Installed 1.10.0 (46.7) from this branch on 2026-09-08. Executable SHA-256: `b29030a045a0f6fbeb164db397046c6379e37acdd37b7510e10903457a37c805`. Source/installed binary identity, deep/strict ad-hoc signature, and running installed process verified. Rendered fixture inspection is distinct from live acceptance of this candidate. + +## Cost, chart details, and theme follow-up + +- Popup: tokens today and estimated API cost today have equal headline size; cost uses opaque gold on dark themes and dark amber on light themes. Tool reports stay separate. An unavailable estimate displays an em dash, not a fabricated zero. The Hub sidebar also shows today's tokens and estimate. +- Usage details: weekly-chart and KPI-sparkline hover cards show the recorded date, exact tokens, and daily cost. Model-bar help includes token and cost totals. Hub activity tooltips add exact tokens; heatmap help no longer labels token-bearing zero-cost days as no activity. Aggregated daily costs may combine estimates and tool reports. +- Kept Nocturne, Aurora, and Nebula. Nocturne uses brighter lavender data ink; Nebula has a darker plum header and a fixed dark surface; Aurora has lower glow intensity and stable dark cards. No theme identifiers or stored preferences were removed. +- Rendered popup states, tooltips, and production Hub layouts at 860, 1100, and 1500 points. Monetary/status contrast checks pass. Native widget pixel comparisons use reference swatches through the same AppKit capture path to avoid mixing native and ImageRenderer color profiles. +- The full native run passed 35 tests, skipped the optional walkthrough, and hit one timing-sensitive Node-probe failure; that unchanged test passed on its focused rerun. Live hover/VoiceOver acceptance remains separate from fixture rendering. + +Installed follow-up: 1.10.0 (46.8), verified running from `/Applications/TokmeterBar.app`. Source/installed executable SHA-256: `d4b7a80cabe83ba0176de006a59d5b7dd827dcbe12f7db6758990017e47d1340`; deep/strict ad-hoc signature passed. Native desktop control was unavailable for a live pointer walkthrough. No npm release or branch publication was performed. + +## Replacement themes after visual feedback + +The user accepted Nebula and rejected the revised Nocturne/Aurora. Carbon now replaces Nocturne with neutral graphite, copper figures, monospaced values, and square-edged panels. Lagoon replaces Aurora with deep teal, mint data, peach cost figures, and opaque rounded panels. Both have static headers without the previous stars or drifting glow. Stored `nocturne`/`aurora` identifiers remain compatible; settings display Carbon/Lagoon. + +Seven focused native render/layout/contrast tests passed, including popup states and Hub widths of 860, 1100, and 1500 points. The Nebula popup fixture PNG is byte-identical to the accepted 46.8 render. These are fixture checks, not live acceptance of the replacements. + +Installed replacement candidate: 1.10.0 (46.9), running from `/Applications/TokmeterBar.app`. Source/installed executable SHA-256: `43bbd43010e3f47f58ac459d0a789e273ad41145d971f99ecc893f9c5a6d1c04`; deep/strict ad-hoc signature passed. Local branch remains uncommitted and unpublished. + +## Prism replacement and theme module documentation + +At the user's request, Prism replaces the previously accepted Nebula. The style uses a faceted dark header, violet/cyan panel edges, and gold cost figures. `PrismSurface.swift` contains the stateless header and panel renderers; the panel is shared by popup cards and the Hub. Palettes, typography, and mode dispatch stay in their existing modules. The stored `nebula` identifier remains compatible. + +The [theme development guide](../macos/themes.md) documents file ownership, renderer inputs, adding/replacing styles, saved identifiers, and validation. Seven focused native render/layout/contrast tests passed. Popup details/tooltips and Hub widths of 860, 1100, and 1500 points were rendered; narrow and wide Hub captures were visually inspected. README/guide links and whitespace checks passed. Live acceptance remains open. + +Installed candidate: 1.10.0 (46.10), running from `/Applications/TokmeterBar.app`. Source/installed executable SHA-256: `f050a0b569bff3be687f766a9eb0474fadae3a22a068934a928d74407726bb8e`; deep/strict ad-hoc signature passed. The bundled source archive contains the exact reviewed Prism renderer. Changes remain local and uncommitted. + +## Pause checkpoint — 1.11.0 work in progress + +The user requested a pause before publication. Version sources now target **1.11.0 (48)**. Work remains local on `fix/review-terminal-and-cli`, with mixed staged/unstaged changes and no new commit, PR, tag, or publication. The installed app remains the earlier **1.10.0 (46.10)** candidate. + +The final daemon suite passed **401 JavaScript tests**, with 11 existing todo cases. The workspace build passed. These checks precede the dashboard changes below. A native run compiled the new controller and began tests, then was interrupted for the requested pause; it is not a full passing run. + +Live checks confirmed `http://localhost:3000/` refused connections while the daemon readiness endpoint on port 9877 returned HTTP 200. The old Settings action only opened a hard-coded URL and did not start a server. + +Unverified dashboard implementation is now in the working tree: an app-owned Node child starts on demand, serves bundled code-only web assets on loopback port 3000, proxies only the read-only summary endpoint, and stops through Settings or parent shutdown. Startup checks a per-child nonce before opening the browser. Asset packaging excludes the build machine's `public/data.json`. New modules are `WebDashboardController.swift` and `packages/web/scripts/dashboard-server.mjs`. + +Resume with dashboard lifecycle/security regression tests and a real browser walkthrough (open, current data, stop, restart, occupied port, app quit); inspect the packaged assets for private exports. Finish README/SKILL guidance and dashboard module documentation, then rerun build/lint/JavaScript/native/secrets and candidate package checks. Only after those gates, complete the signed branch → PR → both CI jobs → main → signed tag → npm/macOS/Sparkle/Homebrew release workflow in `packages/macos-bar/RELEASE.md`. The prior hardware-key signing attempt failed and may require physical key interaction. Long-term macOS acceptance gates remain open. + +## Resumed 1.11.0 release validation + +The dashboard fix is now verified through both the actual native child controller and a real isolated Chrome session. All five routes render, project-page reload works, live summaries return successfully, and charts including the 3D surface render without JavaScript errors. Browser inspection found and fixed page-width overflow; all routes fit the tested 1440-point viewport. The bundled dashboard contains only HTML, hashed assets and its server script, with no private usage export. The [dashboard module guide](../macos/web-dashboard.md) documents ownership and lifecycle. + +- JavaScript: **404 passed**, 11 existing todo cases. Native: **41 passed**, one optional walkthrough skipped (42 discovered). Workspace build and lint pass. +- Native controller tests use a real owned Node child and an injected browser callback to verify start, reopen, occupied-port refusal, stop/restart, and cancellation. Direct Settings clicks and VoiceOver are still separate live acceptance checks. +- Both candidate npm packages install together outside the workspace. Both CLI help entrypoints pass; eight real Node CLI subprocesses preserve synthetic scoped totals; TUI navigation and quit restore terminal modes. +- Packaging exposed stale Bun workspace version metadata: Drishti's generated dependency was 1.8.0. The bump script now updates workspace metadata, and package preparation rejects a Drishti/Tokmeter version mismatch. The corrected pair is 1.11.0; an isolated bump fixture preserves external dependency lock entries. +- README/SKILL and native guide local links pass. Private workspace notes remain excluded from staging and distribution source archives. + +These are pre-publication results. The source PR/CI, signed tag, registry publication, notarization, update feed, Homebrew update and installed release identity are recorded separately when completed. diff --git a/packages/cli/README.md b/packages/cli/README.md index 991d81f..2da1959 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -77,11 +77,13 @@ only proposes for unaliased keys and **never overwrites** a user-flagged entry. tokmeter --project my-app # specific project tokmeter --claude --opencode # specific providers tokmeter --today # today only -tokmeter --week # last 7 days +tokmeter --week # today + previous 6 local calendar days tokmeter --month # current month tokmeter --since 2025-01-01 --until 2025-12-31 ``` +Date bounds are inclusive local `YYYY-MM-DD` dates. Reports use saved daily aggregates, so intraday timestamps are rejected. Project/provider/date filters intersect; sealed history remains queryable even when raw session files are gone. + ## Output ```bash diff --git a/packages/cli/package.json b/packages/cli/package.json index cd3d861..81a6598 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@sriinnu/tokmeter-cli", - "version": "1.10.0", + "version": "1.11.0", "private": true, "description": "Token usage tracking CLI and automation helpers", "type": "module", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 4983902..1868c1c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -14,7 +14,7 @@ process.on("uncaughtException", (error) => { process.exit(1); }); -import { TokmeterCore } from "@sriinnu/tokmeter"; +import { TokmeterCore, localDateKey } from "@sriinnu/tokmeter"; import type { ModelSummary, ProjectSummary, ProviderId, ScanOptions } from "@sriinnu/tokmeter"; import Table from "cli-table3"; import { DAEMON_READ_ENDPOINTS, daemonReadEligible } from "./daemon-read.js"; @@ -271,12 +271,12 @@ Installer: Date Filters: --today Only today's usage - --week Last 7 days + --week Today and previous 6 local calendar days --month Current calendar month --year N Specific year - --since D From date (YYYY-MM-DD or ISO) - --until D To date (inclusive) - --older-than N Anything older than N (e.g. 30d, 2w, 1m) + --since D From local date (YYYY-MM-DD; inclusive) + --until D Through local date (YYYY-MM-DD; inclusive) + --older-than N Completed days before the cutoff date (e.g. 30d, 2w, 1m) Digest Options: --period P Period for digest: today, week (default), month @@ -1138,6 +1138,14 @@ async function main() { return; } + // Reports can only select whole saved days. Cleanup/restore handling above + // keeps its existing precise timestamp cutoff. + if (args.olderThan && args.until) { + const cutoff = new Date(args.until); + cutoff.setDate(cutoff.getDate() - 1); + args.until = localDateKey(cutoff.getTime()); + } + // Daemon-read fast path: for `--json` read commands, prefer the warm // singleton daemon over a fresh full-corpus scan. This is the fix for // external pollers that loop `tokmeter stats/daily --json --codex` — each @@ -1148,10 +1156,13 @@ async function main() { if (await tryServeFromDaemon(cmd, args)) return; } - // Scan session files - const records = await core.scan(args); + // Refresh shared state, then filter saved daily aggregates. A raw-record + // return can be empty while sealed history still contains valid usage. + core.getSummary(args); // Validate report bounds before scanning. + await core.scan({ today: args.today, rescanHistory: args.rescanHistory }); + const summary = core.getSummary(args); - if (records.length === 0) { + if (!args.json && summary.stats.totalRecords === 0) { console.log("No token usage data found."); console.log("\nMake sure you have session files from supported AI coding agents:"); console.log(" Claude Code: ~/.claude/projects/"); @@ -1159,7 +1170,7 @@ async function main() { console.log(" Codex CLI: ~/.codex/sessions/"); console.log(" Gemini CLI: ~/.gemini/tmp/"); console.log(" and more... Run `tokmeter --help` for all supported platforms."); - process.exit(0); + return; } // JSON output @@ -1167,19 +1178,19 @@ async function main() { const command = args.command || "overview"; switch (command) { case "models": - console.log(JSON.stringify(core.getModelCosts({ project: args.project }), null, 2)); + console.log(JSON.stringify(summary.models, null, 2)); break; case "daily": - console.log(JSON.stringify(core.getDailyBreakdown({ project: args.project }), null, 2)); + console.log(JSON.stringify(summary.daily, null, 2)); break; case "projects": - console.log(JSON.stringify(core.getAllProjects(), null, 2)); + console.log(JSON.stringify(summary.projects, null, 2)); break; case "stats": - console.log(JSON.stringify(core.getStats(), null, 2)); + console.log(JSON.stringify(summary.stats, null, 2)); break; default: - console.log(JSON.stringify(core.toJSON(), null, 2)); + console.log(JSON.stringify(summary, null, 2)); } return; } @@ -1188,21 +1199,21 @@ async function main() { const command = args.command || "overview"; switch (command) { case "models": - renderModelsTable(core.getModelCosts({ project: args.project })); + renderModelsTable(summary.models); break; case "daily": - renderDailyTable(core.getDailyBreakdown({ project: args.project })); + renderDailyTable(summary.daily); break; case "projects": - renderProjectsTable(core.getAllProjects()); + renderProjectsTable(summary.projects); break; case "stats": - renderStats(core.getStats()); + renderStats(summary.stats); break; default: { // Overview: projects + totals - const stats = core.getStats(); - renderProjectsTable(core.getAllProjects()); + const stats = summary.stats; + renderProjectsTable(summary.projects); console.log( `\nTotal: ${formatNumber(stats.totalTokens)} tokens | ${formatCost(stats.totalCost)} | ${stats.activeDays} active days` ); diff --git a/packages/cli/src/daemon-read.test.ts b/packages/cli/src/daemon-read.test.ts index 1f6edb2..52f0f4c 100644 --- a/packages/cli/src/daemon-read.test.ts +++ b/packages/cli/src/daemon-read.test.ts @@ -20,6 +20,7 @@ describe("daemonReadEligible — the 'silently wrong numbers' guard", () => { expect(daemonReadEligible("stats", { week: true })).toBe(false); expect(daemonReadEligible("stats", { month: true })).toBe(false); expect(daemonReadEligible("stats", { year: 2026 })).toBe(false); + expect(daemonReadEligible("stats", { year: 0 })).toBe(false); expect(daemonReadEligible("daily", { since: "2026-01-01" })).toBe(false); expect(daemonReadEligible("daily", { until: "2026-06-01" })).toBe(false); expect(daemonReadEligible("models", { project: "demo" })).toBe(false); diff --git a/packages/cli/src/daemon-read.ts b/packages/cli/src/daemon-read.ts index 1afefb6..8a2ee1b 100644 --- a/packages/cli/src/daemon-read.ts +++ b/packages/cli/src/daemon-read.ts @@ -48,7 +48,7 @@ export function daemonReadEligible(command: string, args: DaemonReadArgs): boole args.today || args.week || args.month || - args.year + args.year !== undefined ) { return false; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d583474..828b855 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -26,10 +26,11 @@ export interface TokmeterPricingLookup { pricing: TokmeterPricing; } -async function scanCore(options: TokmeterQueryOptions = {}): Promise { +async function scanSummary(options: TokmeterQueryOptions = {}): Promise { const core = new TokmeterCore({ skipPricing: options.light }); - await core.scan(options); - return core; + core.getSummary(options); // Validate calendar bounds before any scan I/O. + await core.scan({ today: options.today, rescanHistory: options.rescanHistory }); + return core.getSummary(options); } /** @@ -38,8 +39,7 @@ async function scanCore(options: TokmeterQueryOptions = {}): Promise { - const core = await scanCore(options); - return core.getSummary(); + return scanSummary(options); } /** @@ -48,8 +48,7 @@ export async function loadTokmeterSummary( export async function loadTokmeterProjects( options: TokmeterQueryOptions = {} ): Promise { - const core = await scanCore(options); - return core.getAllProjects(); + return (await scanSummary(options)).projects; } /** @@ -58,8 +57,7 @@ export async function loadTokmeterProjects( export async function loadTokmeterModels( options: TokmeterQueryOptions = {} ): Promise { - const core = await scanCore(options); - return core.getModelCosts({ project: options.project }); + return (await scanSummary(options)).models; } /** @@ -68,12 +66,7 @@ export async function loadTokmeterModels( export async function loadTokmeterDailyBreakdown( options: TokmeterQueryOptions = {} ): Promise { - const core = await scanCore(options); - return core.getDailyBreakdown({ - since: options.since, - until: options.until, - project: options.project, - }); + return (await scanSummary(options)).daily; } /** @@ -82,8 +75,7 @@ export async function loadTokmeterDailyBreakdown( export async function loadTokmeterStats( options: TokmeterQueryOptions = {} ): Promise { - const core = await scanCore(options); - return core.getStats(); + return (await scanSummary(options)).stats; } /** diff --git a/packages/cli/src/query-filters.test.ts b/packages/cli/src/query-filters.test.ts new file mode 100644 index 0000000..7579050 --- /dev/null +++ b/packages/cli/src/query-filters.test.ts @@ -0,0 +1,183 @@ +import type { ScanOptions, TokenRecord } from "@sriinnu/tokmeter"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const state = vi.hoisted(() => ({ scans: vi.fn(), logs: [] as string[] })); + +// Keep the real source query implementation; replace only scan input/state. +// No source/session paths, provider requests, or daemon reads are used. +vi.mock("@sriinnu/tokmeter", async () => { + const source = await import("../../" + "core/src/index.ts"); + const { aggregateRecordsByDay } = await import("../../" + "core/src/aggregates.ts"); + const records: TokenRecord[] = [ + { + timestamp: new Date(2026, 7, 1, 12).getTime(), + project: "old", + provider: "claude-code", + inputTokens: 100, + }, + { + timestamp: new Date(2026, 8, 7, 12).getTime(), + project: "app", + provider: "codex", + inputTokens: 20, + }, + { + timestamp: new Date(2026, 8, 7, 13).getTime(), + project: "other", + provider: "claude-code", + inputTokens: 40, + }, + ].map((record) => ({ + model: "fixture-model", + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + cost: 0, + ...record, + })) as TokenRecord[]; + return { + ...source, + TokmeterCore: class extends source.TokmeterCore { + constructor() { + super({ skipPricing: true }); + Object.assign(this, { + aliases: {}, + recentRecords: [], + aggregates: new Map( + aggregateRecordsByDay(records).map((day: { date: string }) => [day.date, day]) + ), + }); + } + async scan(options: ScanOptions) { + state.scans(options); + return []; + } + }, + }; +}); + +beforeEach(() => { + vi.useFakeTimers({ now: new Date(2026, 8, 8, 12) }); + state.scans.mockClear(); + state.logs.length = 0; +}); +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("convenience helpers", () => { + test("filters each projection while retaining sealed history", async () => { + const api = await import("./index.js"); + expect((await api.loadTokmeterSummary({ week: true, light: true })).stats.totalTokens).toBe(60); + expect( + (await api.loadTokmeterStats({ month: true, providers: ["codex"], light: true })).totalTokens + ).toBe(20); + expect( + (await api.loadTokmeterProjects({ project: "app", light: true })).map((p) => p.project) + ).toEqual(["app"]); + expect( + (await api.loadTokmeterModels({ providers: ["codex"], light: true })).map((m) => m.provider) + ).toEqual(["codex"]); + expect( + (await api.loadTokmeterDailyBreakdown({ week: true, project: "app", light: true })).map( + (d) => d.totalTokens + ) + ).toEqual([20]); + expect((await api.loadTokmeterStats({ light: true })).totalTokens).toBe(160); + expect( + state.scans.mock.calls.every( + ([options]) => !options.week && !options.since && !options.providers + ) + ).toBe(true); + }); + + test("rejects intraday report requests before scanning", async () => { + const api = await import("./index.js"); + await expect( + api.loadTokmeterSummary({ since: "2026-09-07T12:00:00Z", light: true }) + ).rejects.toThrow("YYYY-MM-DD"); + expect(state.scans).not.toHaveBeenCalled(); + }); +}); + +describe("actual CLI argument and JSON dispatch", () => { + test("rejects --year 0 before the daemon fast path or scanning", async () => { + vi.useRealTimers(); + vi.resetModules(); + const originalArgv = process.argv; + const rejectionHandlers = process.listeners("unhandledRejection"); + const exceptionHandlers = process.listeners("uncaughtException"); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const network = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network disabled")); + try { + process.argv = [process.execPath, "tokmeter", "stats", "--year", "0", "--json", "--light"]; + await import("./cli.js"); + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1)); + expect(error).toHaveBeenCalledWith("Error:", "year must be a four-digit calendar year."); + expect(network).not.toHaveBeenCalled(); + expect(state.scans).not.toHaveBeenCalled(); + } finally { + process.argv = originalArgv; + for (const handler of process.listeners("unhandledRejection")) + if (!rejectionHandlers.includes(handler)) + process.removeListener("unhandledRejection", handler); + for (const handler of process.listeners("uncaughtException")) + if (!exceptionHandlers.includes(handler)) + process.removeListener("uncaughtException", handler); + } + }); + + test.each([ + [["--week"], "summary", 60], + [["--project", "app", "--codex"], "summary", 20], + [["stats", "--month", "--codex"], "stats", 20], + [["daily", "--week", "--codex"], "array", 20], + [["models", "--week", "--codex"], "array", 20], + [["projects", "--week", "--project", "app"], "array", 20], + [["--project", "absent"], "summary", 0], + [["--older-than", "7d"], "summary", 100], + [[], "summary", 160], + ] as const)("%j preserves sealed-only JSON totals", async (flags, shape, expected) => { + vi.useRealTimers(); + vi.spyOn(Date, "now").mockReturnValue(new Date(2026, 8, 8, 12).getTime()); + vi.resetModules(); + const originalArgv = process.argv; + const rejectionHandlers = process.listeners("unhandledRejection"); + const exceptionHandlers = process.listeners("uncaughtException"); + const log = vi + .spyOn(console, "log") + .mockImplementation((value) => state.logs.push(String(value))); + const exit = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("Unexpected process.exit"); + }); + const network = vi + .spyOn(globalThis, "fetch") + .mockRejectedValue(new Error("Network disabled in fixture")); + try { + process.argv = [process.execPath, "tokmeter", ...flags, "--json", "--light"]; + await import("./cli.js"); + await vi.waitFor(() => expect(log).toHaveBeenCalled(), { timeout: 1000 }); + const value = JSON.parse(state.logs[0]); + const actual = + shape === "summary" + ? value.stats.totalTokens + : shape === "stats" + ? value.totalTokens + : value.reduce((sum: number, row: { totalTokens: number }) => sum + row.totalTokens, 0); + expect(actual).toBe(expected); + expect(exit).not.toHaveBeenCalled(); + expect(network).not.toHaveBeenCalled(); + } finally { + process.argv = originalArgv; + for (const handler of process.listeners("unhandledRejection")) + if (!rejectionHandlers.includes(handler)) + process.removeListener("unhandledRejection", handler); + for (const handler of process.listeners("uncaughtException")) + if (!exceptionHandlers.includes(handler)) + process.removeListener("uncaughtException", handler); + } + }); +}); diff --git a/packages/core/README.md b/packages/core/README.md index 64520ca..0033a3c 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -16,7 +16,7 @@ npm install @sriinnu/tokmeter import { TokmeterCore, sumUsage } from "@sriinnu/tokmeter"; const core = new TokmeterCore(); -const records = await core.scan(); +const records = await core.scan(); // today on a new instance; recent records on a warm instance // Per-project breakdown const projects = core.getAllProjects(); @@ -28,26 +28,31 @@ const models = core.getModelCosts({ project: "my-app" }); // Daily trend const daily = core.getDailyBreakdown({ since: "2025-01-01" }); -// Overall stats +// Lifetime stats, including sealed historical days const stats = core.getStats(); console.log(`$${stats.totalCost.toFixed(2)} across ${stats.projects} projects`); -// Derived usage math works across every parser's canonical buckets. +// Cache hit rate for the returned recent records, not lifetime history. const usage = sumUsage(records); console.log(`Cache hit: ${(usage.cacheHitRate * 100).toFixed(1)}%`); ``` +For a scoped report, call `core.getSummary({ week: true, project: "my-app", providers: ["codex"] })` after scanning. No-argument getters retain their all-time view; filters passed only to `scan()` do not scope them. Reports use inclusive local calendar dates; `week` means today plus the previous six days. See [report filters and retained history](../../docs/consuming-tokmeter.md#report-filters-and-retained-history) for record, timestamp, and provenance limits. + ## Supported Providers Claude Code, OpenCode, Codex CLI, Gemini CLI, Cursor, Amp, Droid, OpenClaw, Pi, Kimi, Qwen, Roo Code, Kilo, Kilo CLI, Mux, Synthetic. ## Pricing -4-tier resolution: -1. **kosha direct** -- `registry.model(id)` with API keys -2. **Static table** -- 50+ models with accurate direct-API rates -3. **kosha fuzzy** -- 300+ OpenRouter models for the long tail -4. **null** -- unpriced +After the in-memory cache, pricing resolves through: +1. User overrides in `~/.tokmeter/pricing-overrides.json`. +2. Kosha direct model lookup, preferring usable origin rates over gateway rates. +3. Kosha fuzzy lookup. +4. The kosha registry manifest when runtime discovery lacks a model. +5. `null` when no rate is available. + +There is no bundled static pricing table. Public catalog pricing does not require provider credentials. See [how the numbers work](../../docs/how-the-numbers-work.md) for estimation rules and unavailable costs. Covers: input, output, cache read, cache write, and reasoning tokens. diff --git a/packages/core/SKILL.md b/packages/core/SKILL.md index 0af17a7..f904176 100644 --- a/packages/core/SKILL.md +++ b/packages/core/SKILL.md @@ -9,7 +9,7 @@ Core engine for token usage tracking. Provides session parsers for 16+ AI agent - 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 estimated API pricing (input, output, cache, reasoning tokens) -- 4-tier pricing: kosha direct, static table, kosha fuzzy, null +- Pricing: user overrides, kosha direct/fuzzy lookup, registry manifest fallback, or unavailable - Filter by date range, provider, project ## API diff --git a/packages/core/package.json b/packages/core/package.json index 8c8d79c..07c27f6 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@sriinnu/tokmeter-core", - "version": "1.10.0", + "version": "1.11.0", "private": true, "description": "Token usage tracking core — session parsers, aggregation, and pricing", "type": "module", diff --git a/packages/core/src/summary-query.test.ts b/packages/core/src/summary-query.test.ts new file mode 100644 index 0000000..2520340 --- /dev/null +++ b/packages/core/src/summary-query.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { DailyAccumulator } from "./aggregates-store.js"; +import { aggregateRecordsByDay } from "./aggregates.js"; +import { createSummaryQuery } from "./summary-query.js"; +import { TokmeterCore } from "./tokmeter-core.js"; +import type { ScanOptions, TokenRecord } from "./types.js"; + +const NOW = new Date(2026, 8, 8, 12).getTime(); +function record( + day: number, + project: string, + provider: TokenRecord["provider"], + tokens: number, + cost = 0 +): TokenRecord { + return { + timestamp: new Date(2026, 8, day, 10).getTime(), + project, + provider, + model: "fixture-model", + inputTokens: tokens, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + cost, + }; +} + +const history = [ + record(-32, "archived", "claude-code", 100, 9), + record(1, "app", "codex", 7, 0.7), + record(2, "app", "codex", 11, 1.1), + record(7, "app", "codex", 20, 2), + record(7, "app", "claude-code", 40, 4), + record(7, "other", "codex", 80, 8), +]; +const current = record(8, "app", "codex", 3); +current.costEligible = false; +current.usage = { + source: "tool_sqlite", + inputTokens: "direct", + outputTokens: "not_exposed", + cacheReadTokens: "not_exposed", + cacheWriteTokens: "not_exposed", + reasoningTokens: "not_exposed", + cost: "not_exposed", + notes: ["No trustworthy cost breakdown"], +}; + +function fixtureCore(includeToday = true) { + const core = new TokmeterCore({ skipPricing: true }); + const accumulator = new DailyAccumulator("2026-09-08"); + if (includeToday) accumulator.hydrate(aggregateRecordsByDay([current])[0]); + Object.assign(core, { + aliases: { app: { display: "renamed-app", hidden: false, tags: [], modifiedBy: "user" } }, + aggregates: new Map(aggregateRecordsByDay(history).map((day) => [day.date, day])), + todayAccumulator: includeToday ? accumulator : null, + recentRecords: includeToday ? [current] : [], + scanMeta: { + stableThrough: "2026-09-07", + historySource: "snapshot", + todayState: "live", + lastScanAt: NOW, + warnings: [{ scope: "provider", message: "fixture source warning" }], + unpricedModels: ["fixture-model"], + unpricedRecords: 1, + }, + }); + return core; +} + +describe("aggregate summary queries", () => { + beforeEach(() => vi.useFakeTimers({ now: NOW })); + afterEach(() => vi.useRealTimers()); + + test.each<[ScanOptions, number]>([ + [{}, 261], + [{ week: true }, 154], + [{ month: true }, 161], + [{ today: true }, 3], + [{ since: "2026-09-02", until: "2026-09-07", project: "renamed", providers: ["codex"] }, 31], + [{ project: "app", providers: ["claude-code"] }, 40], + [{ year: 2025 }, 0], + [{ project: "absent" }, 0], + ])("keeps the exact date/project/provider intersection %j", (options, expected) => { + const core = fixtureCore(); + const before = JSON.stringify(core.getDailyAggregates()); + const summary = core.getSummary(options); + expect(summary.stats.totalTokens).toBe(expected); + expect(summary.models.reduce((sum, model) => sum + model.totalTokens, 0)).toBe(expected); + expect(summary.daily.reduce((sum, day) => sum + day.totalTokens, 0)).toBe(expected); + expect(summary.projects.reduce((sum, project) => sum + project.totalTokens, 0)).toBe(expected); + expect(JSON.stringify(core.getDailyAggregates())).toBe(before); + expect(core.getStats().totalTokens).toBe(261); + }); + + test("keeps sealed-only totals when no recent raw records exist", () => { + const core = fixtureCore(false); + expect(core.getSummary({ week: true }).stats.totalTokens).toBe(151); + expect(core.getSummary({ week: true }).records).toEqual([]); + }); + + test("today's live accumulator replaces a duplicate sealed day before project filtering", () => { + const core = fixtureCore(); + const staleToday = aggregateRecordsByDay([record(8, "stale-project", "codex", 999)])[0]; + Object.assign(core, { + aggregates: new Map([...core.getDailyAggregates(), staleToday].map((day) => [day.date, day])), + }); + expect(core.getSummary({ today: true }).stats.totalTokens).toBe(3); + expect(core.getSummary({ today: true, project: "stale-project" }).stats.totalTokens).toBe(0); + }); + + test("hidden projects stay in filtered totals but not project lists", () => { + const core = fixtureCore(); + Object.assign(core, { + aliases: { app: { display: "app", hidden: true, tags: [], modifiedBy: "user" } }, + }); + const summary = core.getSummary({ week: true, providers: ["codex"] }); + expect(summary.stats.totalTokens).toBe(114); + expect(summary.projects.map((project) => project.project)).toEqual(["other"]); + }); + + test("preserves unavailable raw cost provenance, frozen costs, and scan metadata", () => { + const core = fixtureCore(); + const summary = core.getSummary({ week: true, project: "renamed", providers: ["codex"] }); + expect(summary.stats.totalCost).toBeCloseTo(3.1); + expect(summary.stats.totalRecords).toBe(3); + expect(summary.records).toEqual([current]); + expect(summary.records[0]).toBe(current); + expect(summary.records[0].usage?.cost).toBe("not_exposed"); + expect(summary.meta).toMatchObject({ + unpricedModels: ["fixture-model"], + unpricedRecords: 1, + historySource: "snapshot", + stableThrough: "2026-09-07", + }); + expect(summary.meta.warnings[0].message).toBe("fixture source warning"); + expect(summary.signals).toBeUndefined(); + expect(core.getSummary().signals).toBeDefined(); + }); + + test("calendar week includes the whole first day and does not depend on hours elapsed", () => { + const query = createSummaryQuery({ week: true }, {}, NOW); + expect( + query.matchesRecord({ ...current, timestamp: new Date(2026, 8, 2, 0, 1).getTime() }) + ).toBe(true); + expect( + query.matchesRecord({ ...current, timestamp: new Date(2026, 8, 1, 23, 59).getTime() }) + ).toBe(false); + }); + + test("calendar week uses dates across a daylight-saving boundary", () => { + const query = createSummaryQuery({ week: true }, {}, new Date(2026, 2, 30, 0, 15).getTime()); + expect( + query.matchesRecord({ ...current, timestamp: new Date(2026, 2, 24, 0, 1).getTime() }) + ).toBe(true); + expect( + query.matchesRecord({ ...current, timestamp: new Date(2026, 2, 23, 23, 59).getTime() }) + ).toBe(false); + }); + + test.each([ + { since: "2026-09-07T12:00:00Z" }, + { until: "2026-02-30" }, + { since: "2026-09-08", until: "2026-09-07" }, + { year: 2026.5 }, + ])("rejects unsupported bounds %j", (options) => { + expect(() => fixtureCore().getSummary(options)).toThrow(); + }); +}); diff --git a/packages/core/src/summary-query.ts b/packages/core/src/summary-query.ts new file mode 100644 index 0000000..3e7cf09 --- /dev/null +++ b/packages/core/src/summary-query.ts @@ -0,0 +1,155 @@ +import type { DailyAggregate, ProjectDayBucket, ProjectModelDayBucket } from "./aggregates.js"; +/** Read-only report filters over sealed daily aggregates and today's accumulator. */ +import type { AliasMap } from "./alias-service.js"; +import { resolveProjectName } from "./alias-service.js"; +import { localDateKey } from "./date-utils.js"; +import { projectNameIncludes } from "./project-name.js"; +import type { ScanOptions, TokenRecord } from "./types.js"; + +const SUM_FIELDS = [ + "inputTokens", + "outputTokens", + "cacheReadTokens", + "cacheWriteTokens", + "reasoningTokens", + "totalTokens", + "cost", + "recordCount", +] as const; +type Totals = Pick; + +function zeroTotals(): Totals { + return { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + cost: 0, + recordCount: 0, + }; +} + +function addTotals(target: Totals, source: Totals): void { + for (const field of SUM_FIELDS) target[field] += source[field]; +} + +function dateBound(value: string | undefined, name: string): string | undefined { + if (value === undefined) return undefined; + const date = new Date(`${value}T12:00:00`); + if ( + !/^\d{4}-\d{2}-\d{2}$/.test(value) || + !Number.isFinite(date.getTime()) || + localDateKey(date.getTime()) !== value + ) { + throw new Error( + `${name} must be a valid YYYY-MM-DD date; aggregate reports do not support intraday timestamps.` + ); + } + return value; +} + +export function createSummaryQuery(options: ScanOptions, aliases: AliasMap, now = Date.now()) { + let since = dateBound(options.since, "since"); + let until = dateBound(options.until, "until"); + const today = new Date(now); + if (options.today) { + since = until = localDateKey(now); + } else if (options.week) { + today.setDate(today.getDate() - 6); + since = localDateKey(today.getTime()); + until = localDateKey(now); + } else if (options.month) { + since = localDateKey(new Date(today.getFullYear(), today.getMonth(), 1).getTime()); + until = localDateKey(now); + } else if (options.year !== undefined) { + if (!Number.isInteger(options.year) || options.year < 1000 || options.year > 9999) { + throw new Error("year must be a four-digit calendar year."); + } + since = `${options.year}-01-01`; + until = `${options.year}-12-31`; + } + if (since && until && since > until) throw new Error("since must be on or before until."); + + const providers = options.providers?.length ? new Set(options.providers) : null; + const matchesDay = (day: string) => (!since || day >= since) && (!until || day <= until); + const matchesProject = (project: string) => + !options.project || + projectNameIncludes(project, options.project) || + projectNameIncludes(resolveProjectName(project, aliases), options.project); + return { + narrowed: Boolean(since || until || providers || options.project), + narrowedBuckets: Boolean(providers || options.project), + matchesRecord: (record: TokenRecord) => + matchesDay(localDateKey(record.timestamp)) && + matchesProject(record.project) && + (!providers || providers.has(record.provider)), + selectDays(days: DailyAggregate[]): Map { + const selected = new Map(); + for (const day of days) { + if (!matchesDay(day.date)) continue; + if (!providers && !options.project) { + selected.set(day.date, day); + continue; + } + const result: DailyAggregate = { + ...zeroTotals(), + date: day.date, + firstUsed: Number.POSITIVE_INFINITY, + lastUsed: 0, + models: Object.create(null), + projects: Object.create(null), + providers: Object.create(null), + }; + for (const [name, project] of Object.entries(day.projects)) { + if (!matchesProject(name)) continue; + const buckets = Object.entries(project.modelBuckets).filter( + ([, bucket]) => !providers || providers.has(bucket.provider) + ); + if (!buckets.length) continue; + const scoped: ProjectDayBucket = { + ...project, + ...zeroTotals(), + models: [], + modelBuckets: Object.create(null), + }; + for (const [key, bucket] of buckets) { + addTotals(scoped, bucket); + scoped.modelBuckets[key] = bucket; + if (!scoped.models.includes(bucket.model)) scoped.models.push(bucket.model); + foldBucket(result, bucket, project); + } + result.projects[name] = scoped; + addTotals(result, scoped); + result.firstUsed = Math.min(result.firstUsed, scoped.firstUsed); + result.lastUsed = Math.max(result.lastUsed, scoped.lastUsed); + } + if (Object.keys(result.projects).length) selected.set(day.date, result); + } + return selected; + }, + }; +} + +function foldBucket(day: DailyAggregate, bucket: ProjectModelDayBucket, project: ProjectDayBucket) { + day.models[bucket.model] ??= { + ...zeroTotals(), + model: bucket.model, + providers: [], + }; + const model = day.models[bucket.model]; + addTotals(model, bucket); + if (!model.providers.includes(bucket.provider)) model.providers.push(bucket.provider); + day.providers[bucket.provider] ??= { + ...zeroTotals(), + provider: bucket.provider, + firstUsed: Number.POSITIVE_INFINITY, + lastUsed: 0, + }; + const provider = day.providers[bucket.provider]; + addTotals(provider, bucket); + // Cross-cut buckets retain counts and costs, but only their parent's time bounds. + provider.firstUsed = Math.min(provider.firstUsed, project.firstUsed); + provider.lastUsed = Math.max(provider.lastUsed, project.lastUsed); +} diff --git a/packages/core/src/tokmeter-core.ts b/packages/core/src/tokmeter-core.ts index 0887e2f..cbd7438 100644 --- a/packages/core/src/tokmeter-core.ts +++ b/packages/core/src/tokmeter-core.ts @@ -41,6 +41,7 @@ import { } from "./scan-pipeline.js"; import { computeStatbarSignals } from "./signals.js"; import { saveSummaryCache } from "./summary-cache.js"; +import { createSummaryQuery } from "./summary-query.js"; import type { DailyEntry, ModelSummary, @@ -491,7 +492,43 @@ export class TokmeterCore { return computeStatbarSignals(this.recentRecords, now, this.getDailyAggregates()); } - getSummary(): TokmeterSummary { + /** + * Query saved daily aggregates without rescanning or changing instance state. + * Report dates are inclusive local calendar days; week is today plus six days. + * Returned records are only the available recent raw evidence, not history. + */ + getSummary(options: ScanOptions = {}): TokmeterSummary { + const aliases = this.getAliases(); + const query = createSummaryQuery(options, aliases); + if (query.narrowed) { + const today = this.getTodayAggregate(); + const days = query.selectDays([ + ...this.getDailyAggregates().filter((day) => day.date !== today?.date), + ...(today ? [today] : []), + ]); + const meta = this.getScanMeta(); + return { + records: this.recentRecords.filter(query.matchesRecord), + projects: computeAllProjectsFromState(days, null, aliases), + models: computeModelCostsFromState(days, null, {}), + daily: computeDailyBreakdownFromState(days, null), + stats: computeStatsFromState(days, null, aliases), + meta: query.narrowedBuckets + ? { + ...meta, + warnings: [ + ...meta.warnings, + { + scope: "history", + message: + "Filtered totals use saved project/provider buckets. First/last timestamps retain project-day bounds; scan metadata describes the full refresh.", + }, + ], + } + : meta, + // Live signals have rolling/intraday scopes that daily buckets cannot reconstruct. + }; + } return { records: this.recentRecords, projects: this.getAllProjects(), diff --git a/packages/macos-bar/README.md b/packages/macos-bar/README.md index 6316297..eb06612 100644 --- a/packages/macos-bar/README.md +++ b/packages/macos-bar/README.md @@ -7,7 +7,7 @@ A native SwiftUI `MenuBarExtra` and companion Hub for local token usage and cost 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. -For the published 1.10.0 build, install and start the daemon explicitly: +For older 1.10.0 builds, install and start the daemon explicitly: ```sh npm install -g @sriinnu/drishti @@ -21,10 +21,12 @@ The app reads HTTP telemetry from `http://127.0.0.1:9877`. It does not run a sep 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. -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. +Six themes are selectable: Terminal, Paper, Prism, Lagoon, Carbon, and Glass. Prism replaces Nebula and retains the stored `nebula` identifier. Carbon replaces Nocturne; Lagoon replaces Aurora. Their stored identifiers remain `nocturne` and `aurora` so existing preferences continue to decode. 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. 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). +Settings → **Open web dashboard** starts a local dashboard server when needed and opens it after readiness. **Stop web dashboard** stops that child; quitting the app also stops it. The usage daemon continues independently. See [dashboard lifecycle and development](../../docs/macos/web-dashboard.md). + ## Build and test Install Xcode, then run from the repository root: @@ -45,9 +47,12 @@ For optional synthetic UI captures, create an output directory and set `TOKMETER - `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. +- `Theme.swift`, `ThemePalettes.swift`, and `Theme+Modes.swift`: identity, colors, and visual modes. +- `PrismSurface.swift` and `FrostedGlass.swift`: reusable surface drawing shared by the popup and Hub. - `HubView.swift`: full-window companion. +See [native theme development](../../docs/macos/themes.md) for module ownership, adding styles, stored identifiers, and render checks. + 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 diff --git a/packages/macos-bar/RELEASE.md b/packages/macos-bar/RELEASE.md index b3f96ab..f65dfb2 100644 --- a/packages/macos-bar/RELEASE.md +++ b/packages/macos-bar/RELEASE.md @@ -1,191 +1,45 @@ -# TokmeterBar — Release Process +# Release workflow -This is the full pipeline from source to a notarized, Sparkle-updatable -`.app` that any Mac on the internet can install. +Release the npm packages and macOS app from the same reviewed, merged source. Use this manual workflow: `scripts/release.sh` is a legacy convenience script that creates an intermediate tag and permits admin merge before CI; it does not implement this sequence. `bar:ship` only builds and uploads the app. -## TL;DR for a release +## Prepare the branch -From the monorepo root — one command: +1. Run `bash scripts/bump-version.sh X.Y.Z` once. It updates package versions, the README badge, changelog, and macOS version/build. Repeating it advances the build number again. +2. Fill the changelog, review README and SKILL examples against source, and preserve private/unrelated workspace files. +3. Run `bun install --lockfile-only`, `bun run build`, `bun run lint`, `bun run test`, and `swift test --package-path packages/macos-bar`. For native fixture review, set `TOKMETER_UI_QA_DIR` to an existing temporary directory. Run `bun run check:secrets` and inspect `git diff --check`. +4. Run `bash scripts/prepare-packages.sh /tmp/tokmeter-candidate-X.Y.Z`. Inspect both tarballs for version, resolved dependencies, entrypoints, licenses, and source; install them together outside the workspace and exercise CLI help and synthetic query/TUI fixtures. +5. Stage only intended public files. Make a signed commit, push the branch, and open a PR with the actual change and validation. Wait for both JavaScript and native CI on that exact head; resolve failures before merging. Do not bypass a red check with admin merge. +6. Fast-forward local main to the reviewed merge. Create the signed `vX.Y.Z` tag on that commit and push it. Never create or move an intermediate release tag. -```sh -bun run bar:ship # clean → notarized build → GitHub release upload -``` +## Build and publish -Then commit + push the updated `appcast.xml` on your branch and merge to `main` -so Sparkle clients pick it up. Existing users get the update automatically -within 24h. +Signing requires an installed Developer ID Application certificate, notarization credentials, and the existing Sparkle private key. Keep credentials in the ignored `packages/macos-bar/.env`; never print or commit them. `.env.example` lists supported settings. Back up the Sparkle private key securely: changing it breaks updates for existing installations. -Or run the steps by hand if you need to control each one: +From merged main, rebuild and prepare the two npm tarballs, then publish those exact reviewed artifacts, Tokmeter first: ```sh -bun run clean # wipe old artifacts -bun run bar:release # sign + notarize + staple + sparkle-sign + appcast -bun run bar:publish # upload TokmeterBar-.zip to GitHub release -git add packages/macos-bar/appcast.xml && git commit && git push +npm publish /tmp/tokmeter-candidate-X.Y.Z/tokmeter-X.Y.Z.tgz --access public +npm publish /tmp/tokmeter-candidate-X.Y.Z/mcp-X.Y.Z.tgz --access public ``` -## One-time setup - -You need to do these **once** when you first start releasing TokmeterBar: - -### 1. Apple Developer ID - -You need a paid Apple Developer Program membership ($99/year) and a -**Developer ID Application** certificate installed in your keychain. - -```sh -# Verify you have one -security find-identity -v -p codesigning | grep "Developer ID Application" -``` - -If you don't, follow Apple's instructions: -https://developer.apple.com/account/resources/certificates/list - -### 2. App-specific password for notarization - -`notarytool` needs an Apple ID + app-specific password (NOT your real Apple -password). Generate one at: - -https://appleid.apple.com → Sign-In and Security → App-Specific Passwords - -Save it. You'll only see it once. - -### 3. Sparkle EdDSA keypair - -Sparkle signs every update zip with an EdDSA private key. The public half -is embedded in `Info.plist` (`SUPublicEDKey`) and verified by every running -copy of the app. +Build the macOS artifact without replacing the running app during packaging: ```sh cd packages/macos-bar -swift build # fetch Sparkle SPM dep first -./generate-sparkle-keys.sh -``` - -This writes `sparkle_ed25519_priv` (mode 600) and `sparkle_ed25519_priv.pub`. - -**CRITICAL:** Back up the private key to 1Password / encrypted storage. If you -lose it, you can never sign another update — users would need to manually -download a new build because their existing app would reject the new signing -key. - -The private key is gitignored. Never commit it. - -### 4. `.env` file - -```sh -cp .env.example .env -$EDITOR .env # fill in DEV_ID, APPLE_*, paths -``` - -The `.env` is gitignored. - -## Per-release workflow - -Each time you ship a new version: - -### 1. Bump versions - -Edit one or both: -- `CFBundleShortVersionString` → semver (`0.1.0` → `0.2.0`) -- `CFBundleVersion` → integer build number (`1` → `2`) - -You can override either via env vars: -```sh -CFBundleShortVersionString=0.2.0 CFBundleVersion=2 ./bundle.sh --release -``` - -### 2. Build, sign, notarize, staple - -```sh -set -a; source .env; set +a -./bundle.sh --release +set -a +. ./.env +set +a +./bundle.sh --release --no-install ``` -This will: -1. Build the release binary via `swift build -c release` -2. Bundle Sparkle.framework into `Frameworks/` -3. Write `Info.plist` (with `SUPublicEDKey` from your keypair) -4. Sign Sparkle's nested XPC services with hardened runtime -5. Sign the main `.app` with hardened runtime + entitlements -6. Submit to Apple's notary service via `notarytool` (5-30 min wait) -7. Staple the notarization ticket to the `.app` -8. Re-zip the stapled `.app` for distribution -9. Sign the zip with Sparkle's `sign_update` (EdDSA) -10. Append a new `` to `appcast.xml` +The bundler signs the app and nested Sparkle components, submits notarization, staples the accepted ticket, signs the final ZIP with Sparkle, and updates `appcast.xml`. Verify `codesign --verify --deep --strict`, `xcrun stapler validate`, `spctl --assess --type execute`, and the update signature against the bundled public key. Check the app version/build and bundled license/source archive. -The result: `TokmeterBar-X.Y.Z.zip` ready to upload. +Create the GitHub release at the signed tag with the release-specific changelog and ZIP. Download the published ZIP and compare its SHA-256 to the verified local artifact. Verify both npm registry versions and tarball integrity. -### 3. Publish the zip +## Publish the update routes -```sh -gh release create v0.2.0 \ - --title "TokmeterBar v0.2.0" \ - --notes-file CHANGELOG.md \ - TokmeterBar-0.2.0.zip -``` - -Or upload to S3 / R2 / wherever — whatever URL you set in `RELEASE_DOWNLOAD_URL` -must serve the zip at exactly that path. - -### 4. Publish the appcast - -```sh -git add appcast.xml -git commit -m "release: v0.2.0 — short summary" -git push -``` - -The `SUFeedURL` in `Info.plist` points at the raw GitHub URL of `appcast.xml`, -so as soon as it's pushed, every running TokmeterBar will see the new version -on its next 24h check (or immediately when the user clicks "Check for Updates…" -in the popover). - -## Modes recap - -| Command | What it produces | Use case | -|---|---|---| -| `./bundle.sh` | Ad-hoc signed `.app` | Local development, throwaway testing | -| `./bundle.sh --signed` | Developer ID signed `.app` | Sharing with colleagues over AirDrop without notarization wait | -| `./bundle.sh --release` | Notarized, stapled `.app` + signed zip + appcast entry | Public release | -| `./bundle.sh --install` | Ad-hoc + copy to /Applications | Local install | - -## Troubleshooting - -### "notarytool: invalid credentials" -Your `APPLE_APP_PASSWORD` is wrong. Generate a new one at appleid.apple.com. - -### "the executable does not have the hardened runtime enabled" -Apple notarization requires hardened runtime. The bundle.sh script signs with -`--options runtime` automatically — if you see this error, the Sparkle nested -components weren't signed first. Check that `Frameworks/Sparkle.framework` -exists in the bundle before the main signing pass. - -### "the application has invalid signature" -Run `codesign --verify --deep --strict --verbose=2 TokmeterBar.app` to see -which component is failing. Usually it's a Sparkle XPC service that wasn't -re-signed after Sparkle updated. - -### Sparkle: "Update is improperly signed" -Either: -1. The `SUPublicEDKey` in `Info.plist` doesn't match the private key used to - sign the zip — regenerate `appcast.xml` with the correct key. -2. The zip was modified after signing (e.g. re-zipped with a different tool). - Re-run `./bundle.sh --release` end to end. - -### Sparkle: "Couldn't find appcast" -Check that `SUFeedURL` is reachable in a browser and returns valid XML. -If you're hosting on GitHub, use the raw URL not the rendered HTML page. - -## What a release looks like - -``` -packages/macos-bar/ -├── TokmeterBar.app/ # signed, notarized, stapled -├── TokmeterBar-0.2.0.zip # ready to upload -├── appcast.xml # updated with new -└── sparkle_ed25519_priv # secret, gitignored, do not lose -``` +- Commit the generated `packages/macos-bar/appcast.xml` on a separate signed branch, open a PR, wait for CI, and merge. Verify the public feed's version, build, URL, length, and signature against the published ZIP. +- Update `Casks/tokmeterbar.rb` in `sriinnu/homebrew-tap` with the version and published ZIP digest through its own branch and PR. The current `scripts/update-brew-cask.sh` pushes directly; use the PR workflow when branch review is required. +- Install the verified app, restart the matching daemon when needed, and verify installed version, signature, binary identity, and live process separately. Record source, distribution, and installed evidence in the review/completion docs. -After upload + push, users running v0.1.0 will see the update prompt -within 24h or when they manually check. +A published release does not complete the [macOS acceptance tracker](../../docs/macos-completion.md). Fresh-machine use, sustained reliability, a real Sparkle upgrade, independent accounting, live accessibility, and user-trial evidence remain distinct gates. diff --git a/packages/macos-bar/Sources/TokmeterBar/BalancedGrid.swift b/packages/macos-bar/Sources/TokmeterBar/BalancedGrid.swift new file mode 100644 index 0000000..266ca8b --- /dev/null +++ b/packages/macos-bar/Sources/TokmeterBar/BalancedGrid.swift @@ -0,0 +1,45 @@ +import SwiftUI + +/// Reflows cards at explicit width thresholds without geometry/state feedback. +/// Allowed counts keep a four-card KPI row balanced at two columns on small windows. +struct BalancedGrid: Layout { + let columnCounts: [Int] + let minimumColumnWidth: CGFloat + var spacing: CGFloat = 12 + + private func metrics(width: CGFloat, subviews: Subviews) -> (columns: Int, columnWidth: CGFloat, rowHeights: [CGFloat]) { + let columns = columnCounts.sorted(by: >).first { + CGFloat($0) * minimumColumnWidth + CGFloat($0 - 1) * spacing <= width + } ?? 1 + let columnWidth = max(0, (width - CGFloat(columns - 1) * spacing) / CGFloat(columns)) + var heights: [CGFloat] = [] + for start in stride(from: 0, to: subviews.count, by: columns) { + let height = (start.. CGSize { + let width = proposal.width ?? minimumColumnWidth + let layout = metrics(width: width, subviews: subviews) + return CGSize(width: width, height: layout.rowHeights.reduce(0, +) + + CGFloat(max(0, layout.rowHeights.count - 1)) * spacing) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let layout = metrics(width: bounds.width, subviews: subviews) + var y = bounds.minY + for (row, height) in layout.rowHeights.enumerated() { + for column in 0.. ProjectDetailData { + try await get(Self.projectDetailPath(projectName), as: ProjectDetailData.self) + } + + static func projectDetailPath(_ projectName: String) -> String { // Encode as a single path SEGMENT: .urlPathAllowed leaves "/" intact, so // a project name containing "/" or "../" could reshape the request path. // Removing "/" from the allowed set forces %2F, keeping the name in one @@ -168,7 +172,7 @@ final class DaemonClient { let encoded = projectName.addingPercentEncoding( withAllowedCharacters: segmentAllowed ) ?? projectName - return try await get("/api/projects/\(encoded)", as: ProjectDetailData.self) + return "/api/projects/\(encoded)" } /// Fetch the mtime of ~/.kosha/registry.json so the bar can display @@ -258,10 +262,16 @@ final class DaemonClient { // MARK: - Internal + /// Routes already contain their query and escaped path segments. Appending + /// them as one path component escapes `?` and escapes every `%` again. + static func requestURL(for path: String) -> URL { + URL(string: path, relativeTo: baseURL)!.absoluteURL + } + private func post(_ path: String, body: [String: Any], as type: T.Type) async throws -> T { guard isDaemonRunning else { throw DaemonError.daemonNotRunning } - let url = baseURL.appendingPathComponent(path) + let url = Self.requestURL(for: path) var req = URLRequest(url: url) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Accept") @@ -295,7 +305,7 @@ final class DaemonClient { private func get(_ path: String, as type: T.Type) async throws -> T { guard isDaemonRunning else { throw DaemonError.daemonNotRunning } - let url = baseURL.appendingPathComponent(path) + let url = Self.requestURL(for: path) var req = URLRequest(url: url) req.httpMethod = "GET" req.setValue("application/json", forHTTPHeaderField: "Accept") diff --git a/packages/macos-bar/Sources/TokmeterBar/DailyUsageTooltip.swift b/packages/macos-bar/Sources/TokmeterBar/DailyUsageTooltip.swift new file mode 100644 index 0000000..5bed855 --- /dev/null +++ b/packages/macos-bar/Sources/TokmeterBar/DailyUsageTooltip.swift @@ -0,0 +1,55 @@ +import SwiftUI + +/// Shared hover details for charts backed by recorded daily totals. +struct DailyUsageTooltip: View { + let day: DailyUsage + let theme: AppTheme + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text(day.date) + .foregroundColor(theme.backgroundMode.secondaryTextColor) + Text("\(day.tokens.formatted()) tokens") + .foregroundColor(theme.backgroundMode.primaryTextColor) + Text("\(Fmt.cost(day.cost)) cost") + .foregroundColor(theme.costInk) + } + .font(.system(size: 10, weight: .medium, design: theme.fonts.bodyDesign)) + .monospacedDigit() + .padding(8) + .background(RoundedRectangle(cornerRadius: 7).fill(theme.backgroundMode.surfaceColor)) + .overlay(RoundedRectangle(cornerRadius: 7).stroke(theme.costInk.opacity(0.4))) + .shadow(color: .black.opacity(0.2), radius: 5, y: 2) + .fixedSize() + .allowsHitTesting(false) + } +} + +struct SparklineUsageHover: ViewModifier { + let days: [DailyUsage] + let theme: AppTheme + @State private var selected: DailyUsage? + + func body(content: Content) -> some View { + content.overlay { + GeometryReader { geometry in + Color.clear.contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case .active(let point): + guard !days.isEmpty, geometry.size.width > 0 else { selected = nil; return } + let fraction = min(1, max(0, point.x / geometry.size.width)) + let index = Int((fraction * Double(days.count - 1)).rounded()) + selected = days[index] + case .ended: selected = nil + } + } + } + } + .overlay(alignment: .bottom) { + if let selected { + DailyUsageTooltip(day: selected, theme: theme).offset(y: -24) + } + } + } +} diff --git a/packages/macos-bar/Sources/TokmeterBar/DataSections.swift b/packages/macos-bar/Sources/TokmeterBar/DataSections.swift index 4daf4fd..3a2982a 100644 --- a/packages/macos-bar/Sources/TokmeterBar/DataSections.swift +++ b/packages/macos-bar/Sources/TokmeterBar/DataSections.swift @@ -172,7 +172,7 @@ struct ModelsSection: View { ? (model.tokens > 0 ? "\(Fmt.number(model.tokens)) tokens. Cost is unavailable because pricing or a reliable token breakdown is missing." : "This provider doesn't expose token counts or cost locally — only that you used it.") - : compositionTooltip( + : "\(model.tokens.formatted()) tokens · \(Fmt.cost(model.cost)) cost\n" + compositionTooltip( output: model.outputTokens, cacheRead: model.cacheReadTokens, cacheWrite: model.cacheWriteTokens, @@ -269,13 +269,14 @@ struct WeekSection: View { /// 0→1 over ~0.9s on first appear. Drives a leading-edge mask so the /// chart reveals left-to-right like an ink pen drawing the line. @State private var drawProgress: CGFloat = 0 + @State private var hoveredDate: String? private var c: ThemeColors { theme.colors } private var style: WeekChartStyle { configStore.config.chartStyle } var body: some View { VStack(alignment: .leading, spacing: 8) { - SectionHeader(label: "LAST 7 DAYS", count: loader.recentDaily.count, theme: theme) + SectionHeader(label: "LAST 7 RECORDED DAYS", count: loader.recentDaily.count, theme: theme) if loader.isWarming { ShimmerBar(width: 340, height: 60, breathToggle: true) @@ -283,7 +284,7 @@ struct WeekSection: View { Chart(loader.recentDaily) { day in if style == .bars { BarMark( - x: .value("Date", String(day.date.suffix(5))), + x: .value("Date", day.date), y: .value("Cost", day.cost) ) .foregroundStyle(LinearGradient( @@ -292,7 +293,7 @@ struct WeekSection: View { .cornerRadius(3) } else if style == .area { AreaMark( - x: .value("Date", String(day.date.suffix(5))), + x: .value("Date", day.date), y: .value("Cost", day.cost) ) .foregroundStyle(LinearGradient( @@ -301,7 +302,7 @@ struct WeekSection: View { .interpolationMethod(.catmullRom) LineMark( - x: .value("Date", String(day.date.suffix(5))), + x: .value("Date", day.date), y: .value("Cost", day.cost) ) .foregroundStyle(LinearGradient( @@ -311,7 +312,7 @@ struct WeekSection: View { .lineStyle(StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)) } else { LineMark( - x: .value("Date", String(day.date.suffix(5))), + x: .value("Date", day.date), y: .value("Cost", day.cost) ) .foregroundStyle(LinearGradient( @@ -321,7 +322,7 @@ struct WeekSection: View { .lineStyle(StrokeStyle(lineWidth: 2.5, lineCap: .round, lineJoin: .round)) AreaMark( - x: .value("Date", String(day.date.suffix(5))), + x: .value("Date", day.date), y: .value("Cost", day.cost) ) .foregroundStyle(LinearGradient( @@ -335,7 +336,7 @@ struct WeekSection: View { // dollar reading or it reads as "no data for today". if day.date == loader.recentDaily.last?.date { PointMark( - x: .value("Date", String(day.date.suffix(5))), + x: .value("Date", day.date), y: .value("Cost", day.cost) ) .foregroundStyle(c.warm) @@ -352,7 +353,7 @@ struct WeekSection: View { .chartXAxis { AxisMarks { value in AxisValueLabel { - Text(value.as(String.self) ?? "") + Text(String((value.as(String.self) ?? "").suffix(5))) .font(.system(size: 9, design: .rounded)) .foregroundColor(theme.backgroundMode.secondaryTextColor) } @@ -372,6 +373,27 @@ struct WeekSection: View { .onAppear { withAnimation(.easeOut(duration: 0.9)) { drawProgress = 1.0 } } + .chartOverlay { proxy in + GeometryReader { geometry in + Color.clear.contentShape(Rectangle()) + .onContinuousHover { phase in + switch phase { + case .active(let point): + guard let anchor = proxy.plotFrame else { hoveredDate = nil; return } + let frame = geometry[anchor] + guard frame.contains(point) else { hoveredDate = nil; return } + hoveredDate = proxy.value(atX: point.x - frame.minX, as: String.self) + case .ended: hoveredDate = nil + } + } + } + } + .overlay(alignment: .topTrailing) { + if let day = loader.recentDaily.first(where: { $0.date == hoveredDate }) { + DailyUsageTooltip(day: day, theme: theme).offset(y: -30) + } + } + } } } diff --git a/packages/macos-bar/Sources/TokmeterBar/HeroBackground.swift b/packages/macos-bar/Sources/TokmeterBar/HeroBackground.swift index ff0cfce..929c6e2 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HeroBackground.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HeroBackground.swift @@ -18,9 +18,7 @@ struct HeroBackground: View { let theme: AppTheme let breathToggle: Bool /// Whether the popover is actually on screen — see PanelVisibility.swift. - /// Only `aurora` needs this directly (its own TimelineView); every other - /// theme's ambient motion already rides on `breathToggle`, which the - /// parent already gates on visibility. + /// Shared visibility input; ambient motion follows the parent's gated breath flag. var isVisible: Bool = true private var c: ThemeColors { theme.colors } @@ -55,114 +53,28 @@ struct HeroBackground: View { } } - // MARK: - Aurora + // MARK: - Lagoon (legacy Aurora identifier) - /// Drifting northern-lights gradient. The MeshGradient stops shift their - /// positions on a slow 60s cycle so the bg is alive but never flashy — - /// motion as identity, not motion as ornament. Apple's macOS Sonoma - /// "Sky" wallpapers are the lineage. Performance: the only animated - /// view in the entire bar; runs on Core Animation off the main thread. private var aurora: some View { - Group { - if isVisible { - TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in - auroraContent(at: timeline.date) - } - } else { - // Frozen — no TimelineView means no ticking while the panel - // is closed. Whatever phase it's at when hidden is fine; - // nobody can see it, and it resumes instantly when reopened. - auroraContent(at: Date()) - } - } - } - - @ViewBuilder - private func auroraContent(at date: Date) -> some View { - let t = date.timeIntervalSinceReferenceDate - // 45/60/75s phased periods — faster than the initial pass so the - // motion is actually visible without being distracting. Each - // stop drifts on its own phase to keep the pattern non-looping. - let p1 = sin(t * 2 * .pi / 45) - let p2 = cos(t * 2 * .pi / 75) - let p3 = sin(t * 2 * .pi / 60) - ZStack { - // Solid base anchor. - Color(red: 0.02, green: 0.03, blue: 0.08) - // Three radial gradients drift independently with stronger - // peak intensities than v1 — "curtain of light" should land - // as luminous, not subliminal. - RadialGradient( - colors: [c.secondary.opacity(0.78), c.secondary.opacity(0.10), Color.clear], - center: UnitPoint(x: 0.25 + p1 * 0.22, y: 0.30 + p2 * 0.16), - startRadius: 15, endRadius: 320 - ) - RadialGradient( - colors: [c.accent.opacity(0.65), c.accent.opacity(0.08), Color.clear], - center: UnitPoint(x: 0.72 + p3 * 0.20, y: 0.55 + p1 * 0.14), - startRadius: 20, endRadius: 360 - ) - RadialGradient( - colors: [c.tertiary.opacity(0.45), Color.clear], - center: UnitPoint(x: 0.50 + p2 * 0.25, y: 0.20 + p3 * 0.12), - startRadius: 30, endRadius: 280 - ) - // Faint star-like specular over the top. - RadialGradient( - colors: [Color.white.opacity(0.08), Color.clear], - center: .top, startRadius: 0, endRadius: 220 - ) + ZStack(alignment: .bottom) { + LinearGradient(colors: [Color(red: 0.025, green: 0.19, blue: 0.18), + Color(red: 0.035, green: 0.12, blue: 0.15)], + startPoint: .topLeading, endPoint: .bottomTrailing) + Rectangle().fill(c.secondary.opacity(0.6)).frame(height: 2) } } - - // MARK: - Nebula - /// Deep purple → magenta → warm orange diagonal with a slow breathing - /// white overlay and a corner vignette. The identity look. + // MARK: - Prism (legacy Nebula identifier) private var nebula: some View { - ZStack { - LinearGradient( - colors: [c.primary, c.secondary, c.warm, c.highlight], - startPoint: .topLeading, endPoint: .bottomTrailing - ) - RadialGradient( - colors: [Color.clear, Color.black.opacity(0.22)], - center: .bottomTrailing, startRadius: 100, endRadius: 400 - ) - Color.white - .opacity(breathToggle ? 0.08 : 0.0) - .animation(.easeInOut(duration: 4).repeatForever(autoreverses: true), value: breathToggle) - } + PrismHeroBackdrop(colors: c) } - // MARK: - Nocturne - /// Solid deep indigo with a slowly-pulsing corner glow and a faint - /// starfield. The pulse breathes between 0.6 and 1.0 opacity over 5s. - private var nocturne: some View { - ZStack(alignment: .topTrailing) { - c.primary - RadialGradient( - colors: [c.accent.opacity(0.35), c.accent.opacity(0.0)], - center: .topTrailing, startRadius: 20, endRadius: 260 - ) - // Soft breathing — opacity oscillates so the glow feels alive - // without changing color or position. Keyed off breathToggle so - // it shares the same rhythm as the hero's other ambient motion. - .opacity(breathToggle ? 1.0 : 0.55) - .animation(.easeInOut(duration: 5).repeatForever(autoreverses: true), value: breathToggle) + // MARK: - Carbon (legacy Nocturne identifier) - GeometryReader { geo in - ForEach(0..<8, id: \.self) { i in - Circle() - .fill(Color.white.opacity(0.12)) - .frame(width: 1.5, height: 1.5) - .position( - x: CGFloat((i * 47 + 13) % Int(geo.size.width)), - y: CGFloat((i * 31 + 8) % Int(geo.size.height)) - ) - } - } - .allowsHitTesting(false) + private var nocturne: some View { + ZStack(alignment: .bottomLeading) { + Color(red: 0.105, green: 0.105, blue: 0.11) + Rectangle().fill(c.highlight).frame(width: 60, height: 2) } } diff --git a/packages/macos-bar/Sources/TokmeterBar/HeroHeader.swift b/packages/macos-bar/Sources/TokmeterBar/HeroHeader.swift index e635b39..eb01f26 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HeroHeader.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HeroHeader.swift @@ -2,7 +2,7 @@ // // Structure: // - One row: ♾️ (smaller) + TOKMETER wordmark + status (warming/stale/ECG) -// - One row: $48.95 (hero number) · "today" inline at baseline +// - Tokens today and estimated API cost today, with equal headline weight // Total hero height is ~110pt — down from the earlier 160pt — so the KPI // cards and sections below get the vertical real estate. // @@ -119,23 +119,40 @@ struct HeroHeader: View { } } - /// Usage is the headline; monetary estimates and tool reports stay separate. + /// Both headline values describe today; estimates remain separate from tool reports. private var valueRow: some View { - HStack(alignment: .lastTextBaseline, spacing: 6) { + HStack(alignment: .top, spacing: 16) { if loader.isWarming { skeletonHero } else { - Text(Fmt.number(loader.todayTokens)) - .font(theme.fonts.hero(size: heroFontSize)) - .foregroundColor(foreground) - .contentTransition(.numericText()) - .lineLimit(1) - .minimumScaleFactor(0.6) - Text("tokens today") - .font(.system(size: 10, weight: .medium, design: theme.fonts.bodyDesign)) - .foregroundColor(foreground.opacity(0.65)) + headline(Fmt.number(loader.todayTokens), label: "Tokens today", color: foreground) + headline(estimatedCostText, label: "Estimated API cost today", color: theme.costInk) + .help("Usage valued at model API rates. Tool-reported costs are listed separately below.") } } + .padding(.top, 5) + } + + private var estimatedCostText: String { + guard let basis = loader.statbarSignals?.costBasisToday, + basis.estimatedRecords > 0 else { return "—" } + return Fmt.cost(basis.estimatedCost) + } + + private func headline(_ value: String, label: String, color: Color) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(theme.fonts.hero(size: heroFontSize)) + .foregroundColor(color) + .contentTransition(.numericText()) + .lineLimit(1) + .minimumScaleFactor(0.65) + Text(label) + .font(.system(size: 10, weight: .medium, design: theme.fonts.bodyDesign)) + .foregroundColor(foreground.opacity(0.85)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) } @ViewBuilder @@ -143,9 +160,6 @@ struct HeroHeader: View { if !loader.isWarming { VStack(alignment: .leading, spacing: 4) { if let basis = loader.statbarSignals?.costBasisToday { - if basis.estimatedRecords > 0 { - costLine("Estimated API cost", value: basis.estimatedCost) - } if basis.reportedRecords > 0 { costLine("Tool-reported cost", value: basis.reportedCost) } @@ -329,15 +343,15 @@ struct HeroHeader: View { private var ambientShadow: Color { switch theme { - case .nebula: return c.secondary.opacity(0.45) - case .nocturne: return c.accent.opacity(0.22) + case .nebula: return Color.clear + case .nocturne: return Color.clear case .daylight: return Color.black.opacity(0.12) case .synthwave: return c.primary.opacity(0.60) 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.clear - case .aurora: return c.accent.opacity(0.35) + case .aurora: return Color.clear case .blueprint: return Color.black.opacity(0.10) case .noise: return Color.black.opacity(0.40) // hard offset reads as "stuck on" case .mint: return Color.black.opacity(0.06) // hairline whisper @@ -353,10 +367,20 @@ struct HeroHeader: View { /// Bottom-rounded "notch" shape — the popover's top corners stay square /// to match the menubar chrome; bottom corners tuck inward. + private var heroCornerRadius: CGFloat { + switch theme { + case .glass: return 0 + case .nocturne: return 6 + case .nebula: return 18 + case .aurora: return 14 + default: return 26 + } + } + private var notchShape: UnevenRoundedRectangle { UnevenRoundedRectangle( - cornerRadii: .init(topLeading: 0, bottomLeading: theme == .glass ? 0 : 26, - bottomTrailing: theme == .glass ? 0 : 26, topTrailing: 0), + cornerRadii: .init(topLeading: 0, bottomLeading: heroCornerRadius, + bottomTrailing: heroCornerRadius, topTrailing: 0), style: .continuous ) } @@ -366,7 +390,7 @@ struct HeroHeader: View { @ViewBuilder private var innerHighlight: some View { switch theme { - case .daylight, .hud, .terminal, .paper, .blueprint, .noise, .mint, .glass: + case .daylight, .hud, .terminal, .paper, .blueprint, .noise, .mint, .glass, .nocturne, .aurora: EmptyView() default: notchShape.strokeBorder( diff --git a/packages/macos-bar/Sources/TokmeterBar/HubActivityChart.swift b/packages/macos-bar/Sources/TokmeterBar/HubActivityChart.swift index a14fb47..708073d 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubActivityChart.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubActivityChart.swift @@ -19,8 +19,8 @@ struct HubActivityChart: View { private var c: ThemeColors { theme.colors } private var bg: BackgroundMode { theme.backgroundMode } - /// 7-day trailing average, clamped at the leading edge so the first few - /// days reflect a smaller window rather than zero-padding skewing low. + /// Trailing average of up to seven recorded days. Missing calendar dates + /// are not filled; the leading edge uses the available smaller window. private var movingAvg: [TrendPoint] { guard !daily.isEmpty else { return [] } let window = 7 @@ -57,7 +57,7 @@ struct HubActivityChart: View { ForEach(movingAvg) { p in LineMark( x: .value("Day", p.date), - y: .value("7-day avg", p.value) + y: .value("7 recorded days average", p.value) ) .foregroundStyle(c.accent) .lineStyle(StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round)) @@ -66,7 +66,7 @@ struct HubActivityChart: View { if let last = movingAvg.last { PointMark( x: .value("Day", last.date), - y: .value("7-day avg", last.value) + y: .value("7 recorded days average", last.value) ) .foregroundStyle(c.accent) .symbolSize(70) @@ -78,7 +78,7 @@ struct HubActivityChart: View { PointMark(x: .value("Day", hd), y: .value("Cost", v.bar)) .foregroundStyle(c.warm) .symbolSize(55) - PointMark(x: .value("Day", hd), y: .value("7-day avg", v.avg)) + PointMark(x: .value("Day", hd), y: .value("7 recorded days average", v.avg)) .foregroundStyle(c.accent) .symbolSize(60) } @@ -90,6 +90,7 @@ struct HubActivityChart: View { switch phase { case .active(let pt): guard let plotFrame = proxy.plotFrame else { return } + guard geo[plotFrame].contains(pt) else { hoveredDate = nil; return } let plotX = pt.x - geo[plotFrame].origin.x if let date: String = proxy.value(atX: plotX) { hoveredDate = date @@ -104,8 +105,9 @@ struct HubActivityChart: View { } .overlay(alignment: .topTrailing) { if let hd = hoveredDate, let v = byDate[hd] { - HubChartTooltip(date: hd, daily: v.bar, avg: v.avg, theme: theme) + HubChartTooltip(date: hd, daily: v.bar, tokens: daily.first { $0.date == hd }?.tokens ?? 0, avg: v.avg, theme: theme) .padding(8) + .allowsHitTesting(false) .transition(.opacity.combined(with: .scale(scale: 0.92))) } } @@ -175,6 +177,7 @@ private struct TrendPoint: Identifiable { struct HubChartTooltip: View { let date: String let daily: Double + let tokens: Int let avg: Double let theme: AppTheme @@ -188,15 +191,18 @@ struct HubChartTooltip: View { .tracking(0.5) .foregroundColor(bg.secondaryTextColor) tooltipRow( - label: "Daily", + label: "Daily cost", value: Fmt.cost(daily), swatch: LinearGradient( colors: [c.primary, c.secondary, c.warm], startPoint: .bottom, endPoint: .top ) ) + Text("\(tokens.formatted()) tokens") + .font(.system(size: 11, weight: .medium, design: theme.fonts.bodyDesign)) + .foregroundColor(bg.primaryTextColor) tooltipRow( - label: "7-day avg", + label: "7 recorded days avg", value: Fmt.cost(avg), swatch: LinearGradient( colors: [c.accent, c.accent], @@ -208,7 +214,7 @@ struct HubChartTooltip: View { .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 8) - .fill(.ultraThinMaterial) + .fill(bg.surfaceColor) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(c.accent.opacity(0.35), lineWidth: 1) @@ -271,9 +277,10 @@ struct HubChartLegend: View { Capsule() .fill(c.accent) .frame(width: 12, height: 2) - Text("7-day avg") + Text("7-record avg") .font(.system(size: 9, weight: .medium, design: theme.fonts.labelDesign)) .foregroundColor(bg.secondaryTextColor) + .help("Trailing average of up to seven recorded days; missing dates are not filled.") } } } diff --git a/packages/macos-bar/Sources/TokmeterBar/HubCard.swift b/packages/macos-bar/Sources/TokmeterBar/HubCard.swift index ffc320c..91db645 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubCard.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubCard.swift @@ -18,18 +18,32 @@ struct HubCard: View { private var c: ThemeColors { theme.colors } private var bg: BackgroundMode { theme.backgroundMode } + private var panelRadius: CGFloat { + theme == .nocturne ? 6 : (theme == .aurora ? 18 : 14) + } + + private var panelFill: Color { + switch theme { + case .nocturne: return Color(red: 0.10, green: 0.10, blue: 0.105) + case .aurora: return Color(red: 0.035, green: 0.135, blue: 0.14) + default: return Color.primary.opacity(bg.isLight ? 0.03 : 0.05) + } + } + var body: some View { content() .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background { - if bg.usesMaterial { + if theme == .nebula { + PrismPanel(colors: c) + } else if bg.usesMaterial { FrostedGlassPanel() } else { - RoundedRectangle(cornerRadius: 14) - .fill(Color.primary.opacity(bg.isLight ? 0.03 : 0.05)) + RoundedRectangle(cornerRadius: panelRadius) + .fill(panelFill) .overlay( - RoundedRectangle(cornerRadius: 14) + RoundedRectangle(cornerRadius: panelRadius) .stroke(c.accent.opacity(0.12), lineWidth: 1) ) } diff --git a/packages/macos-bar/Sources/TokmeterBar/HubCommandsCatalog.swift b/packages/macos-bar/Sources/TokmeterBar/HubCommandsCatalog.swift index bb27254..4be6bb0 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubCommandsCatalog.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubCommandsCatalog.swift @@ -139,15 +139,15 @@ enum HubCommandCatalog { .init(id: "daemon-start", name: "daemon start", description: "Start the background aggregator (powers the bar + Hub).", - example: "tokmeter daemon start"), + example: "drishti daemon start"), .init(id: "daemon-status", name: "daemon status", - description: "Show the daemon's PID, URL, and liveness.", - example: "tokmeter daemon status"), + description: "Show the daemon port and verified process identity.", + example: "drishti daemon status"), .init(id: "daemon-stop", name: "daemon stop", description: "Stop the running daemon.", - example: "tokmeter daemon stop"), + example: "drishti daemon stop"), ] ), @@ -159,15 +159,15 @@ enum HubCommandCatalog { .init(id: "install-statusline", name: "install-statusline", description: "Wire the statusline hook into every supported editor.", - example: "tokmeter install-statusline"), + example: "drishti install-statusline"), .init(id: "install-mcp", name: "install-mcp", description: "Register the MCP server with every supported editor.", - example: "tokmeter install-mcp"), + example: "drishti install-mcp"), .init(id: "editors", name: "editors", description: "List every editor tokmeter knows how to hook into.", - example: "tokmeter editors"), + example: "drishti editors"), ] ), diff --git a/packages/macos-bar/Sources/TokmeterBar/HubConfigStore.swift b/packages/macos-bar/Sources/TokmeterBar/HubConfigStore.swift index 75b5b79..6022a84 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubConfigStore.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubConfigStore.swift @@ -155,6 +155,8 @@ final class HubConfigStore: ObservableObject { static let shared = HubConfigStore() @Published private(set) var config: HubUserConfig + @Published private(set) var saveError: String? + private let storagePath: String /// Where the file lives. Same path the CLI uses in config-service.ts. static let filePath: String = { @@ -162,25 +164,26 @@ final class HubConfigStore: ObservableObject { return "\(home)/.tokmeter/config.json" }() - private init() { - self.config = Self.loadFromDisk() ?? .defaults + init(filePath: String? = nil) { + let path = filePath ?? Self.filePath + self.storagePath = path + self.config = Self.loadFromDisk(filePath: path) ?? .defaults } - /// Atomic update: mutate in memory, stamp user flag + timestamp, then - /// write to disk. Subscribers on `$config` see the new value immediately. + /// Stamp and persist edits before notifying subscribers. Failed writes + /// retain the prior settings and expose a visible error. func update(_ mutate: (inout HubUserConfig) -> Void) { var next = config mutate(&next) next.modifiedBy = .user next.modifiedAt = ISO8601DateFormatter().string(from: Date()) - config = next - saveToDisk(next) + persist(next) } /// Reload from disk — used when reopening the Settings panel in case the /// user also edited the file by hand, or the CLI wrote to it. func reload() { - if let fresh = Self.loadFromDisk() { + if let fresh = Self.loadFromDisk(filePath: storagePath) { self.config = fresh } } @@ -191,13 +194,12 @@ final class HubConfigStore: ObservableObject { var fresh = HubUserConfig.defaults fresh.modifiedBy = .user fresh.modifiedAt = ISO8601DateFormatter().string(from: Date()) - config = fresh - saveToDisk(fresh) + persist(fresh) } // MARK: - Disk IO - private static func loadFromDisk() -> HubUserConfig? { + private static func loadFromDisk(filePath: String) -> HubUserConfig? { guard FileManager.default.fileExists(atPath: filePath), let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)) else { return nil @@ -229,24 +231,19 @@ final class HubConfigStore: ObservableObject { return out } - private func saveToDisk(_ cfg: HubUserConfig) { - let dir = (Self.filePath as NSString).deletingLastPathComponent - try? FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - guard let data = try? encoder.encode(cfg) else { return } - // Atomic replace: write to sibling tmp, rename over the real file. - let tmp = Self.filePath + ".tmp-\(getpid())" + /// Publish the new settings only after their atomic disk write succeeds. + private func persist(_ cfg: HubUserConfig) { do { - try data.write(to: URL(fileURLWithPath: tmp)) - _ = try FileManager.default.replaceItemAt( - URL(fileURLWithPath: Self.filePath), - withItemAt: URL(fileURLWithPath: tmp) - ) + let url = URL(fileURLWithPath: storagePath) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), + withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(cfg).write(to: url, options: .atomic) + config = cfg + saveError = nil } catch { - // If replaceItemAt fails because the target doesn't exist, fall - // back to a direct write — replaceItemAt is strict about that. - try? data.write(to: URL(fileURLWithPath: Self.filePath)) + saveError = "Settings weren't saved: \(error.localizedDescription)" } } diff --git a/packages/macos-bar/Sources/TokmeterBar/HubKpiTile.swift b/packages/macos-bar/Sources/TokmeterBar/HubKpiTile.swift index ed7aa0f..eff57aa 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubKpiTile.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubKpiTile.swift @@ -18,29 +18,26 @@ struct HubKpiTile: View { var body: some View { HubCard(theme: theme) { - HStack(alignment: .center, spacing: 12) { - ZStack { - RoundedRectangle(cornerRadius: 9) - .fill(accent.opacity(0.18)) - .frame(width: 36, height: 36) + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { Image(systemName: icon) - .font(.system(size: 15, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) .foregroundColor(accent) - } - VStack(alignment: .leading, spacing: 1) { Text(label.uppercased()) .font(.system(size: 9, weight: .semibold, design: theme.fonts.labelDesign)) - .tracking(1.3) + .tracking(0.7) .foregroundColor(bg.secondaryTextColor) - Text(value) - .font(.system(size: 20, weight: .bold, design: theme.fonts.valueDesign)) - .foregroundColor(bg.primaryTextColor) - .contentTransition(.numericText()) .lineLimit(1) - .truncationMode(.tail) } - Spacer(minLength: 0) + Text(value) + .font(.system(size: 22, weight: .bold, design: theme.fonts.valueDesign)) + .foregroundColor(bg.primaryTextColor) + .contentTransition(.numericText()) + .lineLimit(1) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity, alignment: .leading) } + .accessibilityElement(children: .combine) } .scaleEffect(hovered ? 1.015 : 1.0) .offset(y: hovered ? -1 : 0) diff --git a/packages/macos-bar/Sources/TokmeterBar/HubOverview.swift b/packages/macos-bar/Sources/TokmeterBar/HubOverview.swift index 4f1d44a..a3a7168 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubOverview.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubOverview.swift @@ -84,25 +84,14 @@ struct HubOverviewPanel: View { Spacer() // Tok waves hello from the header — the mascot echoed where there's // real room, not crammed into a data view. - TokMascot(theme: theme, scale: 0.62) + TokMascot(theme: theme, scale: 0.42) } } // MARK: - KPI row - // A LazyVGrid with fixed flexible columns — NOT an HStack of - // `.frame(maxWidth: .infinity)` cards. Four greedy equal-priority cards in an - // HStack let the flex solver re-divide width on every render; when a tile's - // numericText value changes on the 30s poll, its transient intrinsic width - // perturbs the split and, at large width (lots of slack), the solver never - // settles within AppKit's Update-Constraints pass budget → crash. A grid - // resolves column widths deterministically (available/4), so a tile's content - // can no longer feed back into the row's geometry. private var kpiRow: some View { - LazyVGrid( - columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: 4), - spacing: 12 - ) { + BalancedGrid(columnCounts: [4, 2, 1], minimumColumnWidth: 145) { HubKpiTile( label: "Total cost", value: Fmt.cost(loader.totalCost), @@ -140,7 +129,7 @@ struct HubOverviewPanel: View { HubCard(theme: theme) { VStack(alignment: .leading, spacing: 12) { HStack { - Text("30-day activity") + Text("Last 30 recorded days") .font(.system(size: 13, weight: .semibold, design: theme.fonts.labelDesign)) .foregroundColor(bg.primaryTextColor) Spacer() diff --git a/packages/macos-bar/Sources/TokmeterBar/HubProjectCliActions.swift b/packages/macos-bar/Sources/TokmeterBar/HubProjectCliActions.swift index 91443df..152bea4 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubProjectCliActions.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubProjectCliActions.swift @@ -19,32 +19,34 @@ struct HubProjectCliActions: View { /// on the clicked button only, not every row. @State private var flashed: String? - private var commands: [CliCommand] { - let name = projectName + private var commands: [CliCommand] { Self.commands(for: projectName) } + + static func commands(for projectName: String) -> [CliCommand] { + let name = ShellArgument.quote(projectName) return [ CliCommand( id: "dry-run", icon: "trash.circle", title: "Preview cleanup", - command: #"tokmeter cleanup --project "\#(name)" --dry-run"# + command: #"tokmeter cleanup --project \#(name) --dry-run"# ), CliCommand( id: "snapshot", icon: "archivebox", title: "Snapshot project", - command: #"tokmeter snapshot --project "\#(name)""# + command: #"tokmeter snapshot --project \#(name)"# ), CliCommand( id: "alias-rename", icon: "character.cursor.ibeam", title: "Rename via alias", - command: #"tokmeter alias set "\#(name)" "Better Name""# + command: #"tokmeter alias set \#(name) "Better Name""# ), CliCommand( id: "alias-hide", icon: "eye.slash", title: "Hide from tables", - command: #"tokmeter alias hide "\#(name)""# + command: #"tokmeter alias hide \#(name)"# ), ] } diff --git a/packages/macos-bar/Sources/TokmeterBar/HubPulseCard.swift b/packages/macos-bar/Sources/TokmeterBar/HubPulseCard.swift index 8878ecf..6a44baf 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubPulseCard.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubPulseCard.swift @@ -24,12 +24,7 @@ struct HubPulseCard: View { Text("Today's pulse") .font(.system(size: 13, weight: .semibold, design: theme.fonts.labelDesign)) .foregroundColor(bg.primaryTextColor) - // Deterministic 5-column grid, not a flexible HStack: same fix as - // the overview KPI row — greedy maxWidth:.infinity tiles let the - // flex solver re-divide width on every poll (numericText values - // change), which doesn't converge at large width and trips AppKit's - // Update-Constraints pass budget. A grid pins the columns. - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 5), spacing: 10) { + BalancedGrid(columnCounts: [5, 3, 2, 1], minimumColumnWidth: 170, spacing: 10) { // "—" for inactive numeric tiles instead of "0%" — "0%" // reads as "cache failed today / reasoning crashed" when // truth is "no data flowed through that path." Em-dash is @@ -53,7 +48,7 @@ struct HubPulseCard: View { + "\(signals.burnRate.windowMinutes)m" : "no activity yet", icon: "flame.fill", - accent: c.warm, + accent: theme.burnRateColor(signals.burnRate.costPerHour), active: burnActive, theme: theme ) @@ -187,10 +182,11 @@ struct PulseTile: View { Text(sub) .font(.system(size: 10, design: theme.fonts.bodyDesign)) .foregroundColor(bg.secondaryTextColor.opacity(active ? 1 : 0.70)) - .lineLimit(1) - .truncationMode(.tail) + .lineLimit(2, reservesSpace: true) + .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } + .accessibilityElement(children: .combine) .frame(maxWidth: .infinity, alignment: .leading) } .scaleEffect(hovered ? 1.015 : 1.0) diff --git a/packages/macos-bar/Sources/TokmeterBar/HubSettings.swift b/packages/macos-bar/Sources/TokmeterBar/HubSettings.swift index 169ef03..50026c0 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubSettings.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubSettings.swift @@ -33,6 +33,12 @@ struct HubSettingsPanel: View { ScrollView(.vertical, showsIndicators: true) { VStack(alignment: .leading, spacing: 20) { header.cascadeIn(delay: 0.04) + if let error = store.saveError { + Text(error) + .font(.system(size: 12, design: theme.fonts.bodyDesign)) + .foregroundColor(theme.statusDanger) + .frame(maxWidth: .infinity, alignment: .leading) + } themeSection.cascadeIn(delay: 0.12) refreshSection.cascadeIn(delay: 0.22) menubarSection.cascadeIn(delay: 0.30) @@ -55,7 +61,7 @@ struct HubSettingsPanel: View { Text("Settings") .font(.system(size: 24, weight: .bold, design: theme.fonts.heroDesign)) .foregroundColor(bg.primaryTextColor) - Text("Edits save to ~/.tokmeter/config.json. Takes effect instantly.") + Text("Saved edits take effect immediately. Settings are stored in ~/.tokmeter/config.json.") .font(.system(size: 12, design: theme.fonts.bodyDesign)) .foregroundColor(bg.secondaryTextColor) } diff --git a/packages/macos-bar/Sources/TokmeterBar/HubSidebar.swift b/packages/macos-bar/Sources/TokmeterBar/HubSidebar.swift index b5875fd..8a640b8 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubSidebar.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubSidebar.swift @@ -60,14 +60,7 @@ struct HubSidebar: View { } .padding(.horizontal, 10) - // A living ∞ mascot floats in the sidebar's breathing room — the - // one always-visible spot with real blank space, so the doodle is - // actually seen (unlike empty states on a data-rich account). - Spacer(minLength: 8) - TokMascot(theme: theme) - .frame(maxWidth: .infinity) - .padding(.vertical, 4) - Spacer(minLength: 8) + Spacer(minLength: 16) HubSidebarRow( section: .settings, @@ -175,9 +168,15 @@ struct HubSidebar: View { shimmerLine(width: 92, height: 22) shimmerLine(width: 120, height: 11) } else { - Text(Fmt.cost(loader.todayCost)) + Text("\(Fmt.number(loader.todayTokens)) tokens") + .font(.system(size: 22, weight: .bold, design: theme.fonts.valueDesign)) + .foregroundColor(bg.primaryTextColor) + .lineLimit(1) + Text(loader.statbarSignals?.costBasisToday.flatMap { basis in + basis.estimatedRecords > 0 ? Fmt.cost(basis.estimatedCost) : nil + } ?? "—") .font(.system(size: 24, weight: .heavy, design: theme.fonts.labelDesign)) - .foregroundColor(c.highlight) + .foregroundColor(theme.costInk) .contentTransition(.numericText()) // Single-pass text only. minimumScaleFactor is a TWO-pass // intrinsic-width measurement; on the 30s data poll it re-reports @@ -185,13 +184,17 @@ struct HubSidebar: View { // window's constraint pass — a prime driver of the delayed crash. .lineLimit(1) .truncationMode(.tail) + Text("Estimated API cost today") + .font(.system(size: 10, weight: .medium, design: theme.fonts.bodyDesign)) + .foregroundColor(bg.secondaryTextColor) + .help("Usage valued at model API rates. The overview's Today cost also includes tool reports.") HStack(spacing: 6) { if let burn = loader.statbarSignals?.burnRate.costPerHour, burn >= 0.01 { miniPill( icon: "flame.fill", text: Fmt.costPerHour(burn), - tint: c.warm + tint: theme.burnRateColor(burn) ) } if let cache = loader.statbarSignals?.cacheHitToday.canonicalRate, cache > 0 { diff --git a/packages/macos-bar/Sources/TokmeterBar/HubToolCallsCard.swift b/packages/macos-bar/Sources/TokmeterBar/HubToolCallsCard.swift index 0e26efd..920d3a0 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubToolCallsCard.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubToolCallsCard.swift @@ -78,8 +78,10 @@ struct ToolCallRow: View { Text(entry.tool) .font(.system(size: 11, weight: .medium, design: theme.fonts.labelDesign)) .foregroundColor(bg.primaryTextColor) - .frame(width: 100, alignment: .leading) - .lineLimit(1) + .frame(width: 140, alignment: .leading) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .help(entry.tool) GeometryReader { geo in ZStack(alignment: .leading) { RoundedRectangle(cornerRadius: 3) diff --git a/packages/macos-bar/Sources/TokmeterBar/HubView.swift b/packages/macos-bar/Sources/TokmeterBar/HubView.swift index 155e7df..ae0f6a7 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubView.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubView.swift @@ -5,9 +5,8 @@ // command reference, settings. Shares the same TokmeterLoader as the bar so // both surfaces refresh off a single timer. // -// Skeleton phase: sidebar + empty section panels. Data wiring, charts, and -// project drilldown land in follow-up commits. Everything here is themed -// against the same AppTheme the bar uses. +// Overview, project detail, command reference, and settings share the bar's +// telemetry and theme. import SwiftUI @@ -49,7 +48,7 @@ enum HubSection: String, CaseIterable, Identifiable { } /// The hub window's content root. Holds the selected section and lays out the -/// sidebar + detail panels inside a NavigationSplitView. +/// sidebar and detail panels in a fixed-sidebar HStack. struct HubView: View { @ObservedObject var loader: TokmeterLoader @@ -81,6 +80,9 @@ struct HubView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .background(hubBackground) } + .background { + if bg.usesMaterial { FrostedGlassBackground() } + } .environment(\.colorScheme, bg.isLight ? .light : .dark) .preferredColorScheme(bg.isLight ? .light : .dark) } @@ -134,7 +136,8 @@ struct HubView: View { @ViewBuilder private var hubBackground: some View { if bg.usesMaterial { - FrostedGlassBackground() + // The root supplies one continuous material behind sidebar and detail. + Color.clear } else { LinearGradient( colors: bg.gradientColors(), diff --git a/packages/macos-bar/Sources/TokmeterBar/HubYearHeatmap.swift b/packages/macos-bar/Sources/TokmeterBar/HubYearHeatmap.swift index 24ed58d..0e7a3bb 100644 --- a/packages/macos-bar/Sources/TokmeterBar/HubYearHeatmap.swift +++ b/packages/macos-bar/Sources/TokmeterBar/HubYearHeatmap.swift @@ -21,6 +21,7 @@ struct YearHeatmap: View { let theme: AppTheme @State private var hovered: String? + @State private var showDailyValues = false /// Cached grid + month-label keyed by `gridDateKey` (today's start-of-day /// string). 365 × 2 `Calendar.date(byAdding:)` calls used to fire every /// time the parent's 30s data poll re-rendered. Now: built once on @@ -85,6 +86,18 @@ struct YearHeatmap: View { private static let maxCell: CGFloat = 22 var body: some View { + VStack(alignment: .leading, spacing: 12) { + heatmap + HeatmapDailyValues(daily: recordedDays, theme: theme, isExpanded: $showDailyValues) + } + } + + private var recordedDays: [DailyUsage] { + let visibleDates = Set(grid.flatMap { $0 }.compactMap { $0 }.map(dateKey)) + return daily.filter { visibleDates.contains($0.date) }.sorted { $0.date > $1.date } + } + + private var heatmap: some View { // SELF-SIZING grid: each of the 7-tall columns is an equal-width slot // (maxWidth: .infinity) and each cell is square via aspectRatio, so the // grid reports its true height to the parent with NO GeometryReader and @@ -208,8 +221,59 @@ struct YearHeatmap: View { return f }() let dateStr = formatter.string(from: date) - if cost <= 0 { return "\(dateStr) — no activity" } - return String(format: "%@ — $%.2f", dateStr, cost) + let tokens = daily.first { $0.date == dateKey(date) }?.tokens ?? 0 + if cost <= 0 && tokens <= 0 { return "\(dateStr) — no activity" } + return "\(dateStr) — \(tokens.formatted()) tokens · \(Fmt.cost(cost)) cost" + } +} + +/// A native table provides keyboard row navigation and accessible column values +/// without turning every painted heatmap square into a separate Tab stop. +struct HeatmapDailyValues: View { + let daily: [DailyUsage] + let theme: AppTheme + @Binding var isExpanded: Bool + @State private var selection: DailyUsage.ID? + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + VStack(alignment: .leading, spacing: 8) { + Text("Recorded days only, newest first. Missing dates are not treated as zero usage.") + .font(.system(size: 10, design: theme.fonts.bodyDesign)) + .foregroundStyle(theme.backgroundMode.secondaryTextColor) + .fixedSize(horizontal: false, vertical: true) + if daily.isEmpty { + Text("No recorded days in this period.") + .foregroundStyle(theme.backgroundMode.secondaryTextColor) + } else { + Table(daily, selection: $selection) { + TableColumn("Date") { day in + Text(day.date) + } + .width(min: 94, ideal: 110) + TableColumn("Cost") { day in + Text(String(format: "$%.2f", day.cost)) + } + .width(min: 80, ideal: 100) + TableColumn("Tokens") { day in + Text(day.tokens.formatted()) + } + .width(min: 100, ideal: 140) + } + .tableStyle(.inset) + .frame(height: min(220, CGFloat(daily.count) * 28 + 34)) + .accessibilityLabel("Recorded daily usage") + } + } + .font(.system(size: 11, design: theme.fonts.bodyDesign)) + .foregroundStyle(theme.backgroundMode.primaryTextColor) + .padding(.top, 8) + } label: { + Text("Daily values · \(daily.count) recorded days") + .font(.system(size: 11, weight: .medium, design: theme.fonts.labelDesign)) + .foregroundStyle(theme.backgroundMode.primaryTextColor) + } + .tint(theme.colors.accent) } } @@ -234,14 +298,14 @@ private struct HeatmapCellTooltip: View { .foregroundColor(bg.secondaryTextColor) } else { row(label: "Cost", value: Fmt.cost(day.cost)) - row(label: "Tokens", value: Fmt.number(day.tokens)) + row(label: "Tokens", value: day.tokens.formatted()) } } .padding(.horizontal, 10) .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 8) - .fill(.ultraThinMaterial) + .fill(bg.surfaceColor) .overlay( RoundedRectangle(cornerRadius: 8) .stroke(c.accent.opacity(0.35), lineWidth: 1) @@ -249,6 +313,7 @@ private struct HeatmapCellTooltip: View { .shadow(color: Color.black.opacity(bg.isLight ? 0.12 : 0.35), radius: 6, y: 2) ) .fixedSize() + .allowsHitTesting(false) } @ViewBuilder diff --git a/packages/macos-bar/Sources/TokmeterBar/NodeToolchain.swift b/packages/macos-bar/Sources/TokmeterBar/NodeToolchain.swift index eda8766..4652796 100644 --- a/packages/macos-bar/Sources/TokmeterBar/NodeToolchain.swift +++ b/packages/macos-bar/Sources/TokmeterBar/NodeToolchain.swift @@ -9,6 +9,11 @@ struct NodeToolchain: Equatable { static func resolve(home: String = NSHomeDirectory(), fileManager: FileManager = .default, systemDirectories: [String] = ["/opt/homebrew/bin", "/usr/local/bin"]) -> NodeToolchain? { + candidates(home: home, fileManager: fileManager, systemDirectories: systemDirectories).first + } + + static func candidates(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"), @@ -23,12 +28,41 @@ struct NodeToolchain: Equatable { .sorted { $0.compare($1, options: .numeric) == .orderedDescending } .map { root + "/" + $0 + suffix } ?? [] } - return firstAvailable(directories: directories, isExecutable: fileManager.isExecutableFile(atPath:)) + return available(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) } + available(directories: directories, isExecutable: isExecutable).first + } + + private static func available(directories: [String], isExecutable: (String) -> Bool) -> [NodeToolchain] { + var seen = Set() + return directories.filter { + seen.insert($0).inserted && isExecutable($0 + "/node") && isExecutable($0 + "/npx") + }.map { NodeToolchain(binDirectory: $0) } + } + + /// An old system Node or broken version-manager shim must not hide a + /// working installation. Probe in preference order, with one total budget + /// as well as a per-child timeout; never run a shell profile or npm here. + static func firstSupported(candidates: [NodeToolchain], environment: [String: String], + timeout: TimeInterval = 10, probeTimeout: TimeInterval = 2) async -> NodeToolchain? { + let deadline = ProcessInfo.processInfo.systemUptime + timeout + for candidate in candidates { + let remaining = deadline - ProcessInfo.processInfo.systemUptime + guard remaining > 0, !Task.isCancelled else { return nil } + do { + let version = try await SubprocessRunner.run( + executable: candidate.node, arguments: ["--version"], + environment: candidate.environment(base: environment), + timeout: min(probeTimeout, remaining)) + if let major = majorVersion(version), major >= 18 { return candidate } + } catch { + // Missing runtimes behind executable shims and hung probes + // are candidate failures, not proof that Node is unavailable. + } + } + return nil } static func majorVersion(_ version: String) -> Int? { diff --git a/packages/macos-bar/Sources/TokmeterBar/PrismSurface.swift b/packages/macos-bar/Sources/TokmeterBar/PrismSurface.swift new file mode 100644 index 0000000..cf457ed --- /dev/null +++ b/packages/macos-bar/Sources/TokmeterBar/PrismSurface.swift @@ -0,0 +1,64 @@ +import SwiftUI + +/// Dark surfaces keep the spectrum at their edges, clear of the data. +struct PrismPanel: View { + let colors: ThemeColors + var cornerRadius: CGFloat = 16 + + private var rim: LinearGradient { + LinearGradient(colors: [colors.secondary.opacity(0.72), colors.accent.opacity(0.18), + colors.warm.opacity(0.12), colors.accent.opacity(0.50)], + startPoint: .topLeading, endPoint: .bottomTrailing) + } + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(LinearGradient(colors: [Color(red: 0.085, green: 0.085, blue: 0.145), + Color(red: 0.045, green: 0.05, blue: 0.09)], + startPoint: .topLeading, endPoint: .bottomTrailing)) + .overlay(RoundedRectangle(cornerRadius: cornerRadius).strokeBorder(rim, lineWidth: 0.8)) + .overlay(alignment: .top) { + LinearGradient(colors: [.clear, colors.secondary.opacity(0.75), colors.accent.opacity(0.7), .clear], + startPoint: .leading, endPoint: .trailing) + .frame(height: 1) + .padding(.horizontal, cornerRadius) + } + } +} + +struct PrismHeroBackdrop: View { + let colors: ThemeColors + + var body: some View { + ZStack(alignment: .bottom) { + LinearGradient(colors: [Color(red: 0.08, green: 0.06, blue: 0.16), + Color(red: 0.035, green: 0.055, blue: 0.10)], + startPoint: .topLeading, endPoint: .bottomTrailing) + GeometryReader { geometry in + let w = geometry.size.width + let h = geometry.size.height + Path { path in + path.move(to: CGPoint(x: w * 0.57, y: -h * 0.2)) + path.addLine(to: CGPoint(x: w * 0.88, y: h * 0.53)) + path.addLine(to: CGPoint(x: w * 0.62, y: h * 1.3)) + path.closeSubpath() + } + .fill(LinearGradient(colors: [colors.secondary.opacity(0.10), colors.accent.opacity(0.025)], + startPoint: .top, endPoint: .bottom)) + Path { path in + path.move(to: CGPoint(x: w * 0.57, y: -h * 0.2)) + path.addLine(to: CGPoint(x: w * 0.88, y: h * 0.53)) + path.addLine(to: CGPoint(x: w * 0.62, y: h * 1.3)) + path.move(to: CGPoint(x: w * 0.88, y: h * 0.53)) + path.addLine(to: CGPoint(x: w * 1.1, y: h * 0.38)) + } + .stroke(LinearGradient(colors: [colors.secondary.opacity(0.4), colors.accent.opacity(0.28), .clear], + startPoint: .top, endPoint: .bottom), lineWidth: 0.8) + } + LinearGradient(colors: [colors.secondary, colors.warm, colors.accent], + startPoint: .leading, endPoint: .trailing) + .frame(height: 2) + } + .allowsHitTesting(false) + } +} diff --git a/packages/macos-bar/Sources/TokmeterBar/SettingsPopover.swift b/packages/macos-bar/Sources/TokmeterBar/SettingsPopover.swift index 2e4a3a8..553a375 100644 --- a/packages/macos-bar/Sources/TokmeterBar/SettingsPopover.swift +++ b/packages/macos-bar/Sources/TokmeterBar/SettingsPopover.swift @@ -14,6 +14,7 @@ struct SettingsPopover: View { @Binding var theme: AppTheme @ObservedObject var loader: TokmeterLoader @ObservedObject private var configStore = HubConfigStore.shared + @ObservedObject private var dashboard = WebDashboardController.shared var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -43,12 +44,22 @@ struct SettingsPopover: View { Divider() - Button(action: openWebPanel) { - Label("Open web dashboard", systemImage: "safari") + Button { Task { await dashboard.open() } } label: { + Label(dashboard.isStarting ? "Starting dashboard…" : "Open web dashboard", systemImage: "safari") .font(.system(size: 11, design: .rounded)) } .buttonStyle(.borderless) + if dashboard.isStarting || dashboard.isRunning { + Button(dashboard.isStarting ? "Cancel dashboard startup" : "Stop web dashboard") { + dashboard.stop() + } + .buttonStyle(.borderless) + } + if let error = dashboard.error { + Text(error).font(.system(size: 11)).foregroundStyle(.red).fixedSize(horizontal: false, vertical: true) + } + Button(action: openConfigFile) { Label("Open Config File", systemImage: "doc.text") .font(.system(size: 11, design: .rounded)) @@ -249,12 +260,6 @@ struct SettingsPopover: View { // MARK: - Actions - private func openWebPanel() { - if let url = URL(string: "http://localhost:3000") { - NSWorkspace.shared.open(url) - } - } - /// Open the user's `~/.tokmeter/config.json` in the default editor. If /// that path has been replaced with a symlink escaping ~/.tokmeter/ /// (e.g. to `~/.ssh/id_rsa`), we silently refuse rather than leak the diff --git a/packages/macos-bar/Sources/TokmeterBar/ShellArgument.swift b/packages/macos-bar/Sources/TokmeterBar/ShellArgument.swift new file mode 100644 index 0000000..5b147d5 --- /dev/null +++ b/packages/macos-bar/Sources/TokmeterBar/ShellArgument.swift @@ -0,0 +1,8 @@ +import Foundation + +/// One literal argument for POSIX shell commands copied to the clipboard. +enum ShellArgument { + static func quote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'" + } +} diff --git a/packages/macos-bar/Sources/TokmeterBar/SignalsRibbon.swift b/packages/macos-bar/Sources/TokmeterBar/SignalsRibbon.swift index 6c9aa56..94e8ee7 100644 --- a/packages/macos-bar/Sources/TokmeterBar/SignalsRibbon.swift +++ b/packages/macos-bar/Sources/TokmeterBar/SignalsRibbon.swift @@ -160,28 +160,16 @@ struct SignalsRibbon: View { .help(help) } - /// Animated burn-rate chip. The flame uses SF Symbols' `.variableColor` - /// effect to feel like it's actually burning — the hierarchical layers - /// pulse through the icon like real fire shifting between layers of fuel. - /// Speed scales with intensity: cold = no flicker, warm = lazy flicker, - /// hot = fast flicker, blazing = full burn. - /// - /// The number uses `.contentTransition(.numericText())` so it rolls between - /// values instead of snapping — Apple's canonical numeric reveal. + /// Keep the small status glyph at full contrast; animated variable-color + /// layers can dim the entire flame against Terminal's black background. @ViewBuilder private func burnChip(_ rate: BurnRate) -> some View { let cph = rate.costPerHour - let intensity = burnIntensity(cph) HStack(spacing: 4) { Image(systemName: "flame.fill") .font(.system(size: 10, weight: .semibold)) - .symbolRenderingMode(.hierarchical) - .foregroundColor(burnColor(cph)) - .symbolEffect( - .variableColor.iterative.reversing, - options: .speed(intensity.symbolSpeed), - isActive: intensity.flickering - ) + .symbolRenderingMode(.monochrome) + .foregroundColor(theme.burnRateColor(cph)) Text(Fmt.costPerHour(cph)) .font(.system(size: 11, weight: .medium, design: theme.fonts.bodyDesign)) .foregroundColor(theme.backgroundMode.primaryTextColor.opacity(0.85)) @@ -195,32 +183,6 @@ struct SignalsRibbon: View { ) } - /// How "alive" the flame should look. Mapped from $/hr; thresholds match - /// `burnColor` so visuals stay in sync (cool color → calm flicker, hot - /// color → fast flicker). Idle = no animation at all so a quiet $0.30/hr - /// trickle doesn't pretend to be a fire. - private struct BurnIntensity { - let flickering: Bool - let symbolSpeed: Double - } - - private func burnIntensity(_ cph: Double) -> BurnIntensity { - if cph >= 20 { return .init(flickering: true, symbolSpeed: 1.7) } - if cph >= 10 { return .init(flickering: true, symbolSpeed: 1.3) } - if cph >= 2 { return .init(flickering: true, symbolSpeed: 0.9) } - return .init(flickering: false, symbolSpeed: 1.0) - } - - /// Burn-rate color: ramps from green (cold) → amber (warm) → red (hot). - /// 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 theme.statusDanger } - if costPerHour >= 10 { return theme.statusWarning } - if costPerHour >= 2 { return c.secondary } - 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 { diff --git a/packages/macos-bar/Sources/TokmeterBar/StatCards.swift b/packages/macos-bar/Sources/TokmeterBar/StatCards.swift index a6539c0..e2eb1fc 100644 --- a/packages/macos-bar/Sources/TokmeterBar/StatCards.swift +++ b/packages/macos-bar/Sources/TokmeterBar/StatCards.swift @@ -56,8 +56,9 @@ struct StatsGrid: View { label: "TOKENS", value: Fmt.number(loader.totalTokens), role: c.secondary, - delta: weekDelta { Double($0.tokens) }, + delta: recordedDayTrend(metric: .tokens) { Double($0.tokens) }, sparkValues: settledDaily.map { Double($0.tokens) }, + sparkDays: settledDaily, theme: theme, isWarming: loader.isWarming, index: 0 @@ -67,8 +68,9 @@ struct StatsGrid: View { label: "COST TOTAL", value: Fmt.cost(loader.totalCost), role: c.highlight, - delta: weekDelta { $0.cost }, + delta: recordedDayTrend(metric: .cost) { $0.cost }, sparkValues: settledDaily.map { $0.cost }, + sparkDays: settledDaily, theme: theme, isWarming: loader.isWarming, index: 1 @@ -93,6 +95,7 @@ struct StatsGrid: View { role: paceRole(for: multiple), delta: nil, sparkValues: loader.recentDaily.map { $0.cost }, + sparkDays: loader.recentDaily, theme: theme, isWarming: false, index: 2 @@ -104,7 +107,7 @@ struct StatsGrid: View { value: "\(s.longestStreak)d", role: c.tertiary, delta: nil, - sparkValues: streakSpark(for: s), + sparkValues: [], theme: theme, isWarming: false, index: 2 @@ -132,21 +135,17 @@ struct StatsGrid: View { /// trend — never a half-day-vs-full-day comparison — so it can't make a /// frozen lifetime total look like it's depleting. Returns nil when there /// aren't two settled days or the prior day is near-zero. - private func weekDelta(extract: (DailyUsage) -> Double) -> Double? { - let days = settledDaily + private func recordedDayTrend(metric: RecordedDayTrend.Metric, extract: (DailyUsage) -> Double) -> RecordedDayTrend? { + let days = settledDaily.filter { $0.date < todayKey } guard days.count >= 2 else { return nil } let latest = extract(days[days.count - 1]) let prior = extract(days[days.count - 2]) guard prior > 0.0001 else { return nil } - return ((latest - prior) / prior) * 100 + return RecordedDayTrend(percent: ((latest - prior) / prior) * 100, metric: metric, + previousDate: days[days.count - 2].date, latestDate: days[days.count - 1].date) } - /// A visually-balanced sparkline for the streak card — a gently rising - /// line whose slope tracks activity density. Not raw data, but a signal. - private func streakSpark(for s: StatsData) -> [Double] { - let activeFraction = min(Double(s.activeDays) / 30.0, 1.0) - return (0..<7).map { 0.3 + activeFraction * Double($0) / 6.0 } - } + } // MARK: - StatCard @@ -158,8 +157,9 @@ struct StatCard: View { let label: String let value: String let role: Color - let delta: Double? + let delta: RecordedDayTrend? let sparkValues: [Double] + var sparkDays: [DailyUsage] = [] let theme: AppTheme let isWarming: Bool /// Card's position in the row (0..2). Controls enter-animation stagger. @@ -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, theme: theme) + DeltaPill(trend: d, theme: theme) } } .padding(.horizontal, 10) @@ -217,6 +217,7 @@ struct StatCard: View { // Sparkline — scroll into view with spring-eased draw-in. InlineSparkline(values: sparkValues, color: role, progress: sparkProgress) .frame(height: 18) + .modifier(SparklineUsageHover(days: sparkDays, theme: theme)) .padding(.horizontal, 8) .padding(.bottom, 8) } @@ -246,6 +247,7 @@ struct StatCard: View { .animation(.spring(response: 0.28, dampingFraction: 0.72), value: hovered) .animation(.spring(response: 0.18, dampingFraction: 0.62), value: pressed) .onHover { hovered = $0 } + .zIndex(hovered ? 10 : 0) .onLongPressGesture(minimumDuration: 0, maximumDistance: .infinity, pressing: { isPressing in pressed = isPressing }, perform: {}) @@ -297,11 +299,28 @@ struct IconBadge: View { // MARK: - Delta pill -/// Small up/down-percentage pill. Green for positive, red for negative. -/// System semantic colors aren't used because we want consistent hue across -/// light and dark surfaces. -struct DeltaPill: View { +struct RecordedDayTrend { + enum Metric { case tokens, cost } let percent: Double + let metric: Metric + let previousDate: String + let latestDate: String + + var description: String { + let name = metric == .cost ? "Daily cost" : "Daily tokens" + let direction = percent >= 0 ? "up" : "down" + return "\(name) \(direction) \(String(format: "%.1f%%", abs(percent))): \(latestDate) vs \(previousDate). Last two completed recorded days; the card value is the lifetime total." + } + + func color(theme: AppTheme) -> Color { + metric == .cost && percent > 0 ? theme.statusWarning : theme.backgroundMode.secondaryTextColor + } +} + +/// Direction does not imply success. Rising cost uses warning ink; other +/// changes are neutral and expose their actual comparison dates. +struct DeltaPill: View { + let trend: RecordedDayTrend let theme: AppTheme /// Signs-flipped detector: when the sign changes (e.g. trend reversed), @@ -309,12 +328,12 @@ struct DeltaPill: View { @State private var pulseScale: CGFloat = 1.0 var body: some View { - let positive = percent >= 0 - let color: Color = positive ? theme.statusSuccess : theme.statusDanger + let positive = trend.percent >= 0 + let color = trend.color(theme: theme) HStack(spacing: 2) { Image(systemName: positive ? "arrow.up" : "arrow.down") .font(.system(size: 7, weight: .bold)) - Text(String(format: "%.1f%%", abs(percent))) + Text(String(format: "%.1f%%", abs(trend.percent))) .font(.system(size: 9, weight: .semibold, design: .rounded)) } .foregroundColor(color) @@ -324,6 +343,9 @@ struct DeltaPill: View { Capsule().fill(Color.white.opacity(theme.backgroundMode.isLight ? 0.5 : 0)) .overlay(Capsule().fill(color.opacity(theme.backgroundMode.isLight ? 0.12 : 0.18))) } + .help(trend.description) + .accessibilityElement(children: .ignore) + .accessibilityLabel(trend.description) .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/Theme+Modes.swift b/packages/macos-bar/Sources/TokmeterBar/Theme+Modes.swift index 0914b5a..564cd19 100644 --- a/packages/macos-bar/Sources/TokmeterBar/Theme+Modes.swift +++ b/packages/macos-bar/Sources/TokmeterBar/Theme+Modes.swift @@ -13,14 +13,14 @@ import SwiftUI enum BackgroundMode { case dark // Standard macOS dark background case darkGradient // Subtle top→bottom dark gradient - case deepIndigo // Near-black with cool blue tint (Nocturne) + case deepIndigo // Neutral graphite (Carbon; legacy mode name) case lightCream // Light ivory/cream (Daylight) case deepMagenta // Very dark purple base (Synthwave) case tactical // Very dark with green-black tint (HUD) case terminalBlack // True black (Terminal) case paperWarm // Warm off-white editorial (Paper) case glassBlur // Translucent material — works over wallpaper (Glass) - case auroraDrift // Deep night with slow-drifting northern-lights gradient + case auroraDrift // Deep petrol (Lagoon; legacy mode name) case blueprintGrid // Cream-paper bg with cyan grid lines (Blueprint) case noiseYellow // Canary-yellow flat surface (Noise / neobrutalist) case mintPeach // Warm peach surface (Mint / soft editorial) @@ -28,10 +28,12 @@ enum BackgroundMode { /// The base surface color painted as the popover's background. var surfaceColor: Color { switch self { - case .dark, .darkGradient: + case .dark: return Color(NSColor.windowBackgroundColor) + case .darkGradient: + return Color(red: 0.035, green: 0.04, blue: 0.07) case .deepIndigo: - return Color(red: 0.04, green: 0.05, blue: 0.10) + return Color(red: 0.065, green: 0.065, blue: 0.07) case .lightCream: return Color(red: 0.975, green: 0.955, blue: 0.925) case .deepMagenta: @@ -46,7 +48,7 @@ enum BackgroundMode { case .glassBlur: 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) + return Color(red: 0.025, green: 0.09, blue: 0.095) case .blueprintGrid: return Color(red: 0.955, green: 0.945, blue: 0.910) case .noiseYellow: @@ -88,7 +90,7 @@ enum BackgroundMode { case .darkGradient: return [base, base.opacity(0.92)] case .deepIndigo: - return [base, Color(red: 0.02, green: 0.03, blue: 0.07)] + return [base, base] case .deepMagenta: return [base, Color(red: 0.04, green: 0.02, blue: 0.08)] case .tactical: @@ -102,7 +104,7 @@ enum BackgroundMode { case .glassBlur: return [base, base.opacity(0.55)] case .auroraDrift: - return [base, Color(red: 0.01, green: 0.02, blue: 0.05)] + return [base, base] case .blueprintGrid: return [base, Color(red: 0.942, green: 0.928, blue: 0.890)] case .noiseYellow: @@ -120,14 +122,14 @@ enum BackgroundMode { /// How the giant "$48.95 / today" header renders. Branch on this in the view. enum HeroMode { case nebulaGradient // Classic purple→magenta→orange diagonal - case nocturneCalm // Deep indigo solid with a faint accent glow + case nocturneCalm // Carbon: flat graphite and copper rule case daylightSoft // Cream with soft color wave; dark foreground case synthwaveHorizon // Sunset horizon + perspective grid overlay case hudScanlines // Dark panel with scanline + OPERATIONAL pill case terminalCRT // Pure black + dense scanlines + green phosphor + cursor case paperEditorial // Cream, large serif display number, hairline rule case glassMaterial // Translucent material + soft tint + glossy highlight - case auroraDrift // Slow-drifting aurora gradient — motion as identity + case auroraDrift // Lagoon: static teal gradient and mint rule case blueprintTechnical // Hairline cyan frame, mono digits, drafting feel case noiseBrutal // Heavy black sans on canary yellow, brutalist case mintEditorial // Peach surface, lime accent, hairline underline @@ -137,15 +139,15 @@ enum HeroMode { /// How KPI cards and list rows render — fill, border, corner radius, shadow. enum CardMode { - case glossyDark // Nebula: color-tinted fill with soft glow - case flatDark // Nocturne: gray-tinted flat fill + case glossyDark // Prism: shared dark surface and spectrum rim + case flatDark // Carbon: flat graphite fill case lightPaper // Daylight: white fill with soft shadow case neonOutlined // Synthwave: neon border, minimal fill, inner glow case hudPanel // HUD: rectangular, tactical, mono values case terminalPanel // Terminal: black fill, green hairline border, mono case paperHairline // Paper: no fill, thin black hairline border, serif case glassFrost // Glass: ultra-thin material with subtle border - case auroraGlass // Aurora: thin-material on the drifting bg, soft glow + case auroraGlass // Lagoon: opaque teal panels case blueprintFrame // Blueprint: cyan hairline frame, no fill, mono case noiseStuck // Noise: solid color + 2pt black border + hard offset shadow case mintHairline // Mint: peach fill + 0.5pt black hairline, no shadow @@ -156,7 +158,10 @@ enum CardMode { case .hudPanel, .terminalPanel, .blueprintFrame: return 4 case .paperHairline: return 2 case .neonOutlined: return 10 - case .glassFrost, .auroraGlass: return 14 + case .glassFrost: return 14 + case .auroraGlass: return 18 + case .flatDark: return 6 + case .glossyDark: return 16 case .noiseStuck: return 8 case .mintHairline: return 14 default: return 12 diff --git a/packages/macos-bar/Sources/TokmeterBar/Theme.swift b/packages/macos-bar/Sources/TokmeterBar/Theme.swift index 9289a49..05b20be 100644 --- a/packages/macos-bar/Sources/TokmeterBar/Theme.swift +++ b/packages/macos-bar/Sources/TokmeterBar/Theme.swift @@ -11,8 +11,8 @@ // The user picks a theme in Settings; persisted via @AppStorage("appTheme"). // // Themes: -// • Nebula — purple→magenta→orange gradient, glossy dark cards (default) -// • Nocturne — deep indigo, calm, no gradient, sparkline-friendly +// • Prism — dark glass, spectrum edges, gold monetary figures (default) +// • Carbon — graphite panels, copper values, monospaced figures // • Daylight — cream/ivory light theme for light-mode Mac users // • Synthwave — retrofuture horizon sun + grid + neon-outlined cards // • HUD — tactical sci-fi with mono typography and status overlays @@ -40,6 +40,13 @@ struct ThemeColors { /// Resolve from the selected theme, independently of the menu window's native /// appearance. MenuBarExtra can retain Dark Aqua while displaying light Glass. extension AppTheme { + /// Shared burn-rate status for the popup, Hub tile, and sidebar badge. + func burnRateColor(_ costPerHour: Double) -> Color { + if costPerHour >= 20 { return statusDanger } + if costPerHour >= 10 { return statusWarning } + return statusSuccess + } + var statusDanger: Color { backgroundMode.isLight ? Color(.sRGB, red: 0.35, green: 0.025, blue: 0.04) @@ -62,15 +69,15 @@ extension AppTheme { // MARK: - Theme enum enum AppTheme: String, CaseIterable, Identifiable { - case nebula - case nocturne + case nebula // Prism; retain the stored identifier for existing preferences. + case nocturne // Carbon; keep the stored identifier for existing preferences. case daylight case synthwave case hud case terminal case paper case glass - case aurora + case aurora // Lagoon; keep the stored identifier for existing preferences. case blueprint case noise case mint @@ -93,15 +100,15 @@ enum AppTheme: String, CaseIterable, Identifiable { var displayName: String { switch self { - case .nebula: return "Nebula" - case .nocturne: return "Nocturne" + case .nebula: return "Prism" + case .nocturne: return "Carbon" case .daylight: return "Daylight" case .synthwave: return "Synthwave" case .hud: return "HUD" case .terminal: return "Terminal" case .paper: return "Paper" case .glass: return "Glass" - case .aurora: return "Aurora" + case .aurora: return "Lagoon" case .blueprint: return "Blueprint" case .noise: return "Noise" case .mint: return "Mint" @@ -110,15 +117,15 @@ enum AppTheme: String, CaseIterable, Identifiable { var tagline: String { switch self { - case .nebula: return "Warm purple identity" - case .nocturne: return "Calm dark focus" + case .nebula: return "Iridescent edges, dark glass" + case .nocturne: return "Graphite, copper, precise type" case .daylight: return "Cream daytime view" case .synthwave: return "Retrofuture neon" case .hud: return "Tactical panel" case .terminal: return "CRT phosphor retro" case .paper: return "Editorial serif" case .glass: return "Frosted glass" - case .aurora: return "Northern lights, drifting" + case .aurora: return "Deep teal and clear mint" case .blueprint: return "Drafting paper, cyan grid" case .noise: return "Neobrutalist canary yellow" case .mint: return "Warm peach + lime accent" @@ -127,15 +134,15 @@ enum AppTheme: String, CaseIterable, Identifiable { var icon: String { switch self { - case .nebula: return "sparkles" - case .nocturne: return "moon.stars.fill" + case .nebula: return "diamond.fill" + case .nocturne: return "square.stack.3d.up.fill" case .daylight: return "sun.max.fill" case .synthwave: return "sunrise.fill" case .hud: return "scope" case .terminal: return "terminal.fill" case .paper: return "doc.text.fill" case .glass: return "circle.lefthalf.filled" - case .aurora: return "sparkle" + case .aurora: return "water.waves" case .blueprint: return "ruler.fill" case .noise: return "exclamationmark.octagon.fill" case .mint: return "leaf.fill" @@ -202,17 +209,26 @@ enum AppTheme: String, CaseIterable, Identifiable { } } + /// Opaque monetary ink with contrast on each theme's light or dark surface. + var costInk: Color { + if self == .nocturne { return Color(.sRGB, red: 1.0, green: 0.72, blue: 0.51) } + if self == .aurora { return Color(.sRGB, red: 1.0, green: 0.75, blue: 0.64) } + return backgroundMode.isLight + ? Color(.sRGB, red: 0.29, green: 0.13, blue: 0.005) + : Color(.sRGB, red: 1.0, green: 0.82, blue: 0.42) + } + /// Type personality for each role. The view reads this to pick fonts. var fonts: ThemeFonts { switch self { case .nebula: - return ThemeFonts(heroDesign: .rounded, heroWeight: .bold, - valueDesign: .rounded, valueWeight: .bold, - labelDesign: .rounded, bodyDesign: .rounded) + return ThemeFonts(heroDesign: .default, heroWeight: .bold, + valueDesign: .default, valueWeight: .bold, + labelDesign: .default, bodyDesign: .default) case .nocturne: - return ThemeFonts(heroDesign: .rounded, heroWeight: .semibold, - valueDesign: .rounded, valueWeight: .semibold, - labelDesign: .rounded, bodyDesign: .rounded) + return ThemeFonts(heroDesign: .monospaced, heroWeight: .semibold, + valueDesign: .monospaced, valueWeight: .semibold, + labelDesign: .default, bodyDesign: .default) case .daylight: return ThemeFonts(heroDesign: .default, heroWeight: .bold, valueDesign: .default, valueWeight: .bold, @@ -241,7 +257,7 @@ enum AppTheme: String, CaseIterable, Identifiable { valueDesign: .default, valueWeight: .semibold, labelDesign: .default, bodyDesign: .default) case .aurora: - // Soft rounded — the bg is doing the heavy visual lifting + // Lagoon: rounded values and clear body labels. return ThemeFonts(heroDesign: .rounded, heroWeight: .semibold, valueDesign: .rounded, valueWeight: .semibold, labelDesign: .rounded, bodyDesign: .rounded) diff --git a/packages/macos-bar/Sources/TokmeterBar/ThemePalettes.swift b/packages/macos-bar/Sources/TokmeterBar/ThemePalettes.swift index 5a5360b..283b672 100644 --- a/packages/macos-bar/Sources/TokmeterBar/ThemePalettes.swift +++ b/packages/macos-bar/Sources/TokmeterBar/ThemePalettes.swift @@ -18,25 +18,25 @@ extension AppTheme { var palette: ThemeColors { switch self { case .nebula: - // Purple → magenta → orange. Classic TOKMETER identity. + // Prism: violet/cyan spectrum, pale gold for monetary values. return ThemeColors( - primary: Color(red: 0.295, green: 0.175, blue: 0.705), // #4b2cb4 deep purple - secondary: Color(red: 0.568, green: 0.259, blue: 0.890), // #9142e3 electric violet - accent: Color(red: 0.710, green: 0.408, blue: 0.980), // #b568fa soft violet - highlight: Color(red: 0.984, green: 0.600, blue: 0.180), // #fb992e amber - warm: Color(red: 0.992, green: 0.420, blue: 0.322), // #fd6b52 warm orange - tertiary: Color(red: 0.098, green: 0.816, blue: 0.675) // #19d0ac teal + primary: Color(red: 0.22, green: 0.12, blue: 0.46), + secondary: Color(red: 0.76, green: 0.66, blue: 1.00), + accent: Color(red: 0.43, green: 0.88, blue: 1.00), + highlight: Color(red: 1.00, green: 0.82, blue: 0.48), + warm: Color(red: 0.96, green: 0.55, blue: 0.76), + tertiary: Color(red: 0.38, green: 0.94, blue: 0.80) ) case .nocturne: - // Deep indigo, calm lavenders, sparkline-friendly. No loud colors. + // Carbon: neutral graphite, chalk data, copper monetary emphasis. return ThemeColors( - primary: Color(red: 0.102, green: 0.122, blue: 0.212), // #1a1f36 midnight - secondary: Color(red: 0.498, green: 0.525, blue: 0.678), // #7f86ad soft lavender - accent: Color(red: 0.376, green: 0.647, blue: 0.980), // #60a5fa electric blue - highlight: Color(red: 0.957, green: 0.894, blue: 0.757), // #f4e4c1 cream highlight - warm: Color(red: 0.878, green: 0.478, blue: 0.371), // #e07a5f muted coral - tertiary: Color(red: 0.529, green: 0.659, blue: 0.471) // #87a878 sage + primary: Color(red: 0.105, green: 0.105, blue: 0.11), + secondary: Color(red: 0.88, green: 0.89, blue: 0.90), + accent: Color(red: 0.83, green: 0.85, blue: 0.87), + highlight: Color(red: 1.00, green: 0.72, blue: 0.51), + warm: Color(red: 0.87, green: 0.57, blue: 0.39), + tertiary: Color(red: 0.70, green: 0.75, blue: 0.73) ) case .daylight: @@ -112,16 +112,14 @@ extension AppTheme { ) case .aurora: - // Northern-lights palette — deep teal, electric green, soft violet, - // with a warm coral highlight so the cost number doesn't melt into - // the cool background. + // Lagoon: deep petrol with mint data and peach monetary emphasis. return ThemeColors( - primary: Color(red: 0.055, green: 0.255, blue: 0.353), // #0e4159 deep teal - secondary: Color(red: 0.180, green: 0.792, blue: 0.694), // #2ecaa3 aurora green - accent: Color(red: 0.541, green: 0.482, blue: 0.945), // #8a7af1 electric violet - highlight: Color(red: 0.984, green: 0.722, blue: 0.420), // #fbb86b warm coral - warm: Color(red: 0.961, green: 0.553, blue: 0.420), // #f58d6b sunset coral - tertiary: Color(red: 0.412, green: 0.871, blue: 0.847) // #69ded8 light teal + primary: Color(red: 0.025, green: 0.20, blue: 0.19), + secondary: Color(red: 0.40, green: 0.91, blue: 0.76), + accent: Color(red: 0.32, green: 0.82, blue: 0.79), + highlight: Color(red: 1.00, green: 0.75, blue: 0.64), + warm: Color(red: 0.62, green: 0.88, blue: 0.69), + tertiary: Color(red: 0.77, green: 0.86, blue: 0.56) ) case .blueprint: diff --git a/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader+CLIFallback.swift b/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader+CLIFallback.swift index f8ff963..cf74391 100644 --- a/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader+CLIFallback.swift +++ b/packages/macos-bar/Sources/TokmeterBar/TokmeterLoader+CLIFallback.swift @@ -55,7 +55,8 @@ extension TokmeterLoader { /// after forking the real daemon). func ensureDaemonStarted() { guard !isStartingDaemon else { return } - guard let toolchain = NodeToolchain.resolve() else { + let candidates = NodeToolchain.candidates() + guard !candidates.isEmpty else { self.lastError = "Install Node.js 18 or later, then choose Retry. Tokmeter needs Node to run its local usage service." self.isWarming = false @@ -69,10 +70,10 @@ extension TokmeterLoader { 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 { + guard let toolchain = await NodeToolchain.firstSupported( + candidates: candidates, environment: ProcessInfo.processInfo.environment) else { self.needsNodeSetup = true - throw DaemonError.networkError("Node.js 18 or later is required. Update Node and choose Retry.") + throw DaemonError.networkError("No working Node.js 18 or later was found. Update Node and choose Retry.") } // `daemon start` forks a detached child and returns fast; the // child becomes the long-lived daemon. This invocation never diff --git a/packages/macos-bar/Sources/TokmeterBar/WebDashboardController.swift b/packages/macos-bar/Sources/TokmeterBar/WebDashboardController.swift new file mode 100644 index 0000000..4ad257c --- /dev/null +++ b/packages/macos-bar/Sources/TokmeterBar/WebDashboardController.swift @@ -0,0 +1,121 @@ +import AppKit +import Foundation + +/// Owns only the optional dashboard child. The usage daemon has its own lifecycle. +@MainActor +final class WebDashboardController: ObservableObject { + static let shared = WebDashboardController() + @Published private(set) var isRunning = false + @Published private(set) var isStarting = false + @Published private(set) var error: String? + private var process: Process? + private var input: Pipe? + private var generation = UUID() + private var terminationObserver: NSObjectProtocol? + private let resources: URL? + private let port: Int + private let openURL: (URL) -> Void + private let resolveToolchain: () async -> NodeToolchain? + + init(resources: URL? = Bundle.main.resourceURL, port: Int = 3000, + openURL: @escaping (URL) -> Void = { NSWorkspace.shared.open($0) }, + resolveToolchain: @escaping () async -> NodeToolchain? = { + await NodeToolchain.firstSupported(candidates: NodeToolchain.candidates(), + environment: ProcessInfo.processInfo.environment) + }) { + self.resources = resources + self.port = port + self.openURL = openURL + self.resolveToolchain = resolveToolchain + terminationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.stop() } + } + } + + deinit { + if let terminationObserver { NotificationCenter.default.removeObserver(terminationObserver) } + try? input?.fileHandleForWriting.close() + } + + var url: URL { URL(string: "http://127.0.0.1:\(port)/")! } + + func open() async { + guard !isStarting else { return } + if isRunning, process?.isRunning == true { + openURL(url) + return + } + isStarting = true + error = nil + let attempt = UUID() + generation = attempt + defer { if generation == attempt { isStarting = false } } + guard let assets = resources?.appendingPathComponent("Dashboard"), + FileManager.default.fileExists(atPath: assets.appendingPathComponent("index.html").path), + FileManager.default.fileExists(atPath: assets.appendingPathComponent("dashboard-server.mjs").path) else { + error = "Dashboard files are missing. Reinstall Tokmeter." + return + } + guard let toolchain = await resolveToolchain() else { + if generation == attempt { error = "Install Node.js 18 or newer to open the web dashboard." } + return + } + guard generation == attempt else { return } + let child = Process() + let stdin = Pipe() + child.executableURL = URL(fileURLWithPath: toolchain.node) + child.arguments = [assets.appendingPathComponent("dashboard-server.mjs").path, + assets.path, String(port), attempt.uuidString] + child.environment = toolchain.environment(base: ProcessInfo.processInfo.environment) + child.standardInput = stdin + child.standardOutput = FileHandle.nullDevice + child.standardError = FileHandle.nullDevice + child.terminationHandler = { [weak self] _ in + Task { @MainActor in + guard let self, self.generation == attempt else { return } + self.isRunning = false + if !self.isStarting { self.error = "Web dashboard stopped. Open it again to restart." } + } + } + do { try child.run() } catch { + self.error = "Could not start the web dashboard: \(error.localizedDescription)" + return + } + process = child + input = stdin + // Check a per-child nonce: an unrelated server on port 3000 is never opened. + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 0.5 + let session = URLSession(configuration: configuration) + defer { session.invalidateAndCancel() } + for _ in 0..<20 { + guard generation == attempt else { return } + if !child.isRunning { break } + if let (data, response) = try? await session.data(from: url.appendingPathComponent("_tokmeter/ready")), + (response as? HTTPURLResponse)?.statusCode == 200, + String(data: data, encoding: .utf8) == attempt.uuidString { + guard generation == attempt, child.isRunning, !Task.isCancelled else { return } + isRunning = true + openURL(url) + return + } + try? await Task.sleep(nanoseconds: 150_000_000) + } + guard generation == attempt else { return } + stop() + error = "Could not start the dashboard on port \(port). Another server may be using it." + } + + func stop() { + generation = UUID() + try? input?.fileHandleForWriting.close() + input = nil + if let process, process.isRunning { process.terminate() } + process = nil + isRunning = false + isStarting = false + error = nil + } +} diff --git a/packages/macos-bar/Tests/TokmeterBarTests/DaemonClientURLTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/DaemonClientURLTests.swift new file mode 100644 index 0000000..e63745d --- /dev/null +++ b/packages/macos-bar/Tests/TokmeterBarTests/DaemonClientURLTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import TokmeterBar + +final class DaemonClientURLTests: XCTestCase { + func testTodaySessionsQueryIsSeparateFromRoute() throws { + let url = DaemonClient.requestURL(for: "/api/sessions?today=true") + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + XCTAssertEqual(components.path, "/api/sessions") + XCTAssertEqual(components.queryItems, [URLQueryItem(name: "today", value: "true")]) + XCTAssertEqual(url.absoluteString, "http://127.0.0.1:9877/api/sessions?today=true") + } + + func testProjectNamesSurviveExactlyOneServerDecode() throws { + // The daemon slices /api/projects/ and decodeURIComponent()s once. + for name in ["tokmeter", "my project", "parent/child", "literal%2Fname", "name?#", "తెలుగు"] { + let url = DaemonClient.requestURL(for: DaemonClient.projectDetailPath(name)) + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let prefix = "/api/projects/" + XCTAssertTrue(components.percentEncodedPath.hasPrefix(prefix), name) + let encodedName = String(components.percentEncodedPath.dropFirst(prefix.count)) + XCTAssertFalse(encodedName.contains("/"), name) + XCTAssertEqual(encodedName.removingPercentEncoding, name) + XCTAssertNil(components.query, name) + XCTAssertNil(components.fragment, name) + XCTAssertEqual(components.host, "127.0.0.1") + XCTAssertEqual(components.port, 9877) + } + } + + func testPlainReadAndMutationRoutesKeepTheirPaths() { + for path in ["/api/quick", "/api/sessions", "/api/update-pricing", "/api/rescan"] { + XCTAssertEqual(DaemonClient.requestURL(for: path).absoluteString, + "http://127.0.0.1:9877" + path) + } + } +} diff --git a/packages/macos-bar/Tests/TokmeterBarTests/DemoRenderTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/DemoRenderTests.swift index 8b1aef5..1cf19c7 100644 --- a/packages/macos-bar/Tests/TokmeterBarTests/DemoRenderTests.swift +++ b/packages/macos-bar/Tests/TokmeterBarTests/DemoRenderTests.swift @@ -32,7 +32,23 @@ final class DemoRenderTests: XCTestCase { loader.todayModels = scene.models.map(TokmeterLoader.toUsage) loader.topModels = loader.todayModels loader.todayProjects = scene.projects + loader.totalTokens = 27_500_000 + loader.totalCost = 78.42 + loader.recentDaily = (1...7).map { day in + DailyUsage(date: "2026-09-0\(day)", tokens: day * 125_000, cost: Double(day) * 0.37) + } for theme in AppTheme.allCases { + let tooltipPreview = VStack(spacing: 12) { + DailyUsageTooltip(day: DailyUsage(date: "2026-09-07", tokens: 1_234_567, cost: 12.34), theme: theme) + HubChartTooltip(date: "2026-09-07", daily: 12.34, tokens: 1_234_567, avg: 9.87, theme: theme) + }.padding(16).background(theme.backgroundMode.surfaceColor) + .environment(\.colorScheme, theme.backgroundMode.isLight ? .light : .dark) + let tooltipRenderer = ImageRenderer(content: tooltipPreview) + tooltipRenderer.scale = 2 + let tooltipImage = try XCTUnwrap(tooltipRenderer.nsImage) + let tooltipBitmap = try XCTUnwrap(NSBitmapImageRep(data: XCTUnwrap(tooltipImage.tiffRepresentation))) + try XCTUnwrap(tooltipBitmap.representation(using: .png, properties: [:])) + .write(to: root.appendingPathComponent("\(theme.rawValue)-tooltips.png")) for expanded in [false, true] { let view = VStack(alignment: .leading, spacing: 0) { HeroHeader(loader: loader, theme: theme, breathToggle: false, diff --git a/packages/macos-bar/Tests/TokmeterBarTests/HeatmapDailyValuesTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/HeatmapDailyValuesTests.swift new file mode 100644 index 0000000..d296f12 --- /dev/null +++ b/packages/macos-bar/Tests/TokmeterBarTests/HeatmapDailyValuesTests.swift @@ -0,0 +1,85 @@ +import AppKit +import SwiftUI +import XCTest +@testable import TokmeterBar + +final class HeatmapDailyValuesTests: XCTestCase { + @MainActor + func testDailyValuesDisclosureFitsNarrowHubAndOffersNativeRowNavigation() throws { + for theme in [AppTheme.terminal, .glass] { + let model = ExpansionModel() + let host = NSHostingView(rootView: DailyValuesFixture(model: model, theme: theme)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = host + defer { window.contentView = nil } + + func settle() -> CGFloat { + for _ in 0..<10 { + host.layoutSubtreeIfNeeded() + window.setContentSize(host.fittingSize) + RunLoop.main.run(until: Date().addingTimeInterval(0.02)) + } + return host.fittingSize.height + } + let collapsedHeight = settle() + XCTAssertNil(findTable(in: host)) + model.expanded = true + let expandedHeight = settle() + XCTAssertGreaterThan(expandedHeight, collapsedHeight + 80) + XCTAssertLessThan(expandedHeight, 350) + XCTAssertEqual(host.fittingSize.width, 480, accuracy: 1) + + let table = try XCTUnwrap(findTable(in: host)) + XCTAssertEqual(table.numberOfRows, 3) + XCTAssertEqual(table.numberOfColumns, 3) + XCTAssertTrue(table.acceptsFirstResponder) + table.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false) + let down = try XCTUnwrap(NSEvent.keyEvent(with: .keyDown, location: .zero, + modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber, + context: nil, characters: "\u{F701}", charactersIgnoringModifiers: "\u{F701}", + isARepeat: false, keyCode: 125)) + table.keyDown(with: down) + XCTAssertEqual(table.selectedRow, 1) + + 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("heatmap-daily-values-\(theme.rawValue).png")) + } + model.expanded = false + XCTAssertEqual(settle(), collapsedHeight, accuracy: 1) + } + } + + @MainActor + private func findTable(in view: NSView) -> NSTableView? { + if let table = view as? NSTableView { return table } + return view.subviews.lazy.compactMap { self.findTable(in: $0) }.first + } +} + +private final class ExpansionModel: ObservableObject { + @Published var expanded = false +} + +private struct DailyValuesFixture: View { + @ObservedObject var model: ExpansionModel + let theme: AppTheme + private let daily = [ + DailyUsage(date: "2026-09-08", tokens: 232_700_000, cost: 240), + DailyUsage(date: "2026-09-06", tokens: 1_234_567_890, cost: 12_345.67), + DailyUsage(date: "2026-09-01", tokens: 100, cost: 0), + ] + + var body: some View { + HeatmapDailyValues(daily: daily, theme: theme, isExpanded: $model.expanded) + .padding(12) + .frame(width: 480) + .fixedSize(horizontal: false, vertical: true) + .background(theme.backgroundMode.surfaceColor) + .environment(\.colorScheme, theme.backgroundMode.isLight ? .light : .dark) + } +} diff --git a/packages/macos-bar/Tests/TokmeterBarTests/HubActionsTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/HubActionsTests.swift new file mode 100644 index 0000000..9bfa9d9 --- /dev/null +++ b/packages/macos-bar/Tests/TokmeterBarTests/HubActionsTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import TokmeterBar + +final class HubActionsTests: XCTestCase { + @MainActor + func testCopiedProjectCommandsPreserveLiteralShellArguments() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let marker = root.appendingPathComponent("must-not-exist") + let project = "Sriinnu's project $(touch \(marker.path)) `echo wrong`\nsecond line" + let expected = [ + ["cleanup", "--project", project, "--dry-run"], + ["snapshot", "--project", project], + ["alias", "set", project, "Better Name"], + ["alias", "hide", project], + ] + for (command, arguments) in zip(HubProjectCliActions.commands(for: project), expected) { + // A shell function captures argv; no real tokmeter command or data is touched. + let script = "tokmeter() { printf '%s\\0' \"$@\"; }; " + command.command + let output = try await SubprocessRunner.run(executable: "/bin/sh", arguments: ["-c", script], + environment: ["PATH": "/usr/bin:/bin"], timeout: 3) + let actual = output.split(separator: "\0", omittingEmptySubsequences: false).dropLast().map(String.init) + XCTAssertEqual(actual, arguments) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: marker.path)) + } + + @MainActor + func testSettingsPersistBeforePublishingAndRecoverFromWriteFailure() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let directory = root.appendingPathComponent("settings") + let path = directory.appendingPathComponent("config.json").path + defer { try? FileManager.default.removeItem(at: root) } + let store = HubConfigStore(filePath: path) + store.update { $0.bar.refreshSeconds = 45 } + XCTAssertNil(store.saveError) + XCTAssertEqual(HubConfigStore(filePath: path).config.bar.refreshSeconds, 45) + store.update { $0.bar.refreshSeconds = 60 } + XCTAssertEqual(HubConfigStore(filePath: path).config.bar.refreshSeconds, 60) + + try FileManager.default.removeItem(at: directory) + try Data("blocked parent".utf8).write(to: directory) + store.reset() + XCTAssertNotNil(store.saveError) + XCTAssertEqual(store.config.bar.refreshSeconds, 60) + store.update { $0.bar.refreshSeconds = 90 } + XCTAssertEqual(store.config.bar.refreshSeconds, 60) + + try FileManager.default.removeItem(at: directory) + store.update { $0.bar.refreshSeconds = 90 } + XCTAssertNil(store.saveError) + XCTAssertEqual(HubConfigStore(filePath: path).config.bar.refreshSeconds, 90) + } + + func testDaemonAndIntegrationCatalogUsesDrishtiEntrypoint() { + let groups = HubCommandCatalog.groups.filter { ["daemon", "install"].contains($0.id) } + XCTAssertFalse(groups.isEmpty) + for command in groups.flatMap(\.commands) { + XCTAssertTrue(command.example.hasPrefix("drishti "), command.id) + } + } +} diff --git a/packages/macos-bar/Tests/TokmeterBarTests/HubResponsiveLayoutTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/HubResponsiveLayoutTests.swift new file mode 100644 index 0000000..3134372 --- /dev/null +++ b/packages/macos-bar/Tests/TokmeterBarTests/HubResponsiveLayoutTests.swift @@ -0,0 +1,78 @@ +import AppKit +import SwiftUI +import XCTest +@testable import TokmeterBar + +final class HubResponsiveLayoutTests: XCTestCase { + @MainActor + func testProductionHubAtMinimumAndWideWindowSizes() throws { + 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]]) + let loader = TokmeterLoader(startPolling: false) + loader.isWarming = false + loader.hasFreshData = true + loader.totalTokens = 117_500_000_000 + loader.totalCost = 74_000 + loader.todayTokens = 232_700_000 + loader.todayCost = 240 + loader.stats = StatsData(totalCost: 74_000, totalTokens: 117_500_000_000, + activeDays: 120, projects: 75, longestStreak: 20) + loader.statbarSignals = try JSONDecoder().decode(StatbarSignals.self, + from: JSONSerialization.data(withJSONObject: XCTUnwrap(scenes.last?["signals"]))) + loader.allDaily = [DailyUsage(date: "2026-09-01", tokens: 50_000_000, cost: 100), + DailyUsage(date: "2026-09-06", tokens: 200_000_000, cost: 220), + DailyUsage(date: "2026-09-08", tokens: 232_700_000, cost: 240)] + loader.recentDaily = loader.allDaily + for theme in [AppTheme.terminal, .glass, .nocturne, .aurora, .nebula] { + let suite = "TokmeterHubResponsiveTests-\(UUID().uuidString)" + let preferences = try XCTUnwrap(UserDefaults(suiteName: suite)) + preferences.set(theme.rawValue, forKey: "appTheme") + defer { preferences.removePersistentDomain(forName: suite) } + for width: CGFloat in [860, 1100, 1500] { + let host = NSHostingView(rootView: HubView(loader: loader) + .defaultAppStorage(preferences) + .frame(width: width, height: 900)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 900), + styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = host + defer { window.contentView = nil } + for _ in 0..<35 { + host.layoutSubtreeIfNeeded() + RunLoop.main.run(until: Date().addingTimeInterval(0.03)) + } + XCTAssertEqual(host.fittingSize.width, width, accuracy: 1) + XCTAssertEqual(host.fittingSize.height, 900, accuracy: 1) + let bitmap = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + if theme == .glass { + // Dark ink needs the light material under the sidebar too. + let pixel = try XCTUnwrap(bitmap.colorAt(x: bitmap.pixelsWide / Int(width) * 100, + y: bitmap.pixelsHigh / 2)?.usingColorSpace(.sRGB)) + XCTAssertGreaterThan(pixel.redComponent, 0.55) + XCTAssertGreaterThan(pixel.greenComponent, 0.55) + XCTAssertGreaterThan(pixel.blueComponent, 0.55) + } + if let directory = ProcessInfo.processInfo.environment["TOKMETER_UI_QA_DIR"] { + try XCTUnwrap(bitmap.representation(using: .png, properties: [:])) + .write(to: URL(fileURLWithPath: directory) + .appendingPathComponent("hub-responsive-\(theme.rawValue)-\(Int(width)).png")) + } + } + } + } + + @MainActor + func testCostAndTokenTrendsHaveDifferentMeaning() { + let cost = RecordedDayTrend(percent: 170.1, metric: .cost, + previousDate: "2026-09-04", latestDate: "2026-09-06") + let tokens = RecordedDayTrend(percent: 215.9, metric: .tokens, + previousDate: "2026-09-04", latestDate: "2026-09-06") + XCTAssertEqual(cost.color(theme: .terminal), AppTheme.terminal.statusWarning) + XCTAssertEqual(tokens.color(theme: .terminal), AppTheme.terminal.backgroundMode.secondaryTextColor) + XCTAssertTrue(cost.description.contains("Daily cost up 170.1%")) + XCTAssertTrue(cost.description.contains("2026-09-06 vs 2026-09-04")) + XCTAssertTrue(cost.description.contains("lifetime total")) + } +} diff --git a/packages/macos-bar/Tests/TokmeterBarTests/PopoverLayoutTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/PopoverLayoutTests.swift index bc41302..c425cef 100644 --- a/packages/macos-bar/Tests/TokmeterBarTests/PopoverLayoutTests.swift +++ b/packages/macos-bar/Tests/TokmeterBarTests/PopoverLayoutTests.swift @@ -6,7 +6,7 @@ import XCTest final class PopoverLayoutTests: XCTestCase { @MainActor func testFullPopoverHasUsageContentOnFirstLayout() throws { - for theme in [AppTheme.nebula, .glass, .terminal, .paper] { + for theme in [AppTheme.nebula, .glass, .terminal, .paper, .nocturne, .aurora] { for expanded in [false, true] { try checkFullPopover(expanded: expanded, theme: theme) } diff --git a/packages/macos-bar/Tests/TokmeterBarTests/StartupTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/StartupTests.swift index f843006..7f0c203 100644 --- a/packages/macos-bar/Tests/TokmeterBarTests/StartupTests.swift +++ b/packages/macos-bar/Tests/TokmeterBarTests/StartupTests.swift @@ -38,6 +38,53 @@ final class StartupTests: XCTestCase { XCTAssertNil(NodeToolchain.majorVersion("not node")) } + func testOldNodeAndBrokenShimDoNotHideWorkingManagedNode() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let old = try makeToolchain(root: root, name: "system", script: "echo v16.20.0") + let broken = try makeToolchain(root: root, name: "shim", script: "exit 9") + let working = try makeToolchain(root: root, name: "managed", script: "echo v22.10.0") + let candidates = NodeToolchain.candidates(home: root.path, + systemDirectories: [old.binDirectory, broken.binDirectory, working.binDirectory]) + XCTAssertEqual(candidates, [old, broken, working]) + let selected = await NodeToolchain.firstSupported(candidates: candidates, environment: ["PATH": "/usr/bin:/bin"]) + XCTAssertEqual(selected, working) + } + + func testHungNodeProbeFallsThroughToWorkingInstallation() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let hung = try makeToolchain(root: root, name: "hung", script: "exec /bin/sleep 10") + let working = try makeToolchain(root: root, name: "working", script: "echo v18.20.0") + // Exercise the production budgets; a one-second override also timed + // out the healthy child intermittently after native render work. + let selected = await NodeToolchain.firstSupported(candidates: [hung, working], environment: [:]) + XCTAssertEqual(selected, working) + } + + func testProbeBudgetStopsBeforeAnotherCandidateStarts() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let hung = try makeToolchain(root: root, name: "hung", script: "exec /bin/sleep 10") + let working = try makeToolchain(root: root, name: "working", script: "echo v22.10.0") + let start = Date() + let selected = await NodeToolchain.firstSupported(candidates: [hung, working], environment: [:], + timeout: 0.1, probeTimeout: 2) + XCTAssertNil(selected) + XCTAssertLessThan(Date().timeIntervalSince(start), 3) + } + + private func makeToolchain(root: URL, name: String, script: String) throws -> NodeToolchain { + let bin = root.appendingPathComponent(name) + try FileManager.default.createDirectory(at: bin, withIntermediateDirectories: true) + for (name, body) in [("node", script), ("npx", "exit 90")] { + let executable = bin.appendingPathComponent(name) + try Data("#!/bin/sh\n\(body)\n".utf8).write(to: executable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path) + } + return NodeToolchain(binDirectory: bin.path) + } + @MainActor func testProtocolFailureStopsWarmingAndClearsLiveClaims() { let loader = TokmeterLoader(startPolling: false) diff --git a/packages/macos-bar/Tests/TokmeterBarTests/ThemeContrastTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/ThemeContrastTests.swift index 9b0c9e1..7e8c6c8 100644 --- a/packages/macos-bar/Tests/TokmeterBarTests/ThemeContrastTests.swift +++ b/packages/macos-bar/Tests/TokmeterBarTests/ThemeContrastTests.swift @@ -6,8 +6,8 @@ import XCTest 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] { + for theme in [AppTheme.glass, .paper, .terminal, .nebula, .nocturne, .aurora] { + for color in [theme.statusWarning, theme.statusSuccess, theme.statusDanger, theme.costInk] { 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) } @@ -71,7 +71,7 @@ final class ThemeContrastTests: XCTestCase { // 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) + let expected = try nativeRenderedRGB(color) var matches = 0 for y in 0.. [Double] { + let host = NSHostingView(rootView: color.frame(width: 10, height: 10) + .environment(\.colorScheme, .dark)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 10, height: 10), + styleMask: [.borderless], backing: .buffered, defer: false) + window.appearance = NSAppearance(named: .darkAqua) + window.contentView = host + defer { window.contentView = nil } + host.layoutSubtreeIfNeeded() + let bitmap = try XCTUnwrap(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: bitmap) + let pixel = try XCTUnwrap(bitmap.colorAt(x: bitmap.pixelsWide / 2, + y: bitmap.pixelsHigh / 2)?.usingColorSpace(.sRGB)) + return [pixel.redComponent, pixel.greenComponent, pixel.blueComponent] + } + @MainActor private func renderedRGB(_ color: Color, scheme: ColorScheme) throws -> [Double] { let renderer = ImageRenderer(content: color.frame(width: 10, height: 10) diff --git a/packages/macos-bar/Tests/TokmeterBarTests/WebDashboardTests.swift b/packages/macos-bar/Tests/TokmeterBarTests/WebDashboardTests.swift new file mode 100644 index 0000000..03cd9d0 --- /dev/null +++ b/packages/macos-bar/Tests/TokmeterBarTests/WebDashboardTests.swift @@ -0,0 +1,106 @@ +import Darwin +import XCTest +@testable import TokmeterBar + +final class WebDashboardTests: XCTestCase { + @MainActor + func testControllerStartsReopensStopsAndRestartsActualDashboardChild() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let assets = root.appendingPathComponent("Dashboard") + try FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try Data("native dashboard fixture".utf8).write(to: assets.appendingPathComponent("index.html")) + let packages = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + try FileManager.default.copyItem(at: packages.appendingPathComponent("web/scripts/dashboard-server.mjs"), + to: assets.appendingPathComponent("dashboard-server.mjs")) + let directories = (ProcessInfo.processInfo.environment["PATH"] ?? "").split(separator: ":").map(String.init) + let resolved = await NodeToolchain.firstSupported( + candidates: NodeToolchain.candidates(systemDirectories: directories), + environment: ProcessInfo.processInfo.environment) + let toolchain = try XCTUnwrap(resolved) + let port = try availablePort() + var opened: [URL] = [] + let controller = WebDashboardController(resources: root, port: port, + openURL: { opened.append($0) }, resolveToolchain: { toolchain }) + defer { controller.stop() } + await controller.open() + XCTAssertTrue(controller.isRunning, controller.error ?? "Not running") + XCTAssertNil(controller.error) + XCTAssertEqual(opened, [controller.url]) + let (body, response) = try await URLSession.shared.data(from: controller.url) + XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200) + XCTAssertTrue(String(decoding: body, as: UTF8.self).contains("native dashboard fixture")) + await controller.open() + XCTAssertEqual(opened.count, 2) + + // A second controller cannot open or stop the first controller's server. + var foreignOpens = 0 + let contender = WebDashboardController(resources: root, port: port, + openURL: { _ in foreignOpens += 1 }, resolveToolchain: { toolchain }) + await contender.open() + XCTAssertFalse(contender.isRunning) + XCTAssertNotNil(contender.error) + XCTAssertEqual(foreignOpens, 0) + XCTAssertTrue(controller.isRunning) + contender.stop() + + controller.stop() + XCTAssertFalse(controller.isRunning) + // EOF/termination runs asynchronously in the owned child. + var stopped = false + for _ in 0..<30 { + if (try? await URLSession.shared.data(from: controller.url)) == nil { stopped = true; break } + try await Task.sleep(nanoseconds: 50_000_000) + } + XCTAssertTrue(stopped) + await controller.open() + XCTAssertTrue(controller.isRunning, controller.error ?? "Restart failed") + XCTAssertEqual(opened.count, 3) + } + + @MainActor + func testCancelDuringRuntimeResolutionCannotLaunchOrPublishAnError() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let assets = root.appendingPathComponent("Dashboard") + try FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + for name in ["index.html", "dashboard-server.mjs"] { + try Data().write(to: assets.appendingPathComponent(name)) + } + var resume: CheckedContinuation? + var opened = false + let controller = WebDashboardController(resources: root, openURL: { _ in opened = true }, + resolveToolchain: { await withCheckedContinuation { resume = $0 } }) + let start = Task { await controller.open() } + while resume == nil { await Task.yield() } + XCTAssertTrue(controller.isStarting) + controller.stop() + resume?.resume(returning: nil) + await start.value + XCTAssertFalse(controller.isStarting) + XCTAssertFalse(controller.isRunning) + XCTAssertFalse(opened) + XCTAssertNil(controller.error) + } + + private func availablePort() throws -> Int { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw POSIXError(.EIO) } + defer { close(fd) } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) } + } + guard bound == 0 else { throw POSIXError(.EADDRINUSE) } + var length = socklen_t(MemoryLayout.size) + let result = withUnsafeMutablePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &length) } + } + guard result == 0 else { throw POSIXError(.EIO) } + return Int(UInt16(bigEndian: address.sin_port)) + } +} diff --git a/packages/macos-bar/bundle.sh b/packages/macos-bar/bundle.sh index 0c5e719..c846593 100755 --- a/packages/macos-bar/bundle.sh +++ b/packages/macos-bar/bundle.sh @@ -60,8 +60,8 @@ MACOS_DIR="${CONTENTS}/MacOS" RESOURCES_DIR="${CONTENTS}/Resources" FRAMEWORKS_DIR="${CONTENTS}/Frameworks" ENTITLEMENTS="entitlements.plist" -SHORT_VERSION="${CFBundleShortVersionString:-1.10.0}" -BUILD_VERSION="${CFBundleVersion:-46}" +SHORT_VERSION="${CFBundleShortVersionString:-1.11.0}" +BUILD_VERSION="${CFBundleVersion:-48}" SUFEED_URL="${SUFEED_URL:-https://raw.githubusercontent.com/sriinnu/tokmeter/main/packages/macos-bar/appcast.xml}" SUPUBLIC_KEY="${SUPUBLIC_KEY:-}" # populated below if private key is present @@ -110,6 +110,13 @@ if [[ -d "${SPARKLE_XC}" ]]; then fi fi +# Bundle code-only web assets. Never ship public/data.json from this machine. +(cd ../web && TOKMETER_APP_BUILD=1 bunx vite build --outDir dist/dashboard --emptyOutDir) +mkdir -p "${RESOURCES_DIR}/Dashboard" +cp ../web/dist/dashboard/index.html "${RESOURCES_DIR}/Dashboard/" +cp -R ../web/dist/dashboard/assets "${RESOURCES_DIR}/Dashboard/" +cp ../web/scripts/dashboard-server.mjs "${RESOURCES_DIR}/Dashboard/" + # License notices and matching source travel with the signed app. python3 ../../scripts/prepare-license-materials.py macos --destination "${RESOURCES_DIR}/Licenses" diff --git a/packages/mcp/README.md b/packages/mcp/README.md index dfed4d1..a262e44 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -174,3 +174,9 @@ Claude Code, OpenCode, Codex CLI, Cursor, Windsurf, Zed, VS Code Copilot, and mo ## License 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). + +## Daemon ownership and refresh + +Status and stop verify the PID against a recorded process start time and command hash. Legacy instances without an identity file must match the installed Drishti CLI entrypoint. Uncertain or changed identity is refused; process inspection and signalling are separate OS operations, so this is not an atomic process handle. + +Startup publishes credentials and ownership only after acquiring the WebSocket listener. A competing start cannot replace the winner's token. Concurrent full rescans share one active or queued full refresh; incremental refreshes remain serialized. These paths have synthetic identity, refresh-count, and listener-contention tests. Windows process inspection still needs Windows runtime validation. diff --git a/packages/mcp/SKILL.md b/packages/mcp/SKILL.md index a7aff0d..bf0ed34 100644 --- a/packages/mcp/SKILL.md +++ b/packages/mcp/SKILL.md @@ -39,3 +39,7 @@ Use `drishti install-mcp` for the repository's supported editor setup; inspect t ## License AGPL-3.0-only; see [licenses and source](../../docs/licensing.md). + +## Daemon lifecycle + +Use `drishti daemon status` before lifecycle actions. Stop refuses uncertain process identity; do not bypass that refusal by killing a PID read from disk. Startup credentials are published after listener ownership, and concurrent forced rescans share one full refresh. These safeguards do not make process inspection and signalling atomic. See [README](README.md#daemon-ownership-and-refresh). diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 4b882a2..b5fea2a 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@sriinnu/drishti", - "version": "1.10.0", + "version": "1.11.0", "description": "दृष्टि — MCP server + live token observatory for AI coding agents", "type": "module", "bin": { diff --git a/packages/mcp/src/daemon/identity.test.ts b/packages/mcp/src/daemon/identity.test.ts new file mode 100644 index 0000000..a4fb5fa --- /dev/null +++ b/packages/mcp/src/daemon/identity.test.ts @@ -0,0 +1,95 @@ +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + captureDaemonIdentity, + inspectDaemon, + readProcessEvidence, + signalVerifiedDaemon, +} from "./identity.js"; + +describe("daemon process identity", () => { + let root: string; + let pidFile: string; + let ownerFile: string; + const evidence = { + startedAt: "Tue Sep 8 10:00:00 2026", + command: "node /fixture/cli.js daemon start", + }; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "drishti-identity-")); + pidFile = join(root, "daemon.pid"); + ownerFile = join(root, "owner.json"); + writeFileSync(pidFile, "4242"); + writeFileSync(ownerFile, JSON.stringify(captureDaemonIdentity(4242, evidence))); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + test("signals the verified lifetime, not merely an existing PID", () => { + const signal = vi.fn(); + signalVerifiedDaemon(pidFile, ownerFile, () => evidence, signal); + expect(signal).toHaveBeenCalledWith(4242, "SIGTERM"); + }); + + test("a recycled PID or a changed executable command is not the daemon", () => { + for (const replacement of [ + { ...evidence, startedAt: "later" }, + { ...evidence, command: "unrelated app" }, + ]) { + const signal = vi.fn(); + expect(inspectDaemon(pidFile, ownerFile, () => replacement).state).toBe("unverified"); + expect(() => signalVerifiedDaemon(pidFile, ownerFile, () => replacement, signal)).toThrow( + "refusing" + ); + expect(signal).not.toHaveBeenCalled(); + } + }); + + test("refuses if the identity changes between lookup and signal", () => { + const inspect = vi + .fn() + .mockReturnValueOnce(evidence) + .mockReturnValue({ ...evidence, startedAt: "later" }); + const signal = vi.fn(); + expect(() => signalVerifiedDaemon(pidFile, ownerFile, inspect, signal)).toThrow("refusing"); + expect(signal).not.toHaveBeenCalled(); + }); + + test("malformed PID and malformed ownership files fail closed", () => { + writeFileSync(pidFile, "4242junk"); + expect(inspectDaemon(pidFile, ownerFile, () => evidence).state).toBe("unverified"); + writeFileSync(pidFile, "4242"); + writeFileSync(ownerFile, "not json"); + expect(inspectDaemon(pidFile, ownerFile, () => evidence).state).toBe("unverified"); + }); + + test("legacy discovery requires the package's real CLI entrypoint, including symlinks", () => { + rmSync(ownerFile); + const packageRoot = join(root, "legacy package"); + mkdirSync(join(packageRoot, "dist"), { recursive: true }); + const entry = join(packageRoot, "dist", "cli.js"); + writeFileSync(entry, ""); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ name: "@sriinnu/drishti", bin: { drishti: "dist/cli.js" } }) + ); + const link = join(root, "drishti"); + symlinkSync(entry, link); + const legacy = { ...evidence, command: `/opt/node ${link} daemon start` }; + expect(inspectDaemon(pidFile, ownerFile, () => legacy).state).toBe("verified"); + expect( + inspectDaemon(pidFile, ownerFile, () => ({ ...legacy, command: `${legacy.command} extra` })) + .state + ).toBe("unverified"); + writeFileSync( + join(packageRoot, "package.json"), + JSON.stringify({ name: "unrelated", bin: { drishti: "dist/cli.js" } }) + ); + expect(inspectDaemon(pidFile, ownerFile, () => legacy).state).toBe("unverified"); + }); + + test("the bounded native query can identify this test process", () => { + expect(readProcessEvidence(process.pid)?.startedAt).toBeTruthy(); + }); +}); diff --git a/packages/mcp/src/daemon/identity.ts b/packages/mcp/src/daemon/identity.ts new file mode 100644 index 0000000..29a5586 --- /dev/null +++ b/packages/mcp/src/daemon/identity.ts @@ -0,0 +1,137 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { readFileSync, realpathSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +export interface ProcessEvidence { + startedAt: string; + command: string; +} + +export interface DaemonIdentity { + pid: number; + startedAt: string; + commandHash: string; +} + +export type DaemonInspection = + | { state: "verified"; identity: DaemonIdentity } + | { state: "absent" | "unverified" }; + +/** Inspect only the requested PID. Commands are hashed before being stored. */ +export function readProcessEvidence(pid: number): ProcessEvidence | null { + if (!Number.isSafeInteger(pid) || pid <= 1) return null; + try { + if (process.platform === "win32") { + const script = `$tokmeterProcess = Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}'; if ($tokmeterProcess) { @{ startedAt = $tokmeterProcess.CreationDate.ToUniversalTime().ToString('o'); command = $tokmeterProcess.CommandLine } | ConvertTo-Json -Compress }`; + const value = JSON.parse( + execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { + encoding: "utf8", + timeout: 1500, + maxBuffer: 16_384, + stdio: ["ignore", "pipe", "ignore"], + }) + ); + return typeof value.startedAt === "string" && typeof value.command === "string" + ? value + : null; + } + const result = execFileSync("/bin/ps", ["-p", String(pid), "-o", "lstart=", "-o", "args="], { + encoding: "utf8", + timeout: 1500, + maxBuffer: 16_384, + stdio: ["ignore", "pipe", "ignore"], + env: { ...process.env, LC_ALL: "C" }, + }).trim(); + const match = result.match( + /^([A-Za-z]{3}\s+[A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+([^\r\n]+)$/ + ); + return match ? { startedAt: match[1], command: match[2] } : null; + } catch { + return null; + } +} + +export function captureDaemonIdentity(pid: number, evidence: ProcessEvidence): DaemonIdentity { + return { + pid, + startedAt: evidence.startedAt, + commandHash: createHash("sha256").update(evidence.command).digest("hex"), + }; +} + +/** Compatibility for pre-identity releases: require the actual Drishti entrypoint. */ +function isLegacyDaemon(command: string): boolean { + const match = command.match(/^(?:.+\/(?:node|nodejs|bun)|node|nodejs|bun) (.+) daemon start$/); + if (!match || !isAbsolute(match[1])) return false; + try { + const entrypoint = realpathSync(match[1]); + const packageRoot = dirname(dirname(entrypoint)); + const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")); + return ( + manifest.name === "@sriinnu/drishti" && + typeof manifest.bin?.drishti === "string" && + resolve(packageRoot, manifest.bin.drishti) === entrypoint + ); + } catch { + return false; + } +} + +export function inspectDaemon( + pidFile: string, + identityFile: string, + inspect: (pid: number) => ProcessEvidence | null = readProcessEvidence +): DaemonInspection { + let pid: number; + try { + const raw = readFileSync(pidFile, "utf8").trim(); + if (!/^[1-9]\d*$/.test(raw)) return { state: "unverified" }; + pid = Number(raw); + if (!Number.isSafeInteger(pid) || pid <= 1) return { state: "unverified" }; + } catch (error) { + return { state: (error as NodeJS.ErrnoException).code === "ENOENT" ? "absent" : "unverified" }; + } + const evidence = inspect(pid); + if (!evidence) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return { state: "absent" }; + } + return { state: "unverified" }; + } + const current = captureDaemonIdentity(pid, evidence); + try { + const saved = JSON.parse(readFileSync(identityFile, "utf8")) as DaemonIdentity; + return saved.pid === current.pid && + saved.startedAt === current.startedAt && + saved.commandHash === current.commandHash + ? { state: "verified", identity: current } + : { state: "unverified" }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" && isLegacyDaemon(evidence.command)) { + return { state: "verified", identity: current }; + } + return { state: "unverified" }; + } +} + +/** Recheck lifetime immediately before signalling; never signal from a bare PID file. */ +export function signalVerifiedDaemon( + pidFile: string, + identityFile: string, + inspect: (pid: number) => ProcessEvidence | null = readProcessEvidence, + signal: (pid: number, signal: NodeJS.Signals) => unknown = process.kill +): void { + const first = inspectDaemon(pidFile, identityFile, inspect); + const second = inspectDaemon(pidFile, identityFile, inspect); + if ( + first.state !== "verified" || + second.state !== "verified" || + JSON.stringify(first.identity) !== JSON.stringify(second.identity) + ) { + throw new Error("Daemon identity is unverified or changed; refusing to signal the PID."); + } + signal(second.identity.pid, "SIGTERM"); +} diff --git a/packages/mcp/src/daemon/protocol.ts b/packages/mcp/src/daemon/protocol.ts index 480589a..570ac6c 100644 --- a/packages/mcp/src/daemon/protocol.ts +++ b/packages/mcp/src/daemon/protocol.ts @@ -127,6 +127,7 @@ try { /* dir may already exist with different perms; not fatal — we still write 0600 files */ } +export const DAEMON_IDENTITY_FILE = join(DAEMON_STATE_DIR, "daemon-identity.json"); export const DAEMON_PID_FILE = join(DAEMON_STATE_DIR, "daemon.pid"); export const DAEMON_TOKEN_FILE = join(DAEMON_STATE_DIR, "daemon.token"); export const DAEMON_STATE_FILE = join(DAEMON_STATE_DIR, "daemon-state.json"); diff --git a/packages/mcp/src/daemon/refresh-coordinator.test.ts b/packages/mcp/src/daemon/refresh-coordinator.test.ts new file mode 100644 index 0000000..56fffb8 --- /dev/null +++ b/packages/mcp/src/daemon/refresh-coordinator.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test, vi } from "vitest"; +import { RefreshCoordinator } from "./refresh-coordinator.js"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((ok, fail) => { + resolve = ok; + reject = fail; + }); + return { promise, resolve, reject }; +} + +describe("refresh coalescing", () => { + test("a burst during an incremental refresh shares exactly one later full scan", async () => { + const incremental = deferred(); + const full = deferred(); + const work = vi.fn((scan: boolean) => (scan ? full.promise : incremental.promise)); + const gate = new RefreshCoordinator(work); + const read = gate.run(false); + const rescans = Array.from({ length: 10 }, () => gate.run(true)); + expect(new Set(rescans).size).toBe(1); + expect(gate.busy).toBe(true); + incremental.resolve("incremental"); + expect(await read).toBe("incremental"); + await Promise.resolve(); + expect(gate.run(true)).toBe(rescans[0]); + full.resolve("full"); + expect(await Promise.all(rescans)).toEqual(Array(10).fill("full")); + expect(work.mock.calls).toEqual([[false], [true]]); + expect(gate.busy).toBe(false); + }); + + test("a running full scan satisfies concurrent forced and ordinary callers", async () => { + const full = deferred(); + const work = vi.fn(() => full.promise); + const gate = new RefreshCoordinator(work); + const first = gate.run(true); + expect(gate.run(true)).toBe(first); + expect(gate.run(false)).toBe(first); + full.resolve(1); + await first; + expect(work).toHaveBeenCalledTimes(1); + }); + + test("a failed incremental refresh does not poison the queued full scan", async () => { + const incremental = deferred(); + const work = vi.fn((full: boolean) => (full ? Promise.resolve(2) : incremental.promise)); + const gate = new RefreshCoordinator(work); + const read = gate.run(false); + const failed = expect(read).rejects.toThrow("refresh failed"); + const queued = gate.run(true); + incremental.reject(new Error("refresh failed")); + await failed; + expect(await queued).toBe(2); + expect(work.mock.calls).toEqual([[false], [true]]); + expect(gate.busy).toBe(false); + }); + + test("a rejected full scan permits a later explicit retry", async () => { + const work = vi.fn().mockRejectedValueOnce(new Error("scan failed")).mockResolvedValueOnce(3); + const gate = new RefreshCoordinator(work); + await expect(gate.run(true)).rejects.toThrow("scan failed"); + expect(gate.busy).toBe(false); + expect(await gate.run(true)).toBe(3); + }); +}); diff --git a/packages/mcp/src/daemon/refresh-coordinator.ts b/packages/mcp/src/daemon/refresh-coordinator.ts new file mode 100644 index 0000000..bfb27aa --- /dev/null +++ b/packages/mcp/src/daemon/refresh-coordinator.ts @@ -0,0 +1,35 @@ +/** Serialize refreshes and share exactly one queued full scan after an incremental refresh. */ +export class RefreshCoordinator { + private flight: { full: boolean; promise: Promise } | null = null; + private queuedFull: Promise | null = null; + + constructor(private readonly refresh: (full: boolean) => Promise) {} + + get busy(): boolean { + return this.flight !== null || this.queuedFull !== null; + } + + run(full: boolean): Promise { + if (this.queuedFull) return this.queuedFull; + if (this.flight) { + if (!full || this.flight.full) return this.flight.promise; + const queued = this.flight.promise.catch(() => undefined).then(() => this.start(true)); + const result = queued.finally(() => { + if (this.queuedFull === result) this.queuedFull = null; + }); + this.queuedFull = result; + return result; + } + return this.start(full); + } + + private start(full: boolean): Promise { + const promise = Promise.resolve() + .then(() => this.refresh(full)) + .finally(() => { + if (this.flight?.promise === promise) this.flight = null; + }); + this.flight = { full, promise }; + return promise; + } +} diff --git a/packages/mcp/src/daemon/server-lifecycle.test.ts b/packages/mcp/src/daemon/server-lifecycle.test.ts new file mode 100644 index 0000000..165df48 --- /dev/null +++ b/packages/mcp/src/daemon/server-lifecycle.test.ts @@ -0,0 +1,112 @@ +import { EventEmitter, once } from "node:events"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test, vi } from "vitest"; + +const cleanups: Array<() => void> = []; +afterEach(async () => { + for (const cleanup of cleanups.reverse()) cleanup(); + cleanups.length = 0; + await new Promise((resolve) => setTimeout(resolve, 20)); + vi.restoreAllMocks(); + vi.resetModules(); +}); + +test("a competing bind cannot replace or remove the winner's token and identity", async () => { + const root = mkdtempSync(join(tmpdir(), "drishti-start-race-")); + cleanups.push(() => rmSync(root, { recursive: true, force: true })); + // Reserve an ephemeral test port, never either production daemon port. + const probe = createServer(); + probe.listen(0, "127.0.0.1"); + await once(probe, "listening"); + const address = probe.address(); + if (!address || typeof address === "string") throw new Error("missing test port"); + const port = address.port; + await new Promise((resolve, reject) => + probe.close((error) => (error ? reject(error) : resolve())) + ); + const files = { + DAEMON_STATE_DIR: root, + DAEMON_PID_FILE: join(root, "daemon.pid"), + DAEMON_IDENTITY_FILE: join(root, "identity.json"), + DAEMON_TOKEN_FILE: join(root, "daemon.token"), + DAEMON_STATE_FILE: join(root, "state.json"), + LEGACY_DAEMON_PID_FILE: join(root, "legacy.pid"), + LEGACY_DAEMON_TOKEN_FILE: join(root, "legacy.token"), + }; + vi.doMock("./protocol.js", () => ({ + ...files, + DAEMON_PORT: port, + DAEMON_HOST: "127.0.0.1", + DAEMON_URL: `ws://127.0.0.1:${port}`, + })); + vi.doMock("node:os", async () => ({ + ...(await vi.importActual("node:os")), + setPriority: vi.fn(), + })); + vi.doMock("./identity.js", async () => ({ + ...(await vi.importActual("./identity.js")), + readProcessEvidence: () => ({ startedAt: "fixture lifetime", command: "fixture daemon" }), + })); + vi.doMock("@sriinnu/tokmeter", () => ({ + loadConfig: () => ({ daemon: { antigravityLivePolling: false } }), + localDateKey: () => "2026-09-08", + pollAntigravityLiveStatus: vi.fn(), + refreshKoshaRegistry: vi.fn(), + TokmeterCore: class { + async scan() {} + }, + })); + // The real WebSocket bind is under test. HTTP, scans, priority, and paths are fixtures. + vi.doMock("node:http", () => ({ + createServer: () => { + const server = new EventEmitter() as EventEmitter & { + listen: (...args: unknown[]) => void; + close: () => void; + }; + server.listen = (...args) => (args.at(-1) as () => void)(); + server.close = () => {}; + return server; + }, + })); + const sockets: EventEmitter[] = []; + vi.doMock("ws", async () => { + const real = await vi.importActual("ws"); + return { + ...real, + WebSocketServer: class extends real.WebSocketServer { + constructor(options: import("ws").ServerOptions) { + super(options); + sockets.push(this); + } + }, + }; + }); + const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + + writeFileSync(files.DAEMON_TOKEN_FILE, "old fixture token"); + const winner = await import("./server.js"); + cleanups.push(() => winner.stopDaemon()); + winner.startDaemon(); + expect(readFileSync(files.DAEMON_TOKEN_FILE, "utf8")).toBe("old fixture token"); + expect(existsSync(files.DAEMON_PID_FILE)).toBe(false); + await once(sockets[0], "listening"); + const token = readFileSync(files.DAEMON_TOKEN_FILE, "utf8"); + const identity = readFileSync(files.DAEMON_IDENTITY_FILE, "utf8"); + expect(token).not.toBe("old fixture token"); + + vi.resetModules(); + const loser = await import("./server.js"); + cleanups.push(() => loser.stopDaemon()); + loser.startDaemon(); + await once(sockets[1], "error"); + expect(exit).toHaveBeenCalledWith(0); + loser.stopDaemon(); + expect(readFileSync(files.DAEMON_TOKEN_FILE, "utf8")).toBe(token); + expect(readFileSync(files.DAEMON_IDENTITY_FILE, "utf8")).toBe(identity); + expect(readFileSync(files.DAEMON_PID_FILE, "utf8")).toBe(String(process.pid)); +}, 10_000); diff --git a/packages/mcp/src/daemon/server.ts b/packages/mcp/src/daemon/server.ts index 7ca9df3..db36d50 100644 --- a/packages/mcp/src/daemon/server.ts +++ b/packages/mcp/src/daemon/server.ts @@ -33,6 +33,12 @@ import { refreshKoshaRegistry, } from "@sriinnu/tokmeter"; import { WebSocket, WebSocketServer } from "ws"; +import { + captureDaemonIdentity, + inspectDaemon, + readProcessEvidence, + signalVerifiedDaemon, +} from "./identity.js"; import { AGENT_LABEL, agentPlistPath, @@ -45,6 +51,7 @@ import { import type { BroadcastMessage, ClientMessage, ServerMessage } from "./protocol.js"; import { DAEMON_HOST, + DAEMON_IDENTITY_FILE, DAEMON_PID_FILE, DAEMON_PORT, DAEMON_STATE_DIR, @@ -54,6 +61,7 @@ import { LEGACY_DAEMON_PID_FILE, LEGACY_DAEMON_TOKEN_FILE, } from "./protocol.js"; +import { RefreshCoordinator } from "./refresh-coordinator.js"; import { SessionManager } from "./session.js"; // ─── Server State ─────────────────────────────────────────────────────── @@ -120,6 +128,7 @@ const DAEMON_HEAP_CAP_MB = Number.parseInt(process.env.TOKMETER_DAEMON_HEAP_MB ? import { randomBytes, timingSafeEqual } from "node:crypto"; let _authToken: string | null = null; +let _ownsDaemonState = false; /** * Write `data` to `path` with mode 0600 using `O_CREAT|O_EXCL|O_WRONLY`. The @@ -226,63 +235,9 @@ export function startDaemon(): void { // Non-fatal — if the OS refuses, the daemon still works, just less polite. } - // Cross-process singleton guard. The statusline + bar both fire-and-forget - // a `daemon start` when they can't reach the daemon; without this guard a - // burst of those would spawn a stampede of servers all fighting for the - // port. If a live daemon already owns the PID file, bow out silently. - if (existsSync(DAEMON_PID_FILE)) { - const raw = (() => { - try { - return readFileSync(DAEMON_PID_FILE, "utf-8").trim(); - } catch { - return ""; - } - })(); - const pid = Number.parseInt(raw, 10); - if (Number.isFinite(pid) && pid > 1 && pid !== process.pid) { - // process.kill(pid, 0) probes the process WITHOUT signalling it. - // - succeeds → process exists and is signalable by us → alive, bow out - // - throws EPERM → process exists but is owned by another user (or restricted) → alive, bow out - // - throws ESRCH → no such process → stale PID, safe to reclaim - // - throws anything else → conservative: treat as alive (avoid stomping on a real daemon) - let liveness: "alive" | "stale" = "stale"; - try { - process.kill(pid, 0); - liveness = "alive"; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "EPERM") { - liveness = "alive"; - } else if (code === "ESRCH") { - liveness = "stale"; - } else { - // Unknown errno — fail safe by assuming alive. Better to bow out - // than to double-bind and crash a working daemon. - liveness = "alive"; - } - } - if (liveness === "alive") { - console.log(`daemon already running (pid ${pid})`); - return; - } - // Stale PID file — clean up both canonical and legacy paths. - for (const f of [DAEMON_PID_FILE, LEGACY_DAEMON_PID_FILE]) { - try { - unlinkSync(f); - } catch {} - } - } else { - // Garbage PID (empty / non-numeric / self) — treat as stale. - for (const f of [DAEMON_PID_FILE, LEGACY_DAEMON_PID_FILE]) { - try { - unlinkSync(f); - } catch {} - } - } - } - + // Binding the WebSocket port is the cross-process startup claim. A PID + // file alone cannot establish ownership and must never decide publication. sessionManager = new SessionManager(); - initAuthToken(); wss = new WebSocketServer({ port: DAEMON_PORT, @@ -302,10 +257,24 @@ export function startDaemon(): void { }); wss.on("listening", () => { + // Only the process that bound the port may publish credentials or identity. + const evidence = readProcessEvidence(process.pid); + if (!evidence) { + console.error("Cannot verify this daemon process; refusing to publish credentials"); + stopDaemon(); + process.exitCode = 1; + return; + } + _ownsDaemonState = true; + initAuthToken(); + writeSecretFile( + DAEMON_IDENTITY_FILE, + JSON.stringify(captureDaemonIdentity(process.pid, evidence)) + ); console.log(`【♾️】 Drishti Daemon listening on ${DAEMON_URL}`); // Write PID file (canonical + legacy /tmp shim for bar v1.4.0 compat). - writeFileSync(DAEMON_PID_FILE, String(process.pid), { mode: 0o600 }); + writeSecretFile(DAEMON_PID_FILE, String(process.pid)); try { writeFileSync(LEGACY_DAEMON_PID_FILE, String(process.pid), { mode: 0o600 }); } catch { @@ -403,7 +372,7 @@ export function startDaemon(): void { // Another daemon won the bind race (the PID-file check above has a // small TOCTOU window). Exit cleanly rather than crash-looping — the // other daemon is the live singleton and there's nothing for us to do. - console.log(`daemon port ${DAEMON_PORT} already in use — another daemon won the race`); + console.log(`daemon port ${DAEMON_PORT} already in use — startup skipped`); process.exit(0); } console.error("Server error:", err.message); @@ -602,8 +571,8 @@ function loadState(): void { // ─── Daemon Management ────────────────────────────────────────────────── export function stopDaemon(): void { - // Save state one final time before stopping - saveState(); + // A losing startup process must not save over or unlink the winner's state. + if (_ownsDaemonState) saveState(); if (cleanupInterval) { clearInterval(cleanupInterval); @@ -628,13 +597,25 @@ export function stopDaemon(): void { httpServer = null; } - // Clean up PID and token files (canonical + legacy /tmp shims). - for (const f of [ - DAEMON_PID_FILE, - DAEMON_TOKEN_FILE, - LEGACY_DAEMON_PID_FILE, - LEGACY_DAEMON_TOKEN_FILE, - ]) { + // Remove only files belonging to this process, never a successor's state. + const ownsPid = + _ownsDaemonState && + (() => { + try { + return readFileSync(DAEMON_PID_FILE, "utf8").trim() === String(process.pid); + } catch { + return false; + } + })(); + for (const f of ownsPid + ? [ + DAEMON_IDENTITY_FILE, + DAEMON_PID_FILE, + DAEMON_TOKEN_FILE, + LEGACY_DAEMON_PID_FILE, + LEGACY_DAEMON_TOKEN_FILE, + ] + : []) { try { unlinkSync(f); } catch { @@ -643,63 +624,25 @@ export function stopDaemon(): void { } _authToken = null; + _ownsDaemonState = false; sessionManager = null; console.log("Daemon stopped"); } -/** - * Distinguish a truly-dead PID from one we just don't have permission to - * signal. EPERM ⇒ the process exists (alive); ESRCH ⇒ no such process (stale). - * Anything else ⇒ unknown — fail conservatively as "alive" so we never blow - * away the canonical singleton. - */ -function isPidAlive(pid: number): boolean { - if (!Number.isFinite(pid) || pid <= 1) return false; - try { - process.kill(pid, 0); - return true; - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === "ESRCH") return false; - if (code === "EPERM") return true; - return true; // unknown errno → conservative - } -} - export function isDaemonRunning(): boolean { - if (!existsSync(DAEMON_PID_FILE)) return false; - let pid: number; - try { - pid = Number.parseInt(readFileSync(DAEMON_PID_FILE, "utf-8").trim(), 10); - } catch { - return false; - } - if (isPidAlive(pid)) return true; - // Truly stale — clean up so the next start can reclaim cleanly. - for (const f of [DAEMON_PID_FILE, LEGACY_DAEMON_PID_FILE]) { - try { - unlinkSync(f); - } catch {} - } - return false; + return getDaemonStatus().running; } export function getDaemonStatus(): { running: boolean; pid?: number; port: number; + identity: "verified" | "absent" | "unverified"; } { - if (!existsSync(DAEMON_PID_FILE)) { - return { running: false, port: DAEMON_PORT }; - } - let pid: number; - try { - pid = Number.parseInt(readFileSync(DAEMON_PID_FILE, "utf-8").trim(), 10); - } catch { - return { running: false, port: DAEMON_PORT }; - } - if (isPidAlive(pid)) return { running: true, pid, port: DAEMON_PORT }; - return { running: false, port: DAEMON_PORT }; + const owner = inspectDaemon(DAEMON_PID_FILE, DAEMON_IDENTITY_FILE); + return owner.state === "verified" + ? { running: true, pid: owner.identity.pid, port: DAEMON_PORT, identity: owner.state } + : { running: false, port: DAEMON_PORT, identity: owner.state }; } // ─── HTTP REST API ────────────────────────────────────────────────────── @@ -734,12 +677,6 @@ function invalidateHttpCore(): void { const TODAY_REFRESH_TTL = 12_000; const MAX_BODY_BYTES = 1_048_576; // 1MB -/** - * Tracks the in-flight refresh/scan so concurrent callers don't trigger - * duplicate work. The first caller does the work; subsequent callers await the - * same promise. - */ -let _httpCorePromise: Promise | null = null; /** True once the first warmup scan has completed. */ let _httpCoreReady = false; @@ -754,15 +691,6 @@ let _httpCoreReady = false; const DRISHTI_API_VERSION = 1; const SUMMARY_SOURCE_HEADER = "X-Tokmeter-Summary-Source"; -/** - * Set by `rescanHttpCore()` when a pricing update arrives while another core - * operation is in flight. The in-flight promise will see this on completion - * and queue exactly one fullRescan after it — collapsing N back-to-back - * pricing-update bursts into a single follow-up scan instead of stacking N - * full-corpus scans. Resets to false when consumed. - */ -let _pendingFullRescan = false; - // Guards /api/rescan: the deep rebuild runs in the background, so concurrent // triggers (impatient double-click) must coalesce, not stack. let _rescanInFlight = false; @@ -772,83 +700,30 @@ let _rescanInFlight = false; // staying far below the full-history parse that exhausts memory. const DEEP_RESCAN_WINDOW_DAYS = 30; -/** - * Single mediator for ALL core work — warm reads, today refreshes, and full - * rescans — through ONE single-flight promise. Without this, a concurrent - * `rescanHttpCore()` could replace `_httpCorePromise` after awaiting it, - * back-to-back-running TWO full-corpus scans (the exact stampede the rebuild - * exists to prevent, just serialized instead of parallel). - * - * Rules: - * - Warm + fresh (within {@link TODAY_REFRESH_TTL}) and no rescan requested - * → return the warm core instantly. - * - Something already in flight → wait for it. If a rescan was requested - * while a refresh was running, mark it pending so we queue exactly one - * rescan after the current operation completes. - * - Nothing in flight → claim `_httpCorePromise` and run the right work - * (refresh / fullRescan / cold start). - */ -async function ensureCoreFresh(forceFullRescan = false, depth = 0): Promise { - const now = Date.now(); - if (!forceFullRescan && _httpCore && now - _httpCore.ts < TODAY_REFRESH_TTL) { +const coreRefresh = new RefreshCoordinator(async (full) => { + if (_httpCore) { + if (full) await _httpCore.core.scan(); + else await _httpCore.core.refreshToday(); + _httpCore.ts = Date.now(); return _httpCore.core; } - - if (_httpCorePromise) { - if (forceFullRescan) _pendingFullRescan = true; - // Tolerate rejection so a single failed scan doesn't poison every caller - // (the inner finally still clears `_httpCorePromise`). - await _httpCorePromise.catch(() => undefined); - // Re-evaluate: the just-finished work may already have satisfied us, or - // we may need to chain a rescan if `_pendingFullRescan` was set. Cap - // the recursion depth as a belt-and-suspenders against a pathological - // rescan-storm livelock — at depth > 4 we just return whatever core we - // have (even if "stale" by TTL), since the queued rescan will eventually - // catch up on the next caller. - if (depth > 4) { - if (_httpCore) return _httpCore.core; - throw new Error("ensureCoreFresh exceeded recursion depth without producing a core"); - } - return ensureCoreFresh(forceFullRescan || _pendingFullRescan, depth + 1); - } - - const wantFullRescan = forceFullRescan || _pendingFullRescan; - _pendingFullRescan = false; - - _httpCorePromise = (async () => { - try { - if (_httpCore && !wantFullRescan) { - // Warm + stale → cheap incremental today refresh. `refreshToday()` - // re-reads ONLY today's active files and splices onto frozen history, - // which is never touched (immutability rule). - await _httpCore.core.refreshToday(); - _httpCore.ts = Date.now(); - return _httpCore.core; - } - if (_httpCore) { - // Warm + full rescan (e.g. after `/api/update-pricing`). Core's own - // immutability rules keep frozen history frozen; only today reprices. - await _httpCore.core.scan(); - _httpCore.ts = Date.now(); - return _httpCore.core; - } - // Cold start: build the ONE persistent core and load frozen history once. - const { TokmeterCore } = await import("@sriinnu/tokmeter"); - const core = new TokmeterCore(); - await core.scan(); - _httpCore = { core, ts: Date.now() }; - _httpCoreReady = true; - return core; - } finally { - // Promise tracking ends here regardless of success/failure — the next - // caller starts a fresh attempt. `_httpCoreReady` is NOT flipped here - // because we want it to reflect "have we ever produced a warm core", - // not "did the last attempt succeed". - _httpCorePromise = null; - } - })(); - - return _httpCorePromise; + const { TokmeterCore } = await import("@sriinnu/tokmeter"); + const core = new TokmeterCore(); + await core.scan(); + _httpCore = { core, ts: Date.now() }; + _httpCoreReady = true; + return core; +}); + +async function ensureCoreFresh(forceFullRescan = false): Promise { + if ( + !forceFullRescan && + !coreRefresh.busy && + _httpCore && + Date.now() - _httpCore.ts < TODAY_REFRESH_TTL + ) + return _httpCore.core; + return coreRefresh.run(forceFullRescan); } async function getHttpCore(): Promise { @@ -950,7 +825,7 @@ function startHttpApi(): void { if (pathname === "/api/ready") { json(res, { ready: _httpCoreReady, - warming: _httpCorePromise !== null, + warming: coreRefresh.busy, apiVersion: DRISHTI_API_VERSION, }); return; @@ -1718,6 +1593,15 @@ async function readBody(req: IncomingMessage): Promise { const DAEMON_CHILD_FLAG = "__DRISHTI_DAEMON_CHILD__"; export async function runDaemonCLI(command: string): Promise { + if ( + ["stop", "restart", "install-agent"].includes(command) && + !isAgentLoaded() && + getDaemonStatus().identity === "unverified" + ) { + throw new Error( + "Cannot verify daemon ownership; refusing process control. Inspect daemon status before retrying." + ); + } switch (command) { case "start": if (isDaemonRunning()) { @@ -1814,7 +1698,7 @@ export async function runDaemonCLI(command: string): Promise { if (isDaemonRunning()) { const { pid } = getDaemonStatus(); if (pid) { - process.kill(pid, "SIGTERM"); + signalVerifiedDaemon(DAEMON_PID_FILE, DAEMON_IDENTITY_FILE); // Wait for the daemon to actually exit and clean up its PID file. let stopRetries = 10; while (stopRetries > 0 && isDaemonRunning()) { @@ -1850,7 +1734,7 @@ export async function runDaemonCLI(command: string): Promise { if (isDaemonRunning()) { const { pid } = getDaemonStatus(); if (pid) { - process.kill(pid, "SIGTERM"); + signalVerifiedDaemon(DAEMON_PID_FILE, DAEMON_IDENTITY_FILE); // Wait for old process to die let retries = 10; while (retries > 0 && isDaemonRunning()) { @@ -1875,9 +1759,7 @@ export async function runDaemonCLI(command: string): Promise { if (isDaemonRunning()) { const { pid } = getDaemonStatus(); if (pid) { - try { - process.kill(pid, "SIGTERM"); - } catch {} + signalVerifiedDaemon(DAEMON_PID_FILE, DAEMON_IDENTITY_FILE); let retries = 10; while (retries > 0 && isDaemonRunning()) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200); diff --git a/packages/tokmeter/README.md b/packages/tokmeter/README.md index 59c7a0c..4333683 100644 --- a/packages/tokmeter/README.md +++ b/packages/tokmeter/README.md @@ -40,7 +40,7 @@ import { TokmeterCore } from "@sriinnu/tokmeter"; const core = new TokmeterCore(); -// Scan all session files +// Refresh today and load sealed historical aggregates const records = await core.scan(); // Get per-project breakdown @@ -60,30 +60,34 @@ const stats = core.getStats(); #### Filtering +After `await core.scan()`, query the saved aggregates without another scan: + ```typescript // Today only -const today = await core.scan({ today: true }); +const today = core.getSummary({ today: true }); -// Last 7 days -const week = await core.scan({ week: true }); +// Last seven local calendar days, including saved history +const week = core.getSummary({ week: true }); -// Specific date range -const range = await core.scan({ +// Inclusive local calendar date range +const range = core.getSummary({ since: "2025-01-01", until: "2025-01-31", }); // Single provider -const claude = await core.scan({ +const claude = core.getSummary({ providers: ["claude-code"], }); // Single project -const project = await core.scan({ +const project = core.getSummary({ project: "my-app", }); ``` +Report dates are inclusive `YYYY-MM-DD` values in the local timezone; intraday timestamps are rejected. The default scan return and `summary.records` contain recent raw evidence, while summary totals also include sealed history. See [integration guidance](https://github.com/sriinnu/tokmeter/blob/main/docs/consuming-tokmeter.md#report-filters-and-retained-history). + #### Pricing ```typescript @@ -151,7 +155,9 @@ The TUI provides a real-time interactive view with navigable project/model/daily ## Supported Providers -Claude Code, OpenCode, Codex CLI, Gemini CLI, Cursor, Amp, Droid, OpenClaw, Pi, Kimi, Qwen, Roo Code, Kilo Code, Kilo CLI, Mux, Windsurf, and more. +Claude Code, OpenCode, Codex CLI, Gemini CLI, Cursor, Amp, Droid, OpenClaw, Pi, Kimi, Qwen, Roo Code, Kilo Code, Kilo CLI, Mux, and other implemented parsers. See [integration coverage](https://github.com/sriinnu/tokmeter/blob/main/docs/compatibility.md) for validation scope. + +Editor MCP installer targets are separate from session-parser support; Windsurf is an installer target, not a usage parser. ## Author diff --git a/packages/tokmeter/package.json b/packages/tokmeter/package.json index 56f4f5f..782c1fb 100644 --- a/packages/tokmeter/package.json +++ b/packages/tokmeter/package.json @@ -1,6 +1,6 @@ { "name": "@sriinnu/tokmeter", - "version": "1.10.0", + "version": "1.11.0", "description": "Token usage tracking for AI coding agents — parsers, CLI, and TUI", "type": "module", "main": "dist/core/index.js", diff --git a/packages/tui/README.md b/packages/tui/README.md index 876e557..9e80eb4 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -15,16 +15,19 @@ npx -p @sriinnu/tokmeter tokmeter-tui | View | Key | Description | |------|-----|-------------| | Overview | `1` | Bar charts, sparklines, provider breakdown | -| Models | `2` | Sortable table with inline charts | +| Models | `2` | Model table with inline charts | | Daily | `3` | Sparkline + contribution heatmap | | Stats | `4` | Streaks, averages, activity calendar | +| Cleanup | `5` | Select projects, preview deletion, and confirm cleanup | ## Key Bindings | Key | Action | |-----|--------| -| `1-4` | Switch views | -| `Tab` / arrow keys | Navigate | +| `1-5` | Switch views | +| `Tab` / left/right arrows | Switch views | +| `r` | Refresh usage | +| Up/down arrows / Space | Move through cleanup projects / select | | `q` / `Ctrl+C` | Quit | ## License diff --git a/packages/tui/SKILL.md b/packages/tui/SKILL.md index 329adef..0ea3bf2 100644 --- a/packages/tui/SKILL.md +++ b/packages/tui/SKILL.md @@ -10,7 +10,8 @@ Interactive terminal UI for token usage tracking. Full-screen dashboard with cha - Model comparison table with inline charts - Daily usage trend with contribution heatmap - Statistics view with streaks and averages -- Keyboard navigation (1-4 views, Tab, arrows, q to quit) +- Cleanup view with project selection, preview, and explicit deletion confirmation +- Keyboard navigation (1-5 views, Tab, left/right arrows, r to refresh, q to quit) ## Usage diff --git a/packages/tui/package.json b/packages/tui/package.json index 9a89a64..5a5a3ff 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@sriinnu/tokmeter-tui", - "version": "1.10.0", + "version": "1.11.0", "private": true, "description": "Token usage tracking TUI \u2014 interactive terminal UI with charts", "type": "module", diff --git a/packages/web/README.md b/packages/web/README.md index c6dec1f..61c0e12 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -4,7 +4,9 @@ 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. +The macOS app bundles this dashboard. Choose **Open web dashboard** in its Settings to start the server, and **Stop web dashboard** to stop it. It also stops when the app quits. This mode reads the existing daemon and excludes build-machine usage exports; see [the lifecycle guide](../../docs/macos/web-dashboard.md). + +For source development, this remains a private workspace package: ```bash # From the repository root @@ -16,12 +18,19 @@ Open http://localhost:3000 ### Data -Export usage data from the CLI: +Both the development server and Vite preview serve `/api/summary` by scanning local session data, with a persisted-summary fallback. The browser tries this endpoint before `/data.json`, so an exported file does not override a working live endpoint. + +For a static export, install Python 3, then run from the repository root: ```bash -tokmeter --json > packages/web/public/data.json +mkdir -p packages/web/public +npx @sriinnu/tokmeter --json > packages/web/public/data.json +bun run build:web +python3 -m http.server 3000 --bind 127.0.0.1 --directory packages/web/dist ``` +Open http://127.0.0.1:3000. This static server serves the built `data.json` and has no scan endpoint. Re-export and rebuild to update the snapshot. The JSON can contain private project names and usage; review it before sharing the built site. + ## Charts | Chart | Description | diff --git a/packages/web/SKILL.md b/packages/web/SKILL.md index 7328240..adb2109 100644 --- a/packages/web/SKILL.md +++ b/packages/web/SKILL.md @@ -1,6 +1,6 @@ # @sriinnu/tokmeter-web -Private workspace package. Run the web dashboard from this source checkout. +Private workspace package, also bundled with the macOS app. In app Settings, Open web dashboard starts its local server; Stop web dashboard or quitting the app stops it. The app mode forwards read-only summary requests to the usage daemon and ships no usage export. React + Plotly web dashboard for token usage visualization. @@ -14,10 +14,13 @@ React + Plotly web dashboard for token usage visualization. ## Setup ```bash -tokmeter --json > packages/web/public/data.json -cd packages/web && bun run dev +# From the repository root +bun install +bun run dev:web ``` +Development and Vite preview read `/api/summary`, which scans local usage. `/data.json` is a fallback, not an override for the live endpoint. See the [README](README.md#data) for exporting and previewing a static snapshot. + ## License AGPL-3.0-only; see [licenses and source](../../docs/licensing.md). diff --git a/packages/web/package.json b/packages/web/package.json index 6ae7b50..fec0284 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@sriinnu/tokmeter-web", - "version": "1.10.0", + "version": "1.11.0", "private": true, "description": "Token usage tracking web dashboard \u2014 React + Plotly", "type": "module", diff --git a/packages/web/scripts/dashboard-server.mjs b/packages/web/scripts/dashboard-server.mjs new file mode 100644 index 0000000..63181fb --- /dev/null +++ b/packages/web/scripts/dashboard-server.mjs @@ -0,0 +1,116 @@ +import { readFile, realpath } from "node:fs/promises"; +// App-owned, read-only dashboard server. stdin lifetime belongs to the parent. +import { createServer } from "node:http"; +import { extname, resolve, sep } from "node:path"; + +const [assetsArg, portArg = "3000", nonce = "", upstream = "http://127.0.0.1:9877"] = + process.argv.slice(2); +const assets = await realpath(assetsArg); +const mime = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", +}; +const requests = new Set(); +const server = createServer(async (req, res) => { + const reply = (status, type, body) => { + res.writeHead(status, { + "Content-Type": type, + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); + res.end(body); + }; + const address = server.address(); + const allowedHosts = [`localhost:${address.port}`, `127.0.0.1:${address.port}`]; + if ( + !allowedHosts.includes(req.headers.host) || + (req.headers.origin && !allowedHosts.some((host) => req.headers.origin === `http://${host}`)) + ) { + reply(403, "text/plain", "Local dashboard requests only"); + return; + } + if (req.method !== "GET") { + reply(405, "text/plain", "Read-only dashboard"); + return; + } + try { + const path = new URL(req.url, "http://localhost").pathname; + if (path === "/_tokmeter/ready") { + reply(200, "text/plain", nonce); + return; + } + if (path === "/api/summary") { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 15000); + requests.add(controller); + try { + const response = await fetch(`${upstream}/api/summary`, { signal: controller.signal }); + if (!response.ok) throw new Error("Daemon summary unavailable"); + const chunks = []; + let length = 0; + for await (const chunk of response.body) { + length += chunk.length; + if (length > 32 * 1024 * 1024) throw new Error("Summary too large"); + chunks.push(chunk); + } + res.setHeader( + "X-Tokmeter-Summary-Source", + response.headers.get("X-Tokmeter-Summary-Source") || "live" + ); + reply(200, "application/json", Buffer.concat(chunks)); + return; + } finally { + clearTimeout(timeout); + requests.delete(controller); + } + } + // Never serve build-machine usage exports or arbitrary files from Resources. + const page = + ["/", "/index.html", "/projects", "/models", "/timeline", "/3d-view"].includes(path) || + /^\/projects\/[^/]+$/.test(path); + if (!page && !path.startsWith("/assets/")) { + reply(404, "text/plain", "Not found"); + return; + } + const file = await realpath( + resolve(assets, page ? "index.html" : `.${decodeURIComponent(path)}`) + ); + if (!file.startsWith(assets + sep)) { + reply(403, "text/plain", "Outside dashboard assets"); + return; + } + reply(200, mime[extname(file)] || "application/octet-stream", await readFile(file)); + } catch { + reply( + req.url === "/api/summary" ? 503 : 404, + "text/plain", + req.url === "/api/summary" + ? "Usage daemon unavailable. Open Tokmeter and retry." + : "Not found" + ); + } +}); +server.requestTimeout = 20000; +server.headersTimeout = 5000; +server.maxHeadersCount = 32; +server.on("error", () => { + process.exitCode = 1; + process.stdin.destroy(); +}); +server.listen(Number(portArg), "127.0.0.1", () => { + process.stdout.write(`http://127.0.0.1:${server.address().port}\n`); +}); +const stop = () => { + for (const request of requests) request.abort(); + server.close(); + server.closeAllConnections(); + process.stdin.destroy(); +}; +process.stdin.resume(); +process.stdin.on("end", stop); +process.on("SIGTERM", stop); +process.on("SIGINT", stop); diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 0e0935a..3c1aa52 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -17,6 +17,9 @@ function App() {