pi-sdk-web 0.1.9 → 0.2.1
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/cli.js +10 -1
- package/dist/server.js +41 -9
- package/{static → dist/static}/app.js +76 -1
- package/{static → dist/static}/index.html +5 -0
- package/{static → dist/static}/style.css +22 -0
- package/dist/ui-context.js +12 -0
- package/package.json +3 -4
- /package/{static → dist/static}/vendor/marked.min.js +0 -0
package/dist/cli.js
CHANGED
|
@@ -73,9 +73,18 @@ async function cmdResume(name, port) {
|
|
|
73
73
|
const server = new PiWebServer(session, { port });
|
|
74
74
|
await server.start();
|
|
75
75
|
console.log(`server at http://127.0.0.1:${port}/ (session: ${info.name ?? info.id})`);
|
|
76
|
+
let shuttingDown = false;
|
|
76
77
|
const shutdown = async (signal) => {
|
|
78
|
+
if (shuttingDown)
|
|
79
|
+
return; // Repeated Ctrl+C must not re-enter teardown
|
|
80
|
+
shuttingDown = true;
|
|
77
81
|
console.log(`\n${signal} received, shutting down...`);
|
|
78
|
-
|
|
82
|
+
try {
|
|
83
|
+
await server.stop();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// ignore teardown errors - we still need to exit
|
|
87
|
+
}
|
|
79
88
|
try {
|
|
80
89
|
session.dispose();
|
|
81
90
|
}
|
package/dist/server.js
CHANGED
|
@@ -12,7 +12,7 @@ import { execFileSync } from "node:child_process";
|
|
|
12
12
|
import { existsSync, readFileSync } from "node:fs";
|
|
13
13
|
import { readFile } from "node:fs/promises";
|
|
14
14
|
import { createServer } from "node:http";
|
|
15
|
-
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { ModelRegistry, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { WebSocket, WebSocketServer } from "ws";
|
|
@@ -21,8 +21,16 @@ const DEFAULT_PORT = 4080;
|
|
|
21
21
|
// Static frontend: prefer the in-package copy (built by `npm run build` for
|
|
22
22
|
// global installs), fall back to the repo-root static/ during development.
|
|
23
23
|
const PACKAGE_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// Static frontend source:
|
|
25
|
+
// - dev (tsx runs src/server.ts): always the repo-root static/ (live files)
|
|
26
|
+
// - published (dist/server.js): the built copy at dist/static (packaged)
|
|
27
|
+
const RUNNING_FROM_SRC = fileURLToPath(import.meta.url).includes(`${sep}src${sep}`);
|
|
28
|
+
const STATIC_DIR = RUNNING_FROM_SRC
|
|
29
|
+
? resolve(PACKAGE_DIR, "..", "static")
|
|
30
|
+
: (() => {
|
|
31
|
+
const built = resolve(PACKAGE_DIR, "dist", "static");
|
|
32
|
+
return existsSync(built) ? built : resolve(PACKAGE_DIR, "..", "static");
|
|
33
|
+
})();
|
|
26
34
|
// Our own package version (pi-sdk-web), shown in the header next to Pi's version
|
|
27
35
|
const PI_WEB_VERSION = (() => {
|
|
28
36
|
try {
|
|
@@ -135,9 +143,15 @@ export class PiWebServer {
|
|
|
135
143
|
return;
|
|
136
144
|
}
|
|
137
145
|
for (const client of this.clients) {
|
|
138
|
-
if (client.readyState
|
|
146
|
+
if (client.readyState !== WebSocket.OPEN)
|
|
147
|
+
continue;
|
|
148
|
+
try {
|
|
139
149
|
client.send(message);
|
|
140
150
|
}
|
|
151
|
+
catch {
|
|
152
|
+
// One broken client must not block delivery to the others
|
|
153
|
+
this.clients.delete(client);
|
|
154
|
+
}
|
|
141
155
|
}
|
|
142
156
|
}
|
|
143
157
|
broadcastStats() {
|
|
@@ -169,8 +183,10 @@ export class PiWebServer {
|
|
|
169
183
|
let path = (req.url ?? "/").split("?")[0];
|
|
170
184
|
if (path === "/")
|
|
171
185
|
path = "/index.html";
|
|
186
|
+
const root = resolve(this.staticDir);
|
|
172
187
|
const filePath = resolve(this.staticDir, "." + path);
|
|
173
|
-
|
|
188
|
+
// Exact-prefix match (avoid /a/static-evil passing a /a/static check)
|
|
189
|
+
if (filePath !== root && !filePath.startsWith(root + sep)) {
|
|
174
190
|
res.writeHead(403).end();
|
|
175
191
|
return;
|
|
176
192
|
}
|
|
@@ -199,6 +215,11 @@ export class PiWebServer {
|
|
|
199
215
|
// Initial state (state + history), mirroring the Python bridge
|
|
200
216
|
this.sendJson(ws, { type: "state", data: this.buildState() });
|
|
201
217
|
this.sendJson(ws, { type: "history", data: this.buildHistory() });
|
|
218
|
+
// Current extension statuses (setStatus may have fired before this client connected)
|
|
219
|
+
const extStatus = this.uiContext.getStatusSnapshot();
|
|
220
|
+
if (Object.keys(extStatus).length > 0) {
|
|
221
|
+
this.sendJson(ws, { type: "ext_status", data: extStatus });
|
|
222
|
+
}
|
|
202
223
|
}
|
|
203
224
|
sendJson(ws, obj) {
|
|
204
225
|
if (ws.readyState === WebSocket.OPEN) {
|
|
@@ -245,7 +266,14 @@ export class PiWebServer {
|
|
|
245
266
|
}
|
|
246
267
|
return cwd;
|
|
247
268
|
}
|
|
269
|
+
gitBranchCache = null;
|
|
270
|
+
static GIT_CACHE_TTL_MS = 5_000;
|
|
248
271
|
getGitBranch(cwd) {
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
if (this.gitBranchCache && this.gitBranchCache.cwd === cwd && now - this.gitBranchCache.at < PiWebServer.GIT_CACHE_TTL_MS) {
|
|
274
|
+
return this.gitBranchCache.branch;
|
|
275
|
+
}
|
|
276
|
+
let branch = null;
|
|
249
277
|
try {
|
|
250
278
|
const stdout = execFileSync("git", ["branch", "--show-current"], {
|
|
251
279
|
cwd,
|
|
@@ -255,11 +283,13 @@ export class PiWebServer {
|
|
|
255
283
|
// "fatal: not a git repository" in non-git dirs) - suppress it
|
|
256
284
|
stdio: ["ignore", "pipe", "ignore"],
|
|
257
285
|
});
|
|
258
|
-
|
|
286
|
+
branch = stdout.trim() || null;
|
|
259
287
|
}
|
|
260
288
|
catch {
|
|
261
|
-
|
|
289
|
+
branch = null;
|
|
262
290
|
}
|
|
291
|
+
this.gitBranchCache = { cwd, branch, at: now };
|
|
292
|
+
return branch;
|
|
263
293
|
}
|
|
264
294
|
getCommands() {
|
|
265
295
|
try {
|
|
@@ -514,7 +544,7 @@ export class PiWebServer {
|
|
|
514
544
|
sessionManager: sm,
|
|
515
545
|
modelRegistry: new ModelRegistry(this.session.modelRuntime),
|
|
516
546
|
model: this.session.model,
|
|
517
|
-
scopedModels:
|
|
547
|
+
scopedModels: this.session.scopedModels,
|
|
518
548
|
thinkingLevel: this.session.thinkingLevel,
|
|
519
549
|
isIdle: () => this.session.isIdle,
|
|
520
550
|
isProjectTrusted: () => true,
|
|
@@ -535,7 +565,9 @@ export class PiWebServer {
|
|
|
535
565
|
fork: async () => ({ cancelled: true }),
|
|
536
566
|
navigateTree: async () => ({ cancelled: true }),
|
|
537
567
|
switchSession: async () => ({ cancelled: true }),
|
|
538
|
-
reload: async () => {
|
|
568
|
+
reload: async () => {
|
|
569
|
+
await this.session.reload();
|
|
570
|
+
},
|
|
539
571
|
};
|
|
540
572
|
}
|
|
541
573
|
}
|
|
@@ -36,6 +36,7 @@ class PiWebClient {
|
|
|
36
36
|
this.modalMode = null; // 'model' | 'thinking'
|
|
37
37
|
this.hasConnectedBefore = false;
|
|
38
38
|
this.commandMenuIndex = -1;
|
|
39
|
+
this.extStatus = {};
|
|
39
40
|
|
|
40
41
|
this.initThemeSwitch();
|
|
41
42
|
|
|
@@ -169,6 +170,9 @@ class PiWebClient {
|
|
|
169
170
|
case 'pi_error':
|
|
170
171
|
this.appendError(data.error);
|
|
171
172
|
break;
|
|
173
|
+
case 'ext_status':
|
|
174
|
+
this.applyExtStatusSnapshot(data.data);
|
|
175
|
+
break;
|
|
172
176
|
case 'extension_ui_request':
|
|
173
177
|
this.handleExtensionUIRequest(data);
|
|
174
178
|
break;
|
|
@@ -1115,6 +1119,42 @@ class PiWebClient {
|
|
|
1115
1119
|
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
|
1116
1120
|
}
|
|
1117
1121
|
|
|
1122
|
+
/**
|
|
1123
|
+
* Jump to the previous (-1) or next (+1) user message in the scroll view.
|
|
1124
|
+
* Positions the target message at the top of the viewport.
|
|
1125
|
+
*/
|
|
1126
|
+
jumpToUserMessage(direction) {
|
|
1127
|
+
const scroller = document.getElementById('scroll-view');
|
|
1128
|
+
if (!scroller) return;
|
|
1129
|
+
const users = [...document.querySelectorAll('.message.user')];
|
|
1130
|
+
if (users.length === 0) return;
|
|
1131
|
+
|
|
1132
|
+
const scrollerRect = scroller.getBoundingClientRect();
|
|
1133
|
+
const offsetTop = (el) => el.getBoundingClientRect().top - scrollerRect.top + scroller.scrollTop;
|
|
1134
|
+
const current = scroller.scrollTop;
|
|
1135
|
+
const buffer = 12; // px: treat "essentially at this message's top" as already there
|
|
1136
|
+
|
|
1137
|
+
let target = null;
|
|
1138
|
+
if (direction === -1) {
|
|
1139
|
+
for (let i = users.length - 1; i >= 0; i--) {
|
|
1140
|
+
if (offsetTop(users[i]) < current - buffer) {
|
|
1141
|
+
target = users[i];
|
|
1142
|
+
break;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
} else {
|
|
1146
|
+
for (const u of users) {
|
|
1147
|
+
if (offsetTop(u) > current + buffer) {
|
|
1148
|
+
target = u;
|
|
1149
|
+
break;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
if (target) {
|
|
1154
|
+
scroller.scrollTo({ top: Math.max(0, offsetTop(target) - 8), behavior: 'smooth' });
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1118
1158
|
// ------------------------------------------------------------------
|
|
1119
1159
|
// Input
|
|
1120
1160
|
// ------------------------------------------------------------------
|
|
@@ -1196,6 +1236,13 @@ class PiWebClient {
|
|
|
1196
1236
|
}
|
|
1197
1237
|
});
|
|
1198
1238
|
this.inputEl.addEventListener('input', () => this.updateCommandMenu());
|
|
1239
|
+
|
|
1240
|
+
// Header nav buttons: jump to previous/next user message
|
|
1241
|
+
const navPrev = document.getElementById('nav-prev');
|
|
1242
|
+
const navNext = document.getElementById('nav-next');
|
|
1243
|
+
if (navPrev) navPrev.addEventListener('click', () => this.jumpToUserMessage(-1));
|
|
1244
|
+
if (navNext) navNext.addEventListener('click', () => this.jumpToUserMessage(1));
|
|
1245
|
+
|
|
1199
1246
|
if (this.sendBtn) {
|
|
1200
1247
|
this.sendBtn.addEventListener('click', () => this.sendMessage());
|
|
1201
1248
|
}
|
|
@@ -1307,8 +1354,36 @@ class PiWebClient {
|
|
|
1307
1354
|
} else if (method === 'setWidget') {
|
|
1308
1355
|
// Persistent widget panel (TUI: above/below editor). Not a popup.
|
|
1309
1356
|
this.renderWidget(req);
|
|
1357
|
+
} else if (method === 'setStatus') {
|
|
1358
|
+
// Extension status text (e.g. magic-context "mc: 29.3K (4%) · idle")
|
|
1359
|
+
this.renderStatusItem(req);
|
|
1360
|
+
}
|
|
1361
|
+
// setTitle / set_editor_text are handled elsewhere or ignored
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
renderStatusItem(req) {
|
|
1365
|
+
const el = document.getElementById('ext-status');
|
|
1366
|
+
if (!el) return;
|
|
1367
|
+
if (req.statusText === undefined || req.statusText === null) {
|
|
1368
|
+
delete this.extStatus[req.statusKey];
|
|
1369
|
+
} else {
|
|
1370
|
+
this.extStatus[req.statusKey] = req.statusText;
|
|
1310
1371
|
}
|
|
1311
|
-
|
|
1372
|
+
this.updateExtStatusDisplay();
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
applyExtStatusSnapshot(snapshot) {
|
|
1376
|
+
if (!snapshot) return;
|
|
1377
|
+
this.extStatus = Object.assign({}, snapshot);
|
|
1378
|
+
this.updateExtStatusDisplay();
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
updateExtStatusDisplay() {
|
|
1382
|
+
const el = document.getElementById('ext-status');
|
|
1383
|
+
if (!el) return;
|
|
1384
|
+
const entries = Object.entries(this.extStatus);
|
|
1385
|
+
el.textContent = entries.map(([k, v]) => `${k}: ${v}`).join(' · ');
|
|
1386
|
+
el.style.display = entries.length ? 'block' : 'none';
|
|
1312
1387
|
}
|
|
1313
1388
|
|
|
1314
1389
|
openExtensionSelect(req) {
|
|
@@ -16,6 +16,10 @@
|
|
|
16
16
|
<span class="theme-sep">|</span>
|
|
17
17
|
<span class="theme-option" data-theme="bright">Bright</span>
|
|
18
18
|
</span>
|
|
19
|
+
<span id="msg-nav">
|
|
20
|
+
<span class="nav-btn" id="nav-prev" title="Jump to previous user message">↑</span>
|
|
21
|
+
<span class="nav-btn" id="nav-next" title="Jump to next user message">↓</span>
|
|
22
|
+
</span>
|
|
19
23
|
<span id="conn-status" class="disconnected">Disconnected</span>
|
|
20
24
|
</div>
|
|
21
25
|
<div id="main">
|
|
@@ -31,6 +35,7 @@
|
|
|
31
35
|
<div id="status"></div>
|
|
32
36
|
<div id="footer">
|
|
33
37
|
<div id="footer-line"></div>
|
|
38
|
+
<div id="ext-status" style="display:none"></div>
|
|
34
39
|
<div id="footer-stats"></div>
|
|
35
40
|
<div id="command-menu"></div>
|
|
36
41
|
<div class="input-row">
|
|
@@ -131,6 +131,22 @@ body {
|
|
|
131
131
|
margin: 0 4px;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
#msg-nav {
|
|
135
|
+
margin-left: 10px;
|
|
136
|
+
font-size: 13px;
|
|
137
|
+
user-select: none;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.nav-btn {
|
|
141
|
+
cursor: pointer;
|
|
142
|
+
color: var(--dim);
|
|
143
|
+
padding: 0 4px;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.nav-btn:hover {
|
|
147
|
+
color: var(--accent);
|
|
148
|
+
}
|
|
149
|
+
|
|
134
150
|
#conn-status {
|
|
135
151
|
font-size: 12px;
|
|
136
152
|
padding: 2px 8px;
|
|
@@ -583,6 +599,12 @@ body {
|
|
|
583
599
|
margin-bottom: 4px;
|
|
584
600
|
}
|
|
585
601
|
|
|
602
|
+
#ext-status {
|
|
603
|
+
color: var(--dim);
|
|
604
|
+
font-size: 12px;
|
|
605
|
+
margin-bottom: 4px;
|
|
606
|
+
}
|
|
607
|
+
|
|
586
608
|
#footer-stats {
|
|
587
609
|
display: flex;
|
|
588
610
|
justify-content: space-between;
|
package/dist/ui-context.js
CHANGED
|
@@ -21,9 +21,15 @@ export class WebUIContext {
|
|
|
21
21
|
pending = new Map();
|
|
22
22
|
sink;
|
|
23
23
|
identityTheme = createIdentityTheme();
|
|
24
|
+
/** Latest setStatus values per key, so late-connecting browsers get current state */
|
|
25
|
+
statusMap = new Map();
|
|
24
26
|
constructor(sink) {
|
|
25
27
|
this.sink = sink;
|
|
26
28
|
}
|
|
29
|
+
/** Current extension status snapshot (key -> text) for new connections. */
|
|
30
|
+
getStatusSnapshot() {
|
|
31
|
+
return Object.fromEntries(this.statusMap);
|
|
32
|
+
}
|
|
27
33
|
/** Handle a browser `extension_ui_response` message. */
|
|
28
34
|
respond(id, response) {
|
|
29
35
|
const pending = this.pending.get(id);
|
|
@@ -82,6 +88,12 @@ export class WebUIContext {
|
|
|
82
88
|
this.sink({ type: "extension_ui_request", id: crypto.randomUUID(), method: "notify", message, notifyType: type });
|
|
83
89
|
}
|
|
84
90
|
setStatus(key, text) {
|
|
91
|
+
if (text === undefined || text === null) {
|
|
92
|
+
this.statusMap.delete(key);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
this.statusMap.set(key, text);
|
|
96
|
+
}
|
|
85
97
|
this.sink({ type: "extension_ui_request", id: crypto.randomUUID(), method: "setStatus", statusKey: key, statusText: text });
|
|
86
98
|
}
|
|
87
99
|
setTitle(title) {
|
package/package.json
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-sdk-web",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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": {
|
|
7
7
|
"pi-web": "./dist/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"dist"
|
|
11
|
-
"static"
|
|
10
|
+
"dist"
|
|
12
11
|
],
|
|
13
12
|
"scripts": {
|
|
14
|
-
"build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../static', 'static', { recursive: true })\"",
|
|
13
|
+
"build": "tsc -p tsconfig.json && node -e \"require('node:fs').cpSync('../static', 'dist/static', { recursive: true })\"",
|
|
15
14
|
"dev": "tsx src/cli.ts",
|
|
16
15
|
"verify": "tsx src/verify-sdk.ts",
|
|
17
16
|
"prepublishOnly": "npm run build"
|
|
File without changes
|