agentlytics 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +1 -1
  2. package/cache.js +420 -10
  3. package/editors/cursor.js +28 -6
  4. package/editors/vscode.js +6 -0
  5. package/index.js +78 -11
  6. package/package.json +2 -1
  7. package/server.js +27 -0
  8. package/ui/package-lock.json +60 -375
  9. package/ui/package.json +1 -1
  10. package/ui/src/App.jsx +22 -17
  11. package/ui/src/components/ActivityHeatmap.jsx +3 -3
  12. package/ui/src/components/AnimatedLogo.jsx +96 -0
  13. package/ui/src/components/ChatSidebar.jsx +7 -7
  14. package/ui/src/components/DateRangePicker.jsx +5 -5
  15. package/ui/src/components/EditorBreakdown.jsx +2 -2
  16. package/ui/src/components/EditorDot.jsx +1 -1
  17. package/ui/src/components/KpiCard.jsx +2 -2
  18. package/ui/src/components/LiveFeed.jsx +8 -8
  19. package/ui/src/components/LoginScreen.jsx +8 -6
  20. package/ui/src/components/MessageRenderer.jsx +5 -5
  21. package/ui/src/components/ModelBreakdown.jsx +3 -3
  22. package/ui/src/components/SectionTitle.jsx +1 -1
  23. package/ui/src/index.css +1 -1
  24. package/ui/src/lib/api.js +20 -0
  25. package/ui/src/lib/constants.js +8 -0
  26. package/ui/src/pages/ChatDetail.jsx +5 -2
  27. package/ui/src/pages/Compare.jsx +18 -18
  28. package/ui/src/pages/CostAnalysis.jsx +356 -0
  29. package/ui/src/pages/Dashboard.jsx +39 -21
  30. package/ui/src/pages/DeepAnalysis.jsx +38 -31
  31. package/ui/src/pages/ProjectDetail.jsx +23 -15
  32. package/ui/src/pages/Projects.jsx +14 -8
  33. package/ui/src/pages/RelayDashboard.jsx +29 -29
  34. package/ui/src/pages/RelaySessionDetail.jsx +1 -1
  35. package/ui/src/pages/RelayUserDetail.jsx +18 -18
  36. package/ui/src/pages/Sessions.jsx +24 -20
  37. package/ui/src/pages/SqlViewer.jsx +14 -14
@@ -6,9 +6,9 @@ import { Doughnut, Bar, Line } from 'react-chartjs-2'
6
6
  import KpiCard from '../components/KpiCard'
7
7
  import ActivityHeatmap from '../components/ActivityHeatmap'
8
8
  import DateRangePicker from '../components/DateRangePicker'
9
- import { editorColor, editorLabel, formatNumber, dateRangeToApiParams } from '../lib/constants'
9
+ import { editorColor, editorLabel, formatNumber, formatCost, dateRangeToApiParams } from '../lib/constants'
10
10
  import EditorIcon from '../components/EditorIcon'
11
- import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchShareImage, fetchChats } from '../lib/api'
11
+ import { fetchDailyActivity, fetchOverview as fetchOverviewApi, fetchDashboardStats, fetchShareImage, fetchChats, fetchCosts } from '../lib/api'
12
12
  import { useTheme } from '../lib/theme'
13
13
  import SectionTitle from '../components/SectionTitle'
14
14
 
@@ -30,6 +30,7 @@ export default function Dashboard({ overview }) {
30
30
  const [selectedEditor, setSelectedEditor] = useState(null)
31
31
  const [dateRange, setDateRange] = useState(null)
32
32
  const { dark } = useTheme()
33
+ const [costs, setCosts] = useState(null)
33
34
  const [sharing, setSharing] = useState(false)
34
35
  const [largeContextChats, setLargeContextChats] = useState(null)
35
36
  const txtColor = dark ? '#888' : '#555'
@@ -64,16 +65,19 @@ export default function Dashboard({ overview }) {
64
65
  setFilteredData(null)
65
66
  fetchDailyActivity(dateParams).then(setDailyData)
66
67
  fetchDashboardStats(dateParams).then(setStats)
68
+ fetchCosts(dateParams).then(setCosts)
67
69
  return
68
70
  }
69
71
  Promise.all([
70
72
  fetchOverviewApi({ editor: selectedEditor, ...dateParams }),
71
73
  fetchDailyActivity({ editor: selectedEditor, ...dateParams }),
72
74
  fetchDashboardStats({ editor: selectedEditor, ...dateParams }),
73
- ]).then(([ov, daily, st]) => {
75
+ fetchCosts({ editor: selectedEditor, ...dateParams }),
76
+ ]).then(([ov, daily, st, c]) => {
74
77
  setFilteredData(ov)
75
78
  setDailyData(daily)
76
79
  setStats(st)
80
+ setCosts(c)
77
81
  })
78
82
  }, [selectedEditor, dateRange])
79
83
 
@@ -220,7 +224,7 @@ export default function Dashboard({ overview }) {
220
224
  <button
221
225
  onClick={handleShare}
222
226
  disabled={sharing}
223
- className="flex items-center gap-1.5 px-3 py-1 text-[11px] rounded-md transition hover:opacity-80"
227
+ className="flex items-center gap-1.5 px-3 py-1 text-[12px] rounded-md transition hover:opacity-80"
224
228
  style={{ background: '#6366f1', color: '#fff', opacity: sharing ? 0.5 : 1 }}
225
229
  >
226
230
  <Share2 size={12} />
@@ -236,7 +240,7 @@ export default function Dashboard({ overview }) {
236
240
  return (
237
241
  <button
238
242
  key={e.id}
239
- className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] cursor-pointer transition rounded-sm"
243
+ className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-[12px] cursor-pointer transition rounded-sm"
240
244
  style={{
241
245
  border: isSelected ? `1.5px solid ${editorColor(e.id)}` : '1px solid var(--c-border)',
242
246
  background: isSelected ? editorColor(e.id) + '15' : 'transparent',
@@ -254,10 +258,10 @@ export default function Dashboard({ overview }) {
254
258
  </div>
255
259
  {selectedEditor && sel && (
256
260
  <div className="mt-2 flex items-center gap-2">
257
- <button onClick={() => navigate(`/sessions?editor=${selectedEditor}`)} className="flex items-center gap-1 text-[11px] px-2.5 py-1 transition" style={{ color: 'var(--c-accent)', border: '1px solid var(--c-border)' }}>
261
+ <button onClick={() => navigate(`/sessions?editor=${selectedEditor}`)} className="flex items-center gap-1 text-[12px] px-2.5 py-1 transition" style={{ color: 'var(--c-accent)', border: '1px solid var(--c-border)' }}>
258
262
  Show Sessions <ArrowRight size={11} />
259
263
  </button>
260
- <button onClick={() => setSelectedEditor(null)} className="flex items-center gap-1 text-[11px] px-2.5 py-1 transition" style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}>
264
+ <button onClick={() => setSelectedEditor(null)} className="flex items-center gap-1 text-[12px] px-2.5 py-1 transition" style={{ color: 'var(--c-text2)', border: '1px solid var(--c-border)' }}>
261
265
  <X size={9} /> Clear
262
266
  </button>
263
267
  </div>
@@ -282,12 +286,25 @@ export default function Dashboard({ overview }) {
282
286
  </>}
283
287
  </div>
284
288
 
289
+ {/* Token economy KPIs */}
290
+ {tk && tk.input > 0 && (
291
+ <div className="grid gap-2" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(90px, 1fr))' }}>
292
+ <KpiCard label="in tokens" value={formatNumber(tk.input)} sub="total prompt" />
293
+ <KpiCard label="out tokens" value={formatNumber(tk.output)} sub="total completion" />
294
+ <KpiCard label="cache read" value={formatNumber(tk.cacheRead)} sub={`${cacheHitRate}% hit rate`} />
295
+ <KpiCard label="cache write" value={formatNumber(tk.cacheWrite)} />
296
+ <KpiCard label="out/in ratio" value={`${outputInputRatio}×`} sub={<span className="flex items-center gap-0.5"><Zap size={8} /> efficiency</span>} />
297
+ <KpiCard label="you wrote" value={formatNumber(tk.userChars)} sub={`AI wrote ${formatNumber(tk.assistantChars)}`} />
298
+ <KpiCard label="est. cost" value={costs && costs.totalCost > 0 ? formatCost(costs.totalCost) : '—'} sub={costs && costs.byModel.length > 0 ? `${costs.byModel.length} model${costs.byModel.length !== 1 ? 's' : ''}` : undefined} />
299
+ </div>
300
+ )}
301
+
285
302
  {/* Activity Heatmap | Col 1 | Col 2 | Col 3 */}
286
303
  <div className="card p-3">
287
304
  <SectionTitle>agentic coding activity</SectionTitle>
288
305
  <div className="flex gap-4">
289
306
  <div className="min-w-0 flex-shrink-0">
290
- {dailyData ? <ActivityHeatmap dailyData={dailyData} /> : <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>loading...</div>}
307
+ {dailyData ? <ActivityHeatmap dailyData={dailyData} /> : <div className="text-[11px]" style={{ color: 'var(--c-text3)' }}>loading...</div>}
291
308
  </div>
292
309
  {stats && dailyData && (() => {
293
310
  const activeDays = dailyData.filter(d => d.total > 0)
@@ -295,7 +312,7 @@ export default function Dashboard({ overview }) {
295
312
  const totalSessions = activeDays.reduce((s, d) => s + d.total, 0)
296
313
  const avgPerDay = activeDays.length > 0 ? (totalSessions / activeDays.length).toFixed(1) : 0
297
314
  return (
298
- <div className="flex-1 grid grid-cols-3 gap-3 text-[10px] min-w-0" style={{ borderLeft: '1px solid var(--c-border)', paddingLeft: 16 }}>
315
+ <div className="flex-1 grid grid-cols-3 gap-3 text-[11px] min-w-0" style={{ borderLeft: '1px solid var(--c-border)', paddingLeft: 16 }}>
299
316
  <div className="space-y-2 min-w-0">
300
317
  <div>
301
318
  <div style={{ color: 'var(--c-text3)' }} className="uppercase tracking-wider mb-1">streaks</div>
@@ -452,11 +469,11 @@ export default function Dashboard({ overview }) {
452
469
  <div className="space-y-1 max-h-[180px] overflow-y-auto scrollbar-thin">
453
470
  {d.topProjects.slice(0, 12).map(p => (
454
471
  <div key={p.name} className="flex items-center gap-1.5">
455
- <div className="text-[9px] w-6 text-right" style={{ color: 'var(--c-text2)' }}>{p.count}</div>
472
+ <div className="text-[10px] w-6 text-right" style={{ color: 'var(--c-text2)' }}>{p.count}</div>
456
473
  <div className="flex-1 h-3 rounded-sm overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
457
474
  <div className="h-full bg-accent/30 rounded-sm" style={{ width: `${(p.count / maxProject * 100).toFixed(1)}%` }} />
458
475
  </div>
459
- <div className="text-[9px] truncate max-w-[140px]" style={{ color: 'var(--c-text2)' }} title={p.fullPath}>{p.name}</div>
476
+ <div className="text-[10px] truncate max-w-[140px]" style={{ color: 'var(--c-text2)' }} title={p.fullPath}>{p.name}</div>
460
477
  </div>
461
478
  ))}
462
479
  </div>
@@ -467,19 +484,20 @@ export default function Dashboard({ overview }) {
467
484
  <div className="grid grid-cols-1 lg:grid-cols-3 gap-2">
468
485
  {stats && stats.topModels.length > 0 && (
469
486
  <div className="card p-3">
470
- <SectionTitle>top models</SectionTitle>
487
+ <SectionTitle>top models {costs && costs.totalCost > 0 && <span style={{ color: 'var(--c-text3)' }}>({formatCost(costs.totalCost)} est.)</span>}</SectionTitle>
471
488
  <div className="space-y-1">
472
489
  {stats.topModels.map((m, i) => {
473
490
  const maxM = stats.topModels[0].count
491
+ const modelCost = costs?.byModel?.find(c => c.model === m.name)
474
492
  return (
475
493
  <div key={m.name} className="flex items-center gap-2">
476
- <span className="text-[9px] w-3 text-right" style={{ color: 'var(--c-text3)' }}>{i + 1}</span>
494
+ <span className="text-[10px] w-3 text-right" style={{ color: 'var(--c-text3)' }}>{i + 1}</span>
495
+ <span className="text-[9px] truncate w-28" style={{ color: 'var(--c-text2)' }} title={m.name}>{m.name}</span>
477
496
  <div className="flex-1 h-4 rounded-sm overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
478
- <div className="h-full rounded-sm flex items-center px-1.5" style={{ width: `${(m.count / maxM * 100).toFixed(1)}%`, background: i === 0 ? '#6366f1' : i === 1 ? '#818cf8' : '#a5b4fc40' }}>
479
- <span className="text-[8px] truncate" style={{ color: i < 2 ? '#fff' : 'var(--c-text2)' }}>{m.name}</span>
480
- </div>
497
+ <div className="h-full rounded-sm" style={{ width: `${(m.count / maxM * 100).toFixed(1)}%`, background: i === 0 ? '#6366f1' : i === 1 ? '#818cf8' : '#a5b4fc40' }} />
481
498
  </div>
482
- <span className="text-[9px] w-8 text-right" style={{ color: 'var(--c-text3)' }}>{m.count}</span>
499
+ {modelCost ? <span className="text-[9px] w-12 text-right" style={{ color: '#10b981' }}>{formatCost(modelCost.cost)}</span> : null}
500
+ <span className="text-[10px] w-8 text-right" style={{ color: 'var(--c-text3)' }}>{m.count}</span>
483
501
  </div>
484
502
  )
485
503
  })}
@@ -495,13 +513,13 @@ export default function Dashboard({ overview }) {
495
513
  const maxT = stats.topTools[0].count
496
514
  return (
497
515
  <div key={t.name} className="flex items-center gap-2">
498
- <span className="text-[9px] w-3 text-right" style={{ color: 'var(--c-text3)' }}>{i + 1}</span>
516
+ <span className="text-[10px] w-3 text-right" style={{ color: 'var(--c-text3)' }}>{i + 1}</span>
499
517
  <div className="flex-1 h-4 rounded-sm overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
500
518
  <div className="h-full rounded-sm flex items-center px-1.5" style={{ width: `${(t.count / maxT * 100).toFixed(1)}%`, background: i === 0 ? '#10b981' : i === 1 ? '#34d399' : '#6ee7b740' }}>
501
519
  <span className="text-[8px] truncate font-mono" style={{ color: i < 2 ? '#fff' : 'var(--c-text2)' }}>{t.name}</span>
502
520
  </div>
503
521
  </div>
504
- <span className="text-[9px] w-8 text-right" style={{ color: 'var(--c-text3)' }}>{formatNumber(t.count)}</span>
522
+ <span className="text-[10px] w-8 text-right" style={{ color: 'var(--c-text3)' }}>{formatNumber(t.count)}</span>
505
523
  </div>
506
524
  )
507
525
  })}
@@ -527,9 +545,9 @@ export default function Dashboard({ overview }) {
527
545
  onClick={() => navigate(`/sessions/${c.id}`)}
528
546
  >
529
547
  <EditorIcon source={c.source} size={10} />
530
- <span className="text-[9px] truncate flex-1" style={{ color: 'var(--c-text)' }}>{c.name || 'Untitled'}</span>
548
+ <span className="text-[10px] truncate flex-1" style={{ color: 'var(--c-text)' }}>{c.name || 'Untitled'}</span>
531
549
  <span
532
- className="text-[9px] font-bold flex-shrink-0"
550
+ className="text-[10px] font-bold flex-shrink-0"
533
551
  style={{ color: c.bubbleCount >= 500 ? '#ef4444' : '#f59e0b' }}
534
552
  >
535
553
  {c.bubbleCount}
@@ -2,8 +2,8 @@ import { useState, useEffect, useRef, useMemo } from 'react'
2
2
  import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
3
3
  import { Doughnut, Bar } from 'react-chartjs-2'
4
4
  import { Loader2, X, ArrowRight, Zap, MessageSquare, Wrench, Cpu, TrendingUp, BarChart3 } from 'lucide-react'
5
- import { fetchDeepAnalytics, fetchToolCalls } from '../lib/api'
6
- import { editorLabel, editorColor, formatNumber, formatDateTime, dateRangeToApiParams } from '../lib/constants'
5
+ import { fetchDeepAnalytics, fetchToolCalls, fetchCosts } from '../lib/api'
6
+ import { editorLabel, editorColor, formatNumber, formatCost, formatDateTime, dateRangeToApiParams } from '../lib/constants'
7
7
  import { useTheme } from '../lib/theme'
8
8
  import KpiCard from '../components/KpiCard'
9
9
  import EditorIcon from '../components/EditorIcon'
@@ -75,7 +75,7 @@ function DiffBlock({ diff }) {
75
75
  const oldLines = (diff.old || '').split('\n').slice(0, maxLines)
76
76
  const newLines = (diff.new || '').split('\n').slice(0, maxLines)
77
77
  return (
78
- <div className="mt-1 text-[9px] font-mono overflow-x-auto" style={{ border: '1px solid var(--c-border)' }}>
78
+ <div className="mt-1 text-[10px] font-mono overflow-x-auto" style={{ border: '1px solid var(--c-border)' }}>
79
79
  {diff.file && (
80
80
  <div className="px-2 py-0.5" style={{ background: 'var(--c-code-bg)', color: 'var(--c-text2)' }}>{diff.file}</div>
81
81
  )}
@@ -107,7 +107,7 @@ function ToolCallRow({ call, toolName, index }) {
107
107
  const hasDetail = diff || (call.args && Object.keys(call.args).length > 0)
108
108
 
109
109
  return (
110
- <div className="px-2 py-1 text-[10px]" style={{ background: index % 2 === 0 ? 'var(--c-code-bg)' : 'transparent' }}>
110
+ <div className="px-2 py-1 text-[11px]" style={{ background: index % 2 === 0 ? 'var(--c-code-bg)' : 'transparent' }}>
111
111
  <div className="flex items-start gap-2 cursor-pointer" onClick={() => hasDetail && setExpanded(!expanded)}>
112
112
  <EditorIcon source={call.source} size={10} />
113
113
  <div className="flex-1 min-w-0">
@@ -124,7 +124,7 @@ function ToolCallRow({ call, toolName, index }) {
124
124
  </div>
125
125
  {expanded && diff && <DiffBlock diff={diff} />}
126
126
  {expanded && !diff && call.args && Object.keys(call.args).length > 0 && (
127
- <pre className="mt-1 px-2 py-1 text-[9px] overflow-x-auto whitespace-pre-wrap break-all" style={{ background: 'var(--c-code-bg)', color: 'var(--c-text2)' }}>
127
+ <pre className="mt-1 px-2 py-1 text-[10px] overflow-x-auto whitespace-pre-wrap break-all" style={{ background: 'var(--c-code-bg)', color: 'var(--c-text2)' }}>
128
128
  {JSON.stringify(call.args, null, 2)}
129
129
  </pre>
130
130
  )}
@@ -148,7 +148,7 @@ function ToolDrillDown({ toolName, folder, onClose }) {
148
148
  <div className="flex items-center gap-2">
149
149
  <Wrench size={12} style={{ color: 'var(--c-accent)' }} />
150
150
  <span className="text-xs font-bold" style={{ color: 'var(--c-white)' }}>{toolName}</span>
151
- <span className="text-[10px]" style={{ color: 'var(--c-text2)' }}>
151
+ <span className="text-[11px]" style={{ color: 'var(--c-text2)' }}>
152
152
  {calls ? `${calls.length} calls` : '...'}
153
153
  {calls && projectName ? ` in ${projectName}` : ''}
154
154
  </span>
@@ -156,7 +156,7 @@ function ToolDrillDown({ toolName, folder, onClose }) {
156
156
  <button onClick={onClose} className="p-0.5" style={{ color: 'var(--c-text2)' }}><X size={12} /></button>
157
157
  </div>
158
158
  {loading ? (
159
- <div className="text-[10px] py-4 text-center" style={{ color: 'var(--c-text3)' }}>loading...</div>
159
+ <div className="text-[11px] py-4 text-center" style={{ color: 'var(--c-text3)' }}>loading...</div>
160
160
  ) : calls && calls.length > 0 ? (
161
161
  <div className="max-h-[500px] overflow-y-auto scrollbar-thin space-y-0.5">
162
162
  {calls.map((c, i) => (
@@ -164,7 +164,7 @@ function ToolDrillDown({ toolName, folder, onClose }) {
164
164
  ))}
165
165
  </div>
166
166
  ) : (
167
- <div className="text-[10px] py-4 text-center" style={{ color: 'var(--c-text3)' }}>no calls found</div>
167
+ <div className="text-[11px] py-4 text-center" style={{ color: 'var(--c-text3)' }}>no calls found</div>
168
168
  )}
169
169
  </div>
170
170
  )
@@ -195,6 +195,7 @@ export default function DeepAnalysis({ overview }) {
195
195
  const [data, setData] = useState(null)
196
196
  const [loading, setLoading] = useState(false)
197
197
  const [selectedTool, setSelectedTool] = useState(null)
198
+ const [costs, setCosts] = useState(null)
198
199
  const chartRef = useRef(null)
199
200
  const { dark } = useTheme()
200
201
  const txtColor = dark ? '#a0a0a0' : '#444'
@@ -207,8 +208,13 @@ export default function DeepAnalysis({ overview }) {
207
208
 
208
209
  async function analyze() {
209
210
  setLoading(true)
210
- const result = await fetchDeepAnalytics({ editor, folder: folder || undefined, limit: 500, ...dateRangeToApiParams(dateRange) })
211
+ const dateParams = dateRangeToApiParams(dateRange)
212
+ const [result, costData] = await Promise.all([
213
+ fetchDeepAnalytics({ editor, folder: folder || undefined, limit: 500, ...dateParams }),
214
+ fetchCosts({ editor, folder: folder || undefined, ...dateParams }),
215
+ ])
211
216
  setData(result)
217
+ setCosts(costData)
212
218
  setLoading(false)
213
219
  }
214
220
 
@@ -247,7 +253,7 @@ export default function DeepAnalysis({ overview }) {
247
253
  <select
248
254
  value={editor}
249
255
  onChange={e => setEditor(e.target.value)}
250
- className="px-2 py-1.5 text-[11px] outline-none rounded-sm"
256
+ className="px-2 py-1.5 text-[12px] outline-none rounded-sm"
251
257
  style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
252
258
  >
253
259
  <option value="">All Editors</option>
@@ -258,7 +264,7 @@ export default function DeepAnalysis({ overview }) {
258
264
  <select
259
265
  value={folder}
260
266
  onChange={e => setFolder(e.target.value)}
261
- className="px-2 py-1.5 text-[11px] outline-none max-w-[200px] truncate rounded-sm"
267
+ className="px-2 py-1.5 text-[12px] outline-none max-w-[200px] truncate rounded-sm"
262
268
  style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
263
269
  >
264
270
  <option value="">All Projects</option>
@@ -267,7 +273,7 @@ export default function DeepAnalysis({ overview }) {
267
273
  ))}
268
274
  </select>
269
275
  {loading && <Loader2 size={11} className="animate-spin" style={{ color: 'var(--c-text3)' }} />}
270
- {data && <span className="text-[10px]" style={{ color: 'var(--c-text2)' }}>{data.analyzedChats} sessions analyzed</span>}
276
+ {data && <span className="text-[11px]" style={{ color: 'var(--c-text2)' }}>{data.analyzedChats} sessions analyzed</span>}
271
277
  <div className="ml-auto"><DateRangePicker value={dateRange} onChange={setDateRange} /></div>
272
278
  </div>
273
279
 
@@ -279,7 +285,8 @@ export default function DeepAnalysis({ overview }) {
279
285
  <KpiCard label="messages" value={formatNumber(data.totalMessages)} sub={`${insights.msgsPerSession}/session`} />
280
286
  <KpiCard label="tool calls" value={formatNumber(data.totalToolCalls)} sub={`${insights.toolsPerSession}/session`} />
281
287
  <KpiCard label="total tokens" value={formatNumber(insights.totalTok)} sub={`${formatNumber(insights.tokPerMsg)}/msg`} />
282
- <KpiCard label="you wrote" value={formatNumber(data.totalUserChars)} sub={`AI: ${insights.aiVsHuman}× more`} />
288
+ <KpiCard label="you wrote" value={formatNumber(data.totalUserChars)} sub={`AI: ${insights.aiVsHuman}\u00d7 more`} />
289
+ <KpiCard label="est. cost" value={costs && costs.totalCost > 0 ? formatCost(costs.totalCost) : '\u2014'} />
283
290
  </div>
284
291
 
285
292
  {/* Token flow + Insights row */}
@@ -290,7 +297,7 @@ export default function DeepAnalysis({ overview }) {
290
297
  <div className="space-y-3 mt-2">
291
298
  {/* Input tokens breakdown */}
292
299
  <div>
293
- <div className="flex items-center justify-between text-[10px] mb-1">
300
+ <div className="flex items-center justify-between text-[11px] mb-1">
294
301
  <span style={{ color: 'var(--c-text2)' }}>input tokens</span>
295
302
  <span className="font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(data.totalInputTokens)}</span>
296
303
  </div>
@@ -298,14 +305,14 @@ export default function DeepAnalysis({ overview }) {
298
305
  { label: 'Fresh input', value: data.totalInputTokens - data.totalCacheRead, color: '#6366f1' },
299
306
  { label: 'Cache read', value: data.totalCacheRead, color: '#34d399' },
300
307
  ]} />
301
- <div className="flex items-center gap-3 mt-1 text-[9px]">
308
+ <div className="flex items-center gap-3 mt-1 text-[10px]">
302
309
  <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#6366f1' }} /> fresh {formatNumber(data.totalInputTokens - data.totalCacheRead)}</span>
303
310
  <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#34d399' }} /> cached {formatNumber(data.totalCacheRead)}</span>
304
311
  </div>
305
312
  </div>
306
313
  {/* Output tokens */}
307
314
  <div>
308
- <div className="flex items-center justify-between text-[10px] mb-1">
315
+ <div className="flex items-center justify-between text-[11px] mb-1">
309
316
  <span style={{ color: 'var(--c-text2)' }}>output tokens</span>
310
317
  <span className="font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(data.totalOutputTokens)}</span>
311
318
  </div>
@@ -316,7 +323,7 @@ export default function DeepAnalysis({ overview }) {
316
323
  {/* Cache write */}
317
324
  {data.totalCacheWrite > 0 && (
318
325
  <div>
319
- <div className="flex items-center justify-between text-[10px] mb-1">
326
+ <div className="flex items-center justify-between text-[11px] mb-1">
320
327
  <span style={{ color: 'var(--c-text2)' }}>cache write</span>
321
328
  <span className="font-bold" style={{ color: 'var(--c-white)' }}>{formatNumber(data.totalCacheWrite)}</span>
322
329
  </div>
@@ -327,14 +334,14 @@ export default function DeepAnalysis({ overview }) {
327
334
  )}
328
335
  {/* Overall ratio bar */}
329
336
  <div className="pt-2" style={{ borderTop: '1px solid var(--c-border)' }}>
330
- <div className="text-[9px] mb-1" style={{ color: 'var(--c-text3)' }}>overall token distribution</div>
337
+ <div className="text-[10px] mb-1" style={{ color: 'var(--c-text3)' }}>overall token distribution</div>
331
338
  <ProportionBar height={10} segments={[
332
339
  { label: 'Input', value: data.totalInputTokens, color: '#6366f1' },
333
340
  { label: 'Output', value: data.totalOutputTokens, color: '#a78bfa' },
334
341
  { label: 'Cache Read', value: data.totalCacheRead, color: '#34d399' },
335
342
  { label: 'Cache Write', value: data.totalCacheWrite, color: '#fbbf24' },
336
343
  ]} />
337
- <div className="flex items-center gap-3 mt-1 text-[9px]" style={{ color: 'var(--c-text3)' }}>
344
+ <div className="flex items-center gap-3 mt-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
338
345
  <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#6366f1' }} /> in</span>
339
346
  <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#a78bfa' }} /> out</span>
340
347
  <span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm" style={{ background: '#34d399' }} /> cache read</span>
@@ -350,12 +357,12 @@ export default function DeepAnalysis({ overview }) {
350
357
  <div className="space-y-3 mt-2">
351
358
  {/* Human vs AI chars */}
352
359
  <div>
353
- <div className="text-[10px] mb-1" style={{ color: 'var(--c-text2)' }}>you vs AI (characters)</div>
360
+ <div className="text-[11px] mb-1" style={{ color: 'var(--c-text2)' }}>you vs AI (characters)</div>
354
361
  <ProportionBar height={8} segments={[
355
362
  { label: 'You', value: data.totalUserChars, color: '#6366f1' },
356
363
  { label: 'AI', value: data.totalAssistantChars, color: '#34d399' },
357
364
  ]} />
358
- <div className="flex items-center justify-between mt-1 text-[9px]">
365
+ <div className="flex items-center justify-between mt-1 text-[10px]">
359
366
  <span style={{ color: '#6366f1' }}>You: {formatNumber(data.totalUserChars)}</span>
360
367
  <span style={{ color: '#34d399' }}>AI: {formatNumber(data.totalAssistantChars)}</span>
361
368
  </div>
@@ -364,25 +371,25 @@ export default function DeepAnalysis({ overview }) {
364
371
  {/* Metric cards */}
365
372
  <div className="grid grid-cols-2 gap-2">
366
373
  <div className="p-2 rounded-sm" style={{ background: 'var(--c-code-bg)' }}>
367
- <div className="flex items-center gap-1 text-[9px]" style={{ color: 'var(--c-text3)' }}>
374
+ <div className="flex items-center gap-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
368
375
  <TrendingUp size={9} /> output/input ratio
369
376
  </div>
370
377
  <div className="text-sm font-bold mt-0.5" style={{ color: 'var(--c-white)' }}>{insights.outputRatio}×</div>
371
378
  </div>
372
379
  <div className="p-2 rounded-sm" style={{ background: 'var(--c-code-bg)' }}>
373
- <div className="flex items-center gap-1 text-[9px]" style={{ color: 'var(--c-text3)' }}>
380
+ <div className="flex items-center gap-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
374
381
  <Zap size={9} /> cache hit rate
375
382
  </div>
376
383
  <div className="text-sm font-bold mt-0.5" style={{ color: parseFloat(insights.cacheHitRate) > 50 ? '#34d399' : parseFloat(insights.cacheHitRate) > 20 ? '#fbbf24' : 'var(--c-white)' }}>{insights.cacheHitRate}%</div>
377
384
  </div>
378
385
  <div className="p-2 rounded-sm" style={{ background: 'var(--c-code-bg)' }}>
379
- <div className="flex items-center gap-1 text-[9px]" style={{ color: 'var(--c-text3)' }}>
386
+ <div className="flex items-center gap-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
380
387
  <MessageSquare size={9} /> tokens per message
381
388
  </div>
382
389
  <div className="text-sm font-bold mt-0.5" style={{ color: 'var(--c-white)' }}>{formatNumber(insights.tokPerMsg)}</div>
383
390
  </div>
384
391
  <div className="p-2 rounded-sm" style={{ background: 'var(--c-code-bg)' }}>
385
- <div className="flex items-center gap-1 text-[9px]" style={{ color: 'var(--c-text3)' }}>
392
+ <div className="flex items-center gap-1 text-[10px]" style={{ color: 'var(--c-text3)' }}>
386
393
  <Wrench size={9} /> tools per session
387
394
  </div>
388
395
  <div className="text-sm font-bold mt-0.5" style={{ color: 'var(--c-white)' }}>{insights.toolsPerSession}</div>
@@ -391,9 +398,9 @@ export default function DeepAnalysis({ overview }) {
391
398
 
392
399
  {/* AI amplification */}
393
400
  <div className="p-2 rounded-sm text-center" style={{ background: 'var(--c-code-bg)' }}>
394
- <div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>AI amplification factor</div>
401
+ <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>AI amplification factor</div>
395
402
  <div className="text-lg font-bold" style={{ color: 'var(--c-accent)' }}>{insights.aiVsHuman}×</div>
396
- <div className="text-[9px]" style={{ color: 'var(--c-text3)' }}>AI writes {insights.aiVsHuman}× more than you type</div>
403
+ <div className="text-[10px]" style={{ color: 'var(--c-text3)' }}>AI writes {insights.aiVsHuman}× more than you type</div>
397
404
  </div>
398
405
  </div>
399
406
  </div>
@@ -424,7 +431,7 @@ export default function DeepAnalysis({ overview }) {
424
431
  plugins: { legend: { display: false }, tooltip: { bodyFont: { family: MONO, size: 10 }, titleFont: { family: MONO, size: 10 } } },
425
432
  }}
426
433
  />
427
- ) : <div className="text-[10px] text-center py-8" style={{ color: 'var(--c-text3)' }}>no tool calls found</div>}
434
+ ) : <div className="text-[11px] text-center py-8" style={{ color: 'var(--c-text3)' }}>no tool calls found</div>}
428
435
  </div>
429
436
  </div>
430
437
 
@@ -446,16 +453,16 @@ export default function DeepAnalysis({ overview }) {
446
453
  },
447
454
  }}
448
455
  />
449
- ) : <div className="text-[10px] text-center py-8" style={{ color: 'var(--c-text3)' }}>no model data</div>}
456
+ ) : <div className="text-[11px] text-center py-8" style={{ color: 'var(--c-text3)' }}>no model data</div>}
450
457
  </div>
451
458
 
452
459
  {/* Tool categories below models */}
453
460
  {toolCategories.length > 0 && (
454
461
  <div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--c-border)' }}>
455
- <div className="text-[9px] uppercase tracking-wider mb-2" style={{ color: 'var(--c-text3)' }}>tool categories</div>
462
+ <div className="text-[10px] uppercase tracking-wider mb-2" style={{ color: 'var(--c-text3)' }}>tool categories</div>
456
463
  <div className="space-y-1.5">
457
464
  {toolCategories.map(([cat, { tools: catTools, total }]) => (
458
- <div key={cat} className="flex items-center gap-2 text-[10px]">
465
+ <div key={cat} className="flex items-center gap-2 text-[11px]">
459
466
  <span className="truncate flex-1" style={{ color: 'var(--c-text2)' }}>{cat}</span>
460
467
  <span className="font-bold" style={{ color: 'var(--c-white)' }}>{total}</span>
461
468
  <span style={{ color: 'var(--c-text3)' }}>({catTools.length})</span>
@@ -3,8 +3,8 @@ import { useSearchParams, useNavigate } from 'react-router-dom'
3
3
  import { ArrowLeft, Search, FolderOpen, Calendar, MessageSquare, Wrench, Cpu, Zap, AlertTriangle } from 'lucide-react'
4
4
  import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement } from 'chart.js'
5
5
  import { Doughnut, Bar } from 'react-chartjs-2'
6
- import { fetchProjects, fetchChats } from '../lib/api'
7
- import { editorColor, editorLabel, formatNumber, formatDate } from '../lib/constants'
6
+ import { fetchProjects, fetchChats, fetchCosts } from '../lib/api'
7
+ import { editorColor, editorLabel, formatNumber, formatDate, formatCost } from '../lib/constants'
8
8
  import { useTheme } from '../lib/theme'
9
9
  import KpiCard from '../components/KpiCard'
10
10
  import EditorIcon from '../components/EditorIcon'
@@ -31,6 +31,7 @@ export default function ProjectDetail() {
31
31
  const [loading, setLoading] = useState(true)
32
32
  const [chatSearch, setChatSearch] = useState('')
33
33
  const [selectedChatId, setSelectedChatId] = useState(null)
34
+ const [costs, setCosts] = useState(null)
34
35
  const [enabledEditors, setEnabledEditors] = useState(null)
35
36
 
36
37
  useEffect(() => {
@@ -39,10 +40,12 @@ export default function ProjectDetail() {
39
40
  Promise.all([
40
41
  fetchProjects(),
41
42
  fetchChats({ folder, limit: 1000 }),
42
- ]).then(([projects, chatData]) => {
43
+ fetchCosts({ folder }),
44
+ ]).then(([projects, chatData, costData]) => {
43
45
  const match = projects.find(p => p.folder === folder)
44
46
  setProject(match || null)
45
47
  setChats(chatData.chats || [])
48
+ setCosts(costData)
46
49
  if (match) setEnabledEditors(new Set(Object.keys(match.editors)))
47
50
  setLoading(false)
48
51
  })
@@ -104,7 +107,7 @@ export default function ProjectDetail() {
104
107
  <div className="flex items-start gap-3">
105
108
  <button
106
109
  onClick={() => navigate('/projects')}
107
- className="flex items-center gap-1 text-[11px] transition mt-0.5 flex-shrink-0"
110
+ className="flex items-center gap-1 text-[12px] transition mt-0.5 flex-shrink-0"
108
111
  style={{ color: 'var(--c-text3)' }}
109
112
  >
110
113
  <ArrowLeft size={12} />
@@ -112,7 +115,7 @@ export default function ProjectDetail() {
112
115
  <FolderOpen size={18} className="flex-shrink-0 mt-0.5" style={{ color: 'var(--c-accent)' }} />
113
116
  <div className="flex-1 min-w-0">
114
117
  <h1 className="text-sm font-bold truncate" style={{ color: 'var(--c-white)' }}>{project.name}</h1>
115
- <div className="text-[10px] truncate" style={{ color: 'var(--c-text3)' }}>{project.folder}</div>
118
+ <div className="text-[11px] truncate" style={{ color: 'var(--c-text3)' }}>{project.folder}</div>
116
119
  </div>
117
120
  <div className="flex items-center gap-1.5 flex-shrink-0">
118
121
  {editorEntries.map(([e]) => (
@@ -120,7 +123,7 @@ export default function ProjectDetail() {
120
123
  ))}
121
124
  </div>
122
125
  </div>
123
- <div className="flex items-center gap-4 mt-3 pt-3 text-[10px]" style={{ borderTop: '1px solid var(--c-border)' }}>
126
+ <div className="flex items-center gap-4 mt-3 pt-3 text-[11px]" style={{ borderTop: '1px solid var(--c-border)' }}>
124
127
  <div className="flex items-center gap-1" style={{ color: 'var(--c-text3)' }}>
125
128
  <Calendar size={9} />
126
129
  <span>{formatDate(project.firstSeen)}</span>
@@ -144,11 +147,12 @@ export default function ProjectDetail() {
144
147
  <KpiCard label="sessions" value={displaySessions} sub={!allEnabled ? 'filtered' : ''} />
145
148
  <KpiCard label="messages" value={formatNumber(project.totalMessages)} sub={`${avgMsgs} avg/session`} />
146
149
  <KpiCard label="tool calls" value={formatNumber(project.totalToolCalls)} sub={<span className="flex items-center gap-0.5"><Wrench size={8} /> invocations</span>} />
147
- <KpiCard label="tokens" value={formatNumber(totalTok)} sub={`${outputRatio}× out/in`} />
150
+ <KpiCard label="tokens" value={formatNumber(totalTok)} sub={`${outputRatio}\u00d7 out/in`} />
148
151
  {project.totalCacheRead > 0 && (
149
152
  <KpiCard label="cache read" value={formatNumber(project.totalCacheRead)} sub={`write: ${formatNumber(project.totalCacheWrite)}`} />
150
153
  )}
151
154
  <KpiCard label="you wrote" value={formatNumber(project.totalUserChars)} sub={`AI: ${formatNumber(project.totalAssistantChars)}`} />
155
+ <KpiCard label="est. cost" value={costs && costs.totalCost > 0 ? formatCost(costs.totalCost) : '\u2014'} sub={costs && costs.byModel.length > 0 ? `${costs.byModel.length} model${costs.byModel.length !== 1 ? 's' : ''}` : undefined} />
152
156
  </div>
153
157
 
154
158
  {/* Charts row */}
@@ -169,11 +173,11 @@ export default function ProjectDetail() {
169
173
  className="accent-[var(--c-accent)] w-3 h-3 flex-shrink-0 cursor-pointer"
170
174
  />
171
175
  <EditorIcon source={e} size={11} />
172
- <span className="text-[10px] truncate flex-1 cursor-pointer select-none" onClick={() => toggleEditor(e)} style={{ color: checked ? 'var(--c-text2)' : 'var(--c-text3)', opacity: checked ? 1 : 0.4 }}>{editorLabel(e)}</span>
176
+ <span className="text-[11px] truncate flex-1 cursor-pointer select-none" onClick={() => toggleEditor(e)} style={{ color: checked ? 'var(--c-text2)' : 'var(--c-text3)', opacity: checked ? 1 : 0.4 }}>{editorLabel(e)}</span>
173
177
  <div className="w-16 h-3 rounded-sm overflow-hidden" style={{ background: 'var(--c-code-bg)' }}>
174
178
  <div className="h-full rounded-sm transition-all" style={{ width: `${(count / fMaxEditorCount * 100).toFixed(0)}%`, background: checked ? editorColor(e) : 'var(--c-text3)', opacity: checked ? 1 : 0.2 }} />
175
179
  </div>
176
- <span className="text-[10px] w-6 text-right font-bold" style={{ color: checked ? 'var(--c-white)' : 'var(--c-text3)', opacity: checked ? 1 : 0.4 }}>{count}</span>
180
+ <span className="text-[11px] w-6 text-right font-bold" style={{ color: checked ? 'var(--c-white)' : 'var(--c-text3)', opacity: checked ? 1 : 0.4 }}>{count}</span>
177
181
  </div>
178
182
  )
179
183
  })}
@@ -199,7 +203,7 @@ export default function ProjectDetail() {
199
203
  }}
200
204
  />
201
205
  </div>
202
- ) : <div className="text-[10px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no model data</div>}
206
+ ) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no model data</div>}
203
207
  </div>
204
208
 
205
209
  {/* Top tools horizontal bar */}
@@ -226,7 +230,7 @@ export default function ProjectDetail() {
226
230
  }}
227
231
  />
228
232
  </div>
229
- ) : <div className="text-[10px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no tool data</div>}
233
+ ) : <div className="text-[11px] py-8 text-center" style={{ color: 'var(--c-text3)' }}>no tool data</div>}
230
234
  </div>
231
235
  </div>
232
236
 
@@ -241,21 +245,22 @@ export default function ProjectDetail() {
241
245
  placeholder="filter sessions..."
242
246
  value={chatSearch}
243
247
  onChange={e => setChatSearch(e.target.value)}
244
- className="w-full pl-7 pr-3 py-1 text-[11px] outline-none rounded-sm"
248
+ className="w-full pl-7 pr-3 py-1 text-[12px] outline-none rounded-sm"
245
249
  style={{ background: 'var(--c-bg3)', color: 'var(--c-text)', border: '1px solid var(--c-border)' }}
246
250
  />
247
251
  </div>
248
252
  </div>
249
253
 
250
254
  <div className="card overflow-hidden">
251
- <table className="w-full text-[11px]">
255
+ <table className="w-full text-[12px]">
252
256
  <thead>
253
- <tr className="text-[9px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
257
+ <tr className="text-[10px] uppercase tracking-wider" style={{ borderBottom: '1px solid var(--c-border)', color: 'var(--c-text3)' }}>
254
258
  <th className="text-left py-2 px-3 font-medium">editor</th>
255
259
  <th className="text-left py-2 px-3 font-medium">name</th>
256
260
  <th className="text-left py-2 px-3 font-medium">mode</th>
257
261
  <th className="text-left py-2 px-3 font-medium">model</th>
258
262
  <th className="text-left py-2 px-3 font-medium">context</th>
263
+ <th className="text-right py-2 px-3 font-medium">est. cost</th>
259
264
  <th className="text-left py-2 px-3 font-medium">updated</th>
260
265
  </tr>
261
266
  </thead>
@@ -277,7 +282,7 @@ export default function ProjectDetail() {
277
282
  </td>
278
283
  <td className="py-2 px-3 font-medium truncate max-w-[280px]" style={{ color: 'var(--c-white)' }}>
279
284
  {c.name || <span style={{ color: 'var(--c-text3)' }}>Untitled</span>}
280
- {c.encrypted && <span className="ml-1.5 text-[9px] text-yellow-500/60">locked</span>}
285
+ {c.encrypted && <span className="ml-1.5 text-[10px] text-yellow-500/60">locked</span>}
281
286
  </td>
282
287
  <td className="py-2 px-3" style={{ color: 'var(--c-text2)' }}>{c.mode || ''}</td>
283
288
  <td className="py-2 px-3 font-mono truncate max-w-[150px]" style={{ color: 'var(--c-text2)' }} title={c.topModel || ''}>{c.topModel || ''}</td>
@@ -294,6 +299,9 @@ export default function ProjectDetail() {
294
299
  <span style={{ color: 'var(--c-text3)' }}>{c.bubbleCount || 0} msgs</span>
295
300
  )}
296
301
  </td>
302
+ <td className="py-2 px-3 font-mono text-right" style={{ color: c.cost > 0 ? 'var(--c-text2)' : 'var(--c-text3)' }}>
303
+ {c.cost > 0 ? formatCost(c.cost) : ''}
304
+ </td>
297
305
  <td className="py-2 px-3 whitespace-nowrap" style={{ color: 'var(--c-text3)' }}>{formatDate(c.lastUpdatedAt || c.createdAt)}</td>
298
306
  </tr>
299
307
  ))}