@liguoshuai/pi-web-chat 1.8.5 → 1.8.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/docs/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.8.8] - 2026-08-29
11
+
12
+ ### Fixed
13
+ - **消息发送框确认按钮上下居中与正圆对齐优化 (Composer Send Button Alignment & Circular Sizing)**:
14
+ - 修复默认单行输入状态下,发送按钮因与 `textarea` 存在高度差并在 `flex-end` 布局下导致严重偏下、没有垂直居中的问题。
15
+ - 精准对齐单行 `textarea` 高度(32px)与按钮高度(32px),使单行状态下发送/确认按钮在输入框内绝对垂直居中,多行输入时依然平滑吸底。
16
+ - 将桌面端发送按钮修正为标准正圆(`32px × 32px`,`border-radius: 50%`),并补充字号与行高约束,确保图标居中且不影响手机侧及响应式体验。
17
+
18
+ ---
19
+
10
20
  ## [1.8.5] - 2026-08-23
11
21
 
12
22
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.8.5",
3
+ "version": "1.8.8",
4
4
  "description": "A ChatGPT/Gemini-style web UI for the pi coding agent, powered by pi's RPC mode.",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/public/app.js CHANGED
@@ -1959,14 +1959,12 @@ function renderModelList(listContainer) {
1959
1959
  const opt = el("div", {
1960
1960
  class: "opt" + (active ? " active" : ""),
1961
1961
  onclick: () => {
1962
- const prev = state.currentModel;
1963
1962
  $("#modelPillName").textContent = "切换中…";
1964
1963
  sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
1965
1964
  saveRecentModel(m);
1966
1965
  $("#modelMenu").classList.remove("open");
1967
- if (prev && (prev.id !== m.id || prev.provider !== m.provider)) {
1968
- appendSystemNotice(`已切换模型至 ${m.provider ? m.provider + " / " : ""}${m.name || m.id}`);
1969
- }
1966
+ // 成功后的“已切换模型至 …”提示统一由 set_model response 处理逻辑弹出,
1967
+ // 避免在这里乐观提示一次、响应到达后 pi 再提示一次(重复提示)。
1970
1968
  },
1971
1969
  }, [
1972
1970
  el("span", { class: "check", html: active ? "✓" : "" }),
@@ -2316,7 +2314,7 @@ async function init() {
2316
2314
  toggleModelMenu();
2317
2315
  } else if (e.key === "Escape" && state.streaming) {
2318
2316
  const isMenuOpen = $("#modelMenu")?.classList.contains("open") || $("#thinkingMenu")?.classList.contains("open");
2319
- const isModalOpen = $("#cwdModal")?.style.display === "flex";
2317
+ const isModalOpen = $("#cwdModal")?.classList.contains("open");
2320
2318
  if (!isMenuOpen && !isModalOpen) {
2321
2319
  abortGeneration();
2322
2320
  }
package/public/style.css CHANGED
@@ -356,7 +356,11 @@ body {
356
356
  font-size: 15px; line-height: 1.65; color: var(--text);
357
357
  max-width: 100%; min-width: 0;
358
358
  }
359
- .msg.assistant .content p { margin-bottom: 12px; }
359
+ .msg.assistant .content p {
360
+ margin-bottom: 12px;
361
+ white-space: pre-wrap;
362
+ word-break: break-word;
363
+ }
360
364
  .msg.assistant .content p:last-child { margin-bottom: 0; }
361
365
  .msg.assistant .content pre {
362
366
  background: #0d0d0d; border: 1px solid var(--border);
@@ -714,13 +718,14 @@ body {
714
718
  }
715
719
  .composer textarea {
716
720
  flex: 1; background: transparent; border: none; outline: none; resize: none;
717
- color: var(--text); font-family: inherit; font-size: 15px; line-height: 1.5;
718
- padding: 10px 0; max-height: 200px; min-height: 24px;
721
+ color: var(--text); font-family: inherit; font-size: 15px; line-height: 20px;
722
+ padding: 6px 0; max-height: 200px; min-height: 32px;
719
723
  }
720
724
  .composer .send-btn {
721
- width: 36px; height: 32px; border-radius: 18px; border: none; cursor: pointer;
725
+ width: 32px; height: 32px; border-radius: 50%; border: none; cursor: pointer;
722
726
  background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center;
723
- flex-shrink: 0; transition: background .15s, opacity .15s, transform .1s;
727
+ flex-shrink: 0; font-size: 16px; font-weight: 600; line-height: 1;
728
+ transition: background .15s, opacity .15s, transform .1s;
724
729
  }
725
730
  .composer .send-btn:disabled { background: #3a3a3a; color: #6f6f6f; cursor: not-allowed; }
726
731
  .composer .send-btn.stop { background: var(--danger); }
@@ -1449,20 +1454,23 @@ body {
1449
1454
  }
1450
1455
 
1451
1456
  .composer-inner {
1452
- padding: 4px 6px 4px 12px;
1453
- border-radius: 20px;
1457
+ padding: 6px 8px 6px 14px;
1458
+ border-radius: 22px;
1454
1459
  gap: 6px;
1455
1460
  }
1456
1461
 
1457
1462
  .composer textarea {
1458
- padding: 8px 0;
1463
+ padding: 6px 0;
1459
1464
  font-size: 14px;
1465
+ line-height: 20px;
1466
+ min-height: 32px;
1460
1467
  max-height: 160px;
1461
1468
  }
1462
1469
 
1463
1470
  .composer .send-btn {
1464
1471
  width: 32px;
1465
1472
  height: 32px;
1473
+ border-radius: 50%;
1466
1474
  }
1467
1475
 
1468
1476
  /* CWD Modal Mobile Adjustments */
@@ -22,11 +22,12 @@ SRC="$HERE/pi-web-chat.service"
22
22
  DEST_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
23
23
  DEST="$DEST_DIR/pi-web-chat.service"
24
24
 
25
- PORT="${1:-3000}"
25
+ PORT="3000"
26
26
  RESTART_NOW="no"
27
27
  for arg in "$@"; do
28
28
  case "$arg" in
29
29
  --restart|--now) RESTART_NOW="yes" ;;
30
+ [0-9]*) PORT="$arg" ;;
30
31
  esac
31
32
  done
32
33
 
package/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // for listing sessions and reading session history from the JSONL store.
4
4
  import { spawn } from "child_process";
5
5
  import { randomUUID } from "crypto";
6
- import { readFile, readdir, stat, writeFile, mkdir } from "fs/promises";
6
+ import { readFile, readdir, stat, writeFile, mkdir, realpath as fsRealpath } from "fs/promises";
7
7
  import { readFileSync, existsSync } from "fs";
8
8
  import { StringDecoder } from "string_decoder";
9
9
  import express from "express";
@@ -213,14 +213,14 @@ class PiAgent {
213
213
  maybeScheduleIdleKill() {
214
214
  // Only arm the reclamation timer when: no client attached AND truly idle.
215
215
  // A background task that is still running must never be killed here.
216
- if (this.hasWs || this.isBusy) return;
216
+ if (!this.alive || this.hasWs || this.isBusy) return;
217
217
  if (IDLE_TIMEOUT_MS === 0) return; // disabled
218
218
  this.cancelIdleKill();
219
219
  const ms = IDLE_TIMEOUT_MS;
220
220
  this.idleTimer = setTimeout(() => {
221
221
  this.idleTimer = null;
222
222
  // re-check at fire time — a reconnect or new task may have started.
223
- if (this.hasWs || this.isBusy) return;
223
+ if (!this.alive || this.hasWs || this.isBusy) return;
224
224
  console.log(`Reclaiming truly-idle pi agent after ${Math.round(ms / 1000)}s (key=${this.sessionKey || "unkeyed"})`);
225
225
  if (IDLE_DROP_HEAP) {
226
226
  try { if (typeof global.gc === "function") global.gc(); } catch {}
@@ -271,6 +271,10 @@ class PiAgent {
271
271
  if (this.sessionKey) activeAgents.delete(this.sessionKey);
272
272
  this.cancelIdleKill();
273
273
  if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
274
+ for (const [id, resolve] of this.pending) {
275
+ resolve({ type: "response", id, success: false, error: err.message });
276
+ }
277
+ this.pending.clear();
274
278
  console.error(`[pi spawn error]`, err);
275
279
  this.wsSend({ type: "pi_exit", error: err.message });
276
280
  for (const s of this.sockets) { try { s.close(); } catch {} }
@@ -291,6 +295,10 @@ class PiAgent {
291
295
  console.log(`pi exited (code=${code})`);
292
296
  this.wsSend({ type: "pi_exit", code });
293
297
  if (this.sessionKey) activeAgents.delete(this.sessionKey);
298
+ for (const [id, resolve] of this.pending) {
299
+ resolve({ type: "response", id, success: false, error: "pi exited" });
300
+ }
301
+ this.pending.clear();
294
302
  for (const s of this.sockets) { try { s.close(); } catch {} }
295
303
  this.sockets.clear();
296
304
  this.cancelIdleKill();
@@ -326,6 +334,10 @@ class PiAgent {
326
334
  this.setStreaming(false);
327
335
  this.eventBuffer = [];
328
336
  this.bufferHead = 0;
337
+ // If unkeyed, actively query state from pi so sessionKey is recorded
338
+ if (!this.sessionKey) {
339
+ this.send({ type: "get_state" });
340
+ }
329
341
  break;
330
342
  case "pi_exit": this.state = "idle"; break;
331
343
  }
@@ -386,7 +398,22 @@ class PiAgent {
386
398
  }
387
399
  const id = String(++this.reqId);
388
400
  const payload = { ...cmd, id };
389
- this.pending.set(id, resolve);
401
+ // Long-running commands (prompt/steer) are tracked for their whole
402
+ // lifetime: pi's response may legitimately take many minutes, and the
403
+ // pending entry doubles as the isBusy guard that protects a working
404
+ // agent from idle-kill. Fire-and-forget queries (get_state etc.) keep a
405
+ // safety timeout so a dropped response doesn't leak the promise.
406
+ const longRunning = cmd.type === "prompt" || cmd.type === "steer";
407
+ let timeoutId = null;
408
+ const settled = { done: false };
409
+ const complete = (obj) => {
410
+ if (settled.done) return;
411
+ settled.done = true;
412
+ this.pending.delete(id);
413
+ if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
414
+ resolve(obj);
415
+ };
416
+ this.pending.set(id, complete);
390
417
  this.markActivity();
391
418
  try {
392
419
  this.proc.stdin.write(JSON.stringify(payload) + "\n");
@@ -394,14 +421,14 @@ class PiAgent {
394
421
  this.pending.delete(id);
395
422
  return resolve({ type: "response", id, success: false, error: err.message });
396
423
  }
397
- // Safety: timeout so a dropped response doesn't leak the promise.
398
- setTimeout(() => {
399
- if (this.pending.has(id)) {
400
- this.pending.delete(id);
401
- this.markActivity();
402
- resolve({ type: "response", id, success: false, error: "timeout" });
403
- }
404
- }, 60000);
424
+ if (!longRunning) {
425
+ timeoutId = setTimeout(() => {
426
+ if (this.pending.has(id)) {
427
+ this.pending.delete(id);
428
+ resolve({ type: "response", id, success: false, error: "timeout" });
429
+ }
430
+ }, 60000);
431
+ }
405
432
  });
406
433
  }
407
434
 
@@ -459,6 +486,10 @@ class PiAgent {
459
486
  if (this.sessionKey) {
460
487
  activeAgents.delete(this.sessionKey);
461
488
  }
489
+ for (const [id, resolve] of this.pending) {
490
+ resolve({ type: "response", id, success: false, error: "pi process stopped" });
491
+ }
492
+ this.pending.clear();
462
493
  for (const s of this.sockets) { try { s.close(); } catch {} }
463
494
  this.sockets.clear();
464
495
  try { this.proc && this.proc.kill("SIGTERM"); } catch {}
@@ -727,13 +758,34 @@ app.get("/api/session", async (req, res) => {
727
758
  const file = req.query.file;
728
759
  if (!file || !file.endsWith(".jsonl")) return res.status(400).json({ error: "bad file" });
729
760
 
730
- // Security check: ensure the file path is within SESSIONS_DIR
731
- let resolvedFile = normalizePath(file);
761
+ // Security check: ensure the file path is within SESSIONS_DIR. We resolve the
762
+ // canonical (real) path rather than just the lexical one, otherwise a
763
+ // symlink placed inside SESSIONS_DIR could point outside and let a caller
764
+ // read arbitrary .jsonl files via the traversal check above.
732
765
  const resolvedSessionsDir = normalizePath(SESSIONS_DIR);
733
- const relPath = path.relative(resolvedSessionsDir, resolvedFile);
766
+ const requestedPath = normalizePath(file);
767
+ const relPath = path.relative(resolvedSessionsDir, requestedPath);
734
768
  if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
735
769
  return res.status(403).json({ error: "Access denied" });
736
770
  }
771
+ // Canonicalize both target file and sessions directory to verify the real
772
+ // target still lives under the real SESSIONS_DIR even when symlinks exist.
773
+ let resolvedFile;
774
+ try {
775
+ resolvedFile = await fsRealpath(requestedPath);
776
+ } catch {
777
+ // Fall back to the lexical path if realpath fails (e.g. missing file);
778
+ // the read below will surface the actual error.
779
+ resolvedFile = requestedPath;
780
+ }
781
+ let canonicalSessionsDir = resolvedSessionsDir;
782
+ try {
783
+ canonicalSessionsDir = await fsRealpath(resolvedSessionsDir);
784
+ } catch {}
785
+ const realRel = path.relative(canonicalSessionsDir, resolvedFile);
786
+ if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
787
+ return res.status(403).json({ error: "Access denied" });
788
+ }
737
789
 
738
790
  const content = await readFile(resolvedFile, "utf8");
739
791
  const lines = content.split("\n").filter(Boolean);
@@ -751,11 +803,6 @@ app.get("/api/session", async (req, res) => {
751
803
  // Build a map and reconstruct the active path from root -> leaf.
752
804
  const byId = new Map();
753
805
  for (const e of entries) if (e.id) byId.set(e.id, e);
754
- let leaf = null;
755
- for (const e of entries) {
756
- // a leaf is one that nobody else has as parentId (and isn't a non-message like header)
757
- if (e.type === "message" || e.type === "message_summary") leaf = e.id;
758
- }
759
806
  // find true leaf = last entry with no children
760
807
  const childCount = new Map();
761
808
  for (const e of entries) {
@@ -905,7 +952,8 @@ wss.on("connection", (ws, req) => {
905
952
  agent.send({ type: "new_session" });
906
953
  break;
907
954
  case "switch_session":
908
- if (msg.sessionPath) agent.setSessionKey(cwd, msg.sessionPath);
955
+ if (!msg.sessionPath) break; // refuse malformed request; never forward an undefined path to pi
956
+ agent.setSessionKey(cwd, msg.sessionPath);
909
957
  agent.send({ type: "switch_session", sessionPath: msg.sessionPath });
910
958
  break;
911
959
  case "steer":