@co0ontty/wand 1.32.3 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -135,6 +135,7 @@
135
135
  // askuserquestion 菜单多份叠加的最强候选根因。
136
136
  syncOutputBuffer: null,
137
137
  syncOutputDeadline: 0,
138
+ syncFramingResidue: false,
138
139
  lastChunkAt: 0,
139
140
  terminalHealthTimer: null,
140
141
  lastTerminalResyncAt: 0,
@@ -147,12 +148,14 @@
147
148
  // scroll 动画),多次调用用 Math.max 合并、不会被短窗口缩短。
148
149
  terminalProgrammaticScrollUntil: 0,
149
150
  terminalScrollIdleTimer: null,
150
- terminalScrollIdleMs: 1800,
151
151
  terminalScrollThreshold: 12,
152
152
  showTerminalJumpToBottom: false,
153
153
  terminalViewportEl: null,
154
154
  terminalViewportScrollHandler: null,
155
155
  terminalViewportTouchHandler: null,
156
+ terminalViewportTouchStartHandler: null,
157
+ terminalTouchStartY: 0,
158
+ terminalComposing: false,
156
159
  resizeObserver: null,
157
160
  resizeHandler: null,
158
161
  resizeTimer: null,
@@ -6179,12 +6182,25 @@
6179
6182
  inputBox.addEventListener("keydown", handleInputBoxKeydown);
6180
6183
  inputBox.addEventListener("paste", handleInputPaste);
6181
6184
  inputBox.addEventListener("input", function() {
6185
+ // INPUT-3: IME 组字期间不把半成品发给 PTY,等 compositionend 再统一发。
6186
+ if (state.terminalComposing) return;
6182
6187
  if (handleInteractiveTextInput(inputBox)) {
6183
6188
  return;
6184
6189
  }
6185
6190
  refreshInputBoxState(inputBox);
6186
6191
  setDraftValue(inputBox.value, true);
6187
6192
  });
