@co0ontty/wand 1.21.4 → 1.21.7

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.
@@ -1277,7 +1277,7 @@
1277
1277
  '<button id="sidebar-pin-btn" class="btn btn-ghost btn-sm sidebar-pin-toggle' + (state.sidebarPinned ? ' pinned' : '') + '" type="button" title="' + (state.sidebarPinned ? '取消固定侧栏' : '固定侧栏') + '">' +
1278
1278
  '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24z"/></svg>' +
1279
1279
  '</button>' +
1280
- '<button id="close-drawer-button" class="btn btn-ghost btn-sm sidebar-close" type="button" aria-label="关闭菜单">×</button>' +
1280
+ '<button id="close-drawer-button" class="btn btn-ghost btn-icon sidebar-close drawer-close-btn" type="button" aria-label="关闭菜单"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1281
1281
  '</div>' +
1282
1282
  '</div>' +
1283
1283
  '<div class="sidebar-body">' +
@@ -1467,19 +1467,15 @@
1467
1467
  '</div>' +
1468
1468
  '</div>' +
1469
1469
  renderExpandedShortcutsRow() +
1470
- // Session info bar at bottom
1471
- '<div class="input-session-info-bar">' +
1472
- '<span id="session-cwd-display" class="session-cwd-display">' + (selectedSession && selectedSession.cwd ? escapeHtml(selectedSession.cwd) : '未设置目录') + '</span>' +
1473
- '<span class="session-info-separator">|</span>' +
1474
- '<span id="session-mode-display" class="session-mode-display">' + (selectedSession ? getModeLabel(selectedSession.mode) : '默认') + '</span>' +
1475
- (selectedSession && selectedSession.autoApprovePermissions ? '<span class="session-info-separator">|</span><span id="auto-approve-toggle" class="auto-approve-indicator active" title="自动批准已启用 点击关闭">🛡 自动批准</span>' : '<span class="session-info-separator">|</span><span id="auto-approve-toggle" class="auto-approve-indicator" title="自动批准已关闭 点击开启">🛡 手动</span>') +
1476
- '<span class="session-info-separator">|</span>' +
1477
- '<span id="session-kind-display" class="session-kind-display">' + (selectedSession ? getSessionKindLabel(selectedSession) : '终端') + '</span>' +
1478
- '<span class="session-info-separator">|</span>' +
1479
- '<span id="session-status-display" class="session-status-display">' + (selectedSession ? getSessionStatusLabel(selectedSession) : '-') + '</span>' +
1480
- (selectedSession && 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>' : '') +
1481
- (selectedSession && !isStructuredSession(selectedSession) ? '<span class="session-info-separator">|</span><span id="session-exit-display" class="session-exit-display">退出码=' + (selectedSession.exitCode !== undefined ? selectedSession.exitCode : 'n/a') + '</span>' : '') +
1482
- '</div>' +
1470
+ // Session info bar at bottom — only keeps unique controls/info
1471
+ // (cwd / mode / status / kind are already shown in topbar or composer dropdown)
1472
+ (selectedSession
1473
+ ? '<div class="input-session-info-bar">' +
1474
+ (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>') +
1475
+ (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>' : '') +
1476
+ (!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>' : '') +
1477
+ '</div>'
1478
+ : '') +
1483
1479
  '</div>' +
1484
1480
  '<p id="action-error" class="error-message hidden"></p>' +
1485
1481
  '</div>' +
@@ -1488,7 +1484,7 @@
1488
1484
  '<div class="modal folder-picker-modal">' +
1489
1485
  '<div class="modal-header">' +
1490
1486
  '<h2 class="modal-title">选择工作目录</h2>' +
1491
- '<button id="close-folder-picker" class="btn btn-ghost btn-icon">×</button>' +
1487
+ '<button id="close-folder-picker" class="btn btn-ghost btn-icon modal-close-btn" type="button" aria-label="关闭"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1492
1488
  '</div>' +
1493
1489
  '<div class="modal-body">' +
1494
1490
  '<div class="folder-picker-quick-row">' +
@@ -1794,7 +1790,7 @@
1794
1790
  '<h2 id="quick-commit-title" class="modal-title">快捷提交</h2>' +
1795
1791
  '<p class="modal-subtitle">' + escapeHtml((s.branch || "(no branch)") + ' · ' + (s.modifiedCount || 0) + ' 个改动') + '</p>' +
1796
1792
  '</div>' +
1797
- '<button id="quick-commit-close-btn" class="btn btn-ghost btn-icon" type="button" aria-label="关闭">&times;</button>' +
1793
+ '<button id="quick-commit-close-btn" class="btn btn-ghost btn-icon modal-close-btn" type="button" aria-label="关闭"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1798
1794
  '</div>' +
1799
1795
  '<div class="modal-body">' +
1800
1796
  '<div class="qc-files-wrap">' + fileRows + '</div>' +
@@ -1804,17 +1800,23 @@
1804
1800
  '</div>' +
1805
1801
  '<textarea id="quick-commit-message" class="field-input" rows="2" placeholder="输入 commit message 或点击 AI 生成">' + escapeHtml(f.customMessage || "") + '</textarea>' +
1806
1802
  '</div>' +
1807
- '<label class="qc-checkbox-row">' +
1808
- '<input type="checkbox" id="quick-commit-make-tag"' + (f.makeTag ? ' checked' : '') + '>' +
1809
- '<span>提交后打 tag' + (s.latestTag ? '(当前:' + escapeHtml(s.latestTag) + ')' : '') + '</span>' +
1810
- '</label>' +
1803
+ '<div class="qc-checkbox-row">' +
1804
+ '<label class="qc-checkbox-label" for="quick-commit-make-tag">提交后打 tag' + (s.latestTag ? '(当前:' + escapeHtml(s.latestTag) + ')' : '') + '</label>' +
1805
+ '<label class="qc-switch">' +
1806
+ '<input type="checkbox" id="quick-commit-make-tag" class="switch-toggle"' + (f.makeTag ? ' checked' : '') + '>' +
1807
+ '<span class="switch-slider"></span>' +
1808
+ '</label>' +
1809
+ '</div>' +
1811
1810
  '<div class="qc-tag-row' + (f.makeTag ? '' : ' hidden') + '" id="quick-commit-tag-row">' +
1812
1811
  '<input type="text" id="quick-commit-tag" class="field-input" placeholder="留空自动 bump patch' + (s.suggestedNextTag ? '(如 ' + escapeHtml(s.suggestedNextTag) + ')' : '') + '" value="' + escapeHtml(f.tag || "") + '">' +
1813
1812
  '</div>' +
1814
- '<label class="qc-checkbox-row">' +
1815
- '<input type="checkbox" id="quick-commit-push"' + (f.push ? ' checked' : '') + '>' +
1816
- '<span>提交后 push 到远端</span>' +
1817
- '</label>' +
1813
+ '<div class="qc-checkbox-row">' +
1814
+ '<label class="qc-checkbox-label" for="quick-commit-push">提交后 push 到远端</label>' +
1815
+ '<label class="qc-switch">' +
1816
+ '<input type="checkbox" id="quick-commit-push" class="switch-toggle"' + (f.push ? ' checked' : '') + '>' +
1817
+ '<span class="switch-slider"></span>' +
1818
+ '</label>' +
1819
+ '</div>' +
1818
1820
  '<p id="quick-commit-error" class="error-message' + (state.quickCommitError ? '' : ' hidden') + '">' + escapeHtml(state.quickCommitError || "") + '</p>' +
1819
1821
  '<div class="worktree-merge-actions">' +
1820
1822
  '<button id="quick-commit-cancel-btn" class="btn btn-secondary" type="button">取消</button>' +
@@ -1833,7 +1835,7 @@
1833
1835
  '<h2 class="modal-title">合并 Worktree</h2>' +
1834
1836
  '<p class="modal-subtitle">检查当前任务分支并快捷合并到主分支。</p>' +
1835
1837
  '</div>' +
1836
- '<button id="close-worktree-merge-button" class="btn btn-ghost btn-icon">&times;</button>' +
1838
+ '<button id="close-worktree-merge-button" class="btn btn-ghost btn-icon modal-close-btn" type="button" aria-label="关闭"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1837
1839
  '</div>' +
1838
1840
  '<div class="modal-body">' +
1839
1841
  '<div id="worktree-merge-content" class="worktree-merge-content"></div>' +
@@ -1855,7 +1857,7 @@
1855
1857
  '<h2 class="modal-title">设置</h2>' +
1856
1858
  '<p class="settings-modal-subtitle">调整应用配置、通知、安全和显示偏好</p>' +
1857
1859
  '</div>' +
1858
- '<button id="close-settings-button" class="btn btn-ghost btn-icon">×</button>' +
1860
+ '<button id="close-settings-button" class="btn btn-ghost btn-icon modal-close-btn" type="button" aria-label="关闭"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
1859
1861
  '</div>' +
1860
1862
  '<div class="modal-body settings-modal-body">' +
1861
1863
  '<div class="settings-layout">' +
@@ -2067,10 +2069,10 @@
2067
2069
  '<span class="settings-value" id="notification-permission-status">-</span>' +
2068
2070
  '</div>' +
2069
2071
  '<div class="settings-update-actions">' +
2070
- '<button id="notification-request-btn" class="btn btn-secondary btn-sm hidden" type="button">授权通知</button>' +
2071
- '<button id="notification-reset-btn" class="btn btn-secondary btn-sm hidden" type="button">重新授权</button>' +
2072
+ '<button id="notification-request-btn" class="btn btn-primary btn-sm hidden" type="button">授权通知</button>' +
2073
+ '<button id="notification-reset-btn" class="btn btn-ghost btn-sm hidden" type="button">重新授权</button>' +
2072
2074
  '<button id="notification-test-btn" class="btn btn-secondary btn-sm" type="button">发送测试通知</button>' +
2073
- '<button id="notification-test-delay-btn" class="btn btn-secondary btn-sm" type="button">10 秒后发送</button>' +
2075
+ '<button id="notification-test-delay-btn" class="btn btn-ghost btn-sm" type="button">10 秒后发送</button>' +
2074
2076
  '</div>' +
2075
2077
  '<p id="notification-test-message" class="hint hidden"></p>' +
2076
2078
  '</div>' +
@@ -2092,9 +2094,15 @@
2092
2094
  '<input id="cfg-port" type="number" class="field-input" placeholder="8443" min="1" max="65535" />' +
2093
2095
  '</div>' +
2094
2096
  '</div>' +
2095
- '<div class="field field-inline">' +
2096
- '<input id="cfg-https" type="checkbox" class="field-checkbox" />' +
2097
- '<label class="field-label" for="cfg-https">启用 HTTPS</label>' +
2097
+ '<div class="settings-toggle-row">' +
2098
+ '<div class="settings-toggle-text">' +
2099
+ '<label class="settings-toggle-title" for="cfg-https">启用 HTTPS</label>' +
2100
+ '<span class="settings-toggle-desc">使用自签名证书加密浏览器到服务的连接,host 为非 127.0.0.1 时建议开启。</span>' +
2101
+ '</div>' +
2102
+ '<label class="settings-switch">' +
2103
+ '<input id="cfg-https" type="checkbox" class="switch-toggle" />' +
2104
+ '<span class="switch-slider"></span>' +
2105
+ '</label>' +
2098
2106
  '</div>' +
2099
2107
  '<div class="field-row">' +
2100
2108
  '<div class="field">' +
@@ -2126,6 +2134,14 @@
2126
2134
  '</div>' +
2127
2135
  '</div>' +
2128
2136
  '<p class="field-hint" style="margin-top:-4px;">设置回复语言后,Claude 将尽量使用指定语言回复。</p>' +
2137
+ '<div class="field">' +
2138
+ '<label class="field-label" for="cfg-structured-runner">结构化会话 Runner</label>' +
2139
+ '<select id="cfg-structured-runner" class="field-input">' +
2140
+ '<option value="sdk">SDK(@anthropic-ai/claude-agent-sdk,默认)</option>' +
2141
+ '<option value="cli">CLI(spawn claude -p)</option>' +
2142
+ '</select>' +
2143
+ '<p class="field-hint" style="margin-top:4px;">SDK 模式使用官方 Agent SDK 替代 CLI subprocess,接口更整洁,功能等价。保存后对新建会话立即生效。</p>' +
2144
+ '</div>' +
2129
2145
  '<div class="field">' +
2130
2146
  '<label class="field-label" for="cfg-default-model">默认模型</label>' +
2131
2147
  '<div class="settings-row-with-action">' +
@@ -2342,7 +2358,10 @@
2342
2358
  if (!state.sessionsManageMode) {
2343
2359
  return '<div class="session-manage-bar">' +
2344
2360
  '<span class="sidebar-intro">最近的会话记录</span>' +
2345
- '<button class="session-manage-toggle" data-action="toggle-manage-mode" type="button">管理</button>' +
2361
+ '<button class="btn btn-ghost btn-xs session-manage-toggle" data-action="toggle-manage-mode" type="button">' +
2362
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>' +
2363
+ '<span>管理</span>' +
2364
+ '</button>' +
2346
2365
  '</div>';
2347
2366
  }
2348
2367
 
@@ -2356,13 +2375,19 @@
2356
2375
  var selectAllAction = allSelected ? "clear-selection" : "select-all-visible";
2357
2376
  var selectAllDisabled = selectable === 0 ? ' disabled' : '';
2358
2377
 
2378
+ // Linear-style toolbar:
2379
+ // [N selected] ───────── [全选] [清空] [delete (danger)] [完成 (primary)]
2359
2380
  return '<div class="session-manage-bar active">' +
2360
- '<div class="session-manage-summary">已选择 ' + totalCount + ' 项</div>' +
2381
+ '<div class="session-manage-summary">' +
2382
+ '<span class="session-manage-count">' + totalCount + '</span>' +
2383
+ '<span class="session-manage-summary-label">已选择</span>' +
2384
+ '</div>' +
2361
2385
  '<div class="session-manage-actions">' +
2362
- '<button class="session-manage-btn" data-action="' + selectAllAction + '" type="button"' + selectAllDisabled + '>' + selectAllLabel + '</button>' +
2363
- '<button class="session-manage-btn" data-action="clear-selection" type="button"' + (hasAny ? '' : ' disabled') + '>清空</button>' +
2364
- '<button class="session-manage-btn danger" data-action="delete-selected" type="button"' + (hasAny ? '' : ' disabled') + '>删除所选</button>' +
2365
- '<button class="session-manage-btn" data-action="toggle-manage-mode" type="button">完成</button>' +
2386
+ '<button class="btn btn-ghost btn-xs" data-action="' + selectAllAction + '" type="button"' + selectAllDisabled + '>' + selectAllLabel + '</button>' +
2387
+ '<button class="btn btn-ghost btn-xs" data-action="clear-selection" type="button"' + (hasAny ? '' : ' disabled') + '>清空</button>' +
2388
+ '<span class="session-manage-divider"></span>' +
2389
+ '<button class="btn btn-danger btn-xs" data-action="delete-selected" type="button"' + (hasAny ? '' : ' disabled') + '>删除</button>' +
2390
+ '<button class="btn btn-primary btn-xs" data-action="toggle-manage-mode" type="button">完成</button>' +
2366
2391
  '</div>' +
2367
2392
  '</div>';
2368
2393
  }
@@ -2408,7 +2433,7 @@
2408
2433
  ? ' <span class="history-count">' + visibleHistory.length + '</span>'
2409
2434
  : '';
2410
2435
  var clearAllButton = state.claudeHistoryExpanded && state.claudeHistoryLoaded && visibleHistory.length > 0
2411
- ? '<button class="session-manage-btn danger compact" data-action="clear-all-history" type="button">清空历史</button>'
2436
+ ? '<button class="btn btn-danger btn-xs session-history-clear" data-action="clear-all-history" type="button">清空</button>'
2412
2437
  : '';
2413
2438
  var header = '<div class="session-group-title claude-history-toggle" id="claude-history-toggle">' +
2414
2439
  '<span class="chevron">' + chevron + '</span> Claude 历史' + countBadge +
@@ -2618,7 +2643,7 @@
2618
2643
  '<div class="session-group-title claude-history-directory-title">' +
2619
2644
  '<span class="chevron">' + chevron + '</span>' +
2620
2645
  '<span class="claude-history-directory-label">' + escapeHtml(cwdShort) + ' (' + count + ')</span>' +
2621
- '<button class="session-manage-btn danger compact claude-history-directory-clear-btn" data-action="delete-history-directory" data-cwd="' +
2646
+ '<button class="btn btn-danger btn-xs claude-history-directory-clear-btn" data-action="delete-history-directory" data-cwd="' +
2622
2647
  escapeHtml(cwd) + '" type="button" aria-label="清空此目录的历史会话" title="清空此目录的历史会话">清空此目录</button>' +
2623
2648
  '</div>' +
2624
2649
  '</div>';
@@ -3093,11 +3118,14 @@
3093
3118
  // Code blocks with syntax highlighting
3094
3119
  escaped = escaped.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
3095
3120
  var highlighted = highlightCodePreview(code.trim(), lang);
3096
- return '<pre><code class="language-' + lang + '">' + highlighted + '</code></pre>';
3121
+ var protectedHighlighted = highlighted.replace(/_/g, '&#95;').replace(/\*/g, '&#42;');
3122
+ return '<pre><code class="language-' + lang + '">' + protectedHighlighted + '</code></pre>';
3097
3123
  });
3098
3124
 
3099
3125
  // Inline code
3100
- escaped = escaped.replace(/`([^`]+)`/g, '<code>$1</code>');
3126
+ escaped = escaped.replace(/`([^`]+)`/g, function(_, code) {
3127
+ return '<code>' + code.replace(/_/g, '&#95;').replace(/\*/g, '&#42;') + '</code>';
3128
+ });
3101
3129
 
3102
3130
  // Headers
3103
3131
  escaped = escaped.replace(/^######\s+(.*)$/gm, '<h6>$1</h6>');
@@ -3111,9 +3139,9 @@
3111
3139
  escaped = escaped.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
3112
3140
  escaped = escaped.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
3113
3141
  escaped = escaped.replace(/\*(.+?)\*/g, '<em>$1</em>');
3114
- escaped = escaped.replace(/___(.+?)___/g, '<strong><em>$1</em></strong>');
3115
- escaped = escaped.replace(/__(.+?)__/g, '<strong>$1</strong>');
3116
- escaped = escaped.replace(/_(.+?)_/g, '<em>$1</em>');
3142
+ escaped = escaped.replace(/(^|[^\w])___(\S(?:[^\n]*?\S)?)___(?!\w)/g, '$1<strong><em>$2</em></strong>');
3143
+ escaped = escaped.replace(/(^|[^\w])__(\S(?:[^\n]*?\S)?)__(?!\w)/g, '$1<strong>$2</strong>');
3144
+ escaped = escaped.replace(/(^|[^\w])_(\S(?:[^\n_]*?\S)?)_(?!\w)/g, '$1<em>$2</em>');
3117
3145
 
3118
3146
  // Strikethrough
3119
3147
  escaped = escaped.replace(/~~(.+?)~~/g, '<del>$1</del>');
@@ -3450,9 +3478,17 @@
3450
3478
  }
3451
3479
 
3452
3480
  function renderWorktreeToggle(enabled) {
3453
- return '<label class="session-inline-toggle" for="session-worktree-toggle">' +
3454
- '<input id="session-worktree-toggle" type="checkbox" class="field-checkbox"' + (enabled ? ' checked' : '') + ' />' +
3481
+ return '<label class="session-inline-toggle" for="session-worktree-toggle" title="为该会话创建独立的 git worktree 分支">' +
3482
+ '<svg class="session-inline-toggle-icon" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
3483
+ '<circle cx="6" cy="6" r="2.2"/>' +
3484
+ '<circle cx="18" cy="6" r="2.2"/>' +
3485
+ '<circle cx="12" cy="18" r="2.2"/>' +
3486
+ '<path d="M6 8.2v3.4a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8.2"/>' +
3487
+ '<path d="M12 13.6v2.2"/>' +
3488
+ '</svg>' +
3455
3489
  '<span class="session-inline-toggle-label">Worktree 模式</span>' +
3490
+ '<input id="session-worktree-toggle" type="checkbox" class="switch-toggle"' + (enabled ? ' checked' : '') + ' />' +
3491
+ '<span class="switch-slider" aria-hidden="true"></span>' +
3456
3492
  '</label>';
3457
3493
  }
3458
3494
 
@@ -3481,7 +3517,7 @@
3481
3517
  '<h2 class="modal-title">新对话</h2>' +
3482
3518
  '<p class="modal-subtitle">启动 Claude 或 Codex 会话,选择 provider、会话类型、模式和工作目录。</p>' +
3483
3519
  '</div>' +
3484
- '<button id="close-modal-button" class="btn btn-ghost btn-icon">&times;</button>' +
3520
+ '<button id="close-modal-button" class="btn btn-ghost btn-icon modal-close-btn" type="button" aria-label="关闭"><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><line x1="6" y1="6" x2="18" y2="18"/><line x1="18" y1="6" x2="6" y2="18"/></svg></button>' +
3485
3521
  '</div>' +
3486
3522
  '<div class="modal-body">' +
3487
3523
  '<div class="field">' +
@@ -4028,9 +4064,21 @@
4028
4064
  // Volume slider
4029
4065
  var notifVolumeEl = document.getElementById("cfg-notif-volume");
4030
4066
  var notifVolumeVal = document.getElementById("cfg-notif-volume-val");
4067
+ // Helper to keep the iOS-style range fill in sync with the input value
4068
+ var _syncRangeFill = function(el) {
4069
+ if (!el) return;
4070
+ var minVal = Number(el.min || 0);
4071
+ var maxVal = Number(el.max || 100);
4072
+ var curVal = Number(el.value || 0);
4073
+ var pct = maxVal > minVal
4074
+ ? Math.max(0, Math.min(100, ((curVal - minVal) / (maxVal - minVal)) * 100))
4075
+ : 0;
4076
+ el.style.setProperty("--range-fill", pct + "%");
4077
+ };
4031
4078
  if (notifVolumeEl) {
4032
4079
  notifVolumeEl.value = state.notifVolume;
4033
4080
  if (notifVolumeVal) notifVolumeVal.textContent = state.notifVolume + "%";
4081
+ _syncRangeFill(notifVolumeEl);
4034
4082
  // Hide if sound is off
4035
4083
  var volField = document.getElementById("notif-volume-field");
4036
4084
  if (volField) volField.style.display = state.notifSound ? "" : "none";
@@ -4038,6 +4086,7 @@
4038
4086
  notifVolumeEl.addEventListener("input", function() {
4039
4087
  state.notifVolume = parseInt(notifVolumeEl.value, 10);
4040
4088
  if (notifVolumeVal) notifVolumeVal.textContent = state.notifVolume + "%";
4089
+ _syncRangeFill(notifVolumeEl);
4041
4090
  try { localStorage.setItem("wand-notif-volume", String(state.notifVolume)); } catch (e) {}
4042
4091
  // Also sync to native bridge if available
4043
4092
  if (_hasNativeBridge && typeof WandNative.setNotificationVolume === "function") {
@@ -5413,40 +5462,17 @@
5413
5462
  }
5414
5463
  stripWideFillerForCopy();
5415
5464
 
5416
- // ── PTY 链路时序参数索引 ──────────────────────────────────────────
5417
- // 本文件中所有影响 PTY 输入/输出节流的常量集中说明(值仍在使用处定义,
5418
- // 方便阅读上下文,但请保持一致命名以便此索引可 grep 跳转):
5419
- //
5420
- // 服务端(src/ws-broadcast.ts、src/process-manager.ts):
5421
- // OUTPUT_DEBOUNCE_MS = 16 PTY data → ws 推送 debounce
5422
- // OUTPUT_MAX_SIZE = 200_000 record.output ring buffer 字节上限
5423
- //
5424
- // 客户端(本文件):
5425
- // CLIENT_OUTPUT_MAX / CLIENT_OUTPUT_TRIM_AT state.terminalOutput 窗口
5426
- // RESYNC_THROTTLE_MS = 400 chunk → softResync 节流最小间隔
5427
- // RESYNC_TAIL_MS = 350 节流尾巴 timer 等待
5428
- // RESYNC_BUDGET_* 5s 内 resync 频次告警阈值
5429
- // CHAT_RENDER_LIVE_MS = 150 活跃流时 renderChat debounce
5430
- // CHAT_RENDER_IDLE_MS = 30 空闲时 renderChat debounce
5431
- // PENDING_INPUT_TTL_MS = 5000 ws 离线输入队列 TTL
5432
- // PENDING_INPUT_MAX = 100 离线队列长度上限
5433
- //
5434
- // 调参时的关键不变式:
5435
- // OUTPUT_DEBOUNCE_MS < CHAT_RENDER_IDLE_MS ≤ CHAT_RENDER_LIVE_MS
5436
- // RESYNC_TAIL_MS ≤ RESYNC_THROTTLE_MS
5437
- // 否则会出现"上游推得比下游消化得快但下游 timer 还没到期"的堵塞。
5465
+ // PTY 链路节流不变式:
5466
+ // 服务端 OUTPUT_DEBOUNCE_MS < CHAT_RENDER_IDLE_MS ≤ CHAT_RENDER_LIVE_MS
5467
+ // RESYNC_TAIL_MS ≤ RESYNC_THROTTLE_MS
5468
+ // 违反这两条会出现"上游推得比下游消化得快但下游 timer 还没到期"的堵塞。
5438
5469
  var CHAT_RENDER_LIVE_MS = 150;
5439
5470
  var CHAT_RENDER_IDLE_MS = 30;
5440
5471
 
5441
- // 客户端的 state.terminalOutput 仅用于 softResyncTerminal 时重放给
5442
- // wterm 作状态恢复,并不是用户能看到的 scrollback —— wterm 自己有
5443
- // 独立 scrollback。但如果不限长,长跑会话累加几 MB 后每次 resync
5444
- // 都会把整段重新喂给 wterm,CPU/内存随时间线性变差。
5445
- //
5446
- // 服务端 record.output 用 appendWindow(..., 200_000) 限了 200KB,这里
5447
- // 客户端给一个稍宽的上限做兜底;超过就按行边界裁掉头部,行边界处
5448
- // ANSI 状态机一定是 idle 状态,重放结果与未裁等价。找不到行边界时
5449
- // 退化到字节切,并避开 UTF-16 半截、ANSI 半截。
5472
+ // state.terminalOutput 仅作 softResyncTerminal 的重放源(wterm 有自己的
5473
+ // scrollback),所以必须限长,否则长跑会话每次 resync 都喂几 MB wterm
5474
+ // 裁切优先在行边界(ANSI 状态机此时一定 idle,重放等价),找不到再按字节切
5475
+ // 并避开 UTF-16 半截 / ANSI 半截。
5450
5476
  var CLIENT_OUTPUT_MAX = 256 * 1024;
5451
5477
  var CLIENT_OUTPUT_TRIM_AT = 320 * 1024;
5452
5478
  function clampClientTerminalOutput(buf) {
@@ -5510,17 +5536,13 @@
5510
5536
  resetWideParserState();
5511
5537
  }
5512
5538
 
5513
- // Soft resync terminal: reset WASM grid and replay full output buffer.
5514
- // Clears any stale DOM rows left over from CSI cursor-jump sequences
5515
- // (e.g. Claude permission menus redrawing in place while user holds arrow keys).
5516
- // Pass { skipFit: true } when the caller knows the grid was just sized
5517
- // correctly (e.g. wterm.onResize fired this resync — bouncing back into
5518
- // ensureTerminalFit would just trigger another remeasure resize onResize
5519
- // softResyncTerminal recursion).
5520
- //
5521
- // 重放整 buffer 而非截短:alt screen 切换 / 滚动区 / 字符集等模式开关
5522
- // 依赖从 buffer 开头开始消费。从中间切会丢失这些状态机指令,治标变
5523
- // 造反。H1 已经把 buffer 限到 256KB,对 wterm WASM 来说这是 ms 级开销。
5539
+ // Reset wterm WASM grid and replay the full output buffer to clear stale
5540
+ // DOM rows left by CSI cursor-jump sequences (Claude permission menus etc.).
5541
+ // Replays the *whole* buffer because alt-screen / scroll-region / charset
5542
+ // mode switches must be consumed from the start; cutting the middle drops
5543
+ // those state-machine instructions and corrupts the grid.
5544
+ // Pass { skipFit: true } when the caller already sized the grid (e.g.
5545
+ // wterm.onResize fired this resync) — otherwise ensureTerminalFit recurses.
5524
5546
  var _resyncStatsWindowStart = 0;
5525
5547
  var _resyncStatsCount = 0;
5526
5548
  var _resyncLastWarnAt = 0;
@@ -5566,22 +5588,13 @@
5566
5588
  }, typeof delayMs === "number" ? delayMs : 150);
5567
5589
  }
5568
5590
 
5569
- // Claude CLI 的 permission 菜单 / 选择列表,在用户按方向键时会
5570
- // 发送光标定位 (CSI H/f)、光标移动 (CSI A-D)、擦除显示/行 (CSI
5571
- // J/K) 等序列在原地重绘整块区域。wterm 在这种高频原地重绘下,
5572
- // DOM 行经常残留或错位,导致新写入的内容被堆到 grid 顶部 ——
5573
- // 用户体感就是"明明在改菜单,结果跑到最上面去了"。
5574
- //
5575
- // 兜底策略是"重置 wterm 状态机 + 重放整 buffer"(softResyncTerminal)。
5576
- // 这里的关键是触发时机:旧实现用 350ms debounce,但用户实际持续
5577
- // 按方向键时,每次按键都会让 PTY 回流一次原地重绘 chunk,timer
5578
- // 被反复 reset,永远等不到静默期,softResync 实际从不触发——
5579
- // 这是这个保护机制的根本逻辑错误。
5591
+ // Claude CLI 的 permission 菜单 / 选择列表在方向键下会发原地重绘序列
5592
+ // (CSI A-D / J / K / H / f)。wterm 在这种高频原地重绘下 DOM 行容易残留
5593
+ // 或错位,必须用 softResyncTerminal 兜底。
5580
5594
  //
5581
- // 改成 leading + tail 的节流:第一次进入立即 resync(leading),
5582
- // 节流窗口内的连续 chunk 只挂一个尾巴 timer 兜底,不重置。这样
5583
- // 持续按键期间每 RESYNC_THROTTLE_MS 强制 resync 一次,用户停手
5584
- // 时由尾巴 timer 收尾。不依赖按键停顿这种永远不发生的条件。
5595
+ // 触发用 leading + tail 节流而非 debounce:用户持续按键时每次 chunk 都会
5596
+ // reset debounce timer,永远等不到静默期。leading 立即 resync、窗口内
5597
+ // 用尾巴 timer 收尾,不依赖按键停顿。
5585
5598
  var IN_PLACE_REDRAW_RE = /\x1b\[\d*(?:;\d*)?[ABCDfHJK]/;
5586
5599
  var RESYNC_THROTTLE_MS = 400;
5587
5600
  var RESYNC_TAIL_MS = 350;
@@ -6204,7 +6217,7 @@
6204
6217
  cwd: cwdOverride || getEffectiveCwd(),
6205
6218
  mode: modeOverride || state.chatMode || (state.config && state.config.defaultMode) || "default",
6206
6219
  provider: provider,
6207
- runner: provider === "codex" ? "codex-cli-exec" : (state.structuredRunner || "claude-cli-print"),
6220
+ runner: provider === "codex" ? "codex-cli-exec" : ((state.config && state.config.structuredRunner === "sdk") ? "claude-sdk" : (state.structuredRunner || "claude-cli-print")),
6208
6221
  prompt: prompt || undefined,
6209
6222
  worktreeEnabled: worktreeEnabled === true,
6210
6223
  model: modelPref || undefined
@@ -6371,19 +6384,28 @@
6371
6384
  return block && block.__processing;
6372
6385
  });
6373
6386
  })());
6374
- var preserveLocalStructuredProgress = (localSession.sessionKind === "structured")
6375
- && !!localStructuredState
6376
- && localStructuredState.inFlight === true
6377
- && (!serverStructuredState || serverStructuredState.inFlight !== true)
6378
- && localHasPendingAssistant
6379
- && !!localStructuredState.activeRequestId
6380
- && (!serverStructuredState || !serverStructuredState.activeRequestId || serverStructuredState.activeRequestId === localStructuredState.activeRequestId);
6381
6387
  var localMessages = Array.isArray(localSession.messages)
6382
6388
  ? (structuredSession ? stripRenderOnlyStructuredMessages(localSession.messages) : localSession.messages)
6383
6389
  : [];
6384
6390
  var serverMessages = Array.isArray(serverSession.messages)
6385
6391
  ? (structuredSession ? stripRenderOnlyStructuredMessages(serverSession.messages) : serverSession.messages)
6386
6392
  : [];
6393
+ // 服务端已经返回了完整的 assistant 回复(非 __processing 占位)时,
6394
+ // 不应再保留本地的 inFlight=true 状态,否则用户会看到"思考中"转圈永远不停。
6395
+ var serverHasCompletedAssistant = serverMessages.length > 0 && (function() {
6396
+ var last = serverMessages[serverMessages.length - 1];
6397
+ return last && last.role === "assistant" && Array.isArray(last.content)
6398
+ && !last.content.some(function(b) { return b && b.__processing; });
6399
+ })();
6400
+ var preserveLocalStructuredProgress = (localSession.sessionKind === "structured")
6401
+ && !!localStructuredState
6402
+ && localStructuredState.inFlight === true
6403
+ && (!serverStructuredState || serverStructuredState.inFlight !== true)
6404
+ && localHasPendingAssistant
6405
+ && !!localStructuredState.activeRequestId
6406
+ && !!serverStructuredState && !!serverStructuredState.activeRequestId
6407
+ && serverStructuredState.activeRequestId === localStructuredState.activeRequestId
6408
+ && !serverHasCompletedAssistant;
6387
6409
  var preserveLocalMessages = localMessages.length > serverMessages.length
6388
6410
  || (localMessages.length > 0 && serverMessages.length > 0
6389
6411
  && JSON.stringify(localMessages[localMessages.length - 1]) !== JSON.stringify(serverMessages[serverMessages.length - 1])
@@ -6839,6 +6861,8 @@
6839
6861
  updateDrawerState();
6840
6862
  var modal = document.getElementById("session-modal");
6841
6863
  if (modal) {
6864
+ if (modal._wandCloseTimer) { clearTimeout(modal._wandCloseTimer); modal._wandCloseTimer = null; }
6865
+ modal.classList.remove("closing");
6842
6866
  modal.classList.remove("hidden");
6843
6867
  lastFocusedElement = document.activeElement;
6844
6868
  state.sessionTool = getPreferredTool();
@@ -6860,8 +6884,7 @@
6860
6884
  state.modalOpen = false;
6861
6885
  var modal = document.getElementById("session-modal");
6862
6886
  if (modal) {
6863
- modal.classList.add("hidden");
6864
- // Remove focus trap
6887
+ // Remove focus trap before kicking off the exit animation
6865
6888
  if (focusTrapHandler) {
6866
6889
  document.removeEventListener("keydown", focusTrapHandler);
6867
6890
  focusTrapHandler = null;
@@ -6870,10 +6893,38 @@
6870
6893
  if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
6871
6894
  lastFocusedElement.focus();
6872
6895
  }
6896
+ animateModalClose(modal);
6873
6897
  }
6874
6898
  hidePathSuggestions();
6875
6899
  }
6876
6900
 
6901
+ // Run the liquid-glass exit animation on a modal-backdrop, then mark it hidden.
6902
+ // Falls back to instant hide when reduced-motion is requested or a tab is in the background.
6903
+ function animateModalClose(modal) {
6904
+ if (!modal) return;
6905
+ if (modal.classList.contains("hidden")) return;
6906
+ var prefersReducedMotion = false;
6907
+ try {
6908
+ prefersReducedMotion = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
6909
+ } catch (_e) {}
6910
+ if (prefersReducedMotion || document.hidden) {
6911
+ modal.classList.remove("closing");
6912
+ modal.classList.add("hidden");
6913
+ return;
6914
+ }
6915
+ // Cancel any outstanding pending hide on the same node
6916
+ if (modal._wandCloseTimer) {
6917
+ clearTimeout(modal._wandCloseTimer);
6918
+ modal._wandCloseTimer = null;
6919
+ }
6920
+ modal.classList.add("closing");
6921
+ modal._wandCloseTimer = setTimeout(function() {
6922
+ modal.classList.remove("closing");
6923
+ modal.classList.add("hidden");
6924
+ modal._wandCloseTimer = null;
6925
+ }, 170);
6926
+ }
6927
+
6877
6928
  function setupFocusTrap(modal) {
6878
6929
  if (focusTrapHandler) {
6879
6930
  document.removeEventListener("keydown", focusTrapHandler);
@@ -7070,6 +7121,8 @@
7070
7121
  closeSessionModal();
7071
7122
  var modal = document.getElementById("settings-modal");
7072
7123
  if (modal) {
7124
+ if (modal._wandCloseTimer) { clearTimeout(modal._wandCloseTimer); modal._wandCloseTimer = null; }
7125
+ modal.classList.remove("closing");
7073
7126
  modal.classList.remove("hidden");
7074
7127
  lastFocusedElement = document.activeElement;
7075
7128
  var passEl = document.getElementById("new-password");
@@ -7093,6 +7146,8 @@
7093
7146
  if (volEl) {
7094
7147
  volEl.value = state.notifVolume;
7095
7148
  if (volValEl) volValEl.textContent = state.notifVolume + "%";
7149
+ // Sync the iOS-style fill via the input listener (calls _syncRangeFill)
7150
+ try { volEl.dispatchEvent(new Event("input", { bubbles: true })); } catch (_e) {}
7096
7151
  }
7097
7152
  var volField = document.getElementById("notif-volume-field");
7098
7153
  if (volField) volField.style.display = state.notifSound ? "" : "none";
@@ -7114,6 +7169,8 @@
7114
7169
  state.notifVolume = nativeVol;
7115
7170
  if (volEl) volEl.value = nativeVol;
7116
7171
  if (volValEl) volValEl.textContent = nativeVol + "%";
7172
+ // Sync the iOS-style fill so the orange track matches
7173
+ if (volEl) { try { volEl.dispatchEvent(new Event("input", { bubbles: true })); } catch (_e) {} }
7117
7174
  try { localStorage.setItem("wand-notif-volume", String(nativeVol)); } catch (_e) {}
7118
7175
  } catch (_e) {}
7119
7176
  }
@@ -7129,8 +7186,7 @@
7129
7186
  function closeSettingsModal() {
7130
7187
  var modal = document.getElementById("settings-modal");
7131
7188
  if (modal) {
7132
- modal.classList.add("hidden");
7133
- // Remove focus trap
7189
+ // Remove focus trap before kicking off the exit animation
7134
7190
  if (focusTrapHandler) {
7135
7191
  document.removeEventListener("keydown", focusTrapHandler);
7136
7192
  focusTrapHandler = null;
@@ -7139,6 +7195,7 @@
7139
7195
  if (lastFocusedElement && typeof lastFocusedElement.focus === "function") {
7140
7196
  lastFocusedElement.focus();
7141
7197
  }
7198
+ animateModalClose(modal);
7142
7199
  }
7143
7200
  }
7144
7201
 
@@ -7479,6 +7536,9 @@
7479
7536
  var langEl = document.getElementById("cfg-language");
7480
7537
  if (langEl) langEl.value = cfg.language || "";
7481
7538
 
7539
+ var srEl = document.getElementById("cfg-structured-runner");
7540
+ if (srEl) srEl.value = cfg.structuredRunner || "sdk";
7541
+
7482
7542
  // Default model
7483
7543
  state.configDefaultModel = cfg.defaultModel || "";
7484
7544
  updateSettingsDefaultModelSelect();
@@ -7537,6 +7597,7 @@
7537
7597
  shell: (document.getElementById("cfg-shell") || {}).value,
7538
7598
  language: (document.getElementById("cfg-language") || {}).value || "",
7539
7599
  defaultModel: (document.getElementById("cfg-default-model") || {}).value || "",
7600
+ structuredRunner: (document.getElementById("cfg-structured-runner") || {}).value || "sdk",
7540
7601
  };
7541
7602
 
7542
7603
  var previousDefaultModel = (state.config && state.config.defaultModel) || "";
@@ -7555,7 +7616,9 @@
7555
7616
  msgEl.textContent = data.error;
7556
7617
  msgEl.style.color = "var(--error)";
7557
7618
  } else {
7558
- msgEl.textContent = "配置已保存,部分更改需要重启后生效。";
7619
+ msgEl.textContent = data.restartRequired
7620
+ ? "配置已保存,部分部署字段(host/port/https/shell)需要重启服务才生效。"
7621
+ : "配置已保存。";
7559
7622
  msgEl.style.color = "var(--success)";
7560
7623
  }
7561
7624
  msgEl.classList.remove("hidden");
@@ -9288,6 +9351,9 @@
9288
9351
  return Promise.resolve();
9289
9352
  }
