@shanepadgett/tau-agent 0.39.1 → 0.40.0
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/extensions/auto-compact/README.md +1 -1
- package/extensions/auto-compact/index.ts +20 -0
- package/extensions/cost-report/README.md +32 -0
- package/extensions/cost-report/analyze.ts +423 -0
- package/extensions/cost-report/html.ts +474 -0
- package/extensions/cost-report/index.ts +181 -0
- package/extensions/cost-report/panel.ts +64 -0
- package/extensions/cost-report/range.ts +145 -0
- package/extensions/cost-report/types.ts +93 -0
- package/extensions/silent-command-runner/README.md +2 -0
- package/extensions/silent-command-runner/index.ts +50 -11
- package/extensions/tau-help/help.md +4 -0
- package/package.json +2 -2
- package/shared/events.ts +9 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { daysInMonth } from "./range.ts";
|
|
2
|
+
import type { CostReport, DayCost } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
function escapeHtml(value: string): string {
|
|
5
|
+
return value
|
|
6
|
+
.replaceAll("&", "&")
|
|
7
|
+
.replaceAll("<", "<")
|
|
8
|
+
.replaceAll(">", ">")
|
|
9
|
+
.replaceAll('"', """)
|
|
10
|
+
.replaceAll("'", "'");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function money(value: number): string {
|
|
14
|
+
return new Intl.NumberFormat(undefined, {
|
|
15
|
+
style: "currency",
|
|
16
|
+
currency: "USD",
|
|
17
|
+
minimumFractionDigits: 2,
|
|
18
|
+
maximumFractionDigits: 2,
|
|
19
|
+
}).format(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function tokens(value: number): string {
|
|
23
|
+
if (value < 1000) return String(Math.round(value));
|
|
24
|
+
if (value < 10_000) return `${(value / 1000).toFixed(1)}K`;
|
|
25
|
+
if (value < 1_000_000) return `${Math.round(value / 1000)}K`;
|
|
26
|
+
if (value < 10_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
|
27
|
+
return `${Math.round(value / 1_000_000)}M`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function percent(part: number, whole: number): string {
|
|
31
|
+
if (whole <= 0) return "0%";
|
|
32
|
+
return `${Math.round((part / whole) * 100)}%`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function heatClass(amount: number): string {
|
|
36
|
+
if (amount <= 0) return "bg-stone-200 dark:bg-stone-800";
|
|
37
|
+
if (amount < 10) return "bg-green-600";
|
|
38
|
+
if (amount < 18) return "bg-lime-500";
|
|
39
|
+
if (amount < 28) return "bg-yellow-400";
|
|
40
|
+
if (amount < 38) return "bg-yellow-500";
|
|
41
|
+
if (amount < 45) return "bg-amber-500";
|
|
42
|
+
if (amount < 50) return "bg-orange-500";
|
|
43
|
+
if (amount < 100) return "bg-red-600";
|
|
44
|
+
return "bg-red-700";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatGenerated(ms: number): string {
|
|
48
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
49
|
+
dateStyle: "medium",
|
|
50
|
+
timeStyle: "short",
|
|
51
|
+
}).format(new Date(ms));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function dayButton(day: DayCost, fill: boolean): string {
|
|
55
|
+
const tip = `${escapeHtml(day.label)} · ${money(day.cost)}`;
|
|
56
|
+
const fire = day.cost > 100;
|
|
57
|
+
const weekend = day.weekend ? " is-weekend" : "";
|
|
58
|
+
const sizeClass = fill ? "w-full" : "flex-1";
|
|
59
|
+
const aria = [day.label + " · " + money(day.cost), day.weekend ? "weekend" : "", fire ? "high spend" : ""]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(", ");
|
|
62
|
+
return `<button type="button" class="day-cell relative h-full min-w-0 ${sizeClass} rounded-sm border border-stone-900/10 dark:border-white/10 ${heatClass(day.cost)}${weekend}" role="listitem" aria-label="${escapeHtml(aria)}">${
|
|
63
|
+
fire ? `<span class="day-fire" aria-hidden="true">🔥</span>` : ""
|
|
64
|
+
}<span class="day-tip" aria-hidden="true">${tip}</span></button>`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function shareBar(share: number): string {
|
|
68
|
+
const width = Math.max(0, Math.min(100, Math.round(share * 100)));
|
|
69
|
+
return `<span class="mt-1 block h-1 rounded-sm bg-stone-200 dark:bg-stone-700" aria-hidden="true"><span class="block h-full rounded-sm bg-stone-500 dark:bg-stone-400" style="width:${width}%"></span></span>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderDayRow(days: DayCost[]): string {
|
|
73
|
+
return `<div class="flex h-10 w-full gap-1" role="list" aria-label="Spend by day">${days.map((day) => dayButton(day, false)).join("")}</div>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderYtd(days: DayCost[], year: number): string {
|
|
77
|
+
const byMonth = new Map<number, DayCost[]>();
|
|
78
|
+
for (const day of days) {
|
|
79
|
+
const month = new Date(day.timestampMs).getMonth();
|
|
80
|
+
const list = byMonth.get(month) ?? [];
|
|
81
|
+
list.push(day);
|
|
82
|
+
byMonth.set(month, list);
|
|
83
|
+
}
|
|
84
|
+
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
85
|
+
const rows: string[] = [];
|
|
86
|
+
const maxMonth = Math.max(0, ...byMonth.keys());
|
|
87
|
+
for (let month = 0; month <= maxMonth; month += 1) {
|
|
88
|
+
const monthDays = byMonth.get(month) ?? [];
|
|
89
|
+
const byDate = new Map(monthDays.map((day) => [new Date(day.timestampMs).getDate(), day]));
|
|
90
|
+
const cells: string[] = [];
|
|
91
|
+
const length = daysInMonth(year, month);
|
|
92
|
+
for (let date = 1; date <= 31; date += 1) {
|
|
93
|
+
if (date <= length) {
|
|
94
|
+
const existing = byDate.get(date);
|
|
95
|
+
if (existing) {
|
|
96
|
+
cells.push(dayButton(existing, true));
|
|
97
|
+
} else {
|
|
98
|
+
const ts = new Date(year, month, date).getTime();
|
|
99
|
+
cells.push(
|
|
100
|
+
dayButton(
|
|
101
|
+
{
|
|
102
|
+
dateKey: `${year}-${String(month + 1).padStart(2, "0")}-${String(date).padStart(2, "0")}`,
|
|
103
|
+
label: `${date} ${monthNames[month]}`,
|
|
104
|
+
timestampMs: ts,
|
|
105
|
+
cost: 0,
|
|
106
|
+
weekend: new Date(ts).getDay() === 0 || new Date(ts).getDay() === 6,
|
|
107
|
+
},
|
|
108
|
+
true,
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
cells.push(`<span class="min-w-0" aria-hidden="true"></span>`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
rows.push(`<div class="grid grid-cols-[2.5rem_1fr] items-center gap-2" role="listitem">
|
|
117
|
+
<div class="text-xs text-stone-600 dark:text-stone-400">${monthNames[month]}</div>
|
|
118
|
+
<div class="day-grid-31 h-8 w-full" role="list" aria-label="${monthNames[month]} spend">${cells.join("")}</div>
|
|
119
|
+
</div>`);
|
|
120
|
+
}
|
|
121
|
+
return `<div class="space-y-1" role="list" aria-label="Year to date spend by month">${rows.join("")}</div>`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function renderCostReportHtml(report: CostReport): string {
|
|
125
|
+
const scopeLabel = report.scope === "project" ? "Current project" : "All sessions";
|
|
126
|
+
const scopeClass = report.scope === "project" ? "scope-project" : "scope-all";
|
|
127
|
+
const totalShare = report.totalCost;
|
|
128
|
+
const subagentShareBase = report.subagentCost;
|
|
129
|
+
|
|
130
|
+
const modelRows = report.models
|
|
131
|
+
.map((model) => {
|
|
132
|
+
const share = totalShare > 0 ? model.cost / totalShare : 0;
|
|
133
|
+
return `<tr>
|
|
134
|
+
<td class="px-3 py-2">${escapeHtml(model.provider)} / ${escapeHtml(model.model)}</td>
|
|
135
|
+
<td class="px-3 py-2 text-right">${money(model.cost)}</td>
|
|
136
|
+
<td class="px-3 py-2 text-right">${percent(model.cost, totalShare)}</td>
|
|
137
|
+
<td class="px-3 py-2">${shareBar(share)}</td>
|
|
138
|
+
<td class="px-3 py-2 text-right">${model.sessions}</td>
|
|
139
|
+
</tr>`;
|
|
140
|
+
})
|
|
141
|
+
.join("");
|
|
142
|
+
|
|
143
|
+
const subagentRows =
|
|
144
|
+
report.subagents.length === 0
|
|
145
|
+
? `<tr><td class="px-3 py-2 text-stone-600 dark:text-stone-400" colspan="5">No subagent spend in this window.</td></tr>`
|
|
146
|
+
: report.subagents
|
|
147
|
+
.map((agent) => {
|
|
148
|
+
const share = subagentShareBase > 0 ? agent.cost / subagentShareBase : 0;
|
|
149
|
+
return `<tr>
|
|
150
|
+
<td class="px-3 py-2">${escapeHtml(agent.agent)}</td>
|
|
151
|
+
<td class="px-3 py-2 text-right">${money(agent.cost)}</td>
|
|
152
|
+
<td class="px-3 py-2 text-right">${percent(agent.cost, subagentShareBase)}</td>
|
|
153
|
+
<td class="px-3 py-2">${shareBar(share)}</td>
|
|
154
|
+
<td class="px-3 py-2 text-right">${agent.calls}</td>
|
|
155
|
+
</tr>`;
|
|
156
|
+
})
|
|
157
|
+
.join("");
|
|
158
|
+
|
|
159
|
+
const projectRows = report.projects
|
|
160
|
+
.map((project) => {
|
|
161
|
+
const share = totalShare > 0 ? project.cost / totalShare : 0;
|
|
162
|
+
return `<tr data-project-key="${escapeHtml(project.key)}">
|
|
163
|
+
<td class="px-3 py-2">${escapeHtml(project.label)}</td>
|
|
164
|
+
<td class="px-3 py-2 text-right">${money(project.cost)}</td>
|
|
165
|
+
<td class="px-3 py-2 text-right">${percent(project.cost, totalShare)}</td>
|
|
166
|
+
<td class="px-3 py-2">${shareBar(share)}</td>
|
|
167
|
+
<td class="px-3 py-2 text-right">${project.sessions}</td>
|
|
168
|
+
</tr>`;
|
|
169
|
+
})
|
|
170
|
+
.join("");
|
|
171
|
+
|
|
172
|
+
const sessionRows = report.sessions
|
|
173
|
+
.map((session) => {
|
|
174
|
+
const models = session.models.map((model) => `${escapeHtml(model.label)} (${money(model.cost)})`).join(", ");
|
|
175
|
+
return `<tr data-project-key="${escapeHtml(session.projectKey)}">
|
|
176
|
+
<td class="px-3 py-2">
|
|
177
|
+
<span class="font-medium">${escapeHtml(session.name)}</span>
|
|
178
|
+
<span class="mt-0.5 block text-xs text-stone-600 dark:text-stone-400">${escapeHtml(
|
|
179
|
+
formatGenerated(session.startedAtMs),
|
|
180
|
+
)}</span>
|
|
181
|
+
</td>
|
|
182
|
+
<td class="project-col px-3 py-2">${escapeHtml(session.projectLabel)}</td>
|
|
183
|
+
<td class="px-3 py-2 font-mono text-xs text-stone-600 dark:text-stone-400">${models || "—"}</td>
|
|
184
|
+
<td class="px-3 py-2 text-right tabular">${tokens(session.tokens)}</td>
|
|
185
|
+
<td class="px-3 py-2 text-right tabular">${money(session.cost)}</td>
|
|
186
|
+
</tr>`;
|
|
187
|
+
})
|
|
188
|
+
.join("");
|
|
189
|
+
|
|
190
|
+
const projectOptions = [
|
|
191
|
+
`<option value="">All projects</option>`,
|
|
192
|
+
...report.projects.map(
|
|
193
|
+
(project) => `<option value="${escapeHtml(project.key)}">${escapeHtml(project.label)}</option>`,
|
|
194
|
+
),
|
|
195
|
+
].join("");
|
|
196
|
+
|
|
197
|
+
const dailyBody =
|
|
198
|
+
report.range.layout === "ytd"
|
|
199
|
+
? renderYtd(report.days, report.range.year ?? new Date(report.generatedAtMs).getFullYear())
|
|
200
|
+
: renderDayRow(report.days);
|
|
201
|
+
|
|
202
|
+
const dayCountLabel =
|
|
203
|
+
report.range.layout === "ytd" ? `${report.days.length} days · by month` : `${report.days.length} days in range`;
|
|
204
|
+
|
|
205
|
+
return `<!doctype html>
|
|
206
|
+
<html lang="en" class="h-full">
|
|
207
|
+
<head>
|
|
208
|
+
<meta charset="utf-8" />
|
|
209
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
210
|
+
<title>Cost report · ${escapeHtml(report.range.label)} · ${escapeHtml(scopeLabel)}</title>
|
|
211
|
+
<script>
|
|
212
|
+
(() => {
|
|
213
|
+
const key = "tau-cost-report-theme";
|
|
214
|
+
const stored = localStorage.getItem(key);
|
|
215
|
+
const dark =
|
|
216
|
+
stored === "dark" ||
|
|
217
|
+
(stored !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
218
|
+
document.documentElement.classList.toggle("dark", dark);
|
|
219
|
+
})();
|
|
220
|
+
</script>
|
|
221
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
222
|
+
<script>
|
|
223
|
+
tailwind.config = {
|
|
224
|
+
darkMode: "class",
|
|
225
|
+
theme: {
|
|
226
|
+
extend: {
|
|
227
|
+
fontFamily: {
|
|
228
|
+
sans: ["ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "sans-serif"],
|
|
229
|
+
mono: ["ui-monospace", "SF Mono", "Menlo", "Consolas", "monospace"],
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
</script>
|
|
235
|
+
<style type="text/tailwindcss">
|
|
236
|
+
@layer utilities {
|
|
237
|
+
.tabular { font-variant-numeric: tabular-nums; }
|
|
238
|
+
}
|
|
239
|
+
</style>
|
|
240
|
+
<style>
|
|
241
|
+
body.scope-project .all-only { display: none; }
|
|
242
|
+
body.scope-project .project-col { display: none; }
|
|
243
|
+
.day-cell { container-type: size; }
|
|
244
|
+
.day-cell .day-tip {
|
|
245
|
+
position: absolute;
|
|
246
|
+
bottom: calc(100% + 6px);
|
|
247
|
+
left: 50%;
|
|
248
|
+
z-index: 10;
|
|
249
|
+
transform: translateX(-50%);
|
|
250
|
+
white-space: nowrap;
|
|
251
|
+
padding: 0.3rem 0.5rem;
|
|
252
|
+
border-radius: 0.25rem;
|
|
253
|
+
border: 1px solid rgb(214 211 209);
|
|
254
|
+
background: rgb(255 255 255);
|
|
255
|
+
color: rgb(28 25 23);
|
|
256
|
+
font-size: 0.75rem;
|
|
257
|
+
line-height: 1.25;
|
|
258
|
+
pointer-events: none;
|
|
259
|
+
opacity: 0;
|
|
260
|
+
visibility: hidden;
|
|
261
|
+
}
|
|
262
|
+
.dark .day-cell .day-tip {
|
|
263
|
+
border-color: rgb(68 64 60);
|
|
264
|
+
background: rgb(28 25 23);
|
|
265
|
+
color: rgb(245 245 244);
|
|
266
|
+
}
|
|
267
|
+
.day-cell:hover .day-tip,
|
|
268
|
+
.day-cell:focus-visible .day-tip {
|
|
269
|
+
opacity: 1;
|
|
270
|
+
visibility: visible;
|
|
271
|
+
}
|
|
272
|
+
.day-cell .day-fire {
|
|
273
|
+
position: absolute;
|
|
274
|
+
inset: 0.25rem;
|
|
275
|
+
display: grid;
|
|
276
|
+
place-items: center;
|
|
277
|
+
font-size: 80cqmin;
|
|
278
|
+
line-height: 1;
|
|
279
|
+
pointer-events: none;
|
|
280
|
+
}
|
|
281
|
+
.day-cell.is-weekend::before {
|
|
282
|
+
content: "";
|
|
283
|
+
position: absolute;
|
|
284
|
+
inset: 0;
|
|
285
|
+
border-radius: inherit;
|
|
286
|
+
background: repeating-linear-gradient(-45deg, transparent 0 3px, rgb(0 0 0 / 0.14) 3px 5px);
|
|
287
|
+
pointer-events: none;
|
|
288
|
+
}
|
|
289
|
+
.dark .day-cell.is-weekend::before {
|
|
290
|
+
background: repeating-linear-gradient(-45deg, transparent 0 3px, rgb(255 255 255 / 0.16) 3px 5px);
|
|
291
|
+
}
|
|
292
|
+
.day-grid-31 {
|
|
293
|
+
display: grid;
|
|
294
|
+
grid-template-columns: repeat(31, minmax(0, 1fr));
|
|
295
|
+
gap: 0.25rem;
|
|
296
|
+
}
|
|
297
|
+
</style>
|
|
298
|
+
</head>
|
|
299
|
+
<body class="${scopeClass} min-h-full bg-stone-100 text-stone-900 antialiased dark:bg-stone-950 dark:text-stone-100">
|
|
300
|
+
<main class="mx-auto w-full max-w-[90rem] px-4 py-8 sm:px-6 sm:py-10 lg:px-8">
|
|
301
|
+
<header class="mb-6 flex items-start justify-between gap-4 border-b border-stone-300 pb-4 dark:border-stone-700">
|
|
302
|
+
<div>
|
|
303
|
+
<h1 class="text-xl font-semibold tracking-tight">Cost report</h1>
|
|
304
|
+
<p class="mt-1 text-sm text-stone-600 dark:text-stone-400">
|
|
305
|
+
<span>${escapeHtml(report.range.label)}</span>
|
|
306
|
+
<span class="before:mx-2 before:text-stone-400 before:content-['·'] dark:before:text-stone-600">${escapeHtml(scopeLabel)}</span>
|
|
307
|
+
<span class="before:mx-2 before:text-stone-400 before:content-['·'] dark:before:text-stone-600">Generated ${escapeHtml(formatGenerated(report.generatedAtMs))}</span>
|
|
308
|
+
</p>
|
|
309
|
+
</div>
|
|
310
|
+
<button type="button" id="theme-toggle" class="shrink-0 rounded border border-stone-300 bg-white px-2.5 py-1 text-sm text-stone-800 dark:border-stone-600 dark:bg-stone-900 dark:text-stone-100" aria-label="Switch to dark mode">Dark</button>
|
|
311
|
+
</header>
|
|
312
|
+
|
|
313
|
+
<div class="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
314
|
+
<div class="rounded border border-stone-300 bg-white px-4 py-4 text-center dark:border-stone-700 dark:bg-stone-900">
|
|
315
|
+
<div class="text-3xl font-semibold tracking-tight tabular sm:text-4xl">${money(report.totalCost)}</div>
|
|
316
|
+
<div class="mt-1 text-sm text-stone-600 dark:text-stone-400">Total spend</div>
|
|
317
|
+
</div>
|
|
318
|
+
<div class="rounded border border-stone-300 bg-white px-4 py-4 text-center dark:border-stone-700 dark:bg-stone-900">
|
|
319
|
+
<div class="text-3xl font-semibold tracking-tight tabular sm:text-4xl">${report.sessionCount}</div>
|
|
320
|
+
<div class="mt-1 text-sm text-stone-600 dark:text-stone-400">Sessions</div>
|
|
321
|
+
</div>
|
|
322
|
+
<div class="all-only rounded border border-stone-300 bg-white px-4 py-4 text-center dark:border-stone-700 dark:bg-stone-900">
|
|
323
|
+
<div class="text-3xl font-semibold tracking-tight tabular sm:text-4xl">${report.projectCount}</div>
|
|
324
|
+
<div class="mt-1 text-sm text-stone-600 dark:text-stone-400">Projects</div>
|
|
325
|
+
</div>
|
|
326
|
+
<div class="rounded border border-stone-300 bg-white px-4 py-4 text-center dark:border-stone-700 dark:bg-stone-900">
|
|
327
|
+
<div class="text-3xl font-semibold tracking-tight tabular sm:text-4xl">${tokens(report.totalTokens)}</div>
|
|
328
|
+
<div class="mt-1 text-sm text-stone-600 dark:text-stone-400">Tokens</div>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
<section class="mb-8" aria-labelledby="daily-heading">
|
|
333
|
+
<div class="mb-3 flex items-baseline justify-between gap-3">
|
|
334
|
+
<h2 id="daily-heading" class="text-sm font-semibold">Daily spend</h2>
|
|
335
|
+
<span class="text-sm text-stone-600 dark:text-stone-400">${escapeHtml(dayCountLabel)}</span>
|
|
336
|
+
</div>
|
|
337
|
+
${dailyBody}
|
|
338
|
+
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-stone-600 dark:text-stone-400" aria-hidden="true">
|
|
339
|
+
<span>$0</span>
|
|
340
|
+
<span class="flex gap-px">
|
|
341
|
+
<span class="size-3 rounded-sm border border-stone-900/10 bg-stone-200 dark:border-white/10 dark:bg-stone-800"></span>
|
|
342
|
+
<span class="size-3 rounded-sm border border-stone-900/10 bg-green-600"></span>
|
|
343
|
+
<span class="size-3 rounded-sm border border-stone-900/10 bg-yellow-500"></span>
|
|
344
|
+
<span class="size-3 rounded-sm border border-stone-900/10 bg-red-600"></span>
|
|
345
|
+
</span>
|
|
346
|
+
<span>$50+</span>
|
|
347
|
+
<span>· 🔥 over $100</span>
|
|
348
|
+
<span>· diagonal = weekend</span>
|
|
349
|
+
</div>
|
|
350
|
+
</section>
|
|
351
|
+
|
|
352
|
+
<div class="mb-8 grid gap-8 xl:grid-cols-2 xl:items-start">
|
|
353
|
+
<section aria-labelledby="models-heading">
|
|
354
|
+
<h2 id="models-heading" class="mb-3 text-sm font-semibold">Models</h2>
|
|
355
|
+
<div class="overflow-x-auto rounded border border-stone-300 bg-white dark:border-stone-700 dark:bg-stone-900">
|
|
356
|
+
<table class="w-full text-sm tabular">
|
|
357
|
+
<thead>
|
|
358
|
+
<tr class="text-left text-xs font-medium text-stone-600 dark:text-stone-400">
|
|
359
|
+
<th scope="col" class="px-3 py-2 font-medium">Model</th>
|
|
360
|
+
<th scope="col" class="px-3 py-2 text-right font-medium">Cost</th>
|
|
361
|
+
<th scope="col" class="px-3 py-2 text-right font-medium">Share</th>
|
|
362
|
+
<th scope="col" class="min-w-[7rem] px-3 py-2 font-medium"></th>
|
|
363
|
+
<th scope="col" class="px-3 py-2 text-right font-medium">Sessions</th>
|
|
364
|
+
</tr>
|
|
365
|
+
</thead>
|
|
366
|
+
<tbody class="divide-y divide-stone-200 dark:divide-stone-800">${modelRows || `<tr><td class="px-3 py-2 text-stone-600 dark:text-stone-400" colspan="5">No model spend.</td></tr>`}</tbody>
|
|
367
|
+
</table>
|
|
368
|
+
</div>
|
|
369
|
+
</section>
|
|
370
|
+
|
|
371
|
+
<section aria-labelledby="subagents-heading">
|
|
372
|
+
<h2 id="subagents-heading" class="mb-3 text-sm font-semibold">Subagents</h2>
|
|
373
|
+
<div class="overflow-x-auto rounded border border-stone-300 bg-white dark:border-stone-700 dark:bg-stone-900">
|
|
374
|
+
<table class="w-full text-sm tabular">
|
|
375
|
+
<thead>
|
|
376
|
+
<tr class="text-left text-xs font-medium text-stone-600 dark:text-stone-400">
|
|
377
|
+
<th scope="col" class="px-3 py-2 font-medium">Agent</th>
|
|
378
|
+
<th scope="col" class="px-3 py-2 text-right font-medium">Cost</th>
|
|
379
|
+
<th scope="col" class="px-3 py-2 text-right font-medium">Share</th>
|
|
380
|
+
<th scope="col" class="min-w-[7rem] px-3 py-2 font-medium"></th>
|
|
381
|
+
<th scope="col" class="px-3 py-2 text-right font-medium">Calls</th>
|
|
382
|
+
</tr>
|
|
383
|
+
</thead>
|
|
384
|
+
<tbody class="divide-y divide-stone-200 dark:divide-stone-800">${subagentRows}</tbody>
|
|
385
|
+
</table>
|
|
386
|
+
</div>
|
|
387
|
+
</section>
|
|
388
|
+
</div>
|
|
389
|
+
|
|
390
|
+
<section class="all-only mb-8" aria-labelledby="projects-heading">
|
|
391
|
+
<h2 id="projects-heading" class="mb-3 text-sm font-semibold">Projects</h2>
|
|
392
|
+
<div class="overflow-x-auto rounded border border-stone-300 bg-white dark:border-stone-700 dark:bg-stone-900">
|
|
393
|
+
<table class="w-full text-sm tabular">
|
|
394
|
+
<thead>
|
|
395
|
+
<tr class="text-left text-xs font-medium text-stone-600 dark:text-stone-400">
|
|
396
|
+
<th scope="col" class="px-3 py-2 font-medium">Project</th>
|
|
397
|
+
<th scope="col" class="px-3 py-2 text-right font-medium">Cost</th>
|
|
398
|
+
<th scope="col" class="px-3 py-2 text-right font-medium">Share</th>
|
|
399
|
+
<th scope="col" class="min-w-[7rem] px-3 py-2 font-medium"></th>
|
|
400
|
+
<th scope="col" class="px-3 py-2 text-right font-medium">Sessions</th>
|
|
401
|
+
</tr>
|
|
402
|
+
</thead>
|
|
403
|
+
<tbody class="divide-y divide-stone-200 dark:divide-stone-800">${projectRows}</tbody>
|
|
404
|
+
</table>
|
|
405
|
+
</div>
|
|
406
|
+
</section>
|
|
407
|
+
|
|
408
|
+
<section class="mb-8" aria-labelledby="sessions-heading">
|
|
409
|
+
<div class="mb-3 flex items-baseline justify-between gap-3">
|
|
410
|
+
<h2 id="sessions-heading" class="text-sm font-semibold">Sessions</h2>
|
|
411
|
+
<div class="all-only flex items-center gap-2">
|
|
412
|
+
<label for="project-filter" class="text-sm text-stone-600 dark:text-stone-400">Project</label>
|
|
413
|
+
<select id="project-filter" class="rounded border border-stone-300 bg-white px-2 py-1 text-sm text-stone-900 dark:border-stone-600 dark:bg-stone-900 dark:text-stone-100">${projectOptions}</select>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
<div class="overflow-x-auto rounded border border-stone-300 bg-white dark:border-stone-700 dark:bg-stone-900">
|
|
417
|
+
<table class="w-full text-sm">
|
|
418
|
+
<thead>
|
|
419
|
+
<tr class="text-left text-xs font-medium text-stone-600 dark:text-stone-400">
|
|
420
|
+
<th scope="col" class="px-3 py-2 font-medium">Session</th>
|
|
421
|
+
<th scope="col" class="project-col px-3 py-2 font-medium">Project</th>
|
|
422
|
+
<th scope="col" class="px-3 py-2 font-medium">Models</th>
|
|
423
|
+
<th scope="col" class="px-3 py-2 text-right font-medium">Tokens</th>
|
|
424
|
+
<th scope="col" class="px-3 py-2 text-right font-medium">Cost</th>
|
|
425
|
+
</tr>
|
|
426
|
+
</thead>
|
|
427
|
+
<tbody id="sessions-body" class="divide-y divide-stone-200 dark:divide-stone-800">${sessionRows || `<tr><td class="px-3 py-2 text-stone-600 dark:text-stone-400" colspan="5">No sessions.</td></tr>`}</tbody>
|
|
428
|
+
</table>
|
|
429
|
+
</div>
|
|
430
|
+
</section>
|
|
431
|
+
|
|
432
|
+
<footer class="mt-10 border-t border-stone-300 pt-4 text-xs text-stone-600 dark:border-stone-700 dark:text-stone-400">
|
|
433
|
+
<p>
|
|
434
|
+
Costs are estimates from stored session usage, not provider invoices. Branched turns count. Subagent
|
|
435
|
+
totals come from parent tool results. Direct ${money(report.directCost)} · Subagents ${money(report.subagentCost)}.
|
|
436
|
+
</p>
|
|
437
|
+
</footer>
|
|
438
|
+
</main>
|
|
439
|
+
<script>
|
|
440
|
+
(() => {
|
|
441
|
+
const key = "tau-cost-report-theme";
|
|
442
|
+
const root = document.documentElement;
|
|
443
|
+
const button = document.getElementById("theme-toggle");
|
|
444
|
+
if (button) {
|
|
445
|
+
const sync = () => {
|
|
446
|
+
const dark = root.classList.contains("dark");
|
|
447
|
+
button.textContent = dark ? "Light" : "Dark";
|
|
448
|
+
button.setAttribute("aria-label", dark ? "Switch to light mode" : "Switch to dark mode");
|
|
449
|
+
};
|
|
450
|
+
button.addEventListener("click", () => {
|
|
451
|
+
const dark = !root.classList.contains("dark");
|
|
452
|
+
root.classList.toggle("dark", dark);
|
|
453
|
+
localStorage.setItem(key, dark ? "dark" : "light");
|
|
454
|
+
sync();
|
|
455
|
+
});
|
|
456
|
+
sync();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const filter = document.getElementById("project-filter");
|
|
460
|
+
const body = document.getElementById("sessions-body");
|
|
461
|
+
if (filter && body) {
|
|
462
|
+
filter.addEventListener("change", () => {
|
|
463
|
+
const value = filter.value;
|
|
464
|
+
for (const row of body.querySelectorAll("tr[data-project-key]")) {
|
|
465
|
+
const key = row.getAttribute("data-project-key") || "";
|
|
466
|
+
row.hidden = Boolean(value) && key !== value;
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
})();
|
|
471
|
+
</script>
|
|
472
|
+
</body>
|
|
473
|
+
</html>`;
|
|
474
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { access, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { buildCostReport } from "./analyze.ts";
|
|
6
|
+
import { renderCostReportHtml } from "./html.ts";
|
|
7
|
+
import { CostReportStatusPanel } from "./panel.ts";
|
|
8
|
+
import { LIVE_RANGE_CHOICES, liveRange, MONTH_CHOICES, specificMonthRange, yearChoices } from "./range.ts";
|
|
9
|
+
import type { ReportRange, ReportScope } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
const COMMAND = "cost-report";
|
|
12
|
+
|
|
13
|
+
export default function costReportExtension(pi: ExtensionAPI): void {
|
|
14
|
+
pi.registerCommand(COMMAND, {
|
|
15
|
+
description: "Generate an HTML cost report from local sessions",
|
|
16
|
+
handler: async (_args, ctx) => {
|
|
17
|
+
if (ctx.mode !== "tui") {
|
|
18
|
+
ctx.ui.notify("/cost-report requires TUI mode", "warning");
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
await ctx.waitForIdle();
|
|
23
|
+
|
|
24
|
+
const range = await pickRange(ctx);
|
|
25
|
+
if (!range) return;
|
|
26
|
+
|
|
27
|
+
const scope = await pickScope(ctx);
|
|
28
|
+
if (!scope) return;
|
|
29
|
+
|
|
30
|
+
const secondary = `${range.label} · ${scope === "project" ? "Current project" : "All sessions"}`;
|
|
31
|
+
|
|
32
|
+
const path = outputPath(range, scope);
|
|
33
|
+
if (await fileExists(path)) {
|
|
34
|
+
try {
|
|
35
|
+
await openReport(pi, ctx, path);
|
|
36
|
+
} catch {
|
|
37
|
+
// Still notify the path if the OS open fails.
|
|
38
|
+
}
|
|
39
|
+
ctx.ui.notify(`Opening existing cost report · ${path}`, "info");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const outcome = await ctx.ui.custom<
|
|
44
|
+
| { kind: "ok"; path: string }
|
|
45
|
+
| { kind: "empty" }
|
|
46
|
+
| { kind: "cancelled" }
|
|
47
|
+
| { kind: "error"; message: string }
|
|
48
|
+
>((tui, theme, _keys, done) => {
|
|
49
|
+
const panel = new CostReportStatusPanel(tui, theme, secondary);
|
|
50
|
+
void (async () => {
|
|
51
|
+
try {
|
|
52
|
+
const report = await buildCostReport({
|
|
53
|
+
cwd: ctx.cwd,
|
|
54
|
+
range,
|
|
55
|
+
scope,
|
|
56
|
+
signal: panel.signal,
|
|
57
|
+
onProgress: (status) => panel.update(status),
|
|
58
|
+
});
|
|
59
|
+
if (panel.signal.aborted) {
|
|
60
|
+
done({ kind: "cancelled" });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!report) {
|
|
64
|
+
done({ kind: "empty" });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
panel.update("Writing file…");
|
|
69
|
+
await mkdir(join(homedir(), ".pi", "tau", "cost-reports"), { recursive: true });
|
|
70
|
+
await writeFile(path, renderCostReportHtml(report), "utf8");
|
|
71
|
+
|
|
72
|
+
if (panel.signal.aborted) {
|
|
73
|
+
done({ kind: "cancelled" });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
panel.update("Opening…");
|
|
78
|
+
try {
|
|
79
|
+
await openReport(pi, ctx, path);
|
|
80
|
+
} catch {
|
|
81
|
+
// File is written; notify still carries the path if open fails.
|
|
82
|
+
}
|
|
83
|
+
done({ kind: "ok", path });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (panel.signal.aborted) {
|
|
86
|
+
done({ kind: "cancelled" });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
done({
|
|
90
|
+
kind: "error",
|
|
91
|
+
message: error instanceof Error ? error.message : String(error),
|
|
92
|
+
});
|
|
93
|
+
} finally {
|
|
94
|
+
panel.dispose();
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
return panel;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (outcome.kind === "ok") {
|
|
101
|
+
ctx.ui.notify(`Cost report written · ${outcome.path}`, "info");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (outcome.kind === "empty") {
|
|
105
|
+
ctx.ui.notify(`No session spend found for ${secondary}.`, "warning");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (outcome.kind === "cancelled") {
|
|
109
|
+
ctx.ui.notify("Cost report cancelled.", "info");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
ctx.ui.notify(`Unable to build cost report: ${outcome.message}`, "error");
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function pickRange(ctx: ExtensionCommandContext): Promise<ReportRange | undefined> {
|
|
118
|
+
const choice = await ctx.ui.select(
|
|
119
|
+
"Time frame",
|
|
120
|
+
LIVE_RANGE_CHOICES.map((item) => item.label),
|
|
121
|
+
);
|
|
122
|
+
if (!choice) return undefined;
|
|
123
|
+
|
|
124
|
+
const selected = LIVE_RANGE_CHOICES.find((item) => item.label === choice);
|
|
125
|
+
if (!selected) return undefined;
|
|
126
|
+
if (selected.id !== "specific-month") return liveRange(selected.id);
|
|
127
|
+
|
|
128
|
+
const monthLabel = await ctx.ui.select(
|
|
129
|
+
"Month",
|
|
130
|
+
MONTH_CHOICES.map((item) => item.label),
|
|
131
|
+
);
|
|
132
|
+
if (!monthLabel) return undefined;
|
|
133
|
+
const month = MONTH_CHOICES.find((item) => item.label === monthLabel);
|
|
134
|
+
if (!month) return undefined;
|
|
135
|
+
|
|
136
|
+
const years = yearChoices().map(String);
|
|
137
|
+
const yearLabel = await ctx.ui.select("Year", years);
|
|
138
|
+
if (!yearLabel) return undefined;
|
|
139
|
+
const year = Number(yearLabel);
|
|
140
|
+
if (!Number.isFinite(year)) return undefined;
|
|
141
|
+
|
|
142
|
+
return specificMonthRange(year, month.month);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function pickScope(ctx: ExtensionCommandContext): Promise<ReportScope | undefined> {
|
|
146
|
+
const choice = await ctx.ui.select("Scope", ["Current project", "All sessions"]);
|
|
147
|
+
if (choice === "Current project") return "project";
|
|
148
|
+
if (choice === "All sessions") return "all";
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function outputPath(range: ReportRange, scope: ReportScope): string {
|
|
153
|
+
// One file per window identity + scope. Re-running the same picks reopens this path.
|
|
154
|
+
return join(homedir(), ".pi", "tau", "cost-reports", `cost-report-${range.slug}-${scope}.html`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
158
|
+
try {
|
|
159
|
+
await access(path);
|
|
160
|
+
return true;
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function openReport(pi: ExtensionAPI, ctx: ExtensionCommandContext, path: string): Promise<void> {
|
|
167
|
+
const command = openCommand(path);
|
|
168
|
+
const result = await pi.exec(command.command, command.args, {
|
|
169
|
+
signal: ctx.signal,
|
|
170
|
+
timeout: 5_000,
|
|
171
|
+
});
|
|
172
|
+
if (result.code !== 0) {
|
|
173
|
+
throw new Error(result.stderr.trim() || `open command failed with exit ${result.code}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function openCommand(path: string): { command: string; args: string[] } {
|
|
178
|
+
if (process.platform === "darwin") return { command: "open", args: [path] };
|
|
179
|
+
if (process.platform === "win32") return { command: "cmd", args: ["/c", "start", "", path] };
|
|
180
|
+
return { command: "xdg-open", args: [path] };
|
|
181
|
+
}
|