@liguoshuai/pi-web-chat 2.15.4 → 2.17.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/README.md CHANGED
@@ -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.15.4",
3
+ "version": "2.17.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",
@@ -42,13 +42,13 @@
42
42
  "dependencies": {
43
43
  "express": "^4.21.2",
44
44
  "ws": "^8.18.0",
45
- "@liguoshuai/pi-chat-protocol": "2.15.4",
46
- "@liguoshuai/pi-chat-server": "2.15.4"
45
+ "@liguoshuai/pi-chat-protocol": "2.17.8",
46
+ "@liguoshuai/pi-chat-server": "2.17.8"
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,8 @@ 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
85
+ lastSessions: [],
84
86
  };
85
87
 
86
88
  let toastTimer = null;
@@ -308,9 +310,11 @@ const makeCopyIconSvg = () => el("svg", {
308
310
  stroke: "currentColor",
309
311
  "stroke-width": "2",
310
312
  "stroke-linecap": "round",
311
- "stroke-linejoin": "round",
312
- html: '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>'
313
- });
313
+ "stroke-linejoin": "round"
314
+ }, [
315
+ el("rect", { x: "9", y: "9", width: "13", height: "13", rx: "2", ry: "2" }),
316
+ el("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" })
317
+ ]);
314
318
 
315
319
  // ---- Message Time Formatter ----
316
320
  function formatMessageTime(ts) {
@@ -359,8 +363,10 @@ function startStreamingTimer() {
359
363
  if (state.turnStartedAt && state.streamingMsgDurationEl) {
360
364
  state.streamingMsgDurationEl.textContent = formatDuration(now - state.turnStartedAt);
361
365
  }
362
- const liveThinking = document.querySelectorAll(".thinking-duration.live");
363
- 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) => {
364
370
  if (el._startedAt) {
365
371
  el.textContent = formatDuration(now - el._startedAt);
366
372
  el.style.display = "";
@@ -373,6 +379,20 @@ function startStreamingTimer() {
373
379
  el.style.display = "";
374
380
  }
375
381
  });
382
+ // Live update for the thinking placeholder spinner label before first delta arrives
383
+ const placeholder = document.querySelector(".thinking-placeholder");
384
+ if (placeholder && state.turnStartedAt) {
385
+ const elapsed = Math.max(0, now - state.turnStartedAt);
386
+ const labelEl = placeholder.querySelector(".thinking-label");
387
+ if (labelEl) {
388
+ const durStr = formatDuration(elapsed);
389
+ if (elapsed > 2500) {
390
+ labelEl.textContent = `正在深度推理中… (${durStr})`;
391
+ } else {
392
+ labelEl.textContent = `正在思考中… (${durStr})`;
393
+ }
394
+ }
395
+ }
376
396
  }, 100);
377
397
  }
378
398
 
@@ -383,6 +403,20 @@ function stopStreamingTimer() {
383
403
  }
384
404
  }
385
405
 
406
+ function updateUrlSession(sessionFile) {
407
+ if (!sessionFile) return;
408
+ try {
409
+ const params = new URLSearchParams(window.location.search);
410
+ params.set("session", sessionFile);
411
+ if (state.cwd && !params.has("cwd")) {
412
+ params.set("cwd", state.cwd);
413
+ }
414
+ const query = params.toString();
415
+ const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
416
+ window.history.replaceState({ session: sessionFile }, "", newUrl);
417
+ } catch {}
418
+ }
419
+
386
420
  function sameSession(a, b) {
387
421
  if (!a || !b) return false;
388
422
  if (a === b) return true;
@@ -396,10 +430,14 @@ async function refreshSessions() {
396
430
  try {
397
431
  const res = await authFetch(`${API}/api/sessions?cwd=${encodeURIComponent(cwd)}`);
398
432
  const data = await res.json();
399
- renderSidebar(data.sessions || []);
433
+ state.lastSessions = data.sessions || [];
434
+ renderSidebar(state.lastSessions);
400
435
  } catch (err) {
401
436
  console.warn("refreshSessions error:", err);
402
- renderSidebar([]);
437
+ // Preserve existing sessions list rather than blanking out the sidebar on error
438
+ if (state.lastSessions && state.lastSessions.length > 0) {
439
+ renderSidebar(state.lastSessions);
440
+ }
403
441
  }
404
442
  }
405
443
 
@@ -524,9 +562,15 @@ function startNewSession() {
524
562
  state.activeToolCalls.clear();
525
563
  setComposerStreaming(false);
526
564
  try {
527
- window.history.replaceState({}, "", window.location.pathname);
565
+ const params = new URLSearchParams(window.location.search);
566
+ params.delete("session");
567
+ params.delete("file");
568
+ const query = params.toString();
569
+ const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
570
+ window.history.replaceState({}, "", newUrl);
528
571
  } catch {}
529
572
  $("#topSessionName").textContent = "新对话";
573
+ updatePageTitle(null);
530
574
  if (wasStreaming) {
531
575
  showToast("前一个会话已转入后台继续运行");
532
576
  }
@@ -534,8 +578,8 @@ function startNewSession() {
534
578
  if (window.innerWidth <= 768) closeSidebar();
535
579
 
536
580
  connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
537
- // Instantly render optimistic new session at the top of left sidebar
538
- renderSidebar([]);
581
+ // Keep existing sessions in sidebar and immediately display new draft item
582
+ renderSidebar(state.lastSessions || []);
539
583
  refreshSessions();
540
584
  }
541
585
 
@@ -553,7 +597,7 @@ async function deleteSession(file, title) {
553
597
  return;
554
598
  }
555
599
  showToast("会话已删除");
556
- if (state.currentSessionFile === file) {
600
+ if (sameSession(state.currentSessionFile, file)) {
557
601
  startNewSession();
558
602
  } else {
559
603
  await refreshSessions();
@@ -586,6 +630,12 @@ function initMobileToolbarFab() {
586
630
  } else {
587
631
  fab.classList.remove("visible");
588
632
  }
633
+ const distanceFromBottom = chat.scrollHeight - chat.scrollTop - chat.clientHeight;
634
+ if (distanceFromBottom <= 80) {
635
+ userScrolledUp = false;
636
+ } else if (distanceFromBottom > 150) {
637
+ userScrolledUp = true;
638
+ }
589
639
  };
590
640
 
591
641
  chat.addEventListener("scroll", onScroll);
@@ -616,6 +666,7 @@ async function syncSessionHistory(file, force = false) {
616
666
 
617
667
  const topName = data.sessionName || data.firstUser || "新对话";
618
668
  $("#topSessionName").textContent = topName;
669
+ updatePageTitle(topName);
619
670
 
620
671
  // Only overwrite chat if not actively backfilling
621
672
  if (!state.isBackfilling && (force || !state.streaming)) {
@@ -625,7 +676,8 @@ async function syncSessionHistory(file, force = false) {
625
676
  for (const m of msgs) {
626
677
  appendMessageNode(m.role, m);
627
678
  }
628
- scrollBottom();
679
+ userScrolledUp = false;
680
+ scrollBottom(true);
629
681
  refreshSessions();
630
682
  }
631
683
  } catch (e) {
@@ -643,10 +695,7 @@ async function loadSession(file) {
643
695
  state.streamingMsg = null;
644
696
  state.activeToolCalls.clear();
645
697
  setComposerStreaming(false);
646
- try {
647
- const newUrl = window.location.pathname + "?session=" + encodeURIComponent(file);
648
- window.history.replaceState({ session: file }, "", newUrl);
649
- } catch {}
698
+ updateUrlSession(file);
650
699
  // Mobile: close sidebar on selection
651
700
  if (window.innerWidth <= 768) {
652
701
  closeSidebar();
@@ -681,16 +730,39 @@ function reconstructFromEntries(entries, timing = null) {
681
730
  }
682
731
  }
683
732
 
733
+ // Precompute which entry indices are the last assistant message in their turn.
734
+ // A turn is delimited by user messages; the last assistant message before the
735
+ // next user message (or end of entries) is the last in its turn.
736
+ // We only show the total turn duration on this final message, not on every
737
+ // intermediate assistant message (e.g. tool-call messages within the same turn).
738
+ const lastAssistantInTurn = new Set();
739
+ let lastAssistantIdx = null;
740
+ for (let i = 0; i < entries.length; i++) {
741
+ const e = entries[i];
742
+ if (e.type !== "message" || !e.message) continue;
743
+ if (e.message.role === "assistant") {
744
+ lastAssistantIdx = i;
745
+ } else if (e.message.role === "user" && lastAssistantIdx != null) {
746
+ lastAssistantInTurn.add(lastAssistantIdx);
747
+ lastAssistantIdx = null;
748
+ }
749
+ }
750
+ if (lastAssistantIdx != null) lastAssistantInTurn.add(lastAssistantIdx);
751
+
684
752
  const out = [];
685
753
  let lastUserTs = null;
686
- let assistantMsgCount = 0;
687
- for (const e of entries) {
754
+ let turnIndex = -1; // incremented per user message — aligns with timing[] (one entry per turn)
755
+ let thinkingIdx = 0; // accumulated across assistant messages within the same turn
756
+ for (let i = 0; i < entries.length; i++) {
757
+ const e = entries[i];
688
758
  if (e.type !== "message") continue;
689
759
  const m = e.message;
690
760
  if (!m || m.role === "bashExecution") continue;
691
761
  const msgTs = parseEntryTimestamp(m.timestamp || e.timestamp);
692
762
  if (m.role === "user") {
693
763
  lastUserTs = msgTs;
764
+ turnIndex++;
765
+ thinkingIdx = 0; // reset thinking index for the new turn
694
766
  // Extract optional images from user message content array
695
767
  let images = [];
696
768
  if (Array.isArray(m.content)) {
@@ -705,17 +777,19 @@ function reconstructFromEntries(entries, timing = null) {
705
777
  out.push({ role: "user", text: extractContentText(m.content), images, ts: msgTs });
706
778
  } else if (m.role === "assistant") {
707
779
  let turnDurationMs = null;
708
- // Try timing data first, then fall back to timestamp heuristic
709
- const turnTiming = timing ? timing[assistantMsgCount] : null;
710
- if (turnTiming?.turnDuration != null) {
711
- turnDurationMs = turnTiming.turnDuration;
712
- } else if (lastUserTs && msgTs && msgTs >= lastUserTs) {
713
- const diff = msgTs - lastUserTs;
714
- if (diff > 0 && diff < 15 * 60 * 1000) {
715
- turnDurationMs = diff;
780
+ // Try timing data first, then fall back to timestamp heuristic.
781
+ // Only show turn duration on the last assistant message of the turn.
782
+ const turnTiming = timing ? timing[turnIndex] : null;
783
+ if (lastAssistantInTurn.has(i)) {
784
+ if (turnTiming?.turnDuration != null) {
785
+ turnDurationMs = turnTiming.turnDuration;
786
+ } else if (lastUserTs && msgTs && msgTs >= lastUserTs) {
787
+ const diff = msgTs - lastUserTs;
788
+ if (diff > 0 && diff < 15 * 60 * 1000) {
789
+ turnDurationMs = diff;
790
+ }
716
791
  }
717
792
  }
718
- let thinkingIdx = 0;
719
793
  const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
720
794
  const content = rawContent.map(part => {
721
795
  if (part && part.type === "thinking") {
@@ -753,13 +827,11 @@ function reconstructFromEntries(entries, timing = null) {
753
827
  content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
754
828
  }
755
829
  out.push({ role: "assistant", content, ts: msgTs, turnDurationMs, usage: m.usage });
756
- assistantMsgCount++;
757
830
  }
758
831
  // toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
759
832
  }
760
833
  return out;
761
834
  }
762
-
763
835
  function extractContentText(content) {
764
836
  if (typeof content === "string") return content;
765
837
  if (!Array.isArray(content)) return "";
@@ -787,6 +859,7 @@ function clearChat() {
787
859
  chatInner.innerHTML = "";
788
860
  state.streamingMsg = null;
789
861
  state.streamingItems = [];
862
+ lastRenderedItemCount = 0;
790
863
  state.thinkingOpen = true;
791
864
  state.thinkingUserToggled = false;
792
865
  state.activeToolCalls.clear();
@@ -1025,6 +1098,22 @@ async function handleIncomingFiles(files) {
1025
1098
 
1026
1099
  const handleImageFiles = handleIncomingFiles;
1027
1100
 
1101
+ /**
1102
+ * 手动压缩当前会话上下文,释放长会话累积的 token,缓解越聊越慢。
1103
+ * 触发后由 pi 端执行 compaction,返回结果通过 response 事件回显。
1104
+ */
1105
+ function compactContext() {
1106
+ if (state.streaming) {
1107
+ showToast("请等待当前任务结束后再压缩上下文");
1108
+ return;
1109
+ }
1110
+ if (!sendWs({ type: "compact" })) {
1111
+ showToast("连接不可用,无法压缩上下文");
1112
+ return;
1113
+ }
1114
+ showToast("正在压缩上下文…");
1115
+ }
1116
+
1028
1117
  function exportCurrentSession() {
1029
1118
  const chatInner = $("#chat-inner");
1030
1119
  if (!chatInner || chatInner.children.length === 0) {
@@ -1281,6 +1370,79 @@ function updateToolBlockCopyBtn(tc, call) {
1281
1370
  tc.head._cmdToCopy = cmd;
1282
1371
  }
1283
1372
 
1373
+ function formatToolCommandText(name, args) {
1374
+ if (!args) return "";
1375
+ let obj = args;
1376
+ if (typeof args === "string") {
1377
+ try { obj = JSON.parse(args); } catch { return args; }
1378
+ }
1379
+ if (!obj || typeof obj !== "object") return String(args);
1380
+ if (name === "bash" && obj.command) {
1381
+ return `$ ${obj.command}`;
1382
+ }
1383
+ if (name === "read" && obj.path) {
1384
+ let s = `read ${obj.path}`;
1385
+ if (obj.offset != null) s += ` (offset: ${obj.offset})`;
1386
+ if (obj.limit != null) s += ` (limit: ${obj.limit})`;
1387
+ return s;
1388
+ }
1389
+ if (name === "write" && obj.path) {
1390
+ let s = `write ${obj.path}`;
1391
+ if (obj.content) {
1392
+ const preview = obj.content.length > 500 ? obj.content.slice(0, 500) + "\n... (truncated)" : obj.content;
1393
+ s += `\n\n--- 写入内容预览 ---\n${preview}`;
1394
+ }
1395
+ return s;
1396
+ }
1397
+ if (name === "edit" && obj.path) {
1398
+ return `edit ${obj.path}`;
1399
+ }
1400
+ if (name === "ls" && obj.path) {
1401
+ return `ls ${obj.path}`;
1402
+ }
1403
+ if (name === "grep") {
1404
+ return `grep "${obj.pattern || ""}" ${obj.path || ""}`;
1405
+ }
1406
+ if (name === "find") {
1407
+ return `find ${obj.path || "."} -name "${obj.pattern || ""}"`;
1408
+ }
1409
+ try {
1410
+ return JSON.stringify(obj, null, 2);
1411
+ } catch {
1412
+ return String(args);
1413
+ }
1414
+ }
1415
+
1416
+ function renderToolBlockContent(bodyEl, call, resultText, isRunning, isError) {
1417
+ if (!bodyEl) return;
1418
+ bodyEl.innerHTML = "";
1419
+ const cmdText = formatToolCommandText(call?.name, call?.arguments ?? call?.args);
1420
+
1421
+ // 1. 指令 / 参数区域 (有命令就立即提前显示,无需等待输出)
1422
+ if (cmdText) {
1423
+ const cmdSec = el("div", { class: "tool-section tool-cmd-section" }, [
1424
+ el("div", { class: "tool-section-header" }, [
1425
+ el("span", { class: "tool-section-title", text: "执行指令" }),
1426
+ ]),
1427
+ el("pre", { class: "tool-code-block", text: cmdText }),
1428
+ ]);
1429
+ bodyEl.appendChild(cmdSec);
1430
+ }
1431
+
1432
+ // 2. 输出区域 (执行中显示实时状态/输出,执行完显示最终结果)
1433
+ const outSec = el("div", { class: "tool-section tool-out-section" });
1434
+ const outHeader = el("div", { class: "tool-section-header" }, [
1435
+ el("span", { class: "tool-section-title", text: isRunning ? "实时状态" : "输出结果" }),
1436
+ isRunning ? el("span", { class: "tool-running-pulse", text: "正在执行…" }) : null,
1437
+ ]);
1438
+ outSec.appendChild(outHeader);
1439
+
1440
+ const displayOut = resultText || (isRunning ? "(命令正在执行中,请稍候…)" : "(无输出)");
1441
+ const outPre = el("pre", { class: "tool-output-block" + (isError ? " is-error" : ""), text: displayOut });
1442
+ outSec.appendChild(outPre);
1443
+ bodyEl.appendChild(outSec);
1444
+ }
1445
+
1284
1446
  function makeToolBlockFromCall(call, ts = null) {
1285
1447
  const block = el("div", { class: "tool-block" });
1286
1448
  const hasResult = Boolean(call.result);
@@ -1344,9 +1506,9 @@ function makeToolBlockFromCall(call, ts = null) {
1344
1506
  ]);
1345
1507
  head._cmdToCopy = cmdToCopy;
1346
1508
 
1347
- const bodyText = hasResult ? (resultText || "(无输出)") : "执行中…";
1348
- const body = el("div", { class: "tool-body", html: escapeHtml(bodyText) });
1509
+ const body = el("div", { class: "tool-body" });
1349
1510
  body.style.display = "none";
1511
+ renderToolBlockContent(body, call, resultText, !hasResult, isError);
1350
1512
 
1351
1513
  head.addEventListener("click", () => {
1352
1514
  body.style.display = body.style.display === "none" ? "block" : "none";
@@ -1360,7 +1522,7 @@ function makeToolBlockFromCall(call, ts = null) {
1360
1522
  block._durationEl = durationEl;
1361
1523
 
1362
1524
  if (!hasResult && call.id) {
1363
- state.activeToolCalls.set(call.id, { block, body, head, durationEl, startedAt: call.startedAt || Date.now() });
1525
+ state.activeToolCalls.set(call.id, { block, body, head, durationEl, startedAt: call.startedAt || Date.now(), name: call.name, args: call.arguments });
1364
1526
  }
1365
1527
 
1366
1528
  return block;
@@ -1378,20 +1540,36 @@ function summaryArgs(name, args) {
1378
1540
  if (name === "write" && obj.path) return obj.path;
1379
1541
  if (name === "edit" && obj.path) return obj.path;
1380
1542
  if (name === "ls" && obj.path) return obj.path;
1381
- if (name === "grep") return obj.pattern || "";
1382
- if (name === "find") return obj.pattern || obj.path || "";
1543
+ if (name === "grep") return obj.pattern ? `${obj.pattern}${obj.path ? ` · ${obj.path}` : ""}` : (obj.path || "");
1544
+ if (name === "find") return obj.pattern ? `${obj.pattern}${obj.path ? ` · ${obj.path}` : ""}` : (obj.path || "");
1383
1545
  if (typeof obj === "object" && obj !== null) {
1384
1546
  const keys = Object.keys(obj);
1385
1547
  if (keys.length === 1 && typeof obj[keys[0]] === "string") return obj[keys[0]];
1548
+ return JSON.stringify(obj);
1386
1549
  }
1387
- return "";
1550
+ return String(obj);
1388
1551
  } catch { return ""; }
1389
1552
  }
1390
1553
 
1391
- function scrollBottom() {
1554
+ let userScrolledUp = false;
1555
+
1556
+ function updatePageTitle(title) {
1557
+ if (!title || title === "新对话") {
1558
+ document.title = "pi-chat";
1559
+ } else {
1560
+ document.title = `${title} · pi-chat`;
1561
+ }
1562
+ }
1563
+
1564
+ function scrollBottom(force = false) {
1392
1565
  // Don't fight the user during a background-event replay (backfill).
1393
1566
  if (state.isBackfilling) return;
1394
1567
  const chat = $("#chat");
1568
+ if (!chat) return;
1569
+ // If streaming and user manually scrolled up to read history, don't hijack scroll unless forced!
1570
+ if (!force && userScrolledUp && state.streaming) {
1571
+ return;
1572
+ }
1395
1573
  chat.scrollTop = chat.scrollHeight;
1396
1574
  }
1397
1575
 
@@ -1406,6 +1584,7 @@ function getStreamingFullText() {
1406
1584
  function ensureStreamingMsg(ts = Date.now()) {
1407
1585
  if (state.streamingMsg) return state.streamingMsg;
1408
1586
  showEmptyState(false);
1587
+ lastRenderedItemCount = 0;
1409
1588
  state.turnStartedAt = ts || Date.now();
1410
1589
  startStreamingTimer();
1411
1590
  const timeStr = formatMessageTime(ts);
@@ -1463,26 +1642,47 @@ function refreshStreamingContentDebounced() {
1463
1642
  });
1464
1643
  }
1465
1644
 
1645
+ let lastRenderedItemCount = 0;
1466
1646
  function refreshStreamingContent() {
1467
1647
  const node = state.streamingMsg;
1468
1648
  if (!node) return;
1469
1649
  const content = node.querySelector(".content");
1470
- content.innerHTML = "";
1650
+ const items = state.streamingItems;
1471
1651
 
1472
- if (state.streamingItems.length === 0) {
1473
- if (state.streaming) {
1474
- content.appendChild(el("div", { class: "thinking-placeholder" }, [
1475
- el("span", { class: "thinking-spinner" }),
1476
- el("span", { class: "thinking-label", text: "正在思考中…" })
1477
- ]));
1652
+ if (items.length === 0) {
1653
+ if (content.children.length === 0 || !content.querySelector(".thinking-placeholder")) {
1654
+ content.innerHTML = "";
1655
+ if (state.streaming) {
1656
+ content.appendChild(el("div", { class: "thinking-placeholder" }, [
1657
+ el("span", { class: "thinking-spinner" }),
1658
+ el("span", { class: "thinking-label", text: "正在思考中…" })
1659
+ ]));
1660
+ }
1478
1661
  }
1479
1662
  scrollBottom();
1480
1663
  return;
1481
1664
  }
1482
1665
 
1483
- const lastIdx = state.streamingItems.length - 1;
1484
- for (let i = 0; i < state.streamingItems.length; i++) {
1485
- const item = state.streamingItems[i];
1666
+ // Fast path: if item count unchanged and last item is text, only update its innerHTML.
1667
+ // This avoids O(n²) rebuilding on every text delta for long messages with many tool calls.
1668
+ if (items.length === lastRenderedItemCount && items.length > 0) {
1669
+ const lastItem = items[items.length - 1];
1670
+ if (lastItem.type === "text") {
1671
+ const lastEl = content.lastElementChild;
1672
+ if (lastEl && !lastEl.classList.contains("thinking-block") && !lastEl.classList.contains("tool-block")) {
1673
+ const showCursor = state.streaming;
1674
+ lastEl.innerHTML = renderMarkdown(lastItem.text) + (showCursor ? '<span class="typing-cursor"></span>' : "");
1675
+ scrollBottom();
1676
+ return;
1677
+ }
1678
+ }
1679
+ }
1680
+
1681
+ // Full rebuild (new items added or structure changed)
1682
+ content.innerHTML = "";
1683
+ const lastIdx = items.length - 1;
1684
+ for (let i = 0; i < items.length; i++) {
1685
+ const item = items[i];
1486
1686
  const isLast = (i === lastIdx);
1487
1687
  if (item.type === "thinking") {
1488
1688
  const isActivelyThinking = state.streaming && isLast && item.isStreaming !== false;
@@ -1494,11 +1694,14 @@ function refreshStreamingContent() {
1494
1694
  content.appendChild(el("div", { html: renderMarkdown(item.text) + (showCursor ? '<span class="typing-cursor"></span>' : "") }));
1495
1695
  }
1496
1696
  }
1697
+ lastRenderedItemCount = items.length;
1497
1698
  scrollBottom();
1498
1699
  }
1499
1700
 
1500
1701
  function finalizeStreamingMsg() {
1501
1702
  state.streaming = false;
1703
+ if (state.streamingWatchdog) { clearTimeout(state.streamingWatchdog); state.streamingWatchdog = null; }
1704
+ lastRenderedItemCount = 0;
1502
1705
  stopStreamingTimer();
1503
1706
  if (state.streamingMsg) {
1504
1707
  for (const item of state.streamingItems) {
@@ -1771,15 +1974,13 @@ function sendWs(obj) {
1771
1974
  function handlePiMessage(obj) {
1772
1975
  // Automatically bind to the session file as soon as pi allocates it on disk
1773
1976
  const sessionFile = obj.data?.sessionFile || obj.sessionFile || obj.data?.sessionPath || obj.sessionPath;
1774
- if (sessionFile && !sameSession(sessionFile, state.currentSessionFile)) {
1977
+ if (sessionFile) {
1978
+ const isDifferent = !sameSession(sessionFile, state.currentSessionFile);
1775
1979
  state.currentSessionFile = sessionFile;
1776
- try {
1777
- const newUrl = window.location.pathname + "?session=" + encodeURIComponent(sessionFile);
1778
- window.history.replaceState({ session: sessionFile }, "", newUrl);
1779
- } catch {}
1780
- // Do not set baseName(sessionFile) as title — it's a session ID, not a user-friendly summary.
1781
- // The proper title will be set by syncSessionHistory (via firstUser) or by the user prompt.
1782
- refreshSessions();
1980
+ if (state.streaming || isDifferent) {
1981
+ updateUrlSession(sessionFile);
1982
+ refreshSessions();
1983
+ }
1783
1984
  }
1784
1985
 
1785
1986
  // Backfill markers emitted by the server when it replays buffered events
@@ -1873,6 +2074,19 @@ function handlePiMessage(obj) {
1873
2074
  state.aborting = false;
1874
2075
  setComposerStreaming(false);
1875
2076
  }
2077
+ else if (obj.command === "compact") {
2078
+ if (obj.success) {
2079
+ const after = obj.data?.estimatedTokensAfter;
2080
+ const msg = typeof after === "number"
2081
+ ? `上下文已压缩(预估剩余约 ${after} tokens)`
2082
+ : "上下文已压缩";
2083
+ showToast(msg);
2084
+ appendSystemNotice(msg);
2085
+ sendWs({ type: "get_state" });
2086
+ } else {
2087
+ showToast(`压缩上下文失败: ${obj.error || "未知错误"}`);
2088
+ }
2089
+ }
1876
2090
  else if (obj.command === "switch_session" && obj.success) {
1877
2091
  // ask pi for current state so we can get session id, name
1878
2092
  sendWs({ type: "get_state" });
@@ -2021,27 +2235,36 @@ function handlePiMessage(obj) {
2021
2235
  activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
2022
2236
  }
2023
2237
  ensureStreamingMsg();
2024
- const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName, arguments: obj.args };
2025
- // args may be incomplete until toolcall_end; we fill what we have now
2026
- // and patch the head display on toolcall_end.
2027
- ensureToolBlock(call.id, call.name, call.arguments, Date.now());
2238
+ const callId = ev.toolCall?.id || ev.id || obj.toolCallId;
2239
+ const callName = ev.toolCall?.name || ev.toolName || obj.toolName;
2240
+ const callArgs = ev.toolCall?.arguments || obj.args;
2241
+ if (callId) {
2242
+ ensureToolBlock(callId, callName, callArgs, Date.now());
2243
+ }
2028
2244
  } else if (ev.type === "toolcall_delta") {
2029
- // Streaming function-call argument JSON. We don't render it live
2030
- // (JSON fragments are not useful UX), but make sure the tool block
2031
- // exists so toolcall_end has somewhere to write into.
2032
- const id = obj.toolCallId || ev.id;
2033
- ensureToolBlock(id, obj.toolName || ev.toolCall?.name, obj.args, Date.now());
2245
+ // Streaming function-call argument JSON. We do not create dummy tool blocks
2246
+ // with undefined ID here to prevent empty tasks from appearing.
2034
2247
  } else if (ev.type === "toolcall_end") {
2035
- // Authoritative final toolCall object (with full arguments). Patch
2036
- // the block head so the displayed args are the final ones, not the
2037
- // partial ones we got at toolcall_start.
2038
- const call = ev.toolCall || { id: obj.toolCallId, name: obj.toolName, arguments: obj.args };
2039
- const id = call.id || obj.toolCallId;
2040
- const tc = state.activeToolCalls.get(id);
2041
- if (tc) {
2042
- const argsEl = tc.head.querySelector(".args");
2043
- if (argsEl) argsEl.textContent = summaryArgs(call.name, call.arguments);
2044
- updateToolBlockCopyBtn(tc, call);
2248
+ // Authoritative final toolCall object (with full arguments).
2249
+ // Update the block head so the displayed args and copy button are final,
2250
+ // and pre-render the command in the block body immediately!
2251
+ const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName || ev.toolName, arguments: obj.args };
2252
+ const id = call.id || obj.toolCallId || ev.id;
2253
+ if (id) {
2254
+ let tc = state.activeToolCalls.get(id);
2255
+ if (!tc) {
2256
+ tc = ensureToolBlock(id, call.name, call.arguments, Date.now());
2257
+ }
2258
+ if (tc) {
2259
+ tc.name = call.name || tc.name;
2260
+ tc.args = call.arguments || tc.args;
2261
+ const nameEl = tc.head.querySelector(".name");
2262
+ if (nameEl && tc.name) nameEl.textContent = tc.name;
2263
+ const argsEl = tc.head.querySelector(".args");
2264
+ if (argsEl) argsEl.textContent = summaryArgs(tc.name, tc.args);
2265
+ updateToolBlockCopyBtn(tc, { name: tc.name, arguments: tc.args });
2266
+ renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, "", true, false);
2267
+ }
2045
2268
  }
2046
2269
  }
2047
2270
  break;
@@ -2057,18 +2280,26 @@ function handlePiMessage(obj) {
2057
2280
  const tc = ensureToolBlock(obj.toolCallId, obj.toolName, obj.args, Date.now());
2058
2281
  if (tc) {
2059
2282
  tc.startedAt = Date.now();
2283
+ tc.name = obj.toolName || tc.name;
2284
+ tc.args = obj.args || tc.args;
2060
2285
  if (tc.durationEl) {
2061
2286
  tc.durationEl.className = "tool-duration live";
2062
2287
  tc.durationEl._startedAt = tc.startedAt;
2063
2288
  tc.durationEl.textContent = "0.0s";
2064
2289
  tc.durationEl.style.display = "";
2065
2290
  }
2291
+ const nameEl = tc.head.querySelector(".name");
2292
+ if (nameEl && tc.name) nameEl.textContent = tc.name;
2293
+ const argsEl = tc.head.querySelector(".args");
2294
+ if (argsEl) argsEl.textContent = summaryArgs(tc.name, tc.args);
2066
2295
  const stateEl = tc.head.querySelector(".state");
2067
2296
  if (stateEl) {
2068
2297
  stateEl.className = "state running";
2069
2298
  stateEl.textContent = "执行中…";
2070
2299
  }
2071
- updateToolBlockCopyBtn(tc, { name: obj.toolName, arguments: obj.args });
2300
+ updateToolBlockCopyBtn(tc, { name: tc.name, arguments: tc.args });
2301
+ // Immediately render the command code block in body so user sees it right away!
2302
+ renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, "", true, false);
2072
2303
  }
2073
2304
  break;
2074
2305
  }
@@ -2076,7 +2307,12 @@ function handlePiMessage(obj) {
2076
2307
  const tc = state.activeToolCalls.get(obj.toolCallId);
2077
2308
  if (tc) {
2078
2309
  const text = extractContentText(obj.partialResult?.content);
2079
- tc.body.innerHTML = escapeHtml(text) || "(执行中…)";
2310
+ const outPre = tc.body.querySelector(".tool-output-block");
2311
+ if (outPre) {
2312
+ outPre.textContent = text || "(执行中…)";
2313
+ } else {
2314
+ renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, text, true, false);
2315
+ }
2080
2316
  }
2081
2317
  break;
2082
2318
  }
@@ -2084,7 +2320,6 @@ function handlePiMessage(obj) {
2084
2320
  const tc = state.activeToolCalls.get(obj.toolCallId);
2085
2321
  if (tc) {
2086
2322
  const text = extractContentText(obj.result?.content);
2087
- tc.body.innerHTML = escapeHtml(text) || "(无输出)";
2088
2323
  tc.endedAt = Date.now();
2089
2324
  const dur = Math.max(0, tc.endedAt - (tc.startedAt || tc.endedAt));
2090
2325
  tc.durationMs = dur;
@@ -2099,9 +2334,8 @@ function handlePiMessage(obj) {
2099
2334
  stateEl.textContent = obj.isError ? "错误" : "完成";
2100
2335
  stateEl.className = "state" + (obj.isError ? " error" : "");
2101
2336
  }
2337
+ renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, text, false, Boolean(obj.isError));
2102
2338
  // Remove from activeToolCalls so stale entries don't accumulate.
2103
- // Previously only cleared wholesale in finalizeStreamingMsg/clearChat/etc,
2104
- // which meant a missed tool_execution_end left tools stuck in "执行中…" forever.
2105
2339
  state.activeToolCalls.delete(obj.toolCallId);
2106
2340
  }
2107
2341
  break;
@@ -2174,9 +2408,17 @@ function handleExtensionUiRequest(req) {
2174
2408
  }
2175
2409
 
2176
2410
  function ensureToolBlock(toolCallId, name, args, ts = Date.now()) {
2177
- if (state.activeToolCalls.has(toolCallId)) return state.activeToolCalls.get(toolCallId);
2411
+ if (!toolCallId) return null;
2412
+ if (state.activeToolCalls.has(toolCallId)) {
2413
+ const existing = state.activeToolCalls.get(toolCallId);
2414
+ if (name && !existing.name) existing.name = name;
2415
+ if (args && (!existing.args || existing.args === "{}")) existing.args = args;
2416
+ return existing;
2417
+ }
2178
2418
  const block = makeToolBlockFromCall({ id: toolCallId, name, arguments: args, startedAt: ts }, ts);
2179
- const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head, durationEl: block._durationEl, startedAt: ts };
2419
+ const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head, durationEl: block._durationEl, startedAt: ts, name, args };
2420
+ entry.name = name;
2421
+ entry.args = args;
2180
2422
  state.activeToolCalls.set(toolCallId, entry);
2181
2423
  state.streamingItems.push({ type: "tool", id: toolCallId, tc: entry });
2182
2424
  refreshStreamingContentDebounced();
@@ -2838,6 +3080,8 @@ function submitSteer() {
2838
3080
  appendMessageNode("user", { text, isSteer: true, ts: Date.now() });
2839
3081
  ta.value = "";
2840
3082
  autoResize();
3083
+ userScrolledUp = false;
3084
+ scrollBottom(true);
2841
3085
  updateComposerUI();
2842
3086
 
2843
3087
  if (hint) hint.textContent = "已插入指导指令!pi 将在当前轮次中实时接收并调整方向。";
@@ -2882,6 +3126,8 @@ function submitPrompt() {
2882
3126
  const now = Date.now();
2883
3127
  // Render the user's message locally for instant feedback.
2884
3128
  appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [], ts: now });
3129
+ userScrolledUp = false;
3130
+ scrollBottom(true);
2885
3131
 
2886
3132
  ta.value = "";
2887
3133
  state.attachedImages = [];
@@ -2894,10 +3140,26 @@ function submitPrompt() {
2894
3140
  ensureStreamingMsg(now);
2895
3141
  refreshStreamingContent();
2896
3142
 
3143
+ // Streaming watchdog: if no agent_end in 5 minutes, auto-finalize
3144
+ if (state.streamingWatchdog) clearTimeout(state.streamingWatchdog);
3145
+ state.streamingWatchdog = setTimeout(() => {
3146
+ if (state.streaming) {
3147
+ toast("生成超时(5 分钟无响应),已自动结束。", "warn");
3148
+ finalizeStreamingMsg();
3149
+ }
3150
+ }, 5 * 60 * 1000);
3151
+
3152
+ // If sessionFile is already known, immediately anchor it to browser URL so page reloads don't lose the active conversation
3153
+ if (state.currentSessionFile) {
3154
+ updateUrlSession(state.currentSessionFile);
3155
+ }
3156
+
2897
3157
  // Set session name from the first prompt of a brand-new session.
2898
- if (state.currentSessionFile == null && text) {
3158
+ const isFirstPrompt = ($("#chat-inner")?.querySelectorAll(".msg").length || 0) <= 2;
3159
+ if (isFirstPrompt && text) {
2899
3160
  const promptTitle = text.slice(0, 60).replace(/\s+/g, " ");
2900
3161
  $("#topSessionName").textContent = promptTitle;
3162
+ updatePageTitle(promptTitle);
2901
3163
  sendWs({ type: "set_session_name", name: promptTitle });
2902
3164
 
2903
3165
  // Update draft session item in sidebar immediately
@@ -2925,12 +3187,23 @@ function submitPrompt() {
2925
3187
  state.aborting = false;
2926
3188
  setComposerStreaming(false);
2927
3189
  stopStreamingTimer();
3190
+ if (state.streamingWatchdog) { clearTimeout(state.streamingWatchdog); state.streamingWatchdog = null; }
2928
3191
  state.turnStartedAt = null;
2929
3192
  state.streamingMsgDurationEl = null;
2930
3193
  if (state.streamingMsg) { state.streamingMsg.remove(); state.streamingMsg = null; }
2931
3194
  state.streamingItems = [];
3195
+ // Restore user input and attachments so text is not lost
3196
+ if (ta) {
3197
+ ta.value = text;
3198
+ autoResize();
3199
+ ta.focus();
3200
+ }
3201
+ if (imagesToSend && imagesToSend.length > 0) {
3202
+ state.attachedImages = imagesToSend;
3203
+ renderImagePreviews();
3204
+ }
2932
3205
  const hint = $(".composer-hint");
2933
- if (hint) hint.textContent = "发送失败:WebSocket 连接已断开。正在尝试重连…";
3206
+ if (hint) hint.textContent = "发送失败:WebSocket 连接已断开,已为你恢复输入内容。正在尝试重连…";
2934
3207
  scheduleReconnect(0);
2935
3208
  }
2936
3209
  }
@@ -3142,6 +3415,12 @@ async function init() {
3142
3415
  exportBtn.addEventListener("click", exportCurrentSession);
3143
3416
  }
3144
3417
 
3418
+ // Compact context button (manual compaction for long/slow sessions)
3419
+ const compactBtn = $("#btnCompactChat");
3420
+ if (compactBtn) {
3421
+ compactBtn.addEventListener("click", compactContext);
3422
+ }
3423
+
3145
3424
  // Image and file attach / picker / paste / drag-and-drop
3146
3425
  const btnAttach = $("#btnAttachImage");
3147
3426
  const fileInput = $("#imageFileInput");
@@ -3350,12 +3629,16 @@ async function init() {
3350
3629
  }
3351
3630
  });
3352
3631
 
3353
- // Background session status polling
3632
+ // Background session status polling — refresh every 15s when page is visible.
3633
+ // Skip when sidebar is collapsed on mobile to avoid unnecessary API calls.
3354
3634
  setInterval(() => {
3355
3635
  if (document.visibilityState === "visible") {
3356
- refreshSessions();
3636
+ const isSidebarVisible = window.innerWidth > 768 || $(".app").classList.contains("sidebar-open");
3637
+ if (isSidebarVisible || state.streaming) {
3638
+ refreshSessions();
3639
+ }
3357
3640
  }
3358
- }, 10000);
3641
+ }, 15000);
3359
3642
 
3360
3643
  // Global event delegation for code block copy buttons
3361
3644
  document.addEventListener("click", async (e) => {
package/public/index.html CHANGED
@@ -76,6 +76,14 @@
76
76
  </svg>
77
77
  <span class="topbar-action-label">导出</span>
78
78
  </button>
79
+ <button class="btn-topbar-action" id="btnCompactChat" title="压缩当前上下文(长会话变慢时使用)">
80
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
81
+ <polyline points="4 7 4 4 20 4 20 7"></polyline>
82
+ <line x1="9" y1="20" x2="15" y2="20"></line>
83
+ <line x1="12" y1="4" x2="12" y2="20"></line>
84
+ </svg>
85
+ <span class="topbar-action-label">压缩</span>
86
+ </button>
79
87
  <div class="model-menu" id="modelMenu"></div>
80
88
  <div class="thinking-menu" id="thinkingMenu"></div>
81
89
  </div>
@@ -111,7 +119,7 @@
111
119
  <div class="composer">
112
120
  <div class="image-preview-bar" id="imagePreviewBar" style="display:none;"></div>
113
121
  <div class="composer-inner" id="composerInner">
114
- <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>
115
123
  <label class="attach-btn" id="btnAttachImage" for="imageFileInput" role="button" tabindex="0" title="添加附件 / 图片 (支持拖拽与截图粘贴)">
116
124
  <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
117
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>
@@ -42,7 +42,7 @@
42
42
  function sanitizeUrl(url) {
43
43
  if (!url) return "#";
44
44
  const trimmed = url.trim();
45
- if (/^(https?:\/\/|mailto:)/i.test(trimmed)) {
45
+ if (/^(https?:\/\/|mailto:|#)/i.test(trimmed)) {
46
46
  return trimmed.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
47
47
  }
48
48
  return "#";
@@ -111,6 +111,7 @@
111
111
  }
112
112
  let outHtml = "";
113
113
  let para = [];
114
+ let quoteLines = [];
114
115
  let linkListOpen = null;
115
116
  let linkListOrdered = null;
116
117
 
@@ -122,6 +123,13 @@
122
123
  }
123
124
  }
124
125
 
126
+ function flushQuote() {
127
+ if (quoteLines.length === 0) return;
128
+ const block = quoteLines.map(l => mdInlineBlock(l)).join("<br>");
129
+ quoteLines = [];
130
+ outHtml += `<blockquote>${block}</blockquote>`;
131
+ }
132
+
125
133
  function flushPara() {
126
134
  if (para.length === 0) return;
127
135
  const block = para.join("\n").trim();
@@ -132,30 +140,35 @@
132
140
  if (seg.kind === "table") {
133
141
  flushPara();
134
142
  flushList();
143
+ flushQuote();
135
144
  outHtml += mdTable(seg.lines);
136
145
  } else if (seg.kind === "line") {
137
146
  // horizontal rule
138
147
  if (/^\s*([-*_])\s*\1\s*\1[\s\-_*]*$/.test(seg.text)) {
139
148
  flushPara();
140
149
  flushList();
150
+ flushQuote();
141
151
  outHtml += "<hr>";
142
152
  } else if (/^(#{1,6})\s+(.*)$/.test(seg.text)) {
143
153
  // headings
144
154
  const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
145
155
  flushPara();
146
156
  flushList();
157
+ flushQuote();
147
158
  const level = m[1].length;
148
159
  outHtml += `<h${level}>${mdInlineBlock(m[2])}</h${level}>`;
149
160
  } else if (/^\s*$/.test(seg.text)) {
150
161
  flushPara();
151
162
  flushList();
163
+ flushQuote();
152
164
  } else if (/^>\s?/.test(seg.text)) {
153
- // blockquote line
165
+ // blockquote line (accumulate adjacent lines into a single blockquote)
154
166
  flushPara();
155
167
  flushList();
156
- outHtml += `<blockquote>${mdInlineBlock(seg.text.replace(/^>\s?/, ""))}</blockquote>`;
168
+ quoteLines.push(seg.text.replace(/^>\s?/, ""));
157
169
  } else if (/^\s*[-*+]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
158
170
  // list item
171
+ flushQuote();
159
172
  const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
160
173
  if (!linkListOpen || linkListOrdered !== isOrdered) {
161
174
  flushPara();
@@ -176,11 +189,13 @@
176
189
  outHtml += `<li>${taskPrefix}${mdInlineBlock(itemText)}</li>`;
177
190
  } else {
178
191
  flushList();
192
+ flushQuote();
179
193
  para.push(seg.text);
180
194
  }
181
195
  }
182
196
  }
183
197
  flushList();
198
+ flushQuote();
184
199
  flushPara();
185
200
  // restore inline code
186
201
  outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => {
package/public/style.css CHANGED
@@ -741,6 +741,9 @@ body {
741
741
  stroke-linejoin: round;
742
742
  flex-shrink: 0;
743
743
  }
744
+ .btn-copy-code svg *, .btn-copy-msg svg *, .btn-copy-tool svg * {
745
+ fill: none;
746
+ }
744
747
  .msg.assistant .content ul, .msg.assistant .content ol { margin: 8px 0 12px 24px; }
745
748
  .msg.assistant .content li { margin: 4px 0; }
746
749
  .msg.assistant .content h1, .msg.assistant .content h2, .msg.assistant .content h3,
@@ -801,10 +804,48 @@ body {
801
804
  .tool-head .state.error { color: var(--danger); background: #3a1a1a; }
802
805
  .tool-body {
803
806
  padding: 12px 14px; border-top: 1px solid var(--tool-border);
804
- max-height: 280px; overflow-y: auto;
805
- white-space: pre-wrap; word-break: break-word;
807
+ max-height: 380px; overflow-y: auto;
806
808
  font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12px; color: #cfcfcf;
807
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
+ }
808
849
 
809
850
  /* Thinking blocks */
810
851
  .thinking-block {