6193
+ // INPUT-3: 交互模式 IME 组字承接。compositionstart 起置位标志让 input
6194
+ // handler 静默;compositionend 取最终组字结果发 PTY 并清空。非交互模式不
6195
+ // 介入,正常的中文聊天输入不受影响。
6196
+ inputBox.addEventListener("compositionstart", function() {
6197
+ if (state.terminalInteractive) state.terminalComposing = true;
6198
+ });
6199
+ inputBox.addEventListener("compositionend", function() {
6200
+ if (!state.terminalComposing) return;
6201
+ state.terminalComposing = false;
6202
+ if (state.terminalInteractive) handleInteractiveTextInput(inputBox);
6203
+ });
6188
6204
  inputBox.addEventListener("focus", function() {
6189
6205
  // Close drawer when user focuses input to avoid backdrop blocking clicks
6190
6206
  closeSessionsDrawer();
@@ -7132,7 +7148,10 @@
7132
7148
  var shouldShow = !!state.selectedId
7133
7149
  && state.currentView === "terminal"
7134
7150
  && !state.terminalAutoFollow
7135
- && !isTerminalNearBottom();
7151
+ // SCROLL-2: 隐藏判据用严格 2px(isTerminalAtBottom) 而非 12px。否则距底
7152
+ // 3–12px 区间 autoFollow 恒 false(scroll handler 只在 ≤2px 才恢复)却又
7153
+ // 因 isTerminalNearBottom()=true 隐藏按钮 → 既不跟随又无回底入口的死区。
7154
+ && !isTerminalAtBottom();
7136
7155
  state.showTerminalJumpToBottom = shouldShow;
7137
7156
  if (button) {
7138
7157
  button.classList.toggle("visible", shouldShow);
@@ -7180,22 +7199,19 @@
7180
7199
  }
7181
7200
  }
7182
7201
 
7183
- function scheduleTerminalResumeFollow() {
7184
- clearTerminalScrollIdleTimer();
7185
- updateTerminalJumpToBottomButton();
7186
- state.terminalScrollIdleTimer = setTimeout(function() {
7187
- state.terminalScrollIdleTimer = null;
7188
- state.terminalAutoFollow = true;
7189
- if (!isTerminalNearBottom()) {
7190
- scrollTerminalToBottom(true);
7191
- }
7192
- updateTerminalJumpToBottomButton();
7193
- }, state.terminalScrollIdleMs);
7194
- }
7195
-
7196
7202
  function setTerminalManualScrollActive() {
7197
7203
  state.terminalAutoFollow = false;
7198
7204
  clearTerminalScrollIdleTimer();
7205
+ // SCROLL-1: 用户一旦表达"看历史"意图,立刻作废任何在途的程序性拽底。
7206
+ // 否则一个已排队、尚未 fire 的 wterm rAF _doRender 仍读着旧的
7207
+ // _shouldScrollToBottom=true 把视口拽回底,而那次拽底触发的 scroll 事件正好
7208
+ // 落在 120ms 程序窗口内被 scroll handler early-return 吞掉——上滚意图被悄悄
7209
+ // 撤回。这里同步按掉 wterm 的跟随意图、并清零程序窗口,让紧随的真实 scroll
7210
+ // 事件能被 handler 正常复判。
7211
+ state.terminalProgrammaticScrollUntil = 0;
7212
+ if (state.terminal && "_shouldScrollToBottom" in state.terminal) {
7213
+ state.terminal._shouldScrollToBottom = false;
7214
+ }
7199
7215
  updateTerminalJumpToBottomButton();
7200
7216
  }
7201
7217
 
@@ -7446,9 +7462,17 @@
7446
7462
 
7447
7463
  function createWideParserState() { return { mode: "normal" }; }
7448
7464
 
7465
+ // PERF-1: 整块纯 ASCII 且无 ESC ⇒ 无宽字符、无 ANSI 序列,可跳过逐字符扫描。
7466
+ function isAsciiNonEscape(s) {
7467
+ return !/[^\x00-\x7f]/.test(s) && s.indexOf("\x1b") === -1;
7468
+ }
7469
+
7449
7470
  function widePadAnsi(data, st) {
7450
7471
  if (!data) return "";
7451
7472
  var s = String(data);
7473
+ // PERF-1: 不在 ANSI 解析中间态、且整块纯 ASCII 无转义时原样返回,省下逐字符
7474
+ // 拼接与 U+2060 注入。Claude 流式输出与全量重放大量命中此快路径。
7475
+ if (st.mode === "normal" && isAsciiNonEscape(s)) return s;
7452
7476
  var out = "";
7453
7477
  for (var i = 0; i < s.length; i++) {
7454
7478
  var code = s.charCodeAt(i);
@@ -7535,6 +7559,9 @@
7535
7559
  // 护栏:超长/超时强制 flush,避免永久卡死
7536
7560
  out += state.syncOutputBuffer;
7537
7561
  state.syncOutputBuffer = null;
7562
+ // FLICKER: 这是 NEW-A 唯一"失手"路径——半个 ?2026 帧被透传给 wterm,
7563
+ // 可能渲染错位。打标记让 R6 chunk 兜底 resync 一次(且仅此时触发)。
7564
+ state.syncFramingResidue = true;
7538
7565
  }
7539
7566
  return out;
7540
7567
  }
@@ -7634,10 +7661,17 @@
7634
7661
  var node = anchor && anchor.nodeType === 3 ? anchor.parentNode : anchor;
7635
7662
  var output = document.getElementById("output");
7636
7663
  if (!output || !node || !output.contains(node)) return;
7664
+ // COPY-1: 终端每行被 wterm 补齐到整列宽(空 cell 输出真实空格 + white-space:pre),
7665
+ // 选中复制会带一长串行尾空格;同时宽字符后插了零宽填充符 U+2060。两者一起清:
7666
+ // 逐行剥 filler + trimEnd。只要选区落在 #output 内就处理(不再要求"含 filler
7667
+ // 才改写",否则纯 ASCII 行的尾随空格漏网)。
7637
7668
  var text = sel.toString();
7638
- if (text.indexOf(WAND_WIDE_FILLER) === -1) return;
7669
+ var cleaned = text.split("\n").map(function(line) {
7670
+ return line.split(WAND_WIDE_FILLER).join("").replace(/[ \t]+$/, "");
7671
+ }).join("\n");
7672
+ if (cleaned === text) return; // 无可清理内容,交回浏览器默认复制
7639
7673
  if (e.clipboardData) {
7640
- e.clipboardData.setData("text/plain", text.split(WAND_WIDE_FILLER).join(""));
7674
+ e.clipboardData.setData("text/plain", cleaned);
7641
7675
  e.preventDefault();
7642
7676
  }
7643
7677
  });
