@co0ontty/wand 1.39.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.
@@ -1912,7 +1912,7 @@
1912
1912
  '</div>' +
1913
1913
  '<div class="file-search-box">' +
1914
1914
  '<span class="file-search-icon">' + wandFileIcon("search", { size: 14 }) + '</span>' +
1915
- '<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" />' +
1916
1916
  '<button class="file-search-clear" id="file-search-clear" type="button" aria-label="清除搜索" title="清除">' +
1917
1917
  wandFileIcon("x", { size: 13 }) +
1918
1918
  '</button>' +
@@ -2095,7 +2095,7 @@
2095
2095
  '<div id="folder-breadcrumb" class="folder-breadcrumb"></div>' +
2096
2096
  '<div class="folder-picker">' +
2097
2097
  '<span class="folder-picker-icon">' + iconSvg("folder", { size: 15, strokeWidth: 1.7 }) + '</span>' +
2098
- '<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" />' +
2099
2099
  '</div>' +
2100
2100
  '<div id="folder-picker-dropdown" class="folder-picker-dropdown hidden"></div>' +
2101
2101
  '<div id="folder-picker-validation" class="folder-picker-validation"></div>' +
@@ -3538,9 +3538,6 @@
3538
3538
 
3539
3539
  function renderCollapsedSessionTiles() {
3540
3540
  var entries = getRecentEntries();
3541
- if (entries.length === 0) {
3542
- return '<div class="sidebar-collapsed-empty" title="无会话">—</div>';
3543
- }
3544
3541
  var tiles = entries.map(function(e, i) {
3545
3542
  var idx = i + 1;
3546
3543
  if (e.kind === "session") {
@@ -3554,7 +3551,11 @@
3554
3551
  var hTitle = preview + " · " + formatHistoryTime(h.timestamp);
3555
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>';
3556
3553
  }).join("");
3557
- 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>';
3558
3559
  }
3559
3560
 
3560
3561
  function renderSessionsListContent() {
@@ -7248,6 +7249,12 @@
7248
7249
 
7249
7250
  var collapsedTile = target.closest(".sidebar-collapsed-tile");
7250
7251
  if (collapsedTile && collapsedTile instanceof HTMLElement) {
7252
+ if (collapsedTile.dataset.collapsedNewSession) {
7253
+ event.preventDefault();
7254
+ event.stopPropagation();
7255
+ openSessionModal();
7256
+ return;
7257
+ }
7251
7258
  if (collapsedTile.dataset.collapsedSessionId) {
7252
7259
  event.preventDefault();
7253
7260
  event.stopPropagation();
@@ -13941,24 +13948,24 @@
13941
13948
  { key: "down", label: "↓" },
13942
13949
  { key: "left", label: "←" }
13943
13950
  ];
13944
- // 外圈 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
+ // 占位 + 误点。
13945
13959
  var JOYSTICK_OUTER_KEYS = [
13946
13960
  { key: "enter", label: "Enter" },
13947
- { key: "escape", label: "Esc" },
13948
- { key: "tab", label: "Tab" },
13949
- { key: "shift_tab", label: "Shift+Tab" },
13950
13961
  { key: "ctrl_c", label: "Ctrl+C" },
13951
- { key: "ctrl_z", label: "Ctrl+Z" },
13952
- { key: "ctrl_d", label: "Ctrl+D" },
13953
- { key: "ctrl_l", label: "Ctrl+L" }
13954
- ];
13955
- // 钉住面板四角翻页键
13956
- var JOYSTICK_CORNER_KEYS = [
13957
- { key: "pageup", label: "PgUp" },
13958
- { key: "home", label: "Home" },
13959
- { key: "pagedown", label: "PgDn" },
13960
- { key: "end", label: "End" }
13962
+ { key: "escape", label: "Esc" },
13963
+ { key: "shift_tab", label: "Shift+Tab" }
13961
13964
  ];
13965
+ // 钉住面板四角翻页键 —— 已弃用 (PgUp/Home/PgDn/End 在 Claude TUI 里
13966
+ // 不是常用导航, 也跟终端历史回滚冲突)。留空数组让面板渲染逻辑自然
13967
+ // 跳过这一排, 不删数组以保 ringSvg 之外别处 reference 安全。
13968
+ var JOYSTICK_CORNER_KEYS = [];
13962
13969
 
13963
13970
  var ignoredInteractiveTargetIds = new Set([
13964
13971
  "mini-keyboard-fab",
@@ -14947,7 +14954,8 @@
14947
14954
  function handleInputBoxBlur() {
14948
14955
  resetInputPanelViewportSpacing();
14949
14956
  setTimeout(function() {
14950
- window.scrollTo(0, 0);
14957
+ resetRootViewportScroll();
14958
+ syncAppViewportHeight(false);
14951
14959
  // On mobile, force terminal refit + scroll after keyboard dismissal.
14952
14960
  // The container height restores but terminal needs time to
14953
14961
  // fill the expanded space, and the scroll position needs resetting.
@@ -14959,6 +14967,10 @@
14959
14967
  maybeScrollTerminalToBottom("keyboard");
14960
14968
  }
14961
14969
  }, 100);
14970
+ setTimeout(function() {
14971
+ resetRootViewportScroll();
14972
+ syncAppViewportHeight(false);
14973
+ }, 360);
14962
14974
  }
14963
14975
 
14964
14976
  function adjustInputBoxSelection(inputBox) {
@@ -15669,18 +15681,74 @@
15669
15681
  // adjustResize 不再自动 resize WebView 内容;同时仅给 input-panel
15670
15682
  // 加 padding-bottom 只是把 panel 内部底部撑空,并不会让 panel 自身
15671
15683
  // 上移。这里通过 CSS 变量驱动整层高度,是跨 WebView/Chrome/PWA 的
15672
- // 统一兜底。仅在视口比窗口明显变小时(典型 = 软键盘弹起)覆盖,
15673
- // 桌面与无键盘场景维持 100dvh 不抖。
15674
- 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) {
15675
15712
  var vv = window.visualViewport;
15676
15713
  if (!vv) return;
15677
- var diff = window.innerHeight - vv.height - vv.offsetTop;
15678
15714
  var root = document.documentElement;
15679
- if (diff > 50) {
15680
- root.style.setProperty('--app-viewport-height', vv.height + 'px');
15681
- } else {
15682
- 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);
15683
15750
  }
15751
+ return !!el.isContentEditable;
15684
15752
  }
15685
15753
 
15686
15754
  // Visual viewport handling for better mobile keyboard support
@@ -15690,17 +15758,66 @@
15690
15758
  var vv = window.visualViewport;
15691
15759
  var lastHeight = vv.height;
15692
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
+ }
15693
15809
 
15694
15810
  function updateViewport() {
15695
15811
  if (!vv) return;
15696
15812
  var inputBox = document.getElementById('input-box');
15697
15813
  var offsetBottom = window.innerHeight - vv.height - vv.offsetTop;
15698
- var isKeyboardOpen = offsetBottom > 50;
15814
+ refreshViewportBaseline();
15815
+ var isKeyboardOpen = detectKeyboardOpen(inputBox, offsetBottom);
15699
15816
  var heightChanged = Math.abs(vv.height - lastHeight) > 8;
15700
15817
 
15701
15818
  // 键盘开/关与视口尺寸变化时同步 --app-viewport-height,
15702
15819
  // 让 body 高度跟随可见区域,input-panel 自然贴键盘上沿。
15703
- syncAppViewportHeight();
15820
+ syncAppViewportHeight(isKeyboardOpen);
15704
15821
 
15705
15822
  if (isKeyboardOpen && (!keyboardOpen || heightChanged) && shouldAdjustForKeyboard(vv, inputBox)) {
15706
15823
  syncInputBoxScroll(inputBox);
@@ -15720,6 +15837,21 @@
15720
15837
  // final scroll lands AFTER the animation settles.
15721
15838
  var wasStickToBottom = state.terminalAutoFollow || isTerminalNearBottom();
15722
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();
15723
15855
  // Mirror the keyboard-close 200ms delay: by then the iOS / Android
15724
15856
  // keyboard slide-in animation is done, vv.height is final, and
15725
15857
  // scrollHeight reflects the post-replay grid. One more force
@@ -15742,8 +15874,8 @@
15742
15874
  // window.scrollTo(0,0) 不跑,页面停在键盘抬起时被 iOS 推上去的
15743
15875
  // 偏移位置,input-panel 看起来"没回到底"。
15744
15876
  // 这里在 visualViewport 检测到键盘收起的瞬间直接强制复位一次,
15745
- // 并把 --app-viewport-height 兜底清掉,确保 .app-container 回到
15746
- // 100dvh、input-panel 重新贴屏幕底部。
15877
+ // 并把 --app-viewport-height 同步到键盘收起后的实测高度,确保
15878
+ // input-panel 重新贴屏幕底部。
15747
15879
  //
15748
15880
  // Android APK (window.__wandImeNative=true) 跳过这段 iOS hack —
15749
15881
  // MainActivity 已经在 IME 动画 callback 里逐帧把 root setPadding,
@@ -15753,21 +15885,22 @@
15753
15885
  var rootEl = document.documentElement;
15754
15886
  var imeIsNative = !!window.__wandImeNative;
15755
15887
  if (!imeIsNative) {
15756
- rootEl.style.removeProperty('--app-viewport-height');
15757
- window.scrollTo(0, 0);
15758
- if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
15759
- rootEl.scrollTop = 0;
15760
- 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();
15761
15895
  }
15896
+ scheduleViewportSettle();
15762
15897
  setTimeout(function() {
15763
15898
  if (!imeIsNative) {
15764
15899
  // 二次复位:等键盘收起动画 + iOS 自身的回滚跑完后再清一次,
15765
15900
  // 防止 iOS 在动画过程中又把 scrollTop 推上去。
15766
- window.scrollTo(0, 0);
15767
- if (document.scrollingElement) document.scrollingElement.scrollTop = 0;
15768
- rootEl.scrollTop = 0;
15769
- if (document.body) document.body.scrollTop = 0;
15770
- syncAppViewportHeight();
15901
+ resetRootViewportScroll();
15902
+ if (rootEl) rootEl.scrollTop = 0;
15903
+ syncAppViewportHeight(false);
15771
15904
  }
15772
15905
  ensureTerminalFit("keyboard-close", { forceReplay: true });
15773
15906
  // 同 handleInputBoxBlur:尊重 terminalAutoFollow,避免把上滚
@@ -15883,11 +16016,18 @@
15883
16016
  // 不改动终端背景的 touch/scroll/wheel —— 单指空白处仍是原生滚动看历史。
15884
16017
 
15885
16018
  function isJoystickAvailable() {
15886
- // 触屏与桌面网页端都显示(球球用 Pointer Events,鼠标拖拽同样可用)
15887
- 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, 结构化只接受中断意图键。
15888
16029
  var session = getSelectedSession();
15889
16030
  if (!session) return false;
15890
- if (isStructuredSession(session)) return false;
15891
16031
  return true;
15892
16032
  }
15893
16033
 
@@ -15934,16 +16074,21 @@
15934
16074
  for (i = 0; i < JOYSTICK_OUTER_KEYS.length; i++) {
15935
16075
  fnRow += keyBtn(JOYSTICK_OUTER_KEYS[i].key, JOYSTICK_OUTER_KEYS[i].label, "");
15936
16076
  }
15937
- var cornerRow = "";
15938
- for (i = 0; i < JOYSTICK_CORNER_KEYS.length; i++) {
15939
- cornerRow += keyBtn(JOYSTICK_CORNER_KEYS[i].key, JOYSTICK_CORNER_KEYS[i].label, "");
15940
- }
15941
- var modRow = keyBtn("ctrl", "Ctrl", "wjp-mod") + keyBtn("alt", "Alt", "wjp-mod");
15942
- 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>' +
15943
16082
  dpad +
15944
- '<div class="wjp-grid wjp-fnkeys">' + fnRow + "</div>" +
15945
- '<div class="wjp-grid wjp-corners">' + cornerRow + "</div>" +
15946
- '<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;
15947
16092
  }
15948
16093
 
15949
16094
  function joystickPolar(r, deg) {
@@ -15993,12 +16138,17 @@
15993
16138
  '<path d="' + joystickSectorPath(JOYSTICK_R0, JOYSTICK_R1, center - 45 + gap, center + 45 - gap) + '"/>' +
15994
16139
  joystickLabelMarkup(k.label, lp.x, lp.y) + "</g>";
15995
16140
  }
15996
- 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++) {
15997
16147
  k = JOYSTICK_OUTER_KEYS[i];
15998
- center = -90 + i * 45;
16148
+ center = -90 + i * outerStep;
15999
16149
  lp = joystickPolar((JOYSTICK_R1 + JOYSTICK_R2) / 2, center);
16000
16150
  svg += '<g class="wjr-sector wjr-outer" data-key="' + k.key + '">' +
16001
- '<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) + '"/>' +
16002
16152
  joystickLabelMarkup(k.label, lp.x, lp.y) + "</g>";
16003
16153
  }
16004
16154
  svg += '<circle class="wjr-hub" cx="0" cy="0" r="' + (JOYSTICK_R0 - 1) + '"/>';
@@ -16248,10 +16398,14 @@
16248
16398
  if (Math.abs(dy) >= Math.abs(dx)) return { zone: "inner", key: dy < 0 ? "up" : "down" };
16249
16399
  return { zone: "inner", key: dx < 0 ? "left" : "right" };
16250
16400
  }
16251
- // 外圈:8 等分扇区,正上方为 0,顺时针递增;+π/8 让扇区中心对准按钮
16401
+ // 外圈:OUTER_KEYS.length 等分扇区,正上方为 0,顺时针递增;
16402
+ // +halfStep 让扇区中心对准按钮 (原本 N=8 时是 +π/8)。
16252
16403
  var ang = Math.atan2(dx, -dy);
16253
16404
  if (ang < 0) ang += Math.PI * 2;
16254
- 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;
16255
16409
  return { zone: "outer", key: JOYSTICK_OUTER_KEYS[idx].key };
16256
16410
  }
16257
16411
 
@@ -16356,6 +16510,24 @@
16356
16510
  updateJoystickPanelUI();
16357
16511
  return;
16358
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 会话原路径 ──
16359
16531
  var seq = buildPtySequence(key, {
16360
16532
  ctrl: state.modifiers.ctrl,
16361
16533
  alt: state.modifiers.alt,
@@ -16367,6 +16539,44 @@
16367
16539
  scheduleShortcutResync();
16368
16540
  }
16369
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
+
16370
16580
  function toggleJoystickPanel() {
16371
16581
  if (state.joystickPinnedOpen) closeJoystickPanel();
16372
16582
  else openJoystickPanel();
@@ -258,9 +258,9 @@
258
258
  min-height: 100dvh;
259
259
  height: 100vh;
260
260
  height: 100dvh;
261
- /* 软键盘弹起时由 JS 注入 --app-viewport-height = visualViewport.height,
262
- 整体 flex column 自动收缩,让底部 input-panel 上移贴键盘上沿;
263
- 没注入时退回 100dvh 行为。 */
261
+ /* JS 注入 --app-viewport-height = visualViewport.height,
262
+ 键盘开关和 iOS 地址栏动画时整体 flex column 跟随可见视口;
263
+ 不支持 visualViewport 时退回 100dvh */
264
264
  height: var(--app-viewport-height, 100dvh);
265
265
  line-height: var(--line-height-base);
266
266
  overflow: hidden;
@@ -310,7 +310,7 @@
310
310
  min-height: 100dvh;
311
311
  height: 100vh;
312
312
  height: 100dvh;
313
- /* 与 body 同步:键盘弹起时跟随可见视口收缩。 */
313
+ /* 与 body 同步:键盘开关时跟随可见视口收缩/恢复。 */
314
314
  height: var(--app-viewport-height, 100dvh);
315
315
  overflow: hidden;
316
316
  }
@@ -547,7 +547,8 @@
547
547
  }
548
548
  .sidebar.pinned.collapsed .sidebar-header-main,
549
549
  .sidebar.pinned.collapsed .sidebar-header-more,
550
- .sidebar.pinned.collapsed .sidebar-footer {
550
+ .sidebar.pinned.collapsed .sidebar-footer,
551
+ .sidebar.pinned.collapsed .sidebar-history-region {
551
552
  display: none;
552
553
  }
553
554
  .sidebar.pinned.collapsed .sidebar-header-actions {
@@ -613,6 +614,24 @@
613
614
  0 4px 10px -3px rgba(120, 88, 56, 0.3),
614
615
  inset 0 1px 0 rgba(255, 255, 255, 0.48);
615
616
  }
617
+ /* 快速新建会话 tile —— 虚线描边 + 加号,区别于实心会话方块 */
618
+ .sidebar-collapsed-tile.add {
619
+ background: rgba(184, 92, 55, 0.06);
620
+ border: 1.5px dashed rgba(184, 92, 55, 0.5);
621
+ color: #a04a2e;
622
+ box-shadow: none;
623
+ margin-top: 2px;
624
+ }
625
+ .sidebar-collapsed-tile.add:hover {
626
+ filter: none;
627
+ transform: translateY(-1px);
628
+ background: rgba(184, 92, 55, 0.12);
629
+ border-color: rgba(184, 92, 55, 0.72);
630
+ box-shadow: 0 3px 8px -3px rgba(184, 92, 55, 0.32);
631
+ }
632
+ .sidebar-collapsed-tile.add:active {
633
+ transform: translateY(0) scale(0.94);
634
+ }
616
635
  /* Active — accent ring + 一层柔和暖投影 + 顶高光。原先 7 层叠加在 36px 小块上
617
636
  糊成一团,精简到 3 层即可表达「升起 + 选中」。 */
618
637
  .sidebar-collapsed-tile.active {
@@ -627,13 +646,6 @@
627
646
  transform: translateY(0) scale(0.94);
628
647
  transition-duration: 0.08s;
629
648
  }
630
- .sidebar-collapsed-empty {
631
- color: var(--text-muted, rgba(89, 58, 32, 0.45));
632
- text-align: center;
633
- font-size: 16px;
634
- padding: 12px 0;
635
- user-select: none;
636
- }
637
649
  /* Hover tooltip bubble for narrow tiles — chat-bubble look */
638
650
  .sidebar-tile-bubble {
639
651
  position: fixed;
@@ -4045,6 +4057,12 @@
4045
4057
  min-height: 0;
4046
4058
  min-width: 0;
4047
4059
  scrollbar-gutter: stable;
4060
+ /* iOS Safari: 聊天到顶 / 到底再用力滑, scroll 会从 .chat-messages 冒到
4061
+ body, 即使父级有 overflow:hidden 也拦不住 (Safari 已知行为)。这一冒
4062
+ 有两个用户可见的副作用: 1) 整页跟着一起被推一帧, 输入框跳动;
4063
+ 2) 偶尔触发地址栏 / 工具栏的折叠动画。contain 把惯性吃在容器内,
4064
+ 保留 iOS 那种橡皮筋反弹手感, 但不向外溢出。 */
4065
+ overscroll-behavior: contain;
4048
4066
  }
4049
4067
 
4050
4068
  /* ===== 历史消息懒加载 ===== */
@@ -10169,8 +10187,8 @@
10169
10187
  }
10170
10188
 
10171
10189
  html, body {
10172
- min-height: 100dvh;
10173
- height: auto;
10190
+ min-height: var(--app-viewport-height, 100dvh);
10191
+ height: var(--app-viewport-height, 100dvh);
10174
10192
  }
10175
10193
 
10176
10194
  body {
@@ -10181,9 +10199,9 @@
10181
10199
  .app-container {
10182
10200
  min-height: 100dvh;
10183
10201
  height: 100dvh;
10184
- /* 与 body 同步:键盘弹起时 JS 注入 --app-viewport-height,
10185
- app-container 跟随 visualViewport 收缩,input-panel 自动贴键盘上沿;
10186
- 键盘收起后变量被移除,回到 100dvh,input-panel 自然回到屏幕底部。 */
10202
+ /* 与 body 同步:JS 持续注入 --app-viewport-height,
10203
+ app-container 跟随 visualViewport 收缩/恢复,input-panel 自动贴键盘
10204
+ 上沿或屏幕底部。 */
10187
10205
  height: var(--app-viewport-height, 100dvh);
10188
10206
  overflow: hidden;
10189
10207
  }
@@ -10740,7 +10758,11 @@
10740
10758
  .file-search-box { padding: 6px 10px 8px; }
10741
10759
  .file-search-icon { left: 20px; }
10742
10760
  .file-search-clear { right: 16px; }
10743
- .file-search-input { min-height: 34px; font-size: 14px; padding: 7px 32px 7px 30px; }
10761
+ /* 16px 兜底 iOS Safari 聚焦自动放大:
10762
+ 即使设了 user-scalable=no, 部分 iOS 版本 (尤其 15 / 16 早期补丁) 仍会
10763
+ 在 input font-size < 16px 时强制 zoom in, 之后回不到原 scale, 用户得
10764
+ 手动双指捏回。所有移动端会被聚焦的 input 都统一到 16px。 */
10765
+ .file-search-input { min-height: 34px; font-size: 16px; padding: 7px 32px 7px 30px; }
10744
10766
  .file-item { padding: 6px 8px; min-height: 32px; }
10745
10767
 
10746
10768
  /* 欢迎页移动端 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.39.0",
3
+ "version": "1.39.1",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {