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 +37 -2
- package/dist/static/app.js +34 -43
- package/dist/static/index.html +0 -1
- package/dist/static/style.css +2 -43
- package/dist/usage-render.js +28 -8
- package/package.json +1 -1
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({
|
|
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
|
package/dist/static/app.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) => {
|
package/dist/static/index.html
CHANGED
package/dist/static/style.css
CHANGED
|
@@ -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);
|
package/dist/usage-render.js
CHANGED
|
@@ -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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|