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 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
- await server.stop();
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
- const STATIC_DIR = [resolve(PACKAGE_DIR, "static"), resolve(PACKAGE_DIR, "..", "static")].find((p) => existsSync(p)) ??
25
- resolve(PACKAGE_DIR, "..", "static");
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 === WebSocket.OPEN) {
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
- if (!filePath.startsWith(resolve(this.staticDir))) {
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
- return stdout.trim() || null;
286
+ branch = stdout.trim() || null;
259
287
  }
260
288
  catch {
261
- return null;
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
- // setStatus / setTitle / set_editor_text are handled elsewhere or ignored
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;
@@ -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.9",
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