Files
remoteconn-gitea/question-solved.md
2026-03-21 18:57:10 +08:00

1304 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 已解决问题记录
## 目录
1. [Q1iOS 长按选词后继续拖动选区消失2026-02-23](#q1ios-长按选词后继续拖动选区消失2026-02-23)
2. [Q2iPhone 软键盘弹出后立刻收起2026-02-23](#q2iphone-软键盘弹出后立刻收起2026-02-23)
3. [Q3iOS 长按选中后继续移动手指选区消失2026-02-23](#q3ios-长按选中后继续移动手指选区消失2026-02-23)
4. [Q4iPhone 快速滑动时仅移动少量行、体感卡顿2026-02-23](#q4iphone-快速滑动时仅移动少量行体感卡顿2026-02-23)
5. [Q5工具菜单易误触 shell移动端按钮命中偏小2026-02-24](#q5工具菜单易误触-shell移动端按钮命中偏小2026-02-24)
6. [Q6SSH 终端两类高频故障速查(中文显示 + 初始化回显2026-02-25](#q6ssh-终端两类高频故障速查中文显示--初始化回显2026-02-25)
7. [Q7iPhone 软键盘“闪现即收起”终版修复方案2026-02-26](#q7iphone-软键盘闪现即收起终版修复方案2026-02-26)
8. [Q8mac mini Nginx 3000 转发配置丢失全链路恢复2026-02-26](#q8mac-mini-nginx-3000-转发配置丢失全链路恢复2026-02-26)
9. [Q9前端动态导入失败导致白屏自动恢复方案2026-02-26](#q9前端动态导入失败导致白屏自动恢复方案2026-02-26)
10. [Q10键盘工具栏点击会折叠语音层拦截范围过大2026-02-27](#q10键盘工具栏点击会折叠语音层拦截范围过大2026-02-27)
11. [Q11刷新后终端与图标加载慢静态资源缓存策略修复2026-02-27](#q11刷新后终端与图标加载慢静态资源缓存策略修复2026-02-27)
12. [Q12小程序“选择目录”长时间加载后超时目录命令外层兼容性修复2026-03-06](#q12小程序选择目录长时间加载后超时目录命令外层兼容性修复2026-03-06)
13. [Q13小程序终端光标定位重大重构xterm cell 对齐 + 中文宽字符修复2026-03-07](#q13小程序终端光标定位重大重构xterm-cell-对齐--中文宽字符修复2026-03-07)
14. [Q14小程序终端历史内容可继续上滑scroll-view 底部与 live tail 不一致2026-03-08](#q14小程序终端历史内容可继续上滑scroll-view-底部与-live-tail-不一致2026-03-08)
15. [Q15小程序终端 `Codex` 交互严重卡顿与按钮无响应(对照 xterm.js 的时间片写入优化2026-03-10](#q15小程序终端-codex-交互严重卡顿与按钮无响应对照-xtermjs-的时间片写入优化2026-03-10)
16. [Q16小程序 Codex 底部提示块缺行与闪动footer 裁剪 + 同步刷新中间态暴露2026-03-11](#q16小程序-codex-底部提示块缺行与闪动footer-裁剪--同步刷新中间态暴露2026-03-11)
17. [Q17小程序 Codex 会话续接后,历史区顶部空白并偶发裸露 `5;2H`2026-03-11](#q17小程序-codex-会话续接后历史区顶部空白并偶发裸露-52h2026-03-11)
18. [Q18小程序 Codex 多轮交互后持续卡顿(真机日志二次归因 + stdout 渲染降频2026-03-17](#q18小程序-codex-多轮交互后持续卡顿真机日志二次归因--stdout-渲染降频2026-03-17)
19. [Q19小程序终端滚动到顶部偶发空白viewport 补刷时机偏晚2026-03-17](#q19小程序终端滚动到顶部偶发空白viewport-补刷时机偏晚2026-03-17)
---
## Q1iOS 长按选词后继续拖动选区消失2026-02-23
### 现象
1. 长按终端文字 → 震动反馈 + 放大镜出现 + 当前单词被选中(高亮)。
2. **放开手指**:选区正常保留,左右手柄出现,可拖动扩缩选区 ✓
3. **保持按住并继续移动手指**:放大镜跟随手指,但选中高亮消失。
4. **放开手指**:无任何选中,无手柄 ✗
### 根因
iOS Safari 在长按手势期间,根据"**是否有聚焦的可编辑元素**"选择两条完全不同的交互路径:
| 场景 | iOS 手势模式 | 行为 |
| ---------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------- |
| 有聚焦的可编辑元素(如 `<textarea readOnly=false>` | **放大镜光标定位模式**cursor drag | 手指移动 = 移动文本光标;抬手后只更改光标位置,**无选区、无手柄** |
| 无聚焦元素 / 只读元素 | **文本选区扩展模式**text selection drag | 长按选词后,手指移动 = 扩展选区;抬手后**选区保留 + 手柄出现** |
根本问题:上一次 `FOCUS_KEYBOARD` 操作让 `helperTextarea` 保持了聚焦(`readOnly=false`),是"有聚焦的可编辑元素"状态iOS 于是走了光标定位模式,导致拖动期间选区消失。
"放开后拖手柄正确"的场景,恰好是 `helperTextarea` 已失焦空心光标状态iOS 走了文本选区扩展路径。
### 关键点
这个问题与 xterm.js 的 JS 监听器(`removeAllRanges()`**叠加**,使得:
- JS 层面xterm `pointermove` handler 清除 `window.getSelection()`(用 `stopImmediatePropagation()` 已阻断)
- iOS 系统层面:`helperTextarea` 有焦点 → 系统直接走光标定位模式,根本不进入选区扩展路径(无法被 JS 拦截)
### 解决方案
`pointerdown` capture 阶段,**任何触摸手势开始时立即执行**
```typescript
helperTextarea.readOnly = true;
const prevActive = document.activeElement as HTMLElement | null;
if (prevActive instanceof HTMLElement && containerRef.value?.contains(prevActive)) {
prevActive.blur();
}
```
强制 iOS 在整个触摸手势期间进入**文本选区扩展模式**。
若最终 `pointerup` 判定为 `FOCUS_KEYBOARD`,再执行 `readOnly=false + focus()`,键盘正常弹出。
### 附完整事件链分析iOS Safari tap/long-press
```
touchstart (系统 touch 层)
→ pointerdown (JS, capture 先运行)
→ [长按等待 / 震动反馈]
→ [若长按超过系统阈值, touch 层建立选区]
→ pointermove* (JS)
→ pointerup (JS) 或 pointercancel (系统接管)
→ mousedown (JS, 合成)
→ mouseup (JS, 合成)
→ click (JS, 合成)
```
系统 touch 层(选区建立/扩展/手柄)与 JS pointer 事件独立运行,`stopImmediatePropagation()` 只阻止 JS listener不影响系统层行为。
### 验收确认
- 长按 → 震动 → 选词 → 保持按住滑动 → 选区扩展正常,放大镜跟随 ✓
- 放开手指 → 选区保留,手柄出现,可拖动扩缩 ✓
- 点击激活带 → 弹键盘 ✓
- 点击非激活带 → 空心光标,不弹键盘 ✓
- 纵向滚动 → 丝滑,无焦点闪烁 ✓
### 涉及文件
- `apps/web/src/components/TerminalPanel.vue``onTouchKeyboardPointerDown` 函数)
### 解决时间
2026-02-23
---
## Q2iPhone 软键盘弹出后立刻收起2026-02-23
### 现象
1. 在 iPhone Safari 中点击终端激活带区域。
2. 软键盘短暂弹出(约 0.5 秒内)。
3. 软键盘立刻自动收起,无法输入 ✗
4. iPad 上同样操作正常 ✓
### 根因(双重)
**根因 1标志设置时序错误blur 事件同步性)**
`onTextareaBlur` 中有保护逻辑:`focusKeyboardInProgress === true` 时跳过 `readOnly=true`。然而 `focusKeyboardInProgress = true` 原来放在 `ae.blur()` **之后**调用,而 iOS 上 blur 事件是**同步**触发的——`ae.blur()` 返回之前 `onTextareaBlur` 便已执行完毕,此时 flag 仍为 `false``readOnly=true` 照常写入,键盘激活被立即打断。
**根因 2iPhone `visualViewport` 地址栏折叠引发误判**
Safari 在软键盘弹出**之前**先将顶部地址栏折叠,导致 `visualViewport.height` 短暂**增大**(地址栏收起让可视区变高),随后才因键盘占位缩小。原有"高度一增大就判定键盘收起"的单一条件把这次增大误判为键盘收起,触发 `readOnly=true + blur()`,软键盘刚弹出就被 JS 关掉。
> iPad 无地址栏折叠行为,故 iPad 不受影响。
### 解决方案
1. **修复时序**:将 `focusKeyboardInProgress = true` 移到 `ae.blur()` 调用**之前**。
2. **延长保护窗口至 400ms**iPhone 键盘动画约 300ms400ms 定时器既负责 focus 重试(确保键盘稳定弹出),也负责 400ms 后解除 `focusKeyboardInProgress`;定时器 ID 保存供 `touchstart` 取消。
3. **visualViewport 两阶段检测**
- 引入 `keyboardViewportShrinkDetected` 布尔状态。
- Phase 1检测到"明显缩小">120px`helperTextarea` 聚焦 → 置 `true`(认为键盘已打开)。
- Phase 2仅在 Phase 1 成立后,"明显增大">120px才视为键盘收起触发后重置状态。
- `BLUR_ONLY`/`FOCUS_KEYBOARD` 发生时显式重置,保证状态机干净切换。
### 验收确认
- iPhone 点激活带 → 键盘弹出并稳定保持 ✓
- iPhone 键盘弹出后上下滚动 → 滚动正常,键盘不异常收起 ✓
- iPhone 手动关键盘后再次点激活带 → 再次弹出 ✓
- iPad 行为不变 ✓
### 涉及文件
- `apps/web/src/components/TerminalPanel.vue``onTouchKeyboardPointerUp` FOCUS_KEYBOARD 分支、`onViewportKeyboardHide`
### 解决时间
2026-02-23
---
## Q3iOS 长按选中后继续移动手指选区消失2026-02-23
### 现象
1. 长按终端文字 → 震动反馈 + 放大镜出现 + 当前单词选中高亮 ✓
2. **保持按住并继续移动手指**:放大镜跟随移动,但选中高亮消失。
3. 放开手指:无任何选中,无手柄 ✗
> Q1 记录的是"有焦点可编辑元素导致走光标定位模式",已在 v1.0.1 解决。本条为剩余的第二层问题。
### 根因
iOS Safari 在长按进入系统选区后,通常先触发 `pointercancel`(系统接管手势控制),后续手指移动改为 **`touchmove`** 事件驱动放大镜与手柄扩选。
`pointercancel` 触发后pointer 门控(`touchGatePointerId`)已重置,`pointermove` 拦截不再生效。后续 `touchmove` 直接透传到 xterm 的事件监听器xterm 在此链路调用 `window.getSelection().removeAllRanges()`,将系统刚建立的原生选区清空。
### 解决方案
新增 `touchmove` capture 拦截器,**仅在终端区域内存在原生选区时**触发:
```typescript
onTouchKeyboardTouchMove = (event: TouchEvent) => {
const hasSelection = hasActiveNativeSelectionInTerminal();
if (hasSelection) {
lastTouchAction = "PASS_NATIVE";
event.stopImmediatePropagation(); // 阻断 xterm不 preventDefault不影响滚动
}
};
containerRef.value.addEventListener("touchmove", onTouchKeyboardTouchMove, true);
```
同时在 `pointercancel` 中:若已有原生选区,将 `lastTouchAction` 置为 `PASS_NATIVE`,确保后续合成 `mousedown/click` 受保护。
### 验收确认
- 长按 → 保持按住移动手指 → 选区持续扩展,放大镜跟随,放手后手柄出现 ✓
- 放开后拖动手柄扩缩选区 ✓
- 正常上下滑动不受影响 ✓
- 点激活带弹键盘不受影响 ✓
### 涉及文件
- `apps/web/src/components/TerminalPanel.vue``onTouchKeyboardTouchMove``onTouchKeyboardPointerCancel`
### 解决时间
2026-02-23
---
## Q4iPhone 快速滑动时仅移动少量行、体感卡顿2026-02-23
### 现象
1. 在 iPhone 上快速短甩flick终端内容。
2. 列表往往只移动 1~2 行后很快停住,惯性不足。
3. 中速滑动可滚动,但“甩动距离”和原生列表相比偏短。
### 根因
触摸动量初版使用“单次事件位移”近似速度,且对事件时间窗口过于严格;在 iOS 低采样或帧抖动场景中,快速手势的速度被低估,`touchend` 时初始动量过小,导致只滚少量行。
### 解决方案(阶段性)
1. 将触摸速度估算改为**按时间归一化**`(deltaY / dt) * (1000/60)`,统一到“每帧速度”尺度。
2. 采用更高权重的 EMA 融合瞬时速度,提升快速手势响应。
3.`touchend/touchcancel` 启动动量续滚RAF + 指数衰减)。
4. 参数调优:提高阻尼与速度上限,降低停止阈值,并引入 `TOUCH_SCROLL_BOOST`
5. 若检测到原生选区(长按选区链路),不触发动量续滚,避免与选区交互冲突。
### 当前结果
- 快速滑动体验较修复前明显提升。
- 与原生 iOS 滚动手感仍有差距,暂定“阶段性达成”,后续继续参数微调。
### 涉及文件
- `apps/web/src/components/TerminalPanel.vue`
### 解决时间
2026-02-23
---
## Q5工具菜单易误触 shell移动端按钮命中偏小2026-02-24
### 现象
1. 右下角工具菜单展开后,点击按钮附近空白区会误落到 shell触发终端焦点/输入。
2. 工具菜单收起后,若仍保留大保护区,会影响终端正常点击。
3. 22x22 / 24x24 小按钮在手机上命中偏低,容易点不中。
### 根因
1. 交互层未实现 Figma `frame 2251` 的“展开态保护区”语义,只有视觉层 `frame 2248`
2. 工具菜单展开/折叠缺少统一的“外部点击折叠”逻辑。
3. 按钮命中区与视觉尺寸一致,未做 touch 端 hit-slop 扩展。
### 解决方案
1. 工具区改为“展开态保护、折叠态不保护”:
- 展开时启用 `97x205` 保护区(对齐 `frame 2251` 语义),保护区内点击不响应 shell。
- 折叠时仅保留 `22x22` 键盘按钮命中区,不拦截周边 shell 点击。
2. 菜单展开时在 `pointerdown capture` 监听外部点击,点击保护区外自动折叠。
3. 全应用按钮统一增加四周 `8px` 点击扩展:
- 通过全局样式 `button::before { inset: -8px; }` 实现;
- 视觉尺寸不变,仅扩大命中区(如 `22x22 -> 38x38`)。
### 验收确认
- 展开态:保护区内点击不透传 shell ✓
- 展开态:点击保护区外自动折叠 ✓
- 折叠态:终端区域点击不被保护区拦截 ✓
- 全应用按钮:小按钮命中明显改善 ✓
### 涉及文件
- `apps/web/src/components/TerminalPanel.vue`
- `apps/web/src/styles/main.css`
### 解决时间
2026-02-24
---
## Q6SSH 终端两类高频故障速查(中文显示 + 初始化回显2026-02-25
### 现象 A中文显示/输入异常
1. 输入法空格选词阶段就出现乱码(尚未回车)。
2. 回显出现 `<008b>``\M-^X` 等高位异常字符。
### 现象 B初始化回显泄漏
连接后出现不该给用户看的内部命令:
1. `stty -echo; echo '__RCSBEGIN_7f3a__'`
2. `stty iutf8 ... setopt MULTIBYTE ...`
3. `stty echo; echo '__RCSDONE_7f3a__'`
### 根因总结
1. 中文问题本质是“shell 行编辑 UTF-8/多字节模式”与“输入流归一”未稳定。
2. 回显泄漏本质是“初始化静默窗口未可靠命中”且缺少超时兜底净化。
### 标准修复(当前基线,按此执行)
1. 中文显示链路:
- 保留 `encodeInputForSsh()` 的输入归一逻辑;
- 保留 `stty iutf8``setopt MULTIBYTE PRINT_EIGHT_BIT``LANG/LC_*` 初始化;
- 仅清理 C1 控制字符(`0x80-0x9F`),不要扩大误删范围。
2. 初始化回显链路:
- 保留双哨兵窗口:`INIT_BEGIN -> INIT_DONE`
- 哨兵匹配同时支持 `\n``\r\n`
- 保留 `sanitizeInitLeakOutput()` 超时兜底净化(即使哨兵未命中,仍清理内部命令行)。
### 快速排查顺序(固定流程)
1. 先看首屏是否泄漏 `stty/setopt` 内部命令。
2. 再看中文是否在“选词未回车”阶段异常。
3. 对照 `apps/gateway/src/ssh/sshSession.ts` 四个关键点:
- `encodeInputForSsh()`
- `findSentinelLine()`
- `sanitizeInitLeakOutput()`
- 三段初始化命令W1/W2/W3
4. 只做最小改动,不再堆叠临时补丁。
### 回归测试(必须)
文件:`apps/gateway/src/ssh/sshSession.test.ts`
1. BEGIN 前 banner 保留BEGIN->DONE 期间回显丢弃DONE 后提示符正常。
2. LF-only 场景不泄漏内部命令。
3. BEGIN/DONE 未命中并超时时,兜底净化仍生效。
### 提交前验证
```bash
npm run test
npm run typecheck
npm run lint
npm run build
./scripts/gatewayctl.sh deploy
```
### 结论
后续再出现这两类问题,先按本节执行,不再从零试错,避免重复耗费半天以上时间。
---
## Q7iPhone 软键盘“闪现即收起”终版修复方案2026-02-26
### 现象(最终确认口径)
1. iPhone 点击终端“激活带”后,软键盘有时只闪一下就收起。
2. 同一会话里偶发,复现与手势节奏、地址栏动画、焦点竞争有关。
3. iPad 与桌面端通常不复现或复现概率极低。
### 旧方案为何不稳定
1. 仅靠 `visualViewport` 高度变化推断“键盘开/关”在 iPhone Safari 上不可靠。
2. iOS 会先做地址栏折叠,再做键盘动画,期间高度会出现“先增后减”抖动。
3. 若在该抖动窗口把 `helperTextarea` 改回 `readOnly=true` 或触发 `blur()`,键盘会立刻被收回。
4. `blur` 事件与 `readOnly/focus` 的时序若处理不当,会出现“刚 focus 又被 blur 回滚”。
### 最终落地方案(当前稳定基线)
1. **取消 viewport 推断链路**
- 不再使用 `visualViewport resize` 反推键盘收起,彻底移除该脆弱信号源。
2. **把焦点控制收敛到触摸状态机**
- 统一由 `pointerdown/pointerup/touchstart` 决定四类动作:
- `BLUR_ONLY`:仅失焦,不弹键盘。
- `FOCUS_KEYBOARD`:执行受保护的 blur->focus 序列。
- `PASS_NATIVE`:交给 iOS 原生选区,不抢焦点。
- `PASS_SCROLL`:交给滚动链路,不抢焦点。
3. **FOCUS_KEYBOARD 关键时序修复**
- `focusKeyboardInProgress = true` 必须在 `ae.blur()` 之前设置。
-`blur` 当前容器内活动元素,再 `helperTextarea.readOnly = false`,再 `focusHelperTextarea()`
4. **引入保护窗口,阻止“异步 blur 回滚”**
- 在保护窗口内,`onTextareaBlur` 不执行 `readOnly=true` 回写;
- 保护窗口结束后仅清 flag不做二次强制 focus避免焦点打架。
5. **增加有限次数补焦兜底**
- iOS 键盘动画期间若出现瞬时 blur仅做最多 2 次、16ms 间隔补焦;
- 超过次数不再重试,避免无限抢焦点。
6. **新手势开始即取消旧 focus 定时器**
- `touchstart` 里取消遗留 timer防止旧流程在手势中途改写 `readOnly`
### 关键参数(已验证)
1. `TOUCH_KEYBOARD_FOCUS_GUARD_MS = 900`
2. `TOUCH_KEYBOARD_BLUR_RECOVER_DELAY_MS = 16`
3. `TOUCH_KEYBOARD_BLUR_RECOVER_MAX = 2`
4. `TOUCH_KEYBOARD_TAP_MAX_MOVE_PX = 10`
### 关键代码位置
1. `apps/web/src/components/TerminalPanel.vue`
2. 重点函数:
- `onTouchKeyboardPointerUp``FOCUS_KEYBOARD` / `BLUR_ONLY` 分支)
- `onTextareaBlur`
- `scheduleFocusKeyboardBlurRecover`
- `onTouchKeyboardTouchStart`
3. 重点状态:
- `focusKeyboardInProgress`
- `focusKeyboardTimerId`
- `focusKeyboardRecoverTimerId`
- `lastTouchAction`
### 事件链(用于排障)
1. 触摸路径:`touchstart -> pointerdown -> pointerup -> mousedown(click 合成前) -> click`
2. 键盘稳定弹出依赖:
- `FOCUS_KEYBOARD` 分支完成 `blur -> readOnly=false -> focus`
- 保护窗口内不得被 `onTextareaBlur` 回写 `readOnly=true`
- `mousedown/click` 需要在 capture 阶段按动作拦截,避免 xterm 抢焦点覆盖状态机。
### 验收用例(必须全过)
1. iPhone 点击激活带:键盘稳定弹出,不闪退。
2. iPhone 点击非激活带:不弹键盘,仅空心光标。
3. 激活后立刻滑动滚动:滚动正常,键盘不被误收起。
4. 连续快速点按激活带 10 次:不出现“偶发闪现”。
5. 长按选区与手柄拖动:不受键盘逻辑干扰。
### 回归结论
本问题的核心不是“键盘 API 调不调”,而是“**iOS 焦点时序 + 手势状态机 + 异步 blur 回滚**”三者一致性。后续若再出现同类现象,先检查本节 6 条落地方案和 4 个参数,禁止再引入 `visualViewport` 推断式补丁。
---
## Q8mac mini Nginx 3000 转发配置丢失全链路恢复2026-02-26
### 问题背景
RemoteConn 当前公网链路不是“云机直接跑 web/gateway”而是
1. 云服务器负责 TLS 入口(`conn.biboer.cn:443`)。
2. 云服务器把流量转发到本机 `127.0.0.1:13000`
3. `13000` 来自 frp 反向映射,实际落到 mac mini 的 `127.0.0.1:3000`
4. mac mini 的 Nginx `3000` 再拆分:
- `/ws/*` -> `127.0.0.1:8787`gateway
- 其他路径 -> `apps/web/dist`(前端静态 + SPA 回退)
这意味着 **mac mini 的 `3000` server 块丢失/失效** 会直接导致公网不可用。
### 全链路证据(本次实测)
#### A. 云服务器入口层(主机:`kvm-douboer`
1. 云端 Nginx/OpenResty 在线:`openresty/1.25.3.1`
2. 云端监听端口包含:`80/443/7000/13000`
3. 云端配置文件:`/etc/nginx/conf.d/conn.biboer.cn.conf`
- `location /` -> `proxy_pass http://127.0.0.1:13000;`
- `location /ws/` -> `proxy_pass http://127.0.0.1:13000;`
#### B. 云服务器 frps 层
1. frps 进程:`/usr/local/bin/frps -c /etc/frp/frps.toml`
2. frps 配置:`/etc/frp/frps.toml`
- `bindPort = 7000`
- `allowPorts = [{ single = 13000 }, { single = 13001 }]`
#### C. mac mini frpc 层
1. frpc 配置:`/opt/homebrew/etc/frp/frpc.toml`
- `serverAddr = "24.233.3.126"`
- `serverPort = 7000`
- `remoteconn_web`: `localIP=127.0.0.1 localPort=3000 remotePort=13000`
2. frpc 服务在线:`launchctl list` 可见 `homebrew.mxcl.frpc`
#### D. mac mini Nginx 3000 层(关键)
1. 配置文件:`/opt/homebrew/etc/nginx/servers/conn.biboer.cn.conf`
2. 核心配置:
- `listen 3000;`
- `/ws/` -> `proxy_pass http://127.0.0.1:8787;`
- `/gateway-health` -> `proxy_pass http://127.0.0.1:8787/health;`
- `root /Users/gavin/remoteconn/apps/web/dist;`
- `location / { try_files $uri $uri/ /index.html; }`
### 根因归纳
“3000 端口转发配置丢失”本质是 **mac mini Nginx 的 `conn.biboer.cn.conf` 不存在或未被加载**,导致:
1. frp 链路仍在(云端 13000 可连),但后端 `3000` 不再提供预期路由。
2. 公网 `conn.biboer.cn` 经过云端代理后,得到错误页面/错误状态(或 ws 失败)。
### 标准恢复步骤(必须按顺序)
1. 在 mac mini 恢复文件:`/opt/homebrew/etc/nginx/servers/conn.biboer.cn.conf`
2. mac mini 校验并重载 Nginx
- `nginx -t`
- `brew services restart nginx`(或等价重载)
3. 确认 frpc 在线:
- `launchctl list | rg frpc`
- `ps -ef | rg frpc`
4. 逐跳验证(从里到外):
- mac 本地:`curl -I http://127.0.0.1:3000/`
- mac 本地:`curl http://127.0.0.1:3000/gateway-health`
- 云端映射:`curl -I http://24.233.3.126:13000/`
- 云端映射:`curl http://24.233.3.126:13000/gateway-health`
- 公网入口:`curl -I https://conn.biboer.cn/`
- 公网入口:`curl https://conn.biboer.cn/gateway-health`
### 本次恢复后的验收结果
1. `http://24.233.3.126:13000/` 返回 RemoteConn `index.html`(证明 frp -> mac:3000 通)
2. `http://24.233.3.126:13000/gateway-health` 返回 `{"ok":true,...}`(证明 `/ws|gateway` 转发链路通)
3. `https://conn.biboer.cn/gateway-health` 返回 `200 + {"ok":true,...}`(证明公网入口恢复)
### 防再发建议(运维基线)
1. 将 mac 侧 `conn.biboer.cn.conf` 纳入版本化备份(与项目同仓或私有运维仓)。
2. 每次变更后固定执行 6 条逐跳 `curl` 冒烟,不只看首页。
3. 在云端保留 `13000` 直连探针(仅白名单)用于快速判断“云入口问题 vs frp/mac 后端问题”。
---
## Q9前端动态导入失败导致白屏自动恢复方案2026-02-26
### 现象
1. 用户进入某个路由时偶发白屏。
2. 控制台常见错误包含:
- `Importing a module script failed`
- `Failed to fetch dynamically imported module`
- `error loading dynamically imported module`
3. 刷新后通常可恢复,但用户侧没有明确引导。
### 根因
1. 前端采用按路由拆分 chunk发布窗口内可能出现“HTML 指向新 chunk、CDN/缓存仍旧版本”短暂不一致。
2. 网络抖动或代理缓存异常时,动态模块请求会失败并直接中断路由渲染。
3. 若不做节流,简单“捕获后强刷”会在 chunk 持续不可用时进入刷新死循环。
### 解决方案
新增 `dynamicImportGuard` 恢复机制:
1.`router.onError` 识别“动态导入失败”错误文案。
2. 命中后尝试自动刷新当前目标路由。
3. 使用 `sessionStorage` 记录重试时间戳,`15s` 窗口内只允许一次自动刷新。
4. 路由成功就绪后清理重试标记,保证后续新故障仍可触发一次恢复。
5.`sessionStorage` 不可用时,降级为记录错误日志,不抛二次异常。
### 验收确认
1. Safari/Chromium/Firefox 常见文案均能被识别。
2. 重试窗口内第二次失败不再自动刷新,避免循环。
3. 超过窗口后允许再次自动恢复。
4. 单元测试覆盖识别、窗口限流、标记清理逻辑。
### 涉及文件
- `apps/web/src/utils/dynamicImportGuard.ts`
- `apps/web/src/utils/dynamicImportGuard.test.ts`
- `apps/web/src/main.ts`
### 解决时间
2026-02-26
---
## Q10键盘工具栏点击会折叠语音层拦截范围过大2026-02-27
### 现象
1. 键盘工具栏展开后,点击工具栏区域有概率被直接折叠。
2. 表现上像是“点击了工具栏外部”,但用户实际点位在工具区附近。
3. 折叠态下语音按钮周边区域也会出现额外拦截,影响 shell 正常点击。
### 根因
1. 语音层 `terminal-voice-layer` 的层级高于键盘工具层(语音层 `z-index: 5`,键盘层 `z-index: 4`)。
2. `terminal-voice-hitbox` 在折叠态也常驻,并对 `pointer/touch` 全量 `stop + prevent`,导致上层拦截范围过大。
3. 键盘工具栏的“外部点击折叠”依赖 `document pointerdown capture`,当事件目标落在语音命中层而非键盘工具容器时,会被判定为外部点击并触发折叠。
4. 语音按钮命中区叠加了全局 `button::before` 扩展,进一步放大了折叠态的遮挡面积。
### 解决方案
1. 语音保护区改为“展开态保护、折叠态不保护”:
- 仅在 `panelVisible=true` 时渲染 `terminal-voice-hitbox`
- 折叠态不再保留大面积拦截层。
2. 折叠态只保护 `voice.svg` 本体:
- 语音按钮的 `::before` 命中区收回到组件本体(`inset: 0`),不再扩展到周边 shell 区域。
3. 保持展开态语音面板的保护区拦截能力,避免面板内操作误透传到底层 shell。
4. 修复“拖动后键盘开合把 voice 按钮顶到顶部”:
- 增加 `lastUserPlacedButtonPosition`,记录用户拖动后的稳定坐标;
-`keyboard-open` 期间(且用户已手动摆放)不再按“临时缩高容器”夹紧 `y`,仅校正 `x`
- 键盘收回后再回到常规 clamp并把当前合法坐标回写为新的稳定坐标。
### 验收确认
- 键盘工具栏展开后,点击工具按钮不再被误判为“外部点击折叠” ✓
- 键盘工具栏展开后,点击工具区外仍可正常自动折叠 ✓
- 语音折叠态下,`voice.svg` 周边 shell 点击恢复正常 ✓
- 语音展开态下,面板区域继续阻断透传 shell ✓
- 刷新后拖动 voice 按钮,再经历“弹键盘 -> 收键盘”,按钮不再被推到顶部,位置可保持/恢复 ✓
### 涉及文件
- `apps/web/src/components/TerminalVoiceInput.vue`
- `apps/web/src/styles/main.css`
### 解决时间
2026-02-27
## Q11刷新后终端与图标加载慢静态资源缓存策略修复2026-02-27
### 现象
1. 页面刷新后Terminal 首屏与 `svg` 图标加载体感偏慢。
2. 每次刷新都出现静态资源重新校验/重新请求,尤其在网络有抖动时更明显。
### 根因
1. 静态资源响应头为 `Cache-Control: no-cache`,浏览器刷新会频繁 revalidate。
2. `/icons/*.svg` 使用稳定 URL非 hash 文件名),若不单独制定缓存策略,会持续产生请求开销。
3. 缓存策略应下沉到家庭机macNginx 的 `3000` 静态入口云主机kvm仅做反向代理不是静态资源根源。
### 解决方案
1. 明确链路分工:
- kvm保留 TLS/入口反代,不承担静态缓存策略主配置。
- mac`listen 3000` 的 Nginx `server` 内配置静态缓存头。
2. 按资源类型拆分缓存:
- `/assets/*`(构建 hash 产物):`public, max-age=31536000, immutable`
- `/icons/*`(稳定文件名):`public, max-age=2592000`30 天,不使用 immutable便于图标迭代
- `/``/index.html``no-cache`,保证发版后入口可及时更新。
3. 目标配置文件:
- `/opt/homebrew/etc/nginx/servers/conn.biboer.cn.conf`
### 验收确认
- `curl -I http://127.0.0.1:3000/` 返回 `Cache-Control: no-cache`
- `curl -I http://127.0.0.1:3000/icons/voice.svg` 返回 `max-age=2592000`
- `curl -I http://127.0.0.1:3000/assets/index-*.js` 返回 `max-age=31536000``immutable`
- 页面刷新后图标与终端相关静态资源请求显著减少(命中缓存) ✓
### 涉及文件
- `/opt/homebrew/etc/nginx/servers/conn.biboer.cn.conf`
### 解决时间
2026-02-27
---
## Q12小程序“选择目录”长时间加载后超时目录命令外层兼容性修复2026-03-06
### 现象
1. 小程序进入“服务器配置”页,点击“选择目录”。
2. 弹出目录选择面板后,根目录长时间显示“加载中”。
3. 十余秒后弹出“无法连接服务器 / 目录读取超时,请稍后再试”。
4. 同一台服务器从终端页正常连接,说明主机、端口、用户名和认证信息本身没有问题。
### 根因
目录读取命令原先拼成了这类形式:
```sh
__RC_BEGIN=... __RC_END=... __RC_REL=... sh -lc '...'
```
这要求“远端当前登录 shell”支持 `A=B cmd` 这种前缀赋值语法。该假设并不稳。当前 shell 一旦不按这套语法解释,真正的 `sh -lc '...'` 目录脚本就不会正常启动,前端也就一直收不到 begin/end 标记,最终只能超时。
### 解决方案
将变量赋值移入 `sh -lc` 内部脚本,只让外层执行标准的 shell 调用:
```sh
sh -lc '__RC_BEGIN=...; __RC_END=...; __RC_REL=...; ...'
```
这样外层不再依赖远端默认 shell 对前缀赋值语法的支持,目录读取恢复正常。
### 验收确认
- 点击“选择目录”后可正常拉取根目录;
- 展开子目录不再稳定复现超时;
- 服务器终端连接链路不受影响。
### 涉及文件
- `apps/miniprogram/utils/remoteDirectory.js`
### 解决时间
2026-03-06
---
## Q13小程序终端光标定位重大重构xterm cell 对齐 + 中文宽字符修复2026-03-07
### 现象
1. 第二行输入几个中文后,光标会跳回第一行行首;继续输入又跳回第二行,再输入到一定长度又回到第一行。
2. 英文连续输入到右侧时,看起来没有右侧 padding内容几乎贴到物理屏幕最右边。
3. 中文输入过程中,光标与文本尾部的空白会越来越大;到换行边界时,光标已经到边界,但文本右侧还留着一段空白。
4. 前几轮局部修补后,现象仍然几乎不变,说明问题不在单个偏移量,而在终端坐标模型本身。
### 根因
这次问题最终确认不是一个点,而是旧模型的多层叠加:
1. **连接层仍固定 `80x24`**
远端 PTY 按固定列数回显,本地页面却按真实屏宽显示,两边从一开始就不是同一个终端几何。
2. **本地输出解析仍是“字符数组 + 事后补偿”**
旧实现写入时按字符推进后面再按显示列、prompt、wrap 补偿去反推光标,等于先错一次,再补一次。
3. **宽字符 continuation 在渲染层丢失**
buffer 里宽字符应占 2 列但页面把整行重新拼回自然文本流后continuation 不再参与宽度占位,导致:
- buffer 里的 `cursorCol` 按 2 列走;
- 页面文本只画出 1 个 glyph 宽度;
- 中文输入越多,光标和文本尾部偏差越大。
4. **单列宽度测量使用了错误 probe**
旧测量用 `0` 字符估算单列宽度,实际英文字宽和 CJK fallback 字体宽度并不等于这个 probe英文右侧 padding 和中文换行边界都会被带偏。
5. **padding 和内容布局不在同一坐标系**
旧实现把左右 padding 挂在 `scroll-view`,而光标与激活区又按另一套公式定位,导致“红框看起来有 padding但文本实际顶边”。
### 解决方案
本次不再继续补丁式修正,而是直接按 xterm 的 cell 模型重构终端定位链路:
1. **连接前先测量真实终端几何**
- 先测量输出区域宽高;
- 再计算真实 `cols/rows`
- 用真实 `cols/rows` 建连和 `resize`
2. **输出缓冲区重写为固定列 cell 模型**
- 普通字符占 1 列;
- 宽字符写 owner cell + continuation cell
- 组合字符附着到前一个 owner cell不推进列
- 右边界判断改成 `cursorCol + width - 1 >= cols`
3. **渲染层改成按 cell run 输出**
- 连续窄字符按样式合并成 run
- 宽字符 owner 独立成 fixed run
- fixed run 显式占 `2 * charWidth` 的像素宽度continuation 不再丢失。
4. **可见光标改为终端自绘**
- 原生 `input` 只保留“隐藏键盘代理”职责;
- 视觉 caret 由 `cursorRow/cursorCol` 直接推导,不再借用原生 caret 的字符串长度逻辑。
5. **测量链路改为双 probe**
-`W` probe 测单列宽度,贴近 xterm 的单列测量思路;
- 用 CJK probe 做兜底,确保 2 列槽位能装下当前字体的宽字符 glyph。
6. **padding 改到真实输出行**
- 把左右 padding 从 `scroll-view` 挪到 `.output-line`
- 输出文本、光标、激活区统一使用同一套内容区坐标。
### 关键落地点
1. 终端逻辑从“字符串心智”彻底切到“cell 心智”。
2. 光标与文本都从同一个 buffer 派生,不再存在“显示光标”和“逻辑光标”两套来源。
3. 宽字符不再依赖浏览器自然文本流碰运气,而是显式占位。
4. 小程序仍保留原生 `scroll-view` 和原生输入法能力,没有走“纯自绘输入框”这种高风险路线。
### 验收确认
1. 第一轮重构后,用户确认核心输入/宽字符/换行问题已经解决。
2. 第二行输入中文后不再回跳第一行。
3. 英文输入到右侧时,右边 padding 恢复正常。
4. 中文输入过程中,光标与文本尾部不再持续拉开。
5. 中文到换行边界时,光标与文本右边界对齐关系恢复正常。
### 同日追加修复Linux 重连纵向偏移 + caret 显示层收口
#### 追加现象
1. Linux 服务器连接/重连时,`last login...` 之前通常还有额外 banner 与空白行;提示符 caret 的纵向中心会逐次偏离,`clear` 后又恢复正常。
2. 自绘 caret 需要先点击终端才出现。
3. Shell 区左上角还会出现一个原生闪烁 caret。
#### 最终根因
1. 纵向偏移不是 Linux 那几行文字“字体更高”,而是**空白 buffer 行的 DOM 高度和 caret 公式使用的行高不一致**。
- 空白 buffer 行不会生成渲染 segment
- 页面里的空白 `.output-line` 之前只靠 `min-height: 1em` 占高;
- caret 却按像素 `lineHeight * cursorRow` 推导位置;
- Linux 首屏与重连 banner 更容易出现额外空行,因此重连次数越多,累计误差越大;`clear` 后只剩当前提示符那一行,误差自然归零。
2. 自绘 caret 的显示条件绑定在 `shellInputFocus`,所以未点击时不会显示。
3. 隐藏输入代理仍停留在左上角(`top: 0; left: 0; z-index: 1`),原生 caret 仍有机会被系统绘制出来。
#### 最终修复
1. `refreshOutputLayout()` 新增 `outputLineHeightPx`,并把当前计算出来的 `lineHeight` 下发到每一条输出行。
2. `.output-line` 显式设置 `min-height``line-height``outputLineHeightPx`,把空白行真实 DOM 高度强制对齐到终端 buffer 使用的像素行高。
3. `queryOutputRect()` 同时读取 `.terminal-output``scrollOffset``refreshOutputLayout()` 改成“先更新内容,再设置 `scroll-top`,最后在布局落地后同步 overlay”的时序避免 Linux 首屏较长时 overlay 提前读取过旧滚动状态。
4. 自绘 caret 改为 `statusClass === "connected"` 时显示,并在 `setStatus()` 状态变化后立即同步 overlay。
5. 隐藏输入代理移出可视区:`position: fixed; top/left: -2000px; z-index: -1; pointer-events: none;`,彻底去掉左上角原生 caret。
6. 上述修复全部只落在显示层与布局层,没有再修改 `appendOutput()``cursorRow/cursorCol`、宽字符 continuation 或 cell 计算路径。
#### 追加验收
1. 用户已确认Linux 连接/重连场景下,光标纵向位置问题已修复。
2. caret 常显与左上角原生 caret 隐藏已实现,并作为后续小程序真机回归项继续保留。
### 涉及文件
- `apps/miniprogram/pages/terminal/index.js`
- `apps/miniprogram/pages/terminal/index.wxml`
- `apps/miniprogram/pages/terminal/index.wxss`
- `apps/miniprogram/pages/terminal/terminalCursorModel.js`
- `apps/miniprogram/pages/terminal/terminalCursorModel.test.ts`
- `docs/xterm-cursor-algorithm-2026-03-07.md`
- `docs/miniprogram-terminal-cursor-gap-analysis-2026-03-07.md`
- `docs/miniprogram-terminal-shell-input-plan-2026-02-28.md`
- `README.md`
- `apps/miniprogram/README.md`
### 验证命令
```bash
node --check apps/miniprogram/pages/terminal/index.js
node --check apps/miniprogram/pages/terminal/terminalCursorModel.js
npx --no-install eslint apps/miniprogram/pages/terminal/index.js apps/miniprogram/pages/terminal/terminalCursorModel.js apps/miniprogram/pages/terminal/terminalCursorModel.test.ts
npx --no-install vitest run apps/miniprogram/pages/terminal/terminalCursorModel.test.ts
npx --no-install tsc --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext --types vitest/globals,node apps/miniprogram/pages/terminal/terminalCursorModel.test.ts
npm run test
npm run typecheck
npm run lint
npm run build
```
### 解决时间
2026-03-07
---
## Q14小程序终端历史内容可继续上滑scroll-view 底部与 live tail 不一致2026-03-08
### 现象
1. 小程序终端中,光标和红框位置看起来是对的,没有随着手指滚动随意漂移。
2. 但手动浏览历史时,即使当前命令行已经回到视口底部,历史内容仍然可以继续向上滑。
3. 历史不满一屏时,理论上不应存在真实滚动区,但页面仍可能表现出“还能继续拖”的内容高度。
### 根因
这次不是光标逻辑错了,而是页面层同时维护了两套“底部”:
1. **逻辑底部**
overlay caret 和红框的最大滚动值,按 `cursorRow` 推导:
`maxScrollable = max(0, (cursorRow - visibleRows + 1) * lineHeight)`
2. **原生内容底部**
`scroll-view` 的真实内容高度,来自 `outputRenderLines` 的总行数。
旧实现里,`outputRenderLines` 直接渲染 `outputCells` 的全部行;而 normal buffer 在 prompt 之后可能还保留尾部空行。结果就是:
- overlay 认为已经到底了;
- `scroll-view` 仍认为内容更长;
- 用户继续上滑时,历史内容还能动,但光标和红框不会再继续跟着上推。
### 解决方案
不修改 VT buffer 写入逻辑,只在页面投影层统一“渲染尾部”和“最大滚动值”的口径:
1. 新增 `terminalViewportModel.js`,专门计算终端视口状态。
2. **normal buffer** 只渲染到当前 `cursorRow`,把 prompt 后面的虚假尾部空行裁掉。
3. **alternate screen** 保留整屏语义,不做裁剪,避免破坏全屏程序的屏幕模型。
4. `scroll-view` 的内容高度和 overlay 的 `maxScrollTop` 都从同一份 `viewportState` 推导,彻底消除“两套底部”。
### 避免回归的约束
后续凡是继续补 VT 功能、滚动逻辑或视口投影,必须守住下面两条:
1. `scroll-view` 的真实渲染行数和 overlay 的最大滚动值,必须来自同一份 viewport state。
2. normal buffer 的页面投影不能直接渲染 `cursorRow` 之后的尾部空行alternate screen 例外,仍按整屏保留。
本次已经用纯函数测试把这两条钉住,避免以后补 `top/vim/less` 支持时把 normal buffer 的滚动边界打回去。
### 验收确认
1. 历史超过一屏时,回到底部后,当前命令行不再继续被往上推。
2. 历史不满一屏时,不再存在真实可滚动空间。
3. 光标和红框仍保持原本正确行为,没有因为这次修复被拖着漂移。
### 涉及文件
- `apps/miniprogram/pages/terminal/index.js`
- `apps/miniprogram/pages/terminal/terminalViewportModel.js`
- `apps/miniprogram/pages/terminal/terminalViewportModel.test.ts`
- `docs/miniprogram-terminal-vt-guardrails-2026-03-08.md`
### 验证命令
```bash
node --check apps/miniprogram/pages/terminal/terminalViewportModel.js
node --check apps/miniprogram/pages/terminal/index.js
npx --no-install eslint apps/miniprogram/pages/terminal/index.js apps/miniprogram/pages/terminal/terminalViewportModel.js apps/miniprogram/pages/terminal/terminalViewportModel.test.ts
npx vitest run apps/miniprogram/pages/terminal/terminalViewportModel.test.ts apps/miniprogram/pages/terminal/terminalBufferState.test.ts apps/miniprogram/pages/terminal/terminalBufferSet.test.ts apps/miniprogram/pages/terminal/terminalKeyEncoder.test.ts
npm run test
npm run typecheck
npm run lint
npm run build
```
### 解决时间
2026-03-08
---
## Q15小程序终端 `Codex` 交互严重卡顿与按钮无响应(对照 xterm.js 的时间片写入优化2026-03-10
### 现象
1. 点击 `Codex` 连接项后,首个可见回显有时需要数秒到 `10s+` 才出现。
2. 等待与输出洪峰期间,除上下滑动外,右上角连接按钮、工具按钮、输入区聚焦、软键盘输入等交互经常无响应或出现超长延时。
3. 右上角时延显示会被拉高到 `10s+`,但与实际网络状态不一致。
### 根因
根因分两层:
1. **第一层:布局刷新风暴**
- 小程序终端早期实现里,碎片化 `stdout` 会高频触发 `query -> setData -> postLayout -> overlay` 全链路刷新;
- `layout.refresh.long` 一度达到 `3s~4s`,主线程与视图层桥接严重堆积。
2. **第二层stdout 批处理仍是长同步任务**
- 在完成 `stdout` 合批和 layout 单飞后,瓶颈继续收敛到 `captureTerminalBufferState -> applyTerminalOutput -> applyTerminalBufferState`
- `cloneCostMs + applyCostMs + stateApplyCostMs` 能达到数秒;
- 按钮点击、输入框事件、`pong` 时延更新虽然逻辑上异步,但最终都运行在同一个小程序页面 JS 主线程,因此会被这段同步任务整体堵住。
右上角时延显示异常的本质也在这里:
1. 时延值来自 `pingAt -> pong` 的时间差;
2. 一旦主线程晚处理 `pong`,显示出来的就是“网络 RTT + 主线程排队延迟”;
3. 因此过去看到的 `10s+` 更像消息处理延迟,而不是纯网络 RTT。
### 解决方案
本次按 `xterm.js``WriteBuffer` 思路,分阶段把小程序终端改造成“短时间片、可续跑、优先让出主线程”的模型:
#### 第一阶段stdout 合批 + layout 单飞
1. `stdout` 不再每个 chunk 都立刻刷新页面,而是先进入 `terminalRenderScheduler` 合批。
2. 同一时刻只允许一轮真实的 `layout + overlay` 在飞in-flight 期间的新输出只合并成下一轮待处理请求。
3. overlay 直接复用 layout 的 `rect/cursorMetrics`,避免额外再做一次查询。
#### 第二阶段:安全切片 + 时间片推进
1. 新增 `takeTerminalReplaySlice(...)`,保证 `CSI / OSC / DCS / ESC / CRLF` 不会被从中间切断。
2. stdout 批处理改成时间片推进:
- 单轮只处理一个很短的 slice 窗口;
- 当前轮结束后用 `setTimeout(..., 0)` 续跑;
- 用户刚点击按钮、聚焦输入区、发送控制键时,本轮 slice 会更早让出主线程。
3. 新增 `stdout.slice` 过程日志,用于区分“单 slice 耗时”和“整批 append 耗时”。
#### 第三阶段:运行态复用,继续压缩深拷贝成本
1. `stdout task` 创建时只复制一次运行态,后续 slice 直接复用这份已隔离状态;
2. `applyTerminalOutput(...)` 支持在运行态上原地推进,不再每个 slice 重复复制 `normal/alt buffer`
3. 页面层发布 active buffer 时改为引用发布,减少 `stateApply` 阶段的再次深拷贝。
### 解决结果
1. `Codex` 启动后的首个可见回显等待时间已显著缩短,不再长期停留在“点击后数秒到十秒以上无反馈”的状态。
2. 输出洪峰期间,按钮、工具区、输入区聚焦的无响应现象已显著缓解,主问题按“已解决”收口。
3. `layout.refresh` 已从秒级下降到几十毫秒量级;当前主要长尾已从布局风暴收敛到 stdout 写入热路径,并在本轮继续被压缩。
4. 右上角时延显示不再频繁被主线程阻塞拉高;同时文档口径明确了旧问题本质不是纯网络 RTT。
### 当前边界
1. 极端大输出、长时间连续洪峰下,仍有继续压缩长尾的空间。
2. 后续若继续优化,重点会落在 `applyTerminalOutput` 的更细粒度增量化、字符串/字节统计成本和更激进的 stdout 时间片预算控制。
### 涉及文件
- `apps/miniprogram/pages/terminal/index.js`
- `apps/miniprogram/pages/terminal/vtParser.js`
- `apps/miniprogram/pages/terminal/vtParser.test.ts`
- `apps/miniprogram/pages/terminal/terminalBufferState.js`
- `apps/miniprogram/pages/terminal/terminalBufferState.test.ts`
- `apps/miniprogram/pages/terminal/terminalBufferSet.js`
- `apps/miniprogram/pages/terminal/terminalBufferSet.test.ts`
- `apps/miniprogram/pages/terminal/terminalRenderScheduler.js`
- `docs/miniprogram-terminal-xterm-stall-optimization-plan-2026-03-10.md`
- `release.md`
### 验证命令
```bash
npx vitest run apps/miniprogram/pages/terminal/terminalBufferSet.test.ts apps/miniprogram/pages/terminal/terminalBufferState.test.ts apps/miniprogram/pages/terminal/vtParser.test.ts apps/miniprogram/pages/terminal/terminalRenderScheduler.test.ts apps/miniprogram/pages/terminal/terminalKeyEncoder.test.ts apps/miniprogram/pages/terminal/touchShiftState.test.ts
npm run test
npm run typecheck
npm run lint
npm run build
npm run mini
```
### 解决时间
2026-03-10
---
## Q16小程序 Codex 底部提示块缺行与闪动footer 裁剪 + 同步刷新中间态暴露2026-03-11
### 现象
1. 小程序终端进入 `Codex` 持续输出阶段后,底部本应稳定存在的提示块经常只剩上半部分。
2. 提示块下方的状态/路径行会被裁掉,偶尔短暂出现,但大多数时间不可见。
3. 输出高频刷新期间,底部区域会反复上下闪动,给人的观感是“交互区不稳定”。
4.`> Use /skills to list available skills`、代码块等整行高亮区域里,部分行与行之间还能看到终端底色细线透出来。
### 根因
根因分两层:
1. **第一层normal buffer viewport 裁尾过猛**
- 原先 viewport 近似只保留到 `cursorRow + 1`
-`Codex` 的真实 footer 位于光标行之后时footer 虽然已经写进 buffer却会在投影阶段被直接裁掉。
2. **第二层:同步刷新窗口未收口**
- `Codex` 会用 `CSI ? 2026 h/l` 包裹一批局部重绘;
- 若不处理这组同步刷新边界,小程序会把整批 repaint 的中间态逐帧暴露出来,于是底部区域出现明显闪动。
3. **第三层:整行高亮背景仍按 segment 行盒绘制**
- 小程序终端把 ANSI 背景色挂在行内 `text segment` 上,而不是整行容器上;
- 当字体实际占高与行高存在余量时segment 背景不会覆盖整行高度,于是高亮块行间会露出底色细线;
- 这个问题不属于控制流漏处理,而是渲染层背景承载位置不对。
### 解决方案
本次按“最小风险修复”落地:
1. viewport 保留 `cursorRow` 之后仍真实存在的 footer不再简单按光标行截断 normal buffer。
2. stdout 入口对 `CSI ? 2026 h/l` 同步刷新窗口做收口,降低中间态直接显示到界面的概率。
3. 新增 2026-03-11 真实 PTY 抓包回放用例,并补齐 footer 保留与同步刷新窗口相关回归测试。
4. 对“整行统一背景”的 render line将背景提升到 line 容器层绘制;混合背景行继续保留原有 segment 语义,不新增渲染节点,也避免高亮块行间透底。
### 解决结果
1. `Codex` 持续输出期间,底部提示块与状态行已能稳定显示完整高度,不再长期缺最后几行。
2. 输出过程中的明显上下闪动已显著收敛,交互观感恢复稳定。
3. `> Use /skills to list available skills`、代码块与同类整行高亮区域不再透出行间底色细线。
4. 当前版本 `v2.9.4` 已将该问题按“已解决”收口,并同步更新到对外文档口径。
### 涉及文件
- `apps/miniprogram/pages/terminal/vtParser.js`
- `apps/miniprogram/pages/terminal/vtParser.test.ts`
- `apps/miniprogram/pages/terminal/terminalViewportModel.js`
- `apps/miniprogram/pages/terminal/terminalViewportModel.test.ts`
- `apps/miniprogram/pages/terminal/terminalCursorModel.js`
- `apps/miniprogram/pages/terminal/terminalCursorModel.test.ts`
- `apps/miniprogram/pages/terminal/index.js`
- `apps/miniprogram/pages/terminal/index.wxml`
- `apps/miniprogram/pages/terminal/codexCaptureFixture.js`
- `apps/miniprogram/pages/terminal/codexCaptureReplay.test.ts`
- `docs/miniprogram-codex-footer-flicker-optimization-plan-2026-03-11.md`
- `release.md`
### 验证命令
```bash
npx vitest run apps/miniprogram/pages/terminal/vtParser.test.ts apps/miniprogram/pages/terminal/terminalViewportModel.test.ts apps/miniprogram/pages/terminal/terminalCursorModel.test.ts apps/miniprogram/pages/terminal/codexCaptureReplay.test.ts
npm run test
npm run typecheck
npm run lint
npm run build
```
### 解决时间
2026-03-11
---
## Q18小程序 Codex 多轮交互后持续卡顿(真机日志二次归因 + stdout 渲染降频2026-03-17
### 现象
1. 小程序终端进入 `Codex` 多轮交互后,响应会越来越慢,按钮、输入聚焦和滚动都开始卡顿。
2. 慢的时候手机侧耗电明显升高,说明问题更像 JS 主线程 / 视图桥接持续吃 CPU而不是单纯网络慢。
3. 旧日志里经常出现 `scheduler_backlog``stdout.append.long`,一次卡顿可持续数秒到十几秒。
### 根因
根因这次靠真机日志收敛得比较明确,不再停留在“可能是 buffer 太大”的猜测层面:
1. **`scheduler_backlog` 是结果,不是第一根因**
- 真机日志里最慢样本虽然经常表现为 `queueWaitMs / schedulerWaitMs` 很大;
- 但拆开看,真正拖住前一轮任务的主成本是重复的 `layout + overlay`,不是排队逻辑本身。
2. **同一个 `stdout task` 被重复刷新太多次**
- 典型慢样本里,`26KB ~ 55KB` 的 stdout task 会在几秒执行窗口内触发 `11 ~ 14``render/layout/overlay`
- 同一批数据不是“一次大刷新太重”,而是“中等刷新反复发生太多次”;
- `lastRenderDecisionReason="remaining_below_threshold"` 说明旧策略在 task 尾段容易形成 render 风暴。
3. **真正重的是页面层桥接和 overlay不是 VT 解析本身**
- 真机日志里 `layoutCostMs + overlayCostMs` 长期显著高于 `cloneCostMs + applyCostMs + trimCostMs + stateApplyCostMs`
- `renderBuildCostMs` 本身不高,问题更多集中在 `setData` / overlay 这类页面提交成本。
4. **这次问题不是要“重做一个 xterm”**
- 现有小程序方案必须保留原生软键盘、焦点、滚动和输入代理约束;
- 可以借鉴 `xterm` 的调度思路,但不能把整个渲染架构照搬过去。
### 解决方案
本次按“保持小程序壳层轻量,只收敛真正热点”的原则落地:
1. **先加低噪声诊断日志,不刷屏**
- 增加 `perf.summary / perf.snapshot / stdout.append.long / main_thread_lag` 聚合日志;
- 同时记录 `renderPassCount / layoutPassCount / overlayPassCount / totalSetDataCostMs` 等字段,把卡顿从“感觉慢”变成可量化归因。
2. **stdout 改成 task 级渲染冷却**
- 不再因为尾段 `remaining_below_threshold` 就连续触发多次 render
- task 完成、用户输入相关场景仍可立即刷新,避免影响交互正确性。
3. **overlay 从 stdout 主链路里进一步降频**
- 非必要 render 不再每次都跟一次 overlay
- 只有最终帧、首次帧、用户输入相关场景或冷却到期时才同步 overlay。
4. **滚动补刷与顶部空白修复已单独拆分记录**
- 本条只保留与持续卡顿直接相关的 stdout 调度、render 冷却与 overlay 降频;
- 滚动顶部空白另见下方独立问题记录。
### 解决结果
1. 真机新日志已经从“单个 task 十几轮刷新”收敛到更低频:
- 例如 `4.9KB` 的 stdout task 现在是 `sliceCount=5``renderPassCount=2``layoutPassCount=2``overlayPassCount=2`
- 同类任务的 `activeStdoutAgeMs` 已收敛到亚秒级。
2. `layout / overlay / setData` 的累计成本明显下降,旧的 render 风暴特征不再持续出现。
3. 当前结论已经明确:
- **卡顿主因不是 buffer 体量**
- **主因是 stdout task 内重复页面刷新过多**
### 当前边界
1. 当前版本已经把主要热点从“单个 task 十几轮刷新”压到更低频,但极端大输出下仍需继续观察。
2. 后续若还要继续优化,优先方向仍是:
- 继续用真机日志验证是否还存在大 task 的 render 风暴;
- 继续压缩 stdout task 内的页面刷新次数;
- 继续控制 overlay 在高频输出期间的参与频率。
### 涉及文件
- `apps/miniprogram/pages/terminal/index.js`
- `apps/miniprogram/pages/terminal/terminalStdoutRenderPolicy.js`
- `apps/miniprogram/pages/terminal/terminalStdoutRenderPolicy.test.ts`
- `apps/miniprogram/pages/terminal/terminalRenderScheduler.js`
- `apps/miniprogram/pages/terminal/terminalRenderScheduler.test.ts`
- `apps/miniprogram/pages/terminal/terminalPerfLogBuffer.js`
- `apps/miniprogram/pages/terminal/terminalPerfLogBuffer.test.ts`
- `apps/miniprogram/pages/terminal/terminalBufferState.js`
- `docs/miniprogram-terminal-cpu-diagnostics-plan-2026-03-17.md`
### 验证命令
```bash
npx vitest run apps/miniprogram/pages/terminal/terminalStdoutRenderPolicy.test.ts apps/miniprogram/pages/terminal/terminalRenderScheduler.test.ts apps/miniprogram/pages/terminal/terminalPerfLogBuffer.test.ts
npm test
npm run typecheck
npm run lint
npm run build
```
### 解决时间
2026-03-17
---
## Q19小程序终端滚动到顶部偶发空白viewport 补刷时机偏晚2026-03-17
### 现象
1. 终端历史较长时,用户快速向上滑动或继续下拉顶部区域。
2. 顶部有时会先出现一大片空白,随后正文才补进来。
3. 这个问题主要影响滚动观感,不是持续卡顿主链路的主因。
### 根因
1. 终端为了控制 `scroll-view` 负担,只渲染正文窗口,其他高度通过上下 spacer 保持。
2. 旧逻辑即使已经接近甚至进入 `top spacer`,仍然会等待一轮预取定时器再补刷正文。
3. 因此用户快速滚到顶部时,会先看到 spacer 空白,再等正文窗口换入。
### 解决方案
1. 增加“立即补刷”判断:
- 一旦当前可视区已经压进 `top spacer / bottom spacer`,立刻触发窗口补刷。
2. 滚动补刷单独走 `scrollViewport` 模式:
- 只在手势浏览历史时放大正文窗口预算;
- 正常 stdout 输出仍保留默认轻量预算。
3. 滚动期正文窗口预算提升到 `224` 行,边缘 buffer 提升到 `40` 行,减少快速滑动时的换窗次数。
### 解决结果
1. 顶部空白按当前复现路径已收口。
2. 快速滑动历史时,正文换窗频率下降,体感比原先更连续。
3. 这个问题已从持续卡顿主问题中拆出,后续独立跟踪。
### 涉及文件
- `apps/miniprogram/pages/terminal/index.js`
- `apps/miniprogram/pages/terminal/terminalViewportModel.js`
- `apps/miniprogram/pages/terminal/terminalViewportModel.test.ts`
- `apps/miniprogram/pages/terminal/terminalLayoutReuse.test.ts`
### 验证命令
```bash
npx vitest run apps/miniprogram/pages/terminal/terminalViewportModel.test.ts apps/miniprogram/pages/terminal/terminalLayoutReuse.test.ts
npm test
npm run typecheck
npm run lint
npm run build
```
### 解决时间
2026-03-17
---
## Q17小程序 Codex 会话续接后,历史区顶部空白并偶发裸露 `5;2H`2026-03-11
### 现象
1. 在小程序终端连接 `Codex` 并完成一轮输出后,返回服务器列表,再次进入同一服务器会话。
2. 恢复后的终端底部输入区与 footer 大体还在,但向下翻历史记录时,顶部会出现大块空白。
3. 部分场景顶部第一行还会出现类似 `5;2H` 这样的裸露定位参数。
4. 同一轮会话在挂起前的尾部缓冲其实是正确的,问题出现在“恢复第一页”这一步,而不是原始输出阶段。
### 根因
根因分两层:
1. **第一层:恢复时错误地优先使用了被裁剪过的 replayText**
- 终端挂起时同时保存了:
- 最近屏幕行快照 `lines`
- 用于几何变化后重建的尾部 `replayText`
- 其中 `replayText` 会被裁剪到 `128 KiB`,它不保证一定从一条完整业务行或一次完整 TUI 重绘边界开始。
2. **第二层:恢复发生在真实几何测量之前**
- 终端页 `onLoad` 时默认仍是 `80x24`
- 旧逻辑在这一阶段就直接拿 `replayText` 做 buffer 重建;
- 于是原本正确的 `lines` 快照被新的错误重建结果覆盖,历史行数从正确值掉到更少的值,并出现空白和半截 footer。
日志证据已经明确证明了这点:
1. 挂起前 `buffer.snapshot.persist``lineCount = 168`,尾部 ` Improve documentation in @filename` 与 footer 均完整。
2. 恢复后旧逻辑的 `buffer.snapshot.restore.replay_applied` 立刻变成更少的行数,并出现半截 `89` 一类残留。
3. 也就是说,问题不在“快照保存错了”,而在“恢复时用错了恢复源”。
### 解决方案
本次按“最小范围修复,保留后续几何重建能力”的思路落地:
1. 挂起时继续保存 `lines + replayText`,但额外把当时的 `bufferCols / bufferRows` 一起写进快照。
2. 恢复第一页时不再优先用 `replayText` 重建,而是:
- 先按快照里的 `bufferCols / bufferRows` 恢复当时的逻辑几何;
- 再直接用 `lines` 快照还原当前屏幕内容。
3. `replayText` 仍保留,但只留给“后续真实发生列数变化”的重建场景使用,不再在首次恢复时抢占权威来源。
4. 新增终端快照测试,锁住 `bufferCols / bufferRows` 的持久化与恢复前提,避免这类问题再次回归。
### 解决结果
1. 再次进入同一服务器会话时,历史区不再因为首次恢复误用 `replayText` 而塌缩成空白。
2. 向下翻历史记录时,顶部空白与裸露 `5;2H` 问题已收口。
3. 会话续接的当前口径已明确:
- **第一页恢复以 `lines` 快照为准**
- **几何变化后的再排版才使用 `replayText`**
### 涉及文件
- `apps/miniprogram/pages/terminal/index.js`
- `apps/miniprogram/utils/terminalSession.js`
- `apps/miniprogram/utils/terminalSession.test.ts`
- `apps/miniprogram/README.md`
- `CHANGELOG.md`
- `release.md`
### 验证命令
```bash
npx vitest run apps/miniprogram/utils/terminalSession.test.ts apps/miniprogram/pages/terminal/terminalBufferState.test.ts apps/miniprogram/pages/terminal/terminalRenderScheduler.test.ts apps/miniprogram/utils/storage.test.ts apps/miniprogram/utils/terminalNavigation.test.ts apps/miniprogram/utils/terminalSessionState.test.ts
npx eslint apps/miniprogram/pages/terminal/index.js apps/miniprogram/pages/connect/index.js apps/miniprogram/utils/terminalSession.js apps/miniprogram/utils/terminalSession.test.ts
npm run typecheck
npm run lint
npm run build
```
### 解决时间
2026-03-11