pi-sdk-web 0.5.6 → 0.5.8

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
@@ -93,7 +93,25 @@ export class PiWebServer {
93
93
  // Some components (ws internals, MCP-style extensions) accumulate 'close'
94
94
  // listeners on the server; raise the limit to avoid MaxListenersExceededWarning
95
95
  this.httpServer.setMaxListeners(50);
96
- this.wsServer = new WebSocketServer({ server: this.httpServer, path: "/ws" });
96
+ this.wsServer = new WebSocketServer({
97
+ server: this.httpServer,
98
+ path: "/ws",
99
+ // CSWSH guard: only accept same-origin connections (the page itself).
100
+ // ws://127.0.0.1:<port> has no Origin header requirement in browsers,
101
+ // but cross-origin pages would send their own Origin - reject those.
102
+ verifyClient: (info) => {
103
+ const origin = info.origin;
104
+ if (!origin)
105
+ return true; // non-browser clients (no Origin header)
106
+ try {
107
+ const u = new URL(origin);
108
+ return u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "::1";
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ },
114
+ });
97
115
  this.wsServer.setMaxListeners(50);
98
116
  this.wsServer.on("connection", (ws) => this.handleConnection(ws));
99
117
  await new Promise((resolvePromise, reject) => {
@@ -150,6 +168,18 @@ export class PiWebServer {
150
168
  // would stay hidden until the new session's first turn event.
151
169
  const extStatus = this.uiContext.getStatusSnapshot();
152
170
  this.broadcast({ type: "ext_status", data: extStatus });
171
+ // Same for widgets (e.g. todos overlay): clearContent() wipes #widgets
172
+ // and there is no other channel after switching.
173
+ for (const [key, w] of Object.entries(this.uiContext.getWidgetSnapshot())) {
174
+ this.broadcast({
175
+ type: "extension_ui_request",
176
+ id: crypto.randomUUID(),
177
+ method: "setWidget",
178
+ widgetKey: key,
179
+ widgetLines: w.lines,
180
+ widgetPlacement: w.placement,
181
+ });
182
+ }
153
183
  }
154
184
  }
155
185
  async stop() {
@@ -713,8 +743,13 @@ export class PiWebServer {
713
743
  this.unsubscribe = null;
714
744
  try {
715
745
  const result = await this.runtime.switchSession(path);
716
- if (result.cancelled)
746
+ if (result.cancelled) {
747
+ // switchSession was cancelled (e.g. session_before_switch veto): the
748
+ // old session is still live and its events stopped - resubscribe to
749
+ // keep the server usable.
750
+ await this.bindCurrentSession(false);
717
751
  return;
752
+ }
718
753
  }
719
754
  catch (err) {
720
755
  // Rebind hook already ran on failure? No: on error the old session may be
@@ -214,7 +214,6 @@ class PiWebClient {
214
214
  this.streaming = {
215
215
  active: false,
216
216
  el: null,
217
- role: 'assistant',
218
217
  };
219
218
 
220
219
  // Tool call rendering state: map toolCallId -> element
@@ -617,10 +616,6 @@ class PiWebClient {
617
616
  }
618
617
  }
619
618
 
620
- getStats() {
621
- this.send({ type: 'get_stats' });
622
- }
623
-
624
619
  // ------------------------------------------------------------------
625
620
  // Markdown rendering
626
621
  // ------------------------------------------------------------------
@@ -853,7 +848,6 @@ class PiWebClient {
853
848
  toggleSpecial(div) {
854
849
  const expanded = div.dataset.expanded === 'true';
855
850
  div.dataset.expanded = expanded ? 'false' : 'true';
856
- div.classList.toggle('expanded', !expanded);
857
851
  this.applySpecialBlock(div);
858
852
  }
859
853
 
@@ -864,7 +858,7 @@ class PiWebClient {
864
858
  }
865
859
  this.toolTimers.clear();
866
860
  this.toolEls.clear();
867
- this.streaming = { active: false, el: null, role: 'assistant' };
861
+ this.streaming = { active: false, el: null };
868
862
  // Drop stale UI state from the previous session (on /resume reload)
869
863
  const widgets = document.getElementById('widgets');
870
864
  if (widgets) {
@@ -885,6 +879,11 @@ class PiWebClient {
885
879
  // ------------------------------------------------------------------
886
880
 
887
881
  renderEvent(ev) {
882
+ // Capture stickiness BEFORE the handler mutates the DOM: updates that
883
+ // grow content (message_update, tool deltas) increase scrollHeight, so
884
+ // checking wasAtBottom() after the fact would miss "user was already at
885
+ // the bottom" and stop following the stream.
886
+ const wasAtBottom = this.wasAtBottom();
888
887
  switch (ev.type) {
889
888
  case 'agent_start':
890
889
  this.status.working++;
@@ -970,7 +969,9 @@ class PiWebClient {
970
969
  default:
971
970
  break;
972
971
  }
973
- this.scrollToBottom();
972
+ // Auto-scroll only if the user was at the bottom before the event
973
+ // (reading history must not be yanked down by incoming events).
974
+ if (wasAtBottom) this.scrollToBottom();
974
975
  }
975
976
 
976
977
  onMessageStart(message) {
@@ -1009,7 +1010,7 @@ class PiWebClient {
1009
1010
  }
1010
1011
 
1011
1012
  resetStreaming() {
1012
- this.streaming = { active: false, el: null, role: 'assistant' };
1013
+ this.streaming = { active: false, el: null };
1013
1014
  }
1014
1015
 
1015
1016
  // ------------------------------------------------------------------
@@ -1095,21 +1096,35 @@ class PiWebClient {
1095
1096
  }
1096
1097
 
1097
1098
  let el = widgetsEl.querySelector(`[data-widget-key="${CSS.escape(key)}"]`);
1099
+ let body;
1098
1100
  if (!el) {
1099
1101
  el = document.createElement('div');
1100
1102
  el.className = 'widget-block';
1101
1103
  el.dataset.widgetKey = key;
1104
+ el.dataset.expanded = 'false'; // collapsible; collapsed by default
1102
1105
  const title = document.createElement('div');
1103
1106
  title.className = 'widget-title';
1104
- const body = document.createElement('div');
1107
+ body = document.createElement('div');
1105
1108
  body.className = 'widget-body';
1109
+ body.style.display = 'none'; // collapsed by default
1110
+ // Click the title to expand/collapse (keeps the widget compact).
1111
+ title.addEventListener('click', () => {
1112
+ const expanded = el.dataset.expanded === 'true';
1113
+ el.dataset.expanded = expanded ? 'false' : 'true';
1114
+ body.style.display = expanded ? 'none' : 'block';
1115
+ title.textContent = (expanded ? '▸ ' : '▾ ') + el.dataset.widgetKey;
1116
+ });
1106
1117
  el.appendChild(title);
1107
1118
  el.appendChild(body);
1108
1119
  widgetsEl.appendChild(el);
1120
+ } else {
1121
+ body = el.querySelector('.widget-body');
1109
1122
  }
1110
- el.querySelector('.widget-title').textContent = key;
1123
+ // Header label with collapsible indicator (kept in sync).
1124
+ const titleEl = el.querySelector('.widget-title');
1125
+ titleEl.textContent = (el.dataset.expanded === 'true' ? '▾ ' : '▸ ') + key;
1111
1126
  // ANSI escapes (extension theme) render as colored spans per line.
1112
- el.querySelector('.widget-body').innerHTML = (req.widgetLines || [])
1127
+ body.innerHTML = (req.widgetLines || [])
1113
1128
  .map((line) => ansiToHtml(String(line)))
1114
1129
  .join('<br>');
1115
1130
  widgetsEl.style.display = 'block';
@@ -1153,7 +1168,7 @@ class PiWebClient {
1153
1168
  div.appendChild(body);
1154
1169
  this.contentEl.appendChild(div);
1155
1170
 
1156
- this.streaming = { active: true, el: div, body: body, role: 'assistant' };
1171
+ this.streaming = { active: true, el: div, body: body };
1157
1172
  this.renderAssistantContent(message, body);
1158
1173
  }
1159
1174
 
@@ -1306,7 +1321,6 @@ class PiWebClient {
1306
1321
  toggleToolExpand(div) {
1307
1322
  const expanded = div.dataset.expanded === 'true';
1308
1323
  div.dataset.expanded = expanded ? 'false' : 'true';
1309
- div.classList.toggle('expanded', !expanded);
1310
1324
  this.applyToolPreview(div);
1311
1325
  }
1312
1326
 
@@ -1785,6 +1799,12 @@ class PiWebClient {
1785
1799
  }
1786
1800
 
1787
1801
  closeModal() {
1802
+ // If an extension dialog (select/confirm/input/editor) was open, notify
1803
+ // the server that it was cancelled - otherwise the extension's promise
1804
+ // never settles.
1805
+ if (this.currentExtRequest && this.currentExtRequest.id) {
1806
+ this.send({ type: 'extension_ui_response', id: this.currentExtRequest.id, cancelled: true });
1807
+ }
1788
1808
  this.modalOverlay.style.display = 'none';
1789
1809
  this.modalList.innerHTML = '';
1790
1810
  this.modalSearch.value = '';
@@ -2009,20 +2029,6 @@ class PiWebClient {
2009
2029
  this.scrollToBottom();
2010
2030
  }
2011
2031
 
2012
- showToast(message, type) {
2013
- let container = document.getElementById('toast-container');
2014
- if (!container) return;
2015
- const toast = document.createElement('div');
2016
- toast.className = 'toast' + (type ? ` toast-${type}` : '');
2017
- // Extension notify messages may carry ANSI escapes (theme.fg); render them.
2018
- toast.innerHTML = ansiToHtml(String(message));
2019
- container.appendChild(toast);
2020
- setTimeout(() => {
2021
- toast.classList.add('toast-hide');
2022
- setTimeout(() => toast.remove(), 300);
2023
- }, type === 'error' ? 8000 : 5000);
2024
- }
2025
-
2026
2032
  // ------------------------------------------------------------------
2027
2033
  // Usage panel (dedicated big panel for /usage, rendered from the
2028
2034
  // server-aggregated usage_data payload)
@@ -2125,19 +2131,6 @@ class PiWebClient {
2125
2131
  return '$' + c.toFixed(1);
2126
2132
  }
2127
2133
 
2128
- usageRow(stat) {
2129
- const t = stat.tokens || {};
2130
- return [
2131
- this.fmtTokens(t.input),
2132
- this.fmtTokens(t.output),
2133
- this.fmtTokens((t.cacheRead || 0) + (t.cacheWrite || 0)),
2134
- this.fmtTokens((t.total === undefined ? (t.input || 0) + (t.output || 0) + (t.cacheRead || 0) + (t.cacheWrite || 0) : t.total)),
2135
- this.fmtCost(stat.cost),
2136
- String(stat.messages),
2137
- String(stat.sessions),
2138
- ];
2139
- }
2140
-
2141
2134
  renderUsageBody() {
2142
2135
  const body = document.getElementById('usage-body');
2143
2136
  if (!body) return;
@@ -2177,7 +2170,6 @@ class PiWebClient {
2177
2170
  const key = 'p:' + p.name;
2178
2171
  const expanded = this.usageExpanded.has(key);
2179
2172
  const tr = rowFor(p.name, p, false, false);
2180
- tr.classList.add('usage-clickable');
2181
2173
  tr.addEventListener('click', () => {
2182
2174
  if (this.usageExpanded.has(key)) this.usageExpanded.delete(key);
2183
2175
  else this.usageExpanded.add(key);
@@ -2370,7 +2362,6 @@ class PiWebClient {
2370
2362
  this.modalList.innerHTML = '<div class="modal-message">No other sessions available</div>';
2371
2363
  return;
2372
2364
  }
2373
- this.resumeSessions = sessions;
2374
2365
  this.renderModalItems(
2375
2366
  sessions.map((s) => ({ name: s.name || s.id, desc: s.cwd, value: s.path })),
2376
2367
  (item) => {
@@ -58,7 +58,6 @@
58
58
  <div id="modal-close">Close (Esc)</div>
59
59
  </div>
60
60
  </div>
61
- <div id="toast-container"></div>
62
61
  <div id="usage-overlay" style="display:none">
63
62
  <div id="usage-panel">
64
63
  <div id="usage-title">Usage</div>
@@ -27,7 +27,6 @@
27
27
  --code-inline-bg: rgba(128, 128, 128, 0.15);
28
28
  --table-head-bg: rgba(128, 128, 128, 0.1);
29
29
  }
30
-
31
30
  /* Light theme (colors aligned with Pi's light theme) */
32
31
  body.theme-light {
33
32
  --bg: #f5f5f5;
@@ -674,6 +673,8 @@ body {
674
673
  color: var(--accent);
675
674
  font-weight: 600;
676
675
  margin-bottom: 2px;
676
+ cursor: pointer;
677
+ user-select: none;
677
678
  }
678
679
 
679
680
  .widget-body {
@@ -890,40 +891,6 @@ body {
890
891
  color: var(--text);
891
892
  }
892
893
 
893
- /* Lightweight extension notifications (TUI shows notify as transient status) */
894
- #toast-container {
895
- position: fixed;
896
- top: 12px;
897
- right: 12px;
898
- z-index: 200;
899
- display: flex;
900
- flex-direction: column;
901
- gap: 8px;
902
- max-width: 420px;
903
- }
904
-
905
- .toast {
906
- background: var(--modal-bg);
907
- border: 1px solid var(--border);
908
- border-left: 3px solid var(--accent);
909
- border-radius: 6px;
910
- padding: 8px 12px;
911
- color: var(--text);
912
- font-size: 13px;
913
- line-height: 1.5;
914
- white-space: pre-wrap;
915
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
916
- opacity: 1;
917
- transition: opacity 0.3s;
918
- }
919
-
920
- .toast-warning {
921
- border-left-color: var(--warning);
922
- }
923
-
924
- .toast-error {
925
- border-left-color: var(--error);
926
- }
927
894
 
928
895
  /* Extension notify lines appended to the chat stream (TUI showStatus
929
896
  equivalent): persistent dim status text, colored by type. */
@@ -946,7 +913,6 @@ body {
946
913
  border-left-color: var(--error);
947
914
  }
948
915
 
949
- .toast-hide {
950
916
  opacity: 0;
951
917
  }
952
918
 
@@ -1441,13 +1407,6 @@ body {
1441
1407
  background: color-mix(in srgb, var(--muted) 12%, transparent);
1442
1408
  }
1443
1409
 
1444
- .usage-hour-bar-wrap {
1445
- flex: 1;
1446
- display: flex;
1447
- align-items: flex-end;
1448
- min-height: 0;
1449
- }
1450
-
1451
1410
  .usage-hour-label {
1452
1411
  font-size: 9px;
1453
1412
  color: var(--dim);
@@ -96,7 +96,6 @@ function parseSessionFile(path) {
96
96
  let sessionId = "";
97
97
  let cwd = "";
98
98
  let parentSession = "";
99
- let compactionPending = false;
100
99
  const messages = [];
101
100
  let content;
102
101
  try {
@@ -130,15 +129,24 @@ function parseSessionFile(path) {
130
129
  }
131
130
  case "compaction": {
132
131
  const usage = parseUsageAmount(entry.usage);
133
- if (usage)
134
- messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp)));
135
- compactionPending = true;
132
+ if (usage) {
133
+ const ts = tsOf(undefined, entry.timestamp);
134
+ // ts===0 means no timestamp was recoverable - skip so it can't
135
+ // create a 1970 spike in the hourly graph or leak out of all tabs.
136
+ if (ts > 0)
137
+ messages.push(auxMessage(usage, ts));
138
+ }
136
139
  break;
137
140
  }
138
141
  case "branch_summary": {
139
142
  const usage = parseUsageAmount(entry.usage);
140
- if (usage)
141
- messages.push(auxMessage(usage, tsOf(undefined, entry.timestamp)));
143
+ if (usage) {
144
+ const ts = tsOf(undefined, entry.timestamp);
145
+ // ts===0 means no timestamp was recoverable - skip so it can't
146
+ // create a 1970 spike in the hourly graph or leak out of all tabs.
147
+ if (ts > 0)
148
+ messages.push(auxMessage(usage, ts));
149
+ }
142
150
  break;
143
151
  }
144
152
  case "message": {
@@ -160,7 +168,6 @@ function parseSessionFile(path) {
160
168
  reasoning: usage.reasoning,
161
169
  source: "assistant",
162
170
  });
163
- compactionPending = false;
164
171
  }
165
172
  }
166
173
  break;
@@ -240,6 +247,18 @@ function periodStart(key, now) {
240
247
  }
241
248
  return 0; // allTime
242
249
  }
250
+ /** Exclusive upper bound of a tab window (see periodStart: lastWeek ends at
251
+ * this Monday 00:00; today/thisWeek/last30Days/allTime end at 'now'). */
252
+ function periodEnd(key, now) {
253
+ if (key === "lastWeek") {
254
+ const d = new Date(now);
255
+ d.setHours(0, 0, 0, 0);
256
+ const day = d.getDay() === 0 ? 6 : d.getDay() - 1;
257
+ d.setDate(d.getDate() - day);
258
+ return d.getTime();
259
+ }
260
+ return now;
261
+ }
243
262
  /**
244
263
  * Aggregate collected session usage into the structured payload the web
245
264
  * panel renders. Returns null when no usage data exists. When sessionId
@@ -276,7 +295,8 @@ export function buildUsageData(sessionId) {
276
295
  }
277
296
  for (const key of TAB_KEYS) {
278
297
  const start = periodStart(key, now);
279
- if (ts < start)
298
+ const end = periodEnd(key, now);
299
+ if (ts < start || ts >= end)
280
300
  continue;
281
301
  const tab = tabs[key];
282
302
  tab.totals.messages += 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
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": {