pi-sdk-web 0.1.8 → 0.2.0
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
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
|
}
|
|
@@ -245,7 +261,14 @@ export class PiWebServer {
|
|
|
245
261
|
}
|
|
246
262
|
return cwd;
|
|
247
263
|
}
|
|
264
|
+
gitBranchCache = null;
|
|
265
|
+
static GIT_CACHE_TTL_MS = 5_000;
|
|
248
266
|
getGitBranch(cwd) {
|
|
267
|
+
const now = Date.now();
|
|
268
|
+
if (this.gitBranchCache && this.gitBranchCache.cwd === cwd && now - this.gitBranchCache.at < PiWebServer.GIT_CACHE_TTL_MS) {
|
|
269
|
+
return this.gitBranchCache.branch;
|
|
270
|
+
}
|
|
271
|
+
let branch = null;
|
|
249
272
|
try {
|
|
250
273
|
const stdout = execFileSync("git", ["branch", "--show-current"], {
|
|
251
274
|
cwd,
|
|
@@ -255,11 +278,13 @@ export class PiWebServer {
|
|
|
255
278
|
// "fatal: not a git repository" in non-git dirs) - suppress it
|
|
256
279
|
stdio: ["ignore", "pipe", "ignore"],
|
|
257
280
|
});
|
|
258
|
-
|
|
281
|
+
branch = stdout.trim() || null;
|
|
259
282
|
}
|
|
260
283
|
catch {
|
|
261
|
-
|
|
284
|
+
branch = null;
|
|
262
285
|
}
|
|
286
|
+
this.gitBranchCache = { cwd, branch, at: now };
|
|
287
|
+
return branch;
|
|
263
288
|
}
|
|
264
289
|
getCommands() {
|
|
265
290
|
try {
|
|
@@ -514,7 +539,7 @@ export class PiWebServer {
|
|
|
514
539
|
sessionManager: sm,
|
|
515
540
|
modelRegistry: new ModelRegistry(this.session.modelRuntime),
|
|
516
541
|
model: this.session.model,
|
|
517
|
-
scopedModels:
|
|
542
|
+
scopedModels: this.session.scopedModels,
|
|
518
543
|
thinkingLevel: this.session.thinkingLevel,
|
|
519
544
|
isIdle: () => this.session.isIdle,
|
|
520
545
|
isProjectTrusted: () => true,
|
|
@@ -535,7 +560,9 @@ export class PiWebServer {
|
|
|
535
560
|
fork: async () => ({ cancelled: true }),
|
|
536
561
|
navigateTree: async () => ({ cancelled: true }),
|
|
537
562
|
switchSession: async () => ({ cancelled: true }),
|
|
538
|
-
reload: async () => {
|
|
563
|
+
reload: async () => {
|
|
564
|
+
await this.session.reload();
|
|
565
|
+
},
|
|
539
566
|
};
|
|
540
567
|
}
|
|
541
568
|
}
|
|
@@ -545,7 +545,7 @@ class PiWebClient {
|
|
|
545
545
|
labelEl.className = 'special-label';
|
|
546
546
|
labelEl.textContent = label;
|
|
547
547
|
const body = document.createElement('div');
|
|
548
|
-
body.className = 'special-body';
|
|
548
|
+
body.className = 'special-body body-text';
|
|
549
549
|
div.appendChild(labelEl);
|
|
550
550
|
div.appendChild(body);
|
|
551
551
|
div.addEventListener('click', () => this.toggleSpecial(div));
|
|
@@ -865,7 +865,7 @@ class PiWebClient {
|
|
|
865
865
|
for (const block of message.content || []) {
|
|
866
866
|
if (block.type === 'thinking') {
|
|
867
867
|
const p = document.createElement('div');
|
|
868
|
-
p.className = 'thinking';
|
|
868
|
+
p.className = 'thinking body-text';
|
|
869
869
|
p.innerHTML = this.renderMarkdown(block.thinking);
|
|
870
870
|
body.appendChild(p);
|
|
871
871
|
} else if (block.type === 'text') {
|
|
@@ -1115,6 +1115,42 @@ class PiWebClient {
|
|
|
1115
1115
|
if (scroller) scroller.scrollTop = scroller.scrollHeight;
|
|
1116
1116
|
}
|
|
1117
1117
|
|
|
1118
|
+
/**
|
|
1119
|
+
* Jump to the previous (-1) or next (+1) user message in the scroll view.
|
|
1120
|
+
* Positions the target message at the top of the viewport.
|
|
1121
|
+
*/
|
|
1122
|
+
jumpToUserMessage(direction) {
|
|
1123
|
+
const scroller = document.getElementById('scroll-view');
|
|
1124
|
+
if (!scroller) return;
|
|
1125
|
+
const users = [...document.querySelectorAll('.message.user')];
|
|
1126
|
+
if (users.length === 0) return;
|
|
1127
|
+
|
|
1128
|
+
const scrollerRect = scroller.getBoundingClientRect();
|
|
1129
|
+
const offsetTop = (el) => el.getBoundingClientRect().top - scrollerRect.top + scroller.scrollTop;
|
|
1130
|
+
const current = scroller.scrollTop;
|
|
1131
|
+
const buffer = 12; // px: treat "essentially at this message's top" as already there
|
|
1132
|
+
|
|
1133
|
+
let target = null;
|
|
1134
|
+
if (direction === -1) {
|
|
1135
|
+
for (let i = users.length - 1; i >= 0; i--) {
|
|
1136
|
+
if (offsetTop(users[i]) < current - buffer) {
|
|
1137
|
+
target = users[i];
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
} else {
|
|
1142
|
+
for (const u of users) {
|
|
1143
|
+
if (offsetTop(u) > current + buffer) {
|
|
1144
|
+
target = u;
|
|
1145
|
+
break;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
if (target) {
|
|
1150
|
+
scroller.scrollTo({ top: Math.max(0, offsetTop(target) - 8), behavior: 'smooth' });
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1118
1154
|
// ------------------------------------------------------------------
|
|
1119
1155
|
// Input
|
|
1120
1156
|
// ------------------------------------------------------------------
|
|
@@ -1196,6 +1232,13 @@ class PiWebClient {
|
|
|
1196
1232
|
}
|
|
1197
1233
|
});
|
|
1198
1234
|
this.inputEl.addEventListener('input', () => this.updateCommandMenu());
|
|
1235
|
+
|
|
1236
|
+
// Header nav buttons: jump to previous/next user message
|
|
1237
|
+
const navPrev = document.getElementById('nav-prev');
|
|
1238
|
+
const navNext = document.getElementById('nav-next');
|
|
1239
|
+
if (navPrev) navPrev.addEventListener('click', () => this.jumpToUserMessage(-1));
|
|
1240
|
+
if (navNext) navNext.addEventListener('click', () => this.jumpToUserMessage(1));
|
|
1241
|
+
|
|
1199
1242
|
if (this.sendBtn) {
|
|
1200
1243
|
this.sendBtn.addEventListener('click', () => this.sendMessage());
|
|
1201
1244
|
}
|
|
@@ -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">
|
|
@@ -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;
|
|
@@ -396,12 +412,16 @@ body {
|
|
|
396
412
|
|
|
397
413
|
.body-text ul,
|
|
398
414
|
.body-text ol,
|
|
415
|
+
.thinking ul,
|
|
416
|
+
.thinking ol,
|
|
417
|
+
.modal-message.body-text ul,
|
|
418
|
+
.modal-message.body-text ol,
|
|
399
419
|
.message.user .body ul,
|
|
400
420
|
.message.user .body ol {
|
|
401
421
|
margin: 0.4em 0;
|
|
402
422
|
padding-left: 1.6em;
|
|
403
|
-
/*
|
|
404
|
-
list-style-position:
|
|
423
|
+
/* Outside markers sit at the message padding edge, aligned with paragraph text */
|
|
424
|
+
list-style-position: outside;
|
|
405
425
|
}
|
|
406
426
|
|
|
407
427
|
.body-text li,
|
package/package.json
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-sdk-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|