@co0ontty/wand 1.30.3 → 1.31.0

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/dist/tui/index.js CHANGED
@@ -206,14 +206,14 @@ export function startTui(deps) {
206
206
  layout.showToast("服务已安装,按 Shift+S 卸载", "warn", 2500);
207
207
  return;
208
208
  }
209
- const ok = await layout.confirm({
210
- title: "注册为系统服务",
211
- body: process.platform === "linux"
212
- ? "将写入 ~/.config/systemd/user/wand.servicesystemctl --user enable --now"
213
- : process.platform === "darwin"
214
- ? "将写入 ~/Library/LaunchAgents/com.wand.web.plist launchctl load。"
215
- : "当前平台暂不支持。",
216
- });
209
+ // 默认装 system-wide。非 root 时 installService 会返回明确错误,toast 自然展示。
210
+ const isRoot = typeof process.getuid === "function" ? process.getuid() === 0 : false;
211
+ const body = process.platform === "linux"
212
+ ? `将写入 /etc/systemd/system/wand.servicesystemctl enable --now,开机自启。\n${isRoot ? "当前是 root,可以直接装。" : "⚠ 需要 root,建议先 Ctrl+C 退出 TUI 再 sudo wand web 重新进。"}\n不想要 root?退出 TUI 跑 wand service:install --user (登出会被回收)。`
213
+ : process.platform === "darwin"
214
+ ? `将写入 /Library/LaunchDaemons/com.wand.web.plist,launchctl load,开机自启。\n${isRoot ? "当前是 root,可以直接装。" : "⚠ 需要 root,建议先 Ctrl+C 退出 TUI 再 sudo wand web 重新进。"}`
215
+ : "当前平台暂不支持。";
216
+ const ok = await layout.confirm({ title: "注册为系统服务", body });
217
217
  if (!ok)
218
218
  return;
219
219
  const r = await runOffMicrotask(() => installService({ configPath: deps.configPath }));
@@ -64,10 +64,9 @@ export function openServicePanel(deps) {
64
64
  layout.showToast("服务已安装,按 u 先卸载再重装", "warn", 2500);
65
65
  return;
66
66
  }
67
- const ok = await layout.confirm({
68
- title: "注册服务",
69
- body: "将写入 unit / plist 并启用(无需 sudo,使用用户级服务)。",
70
- });
67
+ const isRoot = typeof process.getuid === "function" ? process.getuid() === 0 : false;
68
+ const body = `默认装 system-wide(/etc/systemd/system/ 或 /Library/LaunchDaemons/),开机自启、登出不停。${isRoot ? "" : "\n⚠ 需要 root,如果失败请退出 TUI 跑 sudo wand service:install。"}`;
69
+ const ok = await layout.confirm({ title: "注册服务", body });
71
70
  if (!ok)
72
71
  return;
73
72
  handleResult("install", installService({ configPath }));
package/dist/types.d.ts CHANGED
@@ -239,6 +239,8 @@ export interface CommandRequest {
239
239
  cols?: number;
240
240
  /** 创建会话时由前端测得的真实行数。 */
241
241
  rows?: number;
242
+ /** 思考深度。null/缺省 视为 off(不启用思考)。 */
243
+ thinkingEffort?: "off" | "standard" | "deep" | "max" | null;
242
244
  }
243
245
  export interface InputRequest {
244
246
  input?: string;
@@ -149,6 +149,12 @@
149
149
  chatModel: (function() {
150
150
  try { return localStorage.getItem("wand-chat-model") || ""; } catch (e) { return ""; }
151
151
  })(),
152
+ chatThinking: (function() {
153
+ try {
154
+ var v = localStorage.getItem("wand-thinking-effort") || "off";
155
+ return (v === "off" || v === "standard" || v === "deep" || v === "max") ? v : "off";
156
+ } catch (e) { return "off"; }
157
+ })(),
152
158
  availableModels: [],
153
159
  availableCodexModels: [],
154
160
  modelsRefreshing: false,
@@ -1724,13 +1730,26 @@
1724
1730
  // tabindex="-1": 把这些控件移出 iOS Safari 的表单导航链,
1725
1731
  // 这样 textarea 聚焦时键盘上方就不会出现 ⌃ ⌄ ✓ 表单辅助栏。
1726
1732
  '<input type="file" id="file-upload-input" multiple tabindex="-1" style="position:absolute;width:1px;height:1px;opacity:0;overflow:hidden;clip:rect(0,0,0,0);pointer-events:none">' +
1727
- '<select id="chat-mode-select" class="chat-mode-select" tabindex="-1" title="仅对新建会话生效">' +
1728
- renderModeOptions(preferredTool, composerMode) +
1729
- '</select>' +
1730
- '<select id="chat-model-select" class="chat-mode-select chat-model-select" tabindex="-1" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
1731
- renderChatModelOptions(getEffectiveModel(selectedSession), selectedSession) +
1732
- '</select>' +
1733
- '<button id="terminal-interactive-toggle-top" class="composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
1733
+ // 三件套 (Mode / Model / Thinking) 同属"会话设置"层:视觉上统一为同一种 pill 风格,
1734
+ // CSS .composer-pill-group 内的轻量分隔感传达"归类"。
1735
+ '<span class="composer-pill-group" role="group" aria-label="会话设置">' +
1736
+ '<select id="chat-mode-select" class="composer-pill composer-pill-select chat-mode-select" tabindex="-1" title="新会话模式 · 托管 / 全权限 自动启用批准">' +
1737
+ renderModeOptions(preferredTool, composerMode) +
1738
+ '</select>' +
1739
+ '<select id="chat-model-select" class="composer-pill composer-pill-select chat-mode-select chat-model-select" tabindex="-1" title="切换模型(对运行中会话发送 /model,对新会话作为 --model 启动)">' +
1740
+ renderChatModelOptions(getEffectiveModel(selectedSession), selectedSession) +
1741
+ '</select>' +
1742
+ // 思考深度 trigger:与 mode / model 同形(border + chevron),保证视觉一致。
1743
+ // 可见层只有「档位文字」,原生 <select> 透明叠在上面,依然能调起 iOS 滚轮选择。
1744
+ '<span class="composer-pill composer-pill-select chat-thinking-trigger" title="思考深度(structured 立即生效;PTY 仅作用于通过 chat 视图发送的消息)">' +
1745
+ '<span class="chat-thinking-label" id="chat-thinking-label">' + escapeHtml(getThinkingLabel(getEffectiveThinking(selectedSession))) + '</span>' +
1746
+ '<select id="chat-thinking-select" class="chat-thinking-hidden-select" tabindex="-1" aria-label="思考深度">' +
1747
+ renderChatThinkingOptions(getEffectiveThinking(selectedSession)) +
1748
+ '</select>' +
1749
+ '</span>' +
1750
+ '</span>' +
1751
+ renderAutoApproveChip(selectedSession) +
1752
+ '<button id="terminal-interactive-toggle-top" class="composer-pill composer-pill-chip composer-interactive-toggle' + (state.terminalInteractive ? " active" : "") + '" type="button" title="切换终端交互模式">⌨</button>' +
1734
1753
  '<span class="permission-actions hidden" id="permission-actions">' +
1735
1754
  '<span class="permission-actions-divider"></span>' +
1736
1755
  '<span class="permission-actions-label" id="permission-actions-label">等待授权</span>' +
@@ -1740,7 +1759,9 @@
1740
1759
  renderApprovalStatsBadge() +
1741
1760
  '</div>' +
1742
1761
  '<div class="input-composer-right">' +
1743
- '<span id="queue-counter" class="queue-counter hidden">队列: 0</span>' +
1762
+ // queue-counter:当 queuedMessages 不为空时显示,提示用户后面还堆了几条。
1763
+ // 比小角标更显眼一点——加图标 + 强对比色,避免 v1.30.3 那一版用户没看见。
1764
+ '<span id="queue-counter" class="queue-counter hidden" title="正在排队的输入条数"><span class="queue-counter-dot"></span><span class="queue-counter-text">队列 0</span></span>' +
1744
1765
  '<span class="input-hint' + (state.terminalInteractive ? ' terminal-interactive-hint' : state.currentView === "terminal" ? " hidden" : "") + '">' + (state.terminalInteractive ? '终端交互中 · Ctrl+C 中断 · Ctrl+L 清屏' : 'Enter 发送 · Shift+Enter 换行') + '</span>' +
1745
1766
  renderInlineKeyboard() +
1746
1767
  '<button id="stop-button" class="btn-circle btn-circle-stop' + (state.selectedId ? "" : " hidden") + '" title="停止">' +
@@ -1748,8 +1769,10 @@
1748
1769
  '</button>' +
1749
1770
  // 结构化模式且正在出 token 时显示:中断当前回复、立刻发送新输入。
1750
1771
  // 默认走 #send-input-button → 排队;想插队的人显式按这颗。
1751
- '<button id="interrupt-send-button" class="btn-circle btn-circle-interrupt hidden" type="button" title="中断当前回复并立即发送" aria-label="立即发送">' +
1752
- '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>' +
1772
+ // pill 形态 + 文字 + 脉动,让用户一眼就看到「立即发送」这条快捷路径。
1773
+ '<button id="interrupt-send-button" class="btn-pill btn-pill-interrupt hidden" type="button" title="中断当前回复并立即发送新输入(Cmd/Ctrl+Enter)" aria-label="立即发送">' +
1774
+ '<svg class="btn-pill-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>' +
1775
+ '<span class="btn-pill-label">立即</span>' +
1753
1776
  '</button>' +
1754
1777
  '<button id="send-input-button" class="btn-circle btn-circle-send" title="发送">' +
1755
1778
  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>' +
@@ -1757,14 +1780,20 @@
1757
1780
  '</div>' +
1758
1781
  '</div>' +
1759
1782
  renderExpandedShortcutsRow() +
1760
- // Session info bar at bottom — only keeps unique controls/info
1761
- // (cwd / mode / status / kind are already shown in topbar or composer dropdown)
1783
+ // Session info bar at bottom — 仅保留信息类徽章(Claude session id / exit code)。
1784
+ // 自动批准已从这里移到主 pill 行(renderAutoApproveChip)。
1762
1785
  (selectedSession
1763
- ? '<div class="input-session-info-bar">' +
1764
- (selectedSession.autoApprovePermissions ? '<span id="auto-approve-toggle" class="auto-approve-indicator active" title="自动批准已启用 — 点击关闭">🛡 自动批准</span>' : '<span id="auto-approve-toggle" class="auto-approve-indicator" title="自动批准已关闭 — 点击开启">🛡 手动</span>') +
1765
- (selectedSession.provider === "claude" && selectedSession.claudeSessionId ? '<span class="session-info-separator">|</span><span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>' : '') +
1766
- (!isStructuredSession(selectedSession) && selectedSession.exitCode !== undefined && selectedSession.exitCode !== null ? '<span class="session-info-separator">|</span><span id="session-exit-display" class="session-exit-display">退出码=' + selectedSession.exitCode + '</span>' : '') +
1767
- '</div>'
1786
+ ? (function() {
1787
+ var bits = "";
1788
+ if (selectedSession.provider === "claude" && selectedSession.claudeSessionId) {
1789
+ bits += '<span id="claude-session-id-badge" class="claude-session-id-badge" data-claude-id="' + escapeHtml(selectedSession.claudeSessionId) + '" title="点击复制 Claude 会话 ID">☁ ' + escapeHtml(selectedSession.claudeSessionId.slice(0, 8)) + '</span>';
1790
+ }
1791
+ if (!isStructuredSession(selectedSession) && selectedSession.exitCode !== undefined && selectedSession.exitCode !== null) {
1792
+ if (bits) bits += '<span class="session-info-separator">|</span>';
1793
+ bits += '<span id="session-exit-display" class="session-exit-display">退出码=' + selectedSession.exitCode + '</span>';
1794
+ }
1795
+ return bits ? '<div class="input-session-info-bar">' + bits + '</div>' : '';
1796
+ })()
1768
1797
  : '') +
1769
1798
  '</div>' +
1770
1799
  '<p id="action-error" class="error-message hidden"></p>' +
@@ -5988,6 +6017,10 @@
5988
6017
  if (modelSelect) modelSelect.addEventListener("change", function() {
5989
6018
  onChatModelChange(this.value);
5990
6019
  });
6020
+ var thinkingSelect = document.getElementById("chat-thinking-select");
6021
+ if (thinkingSelect) thinkingSelect.addEventListener("change", function() {
6022
+ onChatThinkingChange(this.value);
6023
+ });
5991
6024
 
5992
6025
  var sessionModal = document.getElementById("session-modal");
5993
6026
  if (sessionModal) sessionModal.addEventListener("click", function(e) {
@@ -7996,7 +8029,7 @@
7996
8029
  // 结构化会话在出 token 时,输入框仍然可用——告诉用户默认行为是排队,
7997
8030
  // 想插队请按右侧的 » 按钮。短语保持单行不换行。
7998
8031
  if (isStructuredSession(session) && session.structuredState && session.structuredState.inFlight) {
7999
- return "回复中…继续输入将排队(» 立即发送)";
8032
+ return "回复中…Enter 排队 · 旁边的「» 立即」按钮中断并立即发送";
8000
8033
  }
8001
8034
  return "";
8002
8035
  }
@@ -8129,10 +8162,109 @@
8129
8162
 
8130
8163
  function syncComposerModelSelect(session) {
8131
8164
  var select = document.getElementById("chat-model-select");
8165
+ if (select) {
8166
+ var effective = getEffectiveModel(session);
8167
+ select.innerHTML = renderChatModelOptions(effective, session);
8168
+ select.value = effective;
8169
+ }
8170
+ // thinking 选择器与 model 选择器属于同一组"会话级设置",
8171
+ // 任何刷新 model 的时机也应该同步刷新 thinking,避免漂移。
8172
+ syncComposerThinkingSelect(session);
8173
+ }
8174
+
8175
+ // ── 思考深度 (thinkingEffort) —— 与 model 选择三件套对称 ──
8176
+
8177
+ // 标签直接用 Claude CLI 原生 magic word:think / think hard / ultrathink。
8178
+ // 这样用户一眼能对上官方文档里的思考强度档位,PTY 模式下也是这几个词被注入到 prompt 前缀。
8179
+ var THINKING_LEVELS = [
8180
+ { id: "off", label: "off", hint: "不启用思考(CLI 无前缀;SDK 关闭 thinking;Codex --reasoning-effort minimal)" },
8181
+ { id: "standard", label: "think", hint: "Claude CLI: think · SDK budget 4096 · Codex low" },
8182
+ { id: "deep", label: "think hard", hint: "Claude CLI: think hard · SDK budget 16000 · Codex medium" },
8183
+ { id: "max", label: "ultrathink", hint: "Claude CLI: ultrathink · SDK budget 31999 · Codex high" }
8184
+ ];
8185
+
8186
+ function getThinkingLabel(id) {
8187
+ for (var i = 0; i < THINKING_LEVELS.length; i++) {
8188
+ if (THINKING_LEVELS[i].id === id) return THINKING_LEVELS[i].label;
8189
+ }
8190
+ return THINKING_LEVELS[0].label;
8191
+ }
8192
+
8193
+ function getEffectiveThinking(session) {
8194
+ if (session && session.thinkingEffort) return session.thinkingEffort;
8195
+ if (state.chatThinking) return state.chatThinking;
8196
+ return "off";
8197
+ }
8198
+
8199
+ function renderChatThinkingOptions(selected) {
8200
+ var v = selected || "off";
8201
+ var html = "";
8202
+ for (var i = 0; i < THINKING_LEVELS.length; i++) {
8203
+ var lvl = THINKING_LEVELS[i];
8204
+ html += '<option value="' + escapeHtml(lvl.id) + '"' + (lvl.id === v ? ' selected' : '') + ' title="' + escapeHtml(lvl.hint) + '">' + escapeHtml(lvl.label) + '</option>';
8205
+ }
8206
+ return html;
8207
+ }
8208
+
8209
+ function syncComposerThinkingSelect(session) {
8210
+ var select = document.getElementById("chat-thinking-select");
8132
8211
  if (!select) return;
8133
- var effective = getEffectiveModel(session);
8134
- select.innerHTML = renderChatModelOptions(effective, session);
8212
+ var effective = getEffectiveThinking(session);
8213
+ select.innerHTML = renderChatThinkingOptions(effective);
8135
8214
  select.value = effective;
8215
+ var labelEl = document.getElementById("chat-thinking-label");
8216
+ if (labelEl) labelEl.textContent = getThinkingLabel(effective);
8217
+ }
8218
+
8219
+ function onChatThinkingChange(value) {
8220
+ var normalized = (value || "off").trim();
8221
+ if (normalized !== "off" && normalized !== "standard" && normalized !== "deep" && normalized !== "max") {
8222
+ normalized = "off";
8223
+ }
8224
+ state.chatThinking = normalized;
8225
+ try { localStorage.setItem("wand-thinking-effort", normalized); } catch (e) {}
8226
+ var labelEl = document.getElementById("chat-thinking-label");
8227
+ if (labelEl) labelEl.textContent = getThinkingLabel(normalized);
8228
+ var session = getSelectedSession();
8229
+ if (!session) return;
8230
+ fetch("/api/sessions/" + encodeURIComponent(session.id) + "/thinking-effort", {
8231
+ method: "POST",
8232
+ headers: { "Content-Type": "application/json" },
8233
+ credentials: "same-origin",
8234
+ body: JSON.stringify({ thinkingEffort: normalized })
8235
+ })
8236
+ .then(function(res) { return res.json(); })
8237
+ .then(function(data) {
8238
+ if (data && data.error) {
8239
+ showToast(data.error, "error");
8240
+ return;
8241
+ }
8242
+ if (data && data.id) {
8243
+ updateSessionSnapshot(data);
8244
+ if (typeof showToast === "function") {
8245
+ showToast("已切换思考深度 → " + getThinkingLabel(normalized), "success");
8246
+ }
8247
+ }
8248
+ })
8249
+ .catch(function() { showToast("切换思考深度失败", "error"); });
8250
+ }
8251
+
8252
+ // 自动批准 chip:与原 .auto-approve-indicator 等价,但用统一的 .composer-pill 风格放主行。
8253
+ // Codex 会话固定全权限不可切;结构化 Claude 会话后端 toggle-auto-approve 路由会拒绝。
8254
+ // 当会话已经处于 managed / full-access 模式时,"自动批准"语义已经由模式表达,
8255
+ // 重复显示一个独立 chip 只会占用空间又制造歧义 —— 此时直接折叠掉。
8256
+ function isAutoApproveImpliedByMode(session) {
8257
+ if (!session) return false;
8258
+ var m = session.mode;
8259
+ return m === "managed" || m === "full-access";
8260
+ }
8261
+ function renderAutoApproveChip(session) {
8262
+ if (!session) return "";
8263
+ if (isAutoApproveImpliedByMode(session)) return "";
8264
+ var enabled = !!session.autoApprovePermissions;
8265
+ return enabled
8266
+ ? '<span id="auto-approve-toggle" class="composer-pill composer-pill-chip auto-approve-indicator active" title="自动批准已启用 — 点击关闭">🛡 自动</span>'
8267
+ : '<span id="auto-approve-toggle" class="composer-pill composer-pill-chip auto-approve-indicator" title="自动批准已关闭 — 点击开启">🛡 手动</span>';
8136
8268
  }
8137
8269
 
8138
8270
  function fetchAvailableModels() {
@@ -8370,6 +8502,7 @@
8370
8502
  function createStructuredSession(prompt, cwdOverride, modeOverride, worktreeEnabled) {
8371
8503
  var provider = state.sessionTool === "codex" ? "codex" : "claude";
8372
8504
  var modelPref = state.chatModel || (state.config && state.config.defaultModel) || "";
8505
+ var thinkingPref = state.chatThinking || "off";
8373
8506
  var payload = {
8374
8507
  cwd: cwdOverride || getEffectiveCwd(),
8375
8508
  mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
@@ -8377,7 +8510,8 @@
8377
8510
  runner: provider === "codex" ? "codex-cli-exec" : ((state.config && state.config.structuredRunner === "sdk") ? "claude-sdk" : (state.structuredRunner || "claude-cli-print")),
8378
8511
  prompt: prompt || undefined,
8379
8512
  worktreeEnabled: worktreeEnabled === true,
8380
- model: modelPref || undefined
8513
+ model: modelPref || undefined,
8514
+ thinkingEffort: thinkingPref
8381
8515
  };
8382
8516
  console.log("[WAND] createStructuredSession payload:", JSON.stringify(payload));
8383
8517
  return fetch("/api/structured-sessions", {
@@ -11916,8 +12050,14 @@
11916
12050
  return Promise.resolve();
11917
12051
  }
11918
12052
 
11919
- // 防止同一会话并发提交(快速双击 / 重复触发)
11920
- var _structuredSubmittingSessions = {};
12053
+ // 防止同一会话「快速双击 / 重复触发」。原来这是个布尔 flag,绑在 fetch 的
12054
+ // promise —— 但 structured-sessions/:id/messages 的 POST 对首条消息会 await
12055
+ // 整段流式 streaming,flag 会被卡到回复完才释放。结果:用户点发送 → 服务端
12056
+ // 流式 30s 不响应 → 这 30s 里再点发送全被这里静默 drop,看起来"排队 / 立即发送
12057
+ // 都没效果"。改成时间戳 + 短窗口(350ms)只挡真正的连击。idempotencyKey 已经
12058
+ // 在后端兜底防 webview 网络层重发,这里的 hot-path 守门只需要应付 UI 双触发。
12059
+ var _structuredLastSubmitAt = {};
12060
+ var DUPLICATE_SUBMIT_WINDOW_MS = 350;
11921
12061
 
11922
12062
  function postStructuredInput(input, inputBox, session, opts) {
11923
12063
  opts = opts || {};
@@ -11931,11 +12071,15 @@
11931
12071
  showToast("会话不存在,请重新选择或新建会话。", "error");
11932
12072
  return Promise.resolve();
11933
12073
  }
11934
- // 同一会话的上一次提交尚未落地,直接忽略防止重复发送
11935
- if (_structuredSubmittingSessions[session.id]) {
11936
- console.log("[wand] postStructuredInput: duplicate submit ignored for session", session.id);
12074
+ // 短窗口内的连击当作重复点击丢掉;正常间隔的两次提交(哪怕第一次还在流式)
12075
+ // 都放行,让 queue / interrupt 真正生效。
12076
+ var nowTs = Date.now();
12077
+ var lastTs = _structuredLastSubmitAt[session.id] || 0;
12078
+ if (nowTs - lastTs < DUPLICATE_SUBMIT_WINDOW_MS) {
12079
+ console.log("[wand] postStructuredInput: duplicate submit (within " + DUPLICATE_SUBMIT_WINDOW_MS + "ms) ignored for session", session.id);
11937
12080
  return Promise.resolve();
11938
12081
  }
12082
+ _structuredLastSubmitAt[session.id] = nowTs;
11939
12083
 
11940
12084
  var sessionInFlight = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
11941
12085
  var isInterrupting = sessionInFlight && requestedInterrupt;
@@ -11960,6 +12104,9 @@
11960
12104
  updateInputHint("已加入排队,等待当前回复完成…");
11961
12105
  renderChat(true);
11962
12106
  updateStructuredQueueCounter();
12107
+ // 乐观 toast:原本只在 POST 完成后才提示,Claude 流式拖太久时用户根本
12108
+ // 看不到反馈,会误判"点了没反应"。点击瞬间就给一条短提示。
12109
+ showToast(nextQueue.length > 1 ? ("已加入排队(共 " + nextQueue.length + " 条等待)") : "已加入排队,等当前回复完成会自动发送。", "info");
11963
12110
  } else {
11964
12111
  // 普通发送 / interrupt 发送:照旧乐观推 user turn + inFlight=true
11965
12112
  var userTurn = { role: "user", content: [{ type: "text", text: input }] };
@@ -11978,6 +12125,11 @@
11978
12125
  }), userMsgs);
11979
12126
  updateInputHint(isInterrupting ? "已中断,正在处理新消息…" : "思考中…");
11980
12127
  renderChat(true);
12128
+ // 中断模式:乐观给一条提示,让用户立刻知道"中断成功了",否则跟 queue 一样会
12129
+ // 觉得"点了没反应"。原 toast 在 then() 里,等 SIGTERM/HTTP roundtrip 完才出。
12130
+ if (isInterrupting) {
12131
+ showToast("已中断上一条回复,正在处理新消息…", "info");
12132
+ }
11981
12133
  }
11982
12134
 
11983
12135
  if (inputBox) {
@@ -12001,7 +12153,6 @@
12001
12153
 
12002
12154
  // 用 session.id(参数绑定,in-flight 期间不变)而不是 state.selectedId
12003
12155
  // 拼 URL,避免用户切到别的会话后 fetch 落到错误 sessionId。
12004
- _structuredSubmittingSessions[session.id] = true;
12005
12156
  return fetch("/api/structured-sessions/" + session.id + "/messages", {
12006
12157
  method: "POST",
12007
12158
  headers: { "Content-Type": "application/json" },
@@ -12020,7 +12171,6 @@
12020
12171
  return res.json();
12021
12172
  })
12022
12173
  .then(function(snapshot) {
12023
- _structuredSubmittingSessions[session.id] = false;
12024
12174
  if (snapshot && snapshot.error) {
12025
12175
  throw new Error(snapshot.error);
12026
12176
  }
@@ -12035,18 +12185,12 @@
12035
12185
  state.currentMessages = buildMessagesForRender(refreshedSession, getPreferredMessages(refreshedSession, snapshot.output, false));
12036
12186
  renderChat(true);
12037
12187
  updateStructuredQueueCounter();
12038
- if (isInterrupting) {
12039
- showToast("已中断上一条回复,正在处理新消息…", "info");
12040
- } else if (isQueueing) {
12041
- var qLen = Array.isArray(refreshedSession.queuedMessages) ? refreshedSession.queuedMessages.length : 0;
12042
- showToast(qLen > 1 ? ("已加入排队(共 " + qLen + " 条等待)") : "已加入排队,等待当前回复完成。", "info");
12043
- }
12188
+ // toast 已在 click 时乐观 fire(见 isQueueing / isInterrupting 分支),
12189
+ // 这里不再重复推送,避免同一动作出两条一样的 toast。
12044
12190
  }
12045
12191
  }
12046
12192
  })
12047
12193
  .catch(function(error) {
12048
- _structuredSubmittingSessions[session.id] = false;
12049
-
12050
12194
  // duplicate_idempotency_key:服务端识别出 WebView 底层重发的副本,
12051
12195
  // 直接拦截不处理。这里**不**回滚乐观更新——第一次的请求实际上已经
12052
12196
  // 被服务端接收并处理(或正在处理),ws 推送会带回真实状态;如果在
@@ -12110,7 +12254,15 @@
12110
12254
  var counter = document.getElementById("queue-counter");
12111
12255
  var count = getSelectedStructuredQueuedInputs().length;
12112
12256
  if (counter) {
12113
- counter.textContent = "队列: " + count;
12257
+ // counter 现在是 dot + text 双节点结构,只更新文字节点;如果用 textContent
12258
+ // 直接覆盖会把内嵌的 .queue-counter-dot 也一并干掉,CSS 上做的脉动小红点就没了。
12259
+ var textNode = counter.querySelector(".queue-counter-text");
12260
+ var label = count > 0 ? ("排队 " + count + " 条") : "队列 0";
12261
+ if (textNode) {
12262
+ textNode.textContent = label;
12263
+ } else {
12264
+ counter.textContent = label;
12265
+ }
12114
12266
  if (count > 0) {
12115
12267
  counter.classList.remove("hidden");
12116
12268
  } else {
@@ -13087,7 +13239,8 @@
13087
13239
  command: command,
13088
13240
  cwd: cwd || "",
13089
13241
  mode: state.chatMode || state.config.defaultMode || "default",
13090
- model: modelPref || undefined
13242
+ model: modelPref || undefined,
13243
+ thinkingEffort: state.chatThinking || undefined
13091
13244
  }))
13092
13245
  })
13093
13246
  .then(function(res) { return res.json(); })
@@ -13229,7 +13382,8 @@
13229
13382
  cwd: defaultCwd,
13230
13383
  mode: mode,
13231
13384
  initialInput: value,
13232
- model: modelPref || undefined
13385
+ model: modelPref || undefined,
13386
+ thinkingEffort: state.chatThinking || undefined
13233
13387
  }))
13234
13388
  })
13235
13389
  .then(function(res) { return res.json(); })
@@ -13267,7 +13421,8 @@
13267
13421
  cwd: defaultCwd,
13268
13422
  mode: mode,
13269
13423
  initialInput: value || undefined,
13270
- model: modelPref || undefined
13424
+ model: modelPref || undefined,
13425
+ thinkingEffort: state.chatThinking || undefined
13271
13426
  }))
13272
13427
  })
13273
13428
  .then(function(res) { return res.json(); })
@@ -14183,7 +14338,27 @@
14183
14338
  // Without an immediate refit, any chunk arriving while the keyboard
14184
14339
  // animates in renders against the old grid and tears the screen.
14185
14340
  if (!keyboardOpen && isKeyboardOpen) {
14341
+ // Snapshot bottom-pinned intent BEFORE the layout starts shifting.
14342
+ // visualViewport.resize fires while the keyboard is still mid-
14343
+ // animation on iOS; if we wait until ensureTerminalFit's rAF
14344
+ // body executes, clientHeight has already shrunk and the user
14345
+ // who was visibly at the bottom registers as "scrolled up".
14346
+ // We pass the snapshot through to a delayed catch-up so the
14347
+ // final scroll lands AFTER the animation settles.
14348
+ var wasStickToBottom = state.terminalAutoFollow || isTerminalNearBottom();
14186
14349
  ensureTerminalFit("keyboard-open", { forceReplay: true });
14350
+ // Mirror the keyboard-close 200ms delay: by then the iOS / Android
14351
+ // keyboard slide-in animation is done, vv.height is final, and
14352
+ // scrollHeight reflects the post-replay grid. One more force
14353
+ // scroll closes the gap between "we scrolled during animation
14354
+ // when scrollHeight was still in flux" and "user expects to see
14355
+ // the bottom now that the keyboard has fully settled".
14356
+ if (wasStickToBottom) {
14357
+ setTimeout(function() {
14358
+ if (!state.terminal) return;
14359
+ maybeScrollTerminalToBottom("force");
14360
+ }, 220);
14361
+ }
14187
14362
  }