@@ -7760,6 +7794,11 @@
7760
7794
  var RESYNC_BUDGET_WINDOW_MS = 5000;
7761
7795
  var RESYNC_BUDGET_MAX = 12;
7762
7796
  var RESYNC_WARN_COOLDOWN_MS = 30000;
7797
+ // RENDER-1: softResync 自身的重放(wandTerminalWrite 末尾会调 maybeScheduleResyncForChunk)
7798
+ // 不应再触发新一轮 resync,否则从 health-check/onResize/刷新/重连等路径进来时,
7799
+ // 整段含 CSI 的 replaySource 会让单次 resync 被放大成 2~3 次全量重放。重放期间置位
7800
+ // 此标志,maybeScheduleResyncForChunk 开头据此短路。
7801
+ var _resyncInProgress = false;
7763
7802
  function softResyncTerminal(options) {
7764
7803
  if (!state.terminal || !state.terminalOutput) return false;
7765
7804
  var opts = options || {};
@@ -7772,8 +7811,13 @@
7772
7811
  var replaySource = marker > 0 ? state.terminalOutput.slice(marker) : state.terminalOutput;
7773
7812
  var bufLen = replaySource.length;
7774
7813
  var startedAt = (typeof performance !== "undefined" && performance.now) ? performance.now() : Date.now();
7775
- resetTerminal();
7776
- wandTerminalWrite(state.terminal, replaySource);
7814
+ _resyncInProgress = true;
7815
+ try {
7816
+ resetTerminal();
7817
+ wandTerminalWrite(state.terminal, replaySource);
7818
+ } finally {
7819
+ _resyncInProgress = false;
7820
+ }
7777
7821
  state.lastTerminalResyncAt = Date.now();
7778
7822
  maybeScrollTerminalToBottom("output");
7779
7823
  if (!opts.skipFit) ensureTerminalFit("refresh");
@@ -7824,6 +7868,14 @@
7824
7868
  var _resyncChunkLastAt = 0;
7825
7869
  var _resyncChunkTailTimer = null;
7826
7870
  function maybeScheduleResyncForChunk(chunk) {
7871
+ if (_resyncInProgress) return; // RENDER-1: 屏蔽 softResync 自身重放触发的递归
7872
+ // FLICKER: R6 chunk 热路径 resync 仅在 NEW-A 失手时兜底——即 ?2026 残帧被
7873
+ // processSyncOutputFraming 超时/超长强制 flush 透传给 wterm 之后。正常完整包帧
7874
+ // 的重绘(菜单/todo/thinking spinner)已被 NEW-A 原子化、wterm 渲染正确,无需
7875
+ // resync。旧实现对每个含 CSI 的 chunk 都触发,使 thinking 期间每 ~1.5s 一次
7876
+ // resetTerminal→空白帧→重画,终端区持续闪烁。残帧标记在此消费一次(仅此触发)。
7877
+ if (!state.syncFramingResidue) return;
7878
+ state.syncFramingResidue = false;
7827
7879
  if (!chunk || typeof chunk !== "string") return;
7828
7880
  if (chunk.indexOf("\x1b[") === -1) return;
7829
7881
  if (!IN_PLACE_REDRAW_RE.test(chunk)) return;
@@ -8053,15 +8105,32 @@
8053
8105
  }
8054
8106
  setTerminalManualScrollActive();
8055
8107
  };
8056
- state.terminalViewportTouchHandler = function() {
8057
- setTerminalManualScrollActive();
8108
+ // SCROLL-3: 触摸只在"看历史"方向(手指下拉、clientY 增大)才下台跟随;
8109
+ // 手指上滑(朝新内容/底部)不关跟随,交给 scroll handler 在到底时恢复。
8110
+ // 终端非 column-reverse:手指下拉=内容下移=看上方历史=上滚意图,与 wheel
8111
+ // 的 deltaY<0 对称。原实现任何 touchmove 都关跟随,移动端在底部轻微回弹
8112
+ // 就丢跟随。
8113
+ state.terminalViewportTouchStartHandler = function(e) {
8114
+ if (e.touches && e.touches.length === 1) {
8115
+ state.terminalTouchStartY = e.touches[0].clientY;
8116
+ }
8117
+ };
8118
+ state.terminalViewportTouchHandler = function(e) {
8119
+ if (!e.touches || e.touches.length !== 1) return;
8120
+ if (typeof state.terminalTouchStartY !== "number") return;
8121
+ if (e.touches[0].clientY - state.terminalTouchStartY > 4) {
8122
+ setTerminalManualScrollActive();
8123
+ }
8058
8124
  };
8059
8125
  viewport.addEventListener("scroll", state.terminalViewportScrollHandler, { passive: true });
8126
+ viewport.addEventListener("touchstart", state.terminalViewportTouchStartHandler, { passive: true });
8060
8127
  viewport.addEventListener("touchmove", state.terminalViewportTouchHandler, { passive: true });
8061
8128
  }
8062
8129
 
8130
+ // SCROLL-5: 只在上滚(朝历史,deltaY<0)时下台跟随;向下滚(想回去)交给
8131
+ // scroll handler 在真正到底时恢复,避免"远离底部时向下滚也被一直按住不跟随"。
8063
8132
  state.terminalWheelHandler = function(e) {
8064
- if (!isTerminalNearBottom() || e.deltaY < 0) {
8133
+ if (e.deltaY < 0) {
8065
8134
  setTerminalManualScrollActive();
8066
8135
  }
8067
8136
  e.stopPropagation();
@@ -8079,7 +8148,13 @@
8079
8148
  wandTerminalWrite(term, "点击上方「新对话」开始你的第一次对话。\r\n");
8080
8149
  }
8081
8150
 
8082
- state.terminalClickHandler = focusInputBox;
8151
+ // COPY-4: 有终端选区时不抢焦点到输入框,否则打断双击选词/三击选行后的复制
8152
+ // (焦点与后续 Ctrl+C 目标被夺走)。wterm 自带 _onClickFocus 有同款护栏。
8153
+ // 透传 event 给 focusInputBox(skipMobile),保留"移动端点终端不自动弹键盘"。
8154
+ state.terminalClickHandler = function(e) {
8155
+ if (hasActiveTerminalSelection()) return;
8156
+ focusInputBox(e);
8157
+ };
8083
8158
  container.addEventListener("click", state.terminalClickHandler);
8084
8159
  updateTerminalJumpToBottomButton();
8085
8160
  initTerminalResizeHandle();
@@ -11431,29 +11506,57 @@
11431
11506
 
11432
11507
  if (event.key === "Escape") {
11433
11508
  event.preventDefault();
11434
- queueDirectInput(getControlInput("escape"), "escape");
11509
+ var escSess = getSelectedSession();
11510
+ if (isStructuredSession(escSess)) {
11511
+ // INPUT-2: 结构化会话没有 PTY,Esc 不能把 \x1b 当消息发给 Claude。
11512
+ // 用户语义 Esc=中断:正在生成时等同点"停止"按钮,否则无操作。
11513
+ if (escSess && escSess.structuredState && escSess.structuredState.inFlight) {
11514
+ stopSession();
11515
+ }
11516
+ } else {
11517
+ queueDirectInput(getControlInput("escape"), "escape");
11518
+ }
11435
11519
  return;
11436
11520
  }
11437
11521
 
11438
11522
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "c") {
11439
- // Allow copy when text is selected; otherwise send SIGINT to terminal
11440
- var inputBox = document.getElementById("input-box");
11441
- var hasSelection = inputBox && (inputBox.selectionStart !== inputBox.selectionEnd);
11442
- if (hasSelection) {
11523
+ // COPY-2: 有选区(输入框内或终端输出区)时放行浏览器原生复制,而不是发
11524
+ // SIGINT。原实现只看输入框选区,漏了文档级终端选区。
11525
+ var inputBoxC = document.getElementById("input-box");
11526
+ var hasSelectionC = (inputBoxC && inputBoxC.selectionStart !== inputBoxC.selectionEnd)
11527
+ || hasActiveTerminalSelection();
11528
+ if (hasSelectionC) {
11443
11529
  return; // Let browser handle copy
11444
11530
  }
11531
+ var ccSess = getSelectedSession();
11532
+ if (isStructuredSession(ccSess)) {
11533
+ // INPUT-2: 结构化会话不把 SIGINT(\x03) 当消息发给 Claude。Ctrl+C 视为
11534
+ // 中断当前生成(等同停止按钮),非生成态则无操作。
11535
+ event.preventDefault();
11536
+ if (ccSess && ccSess.structuredState && ccSess.structuredState.inFlight) {
11537
+ stopSession();
11538
+ }
11539
+ return;
11540
+ }
11445
11541
  event.preventDefault();
11446
11542
  queueDirectInput(getControlInput("ctrl_c"), "ctrl_c");
11447
11543
  return;
11448
11544
  }
11449
11545
 
11450
11546
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "d") {
11451
- // Allow copy when text is selected; otherwise send EOF to terminal
11547
+ // COPY-2: 有选区(输入框内或终端输出区)时放行浏览器复制。
11452
11548
  var inputBox2 = document.getElementById("input-box");
11453
- var hasSelection2 = inputBox2 && (inputBox2.selectionStart !== inputBox2.selectionEnd);
11549
+ var hasSelection2 = (inputBox2 && (inputBox2.selectionStart !== inputBox2.selectionEnd))
11550
+ || hasActiveTerminalSelection();
11454
11551
  if (hasSelection2) {
11455
11552
  return; // Let browser handle copy
11456
11553
  }
11554
+ var cdSess = getSelectedSession();
11555
+ if (isStructuredSession(cdSess)) {
11556
+ // INPUT-2: 结构化会话吞掉 Ctrl+D(EOF 对 Claude 对话无意义,别当消息发)
11557
+ event.preventDefault();
11558
+ return;
11559
+ }
11457
11560
  event.preventDefault();
11458
11561
  queueDirectInput(getControlInput("ctrl_d"), "ctrl_d");
11459
11562
  return;
@@ -11461,6 +11564,11 @@
11461
11564
 
11462
11565
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "l") {
11463
11566
  event.preventDefault();
11567
+ var clSess = getSelectedSession();
11568
+ if (isStructuredSession(clSess)) {
11569
+ // INPUT-2: 结构化会话吞掉 Ctrl+L(清屏对 Claude 对话无意义)
11570
+ return;
11571
+ }
11464
11572
  queueDirectInput(getControlInput("ctrl_l"), "ctrl_l");
11465
11573
  return;
11466
11574
  }
@@ -13547,10 +13655,12 @@
13547
13655
  composer.placeholder = getComposerPlaceholder(selectedSession, state.terminalInteractive);
13548
13656
  composer.disabled = !structured && !!selectedSession && !isRunning && !canResumeOnSend;
13549
13657
  composer.setAttribute("aria-disabled", composer.disabled ? "true" : "false");
13550
- // 终端交互模式下按键由 document capture phase 透传到 PTY;用
13551
- // readOnly 而非 disabled 防止 IME 组合输入等边界场景下字符同时
13552
- // 落到 textarea,又保留 focus 能力。
13553
- composer.readOnly = !!state.terminalInteractive;
13658
+ // INPUT-3: 交互模式不再设 readOnly。readOnly textarea 上 IME 根本不会
13659
+ // 激活(compositionstart 不触发),导致中文/日文等组字输入彻底打不出。普通
13660
+ // 字符 keydown 已由 capture 阶段的 captureTerminalInput preventDefault 拦截、
13661
+ // 不会落进 textarea;唯独 IME 组字期间 capture 放行(isComposing),字符临时
13662
+ // 落入 textarea,由 compositionend 取最终文本发 PTY 后清空。
13663
+ composer.readOnly = false;
13554
13664
  composer.classList.toggle("is-terminal-passthrough", !!state.terminalInteractive);
13555
13665
  }
13556
13666
  var sendBtn = document.getElementById("send-input-button");
@@ -13566,15 +13676,40 @@
13566
13676
  if (container) container.classList.toggle("interactive", !structured && state.terminalInteractive);
13567
13677
  }
13568
13678
 
13679
+ // COPY-2/COPY-4: 是否存在落在终端输出区(#output)内的活动文本选区。用于:
13680
+ // 有选区时 Ctrl+C 放行浏览器原生复制而非发 SIGINT;click 不抢焦点以免打断
13681
+ // 双击选词/三击选行后的复制。
13682
+ function hasActiveTerminalSelection() {
13683
+ var sel = window.getSelection && window.getSelection();
13684
+ if (!sel || sel.isCollapsed) return false;
13685
+ var output = document.getElementById("output");
13686
+ if (!output) return false;
13687
+ var node = sel.anchorNode;
13688
+ if (node && node.nodeType === 3) node = node.parentNode;
13689
+ return !!(node && output.contains(node));
13690
+ }
13691
+
13569
13692
  function captureTerminalInput(event) {
13570
13693
  if (!shouldCaptureTerminalEvent(event)) return;
13694
+ // INPUT-1: 放行 Cmd/Meta 组合键给浏览器(复制/粘贴/刷新/切标签)。PTY 用
13695
+ // Ctrl 不用 Cmd,拦下来既破坏 macOS 原生快捷键,又会把裸字母(Cmd+X→'x')
13696
+ // 误塞进 PTY。
13697
+ if (event.metaKey) return;
13571
13698
  var key = keyFromKeyboardEvent(event);
13572
13699
  if (!key) return;
13573
- event.preventDefault();
13574
13700
  var mods = getModifierStateFromEvent(event, key);
13575
13701
  if (isModifierKey(key)) return;
13702
+ // COPY-2: 有选区时 Ctrl+C 放行浏览器原生复制,而不是发 SIGINT(0x03) 把进程
13703
+ // 杀了还复制不到。无选区的 Ctrl+C 仍透传给 PTY。
13704
+ if (mods.ctrl && key.length === 1 && key.toLowerCase() === "c" && hasActiveTerminalSelection()) {
13705
+ return;
13706
+ }
13576
13707
  var sequence = buildPtySequence(key, mods);
13577
- if (sequence) sendTerminalSequence(sequence, key);
13708
+ // INPUT-4: 只有真正要发给 PTY 的键才 preventDefault;空序列(F5/F12/死键等)
13709
+ // 放行给浏览器,避免"既没发 PTY 又吞掉浏览器默认行为"。
13710
+ if (!sequence) return;
13711
+ event.preventDefault();
13712
+ sendTerminalSequence(sequence, key);
13578
13713
  }
13579
13714
 
13580
13715
  function handleMiniKeyboardClick(event) {
@@ -15255,7 +15390,11 @@
15255
15390
  var now = Date.now();
15256
15391
  var chunkPause = state.lastChunkAt > 0 && (now - state.lastChunkAt > 300);
15257
15392
  var resyncDue = (now - state.lastTerminalResyncAt) > 30000;
15258
- if (resyncDue && (chunkPause || selectedSession.status !== "running") && state.terminalOutput) {
15393
+ // RENDER-2: 仅在"自上次 resync 以来确有新输出"时才重放。静止/已结束会话
15394
+ // buffer 不再变化,30s 周期 resync 是纯无用功,还会把 cursor-home 中间帧
15395
+ // 重堆进 scrollback、扰动滚动位置。lastChunkAt>lastTerminalResyncAt 即脏。
15396
+ var dirtySinceResync = state.lastChunkAt > state.lastTerminalResyncAt;
15397
+ if (resyncDue && dirtySinceResync && (chunkPause || selectedSession.status !== "running") && state.terminalOutput) {
15259
15398
  softResyncTerminal();
15260
15399
  }
15261
15400
  }, 5000);
@@ -15311,6 +15450,9 @@
15311
15450
  if (state.terminalViewportTouchHandler) {
15312
15451
  state.terminalViewportEl.removeEventListener("touchmove", state.terminalViewportTouchHandler);
15313
15452
  }
15453
+ if (state.terminalViewportTouchStartHandler) {
15454
+ state.terminalViewportEl.removeEventListener("touchstart", state.terminalViewportTouchStartHandler);
15455
+ }
15314
15456
  }