9290
9353
 
9354
+ // 防止同一会话并发提交(快速双击 / 重复触发)
9355
+ var _structuredSubmittingSessions = {};
9356
+
9291
9357
  function postStructuredInput(input, inputBox, session) {
9292
9358
  console.log("[WAND] postStructuredInput selectedId:", state.selectedId, "input:", input && input.substring(0, 50), "session:", session && { id: session.id, sessionKind: session.sessionKind, runner: session.runner, status: session.status, inFlight: session.structuredState && session.structuredState.inFlight });
9293
9359
  if (!state.selectedId || !input) return Promise.resolve();
@@ -9295,6 +9361,11 @@
9295
9361
  showToast("会话不存在,请重新选择或新建会话。", "error");
9296
9362
  return Promise.resolve();
9297
9363
  }
9364
+ // 同一会话的上一次提交尚未落地,直接忽略防止重复发送
9365
+ if (_structuredSubmittingSessions[session.id]) {
9366
+ console.log("[wand] postStructuredInput: duplicate submit ignored for session", session.id);
9367
+ return Promise.resolve();
9368
+ }
9298
9369
 
9299
9370
  var isInterrupting = !!(session.structuredState && session.structuredState.inFlight && session.status === "running");
9300
9371
  // Immediately render user message with thinking indicator
@@ -9327,23 +9398,36 @@
9327
9398
  // the HTTP response arrives.
9328
9399
  var epochBeforePost = state.queueEpoch;
9329
9400
 
9401
+ // 给每次发送生成唯一 idempotency key。Android WebView 进程被冻结再恢复
9402
+ // 的边界场景下,底层网络栈偶尔会把上次未收到响应的 POST 重发一次(前端
9403
+ // JS 拦不住),导致同一条消息被 backend 处理两遍。带上 key 让 backend
9404
+ // 在窗口内识别重发并丢弃。
9405
+ var idempotencyKey = (typeof crypto !== "undefined" && crypto.randomUUID)
9406
+ ? crypto.randomUUID()
9407
+ : (Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10));
9408
+
9330
9409
  // 用 session.id(参数绑定,in-flight 期间不变)而不是 state.selectedId
9331
9410
  // 拼 URL,避免用户切到别的会话后 fetch 落到错误 sessionId。
9411
+ _structuredSubmittingSessions[session.id] = true;
9332
9412
  return fetch("/api/structured-sessions/" + session.id + "/messages", {
9333
9413
  method: "POST",
9334
9414
  headers: { "Content-Type": "application/json" },
9335
9415
  credentials: "same-origin",
9336
- body: JSON.stringify({ input: input, interrupt: isInterrupting || undefined })
9416
+ body: JSON.stringify({ input: input, interrupt: isInterrupting || undefined, idempotencyKey: idempotencyKey })
9337
9417
  })
9338
9418
  .then(function(res) {
9339
9419
  if (!res.ok) {
9340
9420
  return res.json().catch(function() { return { error: "请求失败" }; }).then(function(payload) {
9341
- throw new Error((payload && payload.error) || "无法发送结构化消息。");
9421
+ var err = new Error((payload && payload.error) || "无法发送结构化消息。");
9422
+ err.errorCode = payload && payload.errorCode;
9423
+ err.httpStatus = res.status;
9424
+ throw err;
9342
9425
  });
9343
9426
  }
9344
9427
  return res.json();
9345
9428
  })
9346
9429
  .then(function(snapshot) {
9430
+ _structuredSubmittingSessions[session.id] = false;
9347
9431
  if (snapshot && snapshot.error) {
9348
9432
  throw new Error(snapshot.error);
9349
9433
  }
@@ -9364,10 +9448,33 @@
9364
9448
  }
9365
9449
  })
9366
9450
  .catch(function(error) {
9451
+ _structuredSubmittingSessions[session.id] = false;
9452
+
9453
+ // duplicate_idempotency_key:服务端识别出 WebView 底层重发的副本,
9454
+ // 直接拦截不处理。这里**不**回滚乐观更新——第一次的请求实际上已经
9455
+ // 被服务端接收并处理(或正在处理),ws 推送会带回真实状态;如果在
9456
+ // 这里把 user turn rollback 掉,第一次的 user 消息会从 UI 上消失。
9457
+ if (error && error.errorCode === "duplicate_idempotency_key") {
9458
+ showToast(error.message || "检测到重复发送,已拦截。", "warning");
9459
+ updateInputHint("Enter 发送 · Shift+Enter 换行");
9460
+ return;
9461
+ }
9462
+
9463
+ // 回滚乐观更新:恢复发送前的 messages(去掉刚加的 userTurn)和 inFlight 状态
9464
+ var rollbackMsgs = userMsgs.slice(0, -1);
9367
9465
  updateSessionSnapshot({
9368
9466
  id: session.id,
9467
+ status: session.status,
9468
+ messages: rollbackMsgs,
9369
9469
  structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }),
9370
9470
  });
9471
+ if (session.id === state.selectedId) {
9472
+ state.currentMessages = buildMessagesForRender(
9473
+ Object.assign({}, session, { messages: rollbackMsgs, structuredState: Object.assign({}, session.structuredState || {}, { inFlight: false }) }),
9474
+ rollbackMsgs
9475
+ );
9476
+ renderChat(true);
9477
+ }
9371
9478
  var message = (error && error.message) || "";
9372
9479
  var isTransientAbort =
9373
9480
  message === "Failed to fetch" ||
@@ -9559,12 +9666,8 @@
9559
9666
  }, Promise.resolve());
9560
9667
  }
