diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 8794e05..7434bc6 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -1,3 +1,6 @@ #!/bin/sh -node scripts/validate-commit-message.mjs --edit "$1" +set -eu + +cd "$(git rev-parse --show-toplevel)" +exec node scripts/validate-commit-message.mjs --edit "$1" diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..176cee0 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,11 @@ +#!/bin/sh + +set -eu + +cd "$(git rev-parse --show-toplevel)" + +echo "Checking staged changes for whitespace errors..." +GIT_PAGER=cat git --no-pager diff --no-ext-diff --cached --check + +echo "Running pre-commit type checks..." +npm run verify:quick diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..8d3bc3f --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,8 @@ +#!/bin/sh + +set -eu + +cd "$(git rev-parse --show-toplevel)" + +echo "Running pre-push verification..." +npm run verify diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a8ee7d..7c1dc86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,21 @@ name: CI on: pull_request: branches: [main, dev] + push: + branches: [main, dev] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: validate: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - uses: actions/checkout@v4 with: @@ -21,10 +32,29 @@ jobs: webview-ui/package-lock.json - name: Validate PR title - run: node scripts/validate-commit-message.mjs --message "${{ github.event.pull_request.title }}" --source "pull request title" + if: github.event_name == 'pull_request' + env: + PR_TITLE: ${{ github.event.pull_request.title }} + run: npm run lint:commit-msg -- --message "$PR_TITLE" --source "pull request title" - name: Validate PR commit messages - run: node scripts/validate-commit-range.mjs "${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" + if: github.event_name == 'pull_request' + env: + COMMIT_RANGE: ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} + run: npm run lint:commits -- "$COMMIT_RANGE" + + - name: Validate pushed commit messages + if: github.event_name == 'push' + env: + BEFORE_SHA: ${{ github.event.before }} + AFTER_SHA: ${{ github.sha }} + run: | + if git cat-file -e "$BEFORE_SHA^{commit}" 2>/dev/null; then + COMMIT_RANGE="$BEFORE_SHA..$AFTER_SHA" + else + COMMIT_RANGE="$AFTER_SHA" + fi + npm run lint:commits -- "$COMMIT_RANGE" - name: Install root deps run: npm ci @@ -33,19 +63,5 @@ jobs: run: npm ci working-directory: webview-ui - - name: Type check - run: npx tsc --noEmit - - - name: Type check webview-ui - run: npx tsc --noEmit - working-directory: webview-ui - - - name: Run backend tests - run: npx vitest run --coverage - - - name: Run UI tests - run: npx vitest run --coverage - working-directory: webview-ui - - - name: Build - run: npm run build + - name: Verify project + run: npm run verify diff --git a/CLAUDE.md b/CLAUDE.md index cfc515d..2ea81c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,9 @@ Data flows one way: `JSONL files → FileWatcher → DashboardStore → Panel.bu | `parsers/SettingsParser.ts` | Reads `CLAUDE.md`, `.claude/settings.json`, `.mcp.json`, `.claude/commands/*.md` | | `watchers/FileWatcher.ts` | `fs.watch()` on `~/.claude/projects/*.jsonl`, debounces 300ms, calls `store.onFileChanged()` | | `watchers/EventWatcher.ts` | Polls `~/.claude/.dashboard-events.jsonl` every 500ms for live tool_use/session_stop events | -| `hooks/HookManager.ts` | Injects PostToolUse/Stop hooks into `~/.claude/settings.json` (with user consent) | +| `hooks/HookManager.ts` | Injects/removes PostToolUse+Stop hooks in `~/.claude/settings.json` (with user consent). Hooks are armed by a `~/.claude/.dashboard-live` marker and no-op without it | +| `hooks/stripDashboardHooks.ts` | Pure helper that strips dashboard hook entries from a parsed settings object (shared with uninstall) | +| `uninstall.ts` | `vscode:uninstall` script — removes hooks, marker, and event file after uninstall | | `alerts/AlertManager.ts` | Budget alerts (80%/100%) and weekly Monday digest | | `webviews/DashboardPanel.ts` | Singleton webview for main dashboard. `buildState()` assembles all analytics | | `webviews/ProjectPanel.ts` | Per-project webview (`Map`). Strips turns from sessions (loaded on demand via `getSessionTurns` message) | @@ -103,42 +105,46 @@ Data flows one way: `JSONL files → FileWatcher → DashboardStore → Panel.bu | View | File | Description | |---|---|---| -| Dashboard | `views/Dashboard.tsx` | 3 tabs: Overview (recap, stats, projects), Charts (usage, cost), Insights (heatmap, efficiency, tools, hot files) | -| ProjectDetail | `views/ProjectDetail.tsx` | 12 tabs: Sessions, Weekly, CLAUDE.md, Commands, Tools, MCP Servers, Subagents, Files, Memory, Todos, Commits, Settings | +| Dashboard | `views/Dashboard.tsx` | 3 tabs: Home (budget, stats, weekly recap, projects), Analytics (spend/patterns/tools/efficiency with a 7/30/90-day range control), Sessions (cross-project browser + prompt search) | +| ProjectDetail | `views/ProjectDetail.tsx` | 5 tabs: Overview (stat cards, recent sessions, quick links), Sessions (day-grouped list + subagent toggle + detail), Activity (usage trend, files, tools, commits), Setup (CLAUDE.md, memory, commands, MCP, automation), Work (plans + todos — hidden when both empty) | | Sidebar | `views/Sidebar.tsx` | Compact project list grouped by Active/Recent/Older with stats | **Components (all in `components/`):** | Component | Used in | |---|---| -| `SessionList.tsx`, `SessionDetail.tsx` | ProjectDetail → Sessions tab | -| `WeeklyStatsTab.tsx` | ProjectDetail → Weekly tab | -| `MarkdownView.tsx` (+ `CommandBlock`) | ProjectDetail → CLAUDE.md & Commands tabs | -| `ToolUsageBar.tsx` | ProjectDetail → Tools tab, Dashboard → Insights | -| `UsageLineChart.tsx` | Dashboard → Charts (30-day line) | -| `ProjectBarChart.tsx` | Dashboard → Charts (by project) | -| `HeatmapGrid.tsx` | Dashboard → Insights | -| `PatternChart.tsx` | Dashboard → Insights | -| `ProductivityChart.tsx` | Dashboard → Insights | -| `EfficiencyCards.tsx` | Dashboard → Insights | -| `HotFilesList.tsx` | Dashboard → Insights | -| `RecentChanges.tsx` | Dashboard → Insights | -| `ProjectCard.tsx` | Dashboard → Overview | -| `PromptSearch.tsx` | Dashboard → Search tab | -| `ActiveSessionCard.tsx`, `LiveSessionBanner.tsx` | Dashboard → Overview | -| `ActivityFeed.tsx` | Dashboard | +| `SessionDetail.tsx` (+ `modelLabel`, `modelBadgeColor`) | ProjectDetail → Sessions tab; includes the copy-resume-command button | +| `SessionsBrowser.tsx` | Dashboard → Sessions tab (cross-project rows + backend-served prompt search) | +| `WeeklyStatsTab.tsx` | ProjectDetail → Activity (usage trend) | +| `MarkdownView.tsx` (+ `CommandBlock`) | ProjectDetail → Setup (CLAUDE.md, memory, commands) | +| `ToolUsageBar.tsx` | ProjectDetail → Activity, Dashboard → Analytics | +| `UsageLineChart.tsx` | Dashboard → Analytics (30-day line) | +| `ProjectBarChart.tsx` | Dashboard → Analytics (by project) | +| `HeatmapGrid.tsx` | Dashboard → Analytics | +| `PatternChart.tsx` | Dashboard → Analytics | +| `ProductivityChart.tsx` | Dashboard → Analytics | +| `EfficiencyCards.tsx` | Dashboard → Analytics | +| `HotFilesList.tsx` | Dashboard → Analytics | +| `RecentChanges.tsx` | Dashboard → Analytics | +| `PromptSearch.tsx` | Deprecated (superseded by `SessionsBrowser`); pending deletion | ### Message Protocol **Backend → Webview:** -- `{ type: 'stateUpdate', payload: {...} }` — full or partial state refresh -- `{ type: 'liveEvent', payload: { type, tool?, projectId?, sessionId?, timestamp } }` — real-time hook events +- `{ type: 'stateUpdate', payload: {...} }` — full or partial state refresh (dashboard payload includes `showTour`) +- `{ type: 'liveEvent', payload: { type, tool?, projectId?, sessionId?, timestamp } }` — real-time hook events (App.tsx ignores these for state; the store follows each with a debounced `stateUpdate`) - `{ type: 'sessionTurns', sessionId, turns[] }` — lazy-loaded turn data +- `{ type: 'allSessions', sessions[] }` — cross-project `SessionRow[]` for the dashboard Sessions tab +- `{ type: 'promptSearchResults', query, results[] }` — backend-served prompt search +- `{ type: 'selectSession', sessionId }` — deep-link: select a session in an already-open ProjectPanel **Webview → Backend:** -- `{ type: 'openDashboard' }` / `{ type: 'openProject', projectId }` +- `{ type: 'openDashboard' }` / `{ type: 'openProject', projectId, sessionId? }` - `{ type: 'getSessionTurns', sessionId }` — triggers `sessionTurns` response +- `{ type: 'getAllSessions' }` / `{ type: 'searchPrompts', query }` — dashboard Sessions tab - `{ type: 'exportSessions', format: 'json' | 'csv' }` +- `{ type: 'setBudget' }` — opens the budget input box; `{ type: 'dismissTour' }`; `{ type: 'refresh' }` +- `{ type: 'openFile', path }` / `{ type: 'openFolder', path }` — every rendered file/folder is a door ### State Management @@ -160,7 +166,7 @@ Session turns are **lazy-loaded**: initial state ships sessions with `turns: []` - **ClaudeCommit** — `{ hash, shortHash, author, date, subject, filesChanged }` - **HookConfig** — `{ event, matcher?, command }` -Other types: `DailyUsage`, `ProjectUsage`, `HeatmapCell`, `PatternCount`, `ToolUsageStat`, `HotFile`, `ProjectedCost`, `StreakData`, `EfficiencyStats`, `WeeklyRecap`, `RecentFileChange`, `ProductivityHour`, `BudgetStatus`, `ProjectFile`, `ProjectToolCall`, `WeeklyProjectStats`, `McpServer`, `PromptSearchResult` +Other types: `DailyUsage`, `ProjectUsage`, `HeatmapCell`, `PatternCount`, `ToolUsageStat`, `HotFile`, `ProjectedCost`, `StreakData`, `EfficiencyStats`, `WeeklyRecap`, `RecentFileChange`, `ProductivityHour`, `BudgetStatus`, `ProjectFile`, `ProjectToolCall`, `WeeklyProjectStats`, `McpServer`, `PromptSearchResult`, `SessionRow`. Session/SessionRow also carry `pricingConfidence: 'exact' | 'fallback'`. ### How to Add a New ProjectDetail Tab @@ -180,7 +186,7 @@ Other types: `DailyUsage`, `ProjectUsage`, `HeatmapCell`, `PatternCount`, `ToolU 2. **Add to `DashboardPanel.buildState()`** return object 3. **Add interface** to `types.ts`, add prop to `Dashboard.tsx` 4. **Add to `App.tsx`** dashboard destructuring -5. **Render** in the appropriate tab (Overview/Charts/Insights) in `Dashboard.tsx` +5. **Render** in the appropriate tab (Home/Analytics/Sessions) in `Dashboard.tsx` ### Testing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f0deba3..60b27b2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -198,7 +198,17 @@ chore: bump vsce to 2.24 Keep the subject line under 72 characters. Use the body for the "why" when needed. -Running `npm install` in the repo configures a local `commit-msg` hook that rejects non-conventional commit subjects before Git creates the commit. +Running `npm install` configures the repository's native Git hooks from `.githooks`: + +| Hook | Checks | +|---|---| +| `pre-commit` | Staged whitespace errors and TypeScript in the extension and webview | +| `commit-msg` | Conventional Commit format and the 72-character subject limit | +| `pre-push` | TypeScript, coverage thresholds, all tests, and the production build | + +Run `npm run hooks:install` to restore the hook configuration manually. Run `npm run verify:quick` for the pre-commit checks or `npm run verify` for the complete pre-push/CI suite. + +Git hooks are a fast local safeguard, while GitHub Actions repeats the commit validation and full verification for pull requests and pushes to `main` or `dev`. --- diff --git a/PRODUCT.md b/PRODUCT.md index 05e7c0b..12af0a0 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -53,56 +53,46 @@ Knowing whether Claude is actually running right now (not just "was active 20 mi ## Feature Inventory ### Status Bar -- Live token count and estimated cost for today -- Session count indicator (pulses when a session is active) -- Clicks open the full dashboard +- Compact by default: active-session count + today's estimated cost (`✱ 3 · $4.32`). `full` mode adds a label and today's tokens; `off` hides it (setting `claudeDashboard.statusBar`). +- Rich markdown tooltip: today's tokens/cost, project/active counts, and a budget line when a budget is set. +- Turns amber (`statusBarItem.warningBackground`) once monthly spend passes 80% of budget — the always-visible guardrail. +- Clicks open the full dashboard. ### Sidebar (Activity Bar) -- Project list grouped into: Active Now / Recent (last 7 days) / All Projects -- Each project shows last-active time and live indicator -- Single click opens the project detail view -- Refresh button triggers a full re-scan - -### Dashboard — Overview Tab -- Budget alert banner (yellow at 80%, red at 100%) -- Weekly recap card: sessions, projects, tokens, estimated cost, files modified, top project, streak -- Stats strip: tokens today / estimated cost today / tokens this week / estimated cost this week -- Active project cards with live pulse animation -- Filterable, sortable project list (filter by name; sort by last active, estimated cost, or session count) - -### Dashboard — Charts Tab -- Token usage over the last 30 days (line chart) -- Token usage by project (bar chart) -- Projected monthly estimated cost with progress bar -- Estimated cost by project this month (horizontal bar chart) - -### Dashboard — Search Tab -- Full-text search across every user prompt ever written -- Results show project name, session ID, and a highlighted snippet -- Searching "refactor auth" finds every time you asked Claude to do that across all projects - -### Dashboard — Insights Tab -- Prompt category breakdown: Fix/Bug, Explain, Refactor, Feature, Test, Other -- Usage heatmap by hour and day of week -- Efficiency stats: avg tokens per prompt, avg tool calls per session, avg session duration, first-turn resolution rate -- Tool usage breakdown with percentage bars -- Productivity by hour (avg tool calls and files modified per session) -- Hot files: the 15 most-edited files across all sessions -- Recent file changes (last 7 days) - -### Project Detail View -- Project header with tech stack badges, path, and live indicator -- Stats: total tokens, estimated cost, session count -- Sessions tab: chronological session list with: - - Session summary line (first user prompt as italic preview) - - Extended thinking badge (⚡) for sessions that used thinking mode - - Duration, token count, prompt count - - Subagent cost indicator when subagents were spawned - - Session detail pane: full stats, files touched, turn-by-turn conversation - - Cache hit rate and thinking token counts in session metadata -- CLAUDE.md tab: project instructions displayed verbatim -- MCP Servers tab: all configured MCP servers for the project with command/URL -- Export: JSON or CSV export of all session data +- Rebuilt on the shared Tailwind/VS Code-variable system (no more private inline styles). +- Project list grouped into: Active / Recent (last 7 days) / Older, collapsible. +- Each row shows a second line: relative last-active time · tokens · estimated cost; a hover button reveals the folder in the OS. +- Single click opens the project detail view; a "View Dashboard" button opens the full dashboard. + +### Dashboard — Home Tab +- Optional first-run welcome strip (local-only / cost-estimate / enable-live-tracking), dismissible. +- Full-screen empty state when no `~/.claude` data exists yet, with a Refresh action. +- Budget: a progress bar whenever a budget is set, a banner at ≥80%, and an inline "Set a monthly budget" affordance that writes the VS Code setting. +- Weekly recap card: sessions, projects, tokens, estimated cost, files modified, top project, streak. +- Stats strip: tokens today / estimated cost today / tokens this week / estimated cost this week. +- Active project cards with live pulse animation. +- Filterable, sortable project list with honest truncation ("Show all N" past 20). + +### Dashboard — Analytics Tab +- A single tab that merges the former Charts and Insights, with a 7 / 30 / 90-day range control applied to the usage chart (its title names the active range). +- Spend: token usage line, usage by project, projected monthly cost with progress bar. +- Patterns: prompt categories, usage heatmap by hour/day, productivity by hour. +- Efficiency: avg tokens per prompt, avg tool calls per session, avg session duration, first-turn resolution rate. +- Tools & files: tool usage breakdown, hot files (15 most-edited), recent file changes (last 7 days). + +### Dashboard — Sessions Tab +- The promised cross-project browser: every session across all projects, sortable (recent / most expensive / most tokens) and filterable by project and model. +- "Most expensive this month" quick filter — answers the #1 cost question directly. +- Full-text prompt search, **backend-served** (`searchPrompts` → `promptSearchResults`), with highlighted matches. No prompt corpus is shipped in the dashboard payload. +- Clicking a session or a search hit opens the owning project with that session pre-selected. + +### Project Detail View — 5 tabs +- Header: name, live indicator, tech-stack badges, path (click reveals the folder), single Export dropdown (JSON / CSV). +- **Overview**: stat cards (tokens, estimated cost, sessions, last active), the 3 most recent sessions, and quick links into the other tabs. +- **Sessions**: day-grouped list titled by the first prompt (with model/thinking SVG chips and a second metadata line), a filter box, a Subagents toggle that nests delegated runs, and the turn-by-turn detail pane. The detail header includes a one-click "copy resume command" (`claude --resume `). +- **Activity**: usage trend, files touched (each opens in the editor), tool usage + recent calls, and Claude co-authored commits. +- **Setup**: CLAUDE.md, memory (index + referenced files), custom commands, MCP servers, and automation (hooks + project settings). +- **Work**: plans and todos — the tab is hidden entirely when both are empty. ### Alert System - Monthly token budget: alert when monthly token count exceeds configured limit @@ -111,10 +101,11 @@ Knowing whether Claude is actually running right now (not just "was active 20 mi - All alerts fire as VS Code notifications; max once per day to avoid spam ### Real-Time Hook System -- On first activation, offers to inject `PostToolUse` and `Stop` hooks into `~/.claude/settings.json` -- Hooks append events to `~/.claude/.dashboard-events.jsonl` -- EventWatcher polls that file every 500ms and pushes live events to the React frontend -- Enables "Claude is using the Bash tool right now" visibility +- On first activation, asks once whether to enable live tracking (Enable / Not now / Never). Any explicit choice is persisted — the dialog never re-asks. +- With consent, injects `PostToolUse` and `Stop` hooks into `~/.claude/settings.json` (backup saved first). Hooks append events to `~/.claude/.dashboard-events.jsonl`. +- Injected hooks are armed by a `~/.claude/.dashboard-live` marker file and no-op without it, so stale hook entries can never write events. +- Toggle anytime via the `Enable Live Tracking` / `Disable Live Tracking` commands; disabling strips the hooks from settings.json. Uninstalling the extension runs a cleanup script that removes the hooks, marker, and event file. +- EventWatcher polls the event file every 500ms and pushes live events to the React frontend, enabling "Claude is using the Bash tool right now" visibility --- @@ -127,6 +118,7 @@ Knowing whether Claude is actually running right now (not just "was active 20 mi | `~/.claude/sessions/` | Live session metadata (pid, sessionId, cwd) | | `~/.claude/settings.json` | Global Claude settings and MCP server config | | `~/.claude/.dashboard-events.jsonl` | Live hook events (created by extension) | +| `~/.claude/.dashboard-live` | Marker that arms the injected hooks (created/removed by extension) | | `/.claude/settings.local.json` | Per-project MCP and settings | | `/CLAUDE.md` | Project instructions | @@ -139,7 +131,8 @@ The extension never writes to any of these files except `settings.json` (to inje - Token counts come from Claude's local JSONL session logs. - Displayed `totalTokens` exclude cache-read tokens, because cache reads can dwarf the meaningful token count in long sessions. - Estimated cost is computed locally from parsed token usage, detected model family, and a static pricing table bundled with the extension. -- Model detection is heuristic: `opus` maps to Opus pricing, `haiku` maps to Haiku pricing, and everything else falls back to Sonnet pricing. +- Model detection maps the recorded model ID to a pricing family (Fable/Mythos, current Opus, legacy Opus 4.0/4.1, Sonnet, Haiku 4.5, legacy Haiku). Unknown models fall back to Sonnet pricing and the session is marked `est.*` in the UI (`pricingConfidence: 'fallback'`). +- The bundled pricing table records its last-updated date (2026-06), shown in the cost disclaimer. - Aggregate estimated cost includes subagent-attributed cost when Claude spawns subagents. - These numbers are helpful operational estimates, but they are not guaranteed to match Anthropic billing, invoices, or future price changes exactly. @@ -151,7 +144,7 @@ The extension never writes to any of these files except `settings.json` (to inje - It does not store data in a database or send it to any server. - It does not require an API key. - It does not work if you do not have Claude Code installed (`~/.claude/` must exist). -- It does not show content from sessions that happened before installation — it reads historical data from existing JSONL files, so past sessions are visible retroactively. +- It shows sessions from before installation too — historical JSONL files in `~/.claude/projects/` are parsed on first load, so past sessions are visible retroactively. --- diff --git a/README.md b/README.md index 495a8fc..ea625d8 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,9 @@ Search for **Claude Code Dashboard** in the VS Code Extensions panel, or install 1. Click the **pulse icon** in the VS Code activity bar to open the Claude Projects sidebar. 2. The full dashboard opens automatically to the right. -3. On first run you'll be prompted to enable real-time hooks — choose **"Yes, configure hooks"** for live session tracking. A backup of `~/.claude/settings.json` is made first. +3. On first run you'll be asked once whether to enable live tracking (adds two hooks to `~/.claude/settings.json`; a backup is made first). Choose **Enable**, **Not now**, or **Never** — your choice is remembered and the dialog never re-asks. -> Skipping hooks is fine — the dashboard still shows all historical data. You only lose the live "Claude is running" indicator. +> Skipping hooks is fine — the dashboard still shows all historical data. You only lose the live "Claude is running" indicator. Toggle later anytime with **Claude Code Dashboard: Enable Live Tracking** / **Disable Live Tracking** in the command palette. Uninstalling the extension automatically removes the hooks and event file. --- @@ -54,7 +54,6 @@ Search for **Claude Code Dashboard** in the VS Code Extensions panel, or install |---|---| | **Overview** | Weekly recap, today's tokens & estimated cost, active sessions, full project list | | **Charts** | 30-day token trend, usage by project, projected monthly estimated cost | -| **Search** | Full-text search across every prompt you've ever sent to Claude | | **Insights** | Usage heatmap, tool breakdown, productivity by hour, hot files | **Project detail view** — click any project to see its full session history, turn-by-turn conversation, token breakdown, estimated cost, files touched, CLAUDE.md, and MCP servers. Export to JSON or CSV any time. @@ -63,7 +62,7 @@ Search for **Claude Code Dashboard** in the VS Code Extensions panel, or install - Token counts come from local Claude session logs in `~/.claude/projects/`. - Displayed `totalTokens` exclude cache-read tokens, which Claude may reuse heavily across long sessions. -- Cost is a local estimate based on detected model family, a static pricing table, and parsed token usage. +- Cost is a local estimate based on detected model family, a static pricing table (last updated 2026-06), and parsed token usage. Sessions on unknown models are estimated at Sonnet rates and marked `est.*` in the UI. - Aggregate cost views include subagent-attributed cost when Claude spawns subagents. - Estimated cost may differ from Anthropic billing, invoices, or future pricing changes. @@ -86,7 +85,7 @@ Search for **Claude Code Dashboard** in VS Code settings (`Cmd+,` / `Ctrl+,`). — Verify `~/.claude/projects/` exists, then run **Claude Code Dashboard: Refresh** from the command palette. Check the Output panel (select "Claude Code Dashboard") for errors. **Sessions not updating in real time** -— Confirm hooks were configured at first run. Check `~/.claude/settings.json` for entries referencing `.dashboard-events.jsonl`. The file watcher fallback still updates within ~300ms. +— Run **Claude Code Dashboard: Enable Live Tracking** from the command palette, then check `~/.claude/settings.json` for entries referencing `.dashboard-events.jsonl`. The file watcher fallback still updates within ~300ms. **Cost numbers look off** — Cost is estimated locally from parsed token usage, detected model family, and a static pricing table. Cache read tokens are tracked separately and excluded from `totalTokens`, but still contribute to estimated cost. @@ -102,7 +101,10 @@ The extension only reads files in `~/.claude/` and your project directories. No - `~/.claude/settings.json` — hook config (with your consent; backup made first) - `~/.claude/.dashboard-events.jsonl` — live events from the injected hooks -- `~/.claude/settings.json.bak` — backup before hook injection +- `~/.claude/.dashboard-live` — marker file that arms the hooks (deleting it disables them instantly) +- `~/.claude/settings.json.bak` — backup before hook injection or removal + +Disabling live tracking (or uninstalling the extension) removes the hooks, the marker, and the event file. --- diff --git a/esbuild.js b/esbuild.js index 880cecd..03cbd30 100644 --- a/esbuild.js +++ b/esbuild.js @@ -2,9 +2,9 @@ const esbuild = require('esbuild'); const watch = process.argv.includes('--watch'); const ctx = esbuild.context({ - entryPoints: ['src/extension.ts'], + entryPoints: ['src/extension.ts', 'src/uninstall.ts'], bundle: true, - outfile: 'dist/extension.js', + outdir: 'dist', external: ['vscode'], format: 'cjs', platform: 'node', diff --git a/package.json b/package.json index 25a6e01..d372a55 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,16 @@ "title": "Refresh", "category": "Claude Code Dashboard", "icon": "$(refresh)" + }, + { + "command": "claudeDashboard.enableLiveTracking", + "title": "Enable Live Tracking (Add Hooks)", + "category": "Claude Code Dashboard" + }, + { + "command": "claudeDashboard.disableLiveTracking", + "title": "Disable Live Tracking (Remove Hooks)", + "category": "Claude Code Dashboard" } ], "menus": { @@ -92,27 +102,42 @@ "type": "number", "default": 0, "description": "Monthly cost budget in USD. Alerts fire at 80% and 100%. Set to 0 to disable." + }, + "claudeDashboard.statusBar": { + "type": "string", + "enum": ["compact", "full", "off"], + "default": "compact", + "description": "Status bar display: compact (active count + today's cost), full (adds label and tokens), or off." + }, + "claudeDashboard.weeklyDigest": { + "type": "boolean", + "default": true, + "description": "Show a weekly usage digest notification (fires at most once every 7 days)." } } } }, "scripts": { - "build": "node esbuild.js && cd webview-ui && npm run build", + "build": "node esbuild.js && npm --prefix webview-ui run build", "build:ext": "node esbuild.js", - "build:ui": "cd webview-ui && npm run build", + "build:ui": "npm --prefix webview-ui run build", "watch:ext": "node esbuild.js --watch", - "watch:ui": "cd webview-ui && npm run dev", + "watch:ui": "npm --prefix webview-ui run dev", "prepare": "node scripts/setup-git-hooks.mjs", + "hooks:install": "node scripts/setup-git-hooks.mjs", "typecheck": "tsc --noEmit", - "typecheck:all": "npm run typecheck && cd webview-ui && npm run typecheck", + "typecheck:all": "npm run typecheck && npm --prefix webview-ui run typecheck", "vscode:prepublish": "npm run build", + "vscode:uninstall": "node ./dist/uninstall.js", "package": "vsce package", "lint:commit-msg": "node scripts/validate-commit-message.mjs", "lint:commits": "node scripts/validate-commit-range.mjs", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "test:all": "npm test && cd webview-ui && npm test" + "test:all": "npm test && npm --prefix webview-ui test", + "verify:quick": "npm run typecheck:all", + "verify": "npm run verify:quick && npm run test:coverage && npm --prefix webview-ui run test:coverage && npm run build" }, "devDependencies": { "@types/node": "^20.0.0", diff --git a/scripts/setup-git-hooks.mjs b/scripts/setup-git-hooks.mjs index f8176c5..a8c6c93 100644 --- a/scripts/setup-git-hooks.mjs +++ b/scripts/setup-git-hooks.mjs @@ -1,16 +1,42 @@ +import fs from 'node:fs'; +import path from 'node:path'; import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +if (process.env.CI) { + console.log('Skipping git hook setup in CI.'); + process.exit(0); +} + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +let gitRoot; try { - execFileSync('git', ['rev-parse', '--is-inside-work-tree'], { - stdio: 'ignore', - }); + gitRoot = execFileSync('git', ['-C', projectRoot, 'rev-parse', '--show-toplevel'], { + encoding: 'utf8', + }).trim(); } catch { console.log('Skipping git hook setup outside a git worktree.'); process.exit(0); } -execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { +if (path.resolve(gitRoot) !== projectRoot) { + console.log('Skipping git hook setup because this package is not the repository root.'); + process.exit(0); +} + +const hooksPath = path.join(projectRoot, '.githooks'); +const hookNames = ['pre-commit', 'commit-msg', 'pre-push']; + +for (const hookName of hookNames) { + const hookPath = path.join(hooksPath, hookName); + if (!fs.existsSync(hookPath)) { + throw new Error(`Missing required git hook: .githooks/${hookName}`); + } + fs.chmodSync(hookPath, 0o755); +} + +execFileSync('git', ['-C', projectRoot, 'config', '--local', 'core.hooksPath', '.githooks'], { stdio: 'ignore', }); -console.log('Configured git hooks to use .githooks'); +console.log(`Configured ${hookNames.join(', ')} hooks from .githooks`); diff --git a/src/__tests__/__mocks__/vscode.ts b/src/__tests__/__mocks__/vscode.ts index 0e2301a..1bc4cad 100644 --- a/src/__tests__/__mocks__/vscode.ts +++ b/src/__tests__/__mocks__/vscode.ts @@ -5,7 +5,9 @@ const _state: Record = {}; export const workspace = { getConfiguration: vi.fn(() => ({ get: vi.fn((key: string, defaultValue?: unknown) => defaultValue), + update: vi.fn(() => Promise.resolve()), })), + onDidChangeConfiguration: vi.fn(() => ({ dispose: vi.fn() })), fs: { writeFile: vi.fn(), }, @@ -15,6 +17,7 @@ export const window = { showInformationMessage: vi.fn(), showWarningMessage: vi.fn(), showErrorMessage: vi.fn(), + showInputBox: vi.fn(), showSaveDialog: vi.fn(), createStatusBarItem: vi.fn(() => ({ text: '', @@ -55,6 +58,20 @@ export const Uri = { })), }; +export class MarkdownString { + constructor(public value: string = '') {} +} + +export class ThemeColor { + constructor(public id: string) {} +} + +export enum ConfigurationTarget { + Global = 1, + Workspace = 2, + WorkspaceFolder = 3, +} + export enum StatusBarAlignment { Left = 1, Right = 2, diff --git a/src/__tests__/extension.test.ts b/src/__tests__/extension.test.ts index 54d9980..05add5f 100644 --- a/src/__tests__/extension.test.ts +++ b/src/__tests__/extension.test.ts @@ -28,7 +28,7 @@ const mockStore = { const mockFileWatcher = { start: vi.fn() }; const mockEventWatcher = { start: vi.fn() }; -const mockHookManager = { injectHooks: vi.fn(() => Promise.resolve()), needsReinjection: vi.fn(() => false) }; +const mockHookManager = { injectHooks: vi.fn(() => Promise.resolve()), removeHooks: vi.fn(() => Promise.resolve()), needsReinjection: vi.fn(() => false) }; const mockAlertManager = { checkWeeklyDigest: vi.fn() }; const mockSidebarProvider = { setSelectedProject: vi.fn(), clearSelectedProject: vi.fn(), enableAutoOpen: vi.fn() }; @@ -72,7 +72,7 @@ describe('extension activate', () => { expect(DashboardStore).toHaveBeenCalledOnce(); expect(HookManager).toHaveBeenCalledOnce(); expect(vscode.window.registerWebviewViewProvider).toHaveBeenCalledWith('claudeDashboard.sidebar', expect.anything(), expect.anything()); - expect(vscode.commands.registerCommand).toHaveBeenCalledTimes(4); + expect(vscode.commands.registerCommand).toHaveBeenCalledTimes(6); expect(mockFileWatcher.start).toHaveBeenCalledWith(context); expect(mockEventWatcher.start).toHaveBeenCalledWith(context); expect(mockStore.initialize).toHaveBeenCalledOnce(); @@ -88,18 +88,18 @@ describe('extension activate', () => { return defaultValue; }); vi.mocked(vscode.window.showInformationMessage) - .mockResolvedValueOnce('Yes, configure hooks' as unknown as InfoMessageResult) + .mockResolvedValueOnce('Enable' as unknown as InfoMessageResult) .mockResolvedValueOnce(undefined); await activate(context as unknown as vscode.ExtensionContext); expect(mockHookManager.injectHooks).toHaveBeenCalledWith(context.globalState); - expect(context.globalState.update).toHaveBeenCalledWith('hooksConfigured', true); + expect(context.globalState.update).toHaveBeenCalledWith('hooksConsent', 'enabled'); const openProjectHandler = getRegisteredCommand('claudeDashboard.openProject'); openProjectHandler('p1'); expect(mockSidebarProvider.setSelectedProject).toHaveBeenCalledWith('p1'); - expect(ProjectPanel.createOrShow).toHaveBeenCalledWith(context, mockStore, 'p1'); + expect(ProjectPanel.createOrShow).toHaveBeenCalledWith(context, mockStore, 'p1', undefined); }); it('wires refresh and exportSessions command flows end-to-end', async () => { @@ -163,4 +163,66 @@ describe('extension activate', () => { expect(mockHookManager.injectHooks).toHaveBeenCalledWith(context.globalState); expect(deactivate()).toBeUndefined(); }); + + it('persists "Not now" so the consent dialog never re-asks', async () => { + const context = createMockExtensionContext(); + context.globalState.get.mockImplementation((key: string, defaultValue?: unknown) => { + if (key === 'hooksConsent') return undefined; + if (key === 'hooksConfigured') return false; + return defaultValue; + }); + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce('Not now' as unknown as InfoMessageResult); + + await activate(context as unknown as vscode.ExtensionContext); + + expect(mockHookManager.injectHooks).not.toHaveBeenCalled(); + expect(context.globalState.update).toHaveBeenCalledWith('hooksConsent', 'declined'); + }); + + it('persists "Never" and skips the dialog once any consent is stored', async () => { + const context = createMockExtensionContext(); + context.globalState.get.mockImplementation((key: string, defaultValue?: unknown) => { + if (key === 'hooksConsent') return undefined; + if (key === 'hooksConfigured') return false; + return defaultValue; + }); + vi.mocked(vscode.window.showInformationMessage).mockResolvedValueOnce('Never' as unknown as InfoMessageResult); + + await activate(context as unknown as vscode.ExtensionContext); + expect(context.globalState.update).toHaveBeenCalledWith('hooksConsent', 'never'); + + // Second activation with stored consent: no dialog at all + vi.clearAllMocks(); + const context2 = createMockExtensionContext(); + context2.globalState.get.mockImplementation((key: string, defaultValue?: unknown) => { + if (key === 'hooksConsent') return 'never'; + return defaultValue; + }); + + await activate(context2 as unknown as vscode.ExtensionContext); + + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + expect(mockHookManager.injectHooks).not.toHaveBeenCalled(); + }); + + it('enable/disable live tracking commands inject and remove hooks', async () => { + const context = createMockExtensionContext(); + context.globalState.get.mockImplementation((key: string, defaultValue?: unknown) => { + if (key === 'hooksConsent') return 'declined'; + return defaultValue; + }); + + await activate(context as unknown as vscode.ExtensionContext); + + const enableHandler = getRegisteredCommand('claudeDashboard.enableLiveTracking'); + const disableHandler = getRegisteredCommand('claudeDashboard.disableLiveTracking'); + + await enableHandler(); + expect(mockHookManager.injectHooks).toHaveBeenCalledWith(context.globalState); + expect(context.globalState.update).toHaveBeenCalledWith('hooksConsent', 'enabled'); + + await disableHandler(); + expect(mockHookManager.removeHooks).toHaveBeenCalledWith(context.globalState); + expect(context.globalState.update).toHaveBeenCalledWith('hooksConsent', 'declined'); + }); }); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts new file mode 100644 index 0000000..8f28bf9 --- /dev/null +++ b/src/__tests__/uninstall.test.ts @@ -0,0 +1,63 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('fs'); +vi.mock('os', () => ({ homedir: vi.fn() })); + +describe('uninstall cleanup', () => { + beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/home/test'); + }); + + it('removes runtime files and strips only dashboard hooks from settings', async () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ + hooks: { + PostToolUse: [ + { hooks: [{ command: 'append .dashboard-events.jsonl' }] }, + { hooks: [{ command: 'echo keep-me' }] }, + ], + Stop: [{ hooks: [{ command: 'append .dashboard-events.jsonl' }] }], + }, + theme: 'dark', + })); + + await import('../uninstall'); + + expect(fs.unlinkSync).toHaveBeenNthCalledWith(1, '/home/test/.claude/.dashboard-live'); + expect(fs.unlinkSync).toHaveBeenNthCalledWith(2, '/home/test/.claude/.dashboard-events.jsonl'); + expect(fs.readFileSync).toHaveBeenCalledWith('/home/test/.claude/settings.json', 'utf-8'); + expect(fs.writeFileSync).toHaveBeenCalledOnce(); + const written = JSON.parse(String(vi.mocked(fs.writeFileSync).mock.calls[0][1])); + expect(written).toEqual({ + hooks: { PostToolUse: [{ hooks: [{ command: 'echo keep-me' }] }] }, + theme: 'dark', + }); + }); + + it('does not rewrite settings when no dashboard hooks exist', async () => { + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ + hooks: { Stop: [{ hooks: [{ command: 'echo keep-me' }] }] }, + })); + + await import('../uninstall'); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('tolerates missing runtime files and unreadable settings', async () => { + vi.mocked(fs.unlinkSync).mockImplementation(() => { + throw new Error('missing'); + }); + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('unreadable'); + }); + + await expect(import('../uninstall')).resolves.toBeDefined(); + + expect(fs.unlinkSync).toHaveBeenCalledTimes(2); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/alerts/AlertManager.ts b/src/alerts/AlertManager.ts index d73b105..6a2c33f 100644 --- a/src/alerts/AlertManager.ts +++ b/src/alerts/AlertManager.ts @@ -1,5 +1,9 @@ import * as vscode from 'vscode'; import { DashboardStore } from '../store/DashboardStore'; +import { formatTokens, formatCost } from '../utils/format'; + +const OPEN_DASHBOARD = 'Open Dashboard'; +const SNOOZE = 'Snooze this month'; export class AlertManager { private store: DashboardStore; @@ -16,10 +20,30 @@ export class AlertManager { }); } + /** Budget alerts snoozed until the start of next month? */ + private isSnoozed(): boolean { + const until = this.context.globalState.get('budgetSnoozeUntil', 0); + return Date.now() < until; + } + + private snoozeUntilNextMonth() { + const now = new Date(); + const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1).getTime(); + this.context.globalState.update('budgetSnoozeUntil', nextMonth); + } + + private handleAlertAction(choice: string | undefined) { + if (choice === OPEN_DASHBOARD) { + vscode.commands.executeCommand('claudeDashboard.openDashboard'); + } else if (choice === SNOOZE) { + this.snoozeUntilNextMonth(); + } + } + private checkTokenBudget() { const config = vscode.workspace.getConfiguration('claudeDashboard'); const budget = config.get('monthlyTokenBudget', 0); - if (budget <= 0) { return; } + if (budget <= 0 || this.isSnoozed()) { return; } const monthlyTokens = this.store.getMonthlyTokens(); if (monthlyTokens <= budget) { return; } @@ -31,17 +55,17 @@ export class AlertManager { if (now - lastAlert < oneDayMs) { return; } this.context.globalState.update('lastBudgetAlert', now); - const used = (monthlyTokens / 1_000_000).toFixed(2); - const limit = (budget / 1_000_000).toFixed(2); - vscode.window.showWarningMessage( - `Claude Code Dashboard: Monthly token budget exceeded! Used ${used}M of ${limit}M tokens this month.` - ); + void Promise.resolve(vscode.window.showWarningMessage( + `Monthly token budget exceeded: ${formatTokens(monthlyTokens)} of ${formatTokens(budget)} used.`, + OPEN_DASHBOARD, + SNOOZE + )).then(choice => this.handleAlertAction(choice)); } private checkCostBudget() { const config = vscode.workspace.getConfiguration('claudeDashboard'); const budgetUsd = config.get('monthlyBudgetUsd', 0); - if (budgetUsd <= 0) { return; } + if (budgetUsd <= 0 || this.isSnoozed()) { return; } const { costUsd } = this.store.getMonthlyUsage(); const pct = costUsd / budgetUsd; @@ -54,27 +78,32 @@ export class AlertManager { const lastAlert = this.context.globalState.get('lastCostBudgetExceededAlert', 0); if (now - lastAlert < oneDayMs) { return; } this.context.globalState.update('lastCostBudgetExceededAlert', now); - vscode.window.showWarningMessage( - `Claude Code Dashboard: Monthly cost budget exceeded! Spent $${costUsd.toFixed(2)} of $${budgetUsd.toFixed(2)} budget.` - ); + void Promise.resolve(vscode.window.showWarningMessage( + `Monthly cost budget exceeded: ${formatCost(costUsd)} of ${formatCost(budgetUsd)} (estimated).`, + OPEN_DASHBOARD, + SNOOZE + )).then(choice => this.handleAlertAction(choice)); } else { const lastAlert = this.context.globalState.get('lastCostBudget80Alert', 0); if (now - lastAlert < oneDayMs) { return; } this.context.globalState.update('lastCostBudget80Alert', now); - vscode.window.showWarningMessage( - `Claude Code Dashboard: 80% of monthly cost budget used. Spent $${costUsd.toFixed(2)} of $${budgetUsd.toFixed(2)}.` - ); + void Promise.resolve(vscode.window.showWarningMessage( + `80% of monthly cost budget used: ${formatCost(costUsd)} of ${formatCost(budgetUsd)} (estimated).`, + OPEN_DASHBOARD, + SNOOZE + )).then(choice => this.handleAlertAction(choice)); } } checkWeeklyDigest() { - const now = new Date(); - // Only run on Mondays - if (now.getDay() !== 1) { return; } + const config = vscode.workspace.getConfiguration('claudeDashboard'); + if (!config.get('weeklyDigest', true)) { return; } + // Fire on the first activation at least 7 days after the last digest — + // not Mondays-only, which silently skipped users who didn't open VS Code that day. const lastDigest = this.context.globalState.get('lastWeeklyDigest', 0); - const sixDaysMs = 6 * 86_400_000; - if (Date.now() - lastDigest < sixDaysMs) { return; } + const sevenDaysMs = 7 * 86_400_000; + if (Date.now() - lastDigest < sevenDaysMs) { return; } this.context.globalState.update('lastWeeklyDigest', Date.now()); this.showWeeklyDigest(); @@ -86,29 +115,32 @@ export class AlertManager { const projects = this.store.getProjects(); let totalTokens = 0; - const activeProjects = new Set(); + let totalCostUsd = 0; + let sessionCount = 0; + let topProject: { name: string; tokens: number } | null = null; + const perProjectTokens = new Map(); for (const project of projects) { const sessions = this.store.getSessions(project.id); for (const session of sessions) { if (session.startTime >= now - weekMs) { totalTokens += session.totalTokens; - activeProjects.add(project.name); + totalCostUsd += session.costUsd; + sessionCount++; + perProjectTokens.set(project.name, (perProjectTokens.get(project.name) ?? 0) + session.totalTokens); } } } if (totalTokens === 0) { return; } - const tokensStr = totalTokens >= 1_000_000 - ? `${(totalTokens / 1_000_000).toFixed(1)}M` - : `${(totalTokens / 1_000).toFixed(0)}k`; - - const projectList = Array.from(activeProjects).slice(0, 3).join(', '); - const moreProjects = activeProjects.size > 3 ? ` +${activeProjects.size - 3} more` : ''; + for (const [name, tokens] of perProjectTokens) { + if (!topProject || tokens > topProject.tokens) { topProject = { name, tokens }; } + } - vscode.window.showInformationMessage( - `Claude Code Dashboard weekly digest: ${tokensStr} tokens used across ${activeProjects.size} project(s) last week. Projects: ${projectList}${moreProjects}.` - ); + void Promise.resolve(vscode.window.showInformationMessage( + `Last week: ${sessionCount} sessions · ${formatTokens(totalTokens)} tokens · est. ${formatCost(totalCostUsd)} across ${perProjectTokens.size} project${perProjectTokens.size !== 1 ? 's' : ''}${topProject ? ` · top: ${topProject.name}` : ''}.`, + OPEN_DASHBOARD + )).then(choice => this.handleAlertAction(choice)); } } diff --git a/src/alerts/__tests__/AlertManager.test.ts b/src/alerts/__tests__/AlertManager.test.ts index 213e4d3..64e299c 100644 --- a/src/alerts/__tests__/AlertManager.test.ts +++ b/src/alerts/__tests__/AlertManager.test.ts @@ -64,19 +64,41 @@ describe('AlertManager', () => { expect(context.globalState.update).toHaveBeenCalledWith('lastCostBudgetExceededAlert', expect.any(Number)); }); - it('shows weekly digest only on Mondays with activity', () => { + it('shows the weekly digest when 7+ days have passed, with an Open Dashboard action', () => { const manager = new AlertManager(store as unknown as DashboardStore, context as unknown as vscode.ExtensionContext); context.globalState.get.mockReturnValue(0); store.getProjects.mockReturnValue([{ id: 'p1', name: 'Alpha' } as unknown as Project]); - store.getSessions.mockReturnValue([{ startTime: Date.now() - 1000, totalTokens: 12000 } as unknown as Session]); + store.getSessions.mockReturnValue([{ startTime: Date.now() - 1000, totalTokens: 12000, costUsd: 0.5 } as unknown as Session]); manager.checkWeeklyDigest(); expect(context.globalState.update).toHaveBeenCalledWith('lastWeeklyDigest', expect.any(Number)); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(expect.stringContaining('12k tokens used across 1 project(s)')); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining('12.0k tokens'), + 'Open Dashboard' + ); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining('top: Alpha'), + 'Open Dashboard' + ); }); - it('shows the 80% cost warning and summarizes more than three active projects', () => { + it('skips the digest when it was shown less than 7 days ago or disabled by setting', () => { + const manager = new AlertManager(store as unknown as DashboardStore, context as unknown as vscode.ExtensionContext); + context.globalState.get.mockImplementation((key: string) => key === 'lastWeeklyDigest' ? Date.now() - 86_400_000 : 0); + manager.checkWeeklyDigest(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + + // disabled via setting + context.globalState.get.mockReturnValue(0); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, def: unknown) => key === 'weeklyDigest' ? false : def), + } as unknown as vscode.WorkspaceConfiguration); + manager.checkWeeklyDigest(); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + it('shows the 80% cost warning and a multi-project digest', () => { let updateHandler: () => void = () => {}; store.on.mockImplementation((_evt, cb) => { updateHandler = cb; }); const manager = new AlertManager(store as unknown as DashboardStore, context as unknown as vscode.ExtensionContext); @@ -100,8 +122,29 @@ describe('AlertManager', () => { manager.checkWeeklyDigest(); expect(context.globalState.update).toHaveBeenCalledWith('lastCostBudget80Alert', expect.any(Number)); - expect(vscode.window.showWarningMessage).toHaveBeenCalledWith(expect.stringContaining('80% of monthly cost budget used')); - expect(vscode.window.showInformationMessage).toHaveBeenCalledWith(expect.stringContaining('Projects: Alpha, Beta, Gamma +1 more.')); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith( + expect.stringContaining('80% of monthly cost budget used'), + 'Open Dashboard', + 'Snooze this month' + ); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining('across 4 projects'), + 'Open Dashboard' + ); + }); + + it('suppresses budget alerts while snoozed', () => { + let updateHandler: () => void = () => {}; + store.on.mockImplementation((_evt, cb) => { updateHandler = cb; }); + store.getMonthlyTokens.mockReturnValue(1500); + store.getMonthlyUsage.mockReturnValue({ tokens: 1500, costUsd: 12 }); + context.globalState.get.mockImplementation((key: string) => + key === 'budgetSnoozeUntil' ? Date.now() + 86_400_000 : 0); + + new AlertManager(store as unknown as DashboardStore, context as unknown as vscode.ExtensionContext); + updateHandler(); + + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled(); }); it('skips alerts and digest work when budgets are disabled, under threshold, or recently shown', () => { diff --git a/src/extension.ts b/src/extension.ts index 7b116bd..50f2789 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -38,13 +38,23 @@ export async function activate(context: vscode.ExtensionContext) { sidebarProvider.clearSelectedProject(); DashboardPanel.createOrShow(context, store); }), - vscode.commands.registerCommand('claudeDashboard.openProject', (projectId: string) => { + vscode.commands.registerCommand('claudeDashboard.openProject', (projectId: string, sessionId?: string) => { sidebarProvider.setSelectedProject(projectId); - ProjectPanel.createOrShow(context, store, projectId); + ProjectPanel.createOrShow(context, store, projectId, sessionId); }), vscode.commands.registerCommand('claudeDashboard.refresh', () => { store.refresh(); }), + vscode.commands.registerCommand('claudeDashboard.enableLiveTracking', async () => { + await hookManager.injectHooks(context.globalState); + await context.globalState.update('hooksConsent', 'enabled'); + vscode.window.showInformationMessage('Live tracking enabled. Hooks were added to ~/.claude/settings.json (backup saved as settings.json.bak).'); + }), + vscode.commands.registerCommand('claudeDashboard.disableLiveTracking', async () => { + await hookManager.removeHooks(context.globalState); + await context.globalState.update('hooksConsent', 'declined'); + vscode.window.showInformationMessage('Live tracking disabled. Dashboard hooks were removed from ~/.claude/settings.json.'); + }), vscode.commands.registerCommand('claudeDashboard.exportSessions', async (projectId: string, format: 'json' | 'csv') => { const project = store.getProject(projectId); if (!project) { return; } @@ -91,20 +101,33 @@ export async function activate(context: vscode.ExtensionContext) { // Check weekly digest on activation alertManager.checkWeeklyDigest(); - // Setup hooks (ask user on first run, or re-inject if outdated) - const hooksConfigured = context.globalState.get('hooksConfigured', false); - if (!hooksConfigured) { + // Setup hooks. Ask at most once: any explicit choice is persisted and the + // dialog never returns. Live tracking can be toggled later via commands. + let consent = context.globalState.get('hooksConsent'); + if (!consent && context.globalState.get('hooksConfigured', false)) { + // Migrate the legacy flag from versions that only persisted "Yes" + consent = 'enabled'; + await context.globalState.update('hooksConsent', 'enabled'); + } + if (!consent) { const answer = await vscode.window.showInformationMessage( - `Claude Code Dashboard found ${store.getProjects().length} projects. Auto-configure real-time hooks for live session tracking?`, - 'Yes, configure hooks', - 'Skip' + 'Claude Code Dashboard can show live session activity by adding two hooks to ~/.claude/settings.json (a backup is made first). Enable live tracking?', + 'Enable', + 'Not now', + 'Never' ); - if (answer === 'Yes, configure hooks') { + if (answer === 'Enable') { await hookManager.injectHooks(context.globalState); - await context.globalState.update('hooksConfigured', true); - vscode.window.showInformationMessage('Claude Code Dashboard hooks configured. Real-time tracking is active.'); + await context.globalState.update('hooksConsent', 'enabled'); + vscode.window.showInformationMessage('Live tracking enabled. Disable anytime via "Claude Code Dashboard: Disable Live Tracking".'); + } else if (answer === 'Not now') { + await context.globalState.update('hooksConsent', 'declined'); + } else if (answer === 'Never') { + await context.globalState.update('hooksConsent', 'never'); } - } else if (hookManager.needsReinjection(context.globalState)) { + // Dismissing the notification without choosing leaves consent unset, + // so the question is asked again next startup. + } else if (consent === 'enabled' && hookManager.needsReinjection(context.globalState)) { await hookManager.injectHooks(context.globalState); } diff --git a/src/hooks/HookManager.ts b/src/hooks/HookManager.ts index c2a251e..eaedbc7 100644 --- a/src/hooks/HookManager.ts +++ b/src/hooks/HookManager.ts @@ -1,9 +1,14 @@ import * as fs from 'fs'; import * as path from 'path'; +import { stripDashboardHooks } from './stripDashboardHooks'; -const HOOK_COMMAND = `node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const e=JSON.parse(d);const line=JSON.stringify({type:e.hook_event_name,tool:e.tool_name,sessionId:e.session_id,timestamp:Date.now()})+'\\n';require('fs').appendFileSync(require('path').join(require('os').homedir(),'.claude','.dashboard-events.jsonl'),line)}catch(err){}})"`; +// The injected hook exits immediately unless the marker file exists, so a stale +// hook entry (e.g. restored from a settings backup) costs one existsSync and +// writes nothing. +const HOOK_COMMAND = `node -e "const f=require('fs'),p=require('path'),dir=p.join(require('os').homedir(),'.claude');if(!f.existsSync(p.join(dir,'.dashboard-live'))){process.exit(0)}process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{const e=JSON.parse(d);const line=JSON.stringify({type:e.hook_event_name,tool:e.tool_name,sessionId:e.session_id,timestamp:Date.now()})+'\\n';f.appendFileSync(p.join(dir,'.dashboard-events.jsonl'),line)}catch(err){}})"`; -const HOOK_VERSION = 2; +const HOOK_VERSION = 3; +export const LIVE_MARKER_FILE = '.dashboard-live'; export class HookManager { private claudeDir: string; @@ -51,9 +56,36 @@ export class HookManager { settings.hooks = hooks; fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + // Marker file arms the injected hooks (they no-op without it) + fs.writeFileSync( + path.join(this.claudeDir, LIVE_MARKER_FILE), + 'Claude Code Dashboard live tracking is enabled.\nDeleting this file disables the injected hooks without editing settings.json.\n' + ); + // Store version so we know when to re-inject if (globalState) { await globalState.update('dashboardHookVersion', HOOK_VERSION); } } + + /** Remove all dashboard hooks from settings.json and disarm any stale copies. */ + async removeHooks(globalState?: { update(key: string, value: unknown): Thenable }): Promise { + // Delete the marker first so any hook copy stops writing immediately + try { fs.unlinkSync(path.join(this.claudeDir, LIVE_MARKER_FILE)); } catch { /* not present */ } + + const settingsPath = path.join(this.claudeDir, 'settings.json'); + if (fs.existsSync(settingsPath)) { + try { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record; + if (stripDashboardHooks(settings)) { + fs.copyFileSync(settingsPath, settingsPath + '.bak'); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + } + } catch { /* unreadable settings — leave untouched */ } + } + + if (globalState) { + await globalState.update('dashboardHookVersion', undefined); + } + } } diff --git a/src/hooks/__tests__/HookManager.test.ts b/src/hooks/__tests__/HookManager.test.ts index afc67ed..c31b594 100644 --- a/src/hooks/__tests__/HookManager.test.ts +++ b/src/hooks/__tests__/HookManager.test.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { HookManager } from '../HookManager'; +import { HookManager, LIVE_MARKER_FILE } from '../HookManager'; +import { stripDashboardHooks } from '../stripDashboardHooks'; vi.mock('fs'); @@ -16,11 +17,11 @@ describe('HookManager', () => { it('detects reinjection by version', () => { const manager = new HookManager('/claude'); - expect(manager.needsReinjection({ get: () => 1 })).toBe(true); - expect(manager.needsReinjection({ get: () => 2 })).toBe(false); + expect(manager.needsReinjection({ get: () => 2 })).toBe(true); + expect(manager.needsReinjection({ get: () => 3 })).toBe(false); }); - it('injects hooks, removes old dashboard hooks, and updates state', async () => { + it('injects hooks, removes old dashboard hooks, arms the marker, and updates state', async () => { const manager = new HookManager('/claude'); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ @@ -35,11 +36,15 @@ describe('HookManager', () => { await manager.injectHooks(state); expect(fs.copyFileSync).toHaveBeenCalledWith('/claude/settings.json', '/claude/settings.json.bak'); - expect(fs.writeFileSync).toHaveBeenCalledOnce(); + // settings.json + marker file + expect(fs.writeFileSync).toHaveBeenCalledTimes(2); const written = JSON.parse(String(vi.mocked(fs.writeFileSync).mock.calls[0][1])); expect(written.hooks.PostToolUse).toHaveLength(2); expect(JSON.stringify(written.hooks.Stop)).toContain('.dashboard-events.jsonl'); - expect(update).toHaveBeenCalledWith('dashboardHookVersion', 2); + // injected command is guarded by the marker file + expect(JSON.stringify(written.hooks.PostToolUse)).toContain('.dashboard-live'); + expect(vi.mocked(fs.writeFileSync).mock.calls[1][0]).toBe(`/claude/${LIVE_MARKER_FILE}`); + expect(update).toHaveBeenCalledWith('dashboardHookVersion', 3); }); it('creates fresh hook settings when the file is missing or invalid', async () => { @@ -52,11 +57,67 @@ describe('HookManager', () => { await manager.injectHooks(); await manager.injectHooks(); - const firstWrite = JSON.parse(String(vi.mocked(fs.writeFileSync).mock.calls[0][1])); - const secondWrite = JSON.parse(String(vi.mocked(fs.writeFileSync).mock.calls[1][1])); + const settingsWrites = vi.mocked(fs.writeFileSync).mock.calls.filter(c => c[0] === '/claude/settings.json'); + const firstWrite = JSON.parse(String(settingsWrites[0][1])); + const secondWrite = JSON.parse(String(settingsWrites[1][1])); expect(firstWrite.hooks.PostToolUse).toHaveLength(1); expect(firstWrite.hooks.Stop).toHaveLength(1); expect(secondWrite.hooks.PostToolUse).toHaveLength(1); }); + + it('removeHooks deletes the marker, strips dashboard hooks, and clears the stored version', async () => { + const manager = new HookManager('/claude'); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ + hooks: { + PostToolUse: [{ matcher: 'keep', hooks: [{ command: 'echo keep' }] }, { matcher: '*', hooks: [{ command: 'appendFileSync .dashboard-events.jsonl' }] }], + Stop: [{ hooks: [{ command: 'appendFileSync .dashboard-events.jsonl' }] }], + }, + otherSetting: true, + })); + const update = vi.fn(() => Promise.resolve()); + + await manager.removeHooks({ update }); + + expect(fs.unlinkSync).toHaveBeenCalledWith(`/claude/${LIVE_MARKER_FILE}`); + expect(fs.copyFileSync).toHaveBeenCalledWith('/claude/settings.json', '/claude/settings.json.bak'); + const written = JSON.parse(String(vi.mocked(fs.writeFileSync).mock.calls[0][1])); + expect(written.hooks.PostToolUse).toHaveLength(1); + expect(written.hooks.Stop).toBeUndefined(); + expect(written.otherSetting).toBe(true); + expect(update).toHaveBeenCalledWith('dashboardHookVersion', undefined); + }); + + it('removeHooks leaves settings untouched when nothing was injected', async () => { + const manager = new HookManager('/claude'); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({ hooks: { PostToolUse: [{ matcher: 'keep', hooks: [{ command: 'echo keep' }] }] } })); + + await manager.removeHooks(); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(fs.copyFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('stripDashboardHooks', () => { + it('removes only dashboard entries and drops the hooks key when empty', () => { + const settings: Record = { + hooks: { + PostToolUse: [{ matcher: '*', hooks: [{ command: 'x .dashboard-events.jsonl' }] }], + Stop: [{ hooks: [{ command: 'y .dashboard-events.jsonl' }] }], + }, + }; + expect(stripDashboardHooks(settings)).toBe(true); + expect(settings.hooks).toBeUndefined(); + }); + + it('returns false when there is nothing to strip', () => { + const settings: Record = { hooks: { Stop: [{ hooks: [{ command: 'echo hi' }] }] } }; + expect(stripDashboardHooks(settings)).toBe(false); + expect(settings.hooks).toBeDefined(); + + expect(stripDashboardHooks({})).toBe(false); + }); }); diff --git a/src/hooks/stripDashboardHooks.ts b/src/hooks/stripDashboardHooks.ts new file mode 100644 index 0000000..6325bdb --- /dev/null +++ b/src/hooks/stripDashboardHooks.ts @@ -0,0 +1,28 @@ +/** + * Removes every dashboard-injected hook entry from a parsed ~/.claude/settings.json + * object. Shared by HookManager (disable command) and the vscode:uninstall script, + * which runs outside the extension host and cannot import 'vscode'. + */ +export function stripDashboardHooks(settings: Record): boolean { + const hooks = settings.hooks as Record | undefined; + if (!hooks || typeof hooks !== 'object') { return false; } + + let changed = false; + for (const key of Object.keys(hooks)) { + const entries = hooks[key]; + if (!Array.isArray(entries)) { continue; } + const kept = entries.filter(h => !JSON.stringify(h).includes('.dashboard-events.jsonl')); + if (kept.length !== entries.length) { + changed = true; + if (kept.length === 0) { + delete hooks[key]; + } else { + hooks[key] = kept; + } + } + } + if (changed && Object.keys(hooks).length === 0) { + delete settings.hooks; + } + return changed; +} diff --git a/src/parsers/SessionParser.ts b/src/parsers/SessionParser.ts index d79d0b7..4219778 100644 --- a/src/parsers/SessionParser.ts +++ b/src/parsers/SessionParser.ts @@ -1,17 +1,32 @@ import * as fs from 'fs'; import { Session, Turn, ToolCall, TurnAttachment } from '../store/DashboardStore'; +/** Date the pricing table below was last synced against published Anthropic pricing. */ +export const PRICING_TABLE_DATE = '2026-06'; + +// USD per million tokens by model family. Cache writes bill at 1.25x input +// (5-minute TTL); cache reads at 0.1x input. const MODEL_COSTS: Record = { - 'claude-opus-4': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 }, - 'claude-sonnet-4': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 }, - 'claude-haiku-4': { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 }, - default: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 }, + 'fable': { input: 10, output: 50, cacheWrite: 12.5, cacheRead: 1 }, // Fable 5 / Mythos 5 + 'opus': { input: 5, output: 25, cacheWrite: 6.25, cacheRead: 0.5 }, // Opus 4.5+ + 'opus-legacy': { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 }, // Opus 4.0 / 4.1 / Opus 3 + 'sonnet': { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 }, // Sonnet 4.x / Sonnet 5 + 'haiku': { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 }, // Haiku 4.5 + 'haiku-legacy': { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 }, // Haiku 3.x + default: { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 }, // unknown model → Sonnet rates }; -function modelKey(model: string): string { - if (model?.includes('opus')) { return 'claude-opus-4'; } - if (model?.includes('haiku')) { return 'claude-haiku-4'; } - return 'claude-sonnet-4'; +export function modelKey(model: string): string { + const m = (model || '').toLowerCase(); + if (m.includes('fable') || m.includes('mythos')) { return 'fable'; } + if (m.includes('opus')) { + return /opus-4-[01]\b|opus-4-2025|3-opus/.test(m) ? 'opus-legacy' : 'opus'; + } + if (m.includes('haiku')) { + return /haiku-[4-9]/.test(m) ? 'haiku' : 'haiku-legacy'; + } + if (m.includes('sonnet')) { return 'sonnet'; } + return 'default'; } export interface ParsedSession extends Session { @@ -86,8 +101,9 @@ export class SessionParser { .replace(/.*?<\/ide_selection>/gs, '') .trim(); - // Capture first meaningful user prompt as session summary - if (!sessionSummary && displayText.length > 0) { + // Capture first meaningful user prompt as session summary, skipping + // injected skill instructions (they aren't the user's actual prompt). + if (!sessionSummary && displayText.length > 0 && !displayText.startsWith('Base directory for this skill:')) { sessionSummary = displayText.slice(0, 120) + (displayText.length > 120 ? '…' : ''); } @@ -206,6 +222,9 @@ export class SessionParser { // and would inflate the number by 10-20x (e.g. 17M cache reads vs 1M real tokens) const totalTokens = inputTokens + cacheCreationTokens + outputTokens; const costUsd = this.estimateCostDetailed(inputTokens, outputTokens, cacheCreationTokens, cacheReadTokens, detectedModel); + // 'fallback' = no model recorded, or family not in the pricing table (priced at Sonnet rates) + const pricingConfidence: 'exact' | 'fallback' = + detectedModel !== 'default' && modelKey(detectedModel) !== 'default' ? 'exact' : 'fallback'; const sessionId = filePath.split('/').pop()?.replace('.jsonl', '') || 'unknown'; // endTime: last timestamp seen, or null if still active @@ -254,7 +273,8 @@ export class SessionParser { idleTimeMs: computedIdleTimeMs, activeTimeMs: computedActiveTimeMs, activityRatio, - model: detectedModel !== 'default' ? modelKey(detectedModel) : null, + model: detectedModel !== 'default' ? detectedModel : null, + pricingConfidence, }; } catch (e) { console.error('Failed to parse session file:', filePath, e); diff --git a/src/parsers/__tests__/SessionParser.test.ts b/src/parsers/__tests__/SessionParser.test.ts index 2ce1b7c..75d1da7 100644 --- a/src/parsers/__tests__/SessionParser.test.ts +++ b/src/parsers/__tests__/SessionParser.test.ts @@ -226,4 +226,47 @@ describe('SessionParser', () => { expect(errorSpy).toHaveBeenCalled(); errorSpy.mockRestore(); }); + + it('prices model families correctly, legacy vs current', () => { + // Fable 5: $10 in / $50 out + expect(parser.estimateCostDetailed(1_000_000, 1_000_000, 0, 0, 'claude-fable-5')).toBe(60); + expect(parser.estimateCostDetailed(1_000_000, 1_000_000, 0, 0, 'claude-mythos-5')).toBe(60); + // Current Opus (4.5+): $5 in / $25 out — NOT the legacy $15/$75 + expect(parser.estimateCostDetailed(1_000_000, 1_000_000, 0, 0, 'claude-opus-4-8')).toBe(30); + // Legacy Opus 4.0/4.1 keeps $15/$75 + expect(parser.estimateCostDetailed(1_000_000, 1_000_000, 0, 0, 'claude-opus-4-1-20250805')).toBe(90); + expect(parser.estimateCostDetailed(1_000_000, 1_000_000, 0, 0, 'claude-opus-4-20250514')).toBe(90); + // Haiku 4.5: $1 / $5; legacy Haiku 3.5: $0.8 / $4 + expect(parser.estimateCostDetailed(1_000_000, 1_000_000, 0, 0, 'claude-haiku-4-5-20251001')).toBe(6); + expect(parser.estimateCostDetailed(1_000_000, 1_000_000, 0, 0, 'claude-3-5-haiku-20241022')).toBe(4.8); + // Unknown model falls back to Sonnet rates + expect(parser.estimateCostDetailed(1_000_000, 1_000_000, 0, 0, 'some-future-model')).toBe(18); + }); + + it('stores the raw model ID and flags fallback pricing confidence', () => { + const fableSession = [ + JSON.stringify({ type: 'user', uuid: 'u1', timestamp: '2025-01-15T10:00:00Z', cwd: '/test', message: { content: 'Q' } }), + JSON.stringify({ type: 'assistant', uuid: 'a1', timestamp: '2025-01-15T10:01:00Z', message: { model: 'claude-fable-5', content: [{ type: 'text', text: 'A' }], usage: { input_tokens: 100, output_tokens: 50 }, stop_reason: 'stop_sequence' } }), + ].join('\n'); + const unknownModelSession = fableSession.replace(/claude-fable-5/g, 'experimental-model-x'); + const noModelSession = [ + JSON.stringify({ type: 'user', uuid: 'u1', timestamp: '2025-01-15T10:00:00Z', cwd: '/test', message: { content: 'Q' } }), + JSON.stringify({ type: 'assistant', uuid: 'a1', timestamp: '2025-01-15T10:01:00Z', message: { content: [{ type: 'text', text: 'A' }], usage: { input_tokens: 100, output_tokens: 50 }, stop_reason: 'stop_sequence' } }), + ].join('\n'); + vi.mocked(fs.readFileSync) + .mockReturnValueOnce(asReadResult(fableSession)) + .mockReturnValueOnce(asReadResult(unknownModelSession)) + .mockReturnValueOnce(asReadResult(noModelSession)); + + const fableResult = parser.parseFile('/sessions/fable.jsonl', 'proj-1'); + const unknownResult = parser.parseFile('/sessions/unknown.jsonl', 'proj-1'); + const noModelResult = parser.parseFile('/sessions/nomodel.jsonl', 'proj-1'); + + expect(fableResult?.model).toBe('claude-fable-5'); + expect(fableResult?.pricingConfidence).toBe('exact'); + expect(unknownResult?.model).toBe('experimental-model-x'); + expect(unknownResult?.pricingConfidence).toBe('fallback'); + expect(noModelResult?.model).toBeNull(); + expect(noModelResult?.pricingConfidence).toBe('fallback'); + }); }); diff --git a/src/providers/SidebarProvider.ts b/src/providers/SidebarProvider.ts index d8a4396..8e1a8f1 100644 --- a/src/providers/SidebarProvider.ts +++ b/src/providers/SidebarProvider.ts @@ -41,6 +41,9 @@ export class SidebarProvider implements vscode.WebviewViewProvider { this.setSelectedProject(msg.projectId); vscode.commands.executeCommand('claudeDashboard.openProject', msg.projectId); } + if (msg.type === 'openFolder' && msg.path) { + vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(msg.path)); + } }); // If autoOpen is already enabled when resolveWebviewView fires, the user clicked the icon diff --git a/src/providers/StatusBarProvider.ts b/src/providers/StatusBarProvider.ts index 9d25ae0..61df531 100644 --- a/src/providers/StatusBarProvider.ts +++ b/src/providers/StatusBarProvider.ts @@ -1,36 +1,69 @@ import * as vscode from 'vscode'; import { DashboardStore } from '../store/DashboardStore'; +import { formatTokens, formatCost } from '../utils/format'; export class StatusBarProvider implements vscode.Disposable { private item: vscode.StatusBarItem; + private configListener: vscode.Disposable; constructor(private store: DashboardStore) { this.item = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); this.item.command = 'claudeDashboard.openDashboard'; this.update(); store.on('updated', () => this.update()); - this.item.show(); + this.configListener = vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('claudeDashboard')) { this.update(); } + }); } private update() { + const config = vscode.workspace.getConfiguration('claudeDashboard'); + const mode = config.get('statusBar', 'compact'); + if (mode === 'off') { + this.item.hide(); + return; + } + const stats = this.store.getStats(); const tokens = formatTokens(stats.tokensTodayTotal); - const cost = `$${stats.costTodayUsd.toFixed(2)}`; - if (stats.activeSessionCount > 0) { - this.item.text = `$(pulse) Claude: ${stats.activeSessionCount} active · ${tokens} · ${cost}`; - this.item.backgroundColor = undefined; + const cost = formatCost(stats.costTodayUsd); + const active = stats.activeSessionCount; + + if (mode === 'full') { + this.item.text = active > 0 + ? `$(pulse) Claude: ${active} active · ${tokens} · ${cost}` + : `$(pulse) Claude: ${tokens} · ${cost}`; + } else { + this.item.text = active > 0 ? `$(pulse) ${active} · ${cost}` : `$(pulse) ${cost}`; + } + + // The status bar is the always-visible budget guardrail: turn amber at 80% + const budgetUsd = config.get('monthlyBudgetUsd', 0); + let budgetLine = ''; + if (budgetUsd > 0) { + const { costUsd: monthUsd } = this.store.getMonthlyUsage(); + const pct = monthUsd / budgetUsd; + this.item.backgroundColor = pct >= 0.8 + ? new vscode.ThemeColor('statusBarItem.warningBackground') + : undefined; + budgetLine = `\n\n**Budget:** ${formatCost(monthUsd)} of ${formatCost(budgetUsd)} (${Math.round(pct * 100)}%)`; } else { - this.item.text = `$(pulse) Claude: ${tokens} · ${cost}`; this.item.backgroundColor = undefined; } - this.item.tooltip = `Claude Code Dashboard\n${stats.totalProjects} projects · ${stats.activeSessionCount} active sessions\nClick to open dashboard`; - } - dispose() { this.item.dispose(); } -} + const tooltip = new vscode.MarkdownString( + `**Claude Code Dashboard**\n\n` + + `Today: ${tokens} tokens · est. ${cost}\n\n` + + `${stats.totalProjects} projects · ${active} active session${active !== 1 ? 's' : ''}` + + budgetLine + + `\n\n_Click to open dashboard_` + ); + this.item.tooltip = tooltip; + this.item.show(); + } -function formatTokens(n: number): string { - if (n >= 1_000_000) { return `${(n / 1_000_000).toFixed(1)}M`; } - if (n >= 1_000) { return `${(n / 1_000).toFixed(1)}k`; } - return `${n}`; + dispose() { + this.item.dispose(); + this.configListener.dispose(); + } } diff --git a/src/providers/__tests__/StatusBarProvider.test.ts b/src/providers/__tests__/StatusBarProvider.test.ts index 3151e75..af1cafe 100644 --- a/src/providers/__tests__/StatusBarProvider.test.ts +++ b/src/providers/__tests__/StatusBarProvider.test.ts @@ -5,32 +5,82 @@ import { DashboardStats, DashboardStore } from '../../store/DashboardStore'; type StatusBarStore = { getStats(): DashboardStats; + getMonthlyUsage(): { tokens: number; costUsd: number }; on(event: 'updated', callback: () => void): void; }; -type StatusBarItemLike = Pick; +type StatusBarItemLike = Pick & { + tooltip: { value: string }; + show: ReturnType; + hide: ReturnType; +}; + +function mockConfig(values: Record) { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((key: string, def: unknown) => values[key] ?? def), + } as unknown as vscode.WorkspaceConfiguration); +} + +function makeStore(stats: DashboardStats, monthUsd = 0): StatusBarStore & { updateHandler: () => void } { + const holder = { + updateHandler: (() => {}) as () => void, + getStats: vi.fn(() => stats), + getMonthlyUsage: vi.fn(() => ({ tokens: 0, costUsd: monthUsd })), + on: vi.fn((_evt: string, cb: () => void) => { holder.updateHandler = cb; }), + }; + return holder; +} describe('StatusBarProvider', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('renders active session text and reacts to store updates', () => { - let updateHandler: () => void = () => {}; + it('renders compact text by default and reacts to store updates', () => { + mockConfig({}); const stats = { totalProjects: 3, activeSessionCount: 2, tokensTodayTotal: 12500, costTodayUsd: 1.23, tokensWeekTotal: 0, costWeekUsd: 0 }; - const store: StatusBarStore = { - getStats: vi.fn(() => stats), - on: vi.fn((_evt, cb) => { updateHandler = cb; }), - }; + const store = makeStore(stats); const provider = new StatusBarProvider(store as unknown as DashboardStore); const item = vi.mocked(vscode.window.createStatusBarItem).mock.results[0].value as StatusBarItemLike; - expect(item.text).toContain('2 active'); - expect(item.tooltip).toContain('3 projects'); + expect(item.text).toBe('$(pulse) 2 · $1.23'); + expect(item.tooltip.value).toContain('3 projects'); stats.activeSessionCount = 0; - updateHandler(); - expect(item.text).not.toContain('active ·'); + store.updateHandler(); + expect(item.text).toBe('$(pulse) $1.23'); provider.dispose(); expect(item.dispose).toHaveBeenCalledOnce(); }); + + it('renders the full format when configured', () => { + mockConfig({ statusBar: 'full' }); + const store = makeStore({ totalProjects: 1, activeSessionCount: 1, tokensTodayTotal: 2000, costTodayUsd: 0.5, tokensWeekTotal: 0, costWeekUsd: 0 }); + + new StatusBarProvider(store as unknown as DashboardStore); + const item = vi.mocked(vscode.window.createStatusBarItem).mock.results[0].value as StatusBarItemLike; + + expect(item.text).toBe('$(pulse) Claude: 1 active · 2.0k · $0.500'); + }); + + it('hides the item when set to off', () => { + mockConfig({ statusBar: 'off' }); + const store = makeStore({ totalProjects: 0, activeSessionCount: 0, tokensTodayTotal: 0, costTodayUsd: 0, tokensWeekTotal: 0, costWeekUsd: 0 }); + + new StatusBarProvider(store as unknown as DashboardStore); + const item = vi.mocked(vscode.window.createStatusBarItem).mock.results[0].value as StatusBarItemLike; + + expect(item.hide).toHaveBeenCalled(); + expect(item.show).not.toHaveBeenCalled(); + }); + + it('turns amber and reports budget in the tooltip at 80%+ spend', () => { + mockConfig({ monthlyBudgetUsd: 10 }); + const store = makeStore({ totalProjects: 2, activeSessionCount: 0, tokensTodayTotal: 100, costTodayUsd: 0.1, tokensWeekTotal: 0, costWeekUsd: 0 }, 9); + + new StatusBarProvider(store as unknown as DashboardStore); + const item = vi.mocked(vscode.window.createStatusBarItem).mock.results[0].value as StatusBarItemLike; + + expect(item.backgroundColor).toEqual(new vscode.ThemeColor('statusBarItem.warningBackground')); + expect(item.tooltip.value).toContain('$9.00 of $10.00 (90%)'); + }); }); diff --git a/src/store/DashboardStore.ts b/src/store/DashboardStore.ts index 2db2256..a0896a6 100644 --- a/src/store/DashboardStore.ts +++ b/src/store/DashboardStore.ts @@ -109,7 +109,8 @@ export interface Session { idleTimeMs: number | null; // sum of gaps >5min between assistant→user turns activeTimeMs: number | null; // durationMs - idleTimeMs activityRatio: number | null; // activeTimeMs / durationMs * 100 - model: string | null; // detected model (e.g. 'claude-opus-4', 'claude-sonnet-4') + model: string | null; // raw detected model ID (e.g. 'claude-opus-4-8') + pricingConfidence?: 'exact' | 'fallback'; // 'fallback' = unknown model, priced at Sonnet rates } export interface Turn { @@ -161,6 +162,25 @@ export interface PromptSearchResult { snippet: string; // highlighted match context } +/** Lightweight cross-project session row for the dashboard Sessions tab. */ +export interface SessionRow { + id: string; + projectId: string; + projectName: string; + summary: string | null; + model: string | null; + pricingConfidence?: 'exact' | 'fallback'; + startTime: number; + durationMs: number | null; + totalTokens: number; + costUsd: number; + subagentCostUsd: number; + hasThinking: boolean; + promptCount: number; + toolCallCount: number; + isActiveSession: boolean; +} + export interface McpServer { name: string; command?: string; @@ -896,6 +916,34 @@ export class DashboardStore extends EventEmitter { return results.slice(0, 500); } + /** Lightweight cross-project session rows for the dashboard Sessions tab (no turns). */ + getAllSessionRows(): SessionRow[] { + const rows: SessionRow[] = []; + for (const project of this.getProjects()) { + for (const s of this.getSessions(project.id)) { + rows.push({ + id: s.id, + projectId: project.id, + projectName: project.name, + summary: s.sessionSummary, + model: s.model, + pricingConfidence: s.pricingConfidence, + startTime: s.startTime, + durationMs: s.durationMs, + totalTokens: s.totalTokens, + costUsd: s.costUsd, + subagentCostUsd: s.subagentCostUsd, + hasThinking: s.hasThinking, + promptCount: s.promptCount, + toolCallCount: s.toolCallCount, + isActiveSession: s.isActiveSession, + }); + } + } + rows.sort((a, b) => b.startTime - a.startTime); + return rows; + } + handleLiveEvent(event: LiveEvent) { if (event.type === 'session_stop' && event.sessionId) { for (const [projectId, sessions] of this.sessions) { diff --git a/src/uninstall.ts b/src/uninstall.ts new file mode 100644 index 0000000..a10aef0 --- /dev/null +++ b/src/uninstall.ts @@ -0,0 +1,24 @@ +/** + * Runs via the package.json "vscode:uninstall" hook after the extension is + * uninstalled (plain Node, no 'vscode' module available). Cleans up everything + * the dashboard added to ~/.claude so no orphaned hooks keep writing events. + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { stripDashboardHooks } from './hooks/stripDashboardHooks'; +import { LIVE_MARKER_FILE } from './hooks/HookManager'; + +const claudeDir = path.join(os.homedir(), '.claude'); + +try { fs.unlinkSync(path.join(claudeDir, LIVE_MARKER_FILE)); } catch { /* not present */ } + +const settingsPath = path.join(claudeDir, 'settings.json'); +try { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record; + if (stripDashboardHooks(settings)) { + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + } +} catch { /* no settings or unreadable — nothing to clean */ } + +try { fs.unlinkSync(path.join(claudeDir, '.dashboard-events.jsonl')); } catch { /* not present */ } diff --git a/src/utils/format.ts b/src/utils/format.ts new file mode 100644 index 0000000..17d19d0 --- /dev/null +++ b/src/utils/format.ts @@ -0,0 +1,16 @@ +/** + * Backend counterpart of webview-ui/src/utils/format.ts — same formatting + * standard so the status bar and notifications match the dashboard. + */ +export function formatTokens(n: number): string { + if (n >= 1_000_000) { return `${(n / 1_000_000).toFixed(1)}M`; } + if (n >= 1_000) { return `${(n / 1_000).toFixed(1)}k`; } + return String(n); +} + +export function formatCost(usd: number): string { + if (!usd || usd <= 0) { return '$0.00'; } + if (usd >= 1) { return `$${usd.toFixed(2)}`; } + if (usd >= 0.01) { return `$${usd.toFixed(3)}`; } + return '<$0.01'; +} diff --git a/src/webviews/DashboardPanel.ts b/src/webviews/DashboardPanel.ts index 64a0928..6a48b18 100644 --- a/src/webviews/DashboardPanel.ts +++ b/src/webviews/DashboardPanel.ts @@ -13,6 +13,7 @@ function getBudgetStatus(store: DashboardStore): { budgetUsd: number; spentUsd: export class DashboardPanel { static currentPanel: DashboardPanel | undefined; private readonly panel: vscode.WebviewPanel; + private readonly context: vscode.ExtensionContext; private disposables: vscode.Disposable[] = []; static createOrShow(context: vscode.ExtensionContext, store: DashboardStore) { @@ -31,6 +32,7 @@ export class DashboardPanel { private constructor(panel: vscode.WebviewPanel, context: vscode.ExtensionContext, store: DashboardStore) { this.panel = panel; + this.context = context; this.updateContent(context, store); store.on('updated', () => { @@ -41,9 +43,29 @@ export class DashboardPanel { this.panel.webview.postMessage({ type: 'liveEvent', payload: event }); }); - panel.webview.onDidReceiveMessage((msg) => { + panel.webview.onDidReceiveMessage(async (msg) => { if (msg.type === 'openProject') { - vscode.commands.executeCommand('claudeDashboard.openProject', msg.projectId); + vscode.commands.executeCommand('claudeDashboard.openProject', msg.projectId, msg.sessionId); + } + if (msg.type === 'getAllSessions') { + this.panel.webview.postMessage({ type: 'allSessions', sessions: store.getAllSessionRows() }); + } + if (msg.type === 'searchPrompts') { + this.panel.webview.postMessage({ + type: 'promptSearchResults', + query: msg.query, + results: store.searchPrompts(msg.query ?? ''), + }); + } + if (msg.type === 'setBudget') { + await this.promptForBudget(store); + } + if (msg.type === 'dismissTour') { + await this.context.globalState.update('tourDismissed', true); + this.panel.webview.postMessage({ type: 'stateUpdate', payload: { showTour: false } }); + } + if (msg.type === 'refresh') { + vscode.commands.executeCommand('claudeDashboard.refresh'); } }, null, this.disposables); @@ -53,6 +75,20 @@ export class DashboardPanel { }, null, this.disposables); } + private async promptForBudget(store: DashboardStore) { + const config = vscode.workspace.getConfiguration('claudeDashboard'); + const current = config.get('monthlyBudgetUsd', 0); + const input = await vscode.window.showInputBox({ + title: 'Monthly cost budget (USD)', + prompt: 'Alerts fire at 80% and 100% of this estimated spend. Enter 0 to disable.', + value: current > 0 ? String(current) : '', + validateInput: v => (v.trim() === '' || isNaN(Number(v)) || Number(v) < 0) ? 'Enter a non-negative number' : null, + }); + if (input === undefined) { return; } + await config.update('monthlyBudgetUsd', Number(input), vscode.ConfigurationTarget.Global); + this.panel.webview.postMessage({ type: 'stateUpdate', payload: { budgetStatus: getBudgetStatus(store) } }); + } + private updateContent(context: vscode.ExtensionContext, store: DashboardStore) { this.panel.webview.html = getWebviewContent( this.panel.webview, @@ -66,11 +102,11 @@ export class DashboardPanel { return { projects: store.getProjects(), stats: store.getStats(), - usageOverTime: store.getUsageOverTime(30), + // 90 days so the Analytics range toggle (7/30/90) filters client-side + usageOverTime: store.getUsageOverTime(90), usageByProject: store.getUsageByProject(), heatmapData: store.getHeatmapData(), promptPatterns: store.getPromptPatterns(), - allPrompts: store.getAllPrompts(), toolUsage: store.getToolUsageStats(), hotFiles: store.getHotFiles(15), projectedCost: store.getProjectedCost(), @@ -80,6 +116,7 @@ export class DashboardPanel { recentChanges: store.getRecentFileChanges(7), productivityByHour: store.getProductivityByHour(), budgetStatus: getBudgetStatus(store), + showTour: !this.context.globalState.get('tourDismissed', false), }; } } diff --git a/src/webviews/ProjectPanel.ts b/src/webviews/ProjectPanel.ts index be93722..3d86374 100644 --- a/src/webviews/ProjectPanel.ts +++ b/src/webviews/ProjectPanel.ts @@ -7,9 +7,13 @@ export class ProjectPanel { private readonly panel: vscode.WebviewPanel; private disposables: vscode.Disposable[] = []; - static createOrShow(context: vscode.ExtensionContext, store: DashboardStore, projectId: string) { - if (ProjectPanel.panels.has(projectId)) { - ProjectPanel.panels.get(projectId)!.panel.reveal(vscode.ViewColumn.One); + static createOrShow(context: vscode.ExtensionContext, store: DashboardStore, projectId: string, sessionId?: string) { + const existing = ProjectPanel.panels.get(projectId); + if (existing) { + existing.panel.reveal(vscode.ViewColumn.One); + if (sessionId) { + existing.panel.webview.postMessage({ type: 'selectSession', sessionId }); + } return; } const project = store.getProject(projectId); @@ -20,17 +24,18 @@ export class ProjectPanel { vscode.ViewColumn.One, { enableScripts: true, localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'webview-ui', 'dist')] } ); - ProjectPanel.panels.set(projectId, new ProjectPanel(panel, context, store, projectId)); + ProjectPanel.panels.set(projectId, new ProjectPanel(panel, context, store, projectId, sessionId)); } private constructor( panel: vscode.WebviewPanel, context: vscode.ExtensionContext, store: DashboardStore, - projectId: string + projectId: string, + initialSessionId?: string ) { this.panel = panel; - this.updateContent(context, store, projectId); + this.updateContent(context, store, projectId, initialSessionId); store.on('updated', () => { this.panel.webview.postMessage({ type: 'stateUpdate', payload: this.buildState(store, projectId) }); @@ -49,6 +54,16 @@ export class ProjectPanel { turns: session?.turns ?? [], }); } + if (msg.type === 'openFile' && msg.path) { + try { + await vscode.window.showTextDocument(vscode.Uri.file(msg.path), { preview: true }); + } catch { + vscode.window.showWarningMessage(`Could not open ${msg.path} (it may have been moved or deleted).`); + } + } + if (msg.type === 'openFolder' && msg.path) { + vscode.commands.executeCommand('revealFileInOS', vscode.Uri.file(msg.path)); + } }, null, this.disposables); panel.onDidDispose(() => { @@ -57,12 +72,14 @@ export class ProjectPanel { }, null, this.disposables); } - private updateContent(context: vscode.ExtensionContext, store: DashboardStore, projectId: string) { + private updateContent(context: vscode.ExtensionContext, store: DashboardStore, projectId: string, initialSessionId?: string) { this.panel.webview.html = getWebviewContent( this.panel.webview, context.extensionUri, 'project', - this.buildState(store, projectId) + // initialSessionId only ships in the initial HTML payload (deep link from + // the Sessions tab); stateUpdate refreshes never re-select a session. + { ...this.buildState(store, projectId), ...(initialSessionId ? { initialSessionId } : {}) } ); } diff --git a/src/webviews/__tests__/DashboardPanel.test.ts b/src/webviews/__tests__/DashboardPanel.test.ts index 491f689..e976ea4 100644 --- a/src/webviews/__tests__/DashboardPanel.test.ts +++ b/src/webviews/__tests__/DashboardPanel.test.ts @@ -3,7 +3,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DashboardPanel } from '../DashboardPanel'; import { DashboardStore, LiveEvent, ProjectedCost, StreakData, EfficiencyStats, WeeklyRecap } from '../../store/DashboardStore'; -type DashboardMessage = { type: 'openProject'; projectId: string }; +type DashboardMessage = + | { type: 'openProject'; projectId: string; sessionId?: string } + | { type: 'getAllSessions' } + | { type: 'searchPrompts'; query?: string } + | { type: 'setBudget' } + | { type: 'dismissTour' } + | { type: 'refresh' }; type DashboardStoreMock = { on: ReturnType; getProjects: ReturnType; @@ -12,7 +18,6 @@ type DashboardStoreMock = { getUsageByProject: ReturnType; getHeatmapData: ReturnType; getPromptPatterns: ReturnType; - getAllPrompts: ReturnType; getToolUsageStats: ReturnType; getHotFiles: ReturnType; getProjectedCost: ReturnType; @@ -22,6 +27,8 @@ type DashboardStoreMock = { getRecentFileChanges: ReturnType; getProductivityByHour: ReturnType; getMonthlyUsage: ReturnType; + getAllSessionRows: ReturnType; + searchPrompts: ReturnType; }; type WebviewPanelLike = { webview: { @@ -35,6 +42,30 @@ type WebviewPanelLike = { onDidDispose: ReturnType; }; +function createStore(overrides: Partial = {}): DashboardStoreMock { + return { + on: vi.fn(), + getProjects: vi.fn(() => []), + getStats: vi.fn(() => ({ totalProjects: 0, activeSessionCount: 0, tokensTodayTotal: 0, costTodayUsd: 0, tokensWeekTotal: 0, costWeekUsd: 0 })), + getUsageOverTime: vi.fn(() => []), + getUsageByProject: vi.fn(() => []), + getHeatmapData: vi.fn(() => []), + getPromptPatterns: vi.fn(() => []), + getToolUsageStats: vi.fn(() => []), + getHotFiles: vi.fn(() => []), + getProjectedCost: vi.fn(() => null as unknown as ProjectedCost), + getStreak: vi.fn(() => null as unknown as StreakData), + getEfficiencyStats: vi.fn(() => null as unknown as EfficiencyStats), + getWeeklyRecap: vi.fn(() => null as unknown as WeeklyRecap), + getRecentFileChanges: vi.fn(() => []), + getProductivityByHour: vi.fn(() => []), + getMonthlyUsage: vi.fn(() => ({ tokens: 0, costUsd: 2 })), + getAllSessionRows: vi.fn(() => [{ id: 's1', projectId: 'p1', projectName: 'Alpha' }]), + searchPrompts: vi.fn(() => [{ sessionId: 's1', snippet: 'fix auth' }]), + ...overrides, + }; +} + describe('DashboardPanel', () => { beforeEach(() => { vi.clearAllMocks(); @@ -45,28 +76,12 @@ describe('DashboardPanel', () => { let updatedHandler: () => void = () => {}; let liveHandler: (event: LiveEvent) => void = () => {}; let messageHandler: (msg: DashboardMessage) => void = () => {}; - const store: DashboardStoreMock = { + const store = createStore({ on: vi.fn((evt, cb) => { if (evt === 'updated') updatedHandler = cb; if (evt === 'liveEvent') liveHandler = cb; }), - getProjects: vi.fn(() => []), - getStats: vi.fn(() => ({ totalProjects: 0, activeSessionCount: 0, tokensTodayTotal: 0, costTodayUsd: 0, tokensWeekTotal: 0, costWeekUsd: 0 })), - getUsageOverTime: vi.fn(() => []), - getUsageByProject: vi.fn(() => []), - getHeatmapData: vi.fn(() => []), - getPromptPatterns: vi.fn(() => []), - getAllPrompts: vi.fn(() => []), - getToolUsageStats: vi.fn(() => []), - getHotFiles: vi.fn(() => []), - getProjectedCost: vi.fn(() => null as unknown as ProjectedCost), - getStreak: vi.fn(() => null as unknown as StreakData), - getEfficiencyStats: vi.fn(() => null as unknown as EfficiencyStats), - getWeeklyRecap: vi.fn(() => null as unknown as WeeklyRecap), - getRecentFileChanges: vi.fn(() => []), - getProductivityByHour: vi.fn(() => []), - getMonthlyUsage: vi.fn(() => ({ tokens: 0, costUsd: 2 })), - }; + }); vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn(() => 10) } as unknown as vscode.WorkspaceConfiguration); vi.mocked(vscode.window.createWebviewPanel).mockImplementation(() => ({ webview: { @@ -80,19 +95,138 @@ describe('DashboardPanel', () => { onDidDispose: vi.fn(), }) as unknown as vscode.WebviewPanel); - DashboardPanel.createOrShow( - { extensionUri: vscode.Uri.file('/ext') } as Pick as vscode.ExtensionContext, - store as unknown as DashboardStore, - ); + const context = { + extensionUri: vscode.Uri.file('/ext'), + globalState: { get: vi.fn(() => false), update: vi.fn(() => Promise.resolve()) }, + } as unknown as vscode.ExtensionContext; + DashboardPanel.createOrShow(context, store as unknown as DashboardStore); const panel = vi.mocked(vscode.window.createWebviewPanel).mock.results[0].value as WebviewPanelLike; updatedHandler(); liveHandler({ type: 'tool_use', timestamp: 1 }); messageHandler({ type: 'openProject', projectId: 'p1' }); + messageHandler({ type: 'openProject', projectId: 'p1', sessionId: 's9' }); + messageHandler({ type: 'getAllSessions' } as unknown as DashboardMessage); + messageHandler({ type: 'searchPrompts', query: 'auth' } as unknown as DashboardMessage); expect(panel.webview.html).toContain('__INITIAL_VIEW__ = "dashboard"'); expect(panel.webview.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'stateUpdate' })); expect(panel.webview.postMessage).toHaveBeenCalledWith({ type: 'liveEvent', payload: { type: 'tool_use', timestamp: 1 } }); - expect(vscode.commands.executeCommand).toHaveBeenCalledWith('claudeDashboard.openProject', 'p1'); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('claudeDashboard.openProject', 'p1', undefined); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('claudeDashboard.openProject', 'p1', 's9'); + expect(panel.webview.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'allSessions' })); + expect(panel.webview.postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'promptSearchResults', query: 'auth' })); + expect(store.searchPrompts).toHaveBeenCalledWith('auth'); + }); + + it('reuses the panel, handles dashboard controls, and disposes subscriptions', async () => { + let messageHandler: (msg: DashboardMessage) => Promise | void = () => {}; + let disposeHandler: () => void = () => {}; + const receiveDisposable = { dispose: vi.fn() }; + const panelDisposable = { dispose: vi.fn() }; + const panel = { + webview: { + html: '', + postMessage: vi.fn(), + onDidReceiveMessage: vi.fn(( + cb: (msg: DashboardMessage) => Promise | void, + _thisArg: unknown, + disposables: vscode.Disposable[], + ) => { + messageHandler = cb; + disposables.push(receiveDisposable); + return receiveDisposable; + }), + asWebviewUri: vi.fn((u) => u), + cspSource: 'test', + }, + reveal: vi.fn(), + onDidDispose: vi.fn(( + cb: () => void, + _thisArg: unknown, + disposables: vscode.Disposable[], + ) => { + disposeHandler = cb; + disposables.push(panelDisposable); + return panelDisposable; + }), + }; + vi.mocked(vscode.window.createWebviewPanel).mockReturnValue(panel as unknown as vscode.WebviewPanel); + + let budget = 25; + const config = { + get: vi.fn(() => budget), + update: vi.fn(async (_key: string, value: number) => { budget = value; }), + }; + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(config as unknown as vscode.WorkspaceConfiguration); + vi.mocked(vscode.window.showInputBox).mockResolvedValue('50'); + + const updateGlobalState = vi.fn(() => Promise.resolve()); + const context = { + extensionUri: vscode.Uri.file('/ext'), + globalState: { get: vi.fn(() => true), update: updateGlobalState }, + } as unknown as vscode.ExtensionContext; + const store = createStore(); + + DashboardPanel.createOrShow(context, store as unknown as DashboardStore); + DashboardPanel.createOrShow(context, store as unknown as DashboardStore); + await messageHandler({ type: 'setBudget' }); + await messageHandler({ type: 'searchPrompts' }); + await messageHandler({ type: 'dismissTour' }); + await messageHandler({ type: 'refresh' }); + disposeHandler(); + + expect(vscode.window.createWebviewPanel).toHaveBeenCalledOnce(); + expect(panel.reveal).toHaveBeenCalledWith(vscode.ViewColumn.One); + expect(vscode.window.showInputBox).toHaveBeenCalledWith(expect.objectContaining({ value: '25' })); + expect(config.update).toHaveBeenCalledWith('monthlyBudgetUsd', 50, vscode.ConfigurationTarget.Global); + expect(panel.webview.postMessage).toHaveBeenCalledWith({ + type: 'stateUpdate', + payload: { budgetStatus: { budgetUsd: 50, spentUsd: 2, pct: 0.04 } }, + }); + expect(store.searchPrompts).toHaveBeenCalledWith(''); + expect(updateGlobalState).toHaveBeenCalledWith('tourDismissed', true); + expect(panel.webview.postMessage).toHaveBeenCalledWith({ type: 'stateUpdate', payload: { showTour: false } }); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('claudeDashboard.refresh'); + expect(receiveDisposable.dispose).toHaveBeenCalledOnce(); + expect(panelDisposable.dispose).toHaveBeenCalledOnce(); + expect(DashboardPanel.currentPanel).toBeUndefined(); + }); + + it('supports a disabled budget, validates input, and leaves it unchanged when cancelled', async () => { + let messageHandler: (msg: DashboardMessage) => Promise | void = () => {}; + const panel = { + webview: { + html: '', + postMessage: vi.fn(), + onDidReceiveMessage: vi.fn((cb) => { messageHandler = cb; }), + asWebviewUri: vi.fn((u) => u), + cspSource: 'test', + }, + reveal: vi.fn(), + onDidDispose: vi.fn(), + }; + vi.mocked(vscode.window.createWebviewPanel).mockReturnValue(panel as unknown as vscode.WebviewPanel); + const config = { get: vi.fn(() => 0), update: vi.fn() }; + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(config as unknown as vscode.WorkspaceConfiguration); + vi.mocked(vscode.window.showInputBox).mockResolvedValue(undefined); + const store = createStore(); + const context = { + extensionUri: vscode.Uri.file('/ext'), + globalState: { get: vi.fn(() => false), update: vi.fn() }, + } as unknown as vscode.ExtensionContext; + + DashboardPanel.createOrShow(context, store as unknown as DashboardStore); + await messageHandler({ type: 'setBudget' }); + + const inputOptions = vi.mocked(vscode.window.showInputBox).mock.calls[0][0]; + if (!inputOptions) { throw new Error('Expected budget input options'); } + expect(inputOptions.value).toBe(''); + expect(inputOptions.validateInput?.('')).toBe('Enter a non-negative number'); + expect(inputOptions.validateInput?.('not-a-number')).toBe('Enter a non-negative number'); + expect(inputOptions.validateInput?.('-1')).toBe('Enter a non-negative number'); + expect(inputOptions.validateInput?.('0')).toBeNull(); + expect(config.update).not.toHaveBeenCalled(); + expect(store.getMonthlyUsage).not.toHaveBeenCalled(); }); }); diff --git a/webview-ui/src/App.tsx b/webview-ui/src/App.tsx index 6c9f576..b8b7e59 100644 --- a/webview-ui/src/App.tsx +++ b/webview-ui/src/App.tsx @@ -25,7 +25,10 @@ export default function App() { useEffect(() => { const handler = (event: MessageEvent) => { const msg = event.data; - if (msg.type === 'stateUpdate' || msg.type === 'liveEvent') { + // liveEvent payloads are raw hook events ({type, tool, sessionId, ...}) + // and must not be spread into the state root; the store follows every + // live event with a debounced stateUpdate that carries derived state. + if (msg.type === 'stateUpdate') { setData((prev: unknown) => ({ ...(prev as object), ...msg.payload })); } }; @@ -39,7 +42,7 @@ export default function App() { } if (view === 'project') { - const { project, sessions, subagentSessions, config, projectStats, projectFiles, projectTodos, claudeCommits } = data as { + const { project, sessions, subagentSessions, config, projectStats, projectFiles, projectTodos, claudeCommits, initialSessionId } = data as { project: Project; sessions: Session[]; subagentSessions?: Session[]; @@ -48,15 +51,16 @@ export default function App() { projectFiles?: ProjectFile[]; projectTodos?: SessionTodoSnapshot[]; claudeCommits?: ClaudeCommit[]; + initialSessionId?: string; }; - return ; + return ; } const { projects, stats, usageOverTime, usageByProject, heatmapData, promptPatterns, toolUsage, hotFiles, projectedCost, streak, efficiency, weeklyRecap, recentChanges, productivityByHour, - budgetStatus, + budgetStatus, showTour, } = data as { projects: Project[]; stats: DashboardStats; @@ -73,6 +77,7 @@ export default function App() { recentChanges?: RecentFileChange[]; productivityByHour?: ProductivityHour[]; budgetStatus?: BudgetStatus | null; + showTour?: boolean; }; return ( @@ -92,6 +97,7 @@ export default function App() { recentChanges={recentChanges} productivityByHour={productivityByHour} budgetStatus={budgetStatus} + showTour={showTour} /> ); } diff --git a/webview-ui/src/__tests__/App.test.tsx b/webview-ui/src/__tests__/App.test.tsx index e5c212c..e77c523 100644 --- a/webview-ui/src/__tests__/App.test.tsx +++ b/webview-ui/src/__tests__/App.test.tsx @@ -10,7 +10,7 @@ type TestWindow = Window & { }; describe('App', () => { - it('renders dashboard by default and merges stateUpdate/liveEvent messages', async () => { + it('renders dashboard by default, merges stateUpdate, and ignores raw liveEvent payloads', async () => { const testWindow = window as TestWindow; testWindow.__INITIAL_VIEW__ = 'dashboard'; testWindow.__INITIAL_DATA__ = { projects: [], stats: makeStats({ totalProjects: 0, activeSessionCount: 0 }) }; @@ -18,12 +18,14 @@ describe('App', () => { expect(screen.getByText('Claude Code Dashboard')).toBeInTheDocument(); await act(async () => { - window.dispatchEvent(new MessageEvent('message', { data: { type: 'stateUpdate', payload: { projects: [makeProject({ name: 'Alpha' })] } } })); - window.dispatchEvent(new MessageEvent('message', { data: { type: 'liveEvent', payload: { stats: makeStats({ activeSessionCount: 5 }) } } })); + window.dispatchEvent(new MessageEvent('message', { data: { type: 'stateUpdate', payload: { projects: [makeProject({ name: 'Alpha' })], stats: makeStats({ totalProjects: 1, activeSessionCount: 5 }) } } })); + // Raw hook event: must NOT be spread into state (would corrupt the root) + window.dispatchEvent(new MessageEvent('message', { data: { type: 'liveEvent', payload: { type: 'PostToolUse', tool: 'Bash', stats: makeStats({ activeSessionCount: 99 }) } } })); }); expect(screen.getByText('Alpha')).toBeInTheDocument(); expect(screen.getByText(/5 active sessions/)).toBeInTheDocument(); + expect(screen.queryByText(/99 active sessions/)).not.toBeInTheDocument(); }); it('renders sidebar and project views from initial globals', () => { diff --git a/webview-ui/src/components/ActiveSessionCard.tsx b/webview-ui/src/components/ActiveSessionCard.tsx deleted file mode 100644 index a87abe2..0000000 --- a/webview-ui/src/components/ActiveSessionCard.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { Project } from '../types'; - -interface Props { - project: Project; -} - -export default function ActiveSessionCard({ project }: Props) { - return ( -
-
- - {project.name} -
-
- ); -} diff --git a/webview-ui/src/components/ActivityFeed.tsx b/webview-ui/src/components/ActivityFeed.tsx deleted file mode 100644 index a72dcd6..0000000 --- a/webview-ui/src/components/ActivityFeed.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; - -interface ActivityItem { - id: string; - type: string; - description: string; - timestamp: number; -} - -interface Props { - items: ActivityItem[]; -} - -export default function ActivityFeed({ items }: Props) { - return ( -
- {items.map(item => ( -
- {new Date(item.timestamp).toLocaleTimeString()} - {item.description} -
- ))} -
- ); -} diff --git a/webview-ui/src/components/HeatmapGrid.tsx b/webview-ui/src/components/HeatmapGrid.tsx index e028223..e7c3088 100644 --- a/webview-ui/src/components/HeatmapGrid.tsx +++ b/webview-ui/src/components/HeatmapGrid.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { HeatmapCell } from '../types'; +import { formatTokens } from '../utils/format'; interface Props { data: HeatmapCell[]; @@ -15,11 +16,7 @@ function getColor(tokens: number, maxTokens: number): string { return `rgba(99,102,241,${alpha.toFixed(2)})`; } -function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} + export default function HeatmapGrid({ data }: Props) { const maxTokens = Math.max(...data.map(d => d.tokens), 1); diff --git a/webview-ui/src/components/LiveSessionBanner.tsx b/webview-ui/src/components/LiveSessionBanner.tsx deleted file mode 100644 index 8b8b209..0000000 --- a/webview-ui/src/components/LiveSessionBanner.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; - -interface Props { - activeCount: number; -} - -export default function LiveSessionBanner({ activeCount }: Props) { - if (activeCount === 0) return null; - - return ( -
- - - {activeCount} active session{activeCount > 1 ? 's' : ''} running - -
- ); -} diff --git a/webview-ui/src/components/ProjectBarChart.tsx b/webview-ui/src/components/ProjectBarChart.tsx index c36a841..132c236 100644 --- a/webview-ui/src/components/ProjectBarChart.tsx +++ b/webview-ui/src/components/ProjectBarChart.tsx @@ -9,6 +9,7 @@ import { Cell, } from 'recharts'; import { ProjectUsage } from '../types'; +import { formatCost } from '../utils/format'; interface Props { data: ProjectUsage[]; @@ -43,7 +44,7 @@ export function ProjectBarChartTooltip({ active, payload }: { active?: boolean; }}>
{d.name}
{formatProjectBarTokens(d.tokens)} tokens
-
${d.costUsd.toFixed(4)}
+
{formatCost(d.costUsd)}
); } diff --git a/webview-ui/src/components/ProjectCard.tsx b/webview-ui/src/components/ProjectCard.tsx deleted file mode 100644 index 6e021ad..0000000 --- a/webview-ui/src/components/ProjectCard.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import { Project } from '../types'; -import { vscode } from '../vscode'; - -interface Props { - project: Project; -} - -export function formatProjectCardTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} - -export function projectCardTimeAgo(ts: number): string { - if (!ts) return 'never'; - const diff = Date.now() - ts; - const h = Math.floor(diff / 3_600_000); - const d = Math.floor(diff / 86_400_000); - if (h < 1) return 'just now'; - if (h < 24) return `${h}h ago`; - return `${d}d ago`; -} - -export default function ProjectCard({ project }: Props) { - return ( - - ); -} diff --git a/webview-ui/src/components/SessionDetail.tsx b/webview-ui/src/components/SessionDetail.tsx index 04e01d4..2721534 100644 --- a/webview-ui/src/components/SessionDetail.tsx +++ b/webview-ui/src/components/SessionDetail.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Session, Turn, ToolCall, TurnAttachment } from '../types'; -import { formatTokens, formatDuration } from '../utils/format'; +import { formatTokens, formatCost, formatDuration } from '../utils/format'; import { toolColor } from '../utils/toolColor'; import { MarkdownView } from './MarkdownView'; @@ -8,7 +8,13 @@ import { MarkdownView } from './MarkdownView'; type SystemEvent = | { kind: 'command'; name: string; args?: string } - | { kind: 'stdout'; text: string }; + | { kind: 'stdout'; text: string } + | { kind: 'skill'; name: string; body: string }; + +// Loading a skill injects its full instructions as a "user" message that begins +// with this marker. These can be hundreds of KB — surface them as collapsed +// context rather than a user turn. +const SKILL_INJECTION_PREFIX = 'Base directory for this skill:'; // eslint-disable-next-line no-control-regex const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g; @@ -19,6 +25,13 @@ export function parseSystemContent(content: string): SystemEvent | 'skip' | null if (//i.test(t) && !t.replace(/[\s\S]*?<\/local-command-caveat>/gi, '').trim()) { return 'skip'; } + if (t.startsWith(SKILL_INJECTION_PREFIX)) { + const nl = t.indexOf('\n'); + const firstLine = nl === -1 ? t : t.slice(0, nl); + const dir = firstLine.slice(SKILL_INJECTION_PREFIX.length).trim(); + const name = dir.split('/').filter(Boolean).pop() || 'skill'; + return { kind: 'skill', name, body: t.slice(firstLine.length).trim() }; + } const cmdMatch = t.match(/([^<]+)<\/command-name>/); if (cmdMatch) { const argsMatch = t.match(/([\s\S]*?)<\/command-args>/); @@ -46,12 +59,51 @@ function SystemEventRow({ event }: { event: SystemEvent }) { ); } + if (event.kind === 'stdout') { + return ( +
+ + + + {event.text} +
+ ); + } + return null; +} + +// Skill instructions injected as a "user" turn — rendered as collapsed context. +function SkillContextRow({ event }: { event: Extract }) { + const [expanded, setExpanded] = React.useState(false); + const body = event.body; + const shown = body.length > CONTENT_RENDER_CAP ? body.slice(0, CONTENT_RENDER_CAP) : body; + const hidden = body.length - shown.length; return ( -
- - - - {event.text} +
+ + {expanded && ( +
+ + {hidden > 0 && ( +
+ {hidden.toLocaleString()} more chars hidden — open the session file to see the full skill. +
+ )} +
+ )}
); } @@ -199,10 +251,24 @@ function AttachmentChips({ attachments }: { attachments: TurnAttachment[] }) { ); } +// Injected context (skill listings, pasted files) can make a single turn +// hundreds of KB long, which explodes into thousands of DOM nodes and freezes +// the webview. Render a bounded preview by default, expandable up to a hard cap. +const CONTENT_PREVIEW_LIMIT = 4000; +const CONTENT_RENDER_CAP = 40000; + function TurnBlock({ turn }: { turn: Turn }) { const [collapsed, setCollapsed] = React.useState(false); + const [showFull, setShowFull] = React.useState(false); const content = turn.content?.trim() ?? ''; const hasContent = content.length > 0; + const isLongContent = content.length > CONTENT_PREVIEW_LIMIT; + const displayContent = !isLongContent + ? content + : showFull + ? content.slice(0, CONTENT_RENDER_CAP) + : content.slice(0, CONTENT_PREVIEW_LIMIT); + const cappedHidden = isLongContent && showFull ? content.length - CONTENT_RENDER_CAP : 0; const hasTools = turn.toolCalls.length > 0; const attachments = turn.attachments ?? []; @@ -211,6 +277,7 @@ function TurnBlock({ turn }: { turn: Turn }) { if (turn.role === 'user' && hasContent) { const sys = parseSystemContent(content); if (sys === 'skip') return null; + if (sys && sys.kind === 'skill') return ; if (sys) return ; } @@ -273,7 +340,22 @@ function TurnBlock({ turn }: { turn: Turn }) { {attachments.length > 0 && } {hasContent && (
- + + {isLongContent && ( +
+ + {cappedHidden > 0 && ( + + {cappedHidden.toLocaleString()} more chars hidden — use copy to get the full text + + )} +
+ )}
)} {regularCalls.length > 0 && ( @@ -303,6 +385,8 @@ function TurnBlock({ turn }: { turn: Turn }) { export function modelLabel(model: string | null): string | null { if (!model) return null; + if (model.includes('fable')) return 'Fable'; + if (model.includes('mythos')) return 'Mythos'; if (model.includes('opus')) return 'Opus'; if (model.includes('haiku')) return 'Haiku'; if (model.includes('sonnet')) return 'Sonnet'; @@ -311,11 +395,37 @@ export function modelLabel(model: string | null): string | null { export function modelBadgeColor(model: string | null): string { if (!model) return ''; + if (model.includes('fable') || model.includes('mythos')) return 'text-pink-400 bg-pink-500/15'; if (model.includes('opus')) return 'text-purple-400 bg-purple-500/15'; if (model.includes('haiku')) return 'text-orange-400 bg-orange-500/15'; return 'text-blue-400 bg-blue-500/15'; } +function ResumeButton({ sessionId }: { sessionId: string }) { + const [copied, setCopied] = React.useState(false); + const command = `claude --resume ${sessionId}`; + const copy = () => { + navigator.clipboard.writeText(command).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }; + return ( + + ); +} + export default function SessionDetail({ session, turns, loading }: { session: Session; turns: Turn[]; loading: boolean }) { const totalCost = session.costUsd + (session.subagentCostUsd ?? 0); const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'failed'>('idle'); @@ -333,6 +443,9 @@ export default function SessionDetail({ session, turns, loading }: { session: Se return (
+
+ +
@@ -397,16 +520,25 @@ export default function SessionDetail({ session, turns, loading }: { session: Se
Files touched
- {session.filesModified.map(f => ( - - {session.filesCreated?.includes(f) ? '🆕 ' : '✏️ '} - {f.split('/').pop()} - - ))} + {session.filesModified.map(f => { + const created = session.filesCreated?.includes(f); + return ( + + + {f.split('/').pop()} + + ); + })}
)} diff --git a/webview-ui/src/components/SessionList.tsx b/webview-ui/src/components/SessionList.tsx deleted file mode 100644 index de1ca35..0000000 --- a/webview-ui/src/components/SessionList.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { Session } from '../types'; - -interface Props { - sessions: Session[]; - selectedId?: string; - onSelect: (session: Session) => void; -} - -function formatDuration(ms: number | null): string { - if (!ms) return '—'; - const s = Math.floor(ms / 1000); - const m = Math.floor(s / 60); - const h = Math.floor(m / 60); - if (h > 0) return `${h}h ${m % 60}m`; - if (m > 0) return `${m}m ${s % 60}s`; - return `${s}s`; -} - -function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; - return String(n); -} - -function modelLabel(model: string | null): string | null { - if (!model) return null; - if (model.includes('opus')) return 'Opus'; - if (model.includes('haiku')) return 'Haiku'; - if (model.includes('sonnet')) return 'Sonnet'; - return null; -} - -function modelBadgeColor(model: string | null): string { - if (!model) return ''; - if (model.includes('opus')) return 'text-purple-400 bg-purple-500/15'; - if (model.includes('haiku')) return 'text-orange-400 bg-orange-500/15'; - return 'text-blue-400 bg-blue-500/15'; -} - -export default function SessionList({ sessions, selectedId, onSelect }: Props) { - const sorted = [...sessions].sort((a, b) => b.startTime - a.startTime); - - return ( -
- {sorted.map(s => ( - - ))} -
- ); -} diff --git a/webview-ui/src/components/SessionsBrowser.tsx b/webview-ui/src/components/SessionsBrowser.tsx new file mode 100644 index 0000000..d90ecfe --- /dev/null +++ b/webview-ui/src/components/SessionsBrowser.tsx @@ -0,0 +1,270 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { SessionRow, PromptSearchResult } from '../types'; +import { vscode } from '../vscode'; +import { formatTokens, formatCost, formatDuration, timeAgo } from '../utils/format'; +import { modelLabel, modelBadgeColor } from './SessionDetail'; + +type SortKey = 'recent' | 'cost' | 'tokens'; +type ModelFilter = 'all' | 'opus' | 'sonnet' | 'haiku' | 'fable'; + +function ThinkingIcon() { + return ( + + ); +} + +function monthStart(): number { + const d = new Date(); + return new Date(d.getFullYear(), d.getMonth(), 1).getTime(); +} + +export default function SessionsBrowser() { + const [rows, setRows] = useState(null); + const [query, setQuery] = useState(''); + const [searchResults, setSearchResults] = useState(null); + const [searching, setSearching] = useState(false); + const [projectFilter, setProjectFilter] = useState('all'); + const [modelFilter, setModelFilter] = useState('all'); + const [sort, setSort] = useState('recent'); + const [thisMonthOnly, setThisMonthOnly] = useState(false); + + // Request the cross-project session list once, and refresh on state updates. + useEffect(() => { + vscode.postMessage({ type: 'getAllSessions' }); + const handler = (event: MessageEvent) => { + const msg = event.data; + if (msg.type === 'allSessions') { + setRows(msg.sessions ?? []); + } else if (msg.type === 'promptSearchResults') { + setSearchResults(msg.results ?? []); + setSearching(false); + } else if (msg.type === 'stateUpdate') { + // sessions may have changed — re-request + vscode.postMessage({ type: 'getAllSessions' }); + } + }; + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, []); + + // Debounced backend-served prompt search. + useEffect(() => { + const q = query.trim(); + if (!q) { + setSearchResults(null); + setSearching(false); + return; + } + setSearching(true); + const t = setTimeout(() => { + vscode.postMessage({ type: 'searchPrompts', query: q }); + }, 250); + return () => clearTimeout(t); + }, [query]); + + const projectNames = useMemo(() => { + const names = new Set(); + (rows ?? []).forEach(r => names.add(r.projectName)); + return Array.from(names).sort(); + }, [rows]); + + const openSession = useCallback((projectId: string, sessionId: string) => { + vscode.postMessage({ type: 'openProject', projectId, sessionId }); + }, []); + + const filtered = useMemo(() => { + let list = rows ?? []; + if (projectFilter !== 'all') { list = list.filter(r => r.projectName === projectFilter); } + if (modelFilter !== 'all') { list = list.filter(r => (r.model ?? '').includes(modelFilter)); } + if (thisMonthOnly) { + const ms = monthStart(); + list = list.filter(r => r.startTime >= ms); + } + const sorted = [...list]; + if (sort === 'cost') { sorted.sort((a, b) => (b.costUsd + b.subagentCostUsd) - (a.costUsd + a.subagentCostUsd)); } + else if (sort === 'tokens') { sorted.sort((a, b) => b.totalTokens - a.totalTokens); } + else { sorted.sort((a, b) => b.startTime - a.startTime); } + return sorted; + }, [rows, projectFilter, modelFilter, thisMonthOnly, sort]); + + if (rows === null) { + return
Loading sessions…
; + } + + if (rows.length === 0) { + return
No sessions recorded yet.
; + } + + return ( +
+ {/* Prompt search */} +
+ + setQuery(e.target.value)} + placeholder="Search every prompt you've sent to Claude…" + className="w-full text-sm pl-9 pr-3 py-2 rounded-lg bg-[var(--vscode-input-background)] border border-[var(--vscode-input-border)] text-[var(--vscode-input-foreground)] placeholder-[var(--vscode-input-placeholderForeground)] focus:outline-none focus:border-[var(--vscode-button-background)]" + /> +
+ + {searchResults !== null ? ( + + ) : ( + <> + {/* Filter bar */} +
+ + + + + {filtered.length} of {rows.length} +
+ + {/* Session rows */} +
+ {filtered.map(row => ( + + ))} + {filtered.length === 0 && ( +
No sessions match these filters.
+ )} +
+ + )} +
+ ); +} + +function SessionRowItem({ row, onOpen }: { row: SessionRow; onOpen: (projectId: string, sessionId: string) => void }) { + const totalCost = row.costUsd + row.subagentCostUsd; + return ( + + ); +} + +function PromptSearchResults({ + results, + query, + searching, + onOpen, +}: { + results: PromptSearchResult[]; + query: string; + searching: boolean; + onOpen: (projectId: string, sessionId: string) => void; +}) { + if (searching && results.length === 0) { + return
Searching…
; + } + if (results.length === 0) { + return
No prompts match “{query}”.
; + } + return ( +
+
{results.length} match{results.length !== 1 ? 'es' : ''} for “{query}”
+ {results.map((r, i) => ( + + ))} +
+ ); +} + +export function HighlightedSnippet({ text, query }: { text: string; query: string }) { + const q = query.trim(); + if (!q) { return <>{text}; } + const idx = text.toLowerCase().indexOf(q.toLowerCase()); + if (idx === -1) { return <>{text}; } + return ( + <> + {text.slice(0, idx)} + {text.slice(idx, idx + q.length)} + {text.slice(idx + q.length)} + + ); +} diff --git a/webview-ui/src/components/UsageChart.tsx b/webview-ui/src/components/UsageChart.tsx deleted file mode 100644 index cb225db..0000000 --- a/webview-ui/src/components/UsageChart.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; - -interface DataPoint { - label: string; - tokens: number; - cost: number; -} - -interface Props { - data: DataPoint[]; -} - -export default function UsageChart({ data }: Props) { - return ( - - - - - - - - - ); -} diff --git a/webview-ui/src/components/UsageLineChart.tsx b/webview-ui/src/components/UsageLineChart.tsx index 21ee565..abb7e38 100644 --- a/webview-ui/src/components/UsageLineChart.tsx +++ b/webview-ui/src/components/UsageLineChart.tsx @@ -9,6 +9,7 @@ import { CartesianGrid, } from 'recharts'; import { DailyUsage } from '../types'; +import { formatCost } from '../utils/format'; interface Props { data: DailyUsage[]; @@ -43,7 +44,7 @@ export function UsageLineChartTooltip({ active, payload, label }: { active?: boo }}>
{label}
{formatUsageTokens(d.tokens)} tokens
-
${d.costUsd.toFixed(4)}
+
{formatCost(d.costUsd)}
); } diff --git a/webview-ui/src/components/WeeklyStatsTab.tsx b/webview-ui/src/components/WeeklyStatsTab.tsx index eb00294..82f935a 100644 --- a/webview-ui/src/components/WeeklyStatsTab.tsx +++ b/webview-ui/src/components/WeeklyStatsTab.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'; import { ProjectStats } from '../types'; -import { formatTokens } from '../utils/format'; +import { formatTokens, formatCost } from '../utils/format'; function StatCard({ label, value }: { label: string; value: string }) { return ( @@ -13,7 +13,7 @@ function StatCard({ label, value }: { label: string; value: string }) { } export function formatWeeklyTooltipValue(value: number, name: string): [string, string] { - return [name === 'tokens' ? formatTokens(value) : `$${value.toFixed(4)}`, name]; + return [name === 'tokens' ? formatTokens(value) : formatCost(value), name]; } export default function WeeklyStatsTab({ projectStats }: { projectStats?: ProjectStats }) { @@ -28,7 +28,7 @@ export default function WeeklyStatsTab({ projectStats }: { projectStats?: Projec
- +

Costs are estimated from local Claude session logs and detected model pricing. @@ -63,7 +63,7 @@ export default function WeeklyStatsTab({ projectStats }: { projectStats?: Projec {day.date} {day.sessions} session{day.sessions !== 1 ? 's' : ''} {formatTokens(day.tokens)} tok - ${day.costUsd.toFixed(4)} + {formatCost(day.costUsd)} {day.tokens > 0 && (

{ - it('renders the active project name', () => { - render(); - expect(screen.getByText('Live Project')).toBeInTheDocument(); - }); -}); diff --git a/webview-ui/src/components/__tests__/ActivityFeed.test.tsx b/webview-ui/src/components/__tests__/ActivityFeed.test.tsx deleted file mode 100644 index 49aea5b..0000000 --- a/webview-ui/src/components/__tests__/ActivityFeed.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '../../__tests__/helpers/render-helpers'; -import ActivityFeed from '../ActivityFeed'; - -describe('ActivityFeed', () => { - it('renders entries with descriptions', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); - render(); - expect(screen.getByText('Edited src/index.ts')).toBeInTheDocument(); - vi.useRealTimers(); - }); -}); diff --git a/webview-ui/src/components/__tests__/LiveSessionBanner.test.tsx b/webview-ui/src/components/__tests__/LiveSessionBanner.test.tsx deleted file mode 100644 index d7124fa..0000000 --- a/webview-ui/src/components/__tests__/LiveSessionBanner.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { describe, expect, it } from 'vitest'; -import { render, screen } from '../../__tests__/helpers/render-helpers'; -import LiveSessionBanner from '../LiveSessionBanner'; - -describe('LiveSessionBanner', () => { - it('renders nothing for zero sessions and pluralizes otherwise', () => { - const { rerender, container } = render(); - expect(container).toBeEmptyDOMElement(); - rerender(); - expect(screen.getByText('1 active session running')).toBeInTheDocument(); - rerender(); - expect(screen.getByText('2 active sessions running')).toBeInTheDocument(); - }); -}); diff --git a/webview-ui/src/components/__tests__/ProjectBarChart.test.tsx b/webview-ui/src/components/__tests__/ProjectBarChart.test.tsx index b0ef75c..6180002 100644 --- a/webview-ui/src/components/__tests__/ProjectBarChart.test.tsx +++ b/webview-ui/src/components/__tests__/ProjectBarChart.test.tsx @@ -19,7 +19,7 @@ describe('ProjectBarChart', () => { expect(screen.getByText('Large Project')).toBeInTheDocument(); expect(screen.getByText('1.2M tokens')).toBeInTheDocument(); - expect(screen.getByText('$1.2345')).toBeInTheDocument(); + expect(screen.getByText('$1.23')).toBeInTheDocument(); expect(formatProjectBarTokens(1500)).toBe('2k'); expect(formatProjectBarTokens(999)).toBe('999'); }); diff --git a/webview-ui/src/components/__tests__/ProjectCard.test.tsx b/webview-ui/src/components/__tests__/ProjectCard.test.tsx deleted file mode 100644 index ccdcb93..0000000 --- a/webview-ui/src/components/__tests__/ProjectCard.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '../../__tests__/helpers/render-helpers'; -import { makeProject } from '../../__tests__/fixtures/test-data'; -import { mockPostMessage } from '../../__tests__/setup'; -import ProjectCard, { formatProjectCardTokens, projectCardTimeAgo } from '../ProjectCard'; - -describe('ProjectCard', () => { - it('renders active state and posts openProject', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); - - render(); - expect(screen.getByText('Alpha')).toBeInTheDocument(); - expect(screen.getByText('live')).toBeInTheDocument(); - expect(screen.getByText('1.5k tokens')).toBeInTheDocument(); - expect(screen.getByText('2h ago')).toBeInTheDocument(); - - fireEvent.click(screen.getByRole('button')); - expect(mockPostMessage).toHaveBeenCalledWith({ type: 'openProject', projectId: 'p1' }); - - vi.useRealTimers(); - }); - - it('renders inactive fallback time text', () => { - render(); - expect(screen.getByText('never')).toBeInTheDocument(); - }); - - it('formats token counts and relative times across branches', () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2025-01-15T12:00:00Z')); - - expect(formatProjectCardTokens(2_500_000)).toBe('2.5M'); - expect(formatProjectCardTokens(2500)).toBe('2.5k'); - expect(formatProjectCardTokens(250)).toBe('250'); - expect(projectCardTimeAgo(Date.now() - 30 * 60_000)).toBe('just now'); - expect(projectCardTimeAgo(Date.now() - 26 * 3_600_000)).toBe('1d ago'); - - vi.useRealTimers(); - }); -}); diff --git a/webview-ui/src/components/__tests__/SessionDetail.test.tsx b/webview-ui/src/components/__tests__/SessionDetail.test.tsx index dd3fc32..c448334 100644 --- a/webview-ui/src/components/__tests__/SessionDetail.test.tsx +++ b/webview-ui/src/components/__tests__/SessionDetail.test.tsx @@ -90,6 +90,47 @@ describe('SessionDetail', () => { expect(chip.closest('span[title]')).toHaveAttribute('title', '/home/user/project/docs/plan.md'); }); + it('truncates very long turn content behind a show-full toggle', () => { + const longContent = 'word '.repeat(2000).trim(); // ~10k chars + const session = makeSession({ + turns: [makeTurn({ role: 'user', content: longContent, timestamp: 1 })], + }); + + render(); + + const toggle = screen.getByText(`Show full message (${longContent.length.toLocaleString()} chars)`); + expect(toggle).toBeInTheDocument(); + + fireEvent.click(toggle); + expect(screen.getByText('Show less')).toBeInTheDocument(); + }); + + it('parses skill-injection content into a skill event', () => { + const skill = parseSystemContent('Base directory for this skill: /Users/me/.claude/skills/graphify\n\n# graphify\n\nDoes things.'); + expect(skill).toEqual({ kind: 'skill', name: 'graphify', body: '# graphify\n\nDoes things.' }); + }); + + it('renders a skill-injection turn as collapsed context, not a user message', () => { + const body = 'x'.repeat(9000); + const session = makeSession({ + turns: [makeTurn({ + role: 'user', + content: `Base directory for this skill: /Users/me/.claude/skills/dataviz\n\n# Data Viz\n\n${body}`, + timestamp: 1, + })], + }); + + render(); + + expect(screen.getByText('Skill loaded')).toBeInTheDocument(); + expect(screen.getByText('dataviz')).toBeInTheDocument(); + // Collapsed by default — body not rendered until expanded. + expect(screen.queryByText('Data Viz')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Show context')); + expect(screen.getByText('Data Viz')).toBeInTheDocument(); + }); + it('renders cache, thinking, subagent cost, collapsed previews, and tool-only assistant turns', () => { const longContent = 'A'.repeat(140); const session = makeSession({ @@ -125,8 +166,9 @@ describe('SessionDetail', () => { expect(screen.getByText('Opus')).toBeInTheDocument(); expect(screen.getByText('+300 cached')).toBeInTheDocument(); expect(screen.getByText('50% cache')).toBeInTheDocument(); - expect(screen.getByText(/\+\$0.1250 subagents/)).toBeInTheDocument(); - expect(screen.getByText(/\u26a1 thinking/)).toBeInTheDocument(); + expect(screen.getByText(/\+\$0.125 subagents/)).toBeInTheDocument(); + expect(screen.getByText(/thinking \(1.2k\)/)).toBeInTheDocument(); + expect(screen.getByText('Copy resume command')).toBeInTheDocument(); expect(screen.getByText('github/search')).toBeInTheDocument(); expect(screen.getByText('repo:foo bug')).toBeInTheDocument(); expect(screen.getByText('10↑ 20↓')).toBeInTheDocument(); diff --git a/webview-ui/src/components/__tests__/SessionList.test.tsx b/webview-ui/src/components/__tests__/SessionList.test.tsx deleted file mode 100644 index efec77b..0000000 --- a/webview-ui/src/components/__tests__/SessionList.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen } from '../../__tests__/helpers/render-helpers'; -import { makeSession } from '../../__tests__/fixtures/test-data'; -import SessionList from '../SessionList'; - -describe('SessionList', () => { - it('sorts sessions, selects one, and renders activity states', () => { - const onSelect = vi.fn(); - const older = makeSession({ id: 'older', startTime: 1000, durationMs: 61_000, totalTokens: 1200, promptCount: 2, activityRatio: 75, model: 'claude-opus-4' }); - const newer = makeSession({ id: 'newer', startTime: 2000, durationMs: 10_000, totalTokens: 500, promptCount: 1, activityRatio: 50, model: 'claude-sonnet-4' }); - const lowest = makeSession({ id: 'low', startTime: 1500, durationMs: null, totalTokens: 50, promptCount: 3, activityRatio: 20, model: 'claude-haiku-4' }); - const unknown = makeSession({ id: 'unknown', startTime: 500, durationMs: 5_000, totalTokens: 25, promptCount: 1, activityRatio: null, model: 'claude-custom-next' }); - - render(); - - const buttons = screen.getAllByRole('button'); - expect(buttons[0]).toHaveTextContent('500 tokens'); - expect(screen.getByText('75% active')).toBeInTheDocument(); - expect(screen.getByText('50% active')).toBeInTheDocument(); - expect(screen.getByText('20% active')).toBeInTheDocument(); - expect(screen.getByText('Opus')).toBeInTheDocument(); - expect(screen.getByText('Sonnet')).toBeInTheDocument(); - expect(screen.getByText('Haiku')).toBeInTheDocument(); - expect(screen.queryByText('claude-custom-next')).not.toBeInTheDocument(); - expect(buttons[1]).toHaveTextContent('— · 50 tokens · 3 prompts · 20% active'); - - fireEvent.click(buttons[1]); - expect(onSelect).toHaveBeenCalledWith(lowest); - }); -}); diff --git a/webview-ui/src/components/__tests__/SessionsBrowser.test.tsx b/webview-ui/src/components/__tests__/SessionsBrowser.test.tsx new file mode 100644 index 0000000..5261e77 --- /dev/null +++ b/webview-ui/src/components/__tests__/SessionsBrowser.test.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import { describe, expect, it, beforeEach, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '../../__tests__/helpers/render-helpers'; +import { mockPostMessage } from '../../__tests__/setup'; +import SessionsBrowser, { HighlightedSnippet } from '../SessionsBrowser'; +import { SessionRow } from '../../types'; + +function row(overrides: Partial = {}): SessionRow { + return { + id: 's1', projectId: 'p1', projectName: 'Alpha', summary: 'Fix the auth bug', + model: 'claude-opus-4-8', pricingConfidence: 'exact', startTime: Date.now(), durationMs: 60_000, + totalTokens: 12_000, costUsd: 0.5, subagentCostUsd: 0, hasThinking: true, promptCount: 3, + toolCallCount: 5, isActiveSession: false, ...overrides, + }; +} + +function sendAllSessions(sessions: SessionRow[]) { + act(() => { + window.dispatchEvent(new MessageEvent('message', { data: { type: 'allSessions', sessions } })); + }); +} + +describe('SessionsBrowser', () => { + beforeEach(() => { mockPostMessage.mockClear(); }); + + it('requests sessions on mount and renders rows, then opens a session on click', async () => { + render(); + expect(mockPostMessage).toHaveBeenCalledWith({ type: 'getAllSessions' }); + expect(screen.getByText('Loading sessions…')).toBeInTheDocument(); + + sendAllSessions([ + row({ id: 's1', summary: 'Fix the auth bug', projectName: 'Alpha' }), + row({ id: 's2', summary: 'Refactor parser', projectName: 'Beta', model: 'claude-sonnet-5' }), + ]); + + expect(screen.getByText('Fix the auth bug')).toBeInTheDocument(); + expect(screen.getByText('Refactor parser')).toBeInTheDocument(); + expect(screen.getByText('2 of 2')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Fix the auth bug')); + expect(mockPostMessage).toHaveBeenCalledWith({ type: 'openProject', projectId: 'p1', sessionId: 's1' }); + }); + + it('filters by project and model', () => { + render(); + sendAllSessions([ + row({ id: 's1', summary: 'Alpha opus', projectName: 'Alpha', model: 'claude-opus-4-8' }), + row({ id: 's2', summary: 'Beta sonnet', projectName: 'Beta', model: 'claude-sonnet-5' }), + ]); + + fireEvent.change(screen.getByDisplayValue('All projects'), { target: { value: 'Alpha' } }); + expect(screen.getByText('Alpha opus')).toBeInTheDocument(); + expect(screen.queryByText('Beta sonnet')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByDisplayValue('Alpha'), { target: { value: 'all' } }); + fireEvent.change(screen.getByDisplayValue('All models'), { target: { value: 'sonnet' } }); + expect(screen.getByText('Beta sonnet')).toBeInTheDocument(); + expect(screen.queryByText('Alpha opus')).not.toBeInTheDocument(); + }); + + it('runs a debounced prompt search and highlights matches', async () => { + vi.useFakeTimers(); + render(); + sendAllSessions([row()]); + + fireEvent.change(screen.getByPlaceholderText(/Search every prompt/), { target: { value: 'auth' } }); + act(() => { vi.advanceTimersByTime(300); }); + expect(mockPostMessage).toHaveBeenCalledWith({ type: 'searchPrompts', query: 'auth' }); + + act(() => { + window.dispatchEvent(new MessageEvent('message', { data: { type: 'promptSearchResults', query: 'auth', results: [ + { projectId: 'p1', projectName: 'Alpha', sessionId: 's1', turn: {}, snippet: 'please fix the auth flow' }, + ] } })); + }); + expect(screen.getByText('1 match for “auth”')).toBeInTheDocument(); + const highlightedResult = document.querySelector('mark')?.closest('button'); + if (!highlightedResult) { throw new Error('Expected a highlighted prompt result'); } + fireEvent.click(highlightedResult); + expect(mockPostMessage).toHaveBeenCalledWith({ type: 'openProject', projectId: 'p1', sessionId: 's1' }); + vi.useRealTimers(); + }); + + it('refreshes, sorts, and limits sessions to the current month', () => { + render(); + sendAllSessions([ + row({ id: 'recent', summary: 'Recent expensive', startTime: Date.now(), totalTokens: 100, costUsd: 5 }), + row({ id: 'tokens', summary: 'Most tokens', startTime: Date.now() - 1000, totalTokens: 50_000, costUsd: 1 }), + row({ + id: 'old', + summary: null, + model: null, + startTime: 0, + totalTokens: 0, + costUsd: 0, + hasThinking: false, + isActiveSession: true, + }), + ]); + + fireEvent.change(screen.getByDisplayValue('Most recent'), { target: { value: 'cost' } }); + expect(screen.getByDisplayValue('Most expensive')).toBeInTheDocument(); + + fireEvent.change(screen.getByDisplayValue('Most expensive'), { target: { value: 'tokens' } }); + expect(screen.getByDisplayValue('Most tokens')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Most expensive this month' })); + expect(screen.getByText('2 of 3')).toBeInTheDocument(); + expect(screen.queryByText('Untitled session')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Most expensive this month' })); + expect(screen.getByText('3 of 3')).toBeInTheDocument(); + expect(screen.getByText('Untitled session')).toBeInTheDocument(); + + act(() => { + window.dispatchEvent(new MessageEvent('message', { data: { type: 'stateUpdate' } })); + }); + expect(mockPostMessage).toHaveBeenCalledWith({ type: 'getAllSessions' }); + }); + + it('shows an empty state when there are no sessions', () => { + render(); + sendAllSessions([]); + expect(screen.getByText('No sessions recorded yet.')).toBeInTheDocument(); + }); + + it('highlights the matched substring', () => { + render(); + const mark = document.querySelector('mark'); + expect(mark?.textContent).toBe('auth'); + }); + + it('leaves snippets unchanged for empty or missing queries', () => { + const { rerender } = render(); + expect(screen.getByText('plain text')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('plain text')).toBeInTheDocument(); + expect(document.querySelector('mark')).toBeNull(); + }); +}); diff --git a/webview-ui/src/components/__tests__/UsageChart.test.tsx b/webview-ui/src/components/__tests__/UsageChart.test.tsx deleted file mode 100644 index 133a7de..0000000 --- a/webview-ui/src/components/__tests__/UsageChart.test.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import { describe, expect, it } from 'vitest'; -import { render, screen } from '../../__tests__/helpers/render-helpers'; -import UsageChart from '../UsageChart'; - -describe('UsageChart', () => { - it('renders the chart container', () => { - render(); - expect(screen.getByTestId('bar-chart')).toBeInTheDocument(); - }); -}); diff --git a/webview-ui/src/components/__tests__/UsageLineChart.test.tsx b/webview-ui/src/components/__tests__/UsageLineChart.test.tsx index a2db82a..34d013b 100644 --- a/webview-ui/src/components/__tests__/UsageLineChart.test.tsx +++ b/webview-ui/src/components/__tests__/UsageLineChart.test.tsx @@ -28,7 +28,7 @@ describe('UsageLineChart', () => { expect(screen.getByText('1/20')).toBeInTheDocument(); expect(screen.getByText('1k tokens')).toBeInTheDocument(); - expect(screen.getByText('$0.1250')).toBeInTheDocument(); + expect(screen.getByText('$0.125')).toBeInTheDocument(); expect(formatUsageTokens(2_000_000)).toBe('2.0M'); expect(formatUsageTokens(250)).toBe('250'); expect(render().container).toBeEmptyDOMElement(); diff --git a/webview-ui/src/components/__tests__/WeeklyStatsTab.test.tsx b/webview-ui/src/components/__tests__/WeeklyStatsTab.test.tsx index 88f2720..80b950d 100644 --- a/webview-ui/src/components/__tests__/WeeklyStatsTab.test.tsx +++ b/webview-ui/src/components/__tests__/WeeklyStatsTab.test.tsx @@ -51,6 +51,6 @@ describe('WeeklyStatsTab', () => { expect(screen.getByText('No activity in the last 7 days.')).toBeInTheDocument(); expect(formatWeeklyTooltipValue(1250, 'tokens')).toEqual(['1.3k', 'tokens']); - expect(formatWeeklyTooltipValue(0.125, 'costUsd')).toEqual(['$0.1250', 'costUsd']); + expect(formatWeeklyTooltipValue(0.125, 'costUsd')).toEqual(['$0.125', 'costUsd']); }); }); diff --git a/webview-ui/src/types.ts b/webview-ui/src/types.ts index ab1a735..266d01b 100644 --- a/webview-ui/src/types.ts +++ b/webview-ui/src/types.ts @@ -38,7 +38,8 @@ export interface Session { idleTimeMs: number | null; activeTimeMs: number | null; activityRatio: number | null; - model: string | null; + model: string | null; // raw detected model ID (e.g. 'claude-opus-4-8') + pricingConfidence?: 'exact' | 'fallback'; // 'fallback' = unknown model, priced at Sonnet rates } export interface Turn { @@ -101,6 +102,25 @@ export interface PromptSearchResult { snippet: string; } +/** Lightweight cross-project session row for the dashboard Sessions tab. */ +export interface SessionRow { + id: string; + projectId: string; + projectName: string; + summary: string | null; + model: string | null; + pricingConfidence?: 'exact' | 'fallback'; + startTime: number; + durationMs: number | null; + totalTokens: number; + costUsd: number; + subagentCostUsd: number; + hasThinking: boolean; + promptCount: number; + toolCallCount: number; + isActiveSession: boolean; +} + export interface PatternCount { category: string; count: number; diff --git a/webview-ui/src/utils/__tests__/format.test.ts b/webview-ui/src/utils/__tests__/format.test.ts index e8f3beb..fab60e6 100644 --- a/webview-ui/src/utils/__tests__/format.test.ts +++ b/webview-ui/src/utils/__tests__/format.test.ts @@ -1,7 +1,26 @@ import { describe, expect, it, vi } from 'vitest'; -import { formatDuration, formatTokens, timeAgo } from '../format'; +import { formatAvg, formatCost, formatDuration, formatTokens, timeAgo, COST_DISCLAIMER, PRICING_TABLE_DATE } from '../format'; describe('format utils', () => { + it('formats cost with the standard precision tiers', () => { + expect(formatCost(4.321)).toBe('$4.32'); + expect(formatCost(1)).toBe('$1.00'); + expect(formatCost(0.0432)).toBe('$0.043'); + expect(formatCost(0.01)).toBe('$0.010'); + expect(formatCost(0.0042)).toBe('<$0.01'); + expect(formatCost(0)).toBe('$0.00'); + expect(formatCost(-1)).toBe('$0.00'); + }); + + it('formats one-decimal averages', () => { + expect(formatAvg(12.49)).toBe('12.5'); + expect(formatAvg(3)).toBe('3.0'); + }); + + it('embeds the pricing table date in the disclaimer', () => { + expect(COST_DISCLAIMER).toContain(PRICING_TABLE_DATE); + }); + it('formats token counts', () => { expect(formatTokens(999)).toBe('999'); expect(formatTokens(1200)).toBe('1.2k'); diff --git a/webview-ui/src/utils/__tests__/formatGuard.test.ts b/webview-ui/src/utils/__tests__/formatGuard.test.ts new file mode 100644 index 0000000..dbfc95c --- /dev/null +++ b/webview-ui/src/utils/__tests__/formatGuard.test.ts @@ -0,0 +1,54 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; + +/** + * Guard: all user-facing number formatting goes through utils/format.ts. + * Inline `.toFixed(` in components leads to the inconsistent cost precision + * this rule exists to prevent ($0.003 vs $0.00 vs $0.0004 on one screen). + * + * If you legitimately need `.toFixed(` outside utils/format.ts (e.g. CSS/rgba + * interpolation), add the file to the allowlist below with a reason. + */ +const ALLOWED: Record = { + // the formatting module itself + 'utils/format.ts': Infinity, + // rgba() alpha interpolation for heatmap cells — not user-facing numbers + 'components/HeatmapGrid.tsx': 2, + // chart axis tick formatters (0-decimal token shorthand for narrow axes) + 'components/UsageLineChart.tsx': 2, + 'components/ProjectBarChart.tsx': 2, +}; + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === '__tests__' || entry.name === 'node_modules') { continue; } + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...walk(full)); + } else if (/\.(ts|tsx)$/.test(entry.name)) { + out.push(full); + } + } + return out; +} + +describe('formatting guard', () => { + it('no inline .toFixed( outside utils/format.ts (use formatCost/formatTokens/formatAvg)', () => { + const srcRoot = path.resolve(__dirname, '..', '..'); + const violations: string[] = []; + + for (const file of walk(srcRoot)) { + const rel = path.relative(srcRoot, file).replace(/\\/g, '/'); + const count = (fs.readFileSync(file, 'utf-8').match(/\.toFixed\(/g) ?? []).length; + if (count === 0) { continue; } + const allowed = ALLOWED[rel] ?? 0; + if (count > allowed) { + violations.push(`${rel}: ${count} .toFixed( call(s), ${allowed} allowed`); + } + } + + expect(violations, violations.join('\n')).toEqual([]); + }); +}); diff --git a/webview-ui/src/utils/format.ts b/webview-ui/src/utils/format.ts index 04f94a1..81433d6 100644 --- a/webview-ui/src/utils/format.ts +++ b/webview-ui/src/utils/format.ts @@ -1,9 +1,34 @@ +/** Keep in sync with PRICING_TABLE_DATE in src/parsers/SessionParser.ts */ +export const PRICING_TABLE_DATE = '2026-06'; + +export const COST_DISCLAIMER = + `Token usage comes from local Claude session logs. Costs are estimated from detected model pricing (table updated ${PRICING_TABLE_DATE}) and may differ from Anthropic's actual billing.`; + export function formatTokens(n: number): string { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; return String(n); } +/** + * The one way cost renders anywhere in the UI: + * >= $1 → "$4.32" + * $0.01–$1 → "$0.043" + * 0 < x < $0.01 → "<$0.01" + * 0 (or negative) → "$0.00" + */ +export function formatCost(usd: number): string { + if (!usd || usd <= 0) return '$0.00'; + if (usd >= 1) return `$${usd.toFixed(2)}`; + if (usd >= 0.01) return `$${usd.toFixed(3)}`; + return '<$0.01'; +} + +/** Average with one decimal place, e.g. "12.5" */ +export function formatAvg(n: number): string { + return n.toFixed(1); +} + export function formatDuration(ms: number | null): string { if (!ms) return '—'; const s = Math.floor(ms / 1000); diff --git a/webview-ui/src/views/Dashboard.tsx b/webview-ui/src/views/Dashboard.tsx index 2eb9a04..3b017dd 100644 --- a/webview-ui/src/views/Dashboard.tsx +++ b/webview-ui/src/views/Dashboard.tsx @@ -17,6 +17,7 @@ import { BudgetStatus, } from '../types'; import { vscode } from '../vscode'; +import { formatTokens, formatCost, timeAgo, COST_DISCLAIMER } from '../utils/format'; import UsageLineChart from '../components/UsageLineChart'; import ProjectBarChart from '../components/ProjectBarChart'; import HeatmapGrid from '../components/HeatmapGrid'; @@ -26,6 +27,7 @@ import HotFilesList from '../components/HotFilesList'; import EfficiencyCards from '../components/EfficiencyCards'; import RecentChanges from '../components/RecentChanges'; import ProductivityChart from '../components/ProductivityChart'; +import SessionsBrowser from '../components/SessionsBrowser'; interface Props { projects: Project[]; @@ -43,34 +45,33 @@ interface Props { recentChanges?: RecentFileChange[]; productivityByHour?: ProductivityHour[]; budgetStatus?: BudgetStatus | null; + showTour?: boolean; } -type Tab = 'overview' | 'charts' | 'insights'; - -function formatTokens(n: number): string { - if (n >= 1_000_000) { return `${(n / 1_000_000).toFixed(1)}M`; } - if (n >= 1_000) { return `${(n / 1_000).toFixed(1)}k`; } - return String(n); -} - -function timeAgo(ts: number): string { - if (!ts) { return 'never'; } - const diff = Date.now() - ts; - const h = Math.floor(diff / 3_600_000); - const d = Math.floor(diff / 86_400_000); - if (h < 1) { return 'just now'; } - if (h < 24) { return `${h}h ago`; } - return `${d}d ago`; -} +type Tab = 'home' | 'analytics' | 'sessions'; +type RangeKey = 7 | 30 | 90; const TAB_LABELS: Array<{ key: Tab; label: string; description: string }> = [ - { key: 'overview', label: 'Overview', description: 'Track active projects, weekly momentum, and recent portfolio activity.' }, - { key: 'charts', label: 'Charts', description: 'Review usage and cost trends across the month.' }, - { key: 'insights', label: 'Insights', description: 'Explore patterns, productivity, and hotspots across your work.' }, + { key: 'home', label: 'Home', description: 'Today at a glance — live activity, budget, and your projects.' }, + { key: 'analytics', label: 'Analytics', description: 'Spend, patterns, tools, and efficiency over time.' }, + { key: 'sessions', label: 'Sessions', description: 'Browse and search every session across all projects.' }, ]; type SortKey = 'lastActive' | 'cost' | 'sessions'; +// ── ⓘ tooltip carrying the single cost disclaimer ────────────────────────────── +function InfoDot({ label }: { label: string }) { + return ( + + i + + ); +} + export default function Dashboard({ projects, stats, @@ -87,50 +88,53 @@ export default function Dashboard({ recentChanges, productivityByHour, budgetStatus, + showTour, }: Props) { - const [activeTab, setActiveTab] = useState('overview'); + const [activeTab, setActiveTab] = useState('home'); const [projectFilter, setProjectFilter] = useState(''); const [projectSort, setProjectSort] = useState('lastActive'); + const [showAllProjects, setShowAllProjects] = useState(false); + const [range, setRange] = useState(30); const active = projects.filter(p => p.isActive); const activeTabMeta = TAB_LABELS.find(tab => tab.key === activeTab) ?? TAB_LABELS[0]; - const filteredProjects = projects + const allInactive = projects .filter(p => !p.isActive) .filter(p => !projectFilter || p.name.toLowerCase().includes(projectFilter.toLowerCase())) .sort((a, b) => { if (projectSort === 'cost') { return b.totalCostUsd - a.totalCostUsd; } if (projectSort === 'sessions') { return b.sessionCount - a.sessionCount; } return b.lastActive - a.lastActive; - }) - .slice(0, 20); + }); + const PROJECT_CAP = 20; + const filteredProjects = showAllProjects ? allInactive : allInactive.slice(0, PROJECT_CAP); + + const hasData = projects.length > 0 || (stats?.totalProjects ?? 0) > 0; return (
{/* Header */} -
+
-
Workspace
-

Claude Code Dashboard

+

Claude Code Dashboard

{stats?.totalProjects ?? 0} projects · {stats?.activeSessionCount ?? 0} active sessions

-
+
{formatTokens(stats?.tokensTodayTotal ?? 0)} today - est. ${((stats?.costTodayUsd ?? 0)).toFixed(3)} + est. {formatCost(stats?.costTodayUsd ?? 0)} +
-

- Token usage comes from local Claude session logs. Costs are estimated from detected model pricing and may differ from Anthropic's actual billing. -

{/* Tab navigation */} -