@co0ontty/wand 1.5.1 → 1.5.4

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
@@ -1,22 +1,18 @@
1
1
  # wand
2
2
 
3
- 通过浏览器访问本地终端,支持 Claude 等命令行工具。
3
+ 通过浏览器远程访问和管理本地 CLI 工具的 Web 控制台。专为 Claude Code 设计,支持终端和结构化对话双视图、会话持久化与恢复、权限管控、文件浏览等功能。
4
4
 
5
- ## 功能
5
+ ## 安装
6
6
 
7
- - Web 终端 / Chat 模式双视图
8
- - 会话持久化与恢复
9
- - Claude Code 集成
10
- - 文件浏览器
11
- - HTTPS 安全连接(可选,配置 `https: true` 启用)
7
+ ### 一键安装
12
8
 
13
- ## 一键安装
9
+ 自动检测并安装 Node.js(需要 v22+),然后安装 wand:
14
10
 
15
11
  ```bash
16
12
  bash <(curl -Ls https://raw.githubusercontent.com/co0ontty/wand/master/install.sh)
17
13
  ```
18
14
 
19
- ## 手动安装
15
+ ### 手动安装
20
16
 
21
17
  ```bash
22
18
  npm install -g @co0ontty/wand
@@ -24,16 +20,73 @@ wand init
24
20
  wand web
25
21
  ```
26
22
 
27
- 配置文件:`~/.wand/config.json`
23
+ 安装完成后打开浏览器访问终端中提示的地址即可。
24
+
25
+ ## 配置
26
+
27
+ 配置文件位于 `~/.wand/config.json`,首次 `wand init` 时自动生成。
28
+
29
+ ```bash
30
+ wand config:path # 查看配置文件路径
31
+ wand config:show # 查看当前配置
32
+ wand config:set host 0.0.0.0 # 修改配置项
33
+ wand config:set port 9443
34
+ ```
35
+
36
+ 常用配置项:
37
+
38
+ | 字段 | 默认值 | 说明 |
39
+ |------|--------|------|
40
+ | `host` | `127.0.0.1` | 监听地址,`0.0.0.0` 允许远程访问 |
41
+ | `port` | `8443` | 监听端口 |
42
+ | `https` | `false` | 启用 HTTPS(自签证书自动生成) |
43
+ | `password` | (随机生成) | 登录密码 |
44
+ | `replyLanguage` | `""` | Claude 回复语言偏好 |
45
+
46
+ ## 功能
47
+
48
+ - **双视图模式** — 终端原始输出和结构化对话视图可随时切换
49
+ - **会话管理** — 创建、归档、恢复会话;支持从 Claude 原生历史记录恢复
50
+ - **权限控制** — 可视化权限提示,支持逐次确认、单次批准、本轮记忆等策略
51
+ - **文件浏览器** — 内置路径浏览、搜索和收藏功能
52
+ - **多种运行模式** — full-access / default / auto-edit 等 Claude 运行模式
53
+ - **PWA 支持** — 可添加到主屏幕作为独立应用使用
54
+ - **HTTPS** — 可选自签证书,适合远程或移动端访问
28
55
 
29
56
  ## 开发
30
57
 
31
58
  ```bash
32
- npm install
33
- npm run build
34
- npm run dev
59
+ npm install # 安装依赖
60
+ npm run dev # 从源码直接启动开发服务器
61
+ npm run check # TypeScript 类型检查
62
+ npm run build # 编译 + 复制静态资源到 dist/
63
+ ```
64
+
65
+ 隔离测试环境(不影响生产实例):
66
+
67
+ ```bash
68
+ npm run dev -- -c /tmp/wand-test/config.json
35
69
  ```
36
70
 
71
+ ## 项目结构
72
+
73
+ ```
74
+ src/
75
+ cli.ts # CLI 入口,解析命令和参数
76
+ server.ts # Express 服务器、REST API、WebSocket
77
+ server-session-routes.ts # 会话/恢复/历史相关路由
78
+ process-manager.ts # PTY 会话编排、输入输出路由、权限处理
79
+ claude-pty-bridge.ts # PTY 输出解析为结构化对话数据
80
+ storage.ts # SQLite 持久化
81
+ config.ts # 配置加载与合并
82
+ session-lifecycle.ts # 会话状态机(idle/thinking/waiting/archived)
83
+ session-logger.ts # 文件日志 ~/.wand/sessions/
84
+ resume-policy.ts # Claude 历史绑定与恢复策略
85
+ web-ui/ # 服务端渲染的前端 HTML/CSS/JS
86
+ ```
87
+
88
+ 数据存储在 `~/.wand/` 下:`config.json`(配置)、`wand.db`(SQLite)、`sessions/`(日志)。
89
+
37
90
  ## License
38
91
 
39
- MIT
92
+ MIT
package/dist/server.js CHANGED
@@ -604,55 +604,6 @@ export async function startServer(config, configPath) {
604
604
  { path: "/", name: "根目录", icon: "📁" },
605
605
  ]);
606
606
  });
607
- app.get("/api/favorite-paths", (_req, res) => {
608
- const stored = storage.getConfigValue("favorite_paths");
609
- const favorites = parseStoredPathList(stored);
610
- res.json(favorites.filter((f) => !isBlockedFolderPath(normalizeFolderPath(f.path))));
611
- });
612
- app.post("/api/favorite-paths", (req, res) => {
613
- const { path: favPath, name, icon } = req.body;
614
- if (!favPath) {
615
- res.status(400).json({ error: "路径不能为空。" });
616
- return;
617
- }
618
- const resolvedFavoritePath = normalizeFolderPath(favPath);
619
- if (isBlockedFolderPath(resolvedFavoritePath)) {
620
- res.status(403).json({ error: "访问被拒绝:无法收藏系统敏感目录。" });
621
- return;
622
- }
623
- const stored = storage.getConfigValue("favorite_paths");
624
- const favorites = parseStoredPathList(stored);
625
- if (favorites.some((f) => normalizeFolderPath(f.path) === resolvedFavoritePath)) {
626
- res.status(400).json({ error: "该路径已在收藏列表中。" });
627
- return;
628
- }
629
- const newFavorite = {
630
- path: resolvedFavoritePath,
631
- name: name || path.basename(resolvedFavoritePath),
632
- icon: icon || "⭐",
633
- addedAt: new Date().toISOString(),
634
- };
635
- favorites.push(newFavorite);
636
- storage.setConfigValue("favorite_paths", JSON.stringify(favorites));
637
- res.status(201).json(newFavorite);
638
- });
639
- app.delete("/api/favorite-paths", (req, res) => {
640
- const { path: delPath } = req.body;
641
- if (!delPath) {
642
- res.status(400).json({ error: "路径不能为空。" });
643
- return;
644
- }
645
- const stored = storage.getConfigValue("favorite_paths");
646
- const favorites = parseStoredPathList(stored);
647
- const index = favorites.findIndex((f) => f.path === delPath);
648
- if (index === -1) {
649
- res.status(404).json({ error: "未找到该收藏路径。" });
650
- return;
651
- }
652
- favorites.splice(index, 1);
653
- storage.setConfigValue("favorite_paths", JSON.stringify(favorites));
654
- res.json({ ok: true });
655
- });
656
607
  app.get("/api/recent-paths", (_req, res) => {
657
608
  const stored = storage.getConfigValue("recent_paths");
658
609
  const recent = parseStoredPathList(stored);
@@ -627,6 +627,23 @@ export class StructuredSessionManager {
627
627
  sessionId,
628
628
  data: { status: finished.status, exitCode: 0, messages: finished.messages, sessionKind: "structured", structuredState: finished.structuredState },
629
629
  });
630
+ // Auto-continue after plan mode exit: when Claude calls ExitPlanMode,
631
+ // the `-p` process exits because stdin is "ignore" and it cannot get
632
+ // user confirmation. Detect this and automatically resume execution
633
+ // so the plan is actually carried out.
634
+ const lastToolUse = [...turnState.blocks].reverse().find((b) => b.type === "tool_use");
635
+ if (lastToolUse && lastToolUse.name === "ExitPlanMode" && turnState.sessionId) {
636
+ console.log("[WAND] ExitPlanMode detected – auto-continuing plan execution for session:", sessionId);
637
+ resolve();
638
+ // Schedule the continuation outside the current promise chain so it
639
+ // does not block the close handler.
640
+ setImmediate(() => {
641
+ this.sendMessage(sessionId, "Plan approved. Proceed with the implementation.").catch((err) => {
642
+ console.error("[WAND] Auto-continue after ExitPlanMode failed:", err);
643
+ });
644
+ });
645
+ return;
646
+ }
630
647
  resolve();
631
648
  });
632
649
  });
@@ -100,7 +100,7 @@
100
100
  cwdValue: "",
101
101
  modeValue: "managed",
102
102
  chatMode: "managed",
103
- sessionCreateKind: "pty",
103
+ sessionCreateKind: "structured",
104
104
  sessionTool: "claude",
105
105
  preferredCommand: "claude",
106
106
  structuredRunner: "claude-cli-print",
@@ -1030,14 +1030,13 @@
1030
1030
 
1031
1031
  function selectAllVisibleItems() {
1032
1032
  var nextSessionIds = {};
1033
- state.sessions.forEach(function(session) {
1033
+ state.sessions.filter(function(session) {
1034
+ return !session.resumedToSessionId;
1035
+ }).forEach(function(session) {
1034
1036
  nextSessionIds[session.id] = true;
1035
1037
  });
1036
- var cutoff = Date.now() - 24 * 60 * 60 * 1000;
1037
1038
  var nextHistoryIds = {};
1038
- getVisibleClaudeHistorySessions().filter(function(session) {
1039
- return !session.timestamp || new Date(session.timestamp).getTime() <= cutoff;
1040
- }).forEach(function(session) {
1039
+ getVisibleClaudeHistorySessions().forEach(function(session) {
1041
1040
  nextHistoryIds[session.claudeSessionId] = true;
1042
1041
  });
1043
1042
  state.selectedSessionIds = nextSessionIds;
@@ -1815,7 +1814,7 @@
1815
1814
  { id: "full-access", label: "全权限", desc: "自动确认权限" },
1816
1815
  { id: "auto-edit", label: "自动编辑", desc: "自动确认修改" },
1817
1816
  { id: "default", label: "标准", desc: "逐步确认操作" },
1818
- { id: "native", label: "原生", desc: "结构化单轮输出" }
1817
+ { id: "native", label: "原生", desc: "原生结构化输出" }
1819
1818
  ];
1820
1819
  return modes.map(function(m) {
1821
1820
  var active = m.id === selectedMode ? " active" : "";
@@ -1828,8 +1827,8 @@
1828
1827
 
1829
1828
  function renderSessionKindOptions(selectedKind) {
1830
1829
  var kinds = [
1831
- { id: "pty", label: "PTY", desc: "交互式终端会话" },
1832
- { id: "structured", label: "Structured", desc: "单轮结构化输出" }
1830
+ { id: "structured", label: "结构化", desc: "智能对话模式" },
1831
+ { id: "pty", label: "PTY", desc: "交互式终端会话" }
1833
1832
  ];
1834
1833
  return kinds.map(function(kind) {
1835
1834
  var active = kind.id === selectedKind ? " active" : "";
@@ -1842,15 +1841,15 @@
1842
1841
 
1843
1842
  function getSessionKindHint(kind) {
1844
1843
  if (kind === "structured") {
1845
- return "直接使用 claude -p 获取结构化单轮结果。";
1844
+ return "结构化聊天界面,支持多轮对话、流式输出和工具调用展示。";
1846
1845
  }
1847
- return "默认 PTY 会话,支持持续交互、终端视图和权限流。";
1846
+ return "原始 PTY 终端会话,支持持续交互、终端视图和权限流。";
1848
1847
  }
1849
1848
 
1850
1849
  function renderSessionModal() {
1851
1850
  var modalTool = getPreferredTool();
1852
1851
  var modalMode = getSafeModeForTool(modalTool, state.modeValue || state.chatMode || "default");
1853
- var sessionKind = state.sessionCreateKind || "pty";
1852
+ var sessionKind = state.sessionCreateKind || "structured";
1854
1853
  return '<section id="session-modal" class="modal-backdrop hidden">' +
1855
1854
  '<div class="modal session-modal">' +
1856
1855
  '<div class="modal-header">' +
@@ -2349,69 +2348,58 @@
2349
2348
  }
2350
2349
  }
2351
2350
 
2352
- // Helper functions for recent paths
2353
- function getRecentPaths() {
2354
- try {
2355
- var saved = localStorage.getItem("wand-recent-paths");
2356
- return saved ? JSON.parse(saved) : [];
2357
- } catch (e) {
2358
- return [];
2359
- }
2351
+ // Helper functions for recent paths (single source: backend API)
2352
+ function fetchRecentPaths(callback) {
2353
+ fetch("/api/recent-paths", { credentials: "same-origin" })
2354
+ .then(function(res) { return res.json(); })
2355
+ .then(function(items) { callback(items || []); })
2356
+ .catch(function() { callback([]); });
2360
2357
  }
2361
2358
 
2362
2359
  function addRecentPath(path) {
2363
- var recent = getRecentPaths();
2364
- // Remove if already exists
2365
- recent = recent.filter(function(p) { return p !== path; });
2366
- // Add to front
2367
- recent.unshift(path);
2368
- // Keep only last 5
2369
- recent = recent.slice(0, 5);
2370
- try {
2371
- localStorage.setItem("wand-recent-paths", JSON.stringify(recent));
2372
- } catch (e) {
2373
- // Ignore localStorage errors
2374
- }
2360
+ return fetch("/api/recent-paths", {
2361
+ method: "POST",
2362
+ headers: { "Content-Type": "application/json" },
2363
+ credentials: "same-origin",
2364
+ body: JSON.stringify({ path: path })
2365
+ }).catch(function() {});
2375
2366
  }
2376
2367
 
2377
- function renderRecentPaths() {
2378
- var recent = getRecentPaths();
2379
- if (recent.length === 0) return "";
2380
-
2368
+ function renderRecentPathsHtml(items) {
2369
+ if (!items.length) return "";
2381
2370
  var html = '<div class="folder-recent-section">' +
2382
2371
  '<div class="folder-recent-title">最近使用</div>';
2383
-
2384
- recent.forEach(function(path) {
2385
- html += '<div class="folder-recent-item" data-path="' + escapeHtml(path) + '">' +
2386
- '<span class="folder-recent-item-icon">📁</span>' +
2387
- '<span class="folder-recent-item-path">' + escapeHtml(path) + '</span>' +
2372
+ items.forEach(function(item) {
2373
+ var p = item.path || item;
2374
+ html += '<div class="folder-recent-item" data-path="' + escapeHtml(p) + '">' +
2375
+ '<span class="folder-recent-item-path">' + escapeHtml(p) + '</span>' +
2388
2376
  '</div>';
2389
2377
  });
2390
-
2391
2378
  html += '</div>';
2392
2379
  return html;
2393
2380
  }
2394
2381
 
2395
2382
  function showRecentPathsDropdown() {
2396
2383
  if (!folderPickerDropdown) return;
2397
- var recentHtml = renderRecentPaths();
2398
- if (recentHtml) {
2399
- folderPickerDropdown.innerHTML = recentHtml;
2400
- folderPickerDropdown.classList.remove("hidden");
2401
- // Add click handlers for recent paths
2402
- folderPickerDropdown.querySelectorAll(".folder-recent-item").forEach(function(item) {
2403
- item.addEventListener("click", function() {
2404
- var path = this.dataset.path;
2405
- if (folderPickerInput) {
2406
- folderPickerInput.value = path;
2407
- saveWorkingDir(path);
2408
- loadFolderSuggestions(path);
2409
- }
2384
+ fetchRecentPaths(function(items) {
2385
+ var recentHtml = renderRecentPathsHtml(items);
2386
+ if (recentHtml) {
2387
+ folderPickerDropdown.innerHTML = recentHtml;
2388
+ folderPickerDropdown.classList.remove("hidden");
2389
+ folderPickerDropdown.querySelectorAll(".folder-recent-item").forEach(function(item) {
2390
+ item.addEventListener("click", function() {
2391
+ var path = this.dataset.path;
2392
+ if (folderPickerInput) {
2393
+ folderPickerInput.value = path;
2394
+ saveWorkingDir(path);
2395
+ loadFolderSuggestions(path);
2396
+ }
2397
+ });
2410
2398
  });
2411
- });
2412
- } else {
2413
- hideFolderDropdown();
2414
- }
2399
+ } else {
2400
+ hideFolderDropdown();
2401
+ }
2402
+ });
2415
2403
  }
2416
2404
 
2417
2405
  // Working directory indicator click handler for active sessions
@@ -2472,7 +2460,6 @@
2472
2460
  var path = entry.fullPath;
2473
2461
  folderPickerInput.value = path;
2474
2462
  saveWorkingDir(path);
2475
- addRecentPath(path);
2476
2463
  loadFolderSuggestions(path);
2477
2464
  break;
2478
2465
  }
@@ -3462,7 +3449,7 @@
3462
3449
  return "保留交互式会话,同时更偏向直接编辑代码。";
3463
3450
  }
3464
3451
  if (mode === "native") {
3465
- return "按单轮消息调用 Claude 原生输出,适合快速问答或一次性生成。";
3452
+ return "调用 Claude 原生 API 输出,适合快速问答或一次性生成。";
3466
3453
  }
3467
3454
  if (mode === "managed") {
3468
3455
  return "AI 自动完成所有工作,无需中途确认,适合有明确目标的任务。";
@@ -3612,7 +3599,7 @@
3612
3599
  var modeHint = document.getElementById("mode-description");
3613
3600
  var kindHint = document.getElementById("session-kind-description");
3614
3601
  var tool = "claude";
3615
- var sessionKind = state.sessionCreateKind || "pty";
3602
+ var sessionKind = state.sessionCreateKind || "structured";
3616
3603
 
3617
3604
  state.sessionTool = tool;
3618
3605
  state.modeValue = getSafeModeForTool(tool, state.modeValue || state.chatMode || "default");
@@ -4007,7 +3994,7 @@
4007
3994
  modal.classList.remove("hidden");
4008
3995
  lastFocusedElement = document.activeElement;
4009
3996
  state.sessionTool = getPreferredTool();
4010
- state.sessionCreateKind = "pty";
3997
+ state.sessionCreateKind = "structured";
4011
3998
  state.modeValue = getSafeModeForTool(state.sessionTool, state.modeValue || state.chatMode);
4012
3999
  syncSessionModalUI();
4013
4000
  loadRecentPathBubbles();
@@ -4535,7 +4522,7 @@
4535
4522
  var cwdEl = document.getElementById("cwd");
4536
4523
  var errorEl = document.getElementById("modal-error");
4537
4524
  var command = getPreferredTool();
4538
- var sessionKind = state.sessionCreateKind || "pty";
4525
+ var sessionKind = state.sessionCreateKind || "structured";
4539
4526
 
4540
4527
  hideError(errorEl);
4541
4528
 
@@ -4560,6 +4547,7 @@
4560
4547
  syncComposerModeSelect();
4561
4548
  return createStructuredSession(undefined, cwd, mode)
4562
4549
  .then(function(data) {
4550
+ saveWorkingDir(cwd);
4563
4551
  closeSessionModal();
4564
4552
  closeSessionsDrawer();
4565
4553
  return data;
@@ -4597,6 +4585,7 @@
4597
4585
  state.selectedId = data.id;
4598
4586
  console.log("[WAND] runPtyCommandFromModal created session:", data.id, "sessionKind:", data.sessionKind, "runner:", data.runner);
4599
4587
  persistSelectedId();
4588
+ saveWorkingDir(cwd);
4600
4589
  state.drafts[data.id] = "";
4601
4590
  resetChatRenderCache();
4602
4591
  closeSessionModal();
@@ -4655,18 +4644,14 @@
4655
4644
  function loadBlankChatCwdDropdown(dropdown) {
4656
4645
  var defaultCwd = getConfigCwd();
4657
4646
  dropdown.innerHTML = '<div class="blank-chat-cwd-loading">加载中...</div>';
4658
- fetch("/api/recent-paths", { credentials: "same-origin" })
4659
- .then(function(res) { return res.json(); })
4660
- .then(function(items) {
4647
+ fetchRecentPaths(function(items) {
4661
4648
  var html = "";
4662
- // Default directory always first
4663
4649
  var currentDir = state.workingDir || defaultCwd;
4664
4650
  html += '<div class="blank-chat-cwd-item' + (currentDir === defaultCwd ? " active" : "") + '" data-path="' + escapeHtml(defaultCwd) + '">' +
4665
4651
  '<span class="blank-chat-cwd-item-label">默认</span>' +
4666
4652
  '<span class="blank-chat-cwd-item-path">' + escapeHtml(defaultCwd) + '</span>' +
4667
4653
  '</div>';
4668
- // Recent paths (exclude default to avoid duplicate)
4669
- if (items && items.length) {
4654
+ if (items.length) {
4670
4655
  var seen = {};
4671
4656
  seen[defaultCwd] = true;
4672
4657
  items.forEach(function(item) {
@@ -4689,26 +4674,18 @@
4689
4674
  dropdown.classList.add("hidden");
4690
4675
  var arrow = document.getElementById("blank-chat-cwd-arrow");
4691
4676
  if (arrow) arrow.textContent = "▼";
4692
- // Update folder picker input if exists
4693
4677
  var fpInput = document.getElementById("folder-picker-input");
4694
4678
  if (fpInput) fpInput.value = path;
4695
4679
  });
4696
4680
  });
4697
- })
4698
- .catch(function() {
4699
- dropdown.innerHTML = '<div class="blank-chat-cwd-item" data-path="' + escapeHtml(defaultCwd) + '">' +
4700
- '<span class="blank-chat-cwd-item-path">' + escapeHtml(defaultCwd) + '</span>' +
4701
- '</div>';
4702
- });
4681
+ });
4703
4682
  }
4704
4683
 
4705
4684
  function loadRecentPathBubbles() {
4706
4685
  var container = document.getElementById("recent-paths-bubbles");
4707
4686
  if (!container) return;
4708
- fetch("/api/recent-paths", { credentials: "same-origin" })
4709
- .then(function(res) { return res.json(); })
4710
- .then(function(items) {
4711
- if (!items || !items.length) {
4687
+ fetchRecentPaths(function(items) {
4688
+ if (!items.length) {
4712
4689
  container.innerHTML = "";
4713
4690
  return;
4714
4691
  }
@@ -4726,10 +4703,7 @@
4726
4703
  }
4727
4704
  });
4728
4705
  });
4729
- })
4730
- .catch(function() {
4731
- if (container) container.innerHTML = "";
4732
- });
4706
+ });
4733
4707
  }
4734
4708
 
4735
4709
  function schedulePathSuggestions() {
@@ -8040,12 +8014,12 @@
8040
8014
  function fullRenderChat() {
8041
8015
  // Extract system info from PTY output
8042
8016
  var systemInfo = extractPtySystemInfo(selectedSession.output, messages);
8043
-
8017
+
8044
8018
  // Build HTML with system info cards interleaved
8045
8019
  var html = '';
8046
8020
  var reversedMessages = messages.slice().reverse();
8047
8021
  var msgCount = messages.length;
8048
-
8022
+
8049
8023
  for (var i = 0; i < reversedMessages.length; i++) {
8050
8024
  var msg = reversedMessages[i];
8051
8025
  var originalIndex = msgCount - 1 - i; // Original index in messages array
@@ -8070,7 +8044,7 @@
8070
8044
  }
8071
8045
 
8072
8046
  // Render message
8073
- html += renderChatMessage(msg);
8047
+ html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null);
8074
8048
  }
8075
8049
 
8076
8050
  chatMessages.innerHTML = html;
@@ -8107,6 +8081,47 @@
8107
8081
  });
8108
8082
  }
8109
8083
 
8084
+ // Pre-compute per-round cumulative usage.
8085
+ // A "round" starts at a user message and includes all subsequent assistant turns
8086
+ // until the next user message. Only the last assistant in each round shows the total.
8087
+ var roundUsageByIndex = {};
8088
+ (function() {
8089
+ var acc = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, totalCostUsd: 0 };
8090
+ var lastAssistantIdx = -1;
8091
+ for (var mi = 0; mi < messages.length; mi++) {
8092
+ var m = messages[mi];
8093
+ if (m.role === "user") {
8094
+ if (lastAssistantIdx >= 0 && (acc.inputTokens > 0 || acc.outputTokens > 0 || acc.totalCostUsd > 0)) {
8095
+ roundUsageByIndex[lastAssistantIdx] = {
8096
+ inputTokens: acc.inputTokens,
8097
+ outputTokens: acc.outputTokens,
8098
+ cacheReadInputTokens: acc.cacheReadInputTokens,
8099
+ totalCostUsd: acc.totalCostUsd
8100
+ };
8101
+ }
8102
+ acc = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, totalCostUsd: 0 };
8103
+ lastAssistantIdx = -1;
8104
+ } else if (m.role === "assistant" && m.usage) {
8105
+ var u = m.usage;
8106
+ acc.inputTokens += (u.inputTokens || 0);
8107
+ acc.outputTokens += (u.outputTokens || 0);
8108
+ acc.cacheReadInputTokens += (u.cacheReadInputTokens || 0);
8109
+ acc.totalCostUsd += (u.totalCostUsd || 0);
8110
+ lastAssistantIdx = mi;
8111
+ } else if (m.role === "assistant") {
8112
+ lastAssistantIdx = mi;
8113
+ }
8114
+ }
8115
+ if (lastAssistantIdx >= 0 && (acc.inputTokens > 0 || acc.outputTokens > 0 || acc.totalCostUsd > 0)) {
8116
+ roundUsageByIndex[lastAssistantIdx] = {
8117
+ inputTokens: acc.inputTokens,
8118
+ outputTokens: acc.outputTokens,
8119
+ cacheReadInputTokens: acc.cacheReadInputTokens,
8120
+ totalCostUsd: acc.totalCostUsd
8121
+ };
8122
+ }
8123
+ })();
8124
+
8110
8125
  if (needsFullRender) {
8111
8126
  fullRenderChat();
8112
8127
  } else if (msgCount > existingCount) {
@@ -8118,7 +8133,8 @@
8118
8133
  var insertedEls = [];
8119
8134
  for (var i = 0; i < newMessages.length; i++) {
8120
8135
  var div = document.createElement("div");
8121
- div.innerHTML = renderChatMessage(newMessages[i]);
8136
+ var nmOrigIdx = existingCount + (newMessages.length - 1 - i);
8137
+ div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null);
8122
8138
  var el = div.firstElementChild;
8123
8139
  if (el) {
8124
8140
  el.classList.add("animate-in");
@@ -8143,7 +8159,8 @@
8143
8159
  for (var mi = 0; mi < reversedMessages.length && mi < existingEls.length; mi++) {
8144
8160
  var currentEl = existingEls[mi];
8145
8161
  var tmpWrap = document.createElement("div");
8146
- tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi]);
8162
+ var srOrigIdx = reversedMessages.length - 1 - mi;
8163
+ tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null);
8147
8164
  var replacementEl = tmpWrap.firstElementChild;
8148
8165
  if (!replacementEl) continue;
8149
8166
  if (currentEl.innerHTML !== replacementEl.innerHTML || currentEl.className !== replacementEl.className) {
@@ -8859,7 +8876,7 @@
8859
8876
  return messages;
8860
8877
  }
8861
8878
 
8862
- function renderChatMessage(msg) {
8879
+ function renderChatMessage(msg, roundUsage) {
8863
8880
  // Thinking card (deep thought) — from PTY parsing
8864
8881
  if (msg.role === "thinking") {
8865
8882
  return '<div class="chat-message thinking">' +
@@ -8883,7 +8900,7 @@
8883
8900
 
8884
8901
  // Structured content blocks (from JSON chat mode)
8885
8902
  if (Array.isArray(msg.content)) {
8886
- return renderStructuredMessage(msg);
8903
+ return renderStructuredMessage(msg, roundUsage);
8887
8904
  }
8888
8905
 
8889
8906
  // Legacy string content (from PTY parsing)
@@ -8938,7 +8955,7 @@
8938
8955
  return '<div class="structured-tool-hint">已自动恢复一次 ' + escapeHtml(getToolDisplayName(toolName)) + ' 参数问题</div>';
8939
8956
  }
8940
8957
 
8941
- function renderStructuredMessage(msg) {
8958
+ function renderStructuredMessage(msg, roundUsage) {
8942
8959
  var role = msg.role;
8943
8960
  var avatar = role === "assistant" ? '<div class="chat-message-avatar">赛博虎妞</div>' : "";
8944
8961
 
@@ -8972,13 +8989,13 @@
8972
8989
  }
8973
8990
 
8974
8991
  var usageHtml = "";
8975
- if (role === "assistant" && msg.usage) {
8976
- var u = msg.usage;
8992
+ if (role === "assistant" && roundUsage) {
8993
+ var u = roundUsage;
8977
8994
  var parts = [];
8978
- if (u.inputTokens !== undefined) parts.push("输入 " + u.inputTokens);
8979
- if (u.outputTokens !== undefined) parts.push("输出 " + u.outputTokens);
8980
- if (u.cacheReadInputTokens !== undefined && u.cacheReadInputTokens > 0) parts.push("缓存 " + u.cacheReadInputTokens);
8981
- if (u.totalCostUsd !== undefined) parts.push("$" + u.totalCostUsd.toFixed(4));
8995
+ if (u.inputTokens > 0) parts.push("输入 " + u.inputTokens);
8996
+ if (u.outputTokens > 0) parts.push("输出 " + u.outputTokens);
8997
+ if (u.cacheReadInputTokens > 0) parts.push("缓存 " + u.cacheReadInputTokens);
8998
+ if (u.totalCostUsd > 0) parts.push("$" + u.totalCostUsd.toFixed(4));
8982
8999
  if (parts.length > 0) {
8983
9000
  usageHtml = '<div class="message-usage">' + parts.join(" · ") + '</div>';
8984
9001
  }
@@ -7525,29 +7525,29 @@
7525
7525
  .structured-status-bar {
7526
7526
  display: flex;
7527
7527
  align-items: center;
7528
- gap: 10px;
7529
- margin: 8px 0 4px;
7530
- padding: 8px 12px;
7528
+ gap: 8px;
7529
+ margin: 0;
7530
+ padding: 4px 10px;
7531
7531
  border-radius: var(--radius-sm);
7532
- background: rgba(var(--accent-rgb, 99, 102, 241), 0.06);
7533
- border: 1px solid rgba(var(--accent-rgb, 99, 102, 241), 0.12);
7534
- font-size: 0.75rem;
7535
- color: var(--text-secondary);
7532
+ background: transparent;
7533
+ border: none;
7534
+ font-size: 0.6875rem;
7535
+ color: var(--text-muted);
7536
7536
  transition: all 0.3s ease;
7537
7537
  overflow: hidden;
7538
7538
  }
7539
7539
 
7540
7540
  .structured-status-bar .status-bar-label {
7541
7541
  flex-shrink: 0;
7542
- font-weight: 600;
7543
- color: var(--accent-soft);
7542
+ font-weight: 500;
7543
+ color: var(--text-muted);
7544
7544
  }
7545
7545
 
7546
7546
  .structured-status-bar .status-bar-track {
7547
7547
  flex: 1;
7548
- height: 3px;
7549
- border-radius: 2px;
7550
- background: rgba(var(--accent-rgb, 99, 102, 241), 0.1);
7548
+ height: 2px;
7549
+ border-radius: 1px;
7550
+ background: rgba(var(--accent-rgb, 99, 102, 241), 0.08);
7551
7551
  overflow: hidden;
7552
7552
  position: relative;
7553
7553
  }
@@ -7556,9 +7556,9 @@
7556
7556
  position: absolute;
7557
7557
  top: 0;
7558
7558
  left: 0;
7559
- width: 40%;
7559
+ width: 30%;
7560
7560
  height: 100%;
7561
- border-radius: 2px;
7561
+ border-radius: 1px;
7562
7562
  background: linear-gradient(90deg, transparent, var(--accent-soft), transparent);
7563
7563
  animation: marqueeScroll 1.5s ease-in-out infinite;
7564
7564
  }
@@ -7572,14 +7572,14 @@
7572
7572
  flex-shrink: 0;
7573
7573
  font-variant-numeric: tabular-nums;
7574
7574
  font-family: var(--font-mono);
7575
- font-size: 0.6875rem;
7575
+ font-size: 0.625rem;
7576
7576
  color: var(--text-muted);
7577
7577
  }
7578
7578
 
7579
7579
  /* 完成态 */
7580
7580
  .structured-status-bar.completed {
7581
- background: rgba(79, 122, 88, 0.06);
7582
- border-color: rgba(79, 122, 88, 0.15);
7581
+ background: transparent;
7582
+ border-color: transparent;
7583
7583
  animation: statusBarFadeOut 2s ease-out 1s forwards;
7584
7584
  }
7585
7585
 
@@ -7600,9 +7600,9 @@
7600
7600
  }
7601
7601
 
7602
7602
  @keyframes statusBarFadeOut {
7603
- 0% { opacity: 1; max-height: 50px; margin: 8px 0 4px; padding: 8px 12px; }
7604
- 70% { opacity: 0; max-height: 50px; margin: 8px 0 4px; padding: 8px 12px; }
7605
- 100% { opacity: 0; max-height: 0; margin: 0; padding: 0 12px; border-width: 0; }
7603
+ 0% { opacity: 1; max-height: 30px; margin: 0; padding: 4px 10px; }
7604
+ 70% { opacity: 0; max-height: 30px; margin: 0; padding: 4px 10px; }
7605
+ 100% { opacity: 0; max-height: 0; margin: 0; padding: 0 10px; border-width: 0; }
7606
7606
  }
7607
7607
 
7608
7608
  /* 结束标记 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.5.1",
3
+ "version": "1.5.4",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {