pi-sdk-web 0.5.5 → 0.5.7

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) {
@@ -970,7 +964,9 @@ class PiWebClient {
970
964
  default:
971
965
  break;
972
966
  }
973
- this.scrollToBottom();
967
+ // Only auto-scroll when the user is already at the bottom (reading
968
+ // history must not be yanked down by incoming events).
969
+ if (this.wasAtBottom()) this.scrollToBottom();
974
970
  }
975
971
 
976
972
  onMessageStart(message) {
@@ -1009,7 +1005,7 @@ class PiWebClient {
1009
1005
  }
1010
1006
 
1011
1007
  resetStreaming() {
1012
- this.streaming = { active: false, el: null, role: 'assistant' };
1008
+ this.streaming = { active: false, el: null };
1013
1009
  }
1014
1010
 
1015
1011
  // ------------------------------------------------------------------
@@ -1095,21 +1091,35 @@ class PiWebClient {
1095
1091
  }
1096
1092
 
1097
1093
  let el = widgetsEl.querySelector(`[data-widget-key="${CSS.escape(key)}"]`);
1094
+ let body;
1098
1095
  if (!el) {
1099
1096
  el = document.createElement('div');
1100
1097
  el.className = 'widget-block';
1101
1098
  el.dataset.widgetKey = key;
1099
+ el.dataset.expanded = 'false'; // collapsible; collapsed by default
1102
1100
  const title = document.createElement('div');
1103
1101
  title.className = 'widget-title';
1104
- const body = document.createElement('div');
1102
+ body = document.createElement('div');
1105
1103
  body.className = 'widget-body';
1104
+ body.style.display = 'none'; // collapsed by default
1105
+ // Click the title to expand/collapse (keeps the widget compact).
1106
+ title.addEventListener('click', () => {
1107
+ const expanded = el.dataset.expanded === 'true';
1108
+ el.dataset.expanded = expanded ? 'false' : 'true';
1109
+ body.style.display = expanded ? 'none' : 'block';
1110
+ title.textContent = (expanded ? '▸ ' : '▾ ') + el.dataset.widgetKey;
1111
+ });
1106
1112
  el.appendChild(title);
1107
1113
  el.appendChild(body);
1108
1114
  widgetsEl.appendChild(el);
1115
+ } else {
1116
+ body = el.querySelector('.widget-body');
1109
1117
  }
1110
- el.querySelector('.widget-title').textContent = key;
1118
+ // Header label with collapsible indicator (kept in sync).
1119
+ const titleEl = el.querySelector('.widget-title');
1120
+ titleEl.textContent = (el.dataset.expanded === 'true' ? '▾ ' : '▸ ') + key;
1111
1121
  // ANSI escapes (extension theme) render as colored spans per line.
1112
- el.querySelector('.widget-body').innerHTML = (req.widgetLines || [])
1122
+ body.innerHTML = (req.widgetLines || [])
1113
1123
  .map((line) => ansiToHtml(String(line)))
1114
1124
  .join('<br>');
1115
1125
  widgetsEl.style.display = 'block';
@@ -1153,7 +1163,7 @@ class PiWebClient {
1153
1163
  div.appendChild(body);
1154
1164
  this.contentEl.appendChild(div);
1155
1165
 
1156
- this.streaming = { active: true, el: div, body: body, role: 'assistant' };
1166
+ this.streaming = { active: true, el: div, body: body };
1157
1167
  this.renderAssistantContent(message, body);
1158
1168
  }
1159
1169
 
@@ -1306,7 +1316,6 @@ class PiWebClient {
1306
1316
  toggleToolExpand(div) {
1307
1317
  const expanded = div.dataset.expanded === 'true';
1308
1318
  div.dataset.expanded = expanded ? 'false' : 'true';
1309
- div.classList.toggle('expanded', !expanded);
1310
1319
  this.applyToolPreview(div);
1311
1320
  }
1312
1321
 
@@ -1785,6 +1794,12 @@ class PiWebClient {
1785
1794
  }
1786
1795
 
1787
1796
  closeModal() {
1797
+ // If an extension dialog (select/confirm/input/editor) was open, notify
1798
+ // the server that it was cancelled - otherwise the extension's promise
1799
+ // never settles.
1800
+ if (this.currentExtRequest && this.currentExtRequest.id) {
1801
+ this.send({ type: 'extension_ui_response', id: this.currentExtRequest.id, cancelled: true });
1802
+ }
1788
1803
  this.modalOverlay.style.display = 'none';
1789
1804
  this.modalList.innerHTML = '';
1790
1805
  this.modalSearch.value = '';
@@ -2009,20 +2024,6 @@ class PiWebClient {
2009
2024
  this.scrollToBottom();
2010
2025
  }
2011
2026
 
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
2027
  // ------------------------------------------------------------------
2027
2028
  // Usage panel (dedicated big panel for /usage, rendered from the
2028
2029
  // server-aggregated usage_data payload)
@@ -2125,19 +2126,6 @@ class PiWebClient {
2125
2126
  return '$' + c.toFixed(1);
2126
2127
  }
2127
2128
 
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
2129
  renderUsageBody() {
2142
2130
  const body = document.getElementById('usage-body');
2143
2131
  if (!body) return;
@@ -2177,7 +2165,6 @@ class PiWebClient {
2177
2165
  const key = 'p:' + p.name;
2178
2166
  const expanded = this.usageExpanded.has(key);
2179
2167
  const tr = rowFor(p.name, p, false, false);
2180
- tr.classList.add('usage-clickable');
2181
2168
  tr.addEventListener('click', () => {
2182
2169
  if (this.usageExpanded.has(key)) this.usageExpanded.delete(key);
2183
2170
  else this.usageExpanded.add(key);
@@ -2370,7 +2357,6 @@ class PiWebClient {
2370
2357
  this.modalList.innerHTML = '<div class="modal-message">No other sessions available</div>';
2371
2358
  return;
2372
2359
  }
2373
- this.resumeSessions = sessions;
2374
2360
  this.renderModalItems(
2375
2361
  sessions.map((s) => ({ name: s.name || s.id, desc: s.cwd, value: s.path })),
2376
2362
  (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;
@@ -248,12 +247,16 @@ body {
248
247
  overflow-x: hidden;
249
248
  padding: 8px;
250
249
  min-width: 0;
250
+ /* Bordered, rounded message area (distinct region vs sidebar/footer) */
251
+ border: 1px solid var(--border);
252
+ border-radius: 6px;
253
+ margin: 3px 0;
251
254
  }
252
255
 
253
256
  #sidebar {
254
257
  width: 200px;
255
258
  flex-shrink: 0;
256
- border-left: 1px solid var(--border);
259
+ /* scroll-view now has its own right border - no duplicate divider */
257
260
  padding: 8px;
258
261
  overflow-y: auto;
259
262
  }
@@ -670,6 +673,8 @@ body {
670
673
  color: var(--accent);
671
674
  font-weight: 600;
672
675
  margin-bottom: 2px;
676
+ cursor: pointer;
677
+ user-select: none;
673
678
  }
674
679
 
675
680
  .widget-body {
@@ -886,40 +891,6 @@ body {
886
891
  color: var(--text);
887
892
  }
888
893
 
889
- /* Lightweight extension notifications (TUI shows notify as transient status) */
890
- #toast-container {
891
- position: fixed;
892
- top: 12px;
893
- right: 12px;
894
- z-index: 200;
895
- display: flex;
896
- flex-direction: column;
897
- gap: 8px;
898
- max-width: 420px;
899
- }
900
-
901
- .toast {
902
- background: var(--modal-bg);
903
- border: 1px solid var(--border);
904
- border-left: 3px solid var(--accent);
905
- border-radius: 6px;
906
- padding: 8px 12px;
907
- color: var(--text);
908
- font-size: 13px;
909
- line-height: 1.5;
910
- white-space: pre-wrap;
911
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
912
- opacity: 1;
913
- transition: opacity 0.3s;
914
- }
915
-
916
- .toast-warning {
917
- border-left-color: var(--warning);
918
- }
919
-
920
- .toast-error {
921
- border-left-color: var(--error);
922
- }
923
894
 
924
895
  /* Extension notify lines appended to the chat stream (TUI showStatus
925
896
  equivalent): persistent dim status text, colored by type. */
@@ -942,7 +913,6 @@ body {
942
913
  border-left-color: var(--error);
943
914
  }
944
915
 
945
- .toast-hide {
946
916
  opacity: 0;
947
917
  }
948
918
 
@@ -1437,13 +1407,6 @@ body {
1437
1407
  background: color-mix(in srgb, var(--muted) 12%, transparent);
1438
1408
  }
1439
1409
 
1440
- .usage-hour-bar-wrap {
1441
- flex: 1;
1442
- display: flex;
1443
- align-items: flex-end;
1444
- min-height: 0;
1445
- }
1446
-
1447
1410
  .usage-hour-label {
1448
1411
  font-size: 9px;
1449
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.5",
3
+ "version": "0.5.7",
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": {