agentfit 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v*'
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ jobs:
12
+ build-mac:
13
+ runs-on: macos-latest
14
+ timeout-minutes: 30
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+
18
+ - uses: actions/setup-node@v6
19
+ with:
20
+ node-version: 22
21
+ cache: npm
22
+
23
+ - name: Install dependencies
24
+ run: npm ci
25
+
26
+ - name: Import signing certificate
27
+ env:
28
+ CERTIFICATE_P12: ${{ secrets.CERTIFICATE_P12 }}
29
+ CERTIFICATE_PASSWORD: ${{ secrets.CERTIFICATE_PASSWORD }}
30
+ run: |
31
+ echo "$CERTIFICATE_P12" | base64 --decode > certificate.p12
32
+ security create-keychain -p actions build.keychain
33
+ security default-keychain -s build.keychain
34
+ security unlock-keychain -p actions build.keychain
35
+ security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
36
+ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k actions build.keychain
37
+ rm certificate.p12
38
+
39
+ - name: Build macOS DMGs
40
+ run: npm run electron:build:mac
41
+
42
+ - name: Upload DMGs
43
+ uses: actions/upload-artifact@v7
44
+ with:
45
+ name: mac-dmgs
46
+ path: |
47
+ dist-electron/*.dmg
48
+
49
+ build-win:
50
+ runs-on: windows-latest
51
+ steps:
52
+ - uses: actions/checkout@v6
53
+
54
+ - uses: actions/setup-node@v6
55
+ with:
56
+ node-version: 22
57
+ cache: npm
58
+
59
+ - name: Install dependencies
60
+ run: npm ci
61
+
62
+ - name: Build Windows installer
63
+ run: npm run electron:build:win
64
+
65
+ - name: Upload exe
66
+ uses: actions/upload-artifact@v7
67
+ with:
68
+ name: win-exe
69
+ path: |
70
+ dist-electron/*.exe
71
+
72
+ publish-npm:
73
+ runs-on: ubuntu-latest
74
+ steps:
75
+ - uses: actions/checkout@v6
76
+
77
+ - uses: actions/setup-node@v6
78
+ with:
79
+ node-version: 22
80
+ registry-url: https://registry.npmjs.org
81
+
82
+ - name: Install dependencies
83
+ run: npm ci
84
+
85
+ - name: Build
86
+ run: npm run build
87
+
88
+ - name: Publish to npm
89
+ run: npm publish
90
+ env:
91
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
92
+
93
+ release:
94
+ needs: [build-mac, build-win]
95
+ runs-on: ubuntu-latest
96
+ steps:
97
+ - uses: actions/download-artifact@v8
98
+ with:
99
+ name: mac-dmgs
100
+ path: artifacts
101
+
102
+ - uses: actions/download-artifact@v8
103
+ with:
104
+ name: win-exe
105
+ path: artifacts
106
+
107
+ - name: Create GitHub Release
108
+ uses: softprops/action-gh-release@v2
109
+ with:
110
+ files: artifacts/*
111
+ generate_release_notes: true
package/README.md CHANGED
@@ -4,19 +4,28 @@ Fitness tracker dashboard for AI coding agents. Reads your local Claude Code and
4
4
 
5
5
  ## Install
6
6
 
7
- ### Option 1: One-liner (recommended)
7
+ ### Option 1: Desktop App (recommended)
8
+
9
+ Download a pre-built installer from the [Releases](https://github.com/harrywang/agentfit/releases) page:
10
+
11
+ - **macOS**: `AgentFit-x.x.x.dmg` (Intel) or `AgentFit-x.x.x-arm64.dmg` (Apple Silicon)
12
+ - **Windows**: `AgentFit-x.x.x.exe`
13
+
14
+ > **macOS note:** If you see "AgentFit Not Opened" on first launch, go to **System Settings > Privacy & Security**, scroll down, and click **Open Anyway**. Or run `xattr -cr /Applications/AgentFit.app` in Terminal.
15
+
16
+ ### Option 2: One-liner
8
17
 
9
18
  ```bash
10
19
  curl -fsSL https://raw.githubusercontent.com/harrywang/agentfit/main/setup.sh | bash
11
20
  ```
12
21
 
13
- ### Option 2: npx
22
+ ### Option 3: npx
14
23
 
15
24
  ```bash
16
25
  npx agentfit
17
26
  ```
18
27
 
19
- ### Option 3: Manual
28
+ ### Option 4: Manual
20
29
 
21
30
  ```bash
22
31
  git clone https://github.com/harrywang/agentfit.git
@@ -30,7 +39,7 @@ npm start
30
39
 
31
40
  Open [http://localhost:3000](http://localhost:3000). The app auto-syncs your Claude Code (`~/.claude/projects/`) and Codex (`~/.codex/sessions/`) logs on first load.
32
41
 
33
- **Requirements:** Node.js 20+
42
+ **Requirements:** Node.js 20+ (Options 2–4)
34
43
 
35
44
  ## The CRAFT Framework
36
45
 
@@ -60,17 +69,15 @@ Inspired by [DORA Metrics](https://dora.dev) and Microsoft's [SPACE framework](h
60
69
  - **Images** — screenshot analysis across sessions
61
70
  - **Community Plugins** — extensible analysis views
62
71
 
63
- ## Desktop App
72
+ ## Development
64
73
 
65
- Download a pre-built installer from the [Releases](https://github.com/harrywang/agentfit/releases) page, or build yourself:
74
+ Build the desktop app locally:
66
75
 
67
76
  ```bash
68
77
  npm run electron:build:mac # Mac (.dmg)
69
78
  npm run electron:build:win # Windows (.exe)
70
79
  ```
71
80
 
72
- ## Development
73
-
74
81
  ```bash
75
82
  npm run dev # Start dev server (Turbopack)
76
83
  npm run build # Production build
@@ -1,12 +1,66 @@
1
1
  import { NextResponse } from 'next/server'
2
2
  import { analyzeCommands } from '@/lib/commands'
3
+ import { prisma } from '@/lib/db'
3
4
 
4
5
  export const dynamic = 'force-dynamic'
5
6
 
6
7
  export async function GET() {
7
8
  try {
8
9
  const analysis = analyzeCommands()
9
- return NextResponse.json(analysis)
10
+
11
+ // Merge skill invocations from session data (Skill tool calls tracked in DB)
12
+ // These capture cases where the user asked Claude to invoke a skill
13
+ // (e.g. "use /doc-writer to...") rather than typing the slash command directly
14
+ const dbSessions = await prisma.session.findMany({
15
+ select: { skillCallsJson: true },
16
+ })
17
+
18
+ const dbSkillCounts = new Map<string, number>()
19
+ for (const s of dbSessions) {
20
+ const skills = JSON.parse(s.skillCallsJson) as Record<string, number>
21
+ for (const [skill, count] of Object.entries(skills)) {
22
+ const cmd = skill.startsWith('/') ? skill : `/${skill}`
23
+ dbSkillCounts.set(cmd, (dbSkillCounts.get(cmd) || 0) + count)
24
+ }
25
+ }
26
+
27
+ // Build a map of history.jsonl counts for custom commands
28
+ const historyMap = new Map<string, number>()
29
+ for (const c of analysis.customCommands) {
30
+ historyMap.set(c.command, c.count)
31
+ }
32
+
33
+ // Merge DB skill counts into customCommands with breakdown
34
+ const customMap = new Map<string, { command: string; count: number; historyCount: number; sessionCount: number }>()
35
+ for (const c of analysis.customCommands) {
36
+ customMap.set(c.command, { command: c.command, count: c.count, historyCount: c.count, sessionCount: 0 })
37
+ }
38
+ for (const [cmd, count] of dbSkillCounts) {
39
+ const existing = analysis.commands.find(c => c.command === cmd)
40
+ if (existing) {
41
+ existing.count += count
42
+ existing.used = existing.count > 0
43
+ } else {
44
+ const prev = customMap.get(cmd)
45
+ if (prev) {
46
+ prev.sessionCount += count
47
+ prev.count += count
48
+ } else {
49
+ customMap.set(cmd, { command: cmd, count, historyCount: 0, sessionCount: count })
50
+ }
51
+ }
52
+ }
53
+
54
+ analysis.customCommands = Array.from(customMap.values())
55
+ .sort((a, b) => b.count - a.count)
56
+
57
+ // Expose session skill counts so the chart can show breakdown
58
+ const responseData = {
59
+ ...analysis,
60
+ dbSkillCounts: Object.fromEntries(dbSkillCounts),
61
+ }
62
+
63
+ return NextResponse.json(responseData)
10
64
  } catch (error) {
11
65
  return NextResponse.json(
12
66
  { error: (error as Error).message },
@@ -117,6 +117,7 @@ function mergeUsageData(a: UsageData, b: UsageData): UsageData {
117
117
  totalApiErrors: a.overview.totalApiErrors + b.overview.totalApiErrors,
118
118
  totalRateLimitDays: a.overview.totalRateLimitDays + b.overview.totalRateLimitDays,
119
119
  totalUserInterruptions: a.overview.totalUserInterruptions + b.overview.totalUserInterruptions,
120
+ totalSystemPromptEdits: (a.overview.totalSystemPromptEdits ?? 0) + (b.overview.totalSystemPromptEdits ?? 0),
120
121
  models,
121
122
  skillUsage: (() => {
122
123
  const skills: Record<string, number> = { ...a.overview.skillUsage }
@@ -59,7 +59,7 @@ const CRAFT_DIMENSIONS: {
59
59
  color: 'var(--chart-1)',
60
60
  icon: Brain,
61
61
  description: 'How well you engineer the context available to the AI — not just window size, but the holistic curation of tokens: system prompts (CLAUDE.md), just-in-time retrieval, structured notes, sub-agent isolation, and cache efficiency.',
62
- metrics: ['Cache reuse (20%)', 'Overflow avoidance (20%)', 'Just-in-time retrieval (20%)', 'Session length (10%)', 'Note-taking (10%)', 'Output density (10%)', 'Sub-agent isolation (10%)'],
62
+ metrics: ['System prompt maintenance (15%)', 'Cache reuse (15%)', 'Overflow avoidance (15%)', 'Just-in-time retrieval (20%)', 'Session length (10%)', 'Note-taking (10%)', 'Output density (10%)', 'Sub-agent isolation (5%)'],
63
63
  },
64
64
  {
65
65
  key: 'reach',
@@ -1,17 +1,13 @@
1
1
  'use client'
2
2
 
3
- import { useState, useEffect, useCallback, type ReactNode } from 'react'
3
+ import { useState, type ReactNode } from 'react'
4
4
  import Link from 'next/link'
5
5
  import { usePathname } from 'next/navigation'
6
6
  import {
7
- AlertTriangle,
8
7
  BarChart3,
9
8
  Brain,
10
9
  Camera,
11
- Check,
12
10
  ChevronRight,
13
- Cloud,
14
- CloudOff,
15
11
  Coins,
16
12
  FileText,
17
13
  FolderOpen,
@@ -19,13 +15,9 @@ import {
19
15
  HeartPulse,
20
16
  LayoutDashboard,
21
17
  ListTree,
22
- Loader2,
23
18
  Puzzle,
24
- RefreshCw,
25
- RotateCcw,
26
19
  Settings,
27
20
  Terminal,
28
- Upload,
29
21
  Wrench,
30
22
  } from 'lucide-react'
31
23
  import type { ComponentType } from 'react'
@@ -45,19 +37,7 @@ import {
45
37
  SidebarMenuSubItem,
46
38
  SidebarRail,
47
39
  } from '@/components/ui/sidebar'
48
- import {
49
- Dialog,
50
- DialogContent,
51
- DialogDescription,
52
- DialogFooter,
53
- DialogHeader,
54
- DialogTitle,
55
- } from '@/components/ui/dialog'
56
- import { Button } from '@/components/ui/button'
57
- import { Badge } from '@/components/ui/badge'
58
- import { useData } from './data-provider'
59
40
  import { getPlugins } from '@/lib/plugins'
60
- import { toast } from 'sonner'
61
41
  import '@/plugins'
62
42
 
63
43
  interface NavItem {
@@ -75,7 +55,6 @@ interface NavGroup {
75
55
  const topItems: NavItem[] = [
76
56
  { title: 'Dashboard', icon: LayoutDashboard, href: '/' },
77
57
  { title: 'CRAFT Coach', icon: HeartPulse, href: '/coach' },
78
- { title: 'Reports', icon: FileText, href: '/reports' },
79
58
  ]
80
59
 
81
60
  const navGroups: NavGroup[] = [
@@ -94,6 +73,7 @@ const navGroups: NavGroup[] = [
94
73
  { title: 'Daily Usage', icon: BarChart3, href: '/daily' },
95
74
  { title: 'Token Breakdown', icon: Coins, href: '/tokens' },
96
75
  { title: 'Tool Usage', icon: Wrench, href: '/tools' },
76
+ { title: 'Command Usage', icon: Terminal, href: '/commands' },
97
77
  ],
98
78
  },
99
79
  {
@@ -101,9 +81,9 @@ const navGroups: NavGroup[] = [
101
81
  icon: Brain,
102
82
  items: [
103
83
  { title: 'Personality Fit', icon: Brain, href: '/personality' },
104
- { title: 'Command Usage', icon: Terminal, href: '/commands' },
105
84
  { title: 'Session Flow', icon: GitBranch, href: '/flow' },
106
- { title: 'Images', icon: Camera, href: '/images' },
85
+ { title: 'Image Analysis', icon: Camera, href: '/images' },
86
+ { title: 'Reports', icon: FileText, href: '/reports' },
107
87
  ],
108
88
  },
109
89
  ]
@@ -139,194 +119,6 @@ function CollapsibleGroup({ group, pathname }: { group: NavGroup; pathname: stri
139
119
  )
140
120
  }
141
121
 
142
- interface BackupFolderStatus {
143
- exists: boolean
144
- hasGit: boolean
145
- hasRemote: boolean
146
- remoteUrl: string | null
147
- isDirty: boolean
148
- lastCommit: string | null
149
- }
150
-
151
- interface BackupStatus {
152
- ghInstalled: boolean
153
- ghAuthenticated: boolean
154
- ghUser: string | null
155
- claude: BackupFolderStatus
156
- codex: BackupFolderStatus
157
- }
158
-
159
- function BackupSection() {
160
- const [status, setStatus] = useState<BackupStatus | null>(null)
161
- const [loading, setLoading] = useState(false)
162
- const [syncing, setSyncing] = useState<Record<string, boolean>>({})
163
-
164
- const fetchStatus = useCallback(async () => {
165
- setLoading(true)
166
- try {
167
- const res = await fetch('/api/backup')
168
- setStatus(await res.json())
169
- } catch {
170
- // ignore
171
- } finally {
172
- setLoading(false)
173
- }
174
- }, [])
175
-
176
- useEffect(() => {
177
- fetchStatus()
178
- }, [fetchStatus])
179
-
180
- const handleInit = async (folder: 'claude' | 'codex') => {
181
- setSyncing((s) => ({ ...s, [folder]: true }))
182
- try {
183
- const res = await fetch('/api/backup', {
184
- method: 'POST',
185
- headers: { 'Content-Type': 'application/json' },
186
- body: JSON.stringify({ action: 'init', folder }),
187
- })
188
- const data = await res.json()
189
- if (!res.ok) {
190
- toast.error(`Backup failed`, { description: data.error })
191
- return
192
- }
193
- toast.success(`~/.${folder} backed up`, {
194
- description: (
195
- <a href={data.repoUrl} target="_blank" rel="noopener noreferrer" className="underline">
196
- {data.repoUrl}
197
- </a>
198
- ),
199
- })
200
- await fetchStatus()
201
- } catch (e) {
202
- toast.error('Backup failed', { description: (e as Error).message })
203
- } finally {
204
- setSyncing((s) => ({ ...s, [folder]: false }))
205
- }
206
- }
207
-
208
- const handleSync = async (folder: 'claude' | 'codex') => {
209
- setSyncing((s) => ({ ...s, [folder]: true }))
210
- try {
211
- const res = await fetch('/api/backup', {
212
- method: 'POST',
213
- headers: { 'Content-Type': 'application/json' },
214
- body: JSON.stringify({ action: 'sync', folder }),
215
- })
216
- const data = await res.json()
217
- if (!res.ok) {
218
- toast.error(`Sync failed`, { description: data.error })
219
- return
220
- }
221
- toast.success(`~/.${folder} synced`, { description: data.message })
222
- await fetchStatus()
223
- } catch (e) {
224
- toast.error('Sync failed', { description: (e as Error).message })
225
- } finally {
226
- setSyncing((s) => ({ ...s, [folder]: false }))
227
- }
228
- }
229
-
230
- if (loading || !status) {
231
- return (
232
- <div className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground">
233
- <Loader2 className="h-3 w-3 animate-spin" />
234
- Checking GitHub...
235
- </div>
236
- )
237
- }
238
-
239
- if (!status.ghInstalled) {
240
- return (
241
- <div className="space-y-2 px-3 py-1.5">
242
- <div className="flex items-center gap-2 text-xs text-muted-foreground">
243
- <CloudOff className="h-3.5 w-3.5" />
244
- GitHub CLI not found
245
- </div>
246
- <a
247
- href="https://cli.github.com"
248
- target="_blank"
249
- rel="noopener noreferrer"
250
- className="block text-xs text-primary underline"
251
- >
252
- Install GitHub CLI
253
- </a>
254
- </div>
255
- )
256
- }
257
-
258
- if (!status.ghAuthenticated) {
259
- return (
260
- <div className="space-y-2 px-3 py-1.5">
261
- <div className="flex items-center gap-2 text-xs text-muted-foreground">
262
- <CloudOff className="h-3.5 w-3.5" />
263
- Not logged in to GitHub
264
- </div>
265
- <div className="rounded-md bg-muted px-2 py-1.5 text-xs font-mono">
266
- gh auth login
267
- </div>
268
- </div>
269
- )
270
- }
271
-
272
- const folders = [
273
- { key: 'claude' as const, label: '.claude', status: status.claude },
274
- { key: 'codex' as const, label: '.codex', status: status.codex },
275
- ].filter((f) => f.status.exists)
276
-
277
- return (
278
- <div className="space-y-1.5 px-1">
279
- <div className="flex items-center gap-1.5 px-2 text-xs text-muted-foreground">
280
- <Cloud className="h-3 w-3" />
281
- {status.ghUser}
282
- </div>
283
- {folders.map(({ key, label, status: fs }) => {
284
- const isSetUp = fs.hasGit && fs.hasRemote
285
- const isSyncing = syncing[key]
286
-
287
- return (
288
- <div key={key} className="flex items-center justify-between px-2">
289
- <div className="flex items-center gap-1.5">
290
- <span className="text-xs font-medium">~/{label}</span>
291
- {isSetUp && !fs.isDirty && (
292
- <Check className="h-3 w-3 text-green-500" />
293
- )}
294
- {isSetUp && fs.isDirty && (
295
- <Badge variant="secondary" className="h-4 px-1 text-[10px]">
296
- new
297
- </Badge>
298
- )}
299
- </div>
300
- {!isSetUp ? (
301
- <Button
302
- variant="outline"
303
- size="xs"
304
- onClick={() => handleInit(key)}
305
- disabled={isSyncing}
306
- className="gap-1"
307
- >
308
- {isSyncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Upload className="h-3 w-3" />}
309
- Backup
310
- </Button>
311
- ) : (
312
- <Button
313
- variant="ghost"
314
- size="xs"
315
- onClick={() => handleSync(key)}
316
- disabled={isSyncing}
317
- className="gap-1"
318
- >
319
- {isSyncing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Cloud className="h-3 w-3" />}
320
- Sync
321
- </Button>
322
- )}
323
- </div>
324
- )
325
- })}
326
- </div>
327
- )
328
- }
329
-
330
122
  export function AppSidebar() {
331
123
  const pathname = usePathname()
332
124
  const plugins = getPlugins()
@@ -369,22 +161,13 @@ export function AppSidebar() {
369
161
  </SidebarMenuButton>
370
162
  </SidebarMenuItem>
371
163
  )}
372
- </SidebarMenu>
373
- </SidebarGroupContent>
374
- </SidebarGroup>
375
-
376
- {/* Data Management Section */}
377
- <SidebarGroup>
378
- <SidebarGroupLabel className="sr-only">Settings</SidebarGroupLabel>
379
- <SidebarGroupContent>
380
- <SidebarMenu>
381
164
  <SidebarMenuItem>
382
165
  <SidebarMenuButton
383
- render={<Link href="/settings" />}
384
- isActive={pathname === '/settings'}
166
+ render={<Link href="/data-management" />}
167
+ isActive={pathname === '/data-management'}
385
168
  >
386
169
  <Settings className="h-4 w-4" />
387
- <span>Settings</span>
170
+ <span>Data Management</span>
388
171
  </SidebarMenuButton>
389
172
  </SidebarMenuItem>
390
173
  </SidebarMenu>
@@ -393,14 +176,16 @@ export function AppSidebar() {
393
176
  </SidebarContent>
394
177
 
395
178
  <SidebarFooter>
396
- <div className="flex items-center justify-between px-3 py-2 text-xs text-muted-foreground">
397
- <span>v0.1.0</span>
179
+ <div className="flex items-center justify-center px-3 py-2 text-xs text-muted-foreground">
398
180
  <a
399
181
  href="https://github.com/harrywang/agentfit"
400
182
  target="_blank"
401
183
  rel="noopener noreferrer"
402
- className="hover:text-foreground transition-colors"
184
+ className="flex items-center gap-1.5 hover:text-foreground transition-colors"
403
185
  >
186
+ <svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
187
+ <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
188
+ </svg>
404
189
  GitHub
405
190
  </a>
406
191
  </div>
@@ -161,22 +161,40 @@ const commandConfig = {
161
161
  count: { label: 'Uses', color: 'var(--chart-1)' },
162
162
  } satisfies ChartConfig
163
163
 
164
+ interface CommandBarEntry {
165
+ name: string
166
+ count: number
167
+ fill: string
168
+ historyCount: number
169
+ sessionCount: number
170
+ }
171
+
164
172
  export function TopCommandsChart() {
165
- const [data, setData] = useState<{ name: string; count: number; fill: string }[]>([])
173
+ const [data, setData] = useState<CommandBarEntry[]>([])
166
174
 
167
175
  useEffect(() => {
168
176
  fetch('/api/commands')
169
177
  .then(r => r.json())
170
178
  .then(analysis => {
179
+ const dbSkills: Record<string, number> = analysis.dbSkillCounts || {}
171
180
  const bi = analysis.commands
172
181
  .filter((c: { used: boolean; count: number }) => c.used && c.count > 0)
173
- .map((c: { command: string; count: number }) => ({ name: c.command, count: c.count, fill: 'var(--chart-1)' }))
182
+ .map((c: { command: string; count: number }) => {
183
+ const sk = dbSkills[c.command] || 0
184
+ return { name: c.command, count: c.count, fill: 'var(--chart-1)', historyCount: c.count - sk, sessionCount: sk }
185
+ })
174
186
  .sort((a: { count: number }, b: { count: number }) => b.count - a.count)
175
187
  .slice(0, 5)
176
188
  const cu = analysis.customCommands
177
189
  .sort((a: { count: number }, b: { count: number }) => b.count - a.count)
178
190
  .slice(0, 5)
179
- .map((c: { command: string; count: number }) => ({ name: c.command, count: c.count, fill: 'var(--chart-4)' }))
191
+ .map((c: { command: string; count: number; historyCount?: number; sessionCount?: number }) => ({
192
+ name: c.command,
193
+ count: c.count,
194
+ fill: 'var(--chart-4)',
195
+ historyCount: c.historyCount || 0,
196
+ sessionCount: c.sessionCount || 0,
197
+ }))
180
198
  const combined = [...bi, ...cu].sort((a, b) => b.count - a.count)
181
199
  setData(combined)
182
200
  })
@@ -213,7 +231,32 @@ export function TopCommandsChart() {
213
231
  <BarChart data={data} layout="vertical" margin={{ left: 8 }} accessibilityLayer>
214
232
  <XAxis type="number" tickLine={false} axisLine={false} />
215
233
  <YAxis type="category" dataKey="name" tickLine={false} axisLine={false} width={140} />
216
- <ChartTooltip content={<ChartTooltipContent />} />
234
+ <RechartsTooltip
235
+ cursor={{ fill: 'var(--muted)', opacity: 0.3 }}
236
+ content={({ active, payload }) => {
237
+ if (!active || !payload?.length) return null
238
+ const d = payload[0].payload as CommandBarEntry
239
+ return (
240
+ <div className="rounded-lg border bg-background px-3 py-2 text-sm shadow-md">
241
+ <div className="font-semibold">{d.name}</div>
242
+ <div className="text-muted-foreground mt-1 space-y-0.5">
243
+ <div className="flex justify-between gap-4">
244
+ <span>Slash command</span>
245
+ <span className="font-mono">{d.historyCount ?? 0}</span>
246
+ </div>
247
+ <div className="flex justify-between gap-4">
248
+ <span>Skill invocation</span>
249
+ <span className="font-mono">{d.sessionCount ?? 0}</span>
250
+ </div>
251
+ <div className="flex justify-between gap-4 border-t pt-0.5 text-foreground font-medium">
252
+ <span>Total</span>
253
+ <span className="font-mono">{d.count ?? 0}</span>
254
+ </div>
255
+ </div>
256
+ </div>
257
+ )
258
+ }}
259
+ />
217
260
  <Bar dataKey="count" radius={[0, 4, 4, 0]}>
218
261
  {data.map((entry, i) => (
219
262
  <Cell key={i} fill={entry.fill} />