9561
9668
 
9562
- // pendingMessages 用于 ws 离线时缓存输入,重连后批量回放。
9563
- // 旧实现:按字符串入队,仅靠长度上限 100 控制;超出 shift 最早一条。
9564
- // 问题:用户连续按方向键时几秒就把队列填满;shift 把最早按下的丢
9565
- // 掉,剩下的反而是后期按的——重连后回放的"输入序列"和用户实际
9566
- // 按下的顺序矛盾。给每条消息打时间戳,flush 时直接丢弃过期项,
9567
- // 让"离线超过 N 秒后重连"恢复成一个干净状态而不是错位重放。
9669
+ // pendingMessages 缓存 ws 离线时的输入,重连后回放。每条带时间戳,
9670
+ // flush 时丢弃过期项——离线 >TTL 后回放老按键序列只会让 PTY 错位。
9568
9671
  var PENDING_INPUT_TTL_MS = 5000;
9569
9672
  var PENDING_INPUT_MAX = 100;
9570
9673
  function enqueuePendingInput(input) {
@@ -9634,13 +9737,6 @@
9634
9737
  return Promise.resolve();
9635
9738
  }
9636
9739
 
9637
- console.log("[wand] postInput: sending", {
9638
- sessionId: state.selectedId,
9639
- inputLength: input.length,
9640
- view: effectiveView,
9641
- wsConnected: state.wsConnected
9642
- });
9643
-
9644
9740
  return fetch("/api/sessions/" + requestSessionId + "/input", {
9645
9741
  method: "POST",
9646
9742
  headers: { "Content-Type": "application/json" },
@@ -9908,25 +10004,22 @@
9908
10004
  : "Enter 发送 · Shift+Enter 换行";
9909
10005
  }
9910
10006
  }
9911
- var disableStructuredInput = false;
9912
- // 历史会话只要可自动恢复(Claude provider + 有 claudeSessionId),
9913
- // 输入框/发送按钮就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
10007
+ // 历史会话只要可自动恢复(Claude provider + 有 claudeSessionId),输入框/发送按钮
10008
+ // 就保持可用——发送时由 ensureSessionReadyForInput 透明完成恢复。
9914
10009
  var canResumeOnSend = !structured && !isRunning && canAutoResumeSession(selectedSession);
9915
10010
  if (composer) {
9916
10011
  composer.placeholder = getComposerPlaceholder(selectedSession, state.terminalInteractive);
9917
- composer.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
10012
+ composer.disabled = !structured && !!selectedSession && !isRunning && !canResumeOnSend;
9918
10013
  composer.setAttribute("aria-disabled", composer.disabled ? "true" : "false");
9919
- // 终端交互模式下,按键由 document capture phase 直接透传到 PTY
9920
- // textarea 设为 readonly 避免浏览器同时把字符落进输入框
9921
- // (IME 组合输入、preventDefault 不彻底等边界场景下会出现"键
9922
- // 发到了 PTY、输入框里也留下了字"的双状态)。disabled 会让
9923
- // textarea 失去焦点能力影响一些场景,readOnly 更轻、保留焦点。
10014
+ // 终端交互模式下按键由 document capture phase 透传到 PTY;用
10015
+ // readOnly 而非 disabled 防止 IME 组合输入等边界场景下字符同时
10016
+ // 落到 textarea,又保留 focus 能力。
9924
10017
  composer.readOnly = !!state.terminalInteractive;
9925
10018
  composer.classList.toggle("is-terminal-passthrough", !!state.terminalInteractive);
9926
10019
  }
9927
10020
  var sendBtn = document.getElementById("send-input-button");
9928
10021
  if (sendBtn) {
9929
- sendBtn.disabled = structured ? disableStructuredInput : (!!selectedSession && !isRunning && !canResumeOnSend);
10022
+ sendBtn.disabled = !structured && !!selectedSession && !isRunning && !canResumeOnSend;
9930
10023
  sendBtn.setAttribute("title", structured
9931
10024
  ? "发送"
9932
10025
  : (isCodex ? (isRunning ? "发送给 Codex" : "Codex 会话已结束") : (!selectedSession || isRunning || canResumeOnSend ? "发送" : "会话已结束")));
@@ -9987,15 +10080,9 @@
9987
10080
  scheduleShortcutResync();
9988
10081
  }
9989
10082
 
9990
- // 用户点击下方快捷键栏(↑↓←→/Enter/Esc 等)后,PTY 通常会回流大量
9991
- // 原地重绘序列(CSI A-D / J / K / H / f)。maybeScheduleResyncForChunk
9992
- // 已经在收到这类 chunk 时做节流 resync,但 wterm 状态机偶尔会漏抓
9993
- // (比如 Codex 的菜单切换),导致 DOM 行残留 / 错位 —— 表现就是用户
9994
- // 反馈"按方向键之后画面错位,必须按一下右上角缩放才恢复"。
9995
- // 这里在每次快捷键点击之后安排一次延迟的 softResyncTerminal 兜底,
9996
- // 等价于自动按一次缩放按钮:reset 状态机 + 重放 buffer,把残留的
9997
- // 错位洗掉。延迟 ~500ms 是为了让服务端先把这次按键的回执完整推过来,
9998
- // 避免 resync 时只回放到 chunk 一半。
10083
+ // 快捷键点击后做一次延迟 resync 兜底:maybeScheduleResyncForChunk 偶尔会漏
10084
+ // Codex 菜单切换之类的原地重绘,导致 DOM 行残留。500ms 是为了等服务端把
10085
+ // 本次按键的回执完整推过来,避免 resync 只回放到 chunk 一半。
9999
10086
  function scheduleShortcutResync() {
10000
10087
  if (!state.terminal) return;
10001
10088
  scheduleSoftResyncTerminal(500);
@@ -10594,12 +10681,8 @@
10594
10681
  }
10595
10682
 
10596
10683
  function updateInputPanelViewportSpacing() {
10597
- // 旧实现给 input-panel 加底部 padding = 键盘高度,意图是腾出键盘
10598
- // 空间。但 input-panel 本身位置由 flex 决定,padding 增大只是把
10599
- // panel 自身撑高、内部底部多出空白,textarea(panel 顶部)反而
10600
- // 被往上推、离键盘更远。新方案改为让 body 高度跟随 visualViewport
10601
- // 收缩(见 syncAppViewportHeight),input-panel 自然贴键盘上沿。
10602
- // 这里清掉旧 keyboard-offset,避免新旧双重补偿。
10684
+ // 键盘空间通过 syncAppViewportHeight body 跟随 visualViewport 收缩处理;
10685
+ // 这里清掉历史遗留的 --keyboard-offset 避免双重补偿。
10603
10686
  var inputPanel = document.querySelector('.input-panel');
10604
10687
  if (!inputPanel) return;
10605
10688
  inputPanel.style.removeProperty('--keyboard-offset');
@@ -11991,13 +12074,10 @@
11991
12074
  function handleWebSocketMessage(msg) {
11992
12075
  switch (msg.type) {
11993
12076
  case 'output':
11994
- // Update session output (for terminal display and local message parsing)
11995
- // NOTE: For structured sessions, output may be "" during streaming — check messages too
11996
- // thinking → idle 边界自愈:桥接层在 output.chat 事件里把 isResponding
11997
- // 透传过来。当某会话由 true false(assistant 完成一轮响应)时,
11998
- // 主动做一次 softResyncTerminal —— 等价于自动按一次右上角缩放按钮,
11999
- // 把 Claude/Codex 流式渲染中残留的错位光标定位序列洗掉。
12000
- // 用 120ms 微延迟 + 单 timer 防抖,避免连续 false→true→false 触发多次重放。
12077
+ // For structured sessions, output may be "" during streaming check messages too.
12078
+ // thinking idle 边界自愈:bridge isResponding 透传过来,true→false
12079
+ // 主动 softResyncTerminal,洗掉流式渲染残留的错位光标定位序列。
12080
+ // 120ms 微延迟 + 单 timer 防抖,避免连续 false→true→false 多次重放。
12001
12081
  if (msg.data && msg.sessionId
12002
12082
  && Object.prototype.hasOwnProperty.call(msg.data, 'isResponding')) {
12003
12083
  if (!state._lastIsResponding) state._lastIsResponding = {};
@@ -12192,6 +12272,8 @@
12192
12272
 
12193
12273
  if (isStructuredEnded && msg.sessionId === state.selectedId) {
12194
12274
  flushStructuredInputQueue();
12275
+ // 结构化会话结束时也清 localStorage,防止下次加载恢复僵尸队列
12276
+ clearStructuredQueuePersistence(msg.sessionId);
12195
12277
  } else if (!isStructuredEnded) {
12196
12278
  state.structuredInputQueue = [];
12197
12279
  clearStructuredQueuePersistence(state.selectedId);
@@ -12235,18 +12317,12 @@
12235
12317
  renderChat(true);
12236
12318
  updateTaskDisplay();
12237
12319
  updateApprovalStats();
12238
- // ws 重新订阅时拿到的是服务端 ring buffer 的最新窗口(最多
12239
- // 120KB);客户端缓存的 terminalOutput 可能早于服务端窗口
12240
- // 的起点。append 模式有 prefix 检查,prefix 不匹配就 reset+
12241
- // 全量重写、全等就直接 return false——前者会把 alt-screen
12242
- // 中的 Claude TUI 切走,后者会把"应该按真实 cols 重写"的
12243
- // 机会跳过。改用 replace 强制 reset+按当前 cols 重写一次,
12244
- // 这是订阅时唯一可信的全量基线。
12320
+ // 订阅返回的是服务端 ring buffer 最新窗口,与客户端 terminalOutput
12321
+ // 可能不连续。强制 replace(reset + 按当前 cols 重写)是订阅时唯一
12322
+ // 可信的全量基线,避免 append prefix 检查走错分支。
12245
12323
  updateTerminalOutput(msg.data.output || "", msg.sessionId, "replace");
12246
- // 紧接着等容器有真实尺寸再 fit + softResync:wterm 启动
12247
- // 硬编码 cols=120,replace 写入也可能落在错的列宽上,
12248
- // ResizeObserver 的回调是异步的,得用 fit-with-retry 兜
12249
- // 一次,确保最终一定按真实宽度重排。
12324
+ // wterm 启动 cols=120,replace 写入可能落在错的列宽上;ResizeObserver
12325
+ // 回调异步,用 fit-with-retry 兜一次确保按真实宽度重排。
12250
12326
  ensureTerminalFitWithRetry("init");
12251
12327
  }
12252
12328
  break;
@@ -12631,8 +12707,7 @@
12631
12707
  var selectedForDelay = state.sessions.find(function(s) { return s.id === state.selectedId; });
12632
12708
  var isActiveStream = selectedForDelay && selectedForDelay.status === "running"
12633
12709
  && selectedForDelay.sessionKind !== "structured";
12634
- // 活跃流时拉到 CHAT_RENDER_LIVE_MS 减少高频重渲;空闲时用 IDLE 快速响应。
12635
- // 旧实现里这两档在 setTimeout 调用时被覆盖成固定 30ms,分档逻辑形同虚设。
12710
+ // 活跃流时拉到 LIVE 减少高频重渲;空闲时用 IDLE 快速响应。
12636
12711
  var delay = isActiveStream ? CHAT_RENDER_LIVE_MS : CHAT_RENDER_IDLE_MS;
12637
12712
  chatRenderTimer = setTimeout(function() {
12638
12713
  chatRenderTimer = null;
@@ -15005,6 +15080,49 @@
15005
15080
  return source;
15006
15081
  }
15007
15082
 
15083
+ function isWordChar(code) {
15084
+ return (code >= 48 && code <= 57) ||
15085
+ (code >= 65 && code <= 90) ||
15086
+ (code >= 97 && code <= 122) ||
15087
+ code === 95;
15088
+ }
15089
+
15090
+ function replaceUnderscoreEmphasis(source, openTag, closeTag) {
15091
+ var cursor = 0;
15092
+ while (cursor < source.length) {
15093
+ var start = source.indexOf("_", cursor);
15094
+ if (start === -1) break;
15095
+ var leftCode = start > 0 ? source.charCodeAt(start - 1) : 0;
15096
+ if (isWordChar(leftCode)) {
15097
+ cursor = start + 1;
15098
+ continue;
15099
+ }
15100
+ var searchFrom = start + 1;
15101
+ var end = -1;
15102
+ while (searchFrom < source.length) {
15103
+ var candidate = source.indexOf("_", searchFrom);
15104
+ if (candidate === -1) break;
15105
+ var rightIdx = candidate + 1;
15106
+ var rightCode = rightIdx < source.length ? source.charCodeAt(rightIdx) : 0;
15107
+ if (!isWordChar(rightCode)) {
15108
+ end = candidate;
15109
+ break;
15110
+ }
15111
+ searchFrom = candidate + 1;
15112
+ }
15113
+ if (end === -1) break;
15114
+ var inner = source.slice(start + 1, end);
15115
+ if (!inner) {
15116
+ cursor = end + 1;
15117
+ continue;
15118
+ }
15119
+ var replacement = openTag + inner + closeTag;
15120
+ source = source.slice(0, start) + replacement + source.slice(end + 1);
15121
+ cursor = start + replacement.length;
15122
+ }
15123
+ return source;
15124
+ }
15125
+
15008
15126
  function replaceLinePrefix(source, marker, openTag, closeTag) {
15009
15127
  return source.split(newline).map(function(line) {
15010
15128
  if (line.indexOf(marker) !== 0) return line;
@@ -15066,12 +15184,13 @@
15066
15184
  }
15067
15185
 
15068
15186
  var highlighted = highlightCode(code.trim(), lang);
15187
+ var protectedHighlighted = highlighted.replace(/_/g, '&#95;').replace(/\*/g, '&#42;');
15069
15188
  var replacement = '<div class="code-block">' +
15070
15189
  '<div class="code-block-header">' +
15071
15190
  '<span class="code-lang">' + (lang || "code") + '</span>' +
15072
15191
  '<button class="code-copy">Copy</button>' +
15073
15192
  '</div>' +
15074
- '<pre><code>' + highlighted + '</code></pre>' +
15193
+ '<pre><code>' + protectedHighlighted + '</code></pre>' +
15075
15194
  '</div>';
15076
15195
  result = result.slice(0, start) + replacement + result.slice(endTag + 3);
15077
15196
  pos = start + replacement.length;
@@ -15088,14 +15207,15 @@
15088
15207
  continue;
15089
15208
  }
15090
15209
  var inlineCode = result.slice(inlineStart + 1, inlineEnd);
15091
- var inlineReplacement = '<code class="code-inline">' + inlineCode + '</code>';
15210
+ var protectedInlineCode = inlineCode.replace(/_/g, '&#95;').replace(/\*/g, '&#42;');
15211
+ var inlineReplacement = '<code class="code-inline">' + protectedInlineCode + '</code>';
15092
15212
  result = result.slice(0, inlineStart) + inlineReplacement + result.slice(inlineEnd + 1);
15093
15213
  pos = inlineStart + inlineReplacement.length;
15094
15214
  }
15095
15215
 
15096
15216
  result = replacePair(result, "**", '<strong>', '</strong>');
15097
15217
  result = replacePair(result, "*", '<em>', '</em>');
15098
- result = replacePair(result, "_", '<em>', '</em>');
15218
+ result = replaceUnderscoreEmphasis(result, '<em>', '</em>');
15099
15219
  result = replaceLinePrefix(result, "### ", '<h3>', '</h3>');
15100
15220
  result = replaceLinePrefix(result, "## ", '<h2>', '</h2>');
15101
15221
  result = replaceLinePrefix(result, "# ", '<h1>', '</h1>');