@aion0/forge 0.5.28 → 0.5.30
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.
- package/RELEASE_NOTES.md +13 -10
- package/components/ProjectDetail.tsx +23 -20
- package/components/ProjectManager.tsx +1 -1
- package/components/UsagePanel.tsx +431 -49
- package/components/WorkspaceView.tsx +178 -36
- package/lib/usage-scanner.ts +14 -5
- package/lib/workspace/orchestrator.ts +1 -12
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
|
-
# Forge v0.5.
|
|
1
|
+
# Forge v0.5.30
|
|
2
2
|
|
|
3
|
-
Released: 2026-04-
|
|
3
|
+
Released: 2026-04-10
|
|
4
4
|
|
|
5
|
-
## Changes since v0.5.
|
|
5
|
+
## Changes since v0.5.29
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
- feat: shrink trend chart, add GitHub-style heatmap and weekday chart
|
|
9
|
+
- feat: richer usage statistics with charts
|
|
10
|
+
- feat: auto-expand docked terminals based on count
|
|
11
|
+
- feat: workspace terminal dock mode with grid layout
|
|
6
12
|
|
|
7
13
|
### Bug Fixes
|
|
8
|
-
- fix:
|
|
14
|
+
- fix: preserve workspace terminal state across tab/project switches
|
|
9
15
|
|
|
10
|
-
###
|
|
11
|
-
-
|
|
12
|
-
- perf: remove task completion polling (replaced by hook stop)
|
|
13
|
-
- perf: reduce polling frequency and lazy-load non-essential components
|
|
14
|
-
- perf: async terminal-cwd to avoid blocking event loop
|
|
16
|
+
### Other
|
|
17
|
+
- style: make project tab buttons more prominent
|
|
15
18
|
|
|
16
19
|
|
|
17
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.
|
|
20
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.29...v0.5.30
|
|
@@ -85,6 +85,9 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
85
85
|
const [projectSkills, setProjectSkills] = useState<{ name: string; displayName: string; type: string; scope: string; version: string; installedVersion: string; hasUpdate: boolean; source: 'registry' | 'local' }[]>([]);
|
|
86
86
|
const [showSkillsDetail, setShowSkillsDetail] = useState(false);
|
|
87
87
|
const [projectTab, setProjectTab] = useState<'workspace' | 'sessions' | 'code' | 'skills' | 'claudemd' | 'pipelines'>('code');
|
|
88
|
+
// Lazy-mount workspace: only mount after first visit, keep mounted to preserve terminal state
|
|
89
|
+
const [wsMounted, setWsMounted] = useState(false);
|
|
90
|
+
useEffect(() => { if (projectTab === 'workspace') setWsMounted(true); }, [projectTab]);
|
|
88
91
|
const wsViewRef = useRef<import('./WorkspaceView').WorkspaceViewHandle>(null);
|
|
89
92
|
// Pipeline bindings state
|
|
90
93
|
const [pipelineBindings, setPipelineBindings] = useState<{ id: number; workflowName: string; enabled: boolean; config: any; lastRunAt: string | null; nextRunAt: string | null }[]>([]);
|
|
@@ -566,52 +569,52 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
566
569
|
</div>
|
|
567
570
|
{/* Tab switcher */}
|
|
568
571
|
<div className="flex items-center gap-2 mt-1.5">
|
|
569
|
-
<div className="flex bg-[var(--bg-tertiary)] rounded p-0.5">
|
|
572
|
+
<div className="flex bg-[var(--bg-tertiary)] rounded p-1 gap-0.5">
|
|
570
573
|
<button
|
|
571
574
|
onClick={() => setProjectTab('code')}
|
|
572
|
-
className={`text-[
|
|
573
|
-
projectTab === 'code' ? 'bg-[var(--
|
|
575
|
+
className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
576
|
+
projectTab === 'code' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
574
577
|
}`}
|
|
575
578
|
>Code</button>
|
|
576
579
|
<button
|
|
577
580
|
onClick={() => setProjectTab('workspace')}
|
|
578
|
-
className={`text-[
|
|
579
|
-
projectTab === 'workspace' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
581
|
+
className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
582
|
+
projectTab === 'workspace' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
580
583
|
}`}
|
|
581
584
|
>🔨 Workspace</button>
|
|
582
585
|
<button
|
|
583
586
|
onClick={() => setProjectTab('sessions')}
|
|
584
|
-
className={`text-[
|
|
585
|
-
projectTab === 'sessions' ? 'bg-[var(--
|
|
587
|
+
className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
588
|
+
projectTab === 'sessions' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
586
589
|
}`}
|
|
587
590
|
>Sessions</button>
|
|
588
591
|
<button
|
|
589
592
|
onClick={() => setProjectTab('skills')}
|
|
590
|
-
className={`text-[
|
|
591
|
-
projectTab === 'skills' ? 'bg-[var(--
|
|
593
|
+
className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
594
|
+
projectTab === 'skills' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
592
595
|
}`}
|
|
593
596
|
>
|
|
594
597
|
Skills & Cmds
|
|
595
|
-
{projectSkills.length > 0 && <span className="ml-1 text-[
|
|
596
|
-
{projectSkills.some(s => s.hasUpdate) && <span className="ml-1 text-[
|
|
598
|
+
{projectSkills.length > 0 && <span className="ml-1 text-[9px] opacity-70">({projectSkills.length})</span>}
|
|
599
|
+
{projectSkills.some(s => s.hasUpdate) && <span className="ml-1 text-[9px] text-[var(--yellow)]">!</span>}
|
|
597
600
|
</button>
|
|
598
601
|
<button
|
|
599
602
|
onClick={() => setProjectTab('claudemd')}
|
|
600
|
-
className={`text-[
|
|
601
|
-
projectTab === 'claudemd' ? 'bg-[var(--
|
|
603
|
+
className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
604
|
+
projectTab === 'claudemd' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
602
605
|
}`}
|
|
603
606
|
>
|
|
604
607
|
CLAUDE.md
|
|
605
|
-
{claudeMdExists && <span className="ml-1 text-[
|
|
608
|
+
{claudeMdExists && <span className="ml-1 text-[9px] text-[var(--green)]">•</span>}
|
|
606
609
|
</button>
|
|
607
610
|
<button
|
|
608
611
|
onClick={() => setProjectTab('pipelines')}
|
|
609
|
-
className={`text-[
|
|
610
|
-
projectTab === 'pipelines' ? 'bg-[var(--
|
|
612
|
+
className={`text-[11px] font-medium px-2.5 py-1 rounded transition-all ${
|
|
613
|
+
projectTab === 'pipelines' ? 'bg-[var(--accent)]/20 text-[var(--accent)] shadow-sm ring-1 ring-[var(--accent)]/40' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
611
614
|
}`}
|
|
612
615
|
>
|
|
613
616
|
Pipelines
|
|
614
|
-
{pipelineBindings.length > 0 && <span className="ml-1 text-[
|
|
617
|
+
{pipelineBindings.length > 0 && <span className="ml-1 text-[9px] opacity-70">({pipelineBindings.length})</span>}
|
|
615
618
|
</button>
|
|
616
619
|
</div>
|
|
617
620
|
</div>
|
|
@@ -642,9 +645,9 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
642
645
|
</div>
|
|
643
646
|
)}
|
|
644
647
|
|
|
645
|
-
{/* Workspace tab */}
|
|
646
|
-
{
|
|
647
|
-
<div className=
|
|
648
|
+
{/* Workspace tab — always mounted to preserve terminal state, hidden via CSS */}
|
|
649
|
+
{wsMounted && (
|
|
650
|
+
<div className={`flex-1 flex min-h-0 overflow-hidden ${projectTab === 'workspace' ? '' : 'hidden'}`}>
|
|
648
651
|
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">Loading...</div>}>
|
|
649
652
|
<WorkspaceViewLazy
|
|
650
653
|
ref={wsViewRef}
|
|
@@ -21,7 +21,7 @@ interface ProjectTab {
|
|
|
21
21
|
mountedAt: number; // timestamp for LRU eviction
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
const MAX_MOUNTED_TABS =
|
|
24
|
+
const MAX_MOUNTED_TABS = 20;
|
|
25
25
|
function genTabId(): number { return Date.now() + Math.floor(Math.random() * 10000); }
|
|
26
26
|
|
|
27
27
|
export default function ProjectManager() {
|
|
@@ -1,26 +1,30 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
4
4
|
|
|
5
5
|
interface UsageData {
|
|
6
|
-
total: { input: number; output: number; cost: number; sessions: number; messages: number };
|
|
6
|
+
total: { input: number; output: number; cacheRead: number; cacheCreate: number; cost: number; sessions: number; messages: number };
|
|
7
7
|
byProject: { name: string; input: number; output: number; cost: number; sessions: number }[];
|
|
8
8
|
byModel: { model: string; input: number; output: number; cost: number; messages: number }[];
|
|
9
|
-
byDay: { date: string; input: number; output: number; cost: number }[];
|
|
9
|
+
byDay: { date: string; input: number; output: number; cacheRead: number; cacheCreate: number; cost: number; messages: number }[];
|
|
10
10
|
bySource: { source: string; input: number; output: number; cost: number; messages: number }[];
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
function formatTokens(n: number): string {
|
|
14
|
+
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)}B`;
|
|
14
15
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
15
16
|
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
|
16
17
|
return String(n);
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function formatCost(n: number): string {
|
|
21
|
+
if (n >= 1000) return `$${(n / 1000).toFixed(2)}k`;
|
|
22
|
+
if (n >= 100) return `$${n.toFixed(0)}`;
|
|
20
23
|
return `$${n.toFixed(2)}`;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
//
|
|
26
|
+
// ─── Horizontal bar ──────────────────────────────────────
|
|
27
|
+
|
|
24
28
|
function Bar({ value, max, color }: { value: number; max: number; color: string }) {
|
|
25
29
|
const pct = max > 0 ? Math.min(100, (value / max) * 100) : 0;
|
|
26
30
|
return (
|
|
@@ -30,6 +34,275 @@ function Bar({ value, max, color }: { value: number; max: number; color: string
|
|
|
30
34
|
);
|
|
31
35
|
}
|
|
32
36
|
|
|
37
|
+
// ─── Stacked bar for tokens breakdown ─────────────────────
|
|
38
|
+
|
|
39
|
+
function StackedBar({ input, output, cacheRead, cacheCreate }: { input: number; output: number; cacheRead: number; cacheCreate: number }) {
|
|
40
|
+
const total = input + output + cacheRead + cacheCreate;
|
|
41
|
+
if (total === 0) return <div className="h-2 bg-[var(--bg-tertiary)] rounded-full" />;
|
|
42
|
+
const inputPct = (input / total) * 100;
|
|
43
|
+
const outputPct = (output / total) * 100;
|
|
44
|
+
const cacheReadPct = (cacheRead / total) * 100;
|
|
45
|
+
const cacheCreatePct = (cacheCreate / total) * 100;
|
|
46
|
+
return (
|
|
47
|
+
<div className="h-2 bg-[var(--bg-tertiary)] rounded-full overflow-hidden flex">
|
|
48
|
+
<div className="h-full bg-blue-500" style={{ width: `${inputPct}%` }} title={`Input: ${formatTokens(input)}`} />
|
|
49
|
+
<div className="h-full bg-green-500" style={{ width: `${outputPct}%` }} title={`Output: ${formatTokens(output)}`} />
|
|
50
|
+
<div className="h-full bg-purple-500" style={{ width: `${cacheReadPct}%` }} title={`Cache read: ${formatTokens(cacheRead)}`} />
|
|
51
|
+
<div className="h-full bg-orange-500" style={{ width: `${cacheCreatePct}%` }} title={`Cache create: ${formatTokens(cacheCreate)}`} />
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─── Pie/donut chart using SVG ─────────────────────────────
|
|
57
|
+
|
|
58
|
+
interface PieSlice { label: string; value: number; color: string }
|
|
59
|
+
|
|
60
|
+
function DonutChart({ data, size = 140 }: { data: PieSlice[]; size?: number }) {
|
|
61
|
+
const total = data.reduce((s, d) => s + d.value, 0);
|
|
62
|
+
if (total === 0) return <div className="text-[10px] text-[var(--text-secondary)]">No data</div>;
|
|
63
|
+
|
|
64
|
+
const radius = size / 2 - 4;
|
|
65
|
+
const innerRadius = radius * 0.6;
|
|
66
|
+
const cx = size / 2, cy = size / 2;
|
|
67
|
+
|
|
68
|
+
let startAngle = -Math.PI / 2; // start at top
|
|
69
|
+
const slices = data.map(slice => {
|
|
70
|
+
const fraction = slice.value / total;
|
|
71
|
+
const endAngle = startAngle + fraction * 2 * Math.PI;
|
|
72
|
+
const x1 = cx + radius * Math.cos(startAngle);
|
|
73
|
+
const y1 = cy + radius * Math.sin(startAngle);
|
|
74
|
+
const x2 = cx + radius * Math.cos(endAngle);
|
|
75
|
+
const y2 = cy + radius * Math.sin(endAngle);
|
|
76
|
+
const x3 = cx + innerRadius * Math.cos(endAngle);
|
|
77
|
+
const y3 = cy + innerRadius * Math.sin(endAngle);
|
|
78
|
+
const x4 = cx + innerRadius * Math.cos(startAngle);
|
|
79
|
+
const y4 = cy + innerRadius * Math.sin(startAngle);
|
|
80
|
+
const largeArc = fraction > 0.5 ? 1 : 0;
|
|
81
|
+
const d = `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} L ${x3} ${y3} A ${innerRadius} ${innerRadius} 0 ${largeArc} 0 ${x4} ${y4} Z`;
|
|
82
|
+
startAngle = endAngle;
|
|
83
|
+
return { d, color: slice.color, label: slice.label, value: slice.value, pct: fraction * 100 };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="flex items-center gap-3">
|
|
88
|
+
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
|
89
|
+
{slices.map((s, i) => (
|
|
90
|
+
<path key={i} d={s.d} fill={s.color}>
|
|
91
|
+
<title>{`${s.label}: ${formatCost(s.value)} (${s.pct.toFixed(1)}%)`}</title>
|
|
92
|
+
</path>
|
|
93
|
+
))}
|
|
94
|
+
<text x={cx} y={cy - 4} textAnchor="middle" className="fill-[var(--text-primary)] text-[11px] font-bold">
|
|
95
|
+
{formatCost(total)}
|
|
96
|
+
</text>
|
|
97
|
+
<text x={cx} y={cy + 10} textAnchor="middle" className="fill-[var(--text-secondary)] text-[8px]">
|
|
98
|
+
total
|
|
99
|
+
</text>
|
|
100
|
+
</svg>
|
|
101
|
+
<div className="flex-1 space-y-1">
|
|
102
|
+
{slices.map((s, i) => (
|
|
103
|
+
<div key={i} className="flex items-center gap-1.5 text-[10px]">
|
|
104
|
+
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: s.color }} />
|
|
105
|
+
<span className="text-[var(--text-primary)] truncate flex-1">{s.label}</span>
|
|
106
|
+
<span className="text-[var(--text-secondary)] text-[9px] w-10 text-right">{s.pct.toFixed(1)}%</span>
|
|
107
|
+
<span className="text-[var(--text-primary)] w-12 text-right font-mono">{formatCost(s.value)}</span>
|
|
108
|
+
</div>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── Line chart for trends ────────────────────────────────
|
|
116
|
+
|
|
117
|
+
function LineChart({ data, width = 520, height = 80 }: { data: { date: string; cost: number; messages: number }[]; width?: number; height?: number }) {
|
|
118
|
+
if (data.length === 0) return <div className="text-[10px] text-[var(--text-secondary)] py-6 text-center">No data</div>;
|
|
119
|
+
|
|
120
|
+
// Reverse so earliest is on left
|
|
121
|
+
const points = [...data].reverse();
|
|
122
|
+
const padding = { top: 10, right: 10, bottom: 22, left: 40 };
|
|
123
|
+
const chartW = width - padding.left - padding.right;
|
|
124
|
+
const chartH = height - padding.top - padding.bottom;
|
|
125
|
+
|
|
126
|
+
const maxCost = Math.max(...points.map(p => p.cost), 0.001);
|
|
127
|
+
const maxMsgs = Math.max(...points.map(p => p.messages), 1);
|
|
128
|
+
|
|
129
|
+
const xStep = points.length > 1 ? chartW / (points.length - 1) : 0;
|
|
130
|
+
const pathCost = points.map((p, i) => {
|
|
131
|
+
const x = padding.left + i * xStep;
|
|
132
|
+
const y = padding.top + chartH - (p.cost / maxCost) * chartH;
|
|
133
|
+
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`;
|
|
134
|
+
}).join(' ');
|
|
135
|
+
|
|
136
|
+
const areaCost = pathCost + ` L ${padding.left + (points.length - 1) * xStep} ${padding.top + chartH} L ${padding.left} ${padding.top + chartH} Z`;
|
|
137
|
+
|
|
138
|
+
// Y axis ticks (0, 50%, 100%)
|
|
139
|
+
const yTicks = [0, 0.5, 1];
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<svg width="100%" viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid meet">
|
|
143
|
+
{/* Grid lines */}
|
|
144
|
+
{yTicks.map((t, i) => {
|
|
145
|
+
const y = padding.top + chartH * (1 - t);
|
|
146
|
+
return (
|
|
147
|
+
<g key={i}>
|
|
148
|
+
<line x1={padding.left} y1={y} x2={padding.left + chartW} y2={y}
|
|
149
|
+
stroke="var(--border)" strokeWidth="0.5" strokeDasharray="2 2" />
|
|
150
|
+
<text x={padding.left - 4} y={y + 3} textAnchor="end" className="fill-[var(--text-secondary)] text-[8px]">
|
|
151
|
+
{formatCost(maxCost * t)}
|
|
152
|
+
</text>
|
|
153
|
+
</g>
|
|
154
|
+
);
|
|
155
|
+
})}
|
|
156
|
+
|
|
157
|
+
{/* Area fill */}
|
|
158
|
+
<path d={areaCost} fill="var(--accent)" opacity="0.15" />
|
|
159
|
+
{/* Line */}
|
|
160
|
+
<path d={pathCost} fill="none" stroke="var(--accent)" strokeWidth="1.5" />
|
|
161
|
+
|
|
162
|
+
{/* Points + labels (only a few to avoid overlap) */}
|
|
163
|
+
{points.map((p, i) => {
|
|
164
|
+
const x = padding.left + i * xStep;
|
|
165
|
+
const y = padding.top + chartH - (p.cost / maxCost) * chartH;
|
|
166
|
+
const showLabel = points.length <= 10 || i % Math.ceil(points.length / 7) === 0 || i === points.length - 1;
|
|
167
|
+
return (
|
|
168
|
+
<g key={p.date}>
|
|
169
|
+
<circle cx={x} cy={y} r="2" fill="var(--accent)" stroke="var(--bg-primary)" strokeWidth="0.5" />
|
|
170
|
+
{showLabel && (
|
|
171
|
+
<text x={x} y={height - 6} textAnchor="middle" className="fill-[var(--text-secondary)] text-[8px]">
|
|
172
|
+
{p.date.slice(5)}
|
|
173
|
+
</text>
|
|
174
|
+
)}
|
|
175
|
+
</g>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</svg>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Heatmap by day (grid) ────────────────────────────────
|
|
183
|
+
|
|
184
|
+
function Heatmap({ data, days: numDays = 90 }: { data: { date: string; cost: number }[]; days?: number }) {
|
|
185
|
+
if (data.length === 0) return null;
|
|
186
|
+
const max = Math.max(...data.map(d => d.cost), 0.01);
|
|
187
|
+
|
|
188
|
+
const costMap = new Map(data.map(d => [d.date, d.cost]));
|
|
189
|
+
|
|
190
|
+
// Build last N days
|
|
191
|
+
const days: { date: string; cost: number; dow: number }[] = [];
|
|
192
|
+
const today = new Date();
|
|
193
|
+
for (let i = numDays - 1; i >= 0; i--) {
|
|
194
|
+
const d = new Date(today);
|
|
195
|
+
d.setDate(today.getDate() - i);
|
|
196
|
+
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
197
|
+
days.push({ date: dateStr, cost: costMap.get(dateStr) || 0, dow: d.getDay() });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const intensity = (c: number) => {
|
|
201
|
+
if (c === 0) return 0;
|
|
202
|
+
return Math.min(4, Math.ceil((c / max) * 4));
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const bgClasses = [
|
|
206
|
+
'bg-[var(--bg-tertiary)]',
|
|
207
|
+
'bg-blue-900/40',
|
|
208
|
+
'bg-blue-700/60',
|
|
209
|
+
'bg-blue-500/80',
|
|
210
|
+
'bg-blue-400',
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
// Organize by week (columns) × weekday (rows), GitHub-style
|
|
214
|
+
// Start with padding for the first week
|
|
215
|
+
const firstDow = days[0].dow;
|
|
216
|
+
const weeks: (typeof days[0] | null)[][] = [];
|
|
217
|
+
let currentWeek: (typeof days[0] | null)[] = Array(firstDow).fill(null);
|
|
218
|
+
for (const d of days) {
|
|
219
|
+
currentWeek.push(d);
|
|
220
|
+
if (currentWeek.length === 7) {
|
|
221
|
+
weeks.push(currentWeek);
|
|
222
|
+
currentWeek = [];
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (currentWeek.length > 0) {
|
|
226
|
+
while (currentWeek.length < 7) currentWeek.push(null);
|
|
227
|
+
weeks.push(currentWeek);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const dayLabels = ['S', 'M', 'T', 'W', 'T', 'F', 'S'];
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div className="flex gap-1">
|
|
234
|
+
{/* Weekday labels */}
|
|
235
|
+
<div className="flex flex-col gap-[2px] pt-0.5">
|
|
236
|
+
{dayLabels.map((l, i) => (
|
|
237
|
+
<div key={i} className="text-[7px] text-[var(--text-secondary)] h-[11px] leading-[11px]">
|
|
238
|
+
{i % 2 === 1 ? l : ''}
|
|
239
|
+
</div>
|
|
240
|
+
))}
|
|
241
|
+
</div>
|
|
242
|
+
{/* Week columns */}
|
|
243
|
+
<div className="flex gap-[2px] overflow-x-auto">
|
|
244
|
+
{weeks.map((week, wi) => (
|
|
245
|
+
<div key={wi} className="flex flex-col gap-[2px]">
|
|
246
|
+
{week.map((day, di) => (
|
|
247
|
+
day ? (
|
|
248
|
+
<div
|
|
249
|
+
key={di}
|
|
250
|
+
className={`w-[11px] h-[11px] rounded-sm ${bgClasses[intensity(day.cost)]}`}
|
|
251
|
+
title={`${day.date}: ${formatCost(day.cost)}`}
|
|
252
|
+
/>
|
|
253
|
+
) : (
|
|
254
|
+
<div key={di} className="w-[11px] h-[11px]" />
|
|
255
|
+
)
|
|
256
|
+
))}
|
|
257
|
+
</div>
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Weekday cost distribution ──────────────────────────
|
|
265
|
+
|
|
266
|
+
function WeekdayChart({ data }: { data: { date: string; cost: number }[] }) {
|
|
267
|
+
if (data.length === 0) return null;
|
|
268
|
+
const byDow: number[] = Array(7).fill(0);
|
|
269
|
+
const countByDow: number[] = Array(7).fill(0);
|
|
270
|
+
for (const d of data) {
|
|
271
|
+
const date = new Date(d.date);
|
|
272
|
+
const dow = date.getDay();
|
|
273
|
+
byDow[dow] += d.cost;
|
|
274
|
+
countByDow[dow]++;
|
|
275
|
+
}
|
|
276
|
+
const avgByDow = byDow.map((sum, i) => countByDow[i] > 0 ? sum / countByDow[i] : 0);
|
|
277
|
+
const max = Math.max(...avgByDow, 0.01);
|
|
278
|
+
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<div className="flex items-end gap-1 h-16">
|
|
282
|
+
{avgByDow.map((v, i) => {
|
|
283
|
+
const pct = (v / max) * 100;
|
|
284
|
+
const isWeekend = i === 0 || i === 6;
|
|
285
|
+
return (
|
|
286
|
+
<div key={i} className="flex-1 flex flex-col items-center gap-0.5">
|
|
287
|
+
<div className="w-full flex-1 flex items-end">
|
|
288
|
+
<div
|
|
289
|
+
className={`w-full rounded-t ${isWeekend ? 'bg-orange-500/60' : 'bg-blue-500/60'}`}
|
|
290
|
+
style={{ height: `${Math.max(pct, 2)}%` }}
|
|
291
|
+
title={`${labels[i]}: ${formatCost(v)} avg`}
|
|
292
|
+
/>
|
|
293
|
+
</div>
|
|
294
|
+
<div className="text-[8px] text-[var(--text-secondary)]">{labels[i][0]}</div>
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
})}
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ─── Main component ────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
const MODEL_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
|
|
305
|
+
|
|
33
306
|
export default function UsagePanel() {
|
|
34
307
|
const [data, setData] = useState<UsageData | null>(null);
|
|
35
308
|
const [days, setDays] = useState(7);
|
|
@@ -57,16 +330,51 @@ export default function UsagePanel() {
|
|
|
57
330
|
setScanning(false);
|
|
58
331
|
};
|
|
59
332
|
|
|
333
|
+
// Calculate averages and trends
|
|
334
|
+
const stats = useMemo(() => {
|
|
335
|
+
if (!data) return null;
|
|
336
|
+
const byDay = data.byDay;
|
|
337
|
+
const avgDaily = byDay.length > 0 ? data.total.cost / byDay.length : 0;
|
|
338
|
+
const avgPerSession = data.total.sessions > 0 ? data.total.cost / data.total.sessions : 0;
|
|
339
|
+
const avgPerMsg = data.total.messages > 0 ? data.total.cost / data.total.messages : 0;
|
|
340
|
+
|
|
341
|
+
// Trend: compare last half vs first half
|
|
342
|
+
let trend = 0;
|
|
343
|
+
if (byDay.length >= 4) {
|
|
344
|
+
const mid = Math.floor(byDay.length / 2);
|
|
345
|
+
const recent = byDay.slice(0, mid).reduce((s, d) => s + d.cost, 0);
|
|
346
|
+
const earlier = byDay.slice(mid).reduce((s, d) => s + d.cost, 0);
|
|
347
|
+
trend = earlier > 0 ? ((recent - earlier) / earlier) * 100 : 0;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Cache efficiency
|
|
351
|
+
const totalInput = data.total.input + data.total.cacheRead;
|
|
352
|
+
const cacheHitRate = totalInput > 0 ? (data.total.cacheRead / totalInput) * 100 : 0;
|
|
353
|
+
|
|
354
|
+
return { avgDaily, avgPerSession, avgPerMsg, trend, cacheHitRate };
|
|
355
|
+
}, [data]);
|
|
356
|
+
|
|
60
357
|
if (loading && !data) {
|
|
61
358
|
return <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)] text-xs">Loading usage data...</div>;
|
|
62
359
|
}
|
|
63
360
|
|
|
64
|
-
if (!data) {
|
|
361
|
+
if (!data || !stats) {
|
|
65
362
|
return <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)] text-xs">Failed to load usage data</div>;
|
|
66
363
|
}
|
|
67
364
|
|
|
68
365
|
const maxProjectCost = data.byProject.length > 0 ? data.byProject[0].cost : 1;
|
|
69
|
-
|
|
366
|
+
|
|
367
|
+
// Prepare pie chart data
|
|
368
|
+
const modelPie: PieSlice[] = data.byModel.slice(0, 6).map((m, i) => ({
|
|
369
|
+
label: m.model.replace('claude-', ''),
|
|
370
|
+
value: m.cost,
|
|
371
|
+
color: MODEL_COLORS[i % MODEL_COLORS.length],
|
|
372
|
+
}));
|
|
373
|
+
const sourcePie: PieSlice[] = data.bySource.slice(0, 6).map((s, i) => ({
|
|
374
|
+
label: s.source,
|
|
375
|
+
value: s.cost,
|
|
376
|
+
color: MODEL_COLORS[i % MODEL_COLORS.length],
|
|
377
|
+
}));
|
|
70
378
|
|
|
71
379
|
return (
|
|
72
380
|
<div className="flex-1 flex flex-col min-h-0 overflow-y-auto">
|
|
@@ -93,54 +401,123 @@ export default function UsagePanel() {
|
|
|
93
401
|
</button>
|
|
94
402
|
</div>
|
|
95
403
|
|
|
96
|
-
<div className="p-4 space-y-
|
|
97
|
-
{/* Summary cards */}
|
|
404
|
+
<div className="p-4 space-y-5">
|
|
405
|
+
{/* ─── Summary cards ──────────────────────────────── */}
|
|
98
406
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
|
99
407
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
100
408
|
<div className="text-[9px] text-[var(--text-secondary)] uppercase">Total Cost</div>
|
|
101
409
|
<div className="text-lg font-bold text-[var(--text-primary)]">{formatCost(data.total.cost)}</div>
|
|
410
|
+
{stats.trend !== 0 && (
|
|
411
|
+
<div className={`text-[9px] ${stats.trend > 0 ? 'text-red-400' : 'text-green-400'}`}>
|
|
412
|
+
{stats.trend > 0 ? '↑' : '↓'} {Math.abs(stats.trend).toFixed(0)}% vs prev period
|
|
413
|
+
</div>
|
|
414
|
+
)}
|
|
102
415
|
</div>
|
|
103
416
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
104
|
-
<div className="text-[9px] text-[var(--text-secondary)] uppercase">
|
|
105
|
-
<div className="text-lg font-bold text-[var(--text-primary)]">{
|
|
417
|
+
<div className="text-[9px] text-[var(--text-secondary)] uppercase">Daily Avg</div>
|
|
418
|
+
<div className="text-lg font-bold text-[var(--text-primary)]">{formatCost(stats.avgDaily)}</div>
|
|
419
|
+
<div className="text-[9px] text-[var(--text-secondary)]">per day</div>
|
|
106
420
|
</div>
|
|
107
421
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
108
|
-
<div className="text-[9px] text-[var(--text-secondary)] uppercase">
|
|
109
|
-
<div className="text-lg font-bold text-[var(--text-primary)]">{formatTokens(data.total.output)}</div>
|
|
422
|
+
<div className="text-[9px] text-[var(--text-secondary)] uppercase">Tokens</div>
|
|
423
|
+
<div className="text-lg font-bold text-[var(--text-primary)]">{formatTokens(data.total.input + data.total.output + data.total.cacheRead)}</div>
|
|
424
|
+
<div className="text-[9px] text-[var(--text-secondary)]">
|
|
425
|
+
{formatTokens(data.total.input)} in · {formatTokens(data.total.output)} out
|
|
426
|
+
</div>
|
|
110
427
|
</div>
|
|
111
428
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
112
|
-
<div className="text-[9px] text-[var(--text-secondary)] uppercase">
|
|
113
|
-
<div className="text-lg font-bold text-[var(--text-primary)]">{
|
|
114
|
-
<div className="text-[9px] text-[var(--text-secondary)]">{data.total.
|
|
429
|
+
<div className="text-[9px] text-[var(--text-secondary)] uppercase">Cache Hit</div>
|
|
430
|
+
<div className="text-lg font-bold text-[var(--text-primary)]">{stats.cacheHitRate.toFixed(0)}%</div>
|
|
431
|
+
<div className="text-[9px] text-[var(--text-secondary)]">{formatTokens(data.total.cacheRead)} cached</div>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
{/* ─── Token breakdown stacked bar ──────────────── */}
|
|
436
|
+
<div>
|
|
437
|
+
<div className="flex items-center justify-between mb-1.5">
|
|
438
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase">Token Mix</h3>
|
|
439
|
+
<div className="flex items-center gap-3 text-[9px]">
|
|
440
|
+
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-blue-500"></span>Input</span>
|
|
441
|
+
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-green-500"></span>Output</span>
|
|
442
|
+
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-purple-500"></span>Cache R</span>
|
|
443
|
+
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-sm bg-orange-500"></span>Cache W</span>
|
|
444
|
+
</div>
|
|
115
445
|
</div>
|
|
446
|
+
<StackedBar
|
|
447
|
+
input={data.total.input}
|
|
448
|
+
output={data.total.output}
|
|
449
|
+
cacheRead={data.total.cacheRead}
|
|
450
|
+
cacheCreate={data.total.cacheCreate}
|
|
451
|
+
/>
|
|
116
452
|
</div>
|
|
117
453
|
|
|
118
|
-
{/*
|
|
454
|
+
{/* ─── Daily trend line chart ───────────────────── */}
|
|
119
455
|
{data.byDay.length > 0 && (
|
|
120
456
|
<div>
|
|
121
|
-
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">
|
|
122
|
-
<div className="
|
|
123
|
-
{data.byDay
|
|
124
|
-
<div key={d.date} className="flex items-center gap-2 text-[10px]">
|
|
125
|
-
<span className="text-[var(--text-secondary)] w-16 shrink-0">{d.date.slice(5)}</span>
|
|
126
|
-
<Bar value={d.cost} max={maxDayCost} color="bg-[var(--accent)]" />
|
|
127
|
-
<span className="text-[var(--text-primary)] w-16 text-right shrink-0">{formatCost(d.cost)}</span>
|
|
128
|
-
</div>
|
|
129
|
-
))}
|
|
457
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Cost Trend</h3>
|
|
458
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
459
|
+
<LineChart data={data.byDay} />
|
|
130
460
|
</div>
|
|
131
461
|
</div>
|
|
132
462
|
)}
|
|
133
463
|
|
|
134
|
-
{/*
|
|
464
|
+
{/* ─── Activity heatmap (90 days) + weekday ─────── */}
|
|
465
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
466
|
+
<div className="md:col-span-2">
|
|
467
|
+
<div className="flex items-center justify-between mb-2">
|
|
468
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase">Activity (last 90 days)</h3>
|
|
469
|
+
<div className="flex items-center gap-1 text-[8px] text-[var(--text-secondary)]">
|
|
470
|
+
<span>less</span>
|
|
471
|
+
<span className="w-2 h-2 rounded-sm bg-[var(--bg-tertiary)]"></span>
|
|
472
|
+
<span className="w-2 h-2 rounded-sm bg-blue-900/40"></span>
|
|
473
|
+
<span className="w-2 h-2 rounded-sm bg-blue-700/60"></span>
|
|
474
|
+
<span className="w-2 h-2 rounded-sm bg-blue-500/80"></span>
|
|
475
|
+
<span className="w-2 h-2 rounded-sm bg-blue-400"></span>
|
|
476
|
+
<span>more</span>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
480
|
+
<Heatmap data={data.byDay} days={91} />
|
|
481
|
+
</div>
|
|
482
|
+
</div>
|
|
483
|
+
<div>
|
|
484
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Avg by Weekday</h3>
|
|
485
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
486
|
+
<WeekdayChart data={data.byDay} />
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
|
|
491
|
+
{/* ─── Pie charts row ───────────────────────────── */}
|
|
492
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
493
|
+
{modelPie.length > 0 && (
|
|
494
|
+
<div>
|
|
495
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Model</h3>
|
|
496
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
497
|
+
<DonutChart data={modelPie} />
|
|
498
|
+
</div>
|
|
499
|
+
</div>
|
|
500
|
+
)}
|
|
501
|
+
{sourcePie.length > 0 && (
|
|
502
|
+
<div>
|
|
503
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Source</h3>
|
|
504
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3">
|
|
505
|
+
<DonutChart data={sourcePie} />
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
</div>
|
|
510
|
+
|
|
511
|
+
{/* ─── By Project ──────────────────────────────── */}
|
|
135
512
|
{data.byProject.length > 0 && (
|
|
136
513
|
<div>
|
|
137
514
|
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Project</h3>
|
|
138
|
-
<div className="space-y-1.5">
|
|
515
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-3 space-y-1.5">
|
|
139
516
|
{data.byProject.map(p => (
|
|
140
517
|
<div key={p.name} className="flex items-center gap-2 text-[10px]">
|
|
141
518
|
<span className="text-[var(--text-primary)] w-28 truncate shrink-0" title={p.name}>{p.name}</span>
|
|
142
519
|
<Bar value={p.cost} max={maxProjectCost} color="bg-blue-500" />
|
|
143
|
-
<span className="text-[var(--text-primary)] w-16 text-right shrink-0">{formatCost(p.cost)}</span>
|
|
520
|
+
<span className="text-[var(--text-primary)] w-16 text-right shrink-0 font-mono">{formatCost(p.cost)}</span>
|
|
144
521
|
<span className="text-[var(--text-secondary)] w-12 text-right shrink-0">{p.sessions}s</span>
|
|
145
522
|
</div>
|
|
146
523
|
))}
|
|
@@ -148,11 +525,11 @@ export default function UsagePanel() {
|
|
|
148
525
|
</div>
|
|
149
526
|
)}
|
|
150
527
|
|
|
151
|
-
{/* By Model */}
|
|
528
|
+
{/* ─── By Model detailed table ─────────────────── */}
|
|
152
529
|
{data.byModel.length > 0 && (
|
|
153
530
|
<div>
|
|
154
|
-
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">
|
|
155
|
-
<div className="border border-[var(--border)] rounded overflow-hidden">
|
|
531
|
+
<h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">Model Details</h3>
|
|
532
|
+
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
|
156
533
|
<table className="w-full text-[10px]">
|
|
157
534
|
<thead>
|
|
158
535
|
<tr className="bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
|
|
@@ -161,16 +538,20 @@ export default function UsagePanel() {
|
|
|
161
538
|
<th className="text-right px-3 py-1.5">Output</th>
|
|
162
539
|
<th className="text-right px-3 py-1.5">Cost</th>
|
|
163
540
|
<th className="text-right px-3 py-1.5">Msgs</th>
|
|
541
|
+
<th className="text-right px-3 py-1.5">Avg/Msg</th>
|
|
164
542
|
</tr>
|
|
165
543
|
</thead>
|
|
166
544
|
<tbody>
|
|
167
545
|
{data.byModel.map(m => (
|
|
168
546
|
<tr key={m.model} className="border-t border-[var(--border)]/30">
|
|
169
547
|
<td className="px-3 py-1.5 text-[var(--text-primary)]">{m.model}</td>
|
|
170
|
-
<td className="px-3 py-1.5 text-right text-[var(--text-secondary)]">{formatTokens(m.input)}</td>
|
|
171
|
-
<td className="px-3 py-1.5 text-right text-[var(--text-secondary)]">{formatTokens(m.output)}</td>
|
|
172
|
-
<td className="px-3 py-1.5 text-right text-[var(--text-primary)] font-medium">{formatCost(m.cost)}</td>
|
|
548
|
+
<td className="px-3 py-1.5 text-right text-[var(--text-secondary)] font-mono">{formatTokens(m.input)}</td>
|
|
549
|
+
<td className="px-3 py-1.5 text-right text-[var(--text-secondary)] font-mono">{formatTokens(m.output)}</td>
|
|
550
|
+
<td className="px-3 py-1.5 text-right text-[var(--text-primary)] font-medium font-mono">{formatCost(m.cost)}</td>
|
|
173
551
|
<td className="px-3 py-1.5 text-right text-[var(--text-secondary)]">{m.messages}</td>
|
|
552
|
+
<td className="px-3 py-1.5 text-right text-[var(--text-secondary)] font-mono">
|
|
553
|
+
{m.messages > 0 ? `$${(m.cost / m.messages).toFixed(3)}` : '-'}
|
|
554
|
+
</td>
|
|
174
555
|
</tr>
|
|
175
556
|
))}
|
|
176
557
|
</tbody>
|
|
@@ -179,27 +560,28 @@ export default function UsagePanel() {
|
|
|
179
560
|
</div>
|
|
180
561
|
)}
|
|
181
562
|
|
|
182
|
-
{/*
|
|
183
|
-
|
|
184
|
-
<div>
|
|
185
|
-
<
|
|
186
|
-
<div className="
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
563
|
+
{/* ─── Summary stats ────────────────────────────── */}
|
|
564
|
+
<div className="grid grid-cols-3 gap-2 text-[10px]">
|
|
565
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded p-2">
|
|
566
|
+
<div className="text-[8px] text-[var(--text-secondary)] uppercase">Avg per session</div>
|
|
567
|
+
<div className="text-[12px] font-semibold text-[var(--text-primary)]">{formatCost(stats.avgPerSession)}</div>
|
|
568
|
+
</div>
|
|
569
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded p-2">
|
|
570
|
+
<div className="text-[8px] text-[var(--text-secondary)] uppercase">Avg per message</div>
|
|
571
|
+
<div className="text-[12px] font-semibold text-[var(--text-primary)]">${stats.avgPerMsg.toFixed(3)}</div>
|
|
572
|
+
</div>
|
|
573
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded p-2">
|
|
574
|
+
<div className="text-[8px] text-[var(--text-secondary)] uppercase">Sessions / day</div>
|
|
575
|
+
<div className="text-[12px] font-semibold text-[var(--text-primary)]">
|
|
576
|
+
{data.byDay.length > 0 ? (data.total.sessions / data.byDay.length).toFixed(1) : '0'}
|
|
194
577
|
</div>
|
|
195
578
|
</div>
|
|
196
|
-
|
|
579
|
+
</div>
|
|
197
580
|
|
|
198
581
|
{/* Note */}
|
|
199
582
|
<div className="text-[9px] text-[var(--text-secondary)] border-t border-[var(--border)] pt-3">
|
|
200
583
|
Cost estimates based on API pricing (Opus: $15/$75 per M tokens, Sonnet: $3/$15).
|
|
201
|
-
Actual cost may differ with Claude Max/Pro subscription.
|
|
202
|
-
Daily breakdown groups by session completion date.
|
|
584
|
+
Cache reads are ~90% cheaper. Actual cost may differ with Claude Max/Pro subscription.
|
|
203
585
|
</div>
|
|
204
586
|
</div>
|
|
205
587
|
</div>
|
|
@@ -2211,7 +2211,7 @@ function FloatingTerminalInline({ agentLabel, agentIcon, projectPath, agentCliId
|
|
|
2211
2211
|
return <div ref={containerRef} className="w-full h-full" style={{ background: '#0d1117' }} />;
|
|
2212
2212
|
}
|
|
2213
2213
|
|
|
2214
|
-
function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, persistentSession, boundSessionId, initialPos, onSessionReady, onClose }: {
|
|
2214
|
+
function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliCmd: cliCmdProp, cliType, workDir, preferredSessionName, existingSession, resumeMode, resumeSessionId, profileEnv, isPrimary, skipPermissions, persistentSession, boundSessionId, initialPos, docked, onSessionReady, onClose }: {
|
|
2215
2215
|
agentLabel: string;
|
|
2216
2216
|
agentIcon: string;
|
|
2217
2217
|
projectPath: string;
|
|
@@ -2229,6 +2229,7 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2229
2229
|
persistentSession?: boolean;
|
|
2230
2230
|
boundSessionId?: string;
|
|
2231
2231
|
initialPos?: { x: number; y: number };
|
|
2232
|
+
docked?: boolean; // when true, render as grid cell instead of fixed floating window
|
|
2232
2233
|
onSessionReady?: (name: string) => void;
|
|
2233
2234
|
onClose: (killSession: boolean) => void;
|
|
2234
2235
|
}) {
|
|
@@ -2292,11 +2293,36 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2292
2293
|
}
|
|
2293
2294
|
};
|
|
2294
2295
|
|
|
2296
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2297
|
+
const reconnect = () => {
|
|
2298
|
+
if (disposed || reconnectTimer) return;
|
|
2299
|
+
term.write('\r\n\x1b[93m[Reconnecting...]\x1b[0m\r\n');
|
|
2300
|
+
reconnectTimer = setTimeout(() => {
|
|
2301
|
+
reconnectTimer = null;
|
|
2302
|
+
if (disposed) return;
|
|
2303
|
+
const newWs = new WebSocket(getWsUrl());
|
|
2304
|
+
wsRef.current = newWs;
|
|
2305
|
+
const sn = sessionNameRef.current || preferredSessionName;
|
|
2306
|
+
newWs.onopen = () => {
|
|
2307
|
+
newWs.send(JSON.stringify({ type: 'attach', sessionName: sn, cols: term.cols, rows: term.rows }));
|
|
2308
|
+
};
|
|
2309
|
+
newWs.onerror = () => { if (!disposed) reconnect(); };
|
|
2310
|
+
newWs.onclose = () => { if (!disposed) reconnect(); };
|
|
2311
|
+
newWs.onmessage = ws.onmessage;
|
|
2312
|
+
}, 2000);
|
|
2313
|
+
};
|
|
2314
|
+
|
|
2295
2315
|
ws.onerror = () => {
|
|
2296
|
-
if (!disposed)
|
|
2316
|
+
if (!disposed) {
|
|
2317
|
+
term.write('\r\n\x1b[91m[Connection error]\x1b[0m\r\n');
|
|
2318
|
+
reconnect();
|
|
2319
|
+
}
|
|
2297
2320
|
};
|
|
2298
2321
|
ws.onclose = () => {
|
|
2299
|
-
if (!disposed)
|
|
2322
|
+
if (!disposed) {
|
|
2323
|
+
term.write('\r\n\x1b[90m[Disconnected]\x1b[0m\r\n');
|
|
2324
|
+
reconnect();
|
|
2325
|
+
}
|
|
2300
2326
|
};
|
|
2301
2327
|
|
|
2302
2328
|
let launched = false;
|
|
@@ -2390,16 +2416,19 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2390
2416
|
};
|
|
2391
2417
|
|
|
2392
2418
|
term.onData(data => {
|
|
2393
|
-
|
|
2419
|
+
const activeWs = wsRef.current;
|
|
2420
|
+
if (activeWs?.readyState === WebSocket.OPEN) activeWs.send(JSON.stringify({ type: 'input', data }));
|
|
2394
2421
|
});
|
|
2395
2422
|
term.onResize(({ cols, rows }) => {
|
|
2396
|
-
|
|
2423
|
+
const activeWs = wsRef.current;
|
|
2424
|
+
if (activeWs?.readyState === WebSocket.OPEN) activeWs.send(JSON.stringify({ type: 'resize', cols, rows }));
|
|
2397
2425
|
});
|
|
2398
2426
|
|
|
2399
2427
|
return () => {
|
|
2400
2428
|
disposed = true;
|
|
2429
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
2401
2430
|
ro.disconnect();
|
|
2402
|
-
ws.close();
|
|
2431
|
+
(wsRef.current || ws).close();
|
|
2403
2432
|
term.dispose();
|
|
2404
2433
|
};
|
|
2405
2434
|
});
|
|
@@ -2409,13 +2438,16 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2409
2438
|
|
|
2410
2439
|
return (
|
|
2411
2440
|
<div
|
|
2412
|
-
className=
|
|
2413
|
-
|
|
2441
|
+
className={docked
|
|
2442
|
+
? "relative bg-[#0d1117] border border-[#30363d] rounded-lg flex flex-col overflow-hidden w-full h-full"
|
|
2443
|
+
: "fixed z-50 bg-[#0d1117] border border-[#30363d] rounded-lg shadow-2xl flex flex-col overflow-hidden"
|
|
2444
|
+
}
|
|
2445
|
+
style={docked ? undefined : { left: pos.x, top: pos.y, width: size.w, height: size.h }}
|
|
2414
2446
|
>
|
|
2415
|
-
{/*
|
|
2447
|
+
{/* Header — draggable in floating mode, static in docked mode */}
|
|
2416
2448
|
<div
|
|
2417
|
-
className=
|
|
2418
|
-
onMouseDown={(e) => {
|
|
2449
|
+
className={`flex items-center gap-2 px-3 py-1.5 bg-[#161b22] border-b border-[#30363d] shrink-0 select-none ${docked ? '' : 'cursor-move'}`}
|
|
2450
|
+
onMouseDown={docked ? undefined : (e) => {
|
|
2419
2451
|
e.preventDefault();
|
|
2420
2452
|
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
|
|
2421
2453
|
setUserDragged(true);
|
|
@@ -2429,34 +2461,36 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2429
2461
|
}}
|
|
2430
2462
|
>
|
|
2431
2463
|
<span className="text-sm">{agentIcon}</span>
|
|
2432
|
-
<span className="text-[11px] font-semibold text-white">{agentLabel}</span>
|
|
2433
|
-
<span className="text-[8px] text-gray-500">⌨️ manual terminal</span>
|
|
2434
|
-
<button onClick={() => setShowCloseDialog(true)} className="ml-auto text-gray-500 hover:text-white text-sm">✕</button>
|
|
2464
|
+
<span className="text-[11px] font-semibold text-white truncate">{agentLabel}</span>
|
|
2465
|
+
{!docked && <span className="text-[8px] text-gray-500">⌨️ manual terminal</span>}
|
|
2466
|
+
<button onClick={() => setShowCloseDialog(true)} className="ml-auto text-gray-500 hover:text-white text-sm shrink-0">✕</button>
|
|
2435
2467
|
</div>
|
|
2436
2468
|
|
|
2437
2469
|
{/* Terminal */}
|
|
2438
2470
|
<div ref={containerRef} className="flex-1 min-h-0" style={{ background: '#0d1117' }} />
|
|
2439
2471
|
|
|
2440
|
-
{/* Resize handle */}
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
e
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
<
|
|
2458
|
-
|
|
2459
|
-
|
|
2472
|
+
{/* Resize handle — floating mode only */}
|
|
2473
|
+
{!docked && (
|
|
2474
|
+
<div
|
|
2475
|
+
className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize"
|
|
2476
|
+
onMouseDown={(e) => {
|
|
2477
|
+
e.preventDefault();
|
|
2478
|
+
e.stopPropagation();
|
|
2479
|
+
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
|
|
2480
|
+
const onMove = (ev: MouseEvent) => {
|
|
2481
|
+
if (!resizeRef.current) return;
|
|
2482
|
+
setSize({ w: Math.max(400, resizeRef.current.origW + ev.clientX - resizeRef.current.startX), h: Math.max(250, resizeRef.current.origH + ev.clientY - resizeRef.current.startY) });
|
|
2483
|
+
};
|
|
2484
|
+
const onUp = () => { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
|
2485
|
+
window.addEventListener('mousemove', onMove);
|
|
2486
|
+
window.addEventListener('mouseup', onUp);
|
|
2487
|
+
}}
|
|
2488
|
+
>
|
|
2489
|
+
<svg viewBox="0 0 16 16" className="w-3 h-3 absolute bottom-0.5 right-0.5 text-gray-600">
|
|
2490
|
+
<path d="M14 14L8 14L14 8Z" fill="currentColor" />
|
|
2491
|
+
</svg>
|
|
2492
|
+
</div>
|
|
2493
|
+
)}
|
|
2460
2494
|
|
|
2461
2495
|
{/* Close confirmation dialog */}
|
|
2462
2496
|
{showCloseDialog && (
|
|
@@ -3417,6 +3451,27 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3417
3451
|
};
|
|
3418
3452
|
const [floatingTerminals, setFloatingTerminals] = useState<{ agentId: string; label: string; icon: string; cliId: string; cliCmd?: string; cliType?: string; workDir?: string; tmuxSession?: string; sessionName: string; resumeMode?: boolean; resumeSessionId?: string; profileEnv?: Record<string, string>; isPrimary?: boolean; skipPermissions?: boolean; persistentSession?: boolean; boundSessionId?: string; initialPos?: { x: number; y: number } }[]>([]);
|
|
3419
3453
|
const [termPicker, setTermPicker] = useState<{ agent: AgentConfig; sessName: string; workDir?: string; supportsSession?: boolean; currentSessionId: string | null; initialPos?: { x: number; y: number } } | null>(null);
|
|
3454
|
+
// Terminal layout: floating (draggable windows) or docked (fixed grid at bottom)
|
|
3455
|
+
const [terminalLayout, setTerminalLayout] = useState<'floating' | 'docked'>(() => {
|
|
3456
|
+
if (typeof window === 'undefined') return 'floating';
|
|
3457
|
+
return (localStorage.getItem('forge.termLayout') as 'floating' | 'docked') || 'floating';
|
|
3458
|
+
});
|
|
3459
|
+
const [dockColumns, setDockColumns] = useState<number>(() => {
|
|
3460
|
+
if (typeof window === 'undefined') return 2;
|
|
3461
|
+
return parseInt(localStorage.getItem('forge.termDockCols') || '2');
|
|
3462
|
+
});
|
|
3463
|
+
const [dockHeight, setDockHeight] = useState<number>(() => {
|
|
3464
|
+
if (typeof window === 'undefined') return 320;
|
|
3465
|
+
return parseInt(localStorage.getItem('forge.termDockHeight') || '320');
|
|
3466
|
+
});
|
|
3467
|
+
const updateTerminalLayout = (l: 'floating' | 'docked') => {
|
|
3468
|
+
setTerminalLayout(l);
|
|
3469
|
+
if (typeof window !== 'undefined') localStorage.setItem('forge.termLayout', l);
|
|
3470
|
+
};
|
|
3471
|
+
const updateDockColumns = (n: number) => {
|
|
3472
|
+
setDockColumns(n);
|
|
3473
|
+
if (typeof window !== 'undefined') localStorage.setItem('forge.termDockCols', String(n));
|
|
3474
|
+
};
|
|
3420
3475
|
|
|
3421
3476
|
// Expose focusAgent to parent
|
|
3422
3477
|
useImperativeHandle(ref, () => ({
|
|
@@ -3742,6 +3797,32 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3742
3797
|
</>
|
|
3743
3798
|
)}
|
|
3744
3799
|
<div className="ml-auto flex items-center gap-2">
|
|
3800
|
+
{/* Terminal layout switcher */}
|
|
3801
|
+
<div className="flex items-center gap-0.5 px-1 py-0.5 rounded border border-[#30363d] bg-[#0d1117]">
|
|
3802
|
+
<button
|
|
3803
|
+
onClick={() => updateTerminalLayout('floating')}
|
|
3804
|
+
className={`text-[8px] px-1.5 py-0.5 rounded ${terminalLayout === 'floating' ? 'bg-[#58a6ff]/20 text-[#58a6ff]' : 'text-gray-500 hover:text-white'}`}
|
|
3805
|
+
title="Floating terminals (draggable windows)"
|
|
3806
|
+
>⧉ Float</button>
|
|
3807
|
+
<button
|
|
3808
|
+
onClick={() => updateTerminalLayout('docked')}
|
|
3809
|
+
className={`text-[8px] px-1.5 py-0.5 rounded ${terminalLayout === 'docked' ? 'bg-[#58a6ff]/20 text-[#58a6ff]' : 'text-gray-500 hover:text-white'}`}
|
|
3810
|
+
title="Docked terminals (bottom grid)"
|
|
3811
|
+
>▤ Dock</button>
|
|
3812
|
+
{terminalLayout === 'docked' && (
|
|
3813
|
+
<>
|
|
3814
|
+
<span className="w-px h-3 bg-[#30363d] mx-0.5" />
|
|
3815
|
+
{[1, 2, 3, 4].map(n => (
|
|
3816
|
+
<button
|
|
3817
|
+
key={n}
|
|
3818
|
+
onClick={() => updateDockColumns(n)}
|
|
3819
|
+
className={`text-[8px] px-1 py-0.5 rounded ${dockColumns === n ? 'bg-[#58a6ff]/20 text-[#58a6ff]' : 'text-gray-500 hover:text-white'}`}
|
|
3820
|
+
title={`${n} column${n > 1 ? 's' : ''}`}
|
|
3821
|
+
>{n}</button>
|
|
3822
|
+
))}
|
|
3823
|
+
</>
|
|
3824
|
+
)}
|
|
3825
|
+
</div>
|
|
3745
3826
|
<select value={mascotTheme} onChange={e => updateMascotTheme(e.target.value as MascotTheme)}
|
|
3746
3827
|
className="text-[8px] px-1.5 py-0.5 rounded border border-[#30363d] bg-[#0d1117] text-gray-500 hover:text-white hover:border-[#58a6ff]/60 cursor-pointer focus:outline-none"
|
|
3747
3828
|
title="Mascot theme">
|
|
@@ -3983,8 +4064,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3983
4064
|
/>
|
|
3984
4065
|
)}
|
|
3985
4066
|
|
|
3986
|
-
{/*
|
|
3987
|
-
{floatingTerminals.map(ft => (
|
|
4067
|
+
{/* Terminals — floating (draggable windows) or docked (bottom grid) */}
|
|
4068
|
+
{terminalLayout === 'floating' && floatingTerminals.map(ft => (
|
|
3988
4069
|
<FloatingTerminal
|
|
3989
4070
|
key={ft.agentId}
|
|
3990
4071
|
agentLabel={ft.label}
|
|
@@ -4015,6 +4096,67 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
4015
4096
|
/>
|
|
4016
4097
|
))}
|
|
4017
4098
|
|
|
4099
|
+
{/* Docked terminals — bottom panel with grid layout */}
|
|
4100
|
+
{terminalLayout === 'docked' && floatingTerminals.length > 0 && (
|
|
4101
|
+
<div
|
|
4102
|
+
className="fixed bottom-0 left-0 right-0 z-40 bg-[#0a0e14] border-t border-[#30363d] flex flex-col"
|
|
4103
|
+
style={{ height: dockHeight }}
|
|
4104
|
+
>
|
|
4105
|
+
{/* Resize handle */}
|
|
4106
|
+
<div
|
|
4107
|
+
className="h-1 bg-[#30363d] hover:bg-[var(--accent)] cursor-ns-resize shrink-0"
|
|
4108
|
+
onMouseDown={(e) => {
|
|
4109
|
+
e.preventDefault();
|
|
4110
|
+
const startY = e.clientY;
|
|
4111
|
+
const startH = dockHeight;
|
|
4112
|
+
const onMove = (ev: MouseEvent) => {
|
|
4113
|
+
const newH = Math.max(200, Math.min(window.innerHeight - 100, startH - (ev.clientY - startY)));
|
|
4114
|
+
setDockHeight(newH);
|
|
4115
|
+
};
|
|
4116
|
+
const onUp = () => {
|
|
4117
|
+
window.removeEventListener('mousemove', onMove);
|
|
4118
|
+
window.removeEventListener('mouseup', onUp);
|
|
4119
|
+
if (typeof window !== 'undefined') localStorage.setItem('forge.termDockHeight', String(dockHeight));
|
|
4120
|
+
};
|
|
4121
|
+
window.addEventListener('mousemove', onMove);
|
|
4122
|
+
window.addEventListener('mouseup', onUp);
|
|
4123
|
+
}}
|
|
4124
|
+
/>
|
|
4125
|
+
<div className="grid gap-1 p-1 flex-1 min-h-0" style={{ gridTemplateColumns: `repeat(${Math.min(floatingTerminals.length, dockColumns)}, minmax(0, 1fr))` }}>
|
|
4126
|
+
{floatingTerminals.map(ft => (
|
|
4127
|
+
<FloatingTerminal
|
|
4128
|
+
key={ft.agentId}
|
|
4129
|
+
agentLabel={ft.label}
|
|
4130
|
+
agentIcon={ft.icon}
|
|
4131
|
+
projectPath={projectPath}
|
|
4132
|
+
agentCliId={ft.cliId}
|
|
4133
|
+
cliCmd={ft.cliCmd}
|
|
4134
|
+
cliType={ft.cliType}
|
|
4135
|
+
workDir={ft.workDir}
|
|
4136
|
+
preferredSessionName={ft.sessionName}
|
|
4137
|
+
existingSession={ft.tmuxSession}
|
|
4138
|
+
resumeMode={ft.resumeMode}
|
|
4139
|
+
resumeSessionId={ft.resumeSessionId}
|
|
4140
|
+
profileEnv={ft.profileEnv}
|
|
4141
|
+
isPrimary={ft.isPrimary}
|
|
4142
|
+
skipPermissions={ft.skipPermissions}
|
|
4143
|
+
persistentSession={ft.persistentSession}
|
|
4144
|
+
boundSessionId={ft.boundSessionId}
|
|
4145
|
+
docked
|
|
4146
|
+
onSessionReady={(name) => {
|
|
4147
|
+
if (workspaceId) wsApi(workspaceId, 'set_tmux_session', { agentId: ft.agentId, sessionName: name });
|
|
4148
|
+
setFloatingTerminals(prev => prev.map(t => t.agentId === ft.agentId ? { ...t, tmuxSession: name } : t));
|
|
4149
|
+
}}
|
|
4150
|
+
onClose={(killSession) => {
|
|
4151
|
+
setFloatingTerminals(prev => prev.filter(t => t.agentId !== ft.agentId));
|
|
4152
|
+
if (workspaceId) wsApi(workspaceId, 'close_terminal', { agentId: ft.agentId, kill: killSession });
|
|
4153
|
+
}}
|
|
4154
|
+
/>
|
|
4155
|
+
))}
|
|
4156
|
+
</div>
|
|
4157
|
+
</div>
|
|
4158
|
+
)}
|
|
4159
|
+
|
|
4018
4160
|
{/* User input request from agent (via bus) */}
|
|
4019
4161
|
{userInputRequest && workspaceId && (
|
|
4020
4162
|
<RunPromptDialog
|
package/lib/usage-scanner.ts
CHANGED
|
@@ -180,10 +180,10 @@ export function queryUsage(opts: {
|
|
|
180
180
|
source?: string;
|
|
181
181
|
model?: string;
|
|
182
182
|
}): {
|
|
183
|
-
total: { input: number; output: number; cost: number; sessions: number; messages: number };
|
|
183
|
+
total: { input: number; output: number; cacheRead: number; cacheCreate: number; cost: number; sessions: number; messages: number };
|
|
184
184
|
byProject: { name: string; input: number; output: number; cost: number; sessions: number }[];
|
|
185
185
|
byModel: { model: string; input: number; output: number; cost: number; messages: number }[];
|
|
186
|
-
byDay: { date: string; input: number; output: number; cost: number }[];
|
|
186
|
+
byDay: { date: string; input: number; output: number; cacheRead: number; cacheCreate: number; cost: number; messages: number }[];
|
|
187
187
|
bySource: { source: string; input: number; output: number; cost: number; messages: number }[];
|
|
188
188
|
} {
|
|
189
189
|
let where = '1=1';
|
|
@@ -202,6 +202,7 @@ export function queryUsage(opts: {
|
|
|
202
202
|
|
|
203
203
|
const totalRow = db().prepare(`
|
|
204
204
|
SELECT COALESCE(SUM(input_tokens), 0) as input, COALESCE(SUM(output_tokens), 0) as output,
|
|
205
|
+
COALESCE(SUM(cache_read_tokens), 0) as cacheRead, COALESCE(SUM(cache_create_tokens), 0) as cacheCreate,
|
|
205
206
|
COALESCE(SUM(cost_usd), 0) as cost, COUNT(DISTINCT session_id) as sessions,
|
|
206
207
|
COALESCE(SUM(message_count), 0) as messages
|
|
207
208
|
FROM token_usage WHERE ${where}
|
|
@@ -226,11 +227,15 @@ export function queryUsage(opts: {
|
|
|
226
227
|
}));
|
|
227
228
|
|
|
228
229
|
const byDay = (db().prepare(`
|
|
229
|
-
SELECT day as date, SUM(input_tokens) as input, SUM(output_tokens) as output,
|
|
230
|
+
SELECT day as date, SUM(input_tokens) as input, SUM(output_tokens) as output,
|
|
231
|
+
SUM(cache_read_tokens) as cacheRead, SUM(cache_create_tokens) as cacheCreate,
|
|
232
|
+
SUM(cost_usd) as cost, SUM(message_count) as messages
|
|
230
233
|
FROM token_usage WHERE ${where} AND day != 'unknown'
|
|
231
234
|
GROUP BY day ORDER BY day DESC LIMIT 30
|
|
232
235
|
`).all(...params) as any[]).map(r => ({
|
|
233
|
-
date: r.date, input: r.input, output: r.output,
|
|
236
|
+
date: r.date, input: r.input, output: r.output,
|
|
237
|
+
cacheRead: r.cacheRead || 0, cacheCreate: r.cacheCreate || 0,
|
|
238
|
+
cost: +r.cost.toFixed(4), messages: r.messages || 0,
|
|
234
239
|
}));
|
|
235
240
|
|
|
236
241
|
const bySource = (db().prepare(`
|
|
@@ -243,7 +248,11 @@ export function queryUsage(opts: {
|
|
|
243
248
|
}));
|
|
244
249
|
|
|
245
250
|
return {
|
|
246
|
-
total: {
|
|
251
|
+
total: {
|
|
252
|
+
input: totalRow.input, output: totalRow.output,
|
|
253
|
+
cacheRead: totalRow.cacheRead || 0, cacheCreate: totalRow.cacheCreate || 0,
|
|
254
|
+
cost: +totalRow.cost.toFixed(4), sessions: totalRow.sessions, messages: totalRow.messages,
|
|
255
|
+
},
|
|
247
256
|
byProject, byModel, byDay, bySource,
|
|
248
257
|
};
|
|
249
258
|
}
|
|
@@ -1175,18 +1175,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1175
1175
|
entry.worker = null;
|
|
1176
1176
|
}
|
|
1177
1177
|
|
|
1178
|
-
//
|
|
1179
|
-
// This is a Claude Code slash command — no LLM call, just a local context reset.
|
|
1180
|
-
if (entry.state.tmuxSession && entry.config.role?.trim()) {
|
|
1181
|
-
let isAttached = false;
|
|
1182
|
-
try {
|
|
1183
|
-
const info = execSync(`tmux display-message -t "${entry.state.tmuxSession}" -p "#{session_attached}" 2>/dev/null`, { timeout: 3000, encoding: 'utf-8' }).trim();
|
|
1184
|
-
isAttached = info !== '0';
|
|
1185
|
-
} catch {}
|
|
1186
|
-
if (isAttached) {
|
|
1187
|
-
try { this.injectIntoSession(id, '/clear'); } catch {}
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1178
|
+
// Do NOT send /clear — preserves user's conversation context in attached terminals
|
|
1190
1179
|
this.roleInjectState.delete(id);
|
|
1191
1180
|
|
|
1192
1181
|
// 3. Kill tmux session (skip if user is attached to it)
|
package/package.json
CHANGED