@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 CHANGED
@@ -1,17 +1,20 @@
1
- # Forge v0.5.28
1
+ # Forge v0.5.30
2
2
 
3
- Released: 2026-04-09
3
+ Released: 2026-04-10
4
4
 
5
- ## Changes since v0.5.27
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: restore notification polling for Telegram, add Suspense wrappers
14
+ - fix: preserve workspace terminal state across tab/project switches
9
15
 
10
- ### Performance
11
- - perf: notifications fetch on-demand instead of polling
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.27...v0.5.28
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-[9px] px-2 py-0.5 rounded transition-colors ${
573
- projectTab === 'code' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
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-[9px] px-2 py-0.5 rounded transition-colors ${
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-[9px] px-2 py-0.5 rounded transition-colors ${
585
- projectTab === 'sessions' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
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-[9px] px-2 py-0.5 rounded transition-colors ${
591
- projectTab === 'skills' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
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-[8px] text-[var(--text-secondary)]">({projectSkills.length})</span>}
596
- {projectSkills.some(s => s.hasUpdate) && <span className="ml-1 text-[8px] text-[var(--yellow)]">!</span>}
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-[9px] px-2 py-0.5 rounded transition-colors ${
601
- projectTab === 'claudemd' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
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-[8px] text-[var(--green)]">•</span>}
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-[9px] px-2 py-0.5 rounded transition-colors ${
610
- projectTab === 'pipelines' ? 'bg-[var(--bg-secondary)] text-[var(--text-primary)] shadow-sm' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
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-[8px] text-[var(--text-secondary)]">({pipelineBindings.length})</span>}
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
- {projectTab === 'workspace' && (
647
- <div className="flex-1 flex min-h-0 overflow-hidden">
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 = 5;
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
- // Simple bar component
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
- const maxDayCost = data.byDay.length > 0 ? Math.max(...data.byDay.map(d => d.cost)) : 1;
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-6">
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">Input Tokens</div>
105
- <div className="text-lg font-bold text-[var(--text-primary)]">{formatTokens(data.total.input)}</div>
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">Output Tokens</div>
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">Sessions</div>
113
- <div className="text-lg font-bold text-[var(--text-primary)]">{data.total.sessions}</div>
114
- <div className="text-[9px] text-[var(--text-secondary)]">{data.total.messages} messages</div>
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
- {/* By Day bar chart */}
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">Daily Cost</h3>
122
- <div className="space-y-1">
123
- {data.byDay.slice(0, 14).reverse().map(d => (
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
- {/* By Project */}
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">By Model</h3>
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
- {/* By Source */}
183
- {data.bySource.length > 0 && (
184
- <div>
185
- <h3 className="text-[11px] font-semibold text-[var(--text-secondary)] uppercase mb-2">By Source</h3>
186
- <div className="flex gap-3 flex-wrap">
187
- {data.bySource.map(s => (
188
- <div key={s.source} className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-3 py-2 min-w-[120px]">
189
- <div className="text-[9px] text-[var(--text-secondary)] uppercase">{s.source}</div>
190
- <div className="text-sm font-bold text-[var(--text-primary)]">{formatCost(s.cost)}</div>
191
- <div className="text-[9px] text-[var(--text-secondary)]">{s.messages} msgs</div>
192
- </div>
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) term.write('\r\n\x1b[91m[Connection error]\x1b[0m\r\n');
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) term.write('\r\n\x1b[90m[Disconnected]\x1b[0m\r\n');
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
- if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data }));
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
- if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows }));
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="fixed z-50 bg-[#0d1117] border border-[#30363d] rounded-lg shadow-2xl flex flex-col overflow-hidden"
2413
- style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
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
- {/* Draggable header */}
2447
+ {/* Header draggable in floating mode, static in docked mode */}
2416
2448
  <div
2417
- className="flex items-center gap-2 px-3 py-1.5 bg-[#161b22] border-b border-[#30363d] cursor-move shrink-0 select-none"
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
- <div
2442
- className="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize"
2443
- onMouseDown={(e) => {
2444
- e.preventDefault();
2445
- e.stopPropagation();
2446
- resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
2447
- const onMove = (ev: MouseEvent) => {
2448
- if (!resizeRef.current) return;
2449
- 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) });
2450
- };
2451
- const onUp = () => { resizeRef.current = null; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
2452
- window.addEventListener('mousemove', onMove);
2453
- window.addEventListener('mouseup', onUp);
2454
- }}
2455
- >
2456
- <svg viewBox="0 0 16 16" className="w-3 h-3 absolute bottom-0.5 right-0.5 text-gray-600">
2457
- <path d="M14 14L8 14L14 8Z" fill="currentColor" />
2458
- </svg>
2459
- </div>
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
- {/* Floating terminals positioned near their agent node */}
3987
- {floatingTerminals.map(ft => (
4067
+ {/* Terminalsfloating (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
@@ -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, SUM(cost_usd) as cost
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, cost: +r.cost.toFixed(4),
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: { input: totalRow.input, output: totalRow.output, cost: +totalRow.cost.toFixed(4), sessions: totalRow.sessions, messages: totalRow.messages },
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
- // 2b. For persistent sessions: send /clear to reset Claude's context if user is attached.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.28",
3
+ "version": "0.5.30",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {