@co0ontty/wand 1.37.0 → 1.39.1

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.
@@ -370,6 +370,10 @@
370
370
  sessionsManageMode: false,
371
371
  selectedSessionIds: {},
372
372
  selectedClaudeHistoryIds: {},
373
+ codexHistory: [],
374
+ codexHistoryLoaded: false,
375
+ codexHistoryExpandedDirs: {},
376
+ selectedCodexHistoryIds: {},
373
377
  askUserSelections: {}, // { toolUseId: { 0: [optIdx...], submitted: false } }
374
378
  queueEpoch: 0, // Monotonic counter for queue state freshness
375
379
  pendingAttachments: [], // [{ file, previewUrl, name, size }]
@@ -1908,7 +1912,7 @@
1908
1912
  '</div>' +
1909
1913
  '<div class="file-search-box">' +
1910
1914
  '<span class="file-search-icon">' + wandFileIcon("search", { size: 14 }) + '</span>' +
1911
- '<input type="text" id="file-search-input" class="file-search-input" placeholder="搜索当前目录…" autocomplete="off" />' +
1915
+ '<input type="text" id="file-search-input" class="file-search-input" placeholder="搜索当前目录…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />' +
1912
1916
  '<button class="file-search-clear" id="file-search-clear" type="button" aria-label="清除搜索" title="清除">' +
1913
1917
  wandFileIcon("x", { size: 13 }) +
1914
1918
  '</button>' +
@@ -2091,7 +2095,7 @@
2091
2095
  '<div id="folder-breadcrumb" class="folder-breadcrumb"></div>' +
2092
2096
  '<div class="folder-picker">' +
2093
2097
  '<span class="folder-picker-icon">' + iconSvg("folder", { size: 15, strokeWidth: 1.7 }) + '</span>' +
2094
- '<input type="text" id="folder-picker-input" class="folder-picker-input" value="" placeholder="输入或选择工作目录..." autocomplete="off" />' +
2098
+ '<input type="text" id="folder-picker-input" class="folder-picker-input" value="" placeholder="输入或选择工作目录..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />' +
2095
2099
  '</div>' +
2096
2100
  '<div id="folder-picker-dropdown" class="folder-picker-dropdown hidden"></div>' +
2097
2101
  '<div id="folder-picker-validation" class="folder-picker-validation"></div>' +
@@ -2358,6 +2362,10 @@
2358
2362
  state.quickCommitForm.makeTag = tagCb.checked;
2359
2363
  var row = document.getElementById("quick-commit-tag-row");
2360
2364
  if (row) row.classList.toggle("hidden", !tagCb.checked);
2365
+ if (tagCb.checked) {
2366
+ var input = document.getElementById("quick-commit-tag");
2367
+ if (input) setTimeout(function() { input.focus(); }, 0);
2368
+ }
2361
2369
  });
2362
2370
  var tagInput = document.getElementById("quick-commit-tag");
2363
2371
  if (tagInput) tagInput.addEventListener("input", function() {
@@ -2485,9 +2493,8 @@
2485
2493
  state.quickCommitForm.customMessage = aiMessage;
2486
2494
  }
2487
2495
  var currentTag = (state.quickCommitForm.tag || "").trim();
2488
- if (!currentTag && aiTag) {
2496
+ if (state.quickCommitForm.makeTag && !currentTag && aiTag) {
2489
2497
  state.quickCommitForm.tag = aiTag;
2490
- state.quickCommitForm.makeTag = true;
2491
2498
  }
2492
2499
  })
2493
2500
  .catch(function(error) {
@@ -2848,19 +2855,21 @@
2848
2855
  '<div class="qc-files-wrap">' + fileRows + '</div>' +
2849
2856
  '<div class="qc-message-row" id="quick-commit-message-row">' +
2850
2857
  '<div class="qc-message-header"><label class="field-label" for="quick-commit-message">commit message</label>' +
2851
- '<button type="button" id="quick-commit-ai-btn" class="btn btn-ghost btn-sm"' + (state.quickCommitGenerating ? ' disabled' : '') + '>' + (state.quickCommitGenerating ? '生成中…' : 'AI 生成') + '</button>' +
2858
+ '<div class="qc-ai-controls">' +
2859
+ '<button type="button" id="quick-commit-ai-btn" class="btn btn-ghost btn-sm"' + (state.quickCommitGenerating ? ' disabled' : '') + '>' + (state.quickCommitGenerating ? '生成中…' : 'AI 生成') + '</button>' +
2860
+ '<label class="qc-ai-tag-toggle" title="开启后,AI 会一并建议 tag,提交时会为本次 commit 打 tag">' +
2861
+ '<span class="qc-ai-tag-label">含 tag</span>' +
2862
+ '<span class="qc-switch qc-switch--compact">' +
2863
+ '<input type="checkbox" id="quick-commit-make-tag" class="switch-toggle" aria-label="同时为本次 commit 打 tag"' + (f.makeTag ? ' checked' : '') + ((state.quickCommitSubmitting || state.quickCommitGenerating) ? ' disabled' : '') + '>' +
2864
+ '<span class="switch-slider"></span>' +
2865
+ '</span>' +
2866
+ '</label>' +
2867
+ '</div>' +
2852
2868
  '</div>' +
2853
2869
  '<textarea id="quick-commit-message" class="field-input" rows="2" placeholder="输入 commit message 或点击 AI 生成"' + (state.quickCommitSubmitting ? ' disabled' : '') + '>' + escapeHtml(f.customMessage || "") + '</textarea>' +
2854
2870
  '</div>' +
2855
- '<div class="qc-checkbox-row">' +
2856
- '<label class="qc-checkbox-label" for="quick-commit-make-tag">同时为本次 commit 打 tag</label>' +
2857
- '<label class="qc-switch">' +
2858
- '<input type="checkbox" id="quick-commit-make-tag" class="switch-toggle"' + (f.makeTag ? ' checked' : '') + '>' +
2859
- '<span class="switch-slider"></span>' +
2860
- '</label>' +
2861
- '</div>' +
2862
2871
  '<div class="qc-tag-row' + (f.makeTag ? '' : ' hidden') + '" id="quick-commit-tag-row">' +
2863
- '<input type="text" id="quick-commit-tag" class="field-input" placeholder="输入 tag 名称;留空将由 AI 在提交时自动生成" value="' + escapeHtml(f.tag || "") + '"' + (state.quickCommitSubmitting ? ' disabled' : '') + '>' +
2872
+ '<input type="text" id="quick-commit-tag" class="field-input" placeholder="输入 tag 名称;留空则提交时由 AI 自动生成" value="' + escapeHtml(f.tag || "") + '"' + (state.quickCommitSubmitting ? ' disabled' : '') + '>' +
2864
2873
  '</div>' +
2865
2874
  (state.quickCommitError ? '<p class="error-message">' + escapeHtml(state.quickCommitError) + '</p>' : '') +
2866
2875
  '<div class="qc-section-actions">' +
@@ -3529,9 +3538,6 @@
3529
3538
 
3530
3539
  function renderCollapsedSessionTiles() {
3531
3540
  var entries = getRecentEntries();
3532
- if (entries.length === 0) {
3533
- return '<div class="sidebar-collapsed-empty" title="无会话">—</div>';
3534
- }
3535
3541
  var tiles = entries.map(function(e, i) {
3536
3542
  var idx = i + 1;
3537
3543
  if (e.kind === "session") {
@@ -3545,7 +3551,11 @@
3545
3551
  var hTitle = preview + " · " + formatHistoryTime(h.timestamp);
3546
3552
  return '<button class="sidebar-collapsed-tile history" type="button" data-collapsed-history-id="' + escapeHtml(h.claudeSessionId) + '" data-cwd="' + escapeHtml(h.cwd || "") + '" title="' + escapeHtml(hTitle) + '">' + idx + '</button>';
3547
3553
  }).join("");
3548
- return '<div class="sidebar-collapsed-tiles">' + tiles + '</div>';
3554
+ // 窄条底部固定一个「+」快速新建会话方块,替代被隐藏的 footer 新会话入口。
3555
+ var addTile = '<button class="sidebar-collapsed-tile add" type="button" data-collapsed-new-session="1" title="新建会话" aria-label="新建会话">' +
3556
+ '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
3557
+ '</button>';
3558
+ return '<div class="sidebar-collapsed-tiles">' + tiles + addTile + '</div>';
3549
3559
  }
3550
3560
 
3551
3561
  function renderSessionsListContent() {
@@ -3640,7 +3650,9 @@
3640
3650
  var visibleHistory = getClaudeHistoryRegionItems();
3641
3651
  var expanded = !!state.claudeHistoryExpanded;
3642
3652
  var loaded = !!state.claudeHistoryLoaded;
3643
- var count = loaded ? visibleHistory.length : 0;
3653
+ var codexVisible = getVisibleCodexHistorySessions();
3654
+ var codexLoaded = !!state.codexHistoryLoaded;
3655
+ var count = (loaded ? visibleHistory.length : 0) + (codexLoaded ? codexVisible.length : 0);
3644
3656
 
3645
3657
  var badgeCls = "history-bubble";
3646
3658
  var badgeContent;
@@ -3666,7 +3678,7 @@
3666
3678
  '</button>';
3667
3679
 
3668
3680
  var body = expanded
3669
- ? '<div class="sidebar-history-body" id="sidebar-history-body">' + renderClaudeHistoryBodyContent(visibleHistory) + '</div>'
3681
+ ? '<div class="sidebar-history-body" id="sidebar-history-body">' + renderClaudeHistoryBodyContent(visibleHistory) + renderCodexHistoryBodyContent(codexVisible) + '</div>'
3670
3682
  : '';
3671
3683
 
3672
3684
  return header + body;
@@ -3897,6 +3909,44 @@
3897
3909
  });
3898
3910
  }
3899
3911
 
3912
+ function renderCodexHistoryDirectoryHeader(cwd, cwdShort, count, isExpanded) {
3913
+ var chevron = isExpanded ? "\u25be" : "\u25b8";
3914
+ return '<div class="claude-history-directory-header codex-history-directory-header" data-action="toggle-codex-history-directory" data-cwd="' + escapeHtml(cwd) + '" role="button" tabindex="0">' +
3915
+ '<div class="session-group-title claude-history-directory-title">' +
3916
+ '<span class="chevron">' + chevron + '</span>' +
3917
+ '<span class="claude-history-directory-label">' + escapeHtml(cwdShort) + ' (' + count + ')</span>' +
3918
+ '</div>' +
3919
+ '</div>';
3920
+ }
3921
+
3922
+ function renderCodexHistoryBodyContent(visibleHistory) {
3923
+ if (!state.codexHistoryLoaded) {
3924
+ return '<div class="claude-history-loading">\u626b\u63cf Codex \u5386\u53f2\u4f1a\u8bdd\u4e2d\u2026</div>';
3925
+ }
3926
+ if (visibleHistory.length === 0) {
3927
+ return '';
3928
+ }
3929
+ var groups = {};
3930
+ var groupOrder = [];
3931
+ visibleHistory.forEach(function(s) {
3932
+ if (!groups[s.cwd]) {
3933
+ groups[s.cwd] = [];
3934
+ groupOrder.push(s.cwd);
3935
+ }
3936
+ groups[s.cwd].push(s);
3937
+ });
3938
+ var listHtml = '<div class="sidebar-history-section-label">Codex</div>';
3939
+ groupOrder.forEach(function(cwd) {
3940
+ var cwdShort = cwd.split("/").filter(Boolean).slice(-3).join("/");
3941
+ var isDirExpanded = !!state.codexHistoryExpandedDirs[cwd];
3942
+ listHtml += renderCodexHistoryDirectoryHeader(cwd, cwdShort, groups[cwd].length, isDirExpanded);
3943
+ if (isDirExpanded) {
3944
+ listHtml += groups[cwd].map(function(session) { return renderClaudeHistoryItem(session, "codex"); }).join("");
3945
+ }
3946
+ });
3947
+ return '<div class="sidebar-history-scroll codex-history-scroll">' + listHtml + '</div>';
3948
+ }
3949
+
3900
3950
  function renderClaudeHistoryDirectoryHeader(cwd, cwdShort, count, isExpanded) {
3901
3951
  var chevron = isExpanded ? "&#9662;" : "&#9656;";
3902
3952
  return '<div class="claude-history-directory-header" data-action="toggle-history-directory" data-cwd="' + escapeHtml(cwd) + '" role="button" tabindex="0">' +
@@ -3910,19 +3960,23 @@
3910
3960
  }
3911
3961
 
3912
3962
  function renderClaudeHistoryItem(session, kind) {
3963
+ var isCodex = kind === "codex";
3964
+ var rAct = isCodex ? "resume-codex-history" : "resume-history";
3965
+ var dAct = isCodex ? "delete-codex-history" : "delete-history";
3966
+ var selMap = isCodex ? state.selectedCodexHistoryIds : state.selectedClaudeHistoryIds;
3913
3967
  var shortId = session.claudeSessionId.slice(0, 8);
3914
3968
  var preview = session.firstUserMessage || "(空会话)";
3915
3969
  var timeStr = formatHistoryTime(session.timestamp);
3916
3970
  var checkbox = renderManageCheckbox(kind, session.claudeSessionId, "选择历史会话 " + preview);
3917
3971
  var deleteButton = state.sessionsManageMode ? '' :
3918
- '<button class="session-action-btn delete-btn" data-action="delete-history" data-claude-session-id="' +
3972
+ '<button class="session-action-btn delete-btn" data-action="' + dAct + '" data-claude-session-id="' +
3919
3973
  session.claudeSessionId + '" type="button" aria-label="删除会话" title="隐藏此历史会话"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/></svg></button>';
3920
3974
  var resumeButton = state.sessionsManageMode ? '' :
3921
- '<button class="session-action-btn" data-action="resume-history" data-claude-session-id="' +
3975
+ '<button class="session-action-btn" data-action="' + rAct + '" data-claude-session-id="' +
3922
3976
  session.claudeSessionId + '" data-cwd="' + escapeHtml(session.cwd) +
3923
- '" type="button" aria-label="恢复会话" title="恢复此 Claude 历史会话"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 105.64-11.36L3 10"/></svg></button>';
3977
+ '" type="button" aria-label="恢复会话" title="' + (isCodex ? "恢复此 Codex 历史会话" : "恢复此 Claude 历史会话") + '"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 105.64-11.36L3 10"/></svg></button>';
3924
3978
 
3925
- return '<div class="session-item claude-history-item' + (state.sessionsManageMode && state.selectedClaudeHistoryIds[session.claudeSessionId] ? ' selected' : '') + '" data-claude-history-id="' + session.claudeSessionId + '" data-cwd="' + escapeHtml(session.cwd) + '" role="button" tabindex="0">' +
3979
+ return '<div class="session-item claude-history-item' + (state.sessionsManageMode && selMap[session.claudeSessionId] ? ' selected' : '') + '" data-claude-history-id="' + session.claudeSessionId + '" data-cwd="' + escapeHtml(session.cwd) + '" role="button" tabindex="0">' +
3926
3980
  '<div class="session-item-content">' +
3927
3981
  '<div class="session-item-row">' +
3928
3982
  checkbox +
@@ -3980,6 +4034,7 @@
3980
4034
  // 且在已加载时立即 resolve。
3981
4035
  var _claudeHistoryLoadingPromise = null;
3982
4036
  function ensureClaudeHistoryLoaded() {
4037
+ ensureCodexHistoryLoaded();
3983
4038
  if (state.claudeHistoryLoaded) return Promise.resolve();
3984
4039
  if (_claudeHistoryLoadingPromise) return _claudeHistoryLoadingPromise;
3985
4040
  _claudeHistoryLoadingPromise = loadClaudeHistory().then(function() {
@@ -3990,6 +4045,46 @@
3990
4045
  return _claudeHistoryLoadingPromise;
3991
4046
  }
3992
4047
 
4048
+ function loadCodexHistory() {
4049
+ return fetch("/api/codex-history", { credentials: "same-origin" })
4050
+ .then(function(res) {
4051
+ if (!res.ok) return [];
4052
+ return res.json();
4053
+ })
4054
+ .then(function(sessions) {
4055
+ state.codexHistory = sessions || [];
4056
+ state.codexHistoryLoaded = true;
4057
+ updateSessionsList();
4058
+ })
4059
+ .catch(function() {
4060
+ state.codexHistoryLoaded = true;
4061
+ state.codexHistory = [];
4062
+ updateSessionsList();
4063
+ });
4064
+ }
4065
+
4066
+ var _codexHistoryLoadingPromise = null;
4067
+ function ensureCodexHistoryLoaded() {
4068
+ if (state.codexHistoryLoaded) return Promise.resolve();
4069
+ if (_codexHistoryLoadingPromise) return _codexHistoryLoadingPromise;
4070
+ _codexHistoryLoadingPromise = loadCodexHistory().then(function() {
4071
+ _codexHistoryLoadingPromise = null;
4072
+ }, function() {
4073
+ _codexHistoryLoadingPromise = null;
4074
+ });
4075
+ return _codexHistoryLoadingPromise;
4076
+ }
4077
+
4078
+ function getVisibleCodexHistorySessions() {
4079
+ var managedIds = new Set();
4080
+ state.sessions.forEach(function(s) {
4081
+ if (s.claudeSessionId) managedIds.add(s.claudeSessionId);
4082
+ });
4083
+ return state.codexHistory.filter(function(s) {
4084
+ return s.hasConversation && !s.managedByWand && !managedIds.has(s.claudeSessionId);
4085
+ });
4086
+ }
4087
+
3993
4088
  function isMobileLayout() {
3994
4089
  return window.innerWidth <= 768;
3995
4090
  }
@@ -7154,6 +7249,12 @@
7154
7249
 
7155
7250
  var collapsedTile = target.closest(".sidebar-collapsed-tile");
7156
7251
  if (collapsedTile && collapsedTile instanceof HTMLElement) {
7252
+ if (collapsedTile.dataset.collapsedNewSession) {
7253
+ event.preventDefault();
7254
+ event.stopPropagation();
7255
+ openSessionModal();
7256
+ return;
7257
+ }
7157
7258
  if (collapsedTile.dataset.collapsedSessionId) {
7158
7259
  event.preventDefault();
7159
7260
  event.stopPropagation();
@@ -7244,6 +7345,14 @@
7244
7345
  handleResumeAction(actionButton);
7245
7346
  } else if (actionButton.dataset.action === "resume-history" && actionButton.dataset.claudeSessionId) {
7246
7347
  handleResumeHistoryAction(actionButton);
7348
+ } else if (actionButton.dataset.action === "resume-codex-history" && actionButton.dataset.claudeSessionId) {
7349
+ handleResumeCodexHistoryAction(actionButton);
7350
+ } else if (actionButton.dataset.action === "delete-codex-history" && actionButton.dataset.claudeSessionId) {
7351
+ handleDeleteCodexHistoryAction(actionButton);
7352
+ } else if (actionButton.dataset.action === "toggle-codex-history-directory" && actionButton.dataset.cwd) {
7353
+ var codexDirCwd = actionButton.dataset.cwd;
7354
+ state.codexHistoryExpandedDirs[codexDirCwd] = !state.codexHistoryExpandedDirs[codexDirCwd];
7355
+ updateSessionsList();
7247
7356
  } else if (actionButton.dataset.action === "worktree-merge" && actionButton.dataset.sessionId) {
7248
7357
  openWorktreeMergeModal(actionButton.dataset.sessionId);
7249
7358
  } else if (actionButton.dataset.action === "worktree-cleanup" && actionButton.dataset.sessionId) {
@@ -13839,24 +13948,24 @@
13839
13948
  { key: "down", label: "↓" },
13840
13949
  { key: "left", label: "←" }
13841
13950
  ];
13842
- // 外圈 8 功能:i=0 正上方起顺时针,与八分扇区 idx 对应
13951
+ // 外圈功能键:N 个均分扇区,i=0 正上方起顺时针。
13952
+ // 数组长度即扇区数 —— buildJoystickRingSvg / joystickHitTest 都是按
13953
+ // OUTER_KEYS.length 动态算角度,所以这里随便加减都不需要改几何。
13954
+ // 当前 4 键: 上=Enter 右=Ctrl+C 下=Esc 左=Shift+Tab。
13955
+ // 选这 4 个的原因: Claude / Codex 交互里只有方向键 + Enter + Esc +
13956
+ // Shift+Tab (back-tab) + Ctrl+C (abort) 有真实用途, 之前的 Tab /
13957
+ // Ctrl+Z/D/L 在结构化 / chat / PTY claude 里都拿不到效果, 留着只是
13958
+ // 占位 + 误点。
13843
13959
  var JOYSTICK_OUTER_KEYS = [
13844
13960
  { key: "enter", label: "Enter" },
13845
- { key: "escape", label: "Esc" },
13846
- { key: "tab", label: "Tab" },
13847
- { key: "shift_tab", label: "Shift+Tab" },
13848
13961
  { key: "ctrl_c", label: "Ctrl+C" },
13849
- { key: "ctrl_z", label: "Ctrl+Z" },
13850
- { key: "ctrl_d", label: "Ctrl+D" },
13851
- { key: "ctrl_l", label: "Ctrl+L" }
13852
- ];
13853
- // 钉住面板四角翻页键
13854
- var JOYSTICK_CORNER_KEYS = [
13855
- { key: "pageup", label: "PgUp" },
13856
- { key: "home", label: "Home" },
13857
- { key: "pagedown", label: "PgDn" },
13858
- { key: "end", label: "End" }
13962
+ { key: "escape", label: "Esc" },
13963
+ { key: "shift_tab", label: "Shift+Tab" }
13859
13964
  ];
13965
+ // 钉住面板四角翻页键 —— 已弃用 (PgUp/Home/PgDn/End 在 Claude TUI 里
13966
+ // 不是常用导航, 也跟终端历史回滚冲突)。留空数组让面板渲染逻辑自然
13967
+ // 跳过这一排, 不删数组以保 ringSvg 之外别处 reference 安全。
13968
+ var JOYSTICK_CORNER_KEYS = [];
13860
13969
 
13861
13970
  var ignoredInteractiveTargetIds = new Set([
13862
13971
  "mini-keyboard-fab",
@@ -14644,6 +14753,81 @@
14644
14753
  });
14645
14754
  }
14646
14755
 
14756
+ function handleResumeCodexHistoryAction(actionButton) {
14757
+ var threadId = actionButton.dataset.claudeSessionId;
14758
+ var cwd = actionButton.dataset.cwd;
14759
+ console.log("[WAND] handleResumeCodexHistoryAction threadId:", threadId, "cwd:", cwd);
14760
+ if (!threadId) return;
14761
+ actionButton.disabled = true;
14762
+ resumeCodexHistorySession(threadId, cwd)
14763
+ .then(function(data) {
14764
+ if (data && data.id) {
14765
+ state.codexHistory = state.codexHistory.filter(function(s) {
14766
+ return s.claudeSessionId !== threadId;
14767
+ });
14768
+ state.selectedId = data.id;
14769
+ persistSelectedId();
14770
+ state.drafts[data.id] = "";
14771
+ activateSession(data).then(function() {
14772
+ dismissDrawerIfOverlay();
14773
+ });
14774
+ }
14775
+ })
14776
+ .finally(function() {
14777
+ actionButton.disabled = false;
14778
+ });
14779
+ }
14780
+
14781
+ function resumeCodexHistorySession(threadId, cwd) {
14782
+ return fetch("/api/codex-sessions/" + encodeURIComponent(threadId) + "/resume", {
14783
+ method: "POST",
14784
+ headers: { "Content-Type": "application/json" },
14785
+ credentials: "same-origin",
14786
+ body: JSON.stringify(withTerminalDimensions({
14787
+ mode: state.chatMode || (state.config && state.config.defaultMode) || "default",
14788
+ cwd: cwd
14789
+ }))
14790
+ })
14791
+ .then(function(res) { return res.json(); })
14792
+ .then(function(data) {
14793
+ if (data.error) {
14794
+ showToast(data.error, "error");
14795
+ return null;
14796
+ }
14797
+ return data;
14798
+ })
14799
+ .catch(function(error) {
14800
+ showToast((error && error.message) || "无法恢复历史会话。", "error");
14801
+ return null;
14802
+ });
14803
+ }
14804
+
14805
+ function handleDeleteCodexHistoryAction(actionButton) {
14806
+ var threadId = actionButton.dataset.claudeSessionId;
14807
+ if (!threadId) return;
14808
+ console.log("[WAND] handleDeleteCodexHistoryAction threadId:", threadId);
14809
+ var item = actionButton.closest(".claude-history-item");
14810
+ if (item) item.style.opacity = "0.5";
14811
+ fetch("/api/codex-history/" + encodeURIComponent(threadId), {
14812
+ method: "DELETE",
14813
+ credentials: "same-origin"
14814
+ })
14815
+ .then(function(res) { return res.json(); })
14816
+ .then(function(data) {
14817
+ if (data && data.ok) {
14818
+ state.codexHistory = state.codexHistory.filter(function(s) {
14819
+ return s.claudeSessionId !== threadId;
14820
+ });
14821
+ updateSessionsList();
14822
+ } else if (item) {
14823
+ item.style.opacity = "1";
14824
+ }
14825
+ })
14826
+ .catch(function() {
14827
+ if (item) item.style.opacity = "1";
14828
+ });
14829
+ }
14830
+
14647
14831
  function handleResumeHistoryAction(actionButton) {
14648
14832
  var claudeSessionId = actionButton.dataset.claudeSessionId;
14649
14833
  var cwd = actionButton.dataset.cwd;
@@ -14770,7 +14954,8 @@
14770
14954
  function handleInputBoxBlur() {
14771
14955
  resetInputPanelViewportSpacing();
14772
14956
  setTimeout(function() {
14773
- window.scrollTo(0, 0);
14957
+ resetRootViewportScroll();
14958
+ syncAppViewportHeight(false);
14774
14959
  // On mobile, force terminal refit + scroll after keyboard dismissal.
14775
14960
  // The container height restores but terminal needs time to
14776
14961
  // fill the expanded space, and the scroll position needs resetting.
@@ -14782,6 +14967,10 @@
14782
14967
  maybeScrollTerminalToBottom("keyboard");
14783
14968
  }
14784
14969
  }, 100);
14970
+ setTimeout(function() {
14971
+ resetRootViewportScroll();
14972
+ syncAppViewportHeight(false);
14973
+ }, 360);
14785
14974
  }
14786
14975
 
14787
14976
  function adjustInputBoxSelection(inputBox) {
@@ -15492,18 +15681,74 @@
15492
15681
  // adjustResize 不再自动 resize WebView 内容;同时仅给 input-panel
15493
15682
  // 加 padding-bottom 只是把 panel 内部底部撑空,并不会让 panel 自身
15494
15683
  // 上移。这里通过 CSS 变量驱动整层高度,是跨 WebView/Chrome/PWA 的
15495
- // 统一兜底。仅在视口比窗口明显变小时(典型 = 软键盘弹起)覆盖,
15496
- // 桌面与无键盘场景维持 100dvh 不抖。
15497
- function syncAppViewportHeight() {
15684
+ // 统一兜底。iOS 100dvh 在键盘动画后会短暂滞后,所以这里持续用
15685
+ // visualViewport 的实测高度驱动布局,桌面场景下该值基本等于窗口高度。
15686
+ function getRootViewportScrollTop(vv) {
15687
+ var values = [
15688
+ window.scrollY || window.pageYOffset || 0,
15689
+ document.documentElement ? document.documentElement.scrollTop || 0 : 0,
15690
+ document.body ? document.body.scrollTop || 0 : 0
15691
+ ];
15692
+ if (vv) {
15693
+ // pageTop is the visual viewport's top edge in layout coordinates.
15694
+ // On iOS it captures the focus pan that can survive keyboard close.
15695
+ if (typeof vv.pageTop === "number") {
15696
+ values.push(vv.pageTop);
15697
+ } else if (typeof vv.offsetTop === "number") {
15698
+ values.push(vv.offsetTop);
15699
+ }
15700
+ }
15701
+ return Math.max.apply(Math, values);
15702
+ }
15703
+
15704
+ function resetRootViewportScroll() {
15705
+ try { window.scrollTo(0, 0); } catch (e) {}
15706
+ if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
15707
+ if (document.documentElement) document.documentElement.scrollTop = 0;
15708
+ if (document.body) document.body.scrollTop = 0;
15709
+ }
15710
+
15711
+ function syncAppViewportHeight(isKeyboardOpen) {
15498
15712
  var vv = window.visualViewport;
15499
15713
  if (!vv) return;
15500
- var diff = window.innerHeight - vv.height - vv.offsetTop;
15501
15714
  var root = document.documentElement;
15502
- if (diff > 50) {
15503
- root.style.setProperty('--app-viewport-height', vv.height + 'px');
15504
- } else {
15505
- root.style.removeProperty('--app-viewport-height');
15715
+ var visualTop = window.__wandImeNative ? 0 : getRootViewportScrollTop(vv);
15716
+ // iOS Safari 上 100dvh 在键盘 / 地址栏切换后有更新延迟, 经常"卡"在
15717
+ // 上一刻的小值 -> body 比真实可见区还短一截, 输入框下方留出一大段
15718
+ // 奶油色 html 背景。改成直接拿 visualViewport.height 当 body 高度的
15719
+ // 权威值, 每帧实时跟随 (vv.resize/scroll 触发), 不再依赖 dvh。
15720
+ // 桌面浏览器上 vv.height ≈ window.innerHeight, 同样无副作用。
15721
+ // 之前的 diff > 50 阈值现在只用来判断"是不是真键盘上来了"以做
15722
+ // iOS html 滚动复位 (offsetTop hack), 不再控制 body 高度。
15723
+ //
15724
+ // 但 iOS 还有一个更隐蔽的状态: 键盘收起后 visual viewport 已经变高,
15725
+ // 根页面却仍停在键盘弹起时的 pageTop/scrollY。此时如果只写 vv.height,
15726
+ // .app-container 的底边落在 visualViewport 顶点之前, input-panel 会悬在
15727
+ // 屏幕底部上方。把 visualTop 临时加回高度, 再滚回 0; 后续 settle timer
15728
+ // 会用新的 visualTop=0 覆盖回来。
15729
+ root.style.setProperty('--app-viewport-height', Math.ceil(vv.height + Math.max(0, visualTop)) + 'px');
15730
+ // iOS Safari: 当 textarea 获得焦点 / 键盘弹起时, 浏览器会主动把
15731
+ // <html> 向上滚一段, 让焦点元素进可见区 —— 体现为 vv.offsetTop > 0。
15732
+ // 但 body 已经被收缩到 vv.height, 这一段 offsetTop 就变成 body 底部
15733
+ // (= .input-panel) 与键盘上沿之间的"空洞", 用户看到的就是
15734
+ // "输入框离键盘还有很远一截"。这里强行把 html 滚回 0, 让 body 底部
15735
+ // 重新贴回键盘上沿。Wand APK 内 (window.__wandImeNative=true) 走
15736
+ // 原生 IME 回调精确 resize, 这里跳过避免双重补偿。
15737
+ if (!window.__wandImeNative && (isKeyboardOpen || visualTop > 1)) {
15738
+ resetRootViewportScroll();
15739
+ }
15740
+ }
15741
+
15742
+ function isEditableFocusTarget(el) {
15743
+ if (!el) return false;
15744
+ var tag = el.tagName;
15745
+ if (tag === "TEXTAREA") return true;
15746
+ if (tag === "SELECT") return true;
15747
+ if (tag === "INPUT") {
15748
+ var type = (el.getAttribute("type") || "text").toLowerCase();
15749
+ return !/^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/i.test(type);
15506
15750
  }
15751
+ return !!el.isContentEditable;
15507
15752
  }
15508
15753
 
15509
15754
  // Visual viewport handling for better mobile keyboard support
@@ -15513,17 +15758,66 @@
15513
15758
  var vv = window.visualViewport;
15514
15759
  var lastHeight = vv.height;
15515
15760
  var keyboardOpen = false;
15761
+ var lastViewportWidth = Math.max(window.innerWidth || 0, vv.width || 0);
15762
+ var largestViewportHeight = Math.max(window.innerHeight || 0, vv.height || 0);
15763
+ var viewportSettleTimers = [];
15764
+
15765
+ function getCurrentViewportHeightBaseline() {
15766
+ return Math.max(window.innerHeight || 0, vv.height || 0);
15767
+ }
15768
+
15769
+ function refreshViewportBaseline() {
15770
+ var width = Math.max(window.innerWidth || 0, vv.width || 0);
15771
+ var height = getCurrentViewportHeightBaseline();
15772
+ if (Math.abs(width - lastViewportWidth) > 8) {
15773
+ lastViewportWidth = width;
15774
+ largestViewportHeight = height;
15775
+ return;
15776
+ }
15777
+ if (height > largestViewportHeight) {
15778
+ largestViewportHeight = height;
15779
+ }
15780
+ }
15781
+
15782
+ function detectKeyboardOpen(inputBox, offsetBottom) {
15783
+ var activeEl = document.activeElement;
15784
+ var hasEditableFocus = activeEl === inputBox || isEditableFocusTarget(activeEl);
15785
+ var shrinkFromLargest = largestViewportHeight - vv.height;
15786
+ var innerShrinkFromLargest = largestViewportHeight - (window.innerHeight || vv.height || 0);
15787
+ if (offsetBottom > 80) return true;
15788
+ // iOS/Chrome iOS sometimes resize window.innerHeight together with
15789
+ // visualViewport.height, so offsetBottom stays near zero. The
15790
+ // focused-editable + baseline shrink path catches that case.
15791
+ if (hasEditableFocus && (shrinkFromLargest > 120 || innerShrinkFromLargest > 120)) return true;
15792
+ // During close animation focus can disappear before viewport height
15793
+ // is fully restored. Keep the "open" state until the shrink is small.
15794
+ if (keyboardOpen && (shrinkFromLargest > 80 || offsetBottom > 32)) return true;
15795
+ return false;
15796
+ }
15797
+
15798
+ function scheduleViewportSettle() {
15799
+ viewportSettleTimers.forEach(function(timer) { clearTimeout(timer); });
15800
+ viewportSettleTimers = [60, 180, 360, 620].map(function(delay) {
15801
+ return setTimeout(function() {
15802
+ if (!window.__wandImeNative) {
15803
+ resetRootViewportScroll();
15804
+ }
15805
+ syncAppViewportHeight(keyboardOpen);
15806
+ }, delay);
15807
+ });
15808
+ }
15516
15809
 
15517
15810
  function updateViewport() {
15518
15811
  if (!vv) return;
15519
15812
  var inputBox = document.getElementById('input-box');
15520
15813
  var offsetBottom = window.innerHeight - vv.height - vv.offsetTop;
15521
- var isKeyboardOpen = offsetBottom > 50;
15814
+ refreshViewportBaseline();
15815
+ var isKeyboardOpen = detectKeyboardOpen(inputBox, offsetBottom);
15522
15816
  var heightChanged = Math.abs(vv.height - lastHeight) > 8;
15523
15817
 
15524
15818
  // 键盘开/关与视口尺寸变化时同步 --app-viewport-height,
15525
15819
  // 让 body 高度跟随可见区域,input-panel 自然贴键盘上沿。
15526
- syncAppViewportHeight();
15820
+ syncAppViewportHeight(isKeyboardOpen);
15527
15821
 
15528
15822
  if (isKeyboardOpen && (!keyboardOpen || heightChanged) && shouldAdjustForKeyboard(vv, inputBox)) {
15529
15823
  syncInputBoxScroll(inputBox);
@@ -15543,6 +15837,21 @@
15543
15837
  // final scroll lands AFTER the animation settles.
15544
15838
  var wasStickToBottom = state.terminalAutoFollow || isTerminalNearBottom();
15545
15839
  ensureTerminalFit("keyboard-open", { forceReplay: true });
15840
+ // iOS Safari 二次复位: 第一次 syncAppViewportHeight 在键盘动画
15841
+ // 起始帧把 html 滚回 0, 但 iOS 在键盘动画收尾时还会再尝试一次
15842
+ // "把焦点元素拽进可见区", 把 html 重新推上去 —— 留下用户报的
15843
+ // "输入框距离键盘还有很长距离"。镜像 keyboard-close 的 200ms 兜底,
15844
+ // 等键盘动画完整跑完后再清一次 scrollTop + 重算 viewport 高度,
15845
+ // 让 input-panel 最终稳定贴在键盘上沿。
15846
+ // Wand APK (__wandImeNative=true) 跳过, 原生 IME callback 已经在
15847
+ // WebView 层精确 resize, 这里再 scroll 反而抖。
15848
+ if (!window.__wandImeNative) {
15849
+ setTimeout(function() {
15850
+ resetRootViewportScroll();
15851
+ syncAppViewportHeight(true);
15852
+ }, 220);
15853
+ }
15854
+ scheduleViewportSettle();
15546
15855
  // Mirror the keyboard-close 200ms delay: by then the iOS / Android
15547
15856
  // keyboard slide-in animation is done, vv.height is final, and
15548
15857
  // scrollHeight reflects the post-replay grid. One more force
@@ -15565,8 +15874,8 @@
15565
15874
  // window.scrollTo(0,0) 不跑,页面停在键盘抬起时被 iOS 推上去的
15566
15875
  // 偏移位置,input-panel 看起来"没回到底"。
15567
15876
  // 这里在 visualViewport 检测到键盘收起的瞬间直接强制复位一次,
15568
- // 并把 --app-viewport-height 兜底清掉,确保 .app-container 回到
15569
- // 100dvh、input-panel 重新贴屏幕底部。
15877
+ // 并把 --app-viewport-height 同步到键盘收起后的实测高度,确保
15878
+ // input-panel 重新贴屏幕底部。
15570
15879
  //
15571
15880
  // Android APK (window.__wandImeNative=true) 跳过这段 iOS hack —
15572
15881
  // MainActivity 已经在 IME 动画 callback 里逐帧把 root setPadding,
@@ -15576,21 +15885,22 @@
15576
15885
  var rootEl = document.documentElement;
15577
15886
  var imeIsNative = !!window.__wandImeNative;
15578
15887
  if (!imeIsNative) {
15579
- rootEl.style.removeProperty('--app-viewport-height');
15580
- window.scrollTo(0, 0);
15581
- if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
15582
- rootEl.scrollTop = 0;
15583
- if (document.body) document.body.scrollTop = 0;
15888
+ // 不要 removeProperty('--app-viewport-height') —— 那样会让 body 退回
15889
+ // 到 100dvh, 而 iOS Safari 的 100dvh 在键盘动画跑完前经常还停留
15890
+ // 在小值, body 立刻短一截 -> 输入框下方露出大段奶油色 html 背景。
15891
+ // 直接让 syncAppViewportHeight 把它更新为新的 vv.height (键盘已收
15892
+ // 起所以是全可见高度), body 平滑过渡, 不出现空洞。
15893
+ syncAppViewportHeight(false);
15894
+ resetRootViewportScroll();
15584
15895
  }
15896
+ scheduleViewportSettle();
15585
15897
  setTimeout(function() {
15586
15898
  if (!imeIsNative) {
15587
15899
  // 二次复位:等键盘收起动画 + iOS 自身的回滚跑完后再清一次,
15588
15900
  // 防止 iOS 在动画过程中又把 scrollTop 推上去。
15589
- window.scrollTo(0, 0);
15590
- if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
15591
- rootEl.scrollTop = 0;
15592
- if (document.body) document.body.scrollTop = 0;
15593
- syncAppViewportHeight();
15901
+ resetRootViewportScroll();
15902
+ if (rootEl) rootEl.scrollTop = 0;
15903
+ syncAppViewportHeight(false);
15594
15904
  }
15595
15905
  ensureTerminalFit("keyboard-close", { forceReplay: true });
15596
15906
  // 同 handleInputBoxBlur:尊重 terminalAutoFollow,避免把上滚
@@ -15706,11 +16016,18 @@
15706
16016
  // 不改动终端背景的 touch/scroll/wheel —— 单指空白处仍是原生滚动看历史。
15707
16017
 
15708
16018
  function isJoystickAvailable() {
15709
- // 触屏与桌面网页端都显示(球球用 Pointer Events,鼠标拖拽同样可用)
15710
- if (state.currentView !== "terminal") return false;
16019
+ // 触屏与桌面网页端都显示(球球用 Pointer Events,鼠标拖拽同样可用)。
16020
+ // 不再用 currentView/isStructuredSession 关掉:
16021
+ // - chat 视图 (含 PTY Claude 的对话视图): 用户偶尔要给底层 PTY 发
16022
+ // 方向键 / Esc / Shift+Tab 选权限菜单, 但只能切到 terminal 视图才点
16023
+ // 摇杆 —— 现在 chat 视图直接可用。sendJoystickKey 已经走 /input
16024
+ // 接口, 服务端不挑视图。
16025
+ // - 结构化会话: 大多数键 (方向 / Tab) 在 SDK runner 里没真实 effect,
16026
+ // 但 Ctrl+C / Esc 都映射到 query.interrupt() 中断当前回复, 用户
16027
+ // 场景是"等不及当前回答, 想停掉重发"。sendJoystickKey 里按 session
16028
+ // 类型分支处理: PTY 走原本 sequence, 结构化只接受中断意图键。
15711
16029
  var session = getSelectedSession();
15712
16030
  if (!session) return false;
15713
- if (isStructuredSession(session)) return false;
15714
16031
  return true;
15715
16032
  }
15716
16033
 
@@ -15757,16 +16074,21 @@
15757
16074
  for (i = 0; i < JOYSTICK_OUTER_KEYS.length; i++) {
15758
16075
  fnRow += keyBtn(JOYSTICK_OUTER_KEYS[i].key, JOYSTICK_OUTER_KEYS[i].label, "");
15759
16076
  }
15760
- var cornerRow = "";
15761
- for (i = 0; i < JOYSTICK_CORNER_KEYS.length; i++) {
15762
- cornerRow += keyBtn(JOYSTICK_CORNER_KEYS[i].key, JOYSTICK_CORNER_KEYS[i].label, "");
15763
- }
15764
- var modRow = keyBtn("ctrl", "Ctrl", "wjp-mod") + keyBtn("alt", "Alt", "wjp-mod");
15765
- return '<div class="wjp-title">遥控面板</div>' +
16077
+ // 角键 (PgUp/Home/PgDn/End) 与修饰键 (Ctrl/Alt) 排已被裁剪 ——
16078
+ // 当前外圈 4 键全是独立功能键 (Enter / Ctrl+C / Esc / Shift+Tab),
16079
+ // 没有"先按 Ctrl 再按字母"的复合组合, 所以修饰键 toggle 没意义。
16080
+ // CORNER_KEYS 为空时, 对应的 grid 不渲染, 面板高度自动收缩。
16081
+ var html = '<div class="wjp-title">遥控面板</div>' +
15766
16082
  dpad +
15767
- '<div class="wjp-grid wjp-fnkeys">' + fnRow + "</div>" +
15768
- '<div class="wjp-grid wjp-corners">' + cornerRow + "</div>" +
15769
- '<div class="wjp-grid wjp-mods">' + modRow + "</div>";
16083
+ '<div class="wjp-grid wjp-fnkeys">' + fnRow + "</div>";
16084
+ if (JOYSTICK_CORNER_KEYS.length > 0) {
16085
+ var cornerRow = "";
16086
+ for (i = 0; i < JOYSTICK_CORNER_KEYS.length; i++) {
16087
+ cornerRow += keyBtn(JOYSTICK_CORNER_KEYS[i].key, JOYSTICK_CORNER_KEYS[i].label, "");
16088
+ }
16089
+ html += '<div class="wjp-grid wjp-corners">' + cornerRow + "</div>";
16090
+ }
16091
+ return html;
15770
16092
  }
15771
16093
 
15772
16094
  function joystickPolar(r, deg) {
@@ -15816,12 +16138,17 @@
15816
16138
  '<path d="' + joystickSectorPath(JOYSTICK_R0, JOYSTICK_R1, center - 45 + gap, center + 45 - gap) + '"/>' +
15817
16139
  joystickLabelMarkup(k.label, lp.x, lp.y) + "</g>";
15818
16140
  }
15819
- for (i = 0; i < JOYSTICK_OUTER_KEYS.length; i++) {
16141
+ // 外圈扇区宽度跟随 OUTER_KEYS.length 动态计算: 4 90° 每片,
16142
+ // 8 键 → 45° 每片。half 是单片半宽 (扇区中心两侧各延半个 step)。
16143
+ var outerCount = JOYSTICK_OUTER_KEYS.length;
16144
+ var outerStep = outerCount > 0 ? 360 / outerCount : 360;
16145
+ var outerHalf = outerStep / 2;
16146
+ for (i = 0; i < outerCount; i++) {
15820
16147
  k = JOYSTICK_OUTER_KEYS[i];
15821
- center = -90 + i * 45;
16148
+ center = -90 + i * outerStep;
15822
16149
  lp = joystickPolar((JOYSTICK_R1 + JOYSTICK_R2) / 2, center);
15823
16150
  svg += '<g class="wjr-sector wjr-outer" data-key="' + k.key + '">' +
15824
- '<path d="' + joystickSectorPath(JOYSTICK_R1, JOYSTICK_R2, center - 22.5 + gap, center + 22.5 - gap) + '"/>' +
16151
+ '<path d="' + joystickSectorPath(JOYSTICK_R1, JOYSTICK_R2, center - outerHalf + gap, center + outerHalf - gap) + '"/>' +
15825
16152
  joystickLabelMarkup(k.label, lp.x, lp.y) + "</g>";
15826
16153
  }
15827
16154
  svg += '<circle class="wjr-hub" cx="0" cy="0" r="' + (JOYSTICK_R0 - 1) + '"/>';
@@ -16071,10 +16398,14 @@
16071
16398
  if (Math.abs(dy) >= Math.abs(dx)) return { zone: "inner", key: dy < 0 ? "up" : "down" };
16072
16399
  return { zone: "inner", key: dx < 0 ? "left" : "right" };
16073
16400
  }
16074
- // 外圈:8 等分扇区,正上方为 0,顺时针递增;+π/8 让扇区中心对准按钮
16401
+ // 外圈:OUTER_KEYS.length 等分扇区,正上方为 0,顺时针递增;
16402
+ // +halfStep 让扇区中心对准按钮 (原本 N=8 时是 +π/8)。
16075
16403
  var ang = Math.atan2(dx, -dy);
16076
16404
  if (ang < 0) ang += Math.PI * 2;
16077
- var idx = Math.floor((ang + Math.PI / 8) / (Math.PI / 4)) % 8;
16405
+ var outerCount = JOYSTICK_OUTER_KEYS.length;
16406
+ if (outerCount === 0) return { zone: "dead", key: null };
16407
+ var outerStepRad = (Math.PI * 2) / outerCount;
16408
+ var idx = Math.floor((ang + outerStepRad / 2) / outerStepRad) % outerCount;
16078
16409
  return { zone: "outer", key: JOYSTICK_OUTER_KEYS[idx].key };
16079
16410
  }
16080
16411
 
@@ -16179,6 +16510,24 @@
16179
16510
  updateJoystickPanelUI();
16180
16511
  return;
16181
16512
  }
16513
+ var session = getSelectedSession();
16514
+ // ── 结构化会话分支 ──
16515
+ // SDK / claude -p 通道没有 PTY 可写, 把原始 escape 序列丢给
16516
+ // /api/sessions/:id/input 会被结构化 sendMessage 当成对话文本 (例如
16517
+ // 把 "\x1b[A" 作为 prompt 发出去), 既无效又污染上下文。
16518
+ // 这里按"中断意图"白名单转发: Ctrl+C / Esc → query.interrupt()。
16519
+ // 其他键 (方向 / Enter / Shift+Tab) 在结构化里没有合理 mapping, 静默
16520
+ // no-op, 同时震一下做反馈。
16521
+ if (session && isStructuredSession(session)) {
16522
+ if (key === "ctrl_c" || key === "escape") {
16523
+ interruptStructuredSessionFromJoystick(session, key);
16524
+ }
16525
+ // 不论是否真发出去, 都消化掉修饰键 + 更新 UI, 避免下次发送残留状态
16526
+ clearModifiers();
16527
+ updateJoystickPanelUI();
16528
+ return;
16529
+ }
16530
+ // ── PTY 会话原路径 ──
16182
16531
  var seq = buildPtySequence(key, {
16183
16532
  ctrl: state.modifiers.ctrl,
16184
16533
  alt: state.modifiers.alt,
@@ -16190,6 +16539,44 @@
16190
16539
  scheduleShortcutResync();
16191
16540
  }
16192
16541
 
16542
+ // 摇杆触发的结构化会话中断: 复用 /api/structured-sessions/:id/messages
16543
+ // 的 interrupt=true 路径 (sendMessage 内部走 query.interrupt 优雅停止,
16544
+ // 失败 fallback 到 abortController.abort)。空 input + interrupt=true =
16545
+ // "停掉当前回复但不发新消息", 跟用户从摇杆按 Ctrl+C/Esc 的预期一致。
16546
+ function interruptStructuredSessionFromJoystick(session, key) {
16547
+ if (!session || !session.id) return;
16548
+ fetch("/api/structured-sessions/" + session.id + "/messages", {
16549
+ method: "POST",
16550
+ headers: { "Content-Type": "application/json" },
16551
+ credentials: "same-origin",
16552
+ body: JSON.stringify({ input: "", interrupt: true, preserveQueue: true }),
16553
+ })
16554
+ .then(function(res) {
16555
+ if (!res.ok) return res.json().catch(function() { return {}; }).then(function(p) {
16556
+ throw new Error((p && p.error) || ("中断失败 (key=" + key + ")"));
16557
+ });
16558
+ return res.json();
16559
+ })
16560
+ .then(function(snapshot) {
16561
+ if (snapshot && snapshot.id) {
16562
+ updateSessionSnapshot(snapshot);
16563
+ if (snapshot.id === state.selectedId) {
16564
+ var refreshed = state.sessions.find(function(s) { return s.id === snapshot.id; }) || snapshot;
16565
+ state.currentMessages = buildMessagesForRender(refreshed, getPreferredMessages(refreshed, snapshot.output, false));
16566
+ renderChat(true);
16567
+ if (typeof updateQueueBar === "function") updateQueueBar();
16568
+ }
16569
+ }
16570
+ })
16571
+ .catch(function(err) {
16572
+ // 已经在 SDK 内部完成 / 没有 pending query 时, 服务端会返回 400,
16573
+ // 这里静默吃掉, 避免给用户冒出"中断失败"toast (按了也是想停, 没东西可停就当成功)。
16574
+ if (window && window.console && err && err.message) {
16575
+ console.debug("[wand] joystick interrupt no-op:", err.message);
16576
+ }
16577
+ });
16578
+ }
16579
+
16193
16580
  function toggleJoystickPanel() {
16194
16581
  if (state.joystickPinnedOpen) closeJoystickPanel();
16195
16582
  else openJoystickPanel();