pi-sdk-web 0.4.10 → 0.5.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.
File without changes
package/dist/server.js CHANGED
@@ -764,10 +764,38 @@ export class PiWebServer {
764
764
  }
765
765
  };
766
766
  const unsubscribe = this.session.subscribe(listener);
767
+ // hasUI by extension: most extensions degrade gracefully to notify()
768
+ // (our notify -> browser dialog/toast), but a few (magic-context) use
769
+ // ctx.ui.custom() - an extension-drawn TUI panel we can't render. For
770
+ // those, hasUI:false routes them to their own text fallback
771
+ // (appendEntry -> modal), same as before.
772
+ const cmdPath = (cmd.sourceInfo && cmd.sourceInfo.path) || "";
773
+ const customOnly = /pi-magic-context|magic-context/.test(cmdPath);
774
+ const ui = this.uiContext;
775
+ const originalSink = ui.sink;
767
776
  try {
768
- await cmd.handler(args, this.buildCommandContext());
777
+ // While the command runs, notify() broadcasts are tagged with the
778
+ // command name as title, so the browser shows them as a titled modal
779
+ // (colored, Markdown-rendered) instead of a plain stream line.
780
+ ui.sink = (obj) => {
781
+ const req = obj;
782
+ if (req && req.method === "notify") {
783
+ // Tag command-time notifies with the command name as title so the
784
+ // browser renders them as a titled modal (colored, Markdown).
785
+ originalSink({ ...obj, title: `/${name}` });
786
+ }
787
+ else {
788
+ originalSink(obj);
789
+ }
790
+ };
791
+ await cmd.handler(args, this.buildCommandContext(customOnly ? false : true));
792
+ }
793
+ catch (e) {
794
+ ui.sink = originalSink;
795
+ throw e;
769
796
  }
770
797
  finally {
798
+ ui.sink = originalSink;
771
799
  unsubscribe();
772
800
  }
773
801
  // Show command output as a modal (TUI-like). The session entry remains
@@ -789,14 +817,44 @@ export class PiWebServer {
789
817
  notifyType: "info",
790
818
  });
791
819
  }
820
+ // Usage extension: the /usage command collects data (via custom(), whose
821
+ // factory runs the collection) and keeps its cache file up to date.
822
+ // Aggregate the cache into structured data and send it as a dedicated
823
+ // message; the frontend renders its own usage panel (independent
824
+ // integration in usage-render.ts - no effect on other commands).
825
+ if (name === "usage") {
826
+ try {
827
+ const { buildUsageData } = await import("./usage-render.js");
828
+ const data = buildUsageData();
829
+ if (data) {
830
+ this.broadcast({ type: "usage_data", data });
831
+ }
832
+ else {
833
+ this.broadcast({
834
+ type: "extension_ui_request",
835
+ id: crypto.randomUUID(),
836
+ method: "notify",
837
+ title: "/usage",
838
+ message: "No usage data found. Run `/usage` in the TUI first to build the usage cache.",
839
+ notifyType: "info",
840
+ });
841
+ }
842
+ }
843
+ catch {
844
+ // integration failed silently - command output (if any) already
845
+ // broadcast above
846
+ }
847
+ }
792
848
  }