14188
14363
 
14189
14364
  // Keyboard just closed — force terminal refit and scroll to bottom
@@ -14552,6 +14727,21 @@
14552
14727
  ensureTerminalFitWithRetry(reason || "fit-retry", { forceReplay: forceReplay });
14553
14728
  return false;
14554
14729
  }
14730
+ // Snapshot stick-to-bottom intent NOW, before any layout work.
14731
+ // Two concrete bugs this guards against:
14732
+ // 1. Mobile keyboard opens → visualViewport shrinks → terminal
14733
+ // clientHeight drops while scrollTop stays put → by the time
14734
+ // the rAF body runs, isTerminalNearBottom() reads false even
14735
+ // though the user was visibly pinned to the bottom a frame ago.
14736
+ // 2. Any softResyncTerminal triggered below does resetTerminal()
14737
+ // (scrollTop snaps to 0) then re-writes; the wterm element
14738
+ // can fire intermediate scroll events that flip
14739
+ // terminalAutoFollow to false before we get a chance to
14740
+ // scroll back.
14741
+ // Both failure modes left users mid-buffer after a resize. We
14742
+ // capture the intent up front and use "force" below to bypass
14743
+ // the (now-poisoned) flag check inside maybeScrollTerminalToBottom.
14744
+ var shouldStickToBottom = state.terminalAutoFollow || isTerminalNearBottom();
14555
14745
  var prevCols = state.terminal.cols;
14556
14746
  var prevRows = state.terminal.rows;
14557
14747
  requestAnimationFrame(function() {
@@ -14571,8 +14761,8 @@
14571
14761
  if (!didResize && forceReplay && state.terminalOutput) {
14572
14762
  softResyncTerminal({ skipFit: true });
14573
14763
  }
14574
- if (state.terminalAutoFollow || isTerminalNearBottom()) {
14575
- maybeScrollTerminalToBottom("resize");
14764
+ if (shouldStickToBottom) {
14765
+ maybeScrollTerminalToBottom("force");
14576
14766
  }
14577
14767
  });
14578
14768
  });
@@ -14629,9 +14819,15 @@
14629
14819
 
14630
14820
  function syncTerminalSize() {
14631
14821
  if (!state.terminal) return;
14632
- var shouldFollow = state.terminalAutoFollow || isTerminalNearBottom();
14633
- if (shouldFollow) {
14634
- maybeScrollTerminalToBottom("resize");
14822
+ // Force-scroll (vs the weaker maybeScrollTerminalToBottom("resize"))
14823
+ // for the same reason ensureTerminalFit does: between this entry
14824
+ // and the actual scroll, the wterm DOM may fire scroll events that
14825
+ // poison terminalAutoFollow. Capturing intent now + force-scrolling
14826
+ // keeps a user who was visibly at the bottom pinned there across
14827
+ // window/orientation/viewport resizes.
14828
+ var shouldStickToBottom = state.terminalAutoFollow || isTerminalNearBottom();
14829
+ if (shouldStickToBottom) {
14830
+ maybeScrollTerminalToBottom("force");
14635
14831
  }
14636
14832
  sendTerminalResize(state.terminal.cols, state.terminal.rows);
14637
14833
  }
@@ -15427,21 +15623,22 @@
15427
15623
 
15428
15624
  function updateAutoApproveIndicator() {
15429
15625
  var toggle = document.getElementById("auto-approve-toggle");
15430
- if (!toggle) return;
15431
15626
  var selectedSession = state.sessions.find(function(s) { return s.id === state.selectedId; });
15432
- if (selectedSession && selectedSession.provider === "codex") {
15433
- toggle.className = "auto-approve-indicator active";
15434
- toggle.title = "Codex 固定以 full-access PTY 启动,不支持切换自动批准";
15435
- toggle.textContent = "🛡 Codex 固定全权限";
15627
+ // 当模式(managed / full-access)已隐含自动批准,chip 应该不存在;如果上一次渲染留下来了,
15628
+ // 在这里清理掉,避免视觉上还有冗余 chip。
15629
+ if (isAutoApproveImpliedByMode(selectedSession)) {
15630
+ if (toggle && toggle.parentNode) toggle.parentNode.removeChild(toggle);
15436
15631
  return;
15437
15632
  }
15633
+ if (!toggle) return;
15634
+ var base = "composer-pill composer-pill-chip auto-approve-indicator";
15438
15635
  var enabled = selectedSession && selectedSession.autoApprovePermissions;
15439
15636
  if (enabled) {
15440
- toggle.className = "auto-approve-indicator active";
15637
+ toggle.className = base + " active";
15441
15638
  toggle.title = "自动批准已启用 — 点击关闭";
15442
- toggle.textContent = "🛡 自动批准";
15639
+ toggle.textContent = "🛡 自动";
15443
15640
  } else {
15444
- toggle.className = "auto-approve-indicator";
15641
+ toggle.className = base;
15445
15642
  toggle.title = "自动批准已关闭 — 点击开启";
15446
15643
  toggle.textContent = "🛡 手动";
15447
15644
  }