pi-sdk-web 0.5.2 → 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
@@ -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;
@@ -205,7 +198,7 @@ function addTokens(a, b) {
205
198
  a.cacheWrite += b.cacheWrite;
206
199
  }
207
200
  /** Format a cost value the way the extension does (0 -> "-"). */
208
- export function formatUsageCost(cost) {
201
+ function formatUsageCost(cost) {
209
202
  if (cost === 0)
210
203
  return "-";
211
204
  if (cost < 0.01)
@@ -219,7 +212,7 @@ export function formatUsageCost(cost) {
219
212
  return `$${Math.round(cost)}`;
220
213
  }
221
214
  /** Format a token count compactly (12.3K, 4.5M, ...). */
222
- export function formatUsageTokens(n) {
215
+ function formatUsageTokens(n) {
223
216
  if (n >= 1e9)
224
217
  return `${(n / 1e9).toFixed(2)}B`;
225
218
  if (n >= 1e6)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.5.2",
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": {