793
- buildCommandContext() {
849
+ buildCommandContext(hasUI) {
794
850
  const sm = this.session.sessionManager;
795
851
  return {
796
852
  ui: this.uiContext,
797
- // No dialog-capable UI: extension commands fall back to text output
853
+ // hasUI is decided per command (see executeCommand): true for
854
+ // extensions that degrade to notify(), false for custom-panel-only
855
+ // extensions (magic-context) so they use their text fallback.
798
856
  mode: "print",
799
- hasUI: false,
857
+ hasUI,
800
858
  cwd: sm.getCwd(),
801
859
  sessionManager: sm,
802
860
  modelRegistry: new ModelRegistry(this.session.modelRuntime),
@@ -355,6 +355,9 @@ class PiWebClient {
355
355
  case 'scoped_models':
356
356
  this.handleScopedModels(data.data);
357
357
  break;
358
+ case 'usage_data':
359
+ this.renderUsagePanel(data.data);
360
+ break;
358
361
  case 'sessions':
359
362
  this.handleSessions(data.data);
360
363
  break;
@@ -1705,7 +1708,19 @@ class PiWebClient {
1705
1708
  if (e.key === 'Escape' && this.modalOverlay && this.modalOverlay.style.display !== 'none') {
1706
1709
  this.closeModal();
1707
1710
  }
1711
+ const usageOverlay = document.getElementById('usage-overlay');
1712
+ if (e.key === 'Escape' && usageOverlay && usageOverlay.style.display !== 'none') {
1713
+ this.closeUsagePanel();
1714
+ }
1708
1715
  });
1716
+ const usageClose = document.getElementById('usage-close');
1717
+ if (usageClose) usageClose.addEventListener('click', () => this.closeUsagePanel());
1718
+ const usageOverlayEl = document.getElementById('usage-overlay');
1719
+ if (usageOverlayEl) {
1720
+ usageOverlayEl.addEventListener('click', (e) => {
1721
+ if (e.target === usageOverlayEl) this.closeUsagePanel();
1722
+ });
1723
+ }
1709
1724
  }
1710
1725
 
1711
1726
  // ------------------------------------------------------------------
@@ -1915,8 +1930,9 @@ class PiWebClient {
1915
1930
 
1916
1931
  openExtensionNotify(req) {
1917
1932
  // Command output notifications (title starts with "/", e.g. /ctx-status) keep
1918
- // the modal. Other extension notifies are lightweight toasts (TUI shows
1919
- // notify as a transient status message, not a dialog).
1933
+ // the modal. Other extension notifies are appended to the chat stream as
1934
+ // persistent status lines, matching TUI's notify -> showStatus (a dim text
1935
+ // appended to the chat container; NOT a transient toast).
1920
1936
  if (req.title && String(req.title).startsWith('/')) {
1921
1937
  this.openModal(req.title || 'Notification', 'extension-notify');
1922
1938
  this.currentExtRequest = req;
@@ -1924,7 +1940,15 @@ class PiWebClient {
1924
1940
  this.modalList.innerHTML = `<div class="modal-message body-text">${this.renderMarkdown(req.message || '')}</div>`;
1925
1941
  return;
1926
1942
  }
1927
- this.showToast(req.message || '', req.notifyType);
1943
+ this.appendNotifyLine(req.message || '', req.notifyType);
1944
+ }
1945
+
1946
+ appendNotifyLine(message, type) {
1947
+ const div = document.createElement('div');
1948
+ div.className = 'notify-line' + (type ? ` notify-${type}` : '');
1949
+ div.innerHTML = ansiToHtml(String(message));
1950
+ this.contentEl.appendChild(div);
1951
+ this.scrollToBottom();
1928
1952
  }
1929
1953
 
1930
1954
  showToast(message, type) {
@@ -1941,6 +1965,279 @@ class PiWebClient {
1941
1965
  }, type === 'error' ? 8000 : 5000);
1942
1966
  }
1943
1967
 
1968
+ // ------------------------------------------------------------------
1969
+ // Usage panel (dedicated big panel for /usage, rendered from the
1970
+ // server-aggregated usage_data payload)
1971
+ // ------------------------------------------------------------------
1972
+
1973
+ renderUsagePanel(data) {
1974
+ if (!data || !data.tabs) return;
1975
+ this.usageData = data;
1976
+ this.usageTab = 'thisWeek';
1977
+ this.usageView = 'table';
1978
+ this.usageExpanded = new Set();
1979
+ const overlay = document.getElementById('usage-overlay');
1980
+ if (!overlay) return;
1981
+ overlay.style.display = 'flex';
1982
+ this.renderUsageTabs();
1983
+ this.renderUsageBody();
1984
+ }
1985
+
1986
+ closeUsagePanel() {
1987
+ const overlay = document.getElementById('usage-overlay');
1988
+ if (overlay) overlay.style.display = 'none';
1989
+ }
1990
+
1991
+ renderUsageTabs() {
1992
+ const tabsEl = document.getElementById('usage-tabs');
1993
+ if (!tabsEl) return;
1994
+ const labels = {
1995
+ today: 'Today',
1996
+ thisWeek: 'This Week',
1997
+ lastWeek: 'Last Week',
1998
+ last30Days: 'Last 30 Days',
1999
+ allTime: 'All Time',
2000
+ };
2001
+ tabsEl.innerHTML = '';
2002
+ for (const key of Object.keys(labels)) {
2003
+ const tab = document.createElement('span');
2004
+ tab.className = 'usage-tab' + (key === this.usageTab ? ' active' : '');
2005
+ tab.textContent = labels[key];
2006
+ tab.addEventListener('click', () => {
2007
+ this.usageTab = key;
2008
+ this.usageExpanded.clear();
2009
+ this.renderUsageTabs();
2010
+ this.renderUsageBody();
2011
+ });
2012
+ tabsEl.appendChild(tab);
2013
+ }
2014
+ // View switcher: Table / Insights / Graph
2015
+ const viewsEl = document.getElementById('usage-views');
2016
+ if (viewsEl) {
2017
+ viewsEl.innerHTML = '';
2018
+ for (const [key, label] of [['table', 'Table'], ['insights', 'Insights'], ['graph', 'Graph']]) {
2019
+ const v = document.createElement('span');
2020
+ v.className = 'usage-view' + (key === this.usageView ? ' active' : '');
2021
+ v.textContent = label;
2022
+ v.addEventListener('click', () => {
2023
+ this.usageView = key;
2024
+ this.renderUsageTabs();
2025
+ this.renderUsageBody();
2026
+ });
2027
+ viewsEl.appendChild(v);
2028
+ }
2029
+ }
2030
+ }
2031
+
2032
+ fmtTokens(n) {
2033
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
2034
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
2035
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
2036
+ return String(Math.round(n));
2037
+ }
2038
+
2039
+ fmtCost(c) {
2040
+ if (!c || c <= 0) return '-';
2041
+ if (c < 0.01) return '$' + c.toFixed(4);
2042
+ if (c < 10) return '$' + c.toFixed(2);
2043
+ return '$' + c.toFixed(1);
2044
+ }
2045
+
2046
+ usageRow(stat) {
2047
+ const t = stat.tokens || {};
2048
+ return [
2049
+ this.fmtTokens(t.input),
2050
+ this.fmtTokens(t.output),
2051
+ this.fmtTokens((t.cacheRead || 0) + (t.cacheWrite || 0)),
2052
+ this.fmtTokens((t.total === undefined ? (t.input || 0) + (t.output || 0) + (t.cacheRead || 0) + (t.cacheWrite || 0) : t.total)),
2053
+ this.fmtCost(stat.cost),
2054
+ String(stat.messages),
2055
+ String(stat.sessions),
2056
+ ];
2057
+ }
2058
+
2059
+ renderUsageBody() {
2060
+ const body = document.getElementById('usage-body');
2061
+ if (!body) return;
2062
+ const period = this.usageData.tabs[this.usageTab];
2063
+ if (!period) return;
2064
+ body.innerHTML = '';
2065
+ body.scrollTop = 0;
2066
+
2067
+ if (this.usageView === 'table') {
2068
+ const table = document.createElement('table');
2069
+ table.className = 'usage-table';
2070
+ const head = document.createElement('thead');
2071
+ head.innerHTML = '<tr><th>Provider / Model</th><th>Msgs</th><th>Cost</th><th>Tokens</th><th>↑In</th><th>↓Out</th><th>Cache</th><th>Sessions</th></tr>';
2072
+ table.appendChild(head);
2073
+ const tbody = document.createElement('tbody');
2074
+
2075
+ const rowFor = (name, stat, isModel, isTotals) => {
2076
+ const tr = document.createElement('tr');
2077
+ if (isTotals) tr.className = 'usage-totals';
2078
+ else if (isModel) tr.className = 'usage-model-row';
2079
+ else tr.className = 'usage-provider-row';
2080
+ const nameTd = document.createElement('td');
2081
+ nameTd.className = 'usage-name';
2082
+ nameTd.textContent = name;
2083
+ const [, cost, tokens, input, output, cache, msgs, sessions] = [null, this.fmtCost(stat.cost), this.fmtTokens(stat.tokens ? stat.tokens.total : 0), this.fmtTokens(stat.tokens ? stat.tokens.input : 0), this.fmtTokens(stat.tokens ? stat.tokens.output : 0), this.fmtTokens(stat.tokens ? (stat.tokens.cacheRead + stat.tokens.cacheWrite) : 0), String(stat.messages), String(stat.sessions)];
2084
+ tr.appendChild(nameTd);
2085
+ for (const cell of [msgs, cost, tokens, input, output, cache, sessions]) {
2086
+ const td = document.createElement('td');
2087
+ td.textContent = cell;
2088
+ td.className = 'usage-num';
2089
+ tr.appendChild(td);
2090
+ }
2091
+ return tr;
2092
+ };
2093
+
2094
+ for (const p of period.providers) {
2095
+ const key = 'p:' + p.name;
2096
+ const expanded = this.usageExpanded.has(key);
2097
+ const tr = rowFor(p.name, p, false, false);
2098
+ tr.classList.add('usage-clickable');
2099
+ tr.addEventListener('click', () => {
2100
+ if (this.usageExpanded.has(key)) this.usageExpanded.delete(key);
2101
+ else this.usageExpanded.add(key);
2102
+ this.renderUsageBody();
2103
+ });
2104
+ tbody.appendChild(tr);
2105
+ if (expanded) {
2106
+ for (const m of p.models || []) {
2107
+ tbody.appendChild(rowFor(' └ ' + m.name, m, true, false));
2108
+ }
2109
+ }
2110
+ }
2111
+ const totals = period.totals;
2112
+ tbody.appendChild(rowFor('Total', totals, false, true));
2113
+ table.appendChild(tbody);
2114
+ body.appendChild(table);
2115
+ } else if (this.usageView === 'insights') {
2116
+ const list = document.createElement('div');
2117
+ list.className = 'usage-insights';
2118
+ const ins = period.insights || [];
2119
+ if (ins.length === 0) {
2120
+ list.innerHTML = '<div class="usage-insight">No insights for this period.</div>';
2121
+ }
2122
+ for (const i of ins) {
2123
+ const div = document.createElement('div');
2124
+ div.className = 'usage-insight ' + (i.kind === 'alarm' ? 'alarm' : 'structure');
2125
+ const stat = document.createElement('span');
2126
+ stat.className = 'usage-insight-stat';
2127
+ stat.textContent = i.stat;
2128
+ const text = document.createElement('span');
2129
+ text.textContent = i.headline;
2130
+ div.appendChild(stat);
2131
+ div.appendChild(text);
2132
+ if (i.advice) {
2133
+ const advice = document.createElement('div');
2134
+ advice.className = 'usage-insight-advice';
2135
+ advice.textContent = i.advice;
2136
+ div.appendChild(advice);
2137
+ }
2138
+ list.appendChild(div);
2139
+ }
2140
+ body.appendChild(list);
2141
+ } else if (this.usageView === 'graph') {
2142
+ // Simple CSS bar chart: cost per provider (this period) + hourly tokens trend
2143
+ const container = document.createElement('div');
2144
+ container.className = 'usage-graph';
2145
+ if (period.providers.length === 0) {
2146
+ container.innerHTML = '<div class="usage-insight">No data for this period.</div>';
2147
+ body.appendChild(container);
2148
+ return;
2149
+ }
2150
+ const maxCost = Math.max(...period.providers.map((p) => p.cost), 1e-9);
2151
+ let html = '<div class="usage-graph-title">Cost by provider</div><div class="usage-bars">';
2152
+ for (const p of period.providers) {
2153
+ const w = ((p.cost / maxCost) * 100).toFixed(1);
2154
+ html += `<div class="usage-bar-row"><span class="usage-bar-label">${this.escapeHtml(p.name)}</span><div class="usage-bar-track"><div class="usage-bar" style="width:${w}%"></div></div><span class="usage-bar-val">${this.fmtCost(p.cost)}</span></div>`;
2155
+ }
2156
+ html += '</div>';
2157
+ // Hourly tokens trend for the current tab. The server provides one
2158
+ // global hourly series + per-tab windows; filtering (per tab) and
2159
+ // gap-filling are render-side concerns.
2160
+ const hourly = this.usageData.hourly ? this.usageData.hourly : null;
2161
+ const window = (this.usageData.tabWindow || {})[this.usageTab] || null;
2162
+ if (hourly && hourly.length > 0) {
2163
+ // Filter to the tab's window, then fill the x-axis with a
2164
+ // continuous hour sequence so idle hours appear as empty columns.
2165
+ // Axis start rule: if the earliest data predates the period start,
2166
+ // show the whole period (zeros included); if data only exists
2167
+ // inside the period, start from the first hour with data (e.g.
2168
+ // allTime never starts at epoch - it starts at first usage).
2169
+ let seq = hourly;
2170
+ if (window && window[1] > window[0]) {
2171
+ const H = 3600_000;
2172
+ const inWindow = hourly.filter((b) => b.hour >= window[0] && b.hour < window[1]);
2173
+ const periodStartHour = Math.floor(window[0] / H) * H;
2174
+ // Rule: compare the GLOBAL earliest data hour with the period
2175
+ // start. If data predates the period (global first < period
2176
+ // start), show the whole period (zeros included). Only when the
2177
+ // very first usage ever is inside this period do we start from
2178
+ // the first data hour (e.g. allTime starts at first usage, not
2179
+ // epoch).
2180
+ const globalFirstHour = hourly[0].hour; // hourly is time-sorted
2181
+ const startHour = globalFirstHour > periodStartHour ? globalFirstHour : periodStartHour;
2182
+ const endHour = Math.floor(window[1] / H) * H;
2183
+ const byHour = new Map(inWindow.map((b) => [b.hour, b]));
2184
+ seq = [];
2185
+ for (let h = startHour; h <= endHour; h += H) {
2186
+ const b = byHour.get(h);
2187
+ seq.push(b ? { ...b } : { hour: h, cost: 0, tokens: 0, messages: 0 });
2188
+ }
2189
+ }
2190
+ // Cap the column count (~120) so very long windows (allTime) stay
2191
+ // manageable; the hourly chart scrolls horizontally to show idle
2192
+ // hours, so a generous count is fine.
2193
+ let buckets = seq;
2194
+ const maxCols = 120;
2195
+ if (buckets.length > maxCols) {
2196
+ const step = Math.ceil(buckets.length / maxCols);
2197
+ const agg = [];
2198
+ for (let i = 0; i < buckets.length; i += step) {
2199
+ const slice = buckets.slice(i, Math.min(i + step, buckets.length));
2200
+ const start = slice[0].hour;
2201
+ const end = slice[slice.length - 1].hour + 3600_000;
2202
+ agg.push({
2203
+ hour: start,
2204
+ tokens: slice.reduce((s, b) => s + b.tokens, 0),
2205
+ cost: slice.reduce((s, b) => s + b.cost, 0),
2206
+ messages: slice.reduce((s, b) => s + b.messages, 0),
2207
+ spanEnd: end,
2208
+ spanCount: slice.length,
2209
+ });
2210
+ }
2211
+ buckets = agg;
2212
+ }
2213
+ const maxT = Math.max(...buckets.map((b) => b.tokens), 1e-9);
2214
+ // Fixed-height bars (px) - avoids percentage-height resolution
2215
+ // issues in nested flex containers.
2216
+ const BAR_MAX_PX = 96;
2217
+ // Label every ~6th column with its time (sparse to avoid crowding).
2218
+ const labelStep = Math.ceil(buckets.length / 8);
2219
+ html += '<div class="usage-graph-title" style="margin-top:14px">Hourly tokens (recent)</div><div class="usage-hourly">';
2220
+ for (let i = 0; i < buckets.length; i++) {
2221
+ const b = buckets[i];
2222
+ const hPx = b.tokens > 0 ? Math.max(2, Math.round((b.tokens / maxT) * BAR_MAX_PX)) : 0;
2223
+ const d = new Date(b.hour);
2224
+ const spanText = b.spanCount ? ` (${b.spanCount}h)` : '';
2225
+ const showLabel = i % labelStep === 0 || i === buckets.length - 1;
2226
+ const label = showLabel
2227
+ ? d.toLocaleDateString(undefined, { month: 'numeric', day: 'numeric' }) + '\n' + d.toLocaleTimeString(undefined, { hour: '2-digit' })
2228
+ : '';
2229
+ // Day/night banding: hour 6..17 (local) = day, else night
2230
+ const hourOfDay = d.getHours();
2231
+ const band = hourOfDay >= 6 && hourOfDay < 18 ? 'day' : 'night';
2232
+ html += `<div class="usage-hour-col ${band}" title="${d.toLocaleString()}${spanText} — ${this.fmtTokens(b.tokens)}"><div class="usage-hour-bar-wrap"><div class="usage-hour-bar" style="height:${hPx}px"></div></div><div class="usage-hour-label">${this.escapeHtml(label).replace(/\n/g, '<br>')}</div></div>`;
2233
+ }
2234
+ html += '</div>';
2235
+ }
2236
+ container.innerHTML = html;
2237
+ body.appendChild(container);
2238
+ }
2239
+ }
2240
+
1944
2241
  handleModels(models) {
1945
2242
  if (this.modalMode !== 'model') return;
1946
2243
  const items = (models || []).map((m) => ({
@@ -55,6 +55,15 @@
55
55
  </div>
56
56
  </div>
57
57
  <div id="toast-container"></div>
58
+ <div id="usage-overlay" style="display:none">
59
+ <div id="usage-panel">
60
+ <div id="usage-title">Usage</div>
61
+ <div id="usage-tabs"></div>
62
+ <div id="usage-views"></div>
63
+ <div id="usage-body"></div>
64
+ <div id="usage-close">Close (Esc)</div>
65
+ </div>
66
+ </div>
58
67
  <script src="vendor/marked.min.js"></script>
59
68
  <script src="app.js"></script>
60
69
  </body>
@@ -785,8 +785,8 @@ body {
785
785
  background: var(--code-bg);
786
786
  border: 1px solid var(--border);
787
787
  border-radius: 8px;
788
- width: 480px;
789
- max-width: 90vw;
788
+ width: min(720px, 92vw);
789
+ max-width: none;
790
790
  max-height: 70vh;
791
791
  display: flex;
792
792
  flex-direction: column;
@@ -907,6 +907,7 @@ body {
907
907
  color: var(--text);
908
908
  font-size: 13px;
909
909
  line-height: 1.5;
910
+ white-space: pre-wrap;
910
911
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
911
912
  opacity: 1;
912
913
  transition: opacity 0.3s;
@@ -920,6 +921,27 @@ body {
920
921
  border-left-color: var(--error);
921
922
  }
922
923
 
924
+ /* Extension notify lines appended to the chat stream (TUI showStatus
925
+ equivalent): persistent dim status text, colored by type. */
926
+ .notify-line {
927
+ color: var(--dim);
928
+ font-size: 12px;
929
+ padding: 2px 12px;
930
+ white-space: pre-wrap;
931
+ overflow-wrap: anywhere;
932
+ border-left: 3px solid transparent;
933
+ }
934
+
935
+ .notify-warning {
936
+ color: var(--warning);
937
+ border-left-color: var(--warning);
938
+ }
939
+
940
+ .notify-error {
941
+ color: var(--error);
942
+ border-left-color: var(--error);
943
+ }
944
+
923
945
  .toast-hide {
924
946
  opacity: 0;
925
947
  }
@@ -1159,3 +1181,297 @@ body {
1159
1181
  #abort-btn:active {
1160
1182
  opacity: 0.8;
1161
1183
  }
1184
+
1185
+ /* ======================================================================
1186
+ Usage panel (dedicated large panel for /usage)
1187
+ ====================================================================== */
1188
+ #usage-overlay {
1189
+ position: fixed;
1190
+ inset: 0;
1191
+ background: rgba(0, 0, 0, 0.6);
1192
+ display: none;
1193
+ align-items: center;
1194
+ justify-content: center;
1195
+ z-index: 150;
1196
+ }
1197
+
1198
+ #usage-panel {
1199
+ background: var(--modal-bg);
1200
+ border: 1px solid var(--border);
1201
+ border-radius: 10px;
1202
+ width: min(1100px, 94vw);
1203
+ height: min(85vh, 800px);
1204
+ display: flex;
1205
+ flex-direction: column;
1206
+ overflow: hidden;
1207
+ }
1208
+
1209
+ #usage-title {
1210
+ padding: 10px 16px;
1211
+ font-weight: bold;
1212
+ font-size: 15px;
1213
+ border-bottom: 1px solid var(--border);
1214
+ display: flex;
1215
+ justify-content: space-between;
1216
+ align-items: center;
1217
+ }
1218
+
1219
+ #usage-tabs {
1220
+ display: flex;
1221
+ gap: 2px;
1222
+ padding: 8px 12px 4px;
1223
+ border-bottom: 1px solid var(--border);
1224
+ flex-wrap: wrap;
1225
+ }
1226
+
1227
+ .usage-tab, .usage-view {
1228
+ padding: 4px 12px;
1229
+ font-size: 12px;
1230
+ border-radius: 4px;
1231
+ cursor: pointer;
1232
+ color: var(--muted);
1233
+ }
1234
+
1235
+ .usage-tab.active, .usage-view.active {
1236
+ background: var(--accent);
1237
+ color: var(--bg);
1238
+ font-weight: 600;
1239
+ }
1240
+
1241
+ #usage-views {
1242
+ display: flex;
1243
+ gap: 2px;
1244
+ padding: 4px 12px 8px;
1245
+ border-bottom: 1px solid var(--border);
1246
+ }
1247
+
1248
+ #usage-body {
1249
+ flex: 1;
1250
+ overflow-y: auto;
1251
+ padding: 8px 12px;
1252
+ }
1253
+
1254
+ .usage-table {
1255
+ width: 100%;
1256
+ border-collapse: collapse;
1257
+ font-size: 12px;
1258
+ }
1259
+
1260
+ .usage-table th {
1261
+ text-align: right;
1262
+ padding: 4px 8px;
1263
+ color: var(--dim);
1264
+ font-weight: 600;
1265
+ border-bottom: 1px solid var(--border);
1266
+ font-size: 11px;
1267
+ }
1268
+
1269
+ .usage-table th:first-child {
1270
+ text-align: left;
1271
+ }
1272
+
1273
+ .usage-table td {
1274
+ padding: 3px 8px;
1275
+ border-bottom: 1px solid var(--border);
1276
+ white-space: nowrap;
1277
+ }
1278
+
1279
+ .usage-name {
1280
+ text-align: left;
1281
+ overflow: hidden;
1282
+ text-overflow: ellipsis;
1283
+ max-width: 340px;
1284
+ }
1285
+
1286
+ .usage-num {
1287
+ text-align: right;
1288
+ font-family: monospace;
1289
+ }
1290
+
1291
+ .usage-provider-row {
1292
+ cursor: pointer;
1293
+ font-weight: 600;
1294
+ }
1295
+
1296
+ .usage-provider-row:hover {
1297
+ background: var(--tool-pending-bg);
1298
+ }
1299
+
1300
+ .usage-model-row {
1301
+ color: var(--muted);
1302
+ font-size: 11px;
1303
+ }
1304
+
1305
+ .usage-totals {
1306
+ font-weight: 700;
1307
+ border-top: 2px solid var(--border);
1308
+ }
1309
+
1310
+ .usage-insights {
1311
+ display: flex;
1312
+ flex-direction: column;
1313
+ gap: 8px;
1314
+ }
1315
+
1316
+ .usage-insight {
1317
+ border: 1px solid var(--border);
1318
+ border-radius: 6px;
1319
+ padding: 8px 12px;
1320
+ font-size: 13px;
1321
+ display: flex;
1322
+ align-items: baseline;
1323
+ gap: 10px;
1324
+ flex-wrap: wrap;
1325
+ }
1326
+
1327
+ .usage-insight.alarm {
1328
+ border-left: 3px solid var(--error);
1329
+ }
1330
+
1331
+ .usage-insight-stat {
1332
+ font-weight: 700;
1333
+ color: var(--accent);
1334
+ font-family: monospace;
1335
+ white-space: nowrap;
1336
+ }
1337
+
1338
+ .usage-insight-advice {
1339
+ width: 100%;
1340
+ color: var(--dim);
1341
+ font-size: 12px;
1342
+ }
1343
+
1344
+ .usage-graph-title {
1345
+ font-size: 13px;
1346
+ font-weight: 600;
1347
+ margin-bottom: 6px;
1348
+ color: var(--text);
1349
+ }
1350
+
1351
+ .usage-bars {
1352
+ display: flex;
1353
+ flex-direction: column;
1354
+ gap: 4px;
1355
+ }
1356
+
1357
+ .usage-bar-row {
1358
+ display: flex;
1359
+ align-items: center;
1360
+ gap: 8px;
1361
+ font-size: 12px;
1362
+ }
1363
+
1364
+ .usage-bar-label {
1365
+ width: 200px;
1366
+ overflow: hidden;
1367
+ text-overflow: ellipsis;
1368
+ white-space: nowrap;
1369
+ text-align: right;
1370
+ }
1371
+
1372
+ .usage-bar-track {
1373
+ flex: 1;
1374
+ height: 14px;
1375
+ background: var(--tool-pending-bg);
1376
+ border-radius: 3px;
1377
+ overflow: hidden;
1378
+ }
1379
+
1380
+ .usage-bar {
1381
+ height: 100%;
1382
+ background: var(--accent);
1383
+ border-radius: 3px;
1384
+ }
1385
+
1386
+ .usage-bar-val {
1387
+ width: 70px;
1388
+ text-align: right;
1389
+ font-family: monospace;
1390
+ color: var(--muted);
1391
+ }
1392
+
1393
+ .usage-hourly {
1394
+ position: relative;
1395
+ display: flex;
1396
+ align-items: flex-end;
1397
+ gap: 2px;
1398
+ height: 120px;
1399
+ /* fixed-width columns; long windows scroll horizontally - the chart is
1400
+ the one place inside the panel where x-scrolling is acceptable */
1401
+ overflow-x: auto;
1402
+ }
1403
+
1404
+ /* Zero baseline spanning the whole chart, just above the time labels. */
1405
+ .usage-hourly::after {
1406
+ content: '';
1407
+ position: absolute;
1408
+ left: 0;
1409
+ right: 0;
1410
+ bottom: 24px;
1411
+ border-bottom: 1px solid var(--border);
1412
+ pointer-events: none;
1413
+ }
1414
+
1415
+ .usage-hour-bar-wrap {
1416
+ flex: 1;
1417
+ display: flex;
1418
+ align-items: flex-end;
1419
+ min-height: 0;
1420
+ }
1421
+
1422
+ .usage-hour-col {
1423
+ flex: 0 0 20px;
1424
+ width: 20px;
1425
+ height: 100%;
1426
+ display: flex;
1427
+ flex-direction: column;
1428
+ align-items: stretch;
1429
+ }
1430
+
1431
+ /* Day/night banding: 6:00-17:59 = day (warm tint), rest = night (cool tint) */
1432
+ .usage-hour-col.day {
1433
+ background: color-mix(in srgb, var(--accent) 5%, transparent);
1434
+ }
1435
+
1436
+ .usage-hour-col.night {
1437
+ background: color-mix(in srgb, var(--muted) 12%, transparent);
1438
+ }
1439
+
1440
+ .usage-hour-bar-wrap {
1441
+ flex: 1;
1442
+ display: flex;
1443
+ align-items: flex-end;
1444
+ min-height: 0;
1445
+ }
1446
+
1447
+ .usage-hour-label {
1448
+ font-size: 9px;
1449
+ color: var(--dim);
1450
+ text-align: center;
1451
+ line-height: 1.15;
1452
+ white-space: nowrap;
1453
+ overflow: hidden;
1454
+ text-overflow: ellipsis;
1455
+ height: 22px;
1456
+ margin-top: 2px;
1457
+ }
1458
+
1459
+ .usage-hour-bar {
1460
+ width: 100%;
1461
+ background: var(--accent);
1462
+ opacity: 0.75;
1463
+ border-radius: 2px 2px 0 0;
1464
+ }
1465
+
1466
+ #usage-close {
1467
+ padding: 8px 16px;
1468
+ text-align: right;
1469
+ font-size: 12px;
1470
+ color: var(--dim);
1471
+ cursor: pointer;
1472
+ border-top: 1px solid var(--border);
1473
+ }
1474
+
1475
+ #usage-close:hover {
1476
+ color: var(--text);
1477
+ }
@@ -199,6 +199,55 @@ export class WebUIContext {
199
199
  setFooter() { }
200
200
  setHeader() { }
201
201
  custom() {
202
+ // Pi's custom() shows an extension-drawn TUI component. Web has no TUI
203
+ // renderer, so the component can't be *displayed* - but the factory is
204
+ // still invoked with no-op stubs so the extension's own logic runs
205
+ // (e.g. /usage starts its data collection inside the factory), then we
206
+ // settle immediately: display-only panels (user-driven close) would
207
+ // otherwise hang the command forever, and data-producing panels
208
+ // (loader -> done(value)) have their result read from the extension's
209
+ // own cache file afterwards when needed (see usage-render.ts). So
210
+ // custom() never blocks the command.
211
+ // Extensions that branch on hasUI before custom (magic-context
212
+ // ctx-status) are routed to their text fallback by executeCommand
213
+ // (hasUI:false) and never reach this.
214
+ try {
215
+ // Arguments are (factory, options) at runtime; the declared
216
+ // signature stays interface-compatible, read them dynamically.
217
+ const args = arguments;
218
+ const factory = args[0];
219
+ const options = args[1];
220
+ if (typeof factory === "function") {
221
+ // No-op TUI stub: enough surface for factories that need a render
222
+ // handle; rendering itself is never performed on Web.
223
+ const stubTui = {
224
+ requestRender: () => { },
225
+ invalidate: () => { },
226
+ setFocus: () => { },
227
+ getWidth: () => 100,
228
+ };
229
+ // Theme stub: color helpers degrade to plain text.
230
+ const stubTheme = {
231
+ fg: (_k, s) => s,
232
+ bold: (s) => s,
233
+ dim: (s) => s,
234
+ get theme() {
235
+ return undefined;
236
+ },
237
+ };
238
+ const component = factory(stubTui, stubTheme, {}, () => { });
239
+ // Component built and its logic ran (data collection started);
240
+ // NOT disposed - async work inside may still be running and own
241
+ // its resources. settle immediately.
242
+ void component;
243
+ }
244
+ if (options && typeof options.onHandle === "function") {
245
+ options.onHandle({ setHidden: () => { }, focus: () => { } });
246
+ }
247
+ }
248
+ catch {
249
+ // factory threw - ignore, still settle
250
+ }
202
251
  return Promise.resolve(undefined);
203
252
  }
204
253
  pasteToEditor() { }
@@ -0,0 +1,410 @@
1
+ /**
2
+ * Usage extension integration (independent module).
3
+ *
4
+ * pi-usage-extension's `/usage` command draws a TUI dashboard via
5
+ * ctx.ui.custom() which Web cannot render. However, running the command
6
+ * (through WebUIContext.custom() which now executes the factory) makes the
7
+ * extension collect usage data and write its cache file
8
+ * (`~/.pi/agent/usage-extension-cache.json`). This module reads that cache
9
+ * after the command runs and renders an equivalent summary as Markdown,
10
+ * shown as a titled modal - without coupling into the main server flow.
11
+ *
12
+ * Cache format (v7): { version, names: string[], files: { [sessionFile]: {
13
+ * size, mtimeMs, sessionId, cwd, parentSession,
14
+ * messages: 13-tuple[][], toolUsages: 5-tuple[][] } } }
15
+ * message tuple: [provider, model, cost, inputTokens, outputTokens,
16
+ * cacheReadTokens, cacheWriteTokens, timestampMs, thinkingLevel,
17
+ * reasoningTokens, afterCompaction(0|1), source(0 assistant|1 auxiliary),
18
+ * sourceId]
19
+ */
20
+ import { existsSync, readFileSync, statSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
23
+ export const USAGE_CACHE_FILE = "usage-extension-cache.json";
24
+ const USAGE_CACHE_DIRS = [
25
+ join(process.env.PI_HOME?.replace(/^~/, homedir()) ?? homedir(), ".pi", "agent"),
26
+ join(homedir(), ".pi", "agent"),
27
+ ];
28
+ function findCachePath() {
29
+ for (const dir of USAGE_CACHE_DIRS) {
30
+ const p = join(dir, USAGE_CACHE_FILE);
31
+ if (existsSync(p))
32
+ return p;
33
+ }
34
+ return null;
35
+ }
36
+ /** mtime of the usage cache file, or null when absent. */
37
+ export function usageCacheMtime() {
38
+ const path = findCachePath();
39
+ if (!path)
40
+ return null;
41
+ try {
42
+ return statSync(path).mtimeMs;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /** Read and normalize the usage cache; null when absent/unreadable. */
49
+ export function loadUsageCache() {
50
+ const path = findCachePath();
51
+ if (!path)
52
+ return null;
53
+ try {
54
+ const raw = JSON.parse(readFileSync(path, "utf8"));
55
+ const names = raw.names ?? [];
56
+ const files = [];
57
+ for (const file of Object.values(raw.files ?? {})) {
58
+ const f = file;
59
+ const messages = [];
60
+ if (Array.isArray(f.messages)) {
61
+ for (const tuple of f.messages) {
62
+ if (!Array.isArray(tuple) || tuple.length !== 13)
63
+ continue;
64
+ const provider = names[tuple[0]];
65
+ const model = names[tuple[1]];
66
+ const thinkingLevel = names[tuple[8]];
67
+ if (typeof provider !== "string" || typeof model !== "string")
68
+ continue;
69
+ messages.push({
70
+ provider,
71
+ model,
72
+ thinkingLevel,
73
+ cost: Number(tuple[2]) || 0,
74
+ input: Number(tuple[3]) || 0,
75
+ output: Number(tuple[4]) || 0,
76
+ cacheRead: Number(tuple[5]) || 0,
77
+ cacheWrite: Number(tuple[6]) || 0,
78
+ timestamp: Number(tuple[7]) || 0,
79
+ reasoning: Number(tuple[9]) || 0,
80
+ afterCompaction: tuple[10] === 1,
81
+ source: tuple[11] === 1 ? "auxiliary" : "assistant",
82
+ });
83
+ }
84
+ }
85
+ files.push({
86
+ messages,
87
+ sessionId: f.sessionId ?? "",
88
+ cwd: f.cwd ?? "",
89
+ parentSession: f.parentSession ?? "",
90
+ });
91
+ }
92
+ return { files, names };
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
98
+ function startOfDay(ts) {
99
+ const d = new Date(ts);
100
+ d.setHours(0, 0, 0, 0);
101
+ return d.getTime();
102
+ }
103
+ function startOfWeek(ts) {
104
+ const d = new Date(ts);
105
+ const day = d.getDay() === 0 ? 6 : d.getDay() - 1;
106
+ d.setHours(0, 0, 0, 0);
107
+ d.setDate(d.getDate() - day);
108
+ return d.getTime();
109
+ }
110
+ function emptyTotals() {
111
+ return { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, messages: 0, sessions: new Set() };
112
+ }
113
+ function fmt(n) {
114
+ return n >= 1e9
115
+ ? `${(n / 1e9).toFixed(2)}B`
116
+ : n >= 1e6
117
+ ? `${(n / 1e6).toFixed(2)}M`
118
+ : n >= 1e3
119
+ ? `${(n / 1e3).toFixed(1)}K`
120
+ : `${Math.round(n)}`;
121
+ }
122
+ function fmtCost(n) {
123
+ return `$${n.toFixed(n >= 1 ? 2 : 4)}`;
124
+ }
125
+ /** Render a Markdown usage summary (today / this week / all time). */
126
+ export function renderUsageSummary() {
127
+ const cache = loadUsageCache();
128
+ if (!cache) {
129
+ return "## Usage\n\nNo usage data found. Run `/usage` in the TUI first to build the usage cache.";
130
+ }
131
+ const now = Date.now();
132
+ const todayStart = startOfDay(now);
133
+ const weekStart = startOfWeek(now);
134
+ const today = emptyTotals();
135
+ const week = emptyTotals();
136
+ const all = emptyTotals();
137
+ const byModel = new Map();
138
+ for (const file of cache.files) {
139
+ for (const m of file.messages) {
140
+ const t = m.timestamp;
141
+ const targets = [all];
142
+ if (t >= todayStart)
143
+ targets.push(today);
144
+ if (t >= weekStart)
145
+ targets.push(week);
146
+ for (const target of targets) {
147
+ target.cost += m.cost;
148
+ target.input += m.input;
149
+ target.output += m.output;
150
+ target.cacheRead += m.cacheRead;
151
+ target.cacheWrite += m.cacheWrite;
152
+ target.messages += 1;
153
+ target.sessions.add(file.sessionId);
154
+ }
155
+ const key = `${m.provider}/${m.model}`;
156
+ const byModelEntry = byModel.get(key) ?? emptyTotals();
157
+ byModelEntry.cost += m.cost;
158
+ byModelEntry.input += m.input;
159
+ byModelEntry.output += m.output;
160
+ byModelEntry.cacheRead += m.cacheRead;
161
+ byModelEntry.cacheWrite += m.cacheWrite;
162
+ byModelEntry.messages += 1;
163
+ byModel.set(key, byModelEntry);
164
+ }
165
+ }
166
+ const row = (label, t) => `| ${label} | ${fmt(t.input)} | ${fmt(t.output)} | ${fmt(t.cacheRead)} | ${fmt(t.cacheWrite)} | $${t.cost.toFixed(4)} | ${t.messages} | ${t.sessions.size} |`;
167
+ const lines = [
168
+ "## Usage",
169
+ "",
170
+ "| Period | Input | Output | Cache R | Cache W | Cost | Msgs | Sessions |",
171
+ "| --- | --- | --- | --- | --- | --- | --- | --- |",
172
+ row("Today", today),
173
+ row("This week", week),
174
+ row("All time", all),
175
+ ];
176
+ if (byModel.size > 0) {
177
+ const sorted = [...byModel.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 10);
178
+ lines.push("", "### By model (all time)", "", "| Model | Input | Output | Cost | Msgs |", "| --- | --- | --- | --- | --- |");
179
+ for (const [key, t] of sorted) {
180
+ lines.push(`| ${key} | ${fmt(t.input)} | ${fmt(t.output)} | $${t.cost.toFixed(4)} | ${t.messages} |`);
181
+ }
182
+ }
183
+ return lines.join("\n");
184
+ }
185
+ const TAB_KEYS = ["today", "thisWeek", "lastWeek", "last30Days", "allTime"];
186
+ function emptyTokens() {
187
+ return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
188
+ }
189
+ function addTokens(a, b) {
190
+ a.total += b.total;
191
+ a.input += b.input;
192
+ a.output += b.output;
193
+ a.cacheRead += b.cacheRead;
194
+ a.cacheWrite += b.cacheWrite;
195
+ }
196
+ /** Format a cost value the way the extension does (0 -> "-"). */
197
+ export function formatUsageCost(cost) {
198
+ if (cost === 0)
199
+ return "-";
200
+ if (cost < 0.01)
201
+ return `$${cost.toFixed(4)}`;
202
+ if (cost < 1)
203
+ return `$${cost.toFixed(2)}`;
204
+ if (cost < 10)
205
+ return `$${cost.toFixed(2)}`;
206
+ if (cost < 100)
207
+ return `$${cost.toFixed(1)}`;
208
+ return `$${Math.round(cost)}`;
209
+ }
210
+ /** Format a token count compactly (12.3K, 4.5M, ...). */
211
+ export function formatUsageTokens(n) {
212
+ if (n >= 1e9)
213
+ return `${(n / 1e9).toFixed(2)}B`;
214
+ if (n >= 1e6)
215
+ return `${(n / 1e6).toFixed(2)}M`;
216
+ if (n >= 1e3)
217
+ return `${(n / 1e3).toFixed(1)}K`;
218
+ return `${Math.round(n)}`;
219
+ }
220
+ function periodStart(key, now) {
221
+ const d = new Date(now);
222
+ d.setHours(0, 0, 0, 0);
223
+ if (key === "today")
224
+ return d.getTime();
225
+ const day = d.getDay() === 0 ? 6 : d.getDay() - 1; // Monday start
226
+ if (key === "thisWeek") {
227
+ d.setDate(d.getDate() - day);
228
+ return d.getTime();
229
+ }
230
+ if (key === "lastWeek") {
231
+ d.setDate(d.getDate() - day - 7);
232
+ return d.getTime();
233
+ }
234
+ if (key === "last30Days") {
235
+ return d.getTime() - 29 * 24 * 3600 * 1000;
236
+ }
237
+ return 0; // allTime
238
+ }
239
+ /**
240
+ * Aggregate the cache into the structured payload the web panel renders.
241
+ * Returns null when no cache exists yet.
242
+ */
243
+ export function buildUsageData() {
244
+ const cache = loadUsageCache();
245
+ if (!cache)
246
+ return null;
247
+ const now = Date.now();
248
+ // per-tab accumulators
249
+ const tabs = {};
250
+ for (const key of TAB_KEYS) {
251
+ tabs[key] = { providers: new Map(), totals: { messages: 0, cost: 0, tokens: emptyTokens(), sessions: new Set() } };
252
+ }
253
+ // hourly buckets (all providers, global) - the render side filters per tab
254
+ const hourly = new Map();
255
+ const hourStart = (ts) => Math.floor(ts / 3600_000) * 3600_000;
256
+ for (const file of cache.files) {
257
+ for (const m of file.messages) {
258
+ const ts = m.timestamp;
259
+ {
260
+ const bucket = hourly.get(hourStart(ts));
261
+ if (bucket) {
262
+ bucket.cost += m.cost;
263
+ bucket.tokens += m.input + m.output + m.cacheRead + m.cacheWrite;
264
+ bucket.messages += 1;
265
+ }
266
+ else {
267
+ hourly.set(hourStart(ts), { cost: m.cost, tokens: m.input + m.output + m.cacheRead + m.cacheWrite, messages: 1 });
268
+ }
269
+ }
270
+ for (const key of TAB_KEYS) {
271
+ const start = periodStart(key, now);
272
+ if (ts < start)
273
+ continue;
274
+ const tab = tabs[key];
275
+ tab.totals.messages += 1;
276
+ tab.totals.cost += m.cost;
277
+ addTokens(tab.totals.tokens, {
278
+ total: m.input + m.output + m.cacheRead + m.cacheWrite,
279
+ input: m.input,
280
+ output: m.output,
281
+ cacheRead: m.cacheRead,
282
+ cacheWrite: m.cacheWrite,
283
+ });
284
+ tab.totals.sessions.add(file.sessionId);
285
+ let prov = tab.providers.get(m.provider);
286
+ if (!prov) {
287
+ prov = {
288
+ messages: 0, cost: 0, tokens: emptyTokens(), sessions: new Set(), models: new Map(),
289
+ };
290
+ tab.providers.set(m.provider, prov);
291
+ }
292
+ prov.messages += 1;
293
+ prov.cost += m.cost;
294
+ addTokens(prov.tokens, {
295
+ total: m.input + m.output + m.cacheRead + m.cacheWrite,
296
+ input: m.input,
297
+ output: m.output,
298
+ cacheRead: m.cacheRead,
299
+ cacheWrite: m.cacheWrite,
300
+ });
301
+ prov.sessions.add(file.sessionId);
302
+ let model = prov.models.get(m.model);
303
+ if (!model) {
304
+ model = { messages: 0, cost: 0, tokens: emptyTokens(), sessions: new Set() };
305
+ prov.models.set(m.model, model);
306
+ }
307
+ model.messages += 1;
308
+ model.cost += m.cost;
309
+ addTokens(model.tokens, {
310
+ total: m.input + m.output + m.cacheRead + m.cacheWrite,
311
+ input: m.input,
312
+ output: m.output,
313
+ cacheRead: m.cacheRead,
314
+ cacheWrite: m.cacheWrite,
315
+ });
316
+ model.sessions.add(file.sessionId);
317
+ }
318
+ }
319
+ }
320
+ const payload = { tabs: {}, hourly: [], tabWindow: {}, collectedAt: usageCacheMtime() };
321
+ // Graph x-axis windows per tab: [start, end]. today: midnight->now;
322
+ // thisWeek: monday->now; lastWeek: monday->next Monday; last30Days:
323
+ // start->now; allTime: 0 -> now (frontend clips to first data).
324
+ for (const key of TAB_KEYS) {
325
+ const start = periodStart(key, now);
326
+ let end = now;
327
+ if (key === "lastWeek") {
328
+ const d = new Date(now);
329
+ d.setHours(0, 0, 0, 0);
330
+ const day = d.getDay() === 0 ? 6 : d.getDay() - 1;
331
+ d.setDate(d.getDate() - day);
332
+ end = d.getTime();
333
+ }
334
+ payload.tabWindow[key] = [start, end];
335
+ }
336
+ for (const key of TAB_KEYS) {
337
+ const tab = tabs[key];
338
+ const providers = [...tab.providers.entries()]
339
+ .sort((a, b) => b[1].cost - a[1].cost)
340
+ .map(([name, p]) => ({
341
+ name,
342
+ messages: p.messages,
343
+ cost: p.cost,
344
+ tokens: p.tokens,
345
+ sessions: p.sessions.size,
346
+ models: [...p.models.entries()]
347
+ .sort((a, b) => b[1].cost - a[1].cost)
348
+ .map(([mname, m]) => ({
349
+ name: mname,
350
+ messages: m.messages,
351
+ cost: m.cost,
352
+ tokens: m.tokens,
353
+ sessions: m.sessions.size,
354
+ })),
355
+ }));
356
+ // Simple insights: top provider share + largest model cost (alarms
357
+ // when material), plus a structure line for overall usage.
358
+ const insights = [];
359
+ const totalCost = tab.totals.cost;
360
+ const totalTokens = tab.totals.tokens.total;
361
+ if (totalCost > 0 && providers.length > 0) {
362
+ const top = providers[0];
363
+ const share = (top.cost / totalCost) * 100;
364
+ if (share >= 20) {
365
+ insights.push({
366
+ kind: "alarm",
367
+ stat: `${share.toFixed(0)}%`,
368
+ headline: `${top.name} drives ${share.toFixed(0)}% of cost ($${top.cost.toFixed(2)})`,
369
+ advice: "Consider switching cheaper models for routine work.",
370
+ });
371
+ }
372
+ if (top.models.length > 0) {
373
+ const topModel = [...top.models].sort((a, b) => b.cost - a.cost)[0];
374
+ if (topModel.cost / totalCost >= 0.3) {
375
+ insights.push({
376
+ kind: "alarm",
377
+ stat: formatUsageCost(topModel.cost),
378
+ headline: `${topModel.name} is the costliest model (${((topModel.cost / totalCost) * 100).toFixed(0)}% of total)`,
379
+ advice: "Check whether its output quality justifies the price.",
380
+ });
381
+ }
382
+ }
383
+ }
384
+ else if (tab.totals.messages === 0) {
385
+ insights.push({ kind: "structure", stat: "-", headline: "No usage recorded for this period.", advice: "" });
386
+ }
387
+ if (tab.totals.messages > 0) {
388
+ insights.push({
389
+ kind: "structure",
390
+ stat: formatUsageTokens(totalTokens),
391
+ headline: `${tab.totals.messages.toLocaleString()} messages, ${formatUsageTokens(totalTokens)} tokens total`,
392
+ advice: totalCost > 0 ? `Spent ${formatUsageCost(totalCost)} across ${tab.totals.sessions.size} session(s).` : "",
393
+ });
394
+ }
395
+ payload.tabs[key] = {
396
+ providers,
397
+ totals: {
398
+ messages: tab.totals.messages,
399
+ cost: tab.totals.cost,
400
+ tokens: tab.totals.tokens,
401
+ sessions: tab.totals.sessions.size,
402
+ },
403
+ insights,
404
+ };
405
+ }
406
+ payload.hourly = [...hourly.entries()]
407
+ .sort((a, b) => a[0] - b[0])
408
+ .map(([hour, v]) => ({ hour, cost: v.cost, tokens: v.tokens, messages: v.messages }));
409
+ return payload;
410
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.4.10",
3
+ "version": "0.5.0",
4
4
  "description": "Browser Web access for Pi (AI coding agent) via the Pi SDK - standalone module, zero modification to Pi itself",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  "dist"
12
12
  ],
13
13
  "scripts": {
14
- "build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../static', 'dist/static', { recursive: true }); require('node:fs').mkdirSync('dist/pi-bin', { recursive: true }); require('node:fs').renameSync('dist/pii-cli.js', 'dist/pi-bin/pii-cli.js'); require('node:fs').cpSync('../pii/pii', 'dist/pi-bin/pii'); require('node:fs').cpSync('../server', 'dist/pi-bin/server', { recursive: true, filter: (s) => !s.includes('__pycache__') })\"",
14
+ "build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../static', 'dist/static', { recursive: true }); require('node:fs').mkdirSync('dist/pi-bin', { recursive: true }); require('node:fs').renameSync('dist/pii-cli.js', 'dist/pi-bin/pii-cli.js'); require('node:fs').chmodSync('dist/pi-bin/pii-cli.js', 0o755); require('node:fs').cpSync('../pii/pii', 'dist/pi-bin/pii'); require('node:fs').cpSync('../server', 'dist/pi-bin/server', { recursive: true, filter: (s) => !s.includes('__pycache__') })\"",
15
15
  "dev": "tsx src/cli.ts",
16
16
  "verify": "tsx src/verify-sdk.ts",
17
17
  "prepublishOnly": "npm run build"