@liguoshuai/pi-web-chat 2.16.5 → 2.18.13

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@liguoshuai/pi-web-chat.svg)](https://www.npmjs.com/package/@liguoshuai/pi-web-chat)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
- [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-brightgreen.svg)](https://nodejs.org/)
5
+ [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20-brightgreen.svg)](https://nodejs.org/)
6
6
 
7
7
  一个 [pi](https://pi.dev) 编程代理的 Web 界面,风格参考 ChatGPT / Gemini ——
8
8
  左侧历史会话侧边栏 + 右侧对话区 + 底部输入框。底层通过 pi 的 **RPC 模式**
@@ -35,7 +35,7 @@
35
35
  ## 🚀 快速开始
36
36
 
37
37
  ### 1. 环境准备
38
- - **Node.js**: `>= 18.0.0`
38
+ - **Node.js**: `>= 20.0.0`
39
39
  - **操作系统**: Linux / macOS / WSL (Windows)
40
40
 
41
41
  ---
@@ -181,7 +181,7 @@ Web 服务启动后,在浏览器中访问:
181
181
  | `PORT` | `3000` | Web 服务监听端口 |
182
182
  | `PI_BIN` | 自动探测(`~/.npm-global/bin/pi`、`/usr/local/bin/pi` 或 `PATH`) | 显式指定 pi 可执行文件绝对路径 |
183
183
  | `PI_SESSIONS_DIR` | `~/.pi/agent/sessions` | pi 的 session 存储目录 |
184
- | `IDLE_TIMEOUT_MS` | `300000` (5分钟) | 真正空闲(无连接+非流式)后的进程回收超时(`0` 为禁用回收) |
184
+ | `IDLE_TIMEOUT_MS` | `1800000` (30分钟) | 真正空闲(无连接+非流式)后的进程回收超时(`0` 为禁用回收) |
185
185
  | `MAX_AGENT_LIFETIME_MS` | `0` (无上限) | 单个 Agent 进程后台生存硬上限(`0` 为禁用) |
186
186
  | `EVENT_BUFFER_SIZE` | `5000` | 离线环形 Buffer 允许缓存的最大事件条数 |
187
187
  | `MAX_CONCURRENT_AGENTS` | `0` (无限制) | 进程池最大并发 Agent 进程数量 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "2.16.5",
3
+ "version": "2.18.13",
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",
@@ -37,18 +37,18 @@
37
37
  "access": "public"
38
38
  },
39
39
  "engines": {
40
- "node": ">=18"
40
+ "node": ">=20"
41
41
  },
42
42
  "dependencies": {
43
43
  "express": "^4.21.2",
44
44
  "ws": "^8.18.0",
45
- "@liguoshuai/pi-chat-protocol": "2.16.5",
46
- "@liguoshuai/pi-chat-server": "2.16.5"
45
+ "@liguoshuai/pi-chat-server": "2.18.13",
46
+ "@liguoshuai/pi-chat-protocol": "2.18.13"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "node --check server.js && node --check bin/pi-web-chat.js && node --check public/markdown.js && node --check public/app.js",
50
50
  "start": "node server.js",
51
51
  "dev": "node --watch server.js",
52
- "test": "node --test"
52
+ "test": "node --test --test-timeout=8000"
53
53
  }
54
54
  }
package/public/app.js CHANGED
@@ -81,6 +81,7 @@ const state = {
81
81
  attachedImages: [], // Array of { data: string (base64), mimeType: string, url: string }
82
82
  turnStartedAt: null,
83
83
  streamingMsgDurationEl: null,
84
+ streamingWatchdog: null, // 5-min timeout for stuck streaming
84
85
  lastSessions: [],
85
86
  };
86
87
 
@@ -362,21 +363,31 @@ function startStreamingTimer() {
362
363
  if (state.turnStartedAt && state.streamingMsgDurationEl) {
363
364
  state.streamingMsgDurationEl.textContent = formatDuration(now - state.turnStartedAt);
364
365
  }
365
- const liveThinking = document.querySelectorAll(".thinking-duration.live");
366
- liveThinking.forEach((el) => {
366
+ // Single combined query instead of two separate querySelectorAll calls.
367
+ // Interval at 200ms (5 scans/sec) is still smooth for duration display.
368
+ const liveEls = document.querySelectorAll(".thinking-duration.live, .tool-duration.live");
369
+ liveEls.forEach((el) => {
367
370
  if (el._startedAt) {
368
371
  el.textContent = formatDuration(now - el._startedAt);
369
372
  el.style.display = "";
370
373
  }
371
374
  });
372
- const liveTools = document.querySelectorAll(".tool-duration.live");
373
- liveTools.forEach((el) => {
374
- if (el._startedAt) {
375
- el.textContent = formatDuration(now - el._startedAt);
376
- el.style.display = "";
375
+
376
+ // Live update for the thinking placeholder spinner label before first delta arrives
377
+ const placeholder = document.querySelector(".thinking-placeholder");
378
+ if (placeholder && state.turnStartedAt) {
379
+ const elapsed = Math.max(0, now - state.turnStartedAt);
380
+ const labelEl = placeholder.querySelector(".thinking-label");
381
+ if (labelEl) {
382
+ const durStr = formatDuration(elapsed);
383
+ if (elapsed > 2500) {
384
+ labelEl.textContent = `正在深度推理中… (${durStr})`;
385
+ } else {
386
+ labelEl.textContent = `正在思考中… (${durStr})`;
387
+ }
377
388
  }
378
- });
379
- }, 100);
389
+ }
390
+ }, 200);
380
391
  }
381
392
 
382
393
  function stopStreamingTimer() {
@@ -386,6 +397,20 @@ function stopStreamingTimer() {
386
397
  }
387
398
  }
388
399
 
400
+ function updateUrlSession(sessionFile) {
401
+ if (!sessionFile) return;
402
+ try {
403
+ const params = new URLSearchParams(window.location.search);
404
+ params.set("session", sessionFile);
405
+ if (state.cwd && !params.has("cwd")) {
406
+ params.set("cwd", state.cwd);
407
+ }
408
+ const query = params.toString();
409
+ const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
410
+ window.history.replaceState({ session: sessionFile }, "", newUrl);
411
+ } catch {}
412
+ }
413
+
389
414
  function sameSession(a, b) {
390
415
  if (!a || !b) return false;
391
416
  if (a === b) return true;
@@ -531,7 +556,12 @@ function startNewSession() {
531
556
  state.activeToolCalls.clear();
532
557
  setComposerStreaming(false);
533
558
  try {
534
- window.history.replaceState({}, "", window.location.pathname);
559
+ const params = new URLSearchParams(window.location.search);
560
+ params.delete("session");
561
+ params.delete("file");
562
+ const query = params.toString();
563
+ const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
564
+ window.history.replaceState({}, "", newUrl);
535
565
  } catch {}
536
566
  $("#topSessionName").textContent = "新对话";
537
567
  updatePageTitle(null);
@@ -659,10 +689,7 @@ async function loadSession(file) {
659
689
  state.streamingMsg = null;
660
690
  state.activeToolCalls.clear();
661
691
  setComposerStreaming(false);
662
- try {
663
- const newUrl = window.location.pathname + "?session=" + encodeURIComponent(file);
664
- window.history.replaceState({ session: file }, "", newUrl);
665
- } catch {}
692
+ updateUrlSession(file);
666
693
  // Mobile: close sidebar on selection
667
694
  if (window.innerWidth <= 768) {
668
695
  closeSidebar();
@@ -826,6 +853,7 @@ function clearChat() {
826
853
  chatInner.innerHTML = "";
827
854
  state.streamingMsg = null;
828
855
  state.streamingItems = [];
856
+ lastRenderedItemCount = 0;
829
857
  state.thinkingOpen = true;
830
858
  state.thinkingUserToggled = false;
831
859
  state.activeToolCalls.clear();
@@ -1336,6 +1364,79 @@ function updateToolBlockCopyBtn(tc, call) {
1336
1364
  tc.head._cmdToCopy = cmd;
1337
1365
  }
1338
1366
 
1367
+ function formatToolCommandText(name, args) {
1368
+ if (!args) return "";
1369
+ let obj = args;
1370
+ if (typeof args === "string") {
1371
+ try { obj = JSON.parse(args); } catch { return args; }
1372
+ }
1373
+ if (!obj || typeof obj !== "object") return String(args);
1374
+ if (name === "bash" && obj.command) {
1375
+ return `$ ${obj.command}`;
1376
+ }
1377
+ if (name === "read" && obj.path) {
1378
+ let s = `read ${obj.path}`;
1379
+ if (obj.offset != null) s += ` (offset: ${obj.offset})`;
1380
+ if (obj.limit != null) s += ` (limit: ${obj.limit})`;
1381
+ return s;
1382
+ }
1383
+ if (name === "write" && obj.path) {
1384
+ let s = `write ${obj.path}`;
1385
+ if (obj.content) {
1386
+ const preview = obj.content.length > 500 ? obj.content.slice(0, 500) + "\n... (truncated)" : obj.content;
1387
+ s += `\n\n--- 写入内容预览 ---\n${preview}`;
1388
+ }
1389
+ return s;
1390
+ }
1391
+ if (name === "edit" && obj.path) {
1392
+ return `edit ${obj.path}`;
1393
+ }
1394
+ if (name === "ls" && obj.path) {
1395
+ return `ls ${obj.path}`;
1396
+ }
1397
+ if (name === "grep") {
1398
+ return `grep "${obj.pattern || ""}" ${obj.path || ""}`;
1399
+ }
1400
+ if (name === "find") {
1401
+ return `find ${obj.path || "."} -name "${obj.pattern || ""}"`;
1402
+ }
1403
+ try {
1404
+ return JSON.stringify(obj, null, 2);
1405
+ } catch {
1406
+ return String(args);
1407
+ }
1408
+ }
1409
+
1410
+ function renderToolBlockContent(bodyEl, call, resultText, isRunning, isError) {
1411
+ if (!bodyEl) return;
1412
+ bodyEl.innerHTML = "";
1413
+ const cmdText = formatToolCommandText(call?.name, call?.arguments ?? call?.args);
1414
+
1415
+ // 1. 指令 / 参数区域 (有命令就立即提前显示,无需等待输出)
1416
+ if (cmdText) {
1417
+ const cmdSec = el("div", { class: "tool-section tool-cmd-section" }, [
1418
+ el("div", { class: "tool-section-header" }, [
1419
+ el("span", { class: "tool-section-title", text: "执行指令" }),
1420
+ ]),
1421
+ el("pre", { class: "tool-code-block", text: cmdText }),
1422
+ ]);
1423
+ bodyEl.appendChild(cmdSec);
1424
+ }
1425
+
1426
+ // 2. 输出区域 (执行中显示实时状态/输出,执行完显示最终结果)
1427
+ const outSec = el("div", { class: "tool-section tool-out-section" });
1428
+ const outHeader = el("div", { class: "tool-section-header" }, [
1429
+ el("span", { class: "tool-section-title", text: isRunning ? "实时状态" : "输出结果" }),
1430
+ isRunning ? el("span", { class: "tool-running-pulse", text: "正在执行…" }) : null,
1431
+ ]);
1432
+ outSec.appendChild(outHeader);
1433
+
1434
+ const displayOut = resultText || (isRunning ? "(命令正在执行中,请稍候…)" : "(无输出)");
1435
+ const outPre = el("pre", { class: "tool-output-block" + (isError ? " is-error" : ""), text: displayOut });
1436
+ outSec.appendChild(outPre);
1437
+ bodyEl.appendChild(outSec);
1438
+ }
1439
+
1339
1440
  function makeToolBlockFromCall(call, ts = null) {
1340
1441
  const block = el("div", { class: "tool-block" });
1341
1442
  const hasResult = Boolean(call.result);
@@ -1399,9 +1500,9 @@ function makeToolBlockFromCall(call, ts = null) {
1399
1500
  ]);
1400
1501
  head._cmdToCopy = cmdToCopy;
1401
1502
 
1402
- const bodyText = hasResult ? (resultText || "(无输出)") : "执行中…";
1403
- const body = el("div", { class: "tool-body", html: escapeHtml(bodyText) });
1503
+ const body = el("div", { class: "tool-body" });
1404
1504
  body.style.display = "none";
1505
+ renderToolBlockContent(body, call, resultText, !hasResult, isError);
1405
1506
 
1406
1507
  head.addEventListener("click", () => {
1407
1508
  body.style.display = body.style.display === "none" ? "block" : "none";
@@ -1415,7 +1516,7 @@ function makeToolBlockFromCall(call, ts = null) {
1415
1516
  block._durationEl = durationEl;
1416
1517
 
1417
1518
  if (!hasResult && call.id) {
1418
- state.activeToolCalls.set(call.id, { block, body, head, durationEl, startedAt: call.startedAt || Date.now() });
1519
+ state.activeToolCalls.set(call.id, { block, body, head, durationEl, startedAt: call.startedAt || Date.now(), name: call.name, args: call.arguments });
1419
1520
  }
1420
1521
 
1421
1522
  return block;
@@ -1433,13 +1534,14 @@ function summaryArgs(name, args) {
1433
1534
  if (name === "write" && obj.path) return obj.path;
1434
1535
  if (name === "edit" && obj.path) return obj.path;
1435
1536
  if (name === "ls" && obj.path) return obj.path;
1436
- if (name === "grep") return obj.pattern || "";
1437
- if (name === "find") return obj.pattern || obj.path || "";
1537
+ if (name === "grep") return obj.pattern ? `${obj.pattern}${obj.path ? ` · ${obj.path}` : ""}` : (obj.path || "");
1538
+ if (name === "find") return obj.pattern ? `${obj.pattern}${obj.path ? ` · ${obj.path}` : ""}` : (obj.path || "");
1438
1539
  if (typeof obj === "object" && obj !== null) {
1439
1540
  const keys = Object.keys(obj);
1440
1541
  if (keys.length === 1 && typeof obj[keys[0]] === "string") return obj[keys[0]];
1542
+ return JSON.stringify(obj);
1441
1543
  }
1442
- return "";
1544
+ return String(obj);
1443
1545
  } catch { return ""; }
1444
1546
  }
1445
1547
 
@@ -1476,6 +1578,7 @@ function getStreamingFullText() {
1476
1578
  function ensureStreamingMsg(ts = Date.now()) {
1477
1579
  if (state.streamingMsg) return state.streamingMsg;
1478
1580
  showEmptyState(false);
1581
+ lastRenderedItemCount = 0;
1479
1582
  state.turnStartedAt = ts || Date.now();
1480
1583
  startStreamingTimer();
1481
1584
  const timeStr = formatMessageTime(ts);
@@ -1533,26 +1636,47 @@ function refreshStreamingContentDebounced() {
1533
1636
  });
1534
1637
  }
1535
1638
 
1639
+ let lastRenderedItemCount = 0;
1536
1640
  function refreshStreamingContent() {
1537
1641
  const node = state.streamingMsg;
1538
1642
  if (!node) return;
1539
1643
  const content = node.querySelector(".content");
1540
- content.innerHTML = "";
1644
+ const items = state.streamingItems;
1541
1645
 
1542
- if (state.streamingItems.length === 0) {
1543
- if (state.streaming) {
1544
- content.appendChild(el("div", { class: "thinking-placeholder" }, [
1545
- el("span", { class: "thinking-spinner" }),
1546
- el("span", { class: "thinking-label", text: "正在思考中…" })
1547
- ]));
1646
+ if (items.length === 0) {
1647
+ if (content.children.length === 0 || !content.querySelector(".thinking-placeholder")) {
1648
+ content.innerHTML = "";
1649
+ if (state.streaming) {
1650
+ content.appendChild(el("div", { class: "thinking-placeholder" }, [
1651
+ el("span", { class: "thinking-spinner" }),
1652
+ el("span", { class: "thinking-label", text: "正在思考中…" })
1653
+ ]));
1654
+ }
1548
1655
  }
1549
1656
  scrollBottom();
1550
1657
  return;
1551
1658
  }
1552
1659
 
1553
- const lastIdx = state.streamingItems.length - 1;
1554
- for (let i = 0; i < state.streamingItems.length; i++) {
1555
- const item = state.streamingItems[i];
1660
+ // Fast path: if item count unchanged and last item is text, only update its innerHTML.
1661
+ // This avoids O(n²) rebuilding on every text delta for long messages with many tool calls.
1662
+ if (items.length === lastRenderedItemCount && items.length > 0) {
1663
+ const lastItem = items[items.length - 1];
1664
+ if (lastItem.type === "text") {
1665
+ const lastEl = content.lastElementChild;
1666
+ if (lastEl && !lastEl.classList.contains("thinking-block") && !lastEl.classList.contains("tool-block")) {
1667
+ const showCursor = state.streaming;
1668
+ lastEl.innerHTML = renderMarkdown(lastItem.text) + (showCursor ? '<span class="typing-cursor"></span>' : "");
1669
+ scrollBottom();
1670
+ return;
1671
+ }
1672
+ }
1673
+ }
1674
+
1675
+ // Full rebuild (new items added or structure changed)
1676
+ content.innerHTML = "";
1677
+ const lastIdx = items.length - 1;
1678
+ for (let i = 0; i < items.length; i++) {
1679
+ const item = items[i];
1556
1680
  const isLast = (i === lastIdx);
1557
1681
  if (item.type === "thinking") {
1558
1682
  const isActivelyThinking = state.streaming && isLast && item.isStreaming !== false;
@@ -1564,11 +1688,14 @@ function refreshStreamingContent() {
1564
1688
  content.appendChild(el("div", { html: renderMarkdown(item.text) + (showCursor ? '<span class="typing-cursor"></span>' : "") }));
1565
1689
  }
1566
1690
  }
1691
+ lastRenderedItemCount = items.length;
1567
1692
  scrollBottom();
1568
1693
  }
1569
1694
 
1570
1695
  function finalizeStreamingMsg() {
1571
1696
  state.streaming = false;
1697
+ if (state.streamingWatchdog) { clearTimeout(state.streamingWatchdog); state.streamingWatchdog = null; }
1698
+ lastRenderedItemCount = 0;
1572
1699
  stopStreamingTimer();
1573
1700
  if (state.streamingMsg) {
1574
1701
  for (const item of state.streamingItems) {
@@ -1841,15 +1968,13 @@ function sendWs(obj) {
1841
1968
  function handlePiMessage(obj) {
1842
1969
  // Automatically bind to the session file as soon as pi allocates it on disk
1843
1970
  const sessionFile = obj.data?.sessionFile || obj.sessionFile || obj.data?.sessionPath || obj.sessionPath;
1844
- if (sessionFile && !sameSession(sessionFile, state.currentSessionFile)) {
1971
+ if (sessionFile) {
1972
+ const isDifferent = !sameSession(sessionFile, state.currentSessionFile);
1845
1973
  state.currentSessionFile = sessionFile;
1846
- try {
1847
- const newUrl = window.location.pathname + "?session=" + encodeURIComponent(sessionFile);
1848
- window.history.replaceState({ session: sessionFile }, "", newUrl);
1849
- } catch {}
1850
- // Do not set baseName(sessionFile) as title — it's a session ID, not a user-friendly summary.
1851
- // The proper title will be set by syncSessionHistory (via firstUser) or by the user prompt.
1852
- refreshSessions();
1974
+ if (state.streaming || isDifferent) {
1975
+ updateUrlSession(sessionFile);
1976
+ refreshSessions();
1977
+ }
1853
1978
  }
1854
1979
 
1855
1980
  // Backfill markers emitted by the server when it replays buffered events
@@ -2104,27 +2229,36 @@ function handlePiMessage(obj) {
2104
2229
  activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
2105
2230
  }
2106
2231
  ensureStreamingMsg();
2107
- const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName, arguments: obj.args };
2108
- // args may be incomplete until toolcall_end; we fill what we have now
2109
- // and patch the head display on toolcall_end.
2110
- ensureToolBlock(call.id, call.name, call.arguments, Date.now());
2232
+ const callId = ev.toolCall?.id || ev.id || obj.toolCallId;
2233
+ const callName = ev.toolCall?.name || ev.toolName || obj.toolName;
2234
+ const callArgs = ev.toolCall?.arguments || obj.args;
2235
+ if (callId) {
2236
+ ensureToolBlock(callId, callName, callArgs, Date.now());
2237
+ }
2111
2238
  } else if (ev.type === "toolcall_delta") {
2112
- // Streaming function-call argument JSON. We don't render it live
2113
- // (JSON fragments are not useful UX), but make sure the tool block
2114
- // exists so toolcall_end has somewhere to write into.
2115
- const id = obj.toolCallId || ev.id;
2116
- ensureToolBlock(id, obj.toolName || ev.toolCall?.name, obj.args, Date.now());
2239
+ // Streaming function-call argument JSON. We do not create dummy tool blocks
2240
+ // with undefined ID here to prevent empty tasks from appearing.
2117
2241
  } else if (ev.type === "toolcall_end") {
2118
- // Authoritative final toolCall object (with full arguments). Patch
2119
- // the block head so the displayed args are the final ones, not the
2120
- // partial ones we got at toolcall_start.
2121
- const call = ev.toolCall || { id: obj.toolCallId, name: obj.toolName, arguments: obj.args };
2122
- const id = call.id || obj.toolCallId;
2123
- const tc = state.activeToolCalls.get(id);
2124
- if (tc) {
2125
- const argsEl = tc.head.querySelector(".args");
2126
- if (argsEl) argsEl.textContent = summaryArgs(call.name, call.arguments);
2127
- updateToolBlockCopyBtn(tc, call);
2242
+ // Authoritative final toolCall object (with full arguments).
2243
+ // Update the block head so the displayed args and copy button are final,
2244
+ // and pre-render the command in the block body immediately!
2245
+ const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName || ev.toolName, arguments: obj.args };
2246
+ const id = call.id || obj.toolCallId || ev.id;
2247
+ if (id) {
2248
+ let tc = state.activeToolCalls.get(id);
2249
+ if (!tc) {
2250
+ tc = ensureToolBlock(id, call.name, call.arguments, Date.now());
2251
+ }
2252
+ if (tc) {
2253
+ tc.name = call.name || tc.name;
2254
+ tc.args = call.arguments || tc.args;
2255
+ const nameEl = tc.head.querySelector(".name");
2256
+ if (nameEl && tc.name) nameEl.textContent = tc.name;
2257
+ const argsEl = tc.head.querySelector(".args");
2258
+ if (argsEl) argsEl.textContent = summaryArgs(tc.name, tc.args);
2259
+ updateToolBlockCopyBtn(tc, { name: tc.name, arguments: tc.args });
2260
+ renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, "", true, false);
2261
+ }
2128
2262
  }
2129
2263
  }
2130
2264
  break;
@@ -2140,18 +2274,26 @@ function handlePiMessage(obj) {
2140
2274
  const tc = ensureToolBlock(obj.toolCallId, obj.toolName, obj.args, Date.now());
2141
2275
  if (tc) {
2142
2276
  tc.startedAt = Date.now();
2277
+ tc.name = obj.toolName || tc.name;
2278
+ tc.args = obj.args || tc.args;
2143
2279
  if (tc.durationEl) {
2144
2280
  tc.durationEl.className = "tool-duration live";
2145
2281
  tc.durationEl._startedAt = tc.startedAt;
2146
2282
  tc.durationEl.textContent = "0.0s";
2147
2283
  tc.durationEl.style.display = "";
2148
2284
  }
2285
+ const nameEl = tc.head.querySelector(".name");
2286
+ if (nameEl && tc.name) nameEl.textContent = tc.name;
2287
+ const argsEl = tc.head.querySelector(".args");
2288
+ if (argsEl) argsEl.textContent = summaryArgs(tc.name, tc.args);
2149
2289
  const stateEl = tc.head.querySelector(".state");
2150
2290
  if (stateEl) {
2151
2291
  stateEl.className = "state running";
2152
2292
  stateEl.textContent = "执行中…";
2153
2293
  }
2154
- updateToolBlockCopyBtn(tc, { name: obj.toolName, arguments: obj.args });
2294
+ updateToolBlockCopyBtn(tc, { name: tc.name, arguments: tc.args });
2295
+ // Immediately render the command code block in body so user sees it right away!
2296
+ renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, "", true, false);
2155
2297
  }
2156
2298
  break;
2157
2299
  }
@@ -2159,7 +2301,12 @@ function handlePiMessage(obj) {
2159
2301
  const tc = state.activeToolCalls.get(obj.toolCallId);
2160
2302
  if (tc) {
2161
2303
  const text = extractContentText(obj.partialResult?.content);
2162
- tc.body.innerHTML = escapeHtml(text) || "(执行中…)";
2304
+ const outPre = tc.body.querySelector(".tool-output-block");
2305
+ if (outPre) {
2306
+ outPre.textContent = text || "(执行中…)";
2307
+ } else {
2308
+ renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, text, true, false);
2309
+ }
2163
2310
  }
2164
2311
  break;
2165
2312
  }
@@ -2167,7 +2314,6 @@ function handlePiMessage(obj) {
2167
2314
  const tc = state.activeToolCalls.get(obj.toolCallId);
2168
2315
  if (tc) {
2169
2316
  const text = extractContentText(obj.result?.content);
2170
- tc.body.innerHTML = escapeHtml(text) || "(无输出)";
2171
2317
  tc.endedAt = Date.now();
2172
2318
  const dur = Math.max(0, tc.endedAt - (tc.startedAt || tc.endedAt));
2173
2319
  tc.durationMs = dur;
@@ -2182,9 +2328,8 @@ function handlePiMessage(obj) {
2182
2328
  stateEl.textContent = obj.isError ? "错误" : "完成";
2183
2329
  stateEl.className = "state" + (obj.isError ? " error" : "");
2184
2330
  }
2331
+ renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, text, false, Boolean(obj.isError));
2185
2332
  // Remove from activeToolCalls so stale entries don't accumulate.
2186
- // Previously only cleared wholesale in finalizeStreamingMsg/clearChat/etc,
2187
- // which meant a missed tool_execution_end left tools stuck in "执行中…" forever.
2188
2333
  state.activeToolCalls.delete(obj.toolCallId);
2189
2334
  }
2190
2335
  break;
@@ -2257,9 +2402,17 @@ function handleExtensionUiRequest(req) {
2257
2402
  }
2258
2403
 
2259
2404
  function ensureToolBlock(toolCallId, name, args, ts = Date.now()) {
2260
- if (state.activeToolCalls.has(toolCallId)) return state.activeToolCalls.get(toolCallId);
2405
+ if (!toolCallId) return null;
2406
+ if (state.activeToolCalls.has(toolCallId)) {
2407
+ const existing = state.activeToolCalls.get(toolCallId);
2408
+ if (name && !existing.name) existing.name = name;
2409
+ if (args && (!existing.args || existing.args === "{}")) existing.args = args;
2410
+ return existing;
2411
+ }
2261
2412
  const block = makeToolBlockFromCall({ id: toolCallId, name, arguments: args, startedAt: ts }, ts);
2262
- const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head, durationEl: block._durationEl, startedAt: ts };
2413
+ const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head, durationEl: block._durationEl, startedAt: ts, name, args };
2414
+ entry.name = name;
2415
+ entry.args = args;
2263
2416
  state.activeToolCalls.set(toolCallId, entry);
2264
2417
  state.streamingItems.push({ type: "tool", id: toolCallId, tc: entry });
2265
2418
  refreshStreamingContentDebounced();
@@ -2981,8 +3134,23 @@ function submitPrompt() {
2981
3134
  ensureStreamingMsg(now);
2982
3135
  refreshStreamingContent();
2983
3136
 
3137
+ // Streaming watchdog: if no agent_end in 5 minutes, auto-finalize
3138
+ if (state.streamingWatchdog) clearTimeout(state.streamingWatchdog);
3139
+ state.streamingWatchdog = setTimeout(() => {
3140
+ if (state.streaming) {
3141
+ showToast("生成超时(5 分钟无响应),已自动结束。");
3142
+ finalizeStreamingMsg();
3143
+ }
3144
+ }, 5 * 60 * 1000);
3145
+
3146
+ // If sessionFile is already known, immediately anchor it to browser URL so page reloads don't lose the active conversation
3147
+ if (state.currentSessionFile) {
3148
+ updateUrlSession(state.currentSessionFile);
3149
+ }
3150
+
2984
3151
  // Set session name from the first prompt of a brand-new session.
2985
- if (state.currentSessionFile == null && text) {
3152
+ const isFirstPrompt = ($("#chat-inner")?.querySelectorAll(".msg").length || 0) <= 2;
3153
+ if (isFirstPrompt && text) {
2986
3154
  const promptTitle = text.slice(0, 60).replace(/\s+/g, " ");
2987
3155
  $("#topSessionName").textContent = promptTitle;
2988
3156
  updatePageTitle(promptTitle);
@@ -3013,6 +3181,7 @@ function submitPrompt() {
3013
3181
  state.aborting = false;
3014
3182
  setComposerStreaming(false);
3015
3183
  stopStreamingTimer();
3184
+ if (state.streamingWatchdog) { clearTimeout(state.streamingWatchdog); state.streamingWatchdog = null; }
3016
3185
  state.turnStartedAt = null;
3017
3186
  state.streamingMsgDurationEl = null;
3018
3187
  if (state.streamingMsg) { state.streamingMsg.remove(); state.streamingMsg = null; }
@@ -3454,12 +3623,16 @@ async function init() {
3454
3623
  }
3455
3624
  });
3456
3625
 
3457
- // Background session status polling
3626
+ // Background session status polling — refresh every 15s when page is visible.
3627
+ // Skip when sidebar is collapsed on mobile to avoid unnecessary API calls.
3458
3628
  setInterval(() => {
3459
3629
  if (document.visibilityState === "visible") {
3460
- refreshSessions();
3630
+ const isSidebarVisible = window.innerWidth > 768 || $(".app").classList.contains("sidebar-open");
3631
+ if (isSidebarVisible || state.streaming) {
3632
+ refreshSessions();
3633
+ }
3461
3634
  }
3462
- }, 10000);
3635
+ }, 15000);
3463
3636
 
3464
3637
  // Global event delegation for code block copy buttons
3465
3638
  document.addEventListener("click", async (e) => {
package/public/index.html CHANGED
@@ -119,7 +119,7 @@
119
119
  <div class="composer">
120
120
  <div class="image-preview-bar" id="imagePreviewBar" style="display:none;"></div>
121
121
  <div class="composer-inner" id="composerInner">
122
- <input type="file" id="imageFileInput" class="sr-only-file-input" accept="image/*,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg,.heic,.heif,.ico,.avif,text/*,.txt,.md,.json,.js,.ts,.py,.sh,.html,.css,.yaml,.yml,.toml,.xml,.sql,.log,.csv" multiple>
122
+ <input type="file" id="imageFileInput" class="sr-only-file-input" multiple>
123
123
  <label class="attach-btn" id="btnAttachImage" for="imageFileInput" role="button" tabindex="0" title="添加附件 / 图片 (支持拖拽与截图粘贴)">
124
124
  <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
125
125
  <path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
package/public/style.css CHANGED
@@ -804,10 +804,48 @@ body {
804
804
  .tool-head .state.error { color: var(--danger); background: #3a1a1a; }
805
805
  .tool-body {
806
806
  padding: 12px 14px; border-top: 1px solid var(--tool-border);
807
- max-height: 280px; overflow-y: auto;
808
- white-space: pre-wrap; word-break: break-word;
807
+ max-height: 380px; overflow-y: auto;
809
808
  font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12px; color: #cfcfcf;
810
809
  }
810
+ .tool-section {
811
+ margin-bottom: 12px;
812
+ }
813
+ .tool-section:last-child {
814
+ margin-bottom: 0;
815
+ }
816
+ .tool-section-header {
817
+ display: flex; align-items: center; justify-content: space-between;
818
+ margin-bottom: 6px;
819
+ }
820
+ .tool-section-title {
821
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
822
+ letter-spacing: 0.5px; color: var(--text-muted);
823
+ }
824
+ .tool-running-pulse {
825
+ font-size: 11px; color: var(--accent);
826
+ animation: pulse-op 1.4s infinite ease-in-out;
827
+ }
828
+ @keyframes pulse-op {
829
+ 0%, 100% { opacity: 0.5; }
830
+ 50% { opacity: 1; }
831
+ }
832
+ .tool-code-block, .tool-output-block {
833
+ margin: 0; padding: 8px 12px;
834
+ background: rgba(0, 0, 0, 0.35);
835
+ border: 1px solid rgba(255, 255, 255, 0.08);
836
+ border-radius: 6px;
837
+ white-space: pre-wrap; word-break: break-word;
838
+ font-family: inherit; font-size: 12px; line-height: 1.45;
839
+ color: #e5e5e5;
840
+ }
841
+ .tool-code-block {
842
+ color: #93c5fd;
843
+ border-left: 2px solid var(--accent);
844
+ }
845
+ .tool-output-block.is-error {
846
+ color: var(--danger);
847
+ border-left: 2px solid var(--danger);
848
+ }
811
849
 
812
850
  /* Thinking blocks */
813
851
  .thinking-block {