pi-sdk-web 0.5.1 → 0.5.3

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/dist/server.js CHANGED
@@ -270,6 +270,19 @@ export class PiWebServer {
270
270
  if (Object.keys(extStatus).length > 0) {
271
271
  this.sendJson(ws, { type: "ext_status", data: extStatus });
272
272
  }
273
+ // Current widgets (setWidget may have fired before this client connected,
274
+ // e.g. magic-context's todos overlay) - replay as individual requests so
275
+ // the browser's extension_ui_request handler renders them.
276
+ for (const [key, w] of Object.entries(this.uiContext.getWidgetSnapshot())) {
277
+ this.sendJson(ws, {
278
+ type: "extension_ui_request",
279
+ id: crypto.randomUUID(),
280
+ method: "setWidget",
281
+ widgetKey: key,
282
+ widgetLines: w.lines,
283
+ widgetPlacement: w.placement,
284
+ });
285
+ }
273
286
  }
274
287
  sendJson(ws, obj) {
275
288
  if (ws.readyState === WebSocket.OPEN) {
@@ -792,31 +805,15 @@ export class PiWebServer {
792
805
  // (appendEntry -> modal), same as before.
793
806
  const cmdPath = (cmd.sourceInfo && cmd.sourceInfo.path) || "";
794
807
  const customOnly = /pi-magic-context|magic-context/.test(cmdPath);
795
- const ui = this.uiContext;
796
- const originalSink = ui.sink;
797
808
  try {
798
- // While the command runs, notify() broadcasts are tagged with the
799
- // command name as title, so the browser shows them as a titled modal
800
- // (colored, Markdown-rendered) instead of a plain stream line.
801
- ui.sink = (obj) => {
802
- const req = obj;
803
- if (req && req.method === "notify") {
804
- // Tag command-time notifies with the command name as title so the
805
- // browser renders them as a titled modal (colored, Markdown).
806
- originalSink({ ...obj, title: `/${name}` });
807
- }
808
- else {
809
- originalSink(obj);
810
- }
811
- };
809
+ // No sink wrapping: notify() broadcasts go through as-is (no title),
810
+ // so the frontend renders them as persistent status lines - the same
811
+ // as TUI's notify -> showStatus (a dim line appended to the chat),
812
+ // whether or not they come from a command. Command *results* (custom
813
+ // entries) still become titled modals via the captured entries below.
812
814
  await cmd.handler(args, this.buildCommandContext(customOnly ? false : true));
813
815
  }
814
- catch (e) {
815
- ui.sink = originalSink;
816
- throw e;
817
- }
818
816
  finally {
819
- ui.sink = originalSink;
820
817
  unsubscribe();
821
818
  }
822
819
  // Show command output as a modal (TUI-like). The session entry remains
@@ -1944,8 +1944,18 @@ class PiWebClient {
1944
1944
  }
1945
1945
 
1946
1946
  appendNotifyLine(message, type) {
1947
+ // TUI's showStatus reuses the LAST status line: consecutive notifies
1948
+ // update it in place (a sticky status); a new line starts only when
1949
+ // something else (a message) was inserted in between. Mirror that here.
1950
+ const last = this.contentEl.lastElementChild;
1951
+ if (last && last.classList.contains('notify-line') && last.dataset.notifyType === (type || '')) {
1952
+ last.innerHTML = ansiToHtml(String(message));
1953
+ this.scrollToBottom();
1954
+ return;
1955
+ }
1947
1956
  const div = document.createElement('div');
1948
1957
  div.className = 'notify-line' + (type ? ` notify-${type}` : '');
1958
+ div.dataset.notifyType = type || '';
1949
1959
  div.innerHTML = ansiToHtml(String(message));
1950
1960
  this.contentEl.appendChild(div);
1951
1961
  this.scrollToBottom();
@@ -66,6 +66,13 @@ export class WebUIContext {
66
66
  webTheme = createWebTheme();
67
67
  /** Latest setStatus values per key, so late-connecting browsers get current state */
68
68
  statusMap = new Map();
69
+ /** Latest setWidget lines per key (persistent widgets, e.g. TodoOverlay):
70
+ * replayed to late-connecting browsers like setStatus snapshots. */
71
+ widgetMap = new Map();
72
+ /** Current widget snapshot (key -> lines) for new connections. */
73
+ getWidgetSnapshot() {
74
+ return Object.fromEntries(this.widgetMap);
75
+ }
69
76
  constructor(sink) {
70
77
  this.sink = sink;
71
78
  // Pi's ExtensionRunner wraps the ui context with `{...ui}` (a shallow
@@ -106,6 +113,7 @@ export class WebUIContext {
106
113
  }
107
114
  this.pending.clear();
108
115
  this.statusMap.clear();
116
+ this.widgetMap.clear();
109
117
  }
110
118
  /** Handle a browser `extension_ui_response` message. */
111
119
  respond(id, response) {
@@ -177,14 +185,41 @@ export class WebUIContext {
177
185
  this.sink({ type: "extension_ui_request", id: crypto.randomUUID(), method: "setTitle", title });
178
186
  }
179
187
  setWidget(key, content, options) {
188
+ let lines;
189
+ if (Array.isArray(content)) {
190
+ lines = content.map((l) => String(l));
191
+ }
192
+ else if (typeof content === "function") {
193
+ // Some extensions (e.g. magic-context's TodoOverlay) pass a TUI
194
+ // component factory instead of string lines. Web has no TUI renderer,
195
+ // but the factory yields a render(width) -> string[] that we can
196
+ // evaluate with a no-op tui stub and broadcast as text lines - the
197
+ // browser renders them (with ANSI colors) in the widgets panel.
198
+ try {
199
+ const stubTui = { requestRender: () => { }, invalidate: () => { } };
200
+ const component = content(stubTui, this.webTheme);
201
+ const render = component && component.render;
202
+ if (typeof render === "function") {
203
+ const width = 78;
204
+ const out = render(width);
205
+ if (Array.isArray(out))
206
+ lines = out.map((l) => String(l));
207
+ }
208
+ }
209
+ catch {
210
+ // fall through with no lines - widget renders empty
211
+ }
212
+ }
180
213
  this.sink({
181
214
  type: "extension_ui_request",
182
215
  id: crypto.randomUUID(),
183
216
  method: "setWidget",
184
217
  widgetKey: key,
185
- widgetLines: Array.isArray(content) ? content : undefined,
218
+ widgetLines: lines,
186
219
  widgetPlacement: options?.placement,
187
220
  });
221
+ // Store for late-connecting browsers (replayed on WS connect).
222
+ this.widgetMap.set(key, { lines, placement: options?.placement });
188
223
  }
189
224
  // ------------------------------------------------------------------
190
225
  // Terminal-specific features: no-op in web mode
@@ -200,54 +235,11 @@ export class WebUIContext {
200
235
  setHeader() { }
201
236
  custom() {
202
237
  // 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
- }
238
+ // renderer, so the component can't be displayed - same headless stub as
239
+ // Pi's RPC mode (rpc-mode.ts: "Custom UI not supported in RPC mode"):
240
+ // settle immediately so commands awaiting the panel don't hang. This is
241
+ // now purely internal: /usage (which used to collect data inside the
242
+ // factory) collects natively from Pi session files instead.
251
243
  return Promise.resolve(undefined);
252
244
  }
253
245
  pasteToEditor() { }
@@ -14,7 +14,7 @@
14
14
  * (5 time tabs x provider/model x metrics + insights + global hourly
15
15
  * series + tab windows); the frontend renders.
16
16
  */
17
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
17
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { homedir } from "node:os";
20
20
  const SESSIONS_DIRS = [
@@ -73,11 +73,10 @@ function tsOf(messageTimestamp, entryTimestamp) {
73
73
  }
74
74
  return 0;
75
75
  }
76
- function auxMessage(usage, ts, sourceId) {
76
+ function auxMessage(usage, ts) {
77
77
  return {
78
78
  provider: "aux",
79
79
  model: "auxiliary",
80
- thinkingLevel: undefined,
81
80
  cost: usage.cost,
82
81
  input: usage.input,
83
82
  output: usage.output,
@@ -85,7 +84,6 @@ function auxMessage(usage, ts, sourceId) {
85
84
  cacheWrite: usage.cacheWrite,
86
85
  timestamp: ts,
87
86
  reasoning: usage.reasoning,
88
- afterCompaction: false,
89
87
  source: "auxiliary",
90
88
  };
91
89
  }
@@ -98,7 +96,6 @@ function parseSessionFile(path) {
98
96
  let sessionId = "";
99
97
  let cwd = "";
100
98
  let parentSession = "";
101
- let thinkingLevel;
102
99
  let compactionPending = false;
103
100
  const messages = [];
104
101
  let content;
@@ -129,21 +126,19 @@ function parseSessionFile(path) {
129
126
  break;
130
127
  }
131
128
  case "thinking_level_change": {
132
- if (typeof entry.thinkingLevel === "string")
133
- thinkingLevel = entry.thinkingLevel;
134
129
  break;
135
130
  }
136
131
  case "compaction": {
137
132
  const usage = parseUsageAmount(entry.usage);
138
133
  if (usage)
139
- messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp), typeof entry.id === "string" ? entry.id : ""));
134
+ messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp)));
140
135
  compactionPending = true;
141
136
  break;
142
137
  }
143
138
  case "branch_summary": {
144
139
  const usage = parseUsageAmount(entry.usage);
145
140
  if (usage)
146
- messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp), typeof entry.id === "string" ? entry.id : ""));
141
+ messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp)));
147
142
  break;
148
143
  }
149
144
  case "message": {
@@ -156,7 +151,6 @@ function parseSessionFile(path) {
156
151
  messages.push({
157
152
  provider: msg.provider,
158
153
  model: msg.model,
159
- thinkingLevel,
160
154
  cost: usage.cost,
161
155
  input: usage.input,
162
156
  output: usage.output,
@@ -164,7 +158,6 @@ function parseSessionFile(path) {
164
158
  cacheWrite: usage.cacheWrite,
165
159
  timestamp: tsOf(msg.timestamp, entry.timestamp),
166
160
  reasoning: usage.reasoning,
167
- afterCompaction: compactionPending,
168
161
  source: "assistant",
169
162
  });
170
163
  compactionPending = false;
@@ -193,111 +186,6 @@ export function collectUsage() {
193
186
  }
194
187
  return out;
195
188
  }
196
- /** A coarse freshness stamp: sum of session-file mtimes, for cheap invalidation. */
197
- export function sessionsStamp() {
198
- const root = sessionsDir();
199
- if (!root)
200
- return 0;
201
- const files = [];
202
- collectSessionFiles(root, files);
203
- let sum = 0;
204
- for (const f of files) {
205
- try {
206
- sum += statSync(f).mtimeMs;
207
- }
208
- catch {
209
- // ignore
210
- }
211
- }
212
- return sum;
213
- }
214
- function startOfDay(ts) {
215
- const d = new Date(ts);
216
- d.setHours(0, 0, 0, 0);
217
- return d.getTime();
218
- }
219
- function startOfWeek(ts) {
220
- const d = new Date(ts);
221
- const day = d.getDay() === 0 ? 6 : d.getDay() - 1;
222
- d.setHours(0, 0, 0, 0);
223
- d.setDate(d.getDate() - day);
224
- return d.getTime();
225
- }
226
- function emptyTotals() {
227
- return { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, messages: 0, sessions: new Set() };
228
- }
229
- function fmt(n) {
230
- return n >= 1e9
231
- ? `${(n / 1e9).toFixed(2)}B`
232
- : n >= 1e6
233
- ? `${(n / 1e6).toFixed(2)}M`
234
- : n >= 1e3
235
- ? `${(n / 1e3).toFixed(1)}K`
236
- : `${Math.round(n)}`;
237
- }
238
- function fmtCost(n) {
239
- return `$${n.toFixed(n >= 1 ? 2 : 4)}`;
240
- }
241
- /** Render a Markdown usage summary (today / this week / all time). */
242
- export function renderUsageSummary() {
243
- const files = collectUsage();
244
- if (files.length === 0) {
245
- return "## Usage\n\nNo usage data found yet.";
246
- }
247
- const now = Date.now();
248
- const todayStart = startOfDay(now);
249
- const weekStart = startOfWeek(now);
250
- const today = emptyTotals();
251
- const week = emptyTotals();
252
- const all = emptyTotals();
253
- const byModel = new Map();
254
- for (const file of files) {
255
- for (const m of file.messages) {
256
- const t = m.timestamp;
257
- const targets = [all];
258
- if (t >= todayStart)
259
- targets.push(today);
260
- if (t >= weekStart)
261
- targets.push(week);
262
- for (const target of targets) {
263
- target.cost += m.cost;
264
- target.input += m.input;
265
- target.output += m.output;
266
- target.cacheRead += m.cacheRead;
267
- target.cacheWrite += m.cacheWrite;
268
- target.messages += 1;
269
- target.sessions.add(file.sessionId);
270
- }
271
- const key = `${m.provider}/${m.model}`;
272
- const byModelEntry = byModel.get(key) ?? emptyTotals();
273
- byModelEntry.cost += m.cost;
274
- byModelEntry.input += m.input;
275
- byModelEntry.output += m.output;
276
- byModelEntry.cacheRead += m.cacheRead;
277
- byModelEntry.cacheWrite += m.cacheWrite;
278
- byModelEntry.messages += 1;
279
- byModel.set(key, byModelEntry);
280
- }
281
- }
282
- 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} |`;
283
- const lines = [
284
- "## Usage",
285
- "",
286
- "| Period | Input | Output | Cache R | Cache W | Cost | Msgs | Sessions |",
287
- "| --- | --- | --- | --- | --- | --- | --- | --- |",
288
- row("Today", today),
289
- row("This week", week),
290
- row("All time", all),
291
- ];
292
- if (byModel.size > 0) {
293
- const sorted = [...byModel.entries()].sort((a, b) => b[1].cost - a[1].cost).slice(0, 10);
294
- lines.push("", "### By model (all time)", "", "| Model | Input | Output | Cost | Msgs |", "| --- | --- | --- | --- | --- |");
295
- for (const [key, t] of sorted) {
296
- lines.push(`| ${key} | ${fmt(t.input)} | ${fmt(t.output)} | $${t.cost.toFixed(4)} | ${t.messages} |`);
297
- }
298
- }
299
- return lines.join("\n");
300
- }
301
189
  const TAB_KEYS = ["today", "thisWeek", "lastWeek", "last30Days", "allTime"];
302
190
  function emptyTokens() {
303
191
  return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
@@ -310,7 +198,7 @@ function addTokens(a, b) {
310
198
  a.cacheWrite += b.cacheWrite;
311
199
  }
312
200
  /** Format a cost value the way the extension does (0 -> "-"). */
313
- export function formatUsageCost(cost) {
201
+ function formatUsageCost(cost) {
314
202
  if (cost === 0)
315
203
  return "-";
316
204
  if (cost < 0.01)
@@ -324,7 +212,7 @@ export function formatUsageCost(cost) {
324
212
  return `$${Math.round(cost)}`;
325
213
  }
326
214
  /** Format a token count compactly (12.3K, 4.5M, ...). */
327
- export function formatUsageTokens(n) {
215
+ function formatUsageTokens(n) {
328
216
  if (n >= 1e9)
329
217
  return `${(n / 1e9).toFixed(2)}B`;
330
218
  if (n >= 1e6)
@@ -436,7 +324,7 @@ export function buildUsageData(sessionId) {
436
324
  }
437
325
  }
438
326
  }
439
- const payload = { tabs: {}, hourly: [], tabWindow: {}, collectedAt: sessionsStamp() };
327
+ const payload = { tabs: {}, hourly: [], tabWindow: {}, collectedAt: Date.now() };
440
328
  // Graph x-axis windows per tab: [start, end]. today: midnight->now;
441
329
  // thisWeek: monday->now; lastWeek: monday->next Monday; last30Days:
442
330
  // start->now; allTime: 0 -> now (frontend clips to first data).
@@ -494,7 +382,9 @@ export function buildUsageData(sessionId) {
494
382
  insights.push({
495
383
  kind: "alarm",
496
384
  stat: formatUsageCost(topModel.cost),
497
- headline: `${topModel.name} is the costliest model (${((topModel.cost / totalCost) * 100).toFixed(0)}% of total)`,
385
+ // Model names can repeat across providers (e.g. deepseek-v4-
386
+ // flash on several providers) - show provider/model.
387
+ headline: `${top.name}/${topModel.name} is the costliest model (${((topModel.cost / totalCost) * 100).toFixed(0)}% of total)`,
498
388
  advice: "Check whether its output quality justifies the price.",
499
389
  });
500
390
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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": {