15315
15457
  if (output) {
15316
15458
  if (state.terminalWheelHandler) {
@@ -15323,13 +15465,24 @@
15323
15465
  state.terminalViewportEl = null;
15324
15466
  state.terminalViewportScrollHandler = null;
15325
15467
  state.terminalViewportTouchHandler = null;
15468
+ state.terminalViewportTouchStartHandler = null;
15326
15469
  state.terminalWheelHandler = null;
15327
15470
  state.terminalClickHandler = null;
15471
+ // LIFE-1: 清理 initTerminalScrollbar 注册的 hide-timer 与拖拽状态。否则
15472
+ // hide-timer 闭包引用游离节点(置 El=null 拦不住它),且若拖拽中途 teardown,
15473
+ // 残留 dragging=true 会让新会话 scrollbar 的 scheduleHideScrollbar 被永久抑制
15474
+ // (开头 if(dragging)return)→ 滚动条出现后再也不自动消失。
15475
+ if (state.terminalScrollbarHideTimer) {
15476
+ clearTimeout(state.terminalScrollbarHideTimer);
15477
+ state.terminalScrollbarHideTimer = null;
15478
+ }
15328
15479
  if (state.terminalScrollbarEl && state.terminalScrollbarEl.parentNode) {
15329
15480
  state.terminalScrollbarEl.parentNode.removeChild(state.terminalScrollbarEl);
15330
15481
  }
15331
15482
  state.terminalScrollbarEl = null;
15332
15483
  state.terminalScrollbarThumbEl = null;
15484
+ state.terminalScrollbarDragging = false;
15485
+ state.terminalScrollbarRafPending = false;
15333
15486
  if (state.terminal) {
15334
15487
  state.terminal.destroy();
15335
15488
  state.terminal = null;
@@ -15354,6 +15507,7 @@
15354
15507
  // 会让新会话的首批 PTY 字节全部被吞进 buffer 等永远不会来的 end。
15355
15508
  state.syncOutputBuffer = null;
15356
15509
  state.syncOutputDeadline = 0;
15510
+ state.syncFramingResidue = false;
15357
15511
  state.terminalSessionId = null;
15358
15512
  state.terminalOutput = "";
15359
15513
  state.terminalOutputMarker = 0; // R8: teardown 时重置 /clear marker
@@ -15376,6 +15530,11 @@
15376
15530
  _resyncStatsWindowStart = 0;
15377
15531
  _resyncStatsCount = 0;
15378
15532
  _resyncLastWarnAt = 0;
15533
+ _resyncInProgress = false;
15534
+ // SIZE-2: lastResize 是全局去重缓存,记"上次 POST 的尺寸"。切到另一个在不同
15535
+ // 尺寸下创建的会话时,若新算出的 cols/rows 恰好等于上次值会被去重跳过,导致
15536
+ // 后端该会话列宽停在旧值、整段折行。teardown 重置后新会话首次 resize 必发出。
15537
+ state.lastResize = { cols: 0, rows: 0 };
15379
15538
  }
15380
15539
 
15381
15540
  function sendTerminalResize(cols, rows) {
@@ -15388,6 +15547,10 @@
15388
15547
  // wterm WASM grid 的 maxCols 硬编码 256。POST 给服务端的 cols 也同步
15389
15548
  // clamp,避免服务端 pty.resize 给 Claude 一个 wterm 实际渲不下的列宽。
15390
15549
  if (cols > 256) cols = 256;
15550
+ // SIZE-1: rows 也 clamp 到后端上限 160(process-manager clampDimension 10..160)。
15551
+ // 否则高分屏+小字号客户端算出 rows>160 时,后端压到 160,客户端网格底部多出的
15552
+ // 行对应的 PTY 内容永不写入 → 底部大片空白 / 光标错位 / 全屏菜单画到不存在的行。
15553
+ if (rows > 160) rows = 160;
15391
15554
  var nextSize = { cols: cols, rows: rows };
15392
15555
  if (state.lastResize.cols !== nextSize.cols || state.lastResize.rows !== nextSize.rows) {
15393
15556
  state.lastResize = nextSize;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.32.3",
3
+ "version": "1.33.0",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {