@co0ontty/wand 1.6.0 → 1.6.2

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
@@ -41,15 +41,16 @@ wand config:set port 9443
41
41
  | `port` | `8443` | 监听端口 |
42
42
  | `https` | `false` | 启用 HTTPS(自签证书自动生成) |
43
43
  | `password` | (随机生成) | 登录密码 |
44
- | `replyLanguage` | `""` | Claude 回复语言偏好 |
44
+ | `language` | `""` | Claude 回复语言偏好 |
45
45
 
46
46
  ## 功能
47
47
 
48
48
  - **双视图模式** — 终端原始输出和结构化对话视图可随时切换
49
- - **会话管理** — 创建、归档、恢复会话;支持从 Claude 原生历史记录恢复
50
- - **权限控制** — 可视化权限提示,支持逐次确认、单次批准、本轮记忆等策略
51
- - **文件浏览器** — 内置路径浏览、搜索和收藏功能
49
+ - **会话管理** — 创建、归档、恢复会话;支持从 Claude 原生历史记录恢复;会话列表显示摘要
50
+ - **权限控制** — 可视化权限提示,支持逐次确认、单次批准、本轮记忆等策略;工具调用自动分组
51
+ - **文件浏览器** — 内置路径浏览和搜索功能
52
52
  - **多种运行模式** — full-access / default / auto-edit 等 Claude 运行模式
53
+ - **个性化** — 像素风猫咪头像、回复语言偏好设置
53
54
  - **PWA 支持** — 可添加到主屏幕作为独立应用使用
54
55
  - **HTTPS** — 可选自签证书,适合远程或移动端访问
55
56
 
package/dist/config.js CHANGED
@@ -79,6 +79,28 @@ export async function saveConfig(configPath, config) {
79
79
  await mkdir(path.dirname(configPath), { recursive: true });
80
80
  await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
81
81
  }
82
+ function normalizeStructuredChatPersona(input) {
83
+ if (!input || typeof input !== "object")
84
+ return undefined;
85
+ const normalizeRole = (roleInput) => {
86
+ if (!roleInput || typeof roleInput !== "object")
87
+ return undefined;
88
+ const role = roleInput;
89
+ const normalized = {
90
+ name: typeof role.name === "string" ? role.name.trim() : undefined,
91
+ avatar: typeof role.avatar === "string" ? role.avatar.trim() : undefined,
92
+ };
93
+ if (!normalized.name && !normalized.avatar)
94
+ return undefined;
95
+ return normalized;
96
+ };
97
+ const personaInput = input;
98
+ const user = normalizeRole(personaInput.user);
99
+ const assistant = normalizeRole(personaInput.assistant);
100
+ if (!user && !assistant)
101
+ return undefined;
102
+ return { user, assistant };
103
+ }
82
104
  function mergeWithDefaults(input) {
83
105
  const defaults = defaultConfig();
84
106
  return {
@@ -108,6 +130,7 @@ function mergeWithDefaults(input) {
108
130
  mode: isExecutionMode(preset.mode) ? preset.mode : undefined
109
131
  }))
110
132
  : defaults.commandPresets,
133
+ structuredChatPersona: normalizeStructuredChatPersona(input.structuredChatPersona),
111
134
  language: typeof input.language === "string" ? input.language.trim() : defaults.language,
112
135
  };
113
136
  }
package/dist/server.js CHANGED
@@ -8,6 +8,19 @@ import { promisify } from "node:util";
8
8
  import path from "node:path";
9
9
  import process from "node:process";
10
10
  import { WebSocketServer } from "ws";
11
+ import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
12
+ import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
13
+ import { ensureCertificates } from "./cert.js";
14
+ import { isExecutionMode, resolveConfigDir, saveConfig } from "./config.js";
15
+ import { ProcessManager } from "./process-manager.js";
16
+ import { StructuredSessionManager } from "./structured-session-manager.js";
17
+ import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
18
+ import { getErrorMessage, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
19
+ import { resolveDatabasePath, WandStorage } from "./storage.js";
20
+ import { renderApp } from "./web-ui/index.js";
21
+ import { WsBroadcastManager } from "./ws-broadcast.js";
22
+ import { checkRateLimit, recordFailedLogin, resetRateLimit } from "./middleware/rate-limit.js";
23
+ import { isPathWithinBase, isBlockedFolderPath, normalizeFolderPath } from "./middleware/path-safety.js";
11
24
  const execAsync = promisify(exec);
12
25
  const SERVER_MODULE_DIR = path.dirname(new URL(import.meta.url).pathname);
13
26
  const RUNTIME_ROOT_DIR = path.resolve(SERVER_MODULE_DIR, "..");
@@ -52,19 +65,83 @@ function compareSemver(a, b) {
52
65
  }
53
66
  return 0;
54
67
  }
55
- import { ensureAvatarSeed, getAvatarSvg } from "./avatar.js";
56
- import { createSession, revokeSession, setAuthStorage, validateSession } from "./auth.js";
57
- import { ensureCertificates } from "./cert.js";
58
- import { isExecutionMode, resolveConfigDir, saveConfig } from "./config.js";
59
- import { ProcessManager } from "./process-manager.js";
60
- import { StructuredSessionManager } from "./structured-session-manager.js";
61
- import { generatePwaManifest, generateServiceWorker } from "./pwa.js";
62
- import { getErrorMessage, registerClaudeHistoryRoutes, registerSessionRoutes } from "./server-session-routes.js";
63
- import { resolveDatabasePath, WandStorage } from "./storage.js";
64
- import { renderApp } from "./web-ui/index.js";
65
- import { WsBroadcastManager } from "./ws-broadcast.js";
66
- import { checkRateLimit, recordFailedLogin, resetRateLimit } from "./middleware/rate-limit.js";
67
- import { isPathWithinBase, isBlockedFolderPath, normalizeFolderPath } from "./middleware/path-safety.js";
68
+ function isExternalAvatarSource(value) {
69
+ return /^(https?:|data:)/i.test(value);
70
+ }
71
+ function normalizePersonaName(value) {
72
+ if (typeof value !== "string")
73
+ return undefined;
74
+ const trimmed = value.trim();
75
+ return trimmed || undefined;
76
+ }
77
+ function normalizePersonaAvatar(value) {
78
+ if (typeof value !== "string")
79
+ return undefined;
80
+ const trimmed = value.trim();
81
+ return trimmed || undefined;
82
+ }
83
+ function resolveStructuredChatPersona(config) {
84
+ const persona = config.structuredChatPersona;
85
+ if (!persona)
86
+ return undefined;
87
+ const userName = normalizePersonaName(persona.user?.name);
88
+ const userAvatar = normalizePersonaAvatar(persona.user?.avatar);
89
+ const assistantName = normalizePersonaName(persona.assistant?.name);
90
+ const assistantAvatar = normalizePersonaAvatar(persona.assistant?.avatar);
91
+ if (!userName && !userAvatar && !assistantName && !assistantAvatar) {
92
+ return undefined;
93
+ }
94
+ return {
95
+ user: userName || userAvatar ? { name: userName, avatar: userAvatar } : undefined,
96
+ assistant: assistantName || assistantAvatar ? { name: assistantName, avatar: assistantAvatar } : undefined,
97
+ };
98
+ }
99
+ function resolveStructuredChatAvatarPath(configPath, config, role) {
100
+ const avatar = role === "user"
101
+ ? config.structuredChatPersona?.user?.avatar
102
+ : config.structuredChatPersona?.assistant?.avatar;
103
+ if (!avatar || isExternalAvatarSource(avatar)) {
104
+ return null;
105
+ }
106
+ const configDir = resolveConfigDir(configPath);
107
+ return path.isAbsolute(avatar) ? avatar : path.resolve(configDir, avatar);
108
+ }
109
+ async function buildStructuredChatPersonaPayload(configPath, config) {
110
+ const persona = resolveStructuredChatPersona(config);
111
+ if (!persona)
112
+ return undefined;
113
+ const buildRole = async (role) => {
114
+ const roleConfig = role === "user" ? persona.user : persona.assistant;
115
+ if (!roleConfig)
116
+ return undefined;
117
+ let avatar = roleConfig.avatar;
118
+ if (avatar && !isExternalAvatarSource(avatar)) {
119
+ const resolvedPath = resolveStructuredChatAvatarPath(configPath, config, role);
120
+ if (!resolvedPath) {
121
+ avatar = undefined;
122
+ }
123
+ else {
124
+ try {
125
+ const fileStat = await stat(resolvedPath);
126
+ avatar = fileStat.isFile() ? `/api/structured-chat-avatar/${role}` : undefined;
127
+ }
128
+ catch {
129
+ avatar = undefined;
130
+ }
131
+ }
132
+ }
133
+ if (!roleConfig.name && !avatar)
134
+ return undefined;
135
+ return {
136
+ name: roleConfig.name,
137
+ avatar,
138
+ };
139
+ };
140
+ const [user, assistant] = await Promise.all([buildRole("user"), buildRole("assistant")]);
141
+ if (!user && !assistant)
142
+ return undefined;
143
+ return { user, assistant };
144
+ }
68
145
  // ── Git helpers ──
69
146
  async function isGitRepo(dirPath) {
70
147
  try {
@@ -266,6 +343,50 @@ export async function startServer(config, configPath) {
266
343
  res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
267
344
  res.type("html").send(renderApp(configPath));
268
345
  });
346
+ app.get("/api/structured-chat-avatar/:role", async (req, res) => {
347
+ const role = req.params.role === "user" || req.params.role === "assistant"
348
+ ? req.params.role
349
+ : null;
350
+ if (!role) {
351
+ res.status(404).end();
352
+ return;
353
+ }
354
+ const resolvedPath = resolveStructuredChatAvatarPath(configPath, config, role);
355
+ if (!resolvedPath) {
356
+ res.status(404).end();
357
+ return;
358
+ }
359
+ try {
360
+ const fileStat = await stat(resolvedPath);
361
+ if (!fileStat.isFile()) {
362
+ res.status(404).end();
363
+ return;
364
+ }
365
+ const ext = path.extname(resolvedPath).toLowerCase();
366
+ const contentType = ext === ".svg"
367
+ ? "image/svg+xml"
368
+ : ext === ".png"
369
+ ? "image/png"
370
+ : ext === ".jpg" || ext === ".jpeg"
371
+ ? "image/jpeg"
372
+ : ext === ".webp"
373
+ ? "image/webp"
374
+ : ext === ".gif"
375
+ ? "image/gif"
376
+ : ext === ".avif"
377
+ ? "image/avif"
378
+ : null;
379
+ if (!contentType) {
380
+ res.status(415).json({ error: "不支持的头像格式。" });
381
+ return;
382
+ }
383
+ res.setHeader("X-Content-Type-Options", "nosniff");
384
+ res.type(contentType).sendFile(resolvedPath);
385
+ }
386
+ catch {
387
+ res.status(404).end();
388
+ }
389
+ });
269
390
  app.get("/manifest.json", (_req, res) => {
270
391
  res.setHeader("Content-Type", "application/manifest+json");
271
392
  res.send(generatePwaManifest());
@@ -328,7 +449,8 @@ export async function startServer(config, configPath) {
328
449
  });
329
450
  app.use("/api", requireAuth);
330
451
  // ── Config & Session info ──
331
- app.get("/api/config", (_req, res) => {
452
+ app.get("/api/config", async (_req, res) => {
453
+ const structuredChatPersona = await buildStructuredChatPersonaPayload(configPath, config);
332
454
  res.json({
333
455
  host: config.host,
334
456
  port: config.port,
@@ -336,6 +458,7 @@ export async function startServer(config, configPath) {
336
458
  defaultCwd: config.defaultCwd,
337
459
  commandPresets: config.commandPresets,
338
460
  structuredRunners: [{ label: "Claude Structured", runner: "claude-cli-print" }],
461
+ structuredChatPersona,
339
462
  updateAvailable: cachedUpdateInfo?.updateAvailable ?? false,
340
463
  latestVersion: cachedUpdateInfo?.latest ?? null,
341
464
  currentVersion: PKG_VERSION,
@@ -345,19 +345,21 @@ export class StructuredSessionManager {
345
345
  // ---------------------------------------------------------------------------
346
346
  // CLI argument construction
347
347
  // ---------------------------------------------------------------------------
348
- buildPermissionArgs(mode) {
348
+ buildPermissionArgs(mode, autoApprove) {
349
+ const shouldBypass = autoApprove || mode === "full-access" || mode === "managed";
350
+ const shouldAcceptEdits = mode === "auto-edit";
349
351
  if (!isRunningAsRoot()) {
350
- if (mode === "full-access" || mode === "managed") {
352
+ if (shouldBypass) {
351
353
  return ["--permission-mode", "bypassPermissions"];
352
354
  }
353
- if (mode === "auto-edit") {
355
+ if (shouldAcceptEdits) {
354
356
  return ["--permission-mode", "acceptEdits"];
355
357
  }
356
358
  return [];
357
359
  }
358
360
  // Root: Claude CLI refuses bypassPermissions.
359
361
  // acceptEdits auto-approves within CWD; --allowedTools extends to all paths.
360
- if (shouldAutoApproveForMode(mode)) {
362
+ if (shouldBypass || shouldAcceptEdits) {
361
363
  return [
362
364
  "--permission-mode", "acceptEdits",
363
365
  "--allowedTools", "Bash", "Edit", "Write", "Read", "Glob", "Grep",
@@ -384,8 +386,8 @@ export class StructuredSessionManager {
384
386
  return new Promise((resolve, reject) => {
385
387
  const args = ["-p", "--verbose", "--output-format", "stream-json"];
386
388
  console.log("[WAND] runClaudeStreaming sessionId:", sessionId, "mode:", session.mode, "claudeSessionId:", session.claudeSessionId);
387
- // Add permission args based on mode
388
- const permArgs = this.buildPermissionArgs(session.mode);
389
+ // Add permission args based on mode + autoApprovePermissions toggle
390
+ const permArgs = this.buildPermissionArgs(session.mode, session.autoApprovePermissions ?? false);
389
391
  args.push(...permArgs);
390
392
  // In managed mode, append autonomous system prompt
391
393
  if (session.mode === "managed") {
package/dist/types.d.ts CHANGED
@@ -38,6 +38,14 @@ export interface CommandPreset {
38
38
  command: string;
39
39
  mode?: ExecutionMode;
40
40
  }
41
+ export interface StructuredChatPersonaRoleConfig {
42
+ name?: string;
43
+ avatar?: string;
44
+ }
45
+ export interface StructuredChatPersonaConfig {
46
+ user?: StructuredChatPersonaRoleConfig;
47
+ assistant?: StructuredChatPersonaRoleConfig;
48
+ }
41
49
  export interface WandConfig {
42
50
  host: string;
43
51
  port: number;
@@ -50,6 +58,7 @@ export interface WandConfig {
50
58
  startupCommands: string[];
51
59
  allowedCommandPrefixes: string[];
52
60
  commandPresets: CommandPreset[];
61
+ structuredChatPersona?: StructuredChatPersonaConfig;
53
62
  /** Max total size (bytes) for shortcut interaction logs per session (default: 10 MB). Set 0 to disable logging. */
54
63
  shortcutLogMaxBytes?: number;
55
64
  /** Preferred response language for Claude (e.g. "中文", "English"). Empty string means no override. */
@@ -90,6 +90,8 @@
90
90
  inputQueue: Promise.resolve(),
91
91
  pendingMessages: [], // WebSocket 断线期间的消息队列
92
92
  messageQueue: [], // 用户消息排队等待发送
93
+ crossSessionQueue: [], // 跨会话排队消息 [{ id, text, cwd, mode, tool }]
94
+ structuredInputQueue: [], // 结构化会话同会话排队消息
93
95
  drafts: {},
94
96
  isSyncingInputBox: false,
95
97
  loginPending: false,
@@ -1996,23 +1998,23 @@
1996
1998
  var optionLabel = btnEl.dataset.optionLabel;
1997
1999
  if (optionLabel && state.selectedId) {
1998
2000
  btnEl.classList.add("selected");
1999
- var allOptions = document.querySelectorAll(".ask-user-option");
2000
- allOptions.forEach(function(opt) {
2001
- opt.classList.add("selected");
2002
- opt.style.pointerEvents = "none";
2003
- });
2004
- var cardBody = btnEl.closest(".tool-use-card.ask-user");
2005
- if (cardBody) {
2001
+ // Only disable options within the same question group, not globally
2002
+ var questionGroup = btnEl.closest(".ask-user-question-group");
2003
+ if (questionGroup) {
2004
+ questionGroup.querySelectorAll(".ask-user-option").forEach(function(opt) {
2005
+ opt.classList.add("selected");
2006
+ opt.style.pointerEvents = "none";
2007
+ });
2006
2008
  var sentDiv = document.createElement("div");
2007
2009
  sentDiv.className = "ask-user-answer-sent";
2008
- sentDiv.innerHTML = "\\u2713 \\u5df2\\u53d1\\u9001: " + escapeHtml(optionLabel);
2009
- cardBody.appendChild(sentDiv);
2010
+ sentDiv.innerHTML = "\u2713 \u5df2\u53d1\u9001: " + escapeHtml(optionLabel);
2011
+ questionGroup.appendChild(sentDiv);
2010
2012
  }
2011
2013
  fetch("/api/sessions/" + state.selectedId + "/input", {
2012
2014
  method: "POST",
2013
2015
  headers: { "Content-Type": "application/json" },
2014
2016
  credentials: "same-origin",
2015
- body: JSON.stringify({ input: optionLabel + "\\n", view: state.currentView })
2017
+ body: JSON.stringify({ input: optionLabel + "\n", view: state.currentView })
2016
2018
  }).catch(function(err) {
2017
2019
  console.error("[wand] Error sending answer:", err);
2018
2020
  });
@@ -3680,6 +3682,11 @@
3680
3682
  reconcileInteractiveState();
3681
3683
  updateTaskDisplay();
3682
3684
  }
3685
+ // When a session transitions to a non-running state, try flushing cross-session queue
3686
+ if (snapshot.status && snapshot.status !== "running" && state.crossSessionQueue.length > 0) {
3687
+ // Use setTimeout(0) to let the current event processing complete first
3688
+ setTimeout(flushCrossSessionQueue, 0);
3689
+ }
3683
3690
  }
3684
3691
 
3685
3692
  function subscribeToSession(sessionId) {
@@ -3819,6 +3826,11 @@
3819
3826
  loadOutput(state.selectedId);
3820
3827
  }
3821
3828
  }
3829
+
3830
+ // Try to flush cross-session queue on every session list refresh
3831
+ if (state.crossSessionQueue.length > 0) {
3832
+ flushCrossSessionQueue();
3833
+ }
3822
3834
  })
3823
3835
  .catch(function(e) {
3824
3836
  console.error("[wand] loadSessions failed:", e);
@@ -3831,6 +3843,8 @@
3831
3843
  if (listEl) listEl.innerHTML = renderSessions();
3832
3844
  if (countEl) countEl.textContent = String(state.sessions.length);
3833
3845
  updateShellChrome();
3846
+ // Re-render cross-session queue (container may have been destroyed by DOM rebuild)
3847
+ if (state.crossSessionQueue.length > 0) renderCrossSessionQueue();
3834
3848
  }
3835
3849
 
3836
3850
  function updateShellChrome() {
@@ -3939,6 +3953,9 @@
3939
3953
 
3940
3954
  var selectedSession = state.sessions.find(function(s) { return s.id === id; });
3941
3955
  state.currentMessages = getPreferredMessages(selectedSession, data.output, false);
3956
+ if (selectedSession && selectedSession.sessionKind === "structured") {
3957
+ appendQueuedPlaceholders(state.currentMessages);
3958
+ }
3942
3959
 
3943
3960
  if (state.terminal) {
3944
3961
  syncTerminalBuffer(id, data.output || "", { mode: "replace" });
@@ -3960,7 +3977,8 @@
3960
3977
  // Clear queued inputs from the previous session to prevent cross-session leaks
3961
3978
  state.messageQueue = [];
3962
3979
  state.pendingMessages = [];
3963
- updateQueueCounter();
3980
+ state.structuredInputQueue = [];
3981
+ updateStructuredQueueCounter();
3964
3982
  resetChatRenderCache();
3965
3983
  state.currentMessages = [];
3966
3984
  if (chatRenderTimer) { clearTimeout(chatRenderTimer); chatRenderTimer = null; }
@@ -5023,11 +5041,236 @@
5023
5041
  return !!selectedSession && selectedSession.status === "running";
5024
5042
  }
5025
5043
 
5044
+ // ── 跨会话排队 ──
5045
+
5046
+ var _queueLaunching = false; // 防止并发 launch
5047
+
5048
+ function hasAnyBusySession() {
5049
+ return state.sessions.some(function(s) {
5050
+ return s.status === "running" && !s.archived;
5051
+ });
5052
+ }
5053
+
5054
+ function enqueueCrossSessionMessage(text) {
5055
+ if (state.crossSessionQueue.length >= 10) {
5056
+ showToast("排队消息已满(最多 10 条),请等待当前会话完成。", "warning");
5057
+ return;
5058
+ }
5059
+ var id = "csq-" + Date.now() + "-" + Math.random().toString(36).slice(2, 8);
5060
+ state.crossSessionQueue.push({
5061
+ id: id,
5062
+ text: text,
5063
+ cwd: getEffectiveCwd(),
5064
+ mode: state.chatMode || "managed",
5065
+ tool: getPreferredTool(),
5066
+ queuedAt: Date.now()
5067
+ });
5068
+ renderCrossSessionQueue();
5069
+ }
5070
+
5071
+ function launchQueueItem(item) {
5072
+ if (_queueLaunching) return;
5073
+ _queueLaunching = true;
5074
+ fetch("/api/commands", {
5075
+ method: "POST",
5076
+ headers: { "Content-Type": "application/json" },
5077
+ credentials: "same-origin",
5078
+ body: JSON.stringify({
5079
+ command: item.tool,
5080
+ cwd: item.cwd,
5081
+ mode: item.mode,
5082
+ initialInput: item.text
5083
+ })
5084
+ })
5085
+ .then(function(res) { return res.json(); })
5086
+ .then(function(data) {
5087
+ _queueLaunching = false;
5088
+ if (data.error) {
5089
+ showToast(data.error, "error");
5090
+ // 失败回填队首,不丢消息
5091
+ state.crossSessionQueue.unshift(item);
5092
+ renderCrossSessionQueue();
5093
+ return null;
5094
+ }
5095
+ return activateSession(data);
5096
+ })
5097
+ .catch(function(error) {
5098
+ _queueLaunching = false;
5099
+ showToast((error && error.message) || "无法启动排队会话。", "error");
5100
+ state.crossSessionQueue.unshift(item);
5101
+ renderCrossSessionQueue();
5102
+ });
5103
+ }
5104
+
5105
+ function sendQueueItemNow(queueId) {
5106
+ var idx = state.crossSessionQueue.findIndex(function(q) { return q.id === queueId; });
5107
+ if (idx < 0) return;
5108
+ var item = state.crossSessionQueue.splice(idx, 1)[0];
5109
+ renderCrossSessionQueue();
5110
+ // 立即发送不受 _queueLaunching 限制
5111
+ fetch("/api/commands", {
5112
+ method: "POST",
5113
+ headers: { "Content-Type": "application/json" },
5114
+ credentials: "same-origin",
5115
+ body: JSON.stringify({
5116
+ command: item.tool,
5117
+ cwd: item.cwd,
5118
+ mode: item.mode,
5119
+ initialInput: item.text
5120
+ })
5121
+ })
5122
+ .then(function(res) { return res.json(); })
5123
+ .then(function(data) {
5124
+ if (data.error) {
5125
+ showToast(data.error, "error");
5126
+ return null;
5127
+ }
5128
+ return activateSession(data);
5129
+ })
5130
+ .catch(function(error) {
5131
+ showToast((error && error.message) || "无法启动排队会话。", "error");
5132
+ });
5133
+ }
5134
+
5135
+ function cancelQueueItem(queueId) {
5136
+ var idx = state.crossSessionQueue.findIndex(function(q) { return q.id === queueId; });
5137
+ if (idx < 0) return;
5138
+ state.crossSessionQueue.splice(idx, 1);
5139
+ renderCrossSessionQueue();
5140
+ if (state.crossSessionQueue.length === 0) {
5141
+ showToast("排队已清空。", "info");
5142
+ }
5143
+ }
5144
+
5145
+ function flushCrossSessionQueue() {
5146
+ if (state.crossSessionQueue.length === 0) return;
5147
+ if (hasAnyBusySession()) return;
5148
+ if (_queueLaunching) return;
5149
+ var item = state.crossSessionQueue.shift();
5150
+ renderCrossSessionQueue();
5151
+ launchQueueItem(item);
5152
+ }
5153
+
5154
+ function formatQueueAge(queuedAt) {
5155
+ var sec = Math.floor((Date.now() - queuedAt) / 1000);
5156
+ if (sec < 60) return sec + "s";
5157
+ var min = Math.floor(sec / 60);
5158
+ if (min < 60) return min + "m";
5159
+ return Math.floor(min / 60) + "h";
5160
+ }
5161
+
5162
+ function renderCrossSessionQueue() {
5163
+ var container = document.querySelector(".cross-session-queue");
5164
+ var inputPanel = document.querySelector(".input-panel");
5165
+ var statusBar = document.querySelector(".structured-status-bar");
5166
+ var composer = document.querySelector(".input-composer");
5167
+ var blankChat = document.getElementById("blank-chat");
5168
+
5169
+ if (state.crossSessionQueue.length === 0) {
5170
+ if (container) container.remove();
5171
+ return;
5172
+ }
5173
+
5174
+ // Determine parent: input-panel (session view) or blank-chat (welcome view)
5175
+ var isInputPanelVisible = inputPanel && !inputPanel.classList.contains("hidden");
5176
+ var parent = isInputPanelVisible ? inputPanel : blankChat;
5177
+ // Insert above status bar if present, otherwise above composer
5178
+ var insertBefore = isInputPanelVisible ? (statusBar || composer) : null;
5179
+
5180
+ if (!parent) return;
5181
+
5182
+ // If container exists but is in the wrong parent, move it
5183
+ if (container && container.parentNode !== parent) {
5184
+ container.remove();
5185
+ container = null;
5186
+ }
5187
+
5188
+ if (!container) {
5189
+ container = document.createElement("div");
5190
+ container.className = "cross-session-queue";
5191
+ if (insertBefore) {
5192
+ parent.insertBefore(container, insertBefore);
5193
+ } else {
5194
+ parent.appendChild(container);
5195
+ }
5196
+ } else if (isInputPanelVisible && insertBefore && container.nextSibling !== insertBefore) {
5197
+ // Ensure queue stays above status bar
5198
+ parent.insertBefore(container, insertBefore);
5199
+ }
5200
+
5201
+ var total = state.crossSessionQueue.length;
5202
+ var items = state.crossSessionQueue.map(function(item, i) {
5203
+ var preview = item.text.length > 60 ? item.text.slice(0, 60) + "…" : item.text;
5204
+ var age = formatQueueAge(item.queuedAt);
5205
+ return '<div class="queue-item" data-queue-id="' + escapeHtml(item.id) + '">' +
5206
+ '<span class="queue-item-dot"></span>' +
5207
+ '<span class="queue-item-text" title="' + escapeHtml(item.text) + '">' + escapeHtml(preview) + '</span>' +
5208
+ '<span class="queue-item-age">' + age + '</span>' +
5209
+ '<button class="queue-item-send-now" data-queue-id="' + escapeHtml(item.id) + '" title="立即发送" type="button">发送</button>' +
5210
+ '<button class="queue-item-cancel" data-queue-id="' + escapeHtml(item.id) + '" title="取消" type="button">×</button>' +
5211
+ '</div>';
5212
+ }).join("");
5213
+
5214
+ var header = total > 1
5215
+ ? '<div class="queue-header">' +
5216
+ '<span class="queue-header-label">排队 ' + total + ' 条</span>' +
5217
+ '<button class="queue-header-clear" id="queue-clear-all" type="button" title="清空排队">清空</button>' +
5218
+ '</div>'
5219
+ : '';
5220
+
5221
+ container.innerHTML = header + items;
5222
+ }
5223
+
5224
+ // 定时刷新排队项的等待时间 + 尝试 flush
5225
+ setInterval(function() {
5226
+ if (state.crossSessionQueue.length > 0) {
5227
+ // 只更新 age 文本,不重建整个 DOM
5228
+ var ages = document.querySelectorAll(".queue-item-age");
5229
+ state.crossSessionQueue.forEach(function(item, i) {
5230
+ if (ages[i]) ages[i].textContent = formatQueueAge(item.queuedAt);
5231
+ });
5232
+ // 尝试 flush 作为保底(防止 ended 事件 flush 失败)
5233
+ flushCrossSessionQueue();
5234
+ }
5235
+ }, 5000);
5236
+
5237
+ // Delegate click events for cross-session queue items
5238
+ document.addEventListener("click", function(e) {
5239
+ if (e.target.closest("#queue-clear-all")) {
5240
+ e.preventDefault();
5241
+ state.crossSessionQueue = [];
5242
+ renderCrossSessionQueue();
5243
+ showToast("排队已清空。", "info");
5244
+ return;
5245
+ }
5246
+ var sendNow = e.target.closest(".queue-item-send-now");
5247
+ if (sendNow) {
5248
+ e.preventDefault();
5249
+ sendQueueItemNow(sendNow.dataset.queueId);
5250
+ return;
5251
+ }
5252
+ var cancel = e.target.closest(".queue-item-cancel");
5253
+ if (cancel) {
5254
+ e.preventDefault();
5255
+ cancelQueueItem(cancel.dataset.queueId);
5256
+ return;
5257
+ }
5258
+ });
5259
+
5026
5260
  // Send message from the welcome screen input
5027
5261
  function welcomeInputSend() {
5028
5262
  var welcomeInput = document.getElementById("welcome-input");
5029
5263
  var value = welcomeInput ? welcomeInput.value.trim() : "";
5030
5264
  if (!value) return;
5265
+
5266
+ // Cross-session queue: if any session is busy, queue instead of creating
5267
+ if (hasAnyBusySession()) {
5268
+ welcomeInput.value = "";
5269
+ enqueueCrossSessionMessage(value);
5270
+ showToast("已排队,将在当前会话完成后自动发送。", "info");
5271
+ return;
5272
+ }
5273
+
5031
5274
  // Clear todo progress bar at the start of a new session
5032
5275
  var todoEl = document.getElementById("todo-progress");
5033
5276
  if (todoEl) todoEl.classList.add("hidden");
@@ -5093,7 +5336,14 @@
5093
5336
  return;
5094
5337
  }
5095
5338
 
5096
- // No selected session, create a new one
5339
+ // No selected session, create a new one (or queue if busy)
5340
+ if (value && hasAnyBusySession()) {
5341
+ if (inputBox) inputBox.value = "";
5342
+ if (welcomeInput) welcomeInput.value = "";
5343
+ enqueueCrossSessionMessage(value);
5344
+ showToast("已排队,将在当前会话完成后自动发送。", "info");
5345
+ return;
5346
+ }
5097
5347
  var mode = state.chatMode || "managed";
5098
5348
  var defaultCwd = getEffectiveCwd();
5099
5349
  var preferredTool = getPreferredTool();
@@ -5253,10 +5503,27 @@
5253
5503
  return Promise.resolve();
5254
5504
  }
5255
5505
  if (session.structuredState && session.structuredState.inFlight && session.status === "running") {
5256
- // Disable send button while processing, show subtle indicator
5257
- var sendBtn = document.getElementById("send-input-button");
5258
- if (sendBtn) sendBtn.disabled = true;
5259
- showToast("正在等待上一条消息处理完成…", "info");
5506
+ // Queue the message for sending after current processing completes
5507
+ if (state.structuredInputQueue.length >= 10) {
5508
+ showToast("排队消息已满(最多 10 条),请等待当前消息处理完成。", "warning");
5509
+ return Promise.resolve();
5510
+ }
5511
+ state.structuredInputQueue.push(input);
5512
+ if (inputBox) {
5513
+ inputBox.value = "";
5514
+ autoResizeInput(inputBox);
5515
+ }
5516
+ setDraftValue("");
5517
+ // Show the queued message in chat view with a "queued" marker
5518
+ var queuedTurn = { role: "user", content: [{ type: "text", text: input, __queued: true }] };
5519
+ var curMsgs = Array.isArray(state.currentMessages) ? state.currentMessages.slice() : [];
5520
+ curMsgs.push(queuedTurn);
5521
+ state.currentMessages = curMsgs;
5522
+ // Also update session.messages so the queued turn survives WS updates
5523
+ session.messages = curMsgs;
5524
+ renderChat(true);
5525
+ showToast("已排队(第 " + state.structuredInputQueue.length + " 条),将在当前消息处理完成后自动发送。", "info");
5526
+ updateStructuredQueueCounter();
5260
5527
  return Promise.resolve();
5261
5528
  }
5262
5529
 
@@ -5264,8 +5531,14 @@
5264
5531
  var userTurn = { role: "user", content: [{ type: "text", text: input }] };
5265
5532
  var thinkingTurn = { role: "assistant", content: [{ type: "text", text: "", __processing: true }] };
5266
5533
  var userMsgs = Array.isArray(session.messages) ? session.messages.slice() : [];
5534
+ // Filter out __queued placeholders — they'll be re-appended after the new turns
5535
+ userMsgs = userMsgs.filter(function(m) {
5536
+ return !(m.role === "user" && m.content && m.content.some(function(b) { return b.__queued; }));
5537
+ });
5267
5538
  userMsgs.push(userTurn);
5268
5539
  userMsgs.push(thinkingTurn);
5540
+ // Re-append remaining queued messages after the current send
5541
+ appendQueuedPlaceholders(userMsgs);
5269
5542
  session.messages = userMsgs;
5270
5543
  state.currentMessages = userMsgs;
5271
5544
  // Mark inFlight optimistically to prevent double-send via WS updates
@@ -5277,9 +5550,7 @@
5277
5550
  inputBox.value = "";
5278
5551
  autoResizeInput(inputBox);
5279
5552
  }
5280
- // Disable send button so user can't double-send
5281
- var sendBtnEl = document.getElementById("send-input-button");
5282
- if (sendBtnEl) sendBtnEl.disabled = true;
5553
+ // Keep send button enabled so user can queue more messages
5283
5554
  updateInputHint("思考中…");
5284
5555
  setDraftValue("");
5285
5556
  renderChat(true);
@@ -5299,17 +5570,28 @@
5299
5570
  updateSessionSnapshot(snapshot);
5300
5571
  if (snapshot.messages && snapshot.messages.length > 0) {
5301
5572
  state.currentMessages = snapshot.messages;
5573
+ // Re-append queued user messages
5574
+ appendQueuedPlaceholders(state.currentMessages);
5302
5575
  }
5303
5576
  renderChat(true);
5304
5577
  updateInputHint("Enter 发送 · Shift+Enter 换行");
5305
5578
  }
5306
5579
  })
5307
5580
  .catch(function(error) {
5308
- var errSendBtn = document.getElementById("send-input-button");
5309
- if (errSendBtn) errSendBtn.disabled = false;
5581
+ // Reset inFlight so user can send again
5582
+ if (session.structuredState) {
5583
+ session.structuredState.inFlight = false;
5584
+ }
5585
+ // Clear remaining queued messages since the session is likely broken
5586
+ if (state.structuredInputQueue.length > 0) {
5587
+ var dropped = state.structuredInputQueue.length;
5588
+ state.structuredInputQueue = [];
5589
+ updateStructuredQueueCounter();
5590
+ showToast("发送失败,已清空 " + dropped + " 条排队消息。", "error");
5591
+ } else {
5592
+ showToast((error && error.message) || "无法发送结构化消息。", "error");
5593
+ }
5310
5594
  updateInputHint("Enter 发送 · Shift+Enter 换行");
5311
- showToast((error && error.message) || "无法发送结构化消息。", "error");
5312
- throw error;
5313
5595
  });
5314
5596
  }
5315
5597
 
@@ -5318,6 +5600,63 @@
5318
5600
  if (hint) hint.textContent = text;
5319
5601
  }
5320
5602
 
5603
+ function updateStructuredQueueCounter() {
5604
+ var counter = document.getElementById("queue-counter");
5605
+ var count = state.structuredInputQueue.length;
5606
+ if (counter) {
5607
+ counter.textContent = "队列: " + count;
5608
+ if (count > 0) {
5609
+ counter.classList.remove("hidden");
5610
+ } else {
5611
+ counter.classList.add("hidden");
5612
+ }
5613
+ }
5614
+ }
5615
+
5616
+ // Append queued user message placeholders to currentMessages so they
5617
+ // remain visible across WS updates and re-renders.
5618
+ function appendQueuedPlaceholders(messages) {
5619
+ if (state.structuredInputQueue.length === 0) return messages;
5620
+ for (var qi = 0; qi < state.structuredInputQueue.length; qi++) {
5621
+ messages.push({ role: "user", content: [{ type: "text", text: state.structuredInputQueue[qi], __queued: true }] });
5622
+ }
5623
+ return messages;
5624
+ }
5625
+
5626
+ function flushStructuredInputQueue() {
5627
+ if (state.structuredInputQueue.length === 0) return;
5628
+ var session = state.sessions.find(function(s) { return s.id === state.selectedId; });
5629
+ if (!session || session.sessionKind !== "structured") {
5630
+ state.structuredInputQueue = [];
5631
+ updateStructuredQueueCounter();
5632
+ return;
5633
+ }
5634
+ // Only flush if not inFlight
5635
+ if (session.structuredState && session.structuredState.inFlight) return;
5636
+ var nextInput = state.structuredInputQueue.shift();
5637
+ updateStructuredQueueCounter();
5638
+ if (nextInput) {
5639
+ // Remove __queued marker from the matching user turn already in chat.
5640
+ // postStructuredInput will find it's not inFlight now and do the
5641
+ // normal send path, which re-adds the user turn + thinking turn.
5642
+ // So we need to remove the queued placeholder first to avoid duplicates.
5643
+ var msgs = Array.isArray(state.currentMessages) ? state.currentMessages : [];
5644
+ for (var qi = msgs.length - 1; qi >= 0; qi--) {
5645
+ var qm = msgs[qi];
5646
+ if (qm.role === "user" && qm.content && qm.content.some(function(b) {
5647
+ return b.__queued && b.text === nextInput;
5648
+ })) {
5649
+ msgs.splice(qi, 1);
5650
+ break;
5651
+ }
5652
+ }
5653
+ state.currentMessages = msgs;
5654
+ if (session.messages) session.messages = msgs;
5655
+ // Pass null for inputBox to avoid clearing user's current typing
5656
+ postStructuredInput(nextInput, null, session);
5657
+ }
5658
+ }
5659
+
5321
5660
  function getInputErrorMessage(error) {
5322
5661
  if (error && (error.errorCode === "SESSION_NOT_RUNNING" || error.errorCode === "SESSION_NO_PTY")) {
5323
5662
  return "会话已结束;若存在 Claude 历史会话,将在你下次发送消息时自动恢复。";
@@ -5399,12 +5738,10 @@
5399
5738
  function queueDirectInput(input, shortcutKey) {
5400
5739
  if (!input || !state.selectedId) return Promise.resolve();
5401
5740
  state.messageQueue.push(input);
5402
- updateQueueCounter();
5403
5741
  state.inputQueue = state.inputQueue.then(function() {
5404
5742
  return postInput(input, shortcutKey).finally(function() {
5405
5743
  var idx = state.messageQueue.indexOf(input);
5406
5744
  if (idx > -1) state.messageQueue.splice(idx, 1);
5407
- updateQueueCounter();
5408
5745
  scheduleMobileDomUpdate();
5409
5746
  });
5410
5747
  });
@@ -7413,6 +7750,10 @@
7413
7750
  updateSessionSnapshot(snapshot);
7414
7751
  if (msg.sessionId === state.selectedId) {
7415
7752
  state.currentMessages = getPreferredMessages(snapshot, msg.data.output, false);
7753
+ // Re-append queued user messages that haven't been sent yet
7754
+ if (msg.data.sessionKind === 'structured') {
7755
+ appendQueuedPlaceholders(state.currentMessages);
7756
+ }
7416
7757
  // Structured session with inFlight: keep __processing placeholder
7417
7758
  // so the loading indicator stays visible until assistant content arrives
7418
7759
  if (msg.data.sessionKind === 'structured') {
@@ -7472,10 +7813,7 @@
7472
7813
  }
7473
7814
  updateSessionSnapshot(endedSnapshot);
7474
7815
 
7475
- // Re-enable send button when structured session finishes
7476
7816
  if (msg.sessionId === state.selectedId) {
7477
- var endedSendBtn = document.getElementById("send-input-button");
7478
- if (endedSendBtn) endedSendBtn.disabled = false;
7479
7817
  updateInputHint("Enter 发送 · Shift+Enter 换行");
7480
7818
  // Trigger status bar completion animation
7481
7819
  scheduleChatRender(true);
@@ -7507,16 +7845,32 @@
7507
7845
  });
7508
7846
  }
7509
7847
 
7510
- // Clear stale queued inputs so they cannot race with the ended session.
7511
- // Each queued item's postInput will hit the server and get an error, but
7512
- // clearing the queues here prevents them from growing unbounded.
7848
+ // Clear stale queued inputs for PTY sessions.
7849
+ // For structured sessions, each "ended" means one turn completed (not
7850
+ // the session terminated), so we must NOT clear the structured queue —
7851
+ // instead, flush the next queued message.
7513
7852
  state.messageQueue = [];
7514
7853
  state.pendingMessages = [];
7515
7854
 
7855
+ var endedSessionObj = state.sessions.find(function(s) { return s.id === msg.sessionId; });
7856
+ var isStructuredEnded = endedSessionObj && endedSessionObj.sessionKind === "structured";
7857
+
7858
+ if (isStructuredEnded && msg.sessionId === state.selectedId &&
7859
+ state.structuredInputQueue.length > 0) {
7860
+ // Structured session turn completed — flush next queued message
7861
+ setTimeout(flushStructuredInputQueue, 50);
7862
+ } else if (!isStructuredEnded) {
7863
+ // PTY session ended — clear structured queue too
7864
+ state.structuredInputQueue = [];
7865
+ updateStructuredQueueCounter();
7866
+ }
7867
+
7516
7868
  // Disable terminal interactive mode immediately so the terminal stops
7517
7869
  // capturing keystrokes before loadSessions() completes.
7518
7870
  if (msg.sessionId === state.selectedId) {
7519
- setTerminalInteractive(false);
7871
+ if (!isStructuredEnded) {
7872
+ setTerminalInteractive(false);
7873
+ }
7520
7874
  state.currentTask = null;
7521
7875
  updateTaskDisplay();
7522
7876
  }
@@ -7527,9 +7881,14 @@
7527
7881
  updateShellChrome();
7528
7882
  }
7529
7883
 
7530
- loadSessions();
7884
+ loadSessions().then(function() {
7885
+ // After sessions list is refreshed, try to flush cross-session queue
7886
+ flushCrossSessionQueue();
7887
+ });
7531
7888
  if (msg.sessionId === state.selectedId) {
7532
- loadOutput(msg.sessionId);
7889
+ if (!isStructuredEnded) {
7890
+ loadOutput(msg.sessionId);
7891
+ }
7533
7892
  }
7534
7893
  break;
7535
7894
  }
@@ -7626,6 +7985,11 @@
7626
7985
  // Re-render chat when structured session inFlight state changes
7627
7986
  if (statusUpdate.structuredState) {
7628
7987
  scheduleChatRender();
7988
+ // Flush queued structured messages when inFlight clears
7989
+ if (!statusUpdate.structuredState.inFlight) {
7990
+ updateInputHint("Enter 发送 · Shift+Enter 换行");
7991
+ setTimeout(flushStructuredInputQueue, 50);
7992
+ }
7629
7993
  }
7630
7994
  }
7631
7995
  }
@@ -7897,6 +8261,9 @@
7897
8261
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
7898
8262
  if (selectedSession) {
7899
8263
  state.currentMessages = getPreferredMessages(selectedSession, selectedSession.output, true);
8264
+ if (selectedSession.sessionKind === "structured") {
8265
+ appendQueuedPlaceholders(state.currentMessages);
8266
+ }
7900
8267
  }
7901
8268
  renderChat();
7902
8269
  }, 30);
@@ -8378,17 +8745,6 @@
8378
8745
 
8379
8746
  }
8380
8747
 
8381
- function updateQueueCounter() {
8382
- var counter = document.getElementById("queue-counter");
8383
- if (!counter) return;
8384
- var count = state.messageQueue.length;
8385
- if (count > 0) {
8386
- counter.textContent = "队列: " + count;
8387
- counter.classList.remove("hidden");
8388
- } else {
8389
- counter.classList.add("hidden");
8390
- }
8391
- }
8392
8748
 
8393
8749
  function attachCopyHandler(el) {
8394
8750
  el.querySelectorAll(".code-copy").forEach(function(btn) {
@@ -8962,13 +9318,51 @@
8962
9318
  };
8963
9319
  })();
8964
9320
 
9321
+ var DEFAULT_CHAT_PERSONA = {
9322
+ user: {
9323
+ name: "赛博虎妞",
9324
+ avatarSvg: PIXEL_AVATAR.user
9325
+ },
9326
+ assistant: {
9327
+ name: "勤劳初二",
9328
+ avatarSvg: PIXEL_AVATAR.assistant
9329
+ }
9330
+ };
9331
+
9332
+ function getStructuredChatPersona(role) {
9333
+ var configPersona = state.config && state.config.structuredChatPersona;
9334
+ var roleConfig = configPersona && configPersona[role] ? configPersona[role] : null;
9335
+ var defaults = DEFAULT_CHAT_PERSONA[role] || DEFAULT_CHAT_PERSONA.assistant;
9336
+ return {
9337
+ name: roleConfig && typeof roleConfig.name === "string" && roleConfig.name.trim()
9338
+ ? roleConfig.name.trim()
9339
+ : defaults.name,
9340
+ avatar: roleConfig && typeof roleConfig.avatar === "string" && roleConfig.avatar.trim()
9341
+ ? roleConfig.avatar.trim()
9342
+ : null,
9343
+ avatarSvg: defaults.avatarSvg
9344
+ };
9345
+ }
9346
+
9347
+ function renderAvatarFallback(svg) {
9348
+ return '<div class="pixel-avatar">' + svg + '</div>';
9349
+ }
9350
+
9351
+ function handleChatAvatarImageError(img, role) {
9352
+ if (!img || !img.parentNode) return;
9353
+ var persona = getStructuredChatPersona(role === "user" ? "user" : "assistant");
9354
+ img.outerHTML = renderAvatarFallback(persona.avatarSvg);
9355
+ }
9356
+
8965
9357
  function chatAvatar(role) {
8966
- var isUser = role === "user";
8967
- var svg = isUser ? PIXEL_AVATAR.user : PIXEL_AVATAR.assistant;
8968
- var name = isUser ? "赛博虎妞" : "勤劳初二";
9358
+ var personaRole = role === "user" ? "user" : "assistant";
9359
+ var persona = getStructuredChatPersona(personaRole);
9360
+ var avatarInner = persona.avatar
9361
+ ? '<img class="pixel-avatar-image" src="' + escapeHtml(persona.avatar) + '" alt="' + escapeHtml(persona.name) + '" onerror="handleChatAvatarImageError(this, ' + JSON.stringify(personaRole) + ')" />'
9362
+ : renderAvatarFallback(persona.avatarSvg);
8969
9363
  return '<div class="chat-message-avatar ' + role + '">' +
8970
- '<div class="pixel-avatar">' + svg + '</div>' +
8971
- '<span class="avatar-name">' + name + '</span>' +
9364
+ avatarInner +
9365
+ '<span class="avatar-name">' + escapeHtml(persona.name) + '</span>' +
8972
9366
  '</div>';
8973
9367
  }
8974
9368
 
@@ -9147,6 +9541,9 @@
9147
9541
  var role = msg.role;
9148
9542
  var avatar = chatAvatar(role);
9149
9543
 
9544
+ // Check if this is a queued user message
9545
+ var isQueued = role === "user" && msg.content && msg.content.some(function(b) { return b.__queued; });
9546
+
9150
9547
  if (!msg.content || msg.content.length === 0) {
9151
9548
  if (role === "assistant") {
9152
9549
  return '<div class="chat-message ' + role + '">' +
@@ -9182,10 +9579,12 @@
9182
9579
  }
9183
9580
 
9184
9581
  var usageHtml = "";
9582
+ var queuedClass = isQueued ? " queued" : "";
9583
+ var queuedBadge = isQueued ? '<span class="queued-badge">排队中</span>' : "";
9185
9584
 
9186
- return '<div class="chat-message ' + role + '">' +
9585
+ return '<div class="chat-message ' + role + queuedClass + '">' +
9187
9586
  avatar +
9188
- '<div class="chat-message-content">' + blocksHtml + '</div>' +
9587
+ '<div class="chat-message-content">' + blocksHtml + queuedBadge + '</div>' +
9189
9588
  usageHtml +
9190
9589
  '</div>';
9191
9590
  }
@@ -9509,27 +9908,29 @@
9509
9908
  if (toolName === "AskUserQuestion" && block.input && block.input.questions) {
9510
9909
  var questions = block.input.questions;
9511
9910
  if (questions && questions.length > 0) {
9512
- var question = questions[0];
9513
- var questionText = question.question ? '<div class="ask-user-title">' + escapeHtml(question.question) + '</div>' : "";
9514
- var optionsHtml = "";
9515
- if (question.options && question.options.length > 0) {
9516
- optionsHtml = '<div class="ask-user-options">';
9517
- question.options.forEach(function(opt, idx) {
9518
- var label = opt.label ? escapeHtml(opt.label) : "选项 " + (idx + 1);
9519
- optionsHtml += '<button class="ask-user-option" data-option-index="' + idx + '" data-option-label="' + escapeHtml(label) + '" onclick="__askOption(this)">' +
9520
- '<div class="ask-user-option-label">' + label + '</div>' +
9521
- '</button>';
9522
- });
9523
- optionsHtml += '</div>';
9524
- }
9911
+ var questionsHtml = "";
9912
+ questions.forEach(function(question, qIdx) {
9913
+ var questionText = question.question ? '<div class="ask-user-title">' + escapeHtml(question.question) + '</div>' : "";
9914
+ var optionsHtml = "";
9915
+ if (question.options && question.options.length > 0) {
9916
+ optionsHtml = '<div class="ask-user-options">';
9917
+ question.options.forEach(function(opt, idx) {
9918
+ var label = opt.label ? escapeHtml(opt.label) : "选项 " + (idx + 1);
9919
+ optionsHtml += '<button class="ask-user-option" data-option-index="' + idx + '" data-question-index="' + qIdx + '" data-option-label="' + escapeHtml(label) + '" onclick="__askOption(this)">' +
9920
+ '<div class="ask-user-option-label">' + label + '</div>' +
9921
+ '</button>';
9922
+ });
9923
+ optionsHtml += '</div>';
9924
+ }
9925
+ questionsHtml += '<div class="ask-user-question-group">' + questionText + optionsHtml + '</div>';
9926
+ });
9525
9927
  return '<div class="tool-use-card ask-user" data-tool-use-id="' + escapeHtml(toolId) + '">' +
9526
9928
  '<div class="tool-use-header" data-tool-toggle onclick="__tcToggle(event,this)">' +
9527
9929
  '<span class="tool-use-icon">?</span>' +
9528
9930
  '<span class="tool-use-name">提问</span>' +
9529
9931
  '</div>' +
9530
9932
  '<div class="tool-use-body ask-user-body">' +
9531
- questionText +
9532
- optionsHtml +
9933
+ questionsHtml +
9533
9934
  '</div>' +
9534
9935
  '</div>';
9535
9936
  }
@@ -2328,6 +2328,13 @@
2328
2328
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
2329
2329
  }
2330
2330
 
2331
+ .pixel-avatar-image {
2332
+ display: block;
2333
+ width: 100%;
2334
+ height: 100%;
2335
+ object-fit: cover;
2336
+ }
2337
+
2331
2338
  .pixel-avatar-svg {
2332
2339
  display: block;
2333
2340
  width: 100%;
@@ -2766,6 +2773,12 @@
2766
2773
  .ask-user-body {
2767
2774
  padding: 10px 12px;
2768
2775
  }
2776
+ .ask-user-question-group {
2777
+ margin-bottom: 10px;
2778
+ }
2779
+ .ask-user-question-group:last-child {
2780
+ margin-bottom: 0;
2781
+ }
2769
2782
  .ask-user-title {
2770
2783
  font-size: 0.875rem;
2771
2784
  font-weight: 500;
@@ -3770,6 +3783,22 @@
3770
3783
  50% { opacity: 0.7; }
3771
3784
  }
3772
3785
 
3786
+ /* Queued message in chat view */
3787
+ .chat-message.user.queued {
3788
+ opacity: 0.6;
3789
+ }
3790
+ .queued-badge {
3791
+ display: inline-block;
3792
+ font-size: 0.625rem;
3793
+ color: var(--accent);
3794
+ background: var(--accent-muted);
3795
+ padding: 1px 6px;
3796
+ border-radius: 8px;
3797
+ font-weight: 600;
3798
+ margin-top: 4px;
3799
+ animation: queuePulse 1.5s ease-in-out infinite;
3800
+ }
3801
+
3773
3802
  .input-hint {
3774
3803
  font-size: 0.5625rem;
3775
3804
  color: var(--text-muted);
@@ -7750,4 +7779,125 @@
7750
7779
  100% { opacity: 0; max-height: 0; margin: 0; }
7751
7780
  }
7752
7781
 
7782
+ /* ── 跨会话排队消息 ── */
7783
+ .cross-session-queue {
7784
+ display: flex;
7785
+ flex-direction: column;
7786
+ gap: 2px;
7787
+ margin: 0 8px 4px 8px;
7788
+ }
7789
+
7790
+ .queue-header {
7791
+ display: flex;
7792
+ align-items: center;
7793
+ gap: 5px;
7794
+ padding: 0 4px;
7795
+ font-size: 0.6rem;
7796
+ color: var(--text-muted);
7797
+ }
7798
+
7799
+ .queue-header-label {
7800
+ flex: 1;
7801
+ opacity: 0.6;
7802
+ }
7803
+
7804
+ .queue-header-clear {
7805
+ border: none;
7806
+ background: transparent;
7807
+ color: var(--text-muted);
7808
+ cursor: pointer;
7809
+ font-size: 0.5625rem;
7810
+ padding: 1px 4px;
7811
+ border-radius: 3px;
7812
+ opacity: 0.5;
7813
+ transition: color 0.15s, background 0.15s, opacity 0.15s;
7814
+ }
7815
+
7816
+ .queue-header-clear:hover {
7817
+ color: var(--error, #e55);
7818
+ background: rgba(229, 85, 85, 0.12);
7819
+ opacity: 1;
7820
+ }
7821
+
7822
+ .queue-item {
7823
+ display: flex;
7824
+ align-items: center;
7825
+ gap: 6px;
7826
+ padding: 4px 8px;
7827
+ font-size: 0.6875rem;
7828
+ color: var(--text-muted);
7829
+ opacity: 0.7;
7830
+ transition: opacity 0.15s ease;
7831
+ }
7832
+
7833
+ .queue-item:hover {
7834
+ opacity: 1;
7835
+ }
7836
+
7837
+ .queue-item-dot {
7838
+ flex-shrink: 0;
7839
+ width: 4px;
7840
+ height: 4px;
7841
+ border-radius: 50%;
7842
+ background: rgba(var(--accent-rgb, 197, 101, 61), 0.6);
7843
+ animation: statusDotPulse 1.2s ease-in-out infinite;
7844
+ }
7845
+
7846
+ .queue-item-text {
7847
+ flex: 1;
7848
+ min-width: 0;
7849
+ overflow: hidden;
7850
+ text-overflow: ellipsis;
7851
+ white-space: nowrap;
7852
+ font-size: 0.6875rem;
7853
+ color: var(--text-muted);
7854
+ }
7855
+
7856
+ .queue-item-age {
7857
+ flex-shrink: 0;
7858
+ font-size: 0.5625rem;
7859
+ color: var(--text-muted);
7860
+ font-variant-numeric: tabular-nums;
7861
+ opacity: 0.5;
7862
+ }
7863
+
7864
+ .queue-item-send-now {
7865
+ flex-shrink: 0;
7866
+ border: 1px solid rgba(var(--accent-rgb, 197, 101, 61), 0.3);
7867
+ background: transparent;
7868
+ color: var(--accent, #c5653d);
7869
+ cursor: pointer;
7870
+ padding: 1px 8px;
7871
+ border-radius: 4px;
7872
+ font-size: 0.6rem;
7873
+ line-height: 1.4;
7874
+ transition: color 0.15s, background 0.15s, border-color 0.15s;
7875
+ }
7876
+
7877
+ .queue-item-send-now:hover {
7878
+ color: var(--accent, #c5653d);
7879
+ background: rgba(var(--accent-rgb, 197, 101, 61), 0.12);
7880
+ border-color: rgba(var(--accent-rgb, 197, 101, 61), 0.5);
7881
+ }
7882
+
7883
+ .queue-item-cancel {
7884
+ flex-shrink: 0;
7885
+ border: none;
7886
+ background: transparent;
7887
+ color: var(--text-muted);
7888
+ cursor: pointer;
7889
+ padding: 1px 4px;
7890
+ border-radius: 3px;
7891
+ font-size: 0.6875rem;
7892
+ line-height: 1;
7893
+ opacity: 0.4;
7894
+ transition: color 0.15s, background 0.15s, opacity 0.15s;
7895
+ }
7896
+
7897
+ .queue-item-cancel:hover {
7898
+ color: var(--error, #e55);
7899
+ background: rgba(229, 85, 85, 0.12);
7900
+ opacity: 1;
7901
+ }
7902
+
7753
7903
  /* 结束标记 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.6.0",
3
+ "version": "1.6.2",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {