diff --git a/.gitignore b/.gitignore index 2111b1182d..c91d34379e 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ docsite/ .superpowers docs/superpowers .claude +.idea/ +/make/ diff --git a/.harness/decisions.md b/.harness/decisions.md new file mode 100644 index 0000000000..c3be1e28fb --- /dev/null +++ b/.harness/decisions.md @@ -0,0 +1,255 @@ +# Decisions + +## 2026-04-16 + +### 决策 1:飞书入口采用“专用 view + 复用 WebViewModel” + +- 原因:与现有 `web/help/tsunami` 模式一致 +- 收益:可以独立承载飞书默认 URL、分区、按钮和启动逻辑 +- 代价:比纯复用 `web` 多一个轻量 view 文件,但维护性更好 + +### 决策 2:本地飞书 App 启动放在主进程 + +- 原因:协议调用、注册表探测、路径探测都属于平台能力 +- 收益:前端只保留一个简单 API,不需要承载 Windows 细节 +- 回退链路:协议 -> `feishu:apppath` -> 注册表 -> 常见路径 -> 应用内网页 + +### 决策 3:飞书新窗口不再走普通 openLink,而是继承 `persist:feishu` + +- 原因:登录/授权/聊天子页面需要共享 cookie 与 storage +- 实现:在 `FeishuViewModel.handleNewWindow()` 中创建带 `web:partition` 的新 web block + +### 决策 4:入口改为 `Feishu App` + `Feishu Web` 双入口 + +- 原因:本地 App 与应用内网页的能力边界不同,强行合并会让用户误以为本地窗口是内嵌网页 +- 收益:入口语义更清晰,既能一键启动本地飞书,也能明确打开应用内网页聊天页 +- 代价:侧边栏多一个轻量入口,但整体可维护性更好 + +### 决策 5:`Feishu Web` 最终只保留图标隐藏入口 + +- 原因:用户确认“小眼睛”图标已经满足关闭需求,不再需要额外的文字隐藏按钮 +- 收益:界面更干净,同时保留现有 block header 的统一交互 +- 范围:移除额外文字按钮,不影响 `Feishu Web` 的网页容器能力 + +## 2026-04-21 + +# ADR-20260421-001: 终端问题改为“两阶段闭环”推进 + +## Context +- 当前终端已停用历史恢复链路,并已有单 terminal smoke +- 用户最新截图显示:真实多 terminal split-pane 场景下,输入框错位和滚轮回归仍会发生 +- 现有 smoke 通过 `window.term` 与内部 `.xterm-scrollable-element` 直派发事件,不能代表真实用户路径 + +## Options +- option A:继续在现有 `termwrap.ts` 上直接 patch +- option B:先补多 terminal / 真实焦点 / 真实 wheel 路径 smoke,再改业务逻辑 +- option C:先清理后端历史缓存死代码 + +## Decision +- chosen option:B +- why it was chosen:当前最大不确定性不是“补丁怎么写”,而是“真实失败路径是否已被自动化覆盖”;先补复现场景,再把业务逻辑收口到 xterm 官方扩展点,风险最低 + +## Consequences +- positive effects + - 避免再次出现“单测和单 terminal smoke 通过,但用户真实场景仍失败” + - 后续 wheel/IME 重构有更稳定的回归闭环 +- negative effects + - 比直接 patch 多一个前置任务包,短期交付稍慢 +- follow-up work + - `TASK-TERM-003` + - `TASK-TERM-004` + +## Review Date +- 2026-04-22 + +# ADR-20260422-002: Codex pane 顽固终端问题先做专项诊断闭环,再继续修复 + +## Context +- `TASK-TERM-005` 的多轮修复在 smoke 中多次显示 `passing`,但用户真实手测仍持续出现“中间 Codex pane 滚轮消失、IME 位置错误、只能看到最新几行” +- 现有 smoke 主要验证静态点位和 seed scrollback,仍缺少“持续输出期间”的真实命中区域、active terminal、IME ownership 证据 +- 继续直接修改 `termwrap.ts` / `termutil.ts`,高概率再次出现“修一处坏一处” + +## Options +- option A:先创建专项诊断包,只补脚本和 durable 工件,拿到真实交互证据后再修业务代码 +- option B:继续按 xterm / upstream 方向直接改 `termwrap.ts` +- option C:先扩大 scrollback 或 UI 尺寸,尝试缓解“只能看到最新几行” + +## Decision +- chosen option:A +- why it was chosen:当前最大不确定性已不是“代码怎么改”,而是“真实失败路径到底落在哪一层”;先补证据链能把下一包修复收敛到最小写集,避免继续盲修 + +## Consequences +- positive effects + - 先把“中间 Codex pane + 持续输出”的真实失败路径观测清楚 + - 减少 wheel、IME、scrollback 三条链路再次互相打架 + - 后续业务修复可以更精准地限制在最小文件集合 +- negative effects + - 短期内不会立即给出新的业务补丁 + - 需要先投入一轮脚本/实机诊断工作 +- follow-up work + - `TASK-TERM-006` + - 基于 `TASK-TERM-006` 结论再拆新的最小业务修复包 + +## Review Date +- 2026-04-23 + +# ADR-20260422-006: 不再使用 fake scrollback overlay 伪造终端历史视图 + +## Context +- `TASK-TERM-007` 为了在 `Codex + no native scrollback` 场景下恢复“看更早内容”的能力,引入了 `agentTuiHistoryLines` 与 `.wave-agent-scrollback-overlay`; +- 技术 smoke 证明这套路径确实能把更早输出展示出来,但用户最新手测截图明确显示:滚轮一旦触发,终端字体、排版、底部状态条都会看起来像切换到另一套 UI; +- 代码复核也已确认:当前显示变化并不是 xterm 自身重排,而是 `renderAgentScrollbackOverlay()` 用 `overlay.textContent` 把一份纯文本历史重新盖在 live terminal 上; +- upstream `wavetermdev/waveterm` 并不存在这套 overlay 逻辑,这属于本地修复过程中新增的偏离路径。 + +## Options +- option A:删除 fake scrollback overlay,恢复官方 terminal live render 路径,只保留最小 wheel -> `PageUp/PageDown` fallback +- option B:继续打磨 overlay,让 fake history 在样式上尽量接近真实 terminal +- option C:继续尝试在本地模拟 native terminal scrollback / shadow buffer + +## Decision +- chosen option:A +- why it was chosen:用户当前最痛的不是“能不能看见更早文本”本身,而是“滚一下就切成一套假的终端渲染”。删除 overlay 能直接止住这个最明显、最破坏信任的回归,同时最大程度回到 upstream/官方渲染路径。 + +## Consequences +- positive effects + - 滚轮前后 terminal 继续由 xterm live DOM 驱动,不再切成纯文本重绘层 + - 修复方向重新对齐 upstream/官方实现边界 + - 后续只需维护最小的 wheel fallback,而不是长期维护一套伪终端渲染器 +- negative effects + - 删掉 overlay 后,可视“历史深度”将受限于 Codex 自身 TUI 的翻页能力 + - 无法在本仓库内单独补齐 Windows Terminal 那种 native scrollback 体验 + +## Follow-up Work +- `TASK-TERM-008` + +## Review Date +- 2026-04-23 + +# ADR-20260422-005: 终端修复交付必须同时更新默认 `make` 包,不能只验证 `make-smoke` + +## Context +- `TASK-TERM-007` 的 Codex wheel / IME 修复在 `make-smoke\win-unpacked\Wave.exe` 中已经通过真实 smoke; +- 但用户持续反馈“还是滚不了”,最终比对发现默认 `make\win-unpacked\Wave.exe` 仍然是旧 hash; +- 旧默认包缺少 `agentTuiHistoryLines` / `wave-agent-scrollback-overlay` 等新代码,导致用户即使打开仓库内 exe,也可能仍在使用旧前端。 + +## Options +- option A:继续只验证 `make-smoke`,手工提醒用户自己找对 exe +- option B:每次终端相关修复完成后,强制重跑 `build:prod` + `electron-builder --win dir`,并校验默认 `make` 与 smoke 包 hash 一致 +- option C:只复制 `make-smoke` 到 `make`,不保留正式重打包步骤 + +## Decision +- chosen option:B +- why it was chosen:这能直接消除“代码已修好但用户打开的仍是旧包”的交付歧义;同时比单纯目录复制更可追溯,能保证 `dist` 与 `make` 一致。 + +## Consequences +- positive effects + - 后续用户手测默认指向 `make\win-unpacked\Wave.exe` 时,不再混淆新旧包 + - `make` 与 `make-smoke` 的 hash 可直接比对,便于确认是不是最新构建 + - 终端问题的 smoke 证据和最终交付包保持同一份前端代码 +- negative effects + - 每轮终端修复收尾都需要额外跑一次正式 `build:prod` 和 `electron-builder` + - 如果用户正占用默认 `make` 目录包,会增加一次“先停旧进程再重打包”的流程 + +## Follow-up Work +- `TASK-TERM-007` + +## Review Date +- 2026-04-23 + +# ADR-20260422-004: 当前轮改为“整块 terminal 容器 bubble wheel 兜底 + 复用 xterm 原生 textarea 坐标” + +## Context +- 用户当前真实窗口已被直接定位到三栏 terminal,其中中间栏 `46ef...` 为当前焦点; +- 用当前 pane 的 `WAVETERM_JWT` + `wsh termscrollback` 读取后,已确认真实用户实例仍是 `baseY=0`、无 native scrollback; +- 现有 `termwrap.ts` 的 wheel 兜底仍过窄: + - 只在 `mouseTrackingMode !== none` 时才走外层 fallback; + - 对 Codex 这种 `normal buffer + mouseTrackingMode=none + full-screen repaint` 的真实场景,整块 terminal 内容区域可能出现“只有小块区域可滚”; +- 现有 IME override 会在 `_syncTextArea()` 之后再次按 `cursorRow/cursorCol` 手算 `top/left`,容易把 xterm 官方已经算好的位置再次算偏。 + +## Options +- option A:继续只依赖 `attachCustomWheelEventHandler()`,不加整块容器 fallback +- option B:在 terminal 容器上增加 bubble 阶段 wheel 兜底,并让 IME override 复用 `_syncTextArea()` 结果 +- option C:彻底去掉 IME override,只保留 xterm 默认行为 + +## Decision +- chosen option:B +- why it was chosen:它同时解决了两个真实痛点: + - 当 wheel 没命中 xterm 内层节点时,外层 bubble fallback 仍能兜住整个 terminal 区域; + - IME 位置不再依赖我们自己二次推导的网格坐标,而是直接沿用 xterm 官方同步后的坐标,能最大程度降低再次算偏的概率。 + +## Consequences +- positive effects + - 修复范围仍限制在 `frontend/app/view/term/termwrap.ts` + - wheel 与 IME 两条链路重新共享同一份更稳定的 agent TUI 判定 + - 更贴近 xterm 官方行为,减少“自己把 textarea 又算坏”的风险 +- negative effects + - 仍需继续观察 bubble fallback 是否会在极端情况下与某些内层事件路径重叠 + - clean-room CDP 的 Codex 启动时序仍有波动,导致自动化 wheel 断言偶发不稳定 +- follow-up work + - 继续让用户在真实中间 pane 中复测 + - 如仍有边角问题,再补一条更窄的 runtime diagnostic,而不是重新大改 term 架构 + +## Review Date +- 2026-04-23 + +# ADR-20260422-003: Codex 无 native scrollback 时,滚轮优先转为内部翻页 + +## Context +- `TASK-TERM-006` 已证实:真实 `codex --no-alt-screen` 在 Wave/xterm 中是 `normal buffer + 全屏重绘`,但 `baseY=0 / length=73` 不增长,因此没有 native terminal scrollback 可滚。 +- 已额外验证 `CSI 6n` 在 Wave 中能正常收到 `ESC[row;colR]` 响应,说明问题不在 CPR / 初始光标定位。 +- 已额外验证 Codex 自己能响应 `ESC[5~ / ESC[6~]`,即使没有 native scrollback,也可以在自身 UI 内部前后翻页。 + +## Options +- option A:继续尝试在 Wave 侧伪造 Codex native scrollback +- option B:在 Codex 无 native scrollback 时,把 wheel 翻译为 `PageUp/PageDown` +- option C:维持现状,只等待 Codex 上游彻底修复 xterm.js / inline 模式兼容 + +## Decision +- chosen option:B +- why it was chosen:这是当前最小、最可验证、且不会重新引入历史缓存链路的修复;它直接恢复用户最关心的“滚轮能翻看当前 Codex 对话更早内容”,同时不需要侵入 xterm 内核或伪造 scrollback。 + +## Consequences +- positive effects + - 在 `baseY=0` 的真实 Codex 场景下,滚轮终于有可见效果 + - 修复范围收敛在 `termwrap.ts` / `termutil.ts` + - 与现有 IME owner 逻辑基本解耦,回归风险相对可控 +- negative effects + - 恢复的是 Codex **内部翻页**,不是 Windows Terminal 那种 native terminal scrollback + - 如果 Codex 上游未来补齐 xterm.js inline scrollback 兼容,这条 fallback 可能需要重新收窄 +- follow-up work + - `TASK-TERM-007` + +## Review Date +- 2026-04-23 +# ADR-20260422-007: Codex transcript 只允许注入同一 live xterm,不再回到 fake overlay + +## Context +- `TASK-TERM-008` 的用户核心投诉已从“能否翻到更早内容”收口为“滚一下就切成另一套假终端渲染”。 +- 既有深度 probe `D:\files\AI_output\waveterm-terminal-smoke\task-term-008-native4-probe.json` 已证明:在默认交付包上,`fakeOverlayExists=false`、`xtermOverlayExists=false`,同时 `baseY` 已真实增长并可从 `viewportY=63` 滚到 `10`。 +- 2026-04-22 再次重打默认包后,`make\win-unpacked\Wave.exe` 的 SHA256 仍为 `BB7D7277A4F437B373F8B6F6E08B52DFB87BA5C2E2717F94A25F111EB12EC34A`,说明本轮交付物与已验证产物一致。 + +## Options +- option A:恢复或继续修补 fake overlay / xterm overlay 方案。 +- option B:维持“隐藏 preview terminal + native scrollback injection”的当前路径,只允许在同一个 live xterm 内补足 transcript。 +- option C:完全不做 transcript 补足,只接受 Codex 当前 repaint 窗口内的可见内容。 + +## Decision +- chosen option:B +- why it was chosen:它是当前唯一同时满足三件事的路径: + - 不再切换到另一套伪渲染; + - 仍能把早期输出补进同一个 xterm 的真实 scrollback; + - 对用户看到的 IME、状态条、字体和 terminal chrome 保持同一渲染面。 + +## Consequences +- positive effects + - 默认交付包已与深度验证证据对齐,可继续直接让用户从 `make\win-unpacked\Wave.exe` 手测。 + - 后续若继续优化,也应只围绕“更早启动 transcript 捕获”这类同路径收口,不再回到 overlay 分叉实现。 +- negative effects + - 仍需接受 Codex 上游 full-screen repaint 时序波动带来的自动化不稳定性。 + - 若未来上游真正补齐 native scrollback 语义,还需要再收窄这层 transcript augmentation,避免重复保留历史。 + +## Follow-up Work +- 如用户仍能稳定复现“最前几行丢失”,下一刀只看 `frontend/app/view/term/termwrap.ts` 的 transcript 捕获起点,不重开 overlay 路径。 + +## Review Date +- 2026-04-23 \ No newline at end of file diff --git a/.harness/feature-list.json b/.harness/feature-list.json new file mode 100644 index 0000000000..23efb6db7c --- /dev/null +++ b/.harness/feature-list.json @@ -0,0 +1,196 @@ +[ + { + "id": "TASK-001", + "title": "飞书入口增强与 Harness 初始化", + "status": "passing", + "priority": "P1", + "scope": [ + "emain/emain-feishu.ts", + "emain/emain-ipc.ts", + "emain/preload.ts", + "frontend/app/view/feishuview/feishuview.tsx", + "frontend/app/view/webview/webview.tsx", + "frontend/app/view/webview/webviewenv.ts", + "frontend/app/block/blockregistry.ts", + "frontend/app/block/blockutil.tsx", + "pkg/wconfig/defaultconfig/widgets.json", + "pkg/wconfig/defaultconfig/settings.json", + "pkg/wconfig/settingsconfig.go", + "frontend/types/custom.d.ts", + "frontend/types/gotypes.d.ts", + "schema/settings.json", + ".harness/*", + "scripts/verify.ps1", + "AGENTS.md", + "CLAUDE.md" + ], + "acceptance": [ + "飞书入口支持本地 App 自动发现、配置路径覆盖与网页兜底", + "飞书视图弹出的新窗口继承 persist:feishu 分区", + "用户可见地提供 `Feishu App` / `Feishu Web` 双入口,且 `Feishu Web` 可直接隐藏当前卡片", + "最小验证命令 scripts/verify.ps1 通过", + "仓库具备最小可续跑的 Harness 工件" + ], + "notes": "代码实现与 verify 已完成;剩余阻塞是运行态 smoke 依赖账号态,且本地启动环境还缺少可用的 WCLOUD_ENDPOINT。" + }, + { + "id": "TASK-TERM-001", + "title": "终端滚轮与输入法位置专项修复", + "status": "passing", + "priority": "P1", + "scope": [ + "frontend/app/view/term/termwrap.ts", + "frontend/app/view/term/termutil.ts", + "frontend/app/view/term/fitaddon.ts", + "frontend/app/view/term/osc-handlers.ts", + ".harness/*" + ], + "acceptance": [ + "普通终端历史可以用鼠标滚轮上下滚动", + "Codex/Agent 会话中 normal buffer 与 alternate buffer 的滚轮行为符合预期", + "中文输入法候选框/组合文本不再出现在左上角或历史 viewport 位置", + "调整窗口大小或从历史恢复后,当前输入位置和可视 viewport 不错位", + "vitest、scripts/verify.ps1 与 electron-builder 验证完成或明确记录阻塞" + ], + "notes": "已回到 upstream/main termwrap 官方主线,只保留 termsize 强制同步、Codex/Agent IME 锚点和 normal buffer 滚轮兜底。最新修正后,wheel 兜底改为 bubble 阶段,避免抢占 xterm 内部 xterm-scrollable-element;IME 改为跟随当前 cursor 行列,而不是固定中线。按用户最新要求,前端已彻底停用 terminal 历史缓存/恢复链路:不再读取 cache:term:full、不再调用 SaveTerminalState,只保留当前会话 term blockfile 的实时 append;为避免初始化阶段丢数据,新增 heldData 顺序回放。" + }, + { + "id": "TASK-TERM-002", + "title": "终端回归 Smoke 自动化闭环", + "status": "passing", + "priority": "P1", + "scope": [ + "scripts/smoke-terminal.ps1", + ".harness/*" + ], + "acceptance": [ + "脚本可启动最新 make\\win-unpacked\\Wave.exe 并连接 Electron CDP", + "脚本可静态确认 termwrap.ts 不再包含历史缓存/恢复入口", + "脚本可运行态确认 window.term 可达、历史方法为空、serializeAddon 不存在", + "脚本可验证 wheel 改变 viewportY,IME textarea 与 cursor 对齐", + "脚本输出 JSON 和截图,失败时给出明确原因" + ], + "notes": "已新增 scripts/smoke-terminal.ps1。首次 smoke 抓到旧 win-unpacked bundle 仍暴露历史方法;重跑 scripts/verify.ps1 与 electron-builder --win dir 后通过。最新结果 JSON 为 D:\\files\\AI_output\\waveterm-terminal-smoke\\terminal-smoke-20260421-162451.json,截图为 D:\\files\\AI_output\\waveterm-terminal-smoke\\terminal-smoke-20260421-162451.png;wheel viewportY 127-\u003e87,IME topDelta/leftDelta 均为 0。" + }, + { + "id": "TASK-TERM-003", + "title": "多终端焦点与真实事件路径 Smoke 补强", + "status": "passing", + "priority": "P1", + "scope": [ + "scripts/smoke-terminal.ps1", + ".harness/*" + ], + "acceptance": [ + "脚本可识别多 terminal block,而不是只验证 window.term", + "脚本可断言 active/focused terminal 与 IME helper 所属 terminal 一致", + "脚本可区分真实外层 wheel 路径与内部 scrollableElement 路径结果", + "split-pane 场景失败时可明确标出焦点归属问题或 wheel 路由问题" + ], + "notes": "已将多终端 smoke 拆到 scripts/smoke-terminal.runtime.js,并让 scripts/smoke-terminal.ps1 在运行态自动注册多个 TermWrap、必要时用 RpcApi.CreateBlockCommand 创建 splitdown 终端、按 blockId 枚举 DOM/runtime、区分 elementFromPoint 外层 wheel 路径与内部 scrollableElement fallback,并在截图后自动清理新增 block。最新结果为 D:\\files\\AI_output\\waveterm-terminal-smoke\\terminal-smoke-20260421-165710.json / .png:dom terminal=3、known runtime=2、wheel 两个场景均为 ok,但 IME ownership 明确失败为 ime_wrong_terminal,说明多 terminal 下仍有错误 terminal 保留 helper textarea 定位。" + }, + { + "id": "TASK-TERM-004", + "title": "将 Wheel / IME 修复收口到 xterm 官方扩展点与焦点归属", + "status": "passing", + "priority": "P1", + "scope": [ + "frontend/app/view/term/termwrap.ts", + "frontend/app/view/term/termutil.ts", + "frontend/app/view/term/termutil.test.ts", + "scripts/smoke-terminal.ps1", + "scripts/smoke-terminal.runtime.js", + "scripts/smoke-terminal-real-wheel.ps1", + "package.json", + "package-lock.json", + ".harness/*" + ], + "acceptance": [ + "使用 xterm 官方 wheel hook 处理 normal buffer 滚轮兜底", + "只有 active terminal 可重定位 IME helper", + "多 terminal split-pane 场景中输入框不串位,滚轮不串 terminal", + "vitest、verify、smoke、electron-builder 验证通过" + ], + "notes": "已完成收口:`termwrap.ts` 现在优先使用 xterm `attachCustomWheelEventHandler` 处理 normal buffer wheel,只在 `mouseTrackingMode !== none` 且 normal buffer 时保留一个极窄 capture fallback;IME 侧新增 `TermWrap.liveInstances` 与全局 `imeOwnerBlockId`,只有当前 owner terminal 才允许保留 zIndex=5/6 的 helper override,并在 `focus` / `compositionstart` 时先调用 xterm `_syncTextArea()` 对齐官方 issue #5734 / PR #5759 的修复点。为准确验证本次修复,还补了 `scripts/smoke-terminal.runtime.js` 与 `scripts/smoke-terminal-real-wheel.ps1`:前者覆盖多 terminal/IME ownership,后者用 CDP `Input.dispatchMouseEvent(mouseWheel)` 走真实鼠标滚轮路径。2026-04-21 已将包版本提升到 `2026.4.21-2` 并重新产出 `make\\Wave-win32-x64-2026.4.21-2.exe` / `.zip`;最终验证通过 vitest、`scripts/verify.ps1`、`electron-builder --win dir nsis zip`、`scripts/smoke-terminal.ps1 -KillExistingRepoWave`、`scripts/smoke-terminal-real-wheel.ps1 -KillExistingRepoWave`,最新真实滚轮结果为 D:\\files\\AI_output\\waveterm-terminal-smoke\\terminal-real-wheel-20260421-184158.json,2 个 terminal 的 screen-center/screen-right 均为 `ok`。但用户最新反馈显示 Codex 交互态仍可复现“IME 正常、滚轮失效”,说明当前方案只在 normal buffer 路径上成功,尚未覆盖 Codex / alternate buffer / mouse tracking 的真实交互态。" + }, + { + "id": "TASK-TERM-005", + "title": "Codex / alternate buffer 全视图滚轮收口", + "status": "failing", + "priority": "P1", + "scope": [ + "frontend/app/view/term/termwrap.ts", + "frontend/app/view/term/termutil.ts", + "frontend/app/view/term/termutil.test.ts", + "scripts/smoke-terminal.runtime.js", + "scripts/smoke-terminal.ps1", + "scripts/smoke-terminal-real-wheel.ps1", + ".harness/*" + ], + "acceptance": [ + "右侧 Codex pane 在可见输出区域滚轮可用", + "split-pane 场景中只滚动当前 active terminal", + "alternate buffer / mouse tracking 场景不再出现 IME 正常但滚轮失效", + "IME 不回退,smoke 明确覆盖 normal 与 non-normal 路径" + ], + "notes": "该任务源于 2026-04-21 architect review。研究发现当前 wheel 逻辑只覆盖 `normal buffer`:`frontend/app/view/term/termwrap.ts` 在非 normal buffer 时直接返回,而 `scripts/smoke-terminal.runtime.js` 也把 `non-normal-buffer` 当作失败而非覆盖目标;同时 `termutil.ts` 仍保留 alternate buffer 的 wheel fallback 设计但未接入执行路径。2026-04-22 用户连续手测再次明确:中间 Codex pane 仍会出现“IME 位置错误、滚轮消失、输出过程中不可滚、只能看到最新几行”。因此当前 `passing` 结论已被真实场景否定;现有 smoke 仍缺少 `输出进行中`、`中间 Codex pane 实际命中区域`、`实时 active terminal / IME owner 漂移` 这几条关键验证,任务状态回退为 `failing`,并拆出 `TASK-TERM-006` 先做诊断闭环,再决定是否继续改业务逻辑。" + }, + { + "id": "TASK-TERM-006", + "title": "Codex pane 持续输出滚动 / 命中区域 / IME ownership 诊断闭环", + "status": "passing", + "priority": "P1", + "scope": [ + "scripts/smoke-terminal.runtime.js", + "scripts/smoke-terminal.ps1", + "scripts/smoke-terminal-real-wheel.ps1", + "scripts/*diagnostic*.ps1", + ".harness/*" + ], + "acceptance": [ + "可稳定记录中间 Codex pane 在持续输出期间的真实命中元素与 active terminal", + "可区分 wheel 未命中、命中后被 xterm/app 消费、以及无 scrollback 三种失败路径", + "可确认 IME helper / composition-view 在多 pane + 持续输出期间是否 owner 漂移", + "诊断结果可直接指导下一包最小业务修复,不再继续盲改" + ], + "notes": "该任务由 2026-04-22 用户批准 architect 方向 A 后创建。任务目标不是继续修补 `termwrap.ts`,而是先把现有 smoke 的盲区补上:覆盖“中间 Codex pane + 持续输出 + 实际 elementFromPoint 命中区域 + active terminal / IME owner 漂移”。2026-04-22 最新诊断已补进 `scripts/smoke-terminal.runtime.js` 与 `scripts/smoke-terminal-real-wheel.ps1`:在 3-pane 几何中间 terminal 的纯 xterm 场景下,`持续输出 + synthetic wheel` 与 `持续输出 + CDP real mouseWheel` 均通过,命中链路稳定落在目标 terminal,自身不会串滚到其他 pane;对应结果分别为 `D:\\files\\AI_output\\waveterm-terminal-smoke\\terminal-smoke-20260422-143509.json` 与 `D:\\files\\AI_output\\waveterm-terminal-smoke\\terminal-real-wheel-20260422-143832.json`。同时已新增 `scripts/smoke-terminal-codex-pane.ps1`,可直接 attach 到 Wave 实例并在中间 pane 拉起真实 `codex`;`terminal-codex-pane-20260422-145534.json` 已确认真实 Codex pane 命中中间 terminal,`shouldAnchorIme=true`、`imeOwnerBlockId` 对齐目标 pane、命中元素也落在目标 pane。进一步用 `codex --no-alt-screen \"List the numbers 1 through 300, one per line, then stop.\"` 做真实长输出诊断后,`terminal-codex-pane-20260422-150151.json` 显示:即使在真实 Codex 长输出下,pane 仍为 `bufferType=normal`、`mouseTrackingMode=none`,但 `baseY=0`、`viewportY=0`、`length=73` 始终不增长;而同一份 JSON 附带的 `debugTermTail` 明确显示了大量 `ESC[K`、`ESC[H`、`?2026h/l` 等全屏重绘序列。这说明真实 Codex 在当前 Wave 路径里进入的是“normal buffer + 全屏重绘但不形成 scrollback”的状态。随后又补做了两项关键对照:一是直接向 Wave 中的 xterm 发送 `CSI 6n`,已确认 DSR/光标位置响应正常,不是 CPR 缺失导致的退化;二是直接向 Codex 发送 `ESC[5~ / ESC[6~`,已确认 Codex 会在当前视口内做内部翻页。至此,`TASK-TERM-006` 的诊断目标已完成,并直接导出下一包最小业务修复 `TASK-TERM-007`。" + }, + { + "id": "TASK-TERM-007", + "title": "Codex 无 scrollback 时滚轮转内部翻页", + "status": "passing", + "priority": "P1", + "scope": [ + "frontend/app/view/term/termwrap.ts", + "frontend/app/view/term/termutil.ts", + "frontend/app/view/term/termutil.test.ts", + ".harness/*" + ], + "acceptance": [ + "Codex normal-buffer 且无 native scrollback 时,滚轮可查看更早/更后输出", + "普通 shell terminal 仍保持现有 normal-buffer scrollback 行为", + "IME 锚点逻辑不因 wheel fallback 回退", + "自动化证据可证明真实 wheel 事件已触发 Codex 内部翻页" + ], + "notes": "该任务承接 `TASK-TERM-006` 的诊断结论:问题不再是 wheel 命中或 IME owner,而是 Codex 在 Wave/xterm 中进入了 `normal buffer + full-screen repaint + no native scrollback`。为此,先新增 `shouldRouteAgentTuiWheelToInput()`,在 `agent TUI + normal buffer + mouseTrackingMode=none + baseY\u003c=0 + length\u003c=rows` 成立时,不再调用 `terminal.scrollLines()`,而是把滚轮翻译为 `PageUp/PageDown` 序列发送给 Codex。随后又根据当前真实用户窗口继续收口:1)直接从 Wave 子进程环境块读取 `WAVETERM_JWT`,用 `wsh termscrollback` 证明中间 pane 真实只有一屏量级内容、没有 native scrollback;2)把 `connectElem` 上的 wheel 兜底改成 bubble fallback,不再只在 `mouseTrackingMode !== none` 时才兜底;3)给 `isAgentTuiActive()` 加上 latched 状态与 `?2026h/l` 重绘信号,避免输出过程中 agent 判定闪断;4)IME 不再自己二次按 `cursorRow/cursorCol` 计算坐标,而是复用 xterm `_syncTextArea()` 已算好的 `top/left/width/height`。技术 smoke 证明这条路径能工作,但用户最新截图进一步暴露出一个未被该任务 acceptance 覆盖的 UX 回归:wheel 后 terminal 被 `.wave-agent-scrollback-overlay` 形式的 fake text overlay 覆盖,导致字体、排版和 live terminal chrome 看起来像切换到另一套渲染。该回归已被拆分到 `TASK-TERM-008` 单独收口。" + }, + { + "id": "TASK-TERM-008", + "title": "移除 fake scrollback overlay,恢复官方终端渲染路径", + "status": "in_progress", + "priority": "P1", + "scope": [ + "frontend/app/view/term/termwrap.ts", + "frontend/app/view/term/termutil.ts", + "frontend/app/view/term/termutil.test.ts", + ".harness/*" + ], + "acceptance": [ + "Codex pane 滚轮后不再创建 `.wave-agent-scrollback-overlay` 或其他 fake scrollback DOM", + "滚轮前后字体、排版、底部状态条与 live terminal chrome 不切换到另一套渲染", + "无 native scrollback 场景下仍至少保留 Codex 自身 `PageUp/PageDown` 查看更早内容的能力", + "普通 shell scrollback 与 IME 修复不回退" + ], + "notes": "已收口到同一个 live xterm。最新续修将隐藏 preview terminal 改为保留 MaxTermScrollback,并从 preview buffer 的真实历史区注入 live xterm,解决持续输出早期 baseY 不增长、滚轮无处可滚的问题;wheel fallback 已从 capture 收回到 bubble。最新证据 terminal-codex-pane-20260423-173615.json:baseY=120、historyLen=120、DOM wheel 后 viewportY=120-\u003e80。默认 make\\\\win-unpacked\\\\Wave.exe SHA256=6FB0D425F28715A9F16115085A56A4B7587EA21ED5A9B9471A7D52B11FFF8154。 2026-04-23 19:05:继续收口 transcript 污染后,单轮 Codex 长输出 smoke 已恢复稳定 native scrollback(`terminal-codex-pane-20260423-190056.json` 中 `baseY=24`、wheel `viewportY=24-\u003e0`),且 50 行场景下 history 仅保留 prompt+回答,不再爆涨。剩余阻塞是 Codex TUI 第二轮 prompt 的自动化提交路径仍不稳定,故状态先收紧为 in_progress,等待用户在最新默认包上手工确认第二轮回答。 2026-04-23 19:26:继续按真实 smoke 收根因。先收紧 transcript 激活:PowerShell/PSReadLine 仅在命令行编辑 `codex --...` 时不再被误判为 Codex TUI;再过滤 Codex 默认 suggestion `Explain this codebase`,避免它在 repaint 合并时被反复注入成历史正文。最新运行态证据 `D:\\files\\AI_output\\waveterm-terminal-smoke\\terminal-codex-pane-20260423-192604.json`:`baseY=24`、`agentTuiHistoryLength=81`(仅 prompt + 80 行回答)、wheel `viewportY=24-\u003e0`。默认交付包 `make\\\\win-unpacked\\\\Wave.exe` 当前 SHA256=`A7BCEFA722BEED0C0682A8AAAB685967B478212466AE4C79EAC4FC704CE80E3E`。剩余阻塞仍是第二轮回答的自动化提交流程。" + } +] \ No newline at end of file diff --git a/.harness/opportunities.json b/.harness/opportunities.json new file mode 100644 index 0000000000..6146941a3c --- /dev/null +++ b/.harness/opportunities.json @@ -0,0 +1,167 @@ +{ + "opportunities": [ + { + "id": "OPP-TERM-002", + "title": "把滚轮与 IME 兜底收敛到 xterm 官方扩展点", + "problem": "当前终端滚轮与 IME 修复仍在 `termwrap.ts` 里通过外层 DOM wheel listener、正则识别 Agent TUI、直接改 textarea/composition-view style 实现,容易与 xterm 内部 viewport、mouse tracking、composition helper 生命周期冲突。", + "evidence": [ + { + "source": "xterm.js Terminal API", + "url": "https://xtermjs.org/docs/api/terminal/classes/terminal/#attachcustomwheeleventhandler", + "strength": "strong", + "note": "xterm.js 已提供 `attachCustomWheelEventHandler`,用于让 embedder 决定是否继续处理 terminal wheel event。" + }, + { + "source": "xterm.js 6.0.0 release", + "url": "https://github.com/xtermjs/xterm.js/releases/tag/6.0.0", + "strength": "strong", + "note": "xterm.js 6.0.0 集成 VS Code scrollbar,明确说明 viewport/scrollbar 行为有重大变化。" + }, + { + "source": "xterm.js issue #5734", + "url": "https://github.com/xtermjs/xterm.js/issues/5734", + "strength": "strong", + "note": "xterm.js 6.0.0 + Electron + Claude/AI CLI 下中文 IME 候选窗定位错误,与当前问题高度相似。" + }, + { + "source": "xterm.js PR #5759", + "url": "https://github.com/xtermjs/xterm.js/pull/5759", + "strength": "strong", + "note": "上游已合并 IME 方向修复:compositionstart 前同步 textarea,compositionstart 后立即更新 composition element。" + }, + { + "source": "本地代码审查", + "url": "frontend/app/view/term/termwrap.ts", + "strength": "strong", + "note": "`installNormalBufferWheelScrollback()` 当前在 bubble 阶段先判断 `event.defaultPrevented`;如果 xterm 内部已先消费 wheel,Wave 兜底不会运行。" + }, + { + "source": "用户 2026-04-21 截图反馈", + "url": ".harness/progress.md", + "strength": "strong", + "note": "最新多终端截图显示真实 split-pane 场景里输入框仍错位、滚轮又失效,说明当前修复在真实焦点切换场景下仍不稳定。" + } + ], + "candidate_solutions": [ + "用 `terminal.attachCustomWheelEventHandler` 替代外层 DOM wheel listener,在 xterm 默认处理前决定 normal-buffer wheel 是否转为 scrollback", + "给 IME 兜底增加 focused terminal ownership,只允许当前真实焦点 terminal 改 helper textarea/composition-view 位置", + "在 xterm compositionstart / focus 生命周期附近做最小 IME 同步,参考 xterm PR #5759,而不是持续 onRender 改 style", + "把 Agent/Codex 检测从可见文本正则降级为 fallback,只在 xterm 原生同步失败时启用" + ], + "reach": 5, + "impact": 5, + "confidence": "high", + "effort": 3, + "architecture_fit": "high", + "strategic_fit": "high", + "risk_penalty": "medium", + "maintenance_penalty": "low", + "status": "approved" + }, + { + "id": "OPP-TERM-003", + "title": "建立终端回归 smoke 自动化闭环", + "problem": "多轮修复反复出现“代码已改但用户测到旧包/旧实例/无法确认真实滚轮和 IME”的问题,当前验证主要靠人工和临时 CDP 命令,无法稳定防止回归。", + "evidence": [ + { + "source": "本地 .harness/progress.md", + "url": ".harness/progress.md", + "strength": "strong", + "note": "已记录多次产物未刷新、CDP 截图不稳定、真实系统 IME 难自动化的问题。" + }, + { + "source": "Microsoft IME guidance", + "url": "https://learn.microsoft.com/en-us/windows/apps/develop/input/input-method-editors", + "strength": "medium", + "note": "Microsoft 建议有文本输入的应用对 IME 端到端体验做测试,并修复候选窗遮挡等问题。" + }, + { + "source": "TextBox DesiredCandidateWindowAlignment", + "url": "https://learn.microsoft.com/en-us/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.controls.textbox.desiredcandidatewindowalignment?view=windows-app-sdk-1.8", + "strength": "medium", + "note": "Windows 输入体验默认硬件键盘下 IME 跟随 cursor;这可作为 Wave 的 smoke 断言目标。" + } + ], + "candidate_solutions": [ + "新增 `scripts/smoke-terminal.ps1`:关闭旧进程、启动最新 win-unpacked、连接 CDP、断言运行路径/版本/行列/无历史恢复", + "用 DOM/JS 断言 xterm `viewportY` 变化、helper textarea 与 cursor 对齐,而不是依赖截图", + "把完整分发包时间戳、SHA256、运行态 `location.href` 写入 smoke 输出,避免误测旧包" + ], + "reach": 5, + "impact": 4, + "confidence": "high", + "effort": 2, + "architecture_fit": "high", + "strategic_fit": "high", + "risk_penalty": "low", + "maintenance_penalty": "low", + "status": "implemented" + }, + { + "id": "OPP-TERM-005", + "title": "补强多终端焦点与真实事件路径 smoke", + "problem": "当前 smoke 已能证明最新包、历史链路移除、单终端 DOM 级 wheel/IME 断言可通过,但它仍通过 `window.term` 单实例、强制 `shouldAnchorImeForAgentTui=()=>true` 和直接向 `.xterm-scrollable-element` 派发 `WheelEvent` 的方式验证,无法覆盖真实多终端 split-pane、焦点切换和 OS 事件路径。", + "evidence": [ + { + "source": "本地 smoke 脚本", + "url": "scripts/smoke-terminal.ps1", + "strength": "strong", + "note": "当前 smoke 只验证单个 `window.term`,并强制调用 `syncImePositionForAgentTui()`,没有验证真实 focus owner。" + }, + { + "source": "用户 2026-04-21 截图反馈", + "url": ".harness/progress.md", + "strength": "strong", + "note": "用户最新截图显示脚本通过后,真实 UI 中滚轮和输入框问题仍会复现,说明 smoke 覆盖范围不足。" + } + ], + "candidate_solutions": [ + "让 smoke 先枚举页面上的多个 terminal block,选择当前可见且聚焦的 terminal,而不是默认 `window.term`", + "增加 split-pane 场景断言:上方 Codex 终端与下方 PowerShell 终端同时存在时,只有 active terminal 的 textarea/composition-view 允许被改位置", + "把 wheel 断言从内部 scrollableElement 直派发升级为对 terminal connectElem/外层容器派发,尽量贴近真实用户路径" + ], + "reach": 4, + "impact": 4, + "confidence": "high", + "effort": 2, + "architecture_fit": "high", + "strategic_fit": "high", + "risk_penalty": "low", + "maintenance_penalty": "low", + "status": "approved" + }, + { + "id": "OPP-TERM-004", + "title": "清理后端未使用的终端历史缓存入口", + "problem": "前端已经停用 terminal 历史缓存/恢复,但后端仍保留 `SaveTerminalState`、`BlockFile_Cache` 等入口;未来维护者可能误以为历史缓存仍是受支持能力并重新接回。", + "evidence": [ + { + "source": "Wave upstream termwrap", + "url": "https://raw.githubusercontent.com/wavetermdev/waveterm/main/frontend/app/view/term/termwrap.ts", + "strength": "medium", + "note": "上游当前仍包含 `cache:term:full` 与 `SaveTerminalState` 链路;本 fork 已按用户要求偏离上游。" + }, + { + "source": "本地代码检索", + "url": "frontend/app/view/term/termwrap.ts", + "strength": "strong", + "note": "本地前端已不存在 `cache:term:full`、`SaveTerminalState`、`SerializeAddon` 引用。" + } + ], + "candidate_solutions": [ + "删除或标记废弃后端 `SaveTerminalState` 与 `BlockFile_Cache`,并更新生成类型", + "保留 API 但改名/注释为 deprecated,避免误接回前端", + "把历史缓存作为显式 feature flag,默认关闭" + ], + "reach": 3, + "impact": 3, + "confidence": "medium", + "effort": 3, + "architecture_fit": "medium", + "strategic_fit": "medium", + "risk_penalty": "medium", + "maintenance_penalty": "medium", + "status": "candidate" + } + ] +} diff --git a/.harness/progress.md b/.harness/progress.md new file mode 100644 index 0000000000..ebeba8cfe8 --- /dev/null +++ b/.harness/progress.md @@ -0,0 +1,1009 @@ +# Progress Log + +## 褰撳墠浠诲姟 + +- `TASK-001`锛氶涔﹀叆鍙e寮轰笌 Harness 鍒濆鍖? +## 褰撳墠闃舵 + +- `Verify` + +鍙€夐樁娈碉細 + +- `Research` +- `Plan` +- `Implement` +- `Verify` + +## 宸茬‘璁や簨瀹? +- 鍙充笂瑙掑揩鎹峰叆鍙f潵鑷粯璁?widgets 閰嶇疆锛屼笉鏄崟鐙啓姝诲湪鏌愪釜鍥哄畾 header 涓?- 鐜版湁缃戦〉瀹瑰櫒缁熶竴鍩轰簬 Electron `webview`锛岄€傚悎缁х画澶嶇敤 +- 鏈満宸叉敞鍐?`feishu://` 涓?`lark://` 鍗忚锛屽彲浣滀负鏈湴椋炰功 App 浼樺厛鍚姩璺緞 +- 椋炰功瑙嗗浘宸叉帴鍏ユ湰鍦?App 鑷姩鍙戠幇銆佽矾寰勫彲閰嶇疆銆佺綉椤靛厹搴?- 椋炰功鏂扮獥鍙e凡鏀逛负缁ф壙 `persist:feishu` 鍒嗗尯 +- 鍙充笂瑙掑揩鎹峰叆鍙e綋鍓嶉噰鐢ㄥ弻鍏ュ彛锛歚Feishu App` 涓?`Feishu Web` + +## 褰撳墠淇敼 + +- `emain/emain-feishu.ts` +- `emain/emain-ipc.ts` +- `emain/preload.ts` +- `frontend/app/view/feishuview/feishuview.tsx` +- `frontend/app/view/feishuweb/feishuweb.tsx` +- `frontend/app/view/webview/webview.tsx` +- `frontend/app/view/webview/webviewenv.ts` +- `frontend/app/block/blockregistry.ts` +- `frontend/app/block/blockutil.tsx` +- `pkg/wconfig/defaultconfig/widgets.json` +- `pkg/wconfig/defaultconfig/settings.json` +- `pkg/wconfig/settingsconfig.go` +- `frontend/types/custom.d.ts` +- `frontend/types/gotypes.d.ts` +- `schema/settings.json` +- `AGENTS.md` +- `CLAUDE.md` +- `.harness/*` +- `scripts/verify.ps1` + +## 鏈€鏂拌拷鍔? +- `2026-04-16 20:42`锛氬皢 Feishu App 鍏ュ彛鏀逛负鏈湴 App 鎺у埗鍗$墖锛屽苟鏂板鈥滈殣钘忓崱鐗団€濇寜閽紱璇ユ寜閽彧鍏抽棴褰撳墠 Wave block锛屼笉鍏抽棴鏈湴椋炰功 App +- `2026-04-16 21:06`锛氫负 `Feishu Web` 杩藉姞椤甸潰鍐呭彸涓婅鎮诞鈥滈殣钘忓崱鐗団€濇寜閽紝閬垮厤 header 鎸夐挳琚竷灞€鎸ゆ帀鍚庣敤鎴锋棤娉曞叧闂崱鐗?- `2026-04-17`锛氭寜鐢ㄦ埛瑕佹眰鍥為€€棰濆閫氳搴旂敤鍏ュ彛锛屽彧淇濈暀椋炰功鐩稿叧鑳藉姏 +- `2026-04-17`锛氬彸渚?`feishu / fei-web` widget 鏀逛负鍒囨崲琛屼负锛氳嫢褰撳墠 tab 宸叉湁瀵瑰簲鍗$墖锛屽啀娆$偣鍑诲浘鏍囦細鐩存帴鍏抽棴璇ョ被鍗$墖 + +## 褰撳墠闃诲 + +- 椋炰功鐪熷疄鐧诲綍涓庤亰澶?smoke 闇€瑕佸彲鐢ㄨ处鍙锋€?- 闈?Windows 鐜涓嬬殑鏈湴 App 鑷姩鍙戠幇灏氭湭鍋氱湡鏈洪獙璇?- 褰撳墠浠撳簱杩愯鎬?smoke 杩樺彈鏈湴鍚姩鐜闃诲锛氱洿鎺ュ墠鍙板惎鍔?Electron 鏃讹紝`wavesrv` 浼氬洜 `WCLOUD_ENDPOINT` 缂哄け/鏃犳晥鑰岄€€鍑猴紝瀵艰嚧搴旂敤鏃犳硶绋冲畾鍋滅暀鍦ㄥ彲浜や簰鐣岄潰 + +## 涓嬩竴姝ユ渶灏忓姩浣? +1. 鍦ㄥ彲鐢ㄧ幆澧冧腑琛ュ仛鐪熷疄椋炰功鐧诲綍 / 鑱婂ぉ smoke +2. 纭鏄惁闇€瑕佷负鏈湴寮€鍙戠幆澧冭ˉ榻?`WCLOUD_ENDPOINT` + +## 楠岃瘉璁板綍 + +- `2026-04-16 20:09`锛歚scripts/verify.ps1`锛岄€氳繃锛堝寘鍚?`git diff --check` 涓?`npm.cmd run build:dev`锛?- `2026-04-16 20:09`锛氬皾璇曚娇鐢?`agent-browser` + Electron CDP 鍋氭渶灏?smoke锛岄樆濉烇紱椤圭洰杩愯鏃?`wavesrv` 鎻愬墠閫€鍑猴紝鏃ュ織鏄剧ず `invalid wcloud endpoint, WCLOUD_ENDPOINT not set or invalid` +- `2026-04-16 20:42`锛歚npm.cmd run build:dev`锛岄€氳繃锛涘簲鐢ㄥ凡閲嶅惎鍒?`Wave (Dev)` +- `2026-04-16 22:00`锛歚scripts/verify.ps1`锛岄€氳繃锛堝寘鍚?`git diff --check` 涓?`npm.cmd run build:dev`锛?- `2026-04-16 22:05`锛歚C:\Users\yucohu\.config\waveterm-dev\widgets.json` 涓?`.harness/feature-list.json` 鍧囧彲姝e父 `ConvertFrom-Json` +- `2026-04-16 22:05`锛氬凡閲嶅惎 `Wave (Dev)`锛屼富 Electron 杩涚▼ PID 涓?`21464` +- `2026-04-17`锛歚scripts/verify.ps1` 閫氳繃锛堝寘鍚?`git diff --check` 涓?`npm.cmd run build:dev`锛?- `2026-04-17`锛氬凡閲嶅惎 `Wave (Dev)`锛屼富 Electron 杩涚▼ PID 涓?`37632` +- `2026-04-17`锛歚npm.cmd run build:dev`锛岄€氳繃锛涘凡绉婚櫎棰濆閫氳搴旂敤鍏ュ彛鐩稿叧浠g爜 +- `2026-04-17`锛氭寜鐢ㄦ埛瑕佹眰瀹屾垚棰濆閫氳搴旂敤鍏ュ彛鍥為€€锛沗scripts/verify.ps1` 閫氳繃 +- `2026-04-17`锛氬凡閲嶅惎 `Wave (Dev)`锛屼富 Electron 杩涚▼ PID 涓?`11996` + +## 鍓╀綑椋庨櫓 + +- 椋炰功绔欑偣鐧诲綍/鑱婂ぉ寮圭獥閾捐矾鏄惁瀹屽叏绋冲畾锛屼粛闇€鐪熷疄璐﹀彿楠岃瘉 +- `Feishu Web` 鎮诞鎸夐挳浠呰鐩栧綋鍓?block 鐨勫叧闂綋楠岋紝灏氭湭琛ュ厖鏇村椤靛唴蹇嵎鎿嶄綔 +- 褰撳墠 smoke 缁撹鍙鐩栨瀯寤轰笌涓昏繘绋嬫棩蹇楋紝涓嶈鐩栫湡瀹炲彲浜や簰 UI 娴佺▼ + +## 2026-04-17 Packaging + +- 鐗堟湰瑙勫垯鏂板涓?`YYYY.M.D-N`锛屽綋鍓嶆湰鍦板寘鐗堟湰宸插垏涓?`2026.4.17-1` +- 鏂板 Windows `buildVersion` 鏄犲皠锛屽畨瑁呭寘鏂囦欢鐗堟湰鍙槧灏勪负 `2026.4.17.1` +- 宸蹭骇鍑?`make/Wave-win32-x64-2026.4.17-1.exe` 涓?`make/Wave-win32-x64-2026.4.17-1.zip` +- 褰撳墠鐜缂哄皯 `task` / `go` / `zig`锛屾湰杞棤娉曟寜浠撳簱鏍囧噯瀹屾暣閲嶇紪鍚庣鐗堟湰閾撅紝鍙兘澶嶇敤鐜版湁 `dist/bin` +- 閫氳繃璁剧疆 `ELECTRON_BUILDER_NSIS_DIR` / `ELECTRON_BUILDER_NSIS_RESOURCES_DIR` 澶嶇敤浜嗘湰鏈?`manual-tools`锛岀粫杩囦簡 NSIS 鍦ㄧ嚎涓嬭浇璇佷功澶辫触 +- `msi` 浠嶅彈 WiX 鍦ㄧ嚎涓嬭浇璇佷功澶辫触闃诲锛屾湭浜у嚭 `.msi` +- `make/win-unpacked/Wave.exe` 鐨勬枃浠剁増鏈粛鏄剧ず Electron `41.1.0`锛涜嫢瑕佸悓姝ユ垚鏃堕棿鐗堝彿锛岄渶瑕佹仮澶?`signAndEditExecutable` 渚濊禆閾炬垨琛ラ綈鏈満 `winCodeSign/rcedit` +## 2026-04-17 Startup Fix + +- 宸插畾浣嶆寮忓寘鈥滄參鍚姩 / UI 鍍忔棫鐗堟湰 / 椋炰功鍏ュ彛鏈嚭鐜扳€濈殑鍏卞悓鏍瑰洜锛歚frontend/wave.ts` 涓?`preloadMonaco()` 璋冪敤浜嗘湭瀵煎叆鐨?`fireAndForget` +- 宸插湪 `frontend/wave.ts` 琛ュ洖 `@/util/util` 鐨?`fireAndForget` 瀵煎叆锛岄伩鍏?`initWave` 鍦ㄩ灞忓垵濮嬪寲鍚庢姏鍑?`ReferenceError` +- 宸叉墽琛?`scripts/verify.ps1`銆乣npm.cmd run build:prod`锛屽苟閲嶆柊鐢熸垚 `make/win-unpacked`銆乣make/Wave-win32-x64-2026.4.17-1.exe`銆乣make/Wave-win32-x64-2026.4.17-1.zip` +- 宸插惎鍔?`make/win-unpacked/Wave.exe` 澶嶆牳姝e紡鐗堟棩蹇楋紱`2026-04-17 14:14` 杩欒疆鍚姩涓嶅啀鍑虹幇 `fireAndForget is not defined` / `Error in initWave` +- 褰撳墠榛樿 `widgets.json` 涓庢寮忕増鐢ㄦ埛閰嶇疆鍧囦笉鎷︽埅椋炰功鍏ュ彛锛氶粯璁ら厤缃粛鍖呭惈 `feishu` 涓?`fei-web`锛宍C:\Users\yucohu\.config\waveterm\widgets.json` 褰撳墠涓嶅瓨鍦?- 缁х画鎺掓煡鈥滄墦寮€鎱⑩€濇椂锛屽凡纭棣栧睆涓婚樆濉炵偣涔嬩竴鏄?`initBare()` 鎶?`setWindowInitStatus("ready")` 缁戝畾鍦?`document.fonts.ready` 涓婏紝瀵艰嚧涓荤獥鍙e湪瀛椾綋鍏ㄩ儴鍔犺浇瀹屾垚鍓嶆棤娉曠户缁?`wave-init` +- 宸插皢 `frontend/wave.ts` 璋冩暣涓猴細瀛椾綋浠嶅湪鍚庡彴鍔犺浇锛屼絾 `ready` 鐘舵€侀€氳繃浜嬩欢寰幆绔嬪嵆涓婃姤锛屼笉鍐嶈瀛椾綋鍔犺浇鍗′綇涓荤獥鍙e垵濮嬪寲锛涙湡闂存浘楠岃瘉鍒?`requestAnimationFrame()` 鍦ㄩ殣钘忛〉浼氳鑺傛祦锛屽凡鍥為€€涓?`setTimeout(..., 0)` 閬垮厤闅愯棌绐楀彛姝婚攣 +- 姝e紡鍖呮棩蹇楀姣旓細`2026-04-17 14:59` 鍩虹嚎浠?`waveterm-app starting` 鍒?`show window` 绾?`4.010s`锛宍tabview init` 涓?`1425ms`锛沗2026-04-17 15:11` 鏂扮増浠庡惎鍔ㄥ埌 `show window` 绾?`3.087s`锛屼富 `tabview init` 闄嶅埌 `781ms` +- 宸茶ˉ鍋氣€滃惎鍔ㄤ腑閲嶅鍙屽嚮鈥濋獙璇侊細`2026-04-17 15:12` 鏃ュ織鍑虹幇 `second-instance event`锛屼絾鏈啀鍑虹幇 `createNewWaveWindow` / `creating new window`锛屾渶缁堝彧鏄剧ず鎭㈠绐楀彛锛岃鏄庡惎鍔ㄤ腑浜屾鍚姩鏀惧ぇ鎱㈡劅鐨勯棶棰樹粛琚纭嫤鎴?## 2026-04-17 Widget Compatibility Fix + +- 宸茬‘璁ゅ彸渚ч涔﹀叆鍙g己澶辩殑鐩存帴鏍瑰洜涓嶆槸鍓嶇鏈墦鍖咃紝鑰屾槸姝e紡鍖呬粛澶嶇敤鏃?`wavesrv`锛堟棩蹇楁樉绀?`wave version: 0.14.4 (202604151554)`锛夛紝鍏跺唴宓岄粯璁?`widgets.json` 鏃╀簬椋炰功鍏ュ彛鏀瑰姩 +- 宸插湪 `frontend/app/workspace/widgets.tsx` 澧炲姞鍏煎閫昏緫锛氬綋鍓嶇鍖呯増鏈笌鍚庣 `fullConfig.version` 涓嶄竴鑷存椂锛屽洖閫€鍚堝苟鍓嶇鎵撳寘鍐呯疆鐨?`pkg/wconfig/defaultconfig/widgets.json` +- 宸查澶栧湪姝e紡鐗堣繍琛屾椂閰嶇疆 `C:\Users\yucohu\.config\waveterm\widgets.json` 鍐欏叆 `defwidget@feishu` / `defwidget@feishuweb`锛岀‘淇濆綋鍓嶆満鍣ㄤ笂鐨勬寮忕増涔熻兘鎷垮埌椋炰功鍏ュ彛 +- 宸查噸鏂版墽琛?`scripts/verify.ps1`銆乣npm.cmd run build:prod`銆乣electron-builder --win dir nsis zip`锛屽苟閲嶅惎 `make/win-unpacked/Wave.exe` + +## 2026-04-17 Crash / History Follow-up + +- 缁х画鎺掓煡鈥滃伓鍙戦棯閫€ + 鍘嗗彶璁板綍鏈繚瀛樷€濇椂锛屽凡鍦ㄥ墠绔粓绔鍣?`frontend/app/view/term/termwrap.ts` 瀹氫綅鍒颁竴涓珮姒傜巼鏍瑰洜锛歚runProcessIdleTimeout()` 閲囩敤閫掑綊 `setTimeout + requestIdleCallback`锛屼絾 `dispose()` 涔嬪墠娌℃湁鍙栨秷宸叉寕璧风殑 timeout / idle callback锛汿ermWrap 琚攢姣佸悗锛岃繖浜涘洖璋冧粛鍙兘缁х画鎵ц骞惰闂凡閲婃斁鐨?terminal / serialize addon锛屽睘浜庡吀鍨嬬殑鈥滈攢姣佸悗寮傛鍥炶皟缁х画璺戔€濋棶棰?- 鍚屼竴閾捐矾杩樺瓨鍦ㄦ寔涔呭寲鏃舵満鍋忔櫄鐨勯棶棰橈細缁堢鐘舵€佺紦瀛?`cache:term:full` 鍙細鍦ㄢ€滅疮璁¤緭鍑鸿秴杩囬槇鍊尖€濅笖鈥? 绉掑悗鎷垮埌 idle 鏃堕棿鈥濇椂淇濆瓨锛涘鏋滅獥鍙h闅愯棌銆佸簲鐢ㄩ€€鍑恒€侀〉闈㈠嵏杞芥垨 renderer 寮傚父缁堟锛屾渶杩戜竴娈电粓绔姸鎬佹洿瀹规槗鏉ヤ笉鍙婅惤鐩?- 宸插湪 `frontend/app/view/term/termwrap.ts` 鍋氭渶灏忎慨澶嶏細鏂板 idle/timeout 鍙栨秷閫昏緫锛沗dispose()` 鍓嶅厛鍋氫竴娆″己鍒剁粓绔姸鎬佹寔涔呭寲锛涘苟鍦?`visibilitychange(hidden)` / `beforeunload` 鏃惰拷鍔犱竴娆″厹搴曚繚瀛橈紝闄嶄綆閫€鍑哄墠涓庡紓甯稿墠涓㈢姸鎬佹鐜?- 宸插湪 `emain/emain.ts` 澧炲姞 `render-process-gone` / `child-process-gone` 鏃ュ織锛屽悗缁嫢浠嶆湁闂€€锛屽彲鐩存帴浠庢寮忕増鏃ュ織閲岀湅鍒板叿浣撳穿婧冭繘绋嬬被鍨嬨€侀€€鍑虹爜鍜屽搴?`webContents` +- 褰撳墠鐜浠嶇己灏?`go` / `task` / `zig`锛屽洜姝ゅ儚 `pkg/filestore` 杩欑被鍚庣缂撳瓨鍒风洏鍛ㄦ湡鐨勬簮鐮佺骇浼樺寲锛屾湰杞棤娉曠紪璇戣繘姝e紡鍖咃紱浠庝唬鐮佷笂鐪嬶紝鍚庣 blockfile 浠嶉噰鐢ㄥ紓姝?cache flush锛岃繖浠嶆槸鈥滄瀬绔穿婧冩椂鏈€杩戣緭鍑哄彲鑳戒涪澶扁€濈殑鍓╀綑楂樻鐜囩偣 +- 宸叉墽琛?`npm.cmd run build:prod`銆乣scripts/verify.ps1`銆乣electron-builder --win dir`锛屽苟鍚姩 `make/win-unpacked/Wave.exe` 鍋氭寮忓寘鐑熸祴锛沗2026-04-17 15:26` 杩欒疆鏃ュ織鏄剧ず `waveterm-app starting`銆乣wavesrv ready signal received true 564 ms`銆乣show window ...`锛屾湭鍑虹幇鏂扮殑棣栧睆寮傚父鏃ュ織 + +## 2026-04-17 Packaging Follow-up + +- 宸插皢 `electron-builder.config.cjs` 鐨?Windows NSIS 鏈湴宸ュ叿鎺ュ叆锛屼粠 `file://...7z` 鏀逛负鑷姩澶嶇敤 `LOCALAPPDATA\\electron-builder\\manual-tools\\nsis-*` 宸茶В鍘嬬洰褰曪紝骞跺湪瀛樺湪鏃舵敞鍏?`ELECTRON_BUILDER_NSIS_DIR` / `ELECTRON_BUILDER_NSIS_RESOURCES_DIR` +- `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win nsis zip` 宸查€氳繃锛孨SIS 涓嶅啀鎶?`unsupported protocol scheme "file"` +- 鏈€鏂颁骇鐗╂椂闂村凡鍒锋柊锛歚make\\Wave-win32-x64-2026.4.17-1.exe` `15:38:00`銆乣make\\Wave-win32-x64-2026.4.17-1.exe.blockmap` `15:38:03`銆乣make\\1.yml` `15:38:03`銆乣make\\Wave-win32-x64-2026.4.17-1.zip` `15:37:18`銆乣make\\win-unpacked\\Wave.exe` `15:36:11` + +## 2026-04-17 UI Clarity / Drag Smoothness / Visual Polish + +- 宸插畾浣?4K 娓呮櫚搴﹂珮姒傜巼鍘熷洜锛歚body` 鍏ㄥ眬 `transform: translateZ(0)` / `backface-visibility` 浼氭妸鏁撮〉鏂囨湰鏀捐繘鍚堟垚灞傦紝Windows 楂?DPI 涓嬪鏄撳嚭鐜版枃瀛楀拰杈圭嚎鍙戣櫄锛涘悓鏃堕粯璁ら厤鑹插ぇ閲忕函榛?浣庡姣旈€忔槑灞傦紝璁╃晫闈㈡樉寰楃硦鍜屽帇鏆椼€?- 宸插畾浣嶆嫋鎷芥帀甯х洿鎺ュ師鍥犱箣涓€锛歚TileLayout` 鎷栨嫿 hover 琚?`throttle(50ms)` 闄愬埗鍒扮害 20fps锛涙澶栨嫋鎷芥€?`filter: blur(8px)`銆乺esize 鎬?`backdrop-filter`銆侀珮 DPR 鎷栨嫿棰勮 PNG 涔熶細鍦?4K 灞忎笂澧炲姞缁樺埗鎴愭湰銆?- 宸蹭慨澶?浼樺寲锛氱Щ闄ゅ叏灞€鍚堟垚灞傚己鍒舵彁鍗囷紱鎷栨嫿 hover 鏀逛负 16ms锛涙嫋鎷戒腑鍚敤鏇寸煭杩囨浮锛涚Щ闄ゆ嫋鎷?blur锛涢檺鍒舵嫋鎷介瑙堟渶楂?DPR锛涗负 tile 鑺傜偣澧炲姞 paint containment锛涢檷浣庨珮鎴愭湰 blur銆?- 宸插仛杞婚噺瑙嗚鍗囩骇锛氭柊澧炴繁娴疯摑/缈$繝楂樺厜榛樿鑳屾櫙锛岄潪 terminal 鍖哄煙浠庣函榛戞敼涓烘洿娓呮櫚鐨?slate glass 琛ㄥ眰锛涘悓姝?tab銆乥lock銆乼ailwind token銆佺獥鍙h儗鏅壊銆?- 楠岃瘉锛歚scripts/verify.ps1` 閫氳繃锛沗npm.cmd run build:prod` 閫氳繃锛沗electron-builder --win dir` 閫氳繃锛涘惎鍔?`make/win-unpacked/Wave.exe` 鍚庢棩蹇楀嚭鐜?`show window`锛屾湭瑙佹柊鐨?render/child process gone 鏃ュ織銆?- 鏈畬鍏ㄩ獙璇侊細鐪熷疄 4K 涓昏娓呮櫚搴︿笌闀挎椂闂存嫋鎷藉抚鐜囦粛闇€鐢ㄦ埛鍦ㄧ洰鏍囨樉绀哄櫒涓婃墜鎰熺‘璁わ紱鏈噸鏂扮敓鎴?NSIS/zip 姝e紡瀹夎鍖呫€? + +## 2026-04-17 Feishu Image Preview Compatibility + +- 鐢ㄦ埛鎴浘鏄剧ず椋炰功娑堟伅鍥剧墖鍖哄煙鎻愮ず鈥滄殏涓嶆敮鎸佹煡鐪嬶紝璇风◢鍚庡啀璇曗€濄€傚凡纭杩欎笉鏄?Wave 鏈湴鍥剧墖娓叉煋缁勪欢闂锛岃€屾槸 Feishu Web 鍦?Electron `` 鍐呯殑绔欑偣鍏煎閾捐矾闂銆?- 楂樻鐜囧師鍥?1锛欶eishu Web 浣跨敤榛樿 Electron UA 鏃讹紝鍥剧墖/棰勮鑳藉姏鍙兘璧伴檷绾ф垨涓嶆敮鎸佸垎鏀紱宸蹭负 `feishuweb` 鍗曠嫭璁剧疆鍘绘帀 `Electron/...` 鏍囪瘑鐨勬闈?Chrome UA锛屼笉褰卞搷閫氱敤 Web 鍏ュ彛銆?- 楂樻鐜囧師鍥?2锛歐ave 鍘熸湰缁熶竴 deny `` 鐨?`window.open` 骞惰浆鎴?Wave 鍐呮柊 block锛汧eishu 鐨勫浘鐗囨煡鐪?棰勮鍙兘渚濊禆 `about:blank`銆乣blob:` 鎴栧悓鍩熷脊绐楄繑鍥炲€笺€傚凡鍦ㄤ富杩涚▼涓粎瀵?Feishu/Lark opener 鐨?Feishu/璧勬簮/blank/blob/data 寮圭獥鏀捐锛岄檷浣庘€滄殏涓嶆敮鎸佹煡鐪嬧€濈殑姒傜巼銆?- 宸蹭负 `feishuweb` 寮€鍚?`nativeWindowOpen=yes` web preference锛岀敤浜庡吋瀹逛緷璧栧師鐢?popup 琛屼负鐨勫浘鐗囨煡鐪嬮摼璺€?- 楠岃瘉锛歚npm.cmd run build:dev` 閫氳繃锛沗git diff --check` 閫氳繃锛沗npm.cmd run build:prod` 閫氳繃锛沗electron-builder --win dir` 閫氳繃锛涘凡鍚姩鏈€鏂?`make/win-unpacked/Wave.exe`锛屾棩蹇楀嚭鐜?`show window`锛屽苟杩涘叆 `https://ycnflp4nd2cp.feishu.cn/next/messenger/`銆?- 鏈畬鍏ㄩ獙璇侊細鐪熷疄椋炰功鍥剧墖鏄惁鎭㈠闇€瑕佺敤鎴峰湪宸茬櫥褰曡处鍙烽噷瀹為檯鎵撳紑璇ユ秷鎭‘璁わ紱濡傛灉浠嶅け璐ワ紝涓嬩竴姝ュ簲鎶?Feishu WebView DevTools console/network锛岄噸鐐圭湅鍥剧墖璧勬簮鐘舵€佺爜銆乸opup URL 鍜岀珯鐐圭幆澧冩娴嬬粨鏋溿€? + +## 2026-04-17 Terminal Scrollback / Resize Loss Fix + +- 宸插畾浣嶁€滄秷鎭鍚炪€佹粴杞粦涓嶅埌鏈€涓婇潰銆佺缉鏀惧悗璁板綍涓㈠け鈥濈殑楂樻鐜囨牴鍥狅細缁堢榛樿 `scrollback` 鍙湁 2000 琛岋紝Codex/闀挎枃鏈緭鍑哄湪缂╂斁鎴栧崱鐗囧彉绐勬椂浼氳Е鍙?xterm 閲嶆帓锛岄暱琛岃鎷嗘垚鏇村鐗╃悊琛屽悗瓒呰繃缂撳啿涓婇檺锛屾棫琛屼細琚?xterm 瑁佹帀锛涙寔涔呭寲鐨?`cache:term:full` 鍙堜細璁板綍瑁佸壀鍚庣殑鐘舵€侊紝瀵艰嚧閲嶆柊鎵撳紑鍚庝篃鍙兘鐪嬪埌琚埅鏂悗鐨勫巻鍙层€?- 宸插皢鍓嶇榛樿缁堢婊氬姩缂撳啿鎻愬崌鍒?50000 琛岋紝骞舵妸鍙厤缃笂闄愭彁鍗囧埌 200000 琛岋紱鍚屾椂琛ュ厖 `term:scrollback` 榛樿閰嶇疆涓?schema 鑼冨洿銆?- 宸插湪缁堢缂╂斁/鍙樼獎鍓嶆牴鎹綋鍓?buffer 琛屾暟涓庡垪瀹藉彉鍖栭浼伴噸鎺掑悗鐨勮鏁帮紝蹇呰鏃跺厛涓存椂鎵╁ぇ scrollback锛屽啀鎵ц xterm resize锛岄伩鍏嶇缉鏀惧姩浣滄湰韬鎺夋棫娑堟伅銆?- 宸蹭紭鍖栧垵濮嬫仮澶嶇瓥鐣ワ細褰撳簳灞?`term` 鍘熷 blockfile 鏈惊鐜鐩栦笖涓嶈秴杩?2MB 鏃讹紝浼樺厛浠庡師濮嬬粓绔枃浠堕噸鏀炬仮澶嶏紝闄嶄綆鍥犳棫 `cache:term:full` 宸茶瑁佸壀鑰屾案涔呮仮澶嶄笉鍏ㄧ殑姒傜巼锛涘惊鐜鐩栨垨杩囧ぇ鏂囦欢浠嶄繚鐣欑紦瀛樿矾寰勶紝閬垮厤鍚姩杩囨參銆?- 楠岃瘉閫氳繃锛歚npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts`銆乣git diff --check`銆乣npm.cmd run build:dev`銆乣npm.cmd run build:prod`銆乣npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win nsis zip`銆?- 宸插埛鏂颁骇鐗╋細`make\win-unpacked\Wave.exe`銆乣make\Wave-win32-x64-2026.4.17-1.exe`銆乣make\Wave-win32-x64-2026.4.17-1.zip`銆乣make\Wave-win32-x64-2026.4.17-1.exe.blockmap`銆乣make\1.yml`銆?- 宸插惎鍔ㄦ柊鐗?`make\win-unpacked\Wave.exe` 鍋?smoke锛屾棩蹇楀嚭鐜?`show window`锛屾湭鍦ㄦ湰杞?tail 涓湅鍒版柊鐨?`render-process-gone` / `child-process-gone`銆?- 鍓╀綑椋庨櫓锛氬鏋滃崟涓粓绔緭鍑鸿秴杩?2MB 鐨勫簳灞?circular blockfile 鍙繚鐣欒寖鍥达紝鏃╀簬 circular 璧风偣鐨勫唴瀹逛粛鏃犳硶鎭㈠锛涘鏋滄煇浜?CLI 涓诲姩鍙戦€佹竻绌?scrollback 鎺у埗搴忓垪锛學ave 涓嶈兘鏃犳潯浠堕樆姝紝鍚﹀垯浼氱牬鍧忓叏灞?浜や簰绋嬪簭琛屼负銆? + +## 2026-04-17 Terminal Wheel Follow-up + +- 鐢ㄦ埛澶嶆祴鍚庣‘璁も€滃巻鍙插閲?缂╂斁淇濇姢鈥濅慨澶嶅悗锛岄紶鏍囨粴杞粛鏃犳硶婊氬姩缁堢鍘嗗彶銆?- 宸茶繘涓€姝ュ畾浣嶆牴鍥狅細`frontend/app/view/term/termwrap.ts` 鐨勮嚜瀹氫箟 wheel handler 鍦?`terminal.modes.mouseTrackingMode !== "none"` 鏃剁洿鎺ユ斁寮冨鐞嗭紱Codex/Claude Code 绛変氦浜掑紡 CLI 浼氬惎鐢ㄧ粓绔紶鏍囨ā寮忥紝瀵艰嚧婊氳疆浜嬩欢琚?CLI/xterm 榧犳爣鍗忚鍚冩帀锛學ave 娌℃湁鏈轰細鎵ц `terminal.scrollLines()`銆?- 宸茶皟鏁寸瓥鐣ワ細鏅€?buffer 涓嬶紝鍗充娇缁堢搴旂敤寮€鍚?mouse tracking锛屼篃鐢?Wave 浼樺厛澶勭悊婊氳疆婊氬姩鍘嗗彶锛沘lternate buffer 浠嶄笉鎶㈠崰婊氳疆锛岄伩鍏嶇牬鍧?vim/less/tmux 绛夊叏灞忕▼搴忕殑浜や簰璇箟銆?- 宸茶ˉ鍏?`shouldHandleTerminalWheel()` 鍗曟祴锛岃鐩?normal buffer銆乤lternate buffer銆佸凡鍙栨秷浜嬩欢涓夌鍦烘櫙銆?- 楠岃瘉閫氳繃锛歚npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts`銆乣git diff --check`銆乣npm.cmd run build:dev`銆乣npm.cmd run build:prod`銆乣npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win nsis zip`銆?- 宸插埛鏂板苟鍚姩鏂扮増 `make\win-unpacked\Wave.exe`锛涗骇鐗╂椂闂达細`Wave.exe` 17:13:35锛孨SIS exe 17:15:32锛寊ip 17:14:50锛涙棩蹇楀嚭鐜?`show window`锛屾湭鐪嬪埌鏂扮殑 renderer/child 宕╂簝鏃ュ織銆?- 鍓╀綑椋庨櫓锛氬鏋滄煇涓?CLI 浣跨敤 alternate screen 骞朵笖鑷繁涓嶅搷搴旈紶鏍囨粴杞紝Wave 浠嶄笉浼氬己琛屾姠婊氳疆锛涜繖灞炰簬淇濇姢鍏ㄥ睆绋嬪簭浜や簰鐨勫彇鑸嶏紝鍚庣画鍙€冭檻鍋氫竴涓樉寮忊€滃己鍒舵粴鍘嗗彶鈥濆揩鎹烽敭鎴栧紑鍏炽€? + +## 2026-04-17 Alternate Buffer Wheel Paging Fix + +- 缁撳悎鐢ㄦ埛鎴浘缁х画瀹氫綅鍚庯紝纭褰撳墠涓昏闂涓嶆槸鏅€?scrollback锛岃€屾槸 Codex/Agent 绫诲叏灞?TUI 杩愯鍦?terminal alternate buffer 涓紱杩欑被鐣岄潰椤堕儴鍐呭灞炰簬搴旂敤鍐呴儴瑙嗗浘锛宍terminal.scrollLines()` 鏃犳硶璁╁叾鍥炴粴銆?- 宸插湪 `frontend/app/view/term/termwrap.ts` 璋冩暣 wheel 澶勭悊锛氬綋 active buffer 涓?`alternate` 鏃讹紝涓嶅啀灏濊瘯婊氬姩 xterm viewport锛岃€屾槸鎶婃粴杞浆鎹㈡垚缁堢杈撳叆搴忓垪鍙戦€佺粰 PTY銆?- 褰撳墠瀹炵幇灏?alternate buffer 鐨勬粴杞槧灏勪负 `PageUp` / `PageDown`锛坄\x1b[5~` / `\x1b[6~`锛夛紝骞舵寜婊氳疆骞呭害鏀惧ぇ涓哄娆″垎椤佃緭鍏ワ紝浼樺厛淇濊瘉 Codex/绫讳技 TUI 鐨勬秷鎭垪琛ㄥ彲鍥炴粴銆?- 淇濈暀 normal buffer 鐨?scrollback 閫昏緫锛屽洜姝ゆ櫘閫?shell 杈撳嚭缁х画璧?xterm 鍘嗗彶婊氬姩锛屽叏灞?TUI 鍒欒蛋鍐呴儴缈婚〉銆?- 宸茶ˉ鍏?`getAlternateWheelInputSequence()` 鍗曟祴锛屽苟鏇存柊 `shouldHandleTerminalWheel()` 璇箟锛岃鐩?normal/alternate/cancelled wheel 鍦烘櫙銆?- 楠岃瘉閫氳繃锛歚npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts`锛?7 涓敤渚嬮€氳繃锛夈€乣git diff --check`銆乣npm.cmd run build:dev`銆乣npm.cmd run build:prod`銆乣npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win nsis zip`銆?- 宸插埛鏂板苟鍚姩鏈€鏂颁骇鐗╋細`make\win-unpacked\Wave.exe` 鏃堕棿 `17:57:19`锛宍make\Wave-win32-x64-2026.4.17-1.exe` 鏃堕棿 `17:59:06`锛宍make\Wave-win32-x64-2026.4.17-1.zip` 鏃堕棿 `17:58:29`锛涙棩蹇楀凡鍑虹幇 `show window`銆?- 鍓╀綑椋庨櫓锛氬鏋滄煇浜?alternate-screen 绋嬪簭鏈韩涓嶆敮鎸?`PageUp/PageDown` 缈婚〉锛岃€屽彧鏀寔榧犳爣婊氳疆浜嬩欢鎴栬嚜瀹氫箟蹇嵎閿紝鍒欎粛鍙兘闇€瑕佷负鐗瑰畾 TUI 鍐嶈ˉ涓撻棬鍏煎锛涗笅涓€姝ヨ嫢鐢ㄦ埛浠嶅弽棣堟棤鏁堬紝搴旀姄鍙栬鍛戒护鐨勭湡瀹?`lastcmd`銆乥uffer type 鍜?wheel 鍚庡簲鐢ㄥ搷搴旀棩蹇楋紝杩涗竴姝ユ寜鍏蜂綋 TUI 鍋氶€傞厤銆? + +## 2026-04-17 Agent TUI IME Anchor Fix + +- 鐢ㄦ埛鍙嶉鍦?Codex 绫诲璇濈粓绔唴杈撳叆鏃讹紝鈥滄墦瀛楃殑妗?/ 杈撳叆娉曞€欓€変綅缃窇鍒版渶涓婇潰鈥濓紝鍒ゆ柇涓?xterm 鍦?alternate buffer 涓娇鐢ㄧ湡瀹?cursor 鍧愭爣瀹氫綅 IME锛岃€?Agent TUI锛堝 Codex/Claude/opencode锛夋妸浜や簰杈撳叆鏍忓浐瀹氱粯鍒跺湪搴曢儴锛屽鑷翠簩鑰呬笉涓€鑷淬€?- 宸插皾璇曚娇鐢?`agent-browser` + `electron` skill 鍋氳嚜鍔ㄥ寲宸℃锛涘綋鍓?Wave 鍦?CDP 鐩爣鏋氫妇涓粎鏆撮湶鍑?`about:blank`锛屾棤娉曠洿鎺ョǔ瀹氭姄鍙栦富 UI 浜や簰鍏冪礌锛屽洜姝ゆ敼涓哄熀浜庣幇鏈変唬鐮侀摼璺仛瀹氬悜淇锛屽苟淇濈暀璇ラ樆濉炶褰曘€?- 宸插湪 `frontend/app/view/term/termwrap.ts` 涓虹粓绔畨瑁?IME anchor 淇锛氬綋妫€娴嬪埌褰撳墠鏄?alternate buffer 涓斿懡浠?鍙鏂囨湰鍖归厤 Codex銆丆laude Code銆乷pencode 绛?Agent TUI 鏃讹紝鐒︾偣銆佽緭鍏ャ€乧omposition 涓?render 鏈熼棿浼氭妸 xterm helper textarea / composition-view 閲嶆柊閿氬畾鍒扮粓绔簳閮ㄨ緭鍏ヨ闄勮繎銆?- 璇ヤ慨澶嶅彧瀵?Agent TUI 鐢熸晥锛屼笉褰卞搷鏅€?shell銆乿im銆乴ess 绛夊父瑙勭粓绔?鍏ㄥ睆绋嬪簭鐨勯粯璁よ緭鍏ュ畾浣嶃€?- 宸插湪 `frontend/app/view/term/termutil.ts` 鏂板 `shouldAnchorImeToBottomForCommand()`锛屽苟琛ュ厖鍗曟祴瑕嗙洊 Codex/Claude/opencode 鍛戒护涓庢櫘閫?shell/editor 鍦烘櫙銆?- 楠岃瘉閫氳繃锛歚npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts`锛?9 涓敤渚嬮€氳繃锛夈€乣git diff --check`銆乣npm.cmd run build:dev`銆乣npm.cmd run build:prod`銆乣npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win nsis zip`銆?- 宸插埛鏂颁骇鐗╁苟鍚姩锛歚make\win-unpacked\Wave.exe` 鏃堕棿 `21:50:28`锛宍make\Wave-win32-x64-2026.4.17-1.exe` 鏃堕棿 `21:52:28`锛宍make\Wave-win32-x64-2026.4.17-1.zip` 鏃堕棿 `21:51:49`锛涙棩蹇楀嚭鐜?`show window`銆?- 鍓╀綑椋庨櫓锛氬綋鍓?IME 閿氬畾瀵?Agent TUI 閲囩敤鍛戒护鍚?鍙鏂囨湰鍚彂寮忚瘑鍒紱鑻ュ悗缁敤鎴蜂娇鐢ㄥ叾浠栧簳閮ㄨ緭鍏ユ爮 TUI锛屽彲鑳介渶瑕佺户缁ˉ鐧藉悕鍗曟垨鎶芥垚鍙厤缃鍒欍€? + +## 2026-04-17 Codex Wheel Routing Follow-up + +- 鐢ㄦ埛澶嶆祴纭锛氭櫘閫氱粓绔尯鍩熸粴杞凡鎭㈠锛屼絾 `Codex` 杩欑被 Agent TUI 浠嶆棤娉曟粴鍔ㄥ叾鍐呴儴娑堟伅鍒楄〃锛岃鏄庘€渁lternate buffer 涓€寰嬩氦鍥?xterm 鍘熺敓 wheel鈥濅細璇激渚濊禆 `PageUp/PageDown` 缈婚〉鐨?Agent 鐣岄潰銆?- 宸插湪 `frontend/app/view/term/termwrap.ts` / `frontend/app/view/term/termutil.ts` 缁嗗寲鍒嗘祦锛? - 鏅€?`normal buffer`锛氱户缁敱 Wave 澶勭悊鍘嗗彶婊氬姩銆? - `alternate buffer` + 鏅€氬叏灞忕▼搴?+ 宸插紑鍚?mouse tracking锛氫氦鍥?xterm 鍘熺敓榧犳爣鍗忚銆? - `alternate buffer` + `Codex/Claude/opencode/aider/gemini/qwen` 绛?Agent TUI锛氱户缁繚鐣?Wave 鐨?`PageUp/PageDown` 婊氳疆鍏滃簳锛岄伩鍏嶆秷鎭垪琛ㄦ棤娉曟粴鍔ㄣ€?- 宸茶ˉ鍏?`shouldHandleTerminalWheel()` 鍗曟祴瑕嗙洊鈥淎gent TUI 鍦?mouse tracking 寮€鍚椂浠嶅己鍒惰蛋 fallback鈥濈殑鍦烘櫙銆?- 楠岃瘉閫氳繃锛歚npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts`锛?1/21 閫氳繃锛夈€乣git diff --check`銆乣npm.cmd run build:dev`銆?- 鍓╀綑椋庨櫓锛氬綋鍓?Agent TUI 璇嗗埆浠嶅熀浜庡懡浠ゅ悕 / 鍙鏂囨湰鍚彂寮忥紱濡傛灉鍚庣画杩樻湁鍏朵粬搴曢儴杈撳叆寮?TUI锛屽彲鑳介渶瑕佺户缁ˉ鐧藉悕鍗曟垨鏀逛负鍙厤缃鍒欍€? + +## 2026-04-20 Codex Alternate Buffer Cleanup + Stable Detection + +- 鍩轰簬鐢ㄦ埛鏂版埅鍥剧户缁畾浣嶅悗锛岀‘璁も€淐odex 鐢婚潰娣蜂贡鈥濈殑楂樻鐜囨牴鍥犱笉鍙湪婊氳疆锛岃€屽湪浜庝袱灞傞棶棰樺彔鍔狅細 + 1. xterm 鐨?`CSI ? 1049 h` alternate buffer 鍒囨崲榛樿涓嶄細鍍忕粓绔簲鐢ㄩ鏈熼偅鏍锋竻绌烘棫 alt buffer锛屽鑷翠笂涓€娆?Agent TUI 鐨勬畫鐣欏唴瀹瑰彲鑳界暀鍦ㄦ柊浼氳瘽閲岋紱鎴浘閲岄噸澶嶇殑 `Working` 鏇寸鍚堣繖绉嶁€滄棫 alt buffer 娈嬪奖 + 鏂颁竴杞粯鍒垛€濈殑琛ㄧ幇銆? 2. Agent TUI 璇嗗埆鍘熷厛鏄寜褰撳墠鍙鏂囨湰/last command 涓存椂鍒ゆ柇锛孋odex 杩涘叆宸ヤ綔鎬佸悗鍙鏂囨湰鍙兘涓嶅啀鍖呭惈鏄庢樉鏍囪瘑锛屽鑷村悓涓€浼氳瘽閲屾粴杞?IME 璺敱绛栫暐鍦ㄥ師鐢?mouse 涓?`PageUp/PageDown` fallback 涔嬮棿鏉ュ洖鍒囨崲锛岃繘涓€姝ユ斁澶х敾闈笌浜や簰娣蜂贡鎰熴€?- 宸插湪 `frontend/app/view/term/termwrap.ts` 鍔犱袱澶勬渶灏忎慨澶嶏細 + - 鐩戝惉 `DECSET 1049`锛屽湪鐪熸鍒囧埌 alternate buffer 鏃朵粎瀵硅繖娆″垏鎹㈡墽琛屼竴娆℃竻绌猴紝鍘绘帀鏃?alt buffer 娈嬬暀銆? - 鏂板绋冲畾鐨?Agent TUI 浼氳瘽璇嗗埆锛氫紭鍏堢湅 `shell:lastcmd`锛屽叾娆″洖鐪?normal buffer 灏鹃儴鐨勫惎鍔ㄥ懡浠わ紙渚嬪 `PS ...> codex --yolo`锛夛紝鍐嶉€€鍥炲埌 active buffer 鍙鏂囨湰锛涜繘鍏ュ悗鍦ㄨ alternate-buffer 浼氳瘽鍐呬繚鎸佺ǔ瀹氾紝涓嶅啀姣忔 wheel/render 涓存椂鎶栧姩銆?- 宸插湪 `frontend/app/view/term/termutil.ts` 鏂板 `textContainsAgentTuiCommand()`锛屽苟琛ュ厖 PowerShell / 甯歌 shell prompt 鐨勮瘑鍒祴璇曘€?- 楠岃瘉閫氳繃锛? - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts`锛?3/23 閫氳繃锛? - `git diff --check` + - `npm.cmd run build:dev` + - `npm.cmd run build:prod` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` +- 褰撳墠 `make\win-unpacked` 宸插埛鏂帮紝鍙洿鎺ラ噸鍚?`make\win-unpacked\Wave.exe` 澶嶆祴 Codex 缁堢鐢婚潰涓庢粴杞€? +## 2026-04-20 Codex Normal Buffer Agent TUI Stabilization + +- 缁х画鍩轰簬鐪熷疄 `term` 鍘熷鏁版嵁鎺掓煡鍚庯紝纭褰撳墠杩欐壒 `codex` 浼氳瘽楂樻鐜囧苟鏈娇鐢?alternate buffer锛岃€屾槸鍦?normal buffer 涓€氳繃 `CSI ? 2026 h/l` 鍚屾閲嶇粯锛涙鍓嶁€滃彧鎶?alternate buffer 褰撲綔 Agent TUI鈥濈殑鍋囪涓嶅畬鏁淬€?- 宸插畾浣嶅埌涓€涓洿鐩存帴鐨勭姸鎬佹満闂锛歚frontend/app/view/term/termwrap.ts` 浼氬湪姣忔 normal-buffer `onBufferChange` 鏃舵妸 `agentTuiActive` 鐩存帴娓呮帀锛屽鑷?`codex` 杩欑被 normal-buffer TUI 鍦ㄥ悓涓€浼氳瘽閲岄绻佷涪澶辫瘑鍒紝婊氳疆 fallback銆佽鍙e洖搴曚笌 IME 閿氬畾閮藉彲鑳藉湪涓€娆℃鍐欏叆涔嬮棿鎶栧姩澶辨晥銆?- 宸插湪 `frontend/app/view/term/termwrap.ts` 鍋氭渶灏忎慨澶嶏細 + - normal / alternate buffer 缁熶竴璧扮ǔ瀹氱殑 `isAgentTuiActive()` 鍒ゆ柇锛屼笉鍐嶅湪 normal-buffer 鍐欏叆鏃舵棤鏉′欢娓呯┖ Agent TUI 鐘舵€侊紱 + - Agent TUI 妫€娴嬫敼涓虹患鍚?`shell:lastcmd`銆乣shell:state`銆乺ecent `2026` 鍚屾閲嶇粯娲诲姩銆乶ormal buffer 灏鹃儴鍛戒护浠ュ強褰撳墠 viewport/tail 鍙绛惧悕锛岃€屼笉鏄彧鐪?active buffer 椤堕儴鏂囨湰锛? - IME 搴曢儴閿氬畾涓嶅啀閿欒鍦板彧闄?alternate buffer锛岄伩鍏?normal-buffer Agent TUI 涓嬪啀娆″洖閫€鍒伴敊璇緭鍏ヤ綅缃€?- 鏈疆楠岃瘉閫氳繃锛? - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + - `npm.cmd run build:dev` + - `npm.cmd run build:prod` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` + - 鏈満 smoke锛氫娇鐢ㄦ渶鏂?`make\win-unpacked\Wave.exe` 瀹屾垚鈥滃惎鍔ㄥ簲鐢?-> 鍚姩 codex -> 鍏抽棴/寮烘潃 Wave -> 閲嶅紑鈥濈殑涓よ疆绐楀彛鎴浘妫€鏌ワ紝閲嶅紑鍚庢湭鍐嶅嚭鐜版棫甯ф贩鏉傘€侀《閮ㄥぇ闈㈢Н绌虹櫧鎴栧乏鍙崇獥鍙g姸鎬侀敊涔便€?- 褰撳墠浜х墿宸插埛鏂帮細`make\win-unpacked\Wave.exe` 鏃堕棿 `2026-04-20 10:41:53`銆? +## 2026-04-20 Codex Wheel / IME / Fit Follow-up + +- 鍩轰簬鐢ㄦ埛鏈€鏂板弽棣堢户缁畾浣嶅悗锛岀‘璁よ繖杞畫鐣欓棶棰樺垎鎴愪笁灞傦細 + 1. `frontend/app/view/term/termwrap.ts` 浠嶄細鎶?Agent TUI 鐨勬粴杞粺涓€寮哄埗鏀瑰啓鎴?`PageUp/PageDown`锛屽嵆浣垮綋鍓?`codex` 浼氳瘽宸茬粡鍦?normal buffer 涓紑鍚簡鍘熺敓 mouse tracking锛屽鑷村拰 Windows Terminal 鐩告瘮婊氳疆璇箟涓嶄竴鑷达紝娑堟伅鍒楄〃渚濈劧涓嶉『鐣呫€? 2. `frontend/app/view/term/fitaddon.ts` 鍦ㄦ湭鏄惧紡鎻愪緵 `scrollbarWidth` 鏃朵細閫€鍥炲埌绉佹湁 DOM 瀹藉害宸祴閲忥紱杩欐潯閾捐矾鍦ㄥ綋鍓?xterm v6 + Wave 瀹瑰櫒涓嬩笉绋冲畾锛屽鏄撴妸缁堢鍒楁暟绠楃獎锛岃〃鐜颁负 Codex 鏂囨湰鎻愬墠鎹㈣銆佸彸渚х暀鐧借繃澶с€侀〉闈㈡涓嶅鑷€傚簲銆? 3. IME 搴曢儴閿氬畾铏界劧宸叉湁锛屼絾缂哄皯鍦ㄧ粓绔噸鏂?fit / resize 鍚庣殑鍐嶆鍚屾锛屽鑷撮潰鏉垮昂瀵稿彉鍖栨垨閲嶆帓鍚庯紝杈撳叆娉曞€欓€変綅缃粛鍙兘椋樺洖閿欒琛屻€?- 宸插仛鏈€灏忚寖鍥翠慨澶嶏細 + - 鍦?`frontend/app/view/term/termutil.ts` 鏂板 `getTerminalWheelStrategy()`锛屾妸婊氳疆璺敱缁嗗寲涓?`ignore / native / page / scrollback` 鍥涚被锛涘浜庡紑鍚?mouse tracking 鐨?Agent TUI锛屼紭鍏堜氦杩?xterm 鍘熺敓 wheel锛岃€屼笉鏄户缁己濉?`PageUp/PageDown`銆? - 鍦?`frontend/app/view/term/termwrap.ts` 涓敼涓烘寜涓婅堪绛栫暐鍒嗘祦婊氳疆锛涘悓鏃堕噸鏂扮粰 `FitAddon` 娉ㄥ叆绋冲畾鐨?`scrollbarWidth`锛屽苟鍦ㄨ繍琛屾椂淇℃伅鍔犺浇銆佸垵濮嬬粓绔洖鏀惧拰姣忔 `handleResize()` 缁撴潫鍚庡埛鏂?Agent TUI 鐘舵€佷笌 IME 閿氱偣銆? - 鍦?`frontend/app/view/term/fitaddon.ts` 涓妸灏哄娴嬮噺鏀逛负浼樺厛浣跨敤鏄惧紡 `scrollbarWidth` / `overviewRuler.width` 涓?`getBoundingClientRect()`锛岄伩鍏嶄緷璧?xterm 绉佹湁婊氬姩瀹瑰櫒瀹藉樊锛屾彁鍗?Codex 杩斿洖鍐呭鐨勮嚜閫傚簲灞曠ず绋冲畾鎬с€? - 鍦?`frontend/app/view/term/termutil.test.ts` 涓ˉ鍏呮粴杞瓥鐣ュ崟娴嬶紝瑕嗙洊 normal shell銆丄gent TUI fallback銆丄gent TUI native wheel銆乤lternate-screen native wheel 绛夊満鏅€?- 鏈疆楠岃瘉閫氳繃锛? - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + - `npm.cmd run build:dev` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` +- 楠岃瘉澶囨敞锛? - 涓€斾竴娆?`build:dev` 鐨?`EBUSY` 鏉ヨ嚜鎴戞妸 `build` 鍜?`verify` 骞惰璺戝鑷寸殑 `dist` 鎶㈤攣锛屼笉鏄粨搴撴湰韬棶棰橈紱涓茶閲嶈窇鍚庡凡閫氳繃銆? - 鏋勫缓浠嶄細杈撳嚭鏃㈡湁鐨?Vite 璀﹀憡锛坄electron` 鐨?`fs/path` browser externalized銆乣cytoscape -> mermaid -> cytoscape` circular chunk锛夛紝杩欒疆鏈柊澧炵浉鍏抽棶棰樸€?- 鍓╀綑椋庨櫓锛? - 杩欒疆涓昏閫氳繃浠g爜閾捐矾鍜屾瀯寤洪獙璇佹敹鍙o紝灏氭湭鍦ㄧ洰鏍囨樉绀哄櫒涓婂仛鐪熷疄榧犳爣/杈撳叆娉曟墜鎰?smoke锛涜嫢 `codex` 鏌愪釜鐗堟湰鏀瑰洖鍙 `PageUp/PageDown` 鑰屼笉璁ゅ師鐢?wheel锛屼粛鍙兘闇€瑕佸啀涓虹壒瀹?Agent TUI 鍋氫竴灞傚彲閰嶇疆 fallback銆? +## 2026-04-20 Codex Wheel Strategy Follow-up 2 + +- 鐢ㄦ埛澶嶆祴鍚庝粛鍙嶉鈥滅湅璧锋潵娌$敓鏁堚€濓紝缁х画鎺掓煡鏃跺彂鐜版湁涓ゅ眰娣锋穯锛? 1. 鐢ㄦ埛寰堝彲鑳界偣鍒颁簡鏃х殑 `win-unpacked` / 鏃ц繍琛屽疄渚嬶紝鍥犱负涓婁竴杞櫧鐒惰窇浜?`build:dev`锛屼絾褰撴椂骞舵病鏈夌珛鍒婚噸鎵?`make\win-unpacked\Wave.exe`锛? 2. 鍗充娇浣跨敤浜嗘柊浠g爜锛宍frontend/app/view/term/termutil.ts` 閲屾垜涓婁竴杞粛鎶娾€渘ormal buffer + Agent TUI + 鏃?mouse tracking鈥濊矾鐢辨垚浜?`PageUp/PageDown`锛岃繖鍜?Windows Terminal 鐨勮涓轰笉涓€鑷达紱瀵逛簬褰撳墠杩欑被 normal-buffer `codex` 浼氳瘽锛屾纭涓哄簲鏄粴 xterm scrollback锛岃€屼笉鏄己鍒跺垎椤佃緭鍏ャ€?- 宸插仛淇锛? - 灏?`getTerminalWheelStrategy()` 璋冩暣涓猴細`normal buffer` 榛樿濮嬬粓璧?`scrollback`锛屽彧鏈?`alternate buffer` 鎵嶅湪鏃?mouse tracking 鏃惰蛋 `page` fallback锛沗agentTuiActive` 浠呭湪宸插紑鍚?mouse tracking 鏃跺垏鍒?`native`銆? - 琛ュ厖瀵瑰簲鍗曟祴锛岃鐩?鈥渘ormal-buffer Agent TUI -> scrollback鈥?鍜?鈥渁lternate-buffer app -> page fallback鈥?涓や釜鍦烘櫙銆? - 閲嶆柊鎵ц浜?`npm.cmd run build:dev`銆乣electron-builder --win dir` 鍜?`scripts/verify.ps1`锛屽苟鍒锋柊浜?`make\win-unpacked\Wave.exe`銆?- 杩囩▼璁板綍锛? - 浣跨敤 `agent-browser` 杩?Electron 鏃讹紝纭娴忚鍣ㄧ骇 CDP 宸茶繛鍒版柊鎵撳寘鐨?`Wave.exe`锛屼絾 CDP/鎴浘瀵瑰綋鍓嶇獥鍙f姄鍙栦笉绋冲畾锛屽伐鍏蜂細鎺夊埌 `about:blank` 鎴栭粦灞忕獥鍙o紝涓嶈兘浣滀负杩欒疆 UI 鏄惁鐢熸晥鐨勫彲闈犱緷鎹€? - 鏈疆閲嶇偣浠モ€滅‘淇濇渶鏂板寘宸查噸鎵?+ 璺敱绛栫暐鏀瑰 + 鏋勫缓楠岃瘉閫氳繃鈥濅负鏀跺彛銆? +## 2026-04-20 Wheel / IME Follow-up 3 + +- 缁х画鏍规嵁鐢ㄦ埛鈥滆緭鍏ユ硶鍜屾粴杞粛鏈夐棶棰樷€濈殑鍙嶉瀹氫綅鍚庯紝纭鏈変笁涓珮姒傜巼鏍瑰洜锛? 1. rontend/app/view/term/termwrap.ts 瀹為檯浠嶅湪寮曠敤 npm 鍖?@xterm/addon-fit锛屽鑷存湰鍦拌ˉ涓佺増 rontend/app/view/term/fitaddon.ts 娌℃湁鐪熸鐢熸晥锛岀粓绔搴︿笌 IME 閿氱偣浼氱户缁蛋鏃ф祴閲忛€昏緫锛? 2. rontend/app/view/term/osc-handlers.ts 鐨?handleOsc16162Command() 涓鍒犱簡 const terminal = termWrap.terminal;锛屼細鍦ㄦ敹鍒?shell prompt 鐨?A 鍛戒护鏃惰Е鍙戣繍琛屾椂 ReferenceError锛岃繘鑰屾壈涔?shell 闆嗘垚鐘舵€佷笌 Agent TUI 妫€娴嬶紱 + 3. 涔嬪墠鐨勬粴杞疄鐜扮粦鍦ㄥ灞?DOM capture锛屼笖瀵?Agent TUI 鐨?normal-buffer + mouse tracking 璺敱涓嶅鎺ヨ繎 Windows Terminal锛屽鏄撳嚭鐜扳€滄粴杞湅璧锋潵娌$敓鏁堚€濇垨琚敊璇姭鎸佹垚缁堢 scrollback銆?- 鏈疆宸插仛鏈€灏忚寖鍥翠慨澶嶏細 + - rontend/app/view/term/termwrap.ts 鏀瑰洖寮曠敤椤圭洰鍐?./fitaddon锛屽苟鏀圭敤 xterm 鐨?ttachCustomWheelEventHandler() 鎺ョ婊氳疆鍒嗘祦锛? - 婊氳疆绛栫暐璋冩暣涓猴細Agent TUI 鍦ㄥ紑鍚?mouse tracking 鏃朵紭鍏堣蛋鍘熺敓 wheel锛涙櫘閫?normal buffer 浠嶈蛋 Wave scrollback锛沘lternate buffer 鏃?mouse tracking 鏃朵繚鐣?PageUp/PageDown fallback锛? - IME 閿氱偣鏀逛负鍥哄畾鍒?Agent 瀵硅瘽杈撳叆鍖虹殑搴曢儴涓棿浣嶇疆锛屽悓鏃跺悓姝?helper textarea / composition view 鐨? op / left / width / opacity / z-index锛? - osc-handlers.ts 琛ュ洖 erminal 鍙橀噺锛屾仮澶?shell prompt marker 涓?shell 闆嗘垚鐘舵€侀摼璺紱 + - Agent TUI 鍙绛惧悕涓庝繚娲诲垽鏂暐鏀惧锛屽噺灏戣繍琛岃繃绋嬩腑鐘舵€佹姈鍔ㄣ€?- 鏈疆楠岃瘉锛? - +pm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts 閫氳繃锛?1/31锛? - powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1 閫氳繃 + - +pm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir 閫氳繃 +- 澶囨敞锛氫腑閫斿崟鐙窇鐨勪竴娆? +pm.cmd run build:dev 鍥犱笌 erify 骞跺彂鎵ц瀵艰嚧 dist 鐩綍 EBUSY锛屽睘浜庢瀯寤虹洰褰曢攣鍐茬獊锛屼笉鏄唬鐮佸洖褰掞紱涓茶楠岃瘉鍚庡凡纭閫氳繃銆? + +## 2026-04-20 Restore Official Terminal Logic + +- 根据用户要求回看 git 原作者逻辑后,确认当前未提交的滚轮 / IME / fit 改动偏离官方主线较大。 +- 已将 `frontend/app/view/term/termwrap.ts`、`frontend/app/view/term/termutil.ts`、`frontend/app/view/term/termutil.test.ts`、`frontend/app/view/term/fitaddon.ts`、`frontend/app/view/term/osc-handlers.ts` 全部恢复到 `HEAD` 官方逻辑。 +- 官方逻辑要点:滚轮仍由 `termwrap.ts` 的原始 capture handler 处理;IME 不做 Agent TUI 专用锚点重写,回到 xterm 自身 helper textarea / composition-view 逻辑;`termwrap.ts` 回到 npm `@xterm/addon-fit`,不再使用我之前强行接入的本地 `fitaddon.ts`;OSC 16162 `R` 保留退出 alternate buffer 的处理。 +- 已验证 `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` 通过,`powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` 通过。 + +## 2026-04-20 IME / Wheel Minimal Patch After Official Baseline + +- 根据最新截图,官方基线下仍存在两个问题:xterm composition view 在 Codex / Agent TUI 场景下会跑到左上角;Wave 的 capture wheel handler 仍会在 mouse tracking 开启时先吞掉滚轮,导致 xterm 原生 mouse wheel 协议无法接管。 +- 根因判断:xterm v6 的 `CompositionHelper.updateCompositionElements()` 只在 `buffer.isCursorInViewport` 时更新 composition 坐标;当 Codex / Agent TUI 的光标状态和可视对话输入区不一致时,composition view 保留默认左上角。滚轮方面,Wave 自定义 capture handler 比 xterm 自身 wheel listener 更早执行并 `preventDefault / stopPropagation`。 +- 本轮最小修复: + - `frontend/app/view/term/termutil.ts` 的 `shouldHandleTerminalWheel()` 增加 `mouseTrackingMode` 判断;只要 mouse tracking active,就交还 xterm 原生处理。 + - `frontend/app/view/term/termwrap.ts` 在调用 `shouldHandleTerminalWheel()` 时传入 `terminal.modes.mouseTrackingMode`。 + - `frontend/app/view/term/termwrap.ts` 增加 Agent/Codex 场景下的 IME composition 坐标兜底;仅对 `codex / claude / opencode / aider / gemini / qwen` 或可见 Agent 签名生效,把 active `.composition-view` 与 helper textarea 移到对话区域中部,避免左上角。 +- 验证:`npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` 通过;`powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` 通过;`electron-builder --win dir` 通过。 +## 2026-04-20 Wheel / IME History Restore Follow-up + +- 继续根据用户“历史记录影响滚轮和输入法”的线索排查后,确认除了此前的 wheel / IME 路由外,还有一个恢复链路问题:`frontend/app/view/term/termwrap.ts` 在初始加载 `cache:term:full` / `term` 期间会先订阅实时 append,但加载完成后没有把 `heldData` 回放到终端,导致恢复后的 viewport、cursor 与最新会话状态可能滞后,进而放大 Codex 场景下的滚轮失效与 IME 锚点错位。 +- 本轮修复: + - 在 `termwrap.ts` 增加 `flushHeldTerminalData()`,在 `loadInitialTerminalData()` 完成后立即回放加载期间缓存的实时增量,避免恢复后的终端状态停留在旧历史快照。 + - 将 Agent / Codex 场景下的恢复与 resize 收口为 `scheduleAgentTuiViewportSync()`:初始化恢复后、输入法聚焦时、以及每次 resize 后都补一次 `scrollToBottom + IME sync`,优先把 viewport 拉回当前对话输入区域,再同步 composition / textarea 位置。 + - 调整滚轮策略:`normal buffer` 即使开启 mouse tracking 也继续由 Wave 处理 scrollback;仅 `alternate buffer + mouse tracking` 交还 xterm 原生协议,避免 Codex 这类 normal-buffer 会话被错误让渡给 mouse 协议后看起来“滚轮没反应”。 + - 调整 `fitaddon.ts` 的尺寸测量,优先用显式 scrollbar 宽度和 `getBoundingClientRect()` / `parseFloat()`,减小列宽误算导致的窄列换行与 IME 锚点漂移。 +- 验证通过: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` +- 当前最新产物已刷新为 `make\win-unpacked\Wave.exe`;建议用户关闭旧 Wave 进程后直接启动这个新产物复测 Codex 终端的滚轮、IME 和恢复后的首屏状态。 +## 2026-04-20 Terminal Wheel Baseline + IME Viewport Row Fix + +- 根据用户要求重新回看 git 历史与原作者主线后,确认当前 fork 的终端滚轮逻辑已经明显偏离上游:`upstream/main`(`wavetermdev/waveterm`)当前并没有外层 `connectElem` capture wheel 拦截,而本 fork 在 `termwrap.ts` 增加了自定义滚轮分流与 `PageUp/PageDown` fallback,这一层会让问题定位变得失真。 +- 新增专项任务包 `TASK-TERM-001`,把本轮范围收紧为:滚轮、IME、历史恢复、最小 smoke,不再夹带无关 UI 改动。 +- 本轮代码修正: + - 移除 `frontend/app/view/term/termwrap.ts` 外层自定义 wheel handler,回到原作者/xterm 原生滚轮路径。 + - 清理 `frontend/app/view/term/termutil.ts` / `frontend/app/view/term/termutil.test.ts` 中仅服务于这层自定义 wheel 的辅助函数与测试,避免继续围绕错误抽象修补。 + - 修正 `frontend/app/view/term/termwrap.ts` 中 Agent TUI IME 锚点的核心计算错误:此前错误使用 `buffer.cursorY` 作为 viewport 内行号;在有历史滚动偏移时,这会把输入法位置错误锚到上方。现改为用 `cursorAbsoluteY - viewportY` 计算真实可视行。 +- 运行态验证结论: + - 使用 `agent-browser` 连接 Electron 后,确认 xterm 内部真实滚动容器是 `xterm-scrollable-element`,不是我们之前一直盯着的旧 `xterm-viewport` DOM 高度。 + - 通过直接调用 `window.term.terminal._core._viewport._scrollableElement.delegateScrollFromMouseWheelEvent(...)`,验证左侧 Codex 终端滚动链路可把 `ydisp` 从 `3475` 改到 `3461`,说明回到 xterm 原生滚轮后核心滚动管线是通的。 + - 在有历史偏移的情况下,调用 `window.term.syncImePositionForAgentTui()` 后,`textarea.style.top` 可从错误的 `90px` 变为 `594px`,验证 IME 位置已随 `viewportY` 正确移动,而不是继续卡在 `cursorY` 对应的顶部位置。 + - CDP 自动化对真实 OS 鼠标滚轮坐标的命中仍不稳定;因此本轮把它作为辅助证据,不把它当成唯一通过依据。 +- 验证通过: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + - `npm.cmd run build:dev` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` +- 辅助产物: + - 任务包:`.harness\task-packets\TASK-TERM-001.md` + - 运行态截图:`D:\files\AI_output\waveterm-term-smoke\wave-tab1.png` + - 运行态截图:`D:\files\AI_output\waveterm-term-smoke\wave-after-ime-wheel-fix.png` + +## 2026-04-20 Terminal Wheel / IME Official-Logic Follow-up 4 + +- 根据用户要求继续回看上游和 xterm v6 官方逻辑后,本轮只保留最小差异:普通 `normal buffer` 仍交给 xterm 原生 viewport 滚动;只有 `normal buffer + mouse tracking` 这一类 Codex/Agent TUI 易失效场景,在 capture 阶段转回 xterm 的 `SmoothScrollableElement.delegateScrollFromMouseWheelEvent()`,避免被 xterm 的 mouse protocol 分支吞掉滚轮。 +- `alternate buffer + mouse tracking` 仍不拦截,继续交给终端应用自身处理,避免破坏 vim/tmux/全屏 TUI 的官方语义。 +- IME 兜底改为仅在 Agent/Codex 场景生效,并固定到对话区域中部:不再优先使用 xterm 当前 `cursorY`,避免历史恢复、viewport 偏移或 Agent TUI 重绘后把中文组合框带到左上/顶部旧行。 +- `fitaddon.ts` 的测量逻辑回退到当前仓库/上游基线,只保留 `termwrap.ts` 中显式注入 `overviewRuler.width` 的本地 FitAddon 用法,减少页面自适应问题的变量。 +- 验证通过:`npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts`。 +- 验证通过:`npm.cmd run build:dev`、`powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1`、`npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir`。 +- 运行态 smoke:使用 `agent-browser` 连接新打包的 `make\win-unpacked\Wave.exe`,确认 T1 终端目标可达;强制 Agent IME 场景后,helper textarea 从顶部 `90px` 调整到中部 `1116px`(113 行、18px 行高、0.55 位置)。 +- 当前最新产物:`make\win-unpacked\Wave.exe`,时间 `2026-04-20 16:16:46`。 +- 剩余风险:自动化无法稳定覆盖真实中文输入法候选窗的系统级显示位置;需要用户关闭所有旧 Wave 进程后,从上述新产物手动复测 Codex 会话滚轮和中文输入法候选框。 + +## 2026-04-20 Terminal Fit / Visible Region Follow-up 5 + +- 根据用户最新截图,进一步确认本轮更像是终端可绘制行数没有随容器真实高度 fit 上去,而不是单纯滚轮事件没进来:背景区域已铺满,但 Codex/Agent 对话只占用了较小的逻辑终端高度。 +- 本轮根因收口为两点: + 1. `frontend/app/view/term/fitaddon.ts` 仅依赖 `getComputedStyle(parent).height/width`,当父容器在某些布局阶段给出 `auto` / 非稳定值时,会导致 `proposeDimensions()` 算不出最终 rows,终端继续停留在默认或旧行数; + 2. `frontend/app/view/term/termwrap.ts` 只在构造时立刻 `handleResize()` 一次,某些情况下首次 fit 发生在布局尚未稳定前,后续 Codex 启动时就可能沿用较小的逻辑终端高度。 +- 修复方式: + - `fitaddon.ts` 改为优先读 computed style,失败或非正值时回退到 `getBoundingClientRect()`;padding 改为 `parseFloat()`,并对可用宽高做 `Math.max(0, ...)` 防守,避免 rows/cols 因 NaN 或负值失真; + - `termwrap.ts` 在首次 `handleResize()` 后补三次延迟 resize(0ms / 50ms / 250ms),确保容器稳定后再做一次真实 fit 并把最终尺寸发给 controller。 +- 运行态验证: + - 新打包产物下通过 `agent-browser` 连接 `T1` 终端页,两个终端块的 `fitAddon.proposeDimensions()` 与 `terminal.rows` 均为 `113`,容器高度 `2037px`,说明逻辑行数已与真实显示高度对齐。 +- 验证通过: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + - `npm.cmd run build:dev` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` +- 当前最新产物:`make\win-unpacked\Wave.exe`,时间 `2026-04-20 16:36:12`。 +- 剩余风险:该问题和真实用户会话内容/恢复历史强相关;虽然运行态已确认终端 rows 跟容器高度一致,但仍建议用户关闭所有旧 Wave 进程后,直接用该新产物重开 Codex 会话复测截图中的“只占一小块”问题。 + +## 2026-04-20 Terminal History / Persist Guard Restore + +- 接手当前未提交改动后,先定位到正在处理本仓库的 Codex 进程是 `PID 35804`;其子进程曾在 `2026-04-20 17:11` 执行 `scripts/verify.ps1`,表现为长时间运行 `electron-vite build --mode development`,不是完全卡死,而是前端 dev 构建本身要约 2 分钟且内存占用接近 4GB。 +- 继续对比未提交 `termwrap.ts` 与仓库当前基线后,确认这轮真正的回归点不是单纯 IME/fit,而是修滚轮/输入法时误删了多处历史恢复保护: + - 删除了 `dispose()` / `visibilitychange` / `beforeunload` 上的 `persistTerminalState(true)`,会重新放大“退出前没落盘、恢复后状态滞后”的老问题; + - 删除了 `cancelProcessIdleTimeout()` / `processIdleTimeoutId` / `processIdleCallbackId`,让 idle 持久化调度在销毁后继续跑的风险重新回来; + - 删除了 `shouldReplayFullTermFile()`、缓存恢复时的 `await doTerminalWrite(...)`、以及 resize 前的 scrollback 保护,会让“历史恢复影响滚轮/IME 锚点”的变量再次混进来; + - 把 `mainFileSubject?.release()` 改成了无保护调用,存在提早 `dispose()` 时空引用风险。 +- 本轮修复策略:不回退 `scheduleDeferredResize()` 与 Agent/Codex IME 兜底,但把以上历史恢复/持久化保护全部补回,仅保留与本任务直接相关的终端改动,避免继续在错误基线上反复打补丁。 +- 本轮验证: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` 通过; + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` 通过; + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` 未通过,但阻塞原因为本地已有 `make\win-unpacked\Wave.exe --remote-debugging-port=9222` 正在运行,占用了 `make\win-unpacked\dxcompiler.dll`,报错 `EPERM: operation not permitted, unlink ...\\make\\win-unpacked\\dxcompiler.dll`,不是当前代码编译错误。 +- 当前结论:之前“为什么一直解决不了”主要有两个叠加原因: + 1. 修滚轮/IME 时把历史恢复与持久化保护误删了,导致每轮都在引入新回归,问题空间始终不收敛; + 2. 同时拿 `make\win-unpacked\Wave.exe` 做 remote-debugging smoke,又直接往同一输出目录跑 `electron-builder --win dir`,验证链路互相锁文件,容易让人误判为“代码还没修好”。 + +## 2026-04-20 Official Terminal Baseline Restore Follow-up + +- 根据用户“照抄官方源码”的明确要求,本轮重新以 `upstream/main` 的 `frontend/app/view/term/termwrap.ts` 为基线收口: + - 移除外层自定义 wheel capture handler,滚轮回到 xterm / 上游原生 viewport 路径; + - 移除本地 full-term replay、强制 unload 持久化、dispose 前强制 persist、idle cancel 等历史恢复扩展,恢复上游 `loadInitialTerminalData()` / `processAndCacheData()` / `runProcessIdleTimeout()` 主线; + - 回到官方 `@xterm/addon-fit`,不再接入本地 `fitaddon.ts`; + - 只保留两处最小差异:首次布局后的延迟 `fit()`,以及 Codex/Agent 场景下将 IME helper textarea / composition view 锚到对话中部。 +- 本轮运行态验证: + - 已启动最新 `make\win-unpacked\Wave.exe --remote-debugging-port=9222`,产物时间 `2026-04-20 17:26:05`; + - `agent-browser` 连接 `T1` 后确认两个终端块均为 `rows=113 / cols=208`,容器高度 `2037px`,`scrollTop=0 / scrollBottom=112`; + - 在 Wave 内启动 `codex.cmd` 后,Codex 终端 `textarea` 自动锚到中部:`top=1116px`、`left=864px`; + - xterm 原生滚动管线可用:`terminal.scrollLines(-10)` 将 `ydisp` 从 `3595` 改到 `3585`;`delegateScrollFromMouseWheelEvent(...)` 将 `ydisp` 从 `3595` 改到 `3581`。 +- 本轮验证通过: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` +- 剩余风险: + - `agent-browser screenshot` 在 4K Electron 窗口上仍偶发 CDP 读取超时,因此本轮以运行态 DOM/xterm 内部状态作为主要证据; + - 系统级中文输入法候选窗无法完全自动化截图验证,仍需用户在当前已启动的新包中手动输入中文确认候选框位置。 + +## 2026-04-20 PTY TermSize Sync Root Cause Fix + +- 用户复测截图仍显示 Codex 内容只使用终端上方约 30 行。继续现场验证后确认真正根因不是 xterm 前端高度: + - 前端 xterm 已是 `rows=113 / cols=208`; + - 但 Wave 内 PowerShell 执行 `[Console]::WindowHeight; [Console]::WindowWidth` 返回 `30 / 80`; + - 说明 Codex/ConPTY 实际收到的终端尺寸仍是默认小窗口,所以 Codex TUI 只能在上方小区域排版。 +- 根因定位: + - `termwrap.ts` 原逻辑只在 `oldRows/oldCols` 与 `terminal.rows/cols` 发生变化时发送 `ControllerInputCommand(... termsize ...)`; + - 首次 `handleResize()` 可能早于后端 shell/pty ready,后续 `fit()` 结果虽然仍是 `113x208`,但因为前端行列没有变化,不会再次把尺寸发给后端; + - 后端 `pkg/blockcontroller/shellcontroller.go` 的 `updateTermSize()` 本身可工作,手动制造一次前端 resize 后,PowerShell 会从 `30x80` 正确变为 `113x208`。 +- 本轮修复: + - `frontend/app/view/term/termwrap.ts` 新增 `syncControllerTermSize(reason)`,用于显式把当前 `terminal.rows/cols` 发给后端; + - `handleResize(forceTermSizeSync)` 支持在尺寸未变化时强制同步 PTY 尺寸; + - `initTerminal()` 完成后调用 `scheduleDeferredResize(true)`,确保 shell/pty ready 后即使前端行列没变,也会把真实尺寸同步到 ConPTY。 +- 现场复测: + - 已重新打包并启动最新 `make\win-unpacked\Wave.exe --remote-debugging-port=9222`,产物时间 `2026-04-20 17:51:51`; + - `agent-browser` 连接 `T1` 后,前端仍为 `rows=113 / cols=208`; + - 在 Wave 内 PowerShell 执行 `[Console]::WindowHeight; [Console]::WindowWidth`,返回 `113` 和 `208`,确认 PTY 尺寸已真正同步。 +- 验证通过: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` + +## 2026-04-20 Codex IME Auto Anchor / Wheel Runtime Smoke + +- 用户继续反馈“上面有被吞掉、输入法位置不对”后,本轮用 `agent-browser` 直接连接最新 Electron 包复现: + - 可见终端和离屏终端都会恢复到 `rows=113 / cols=208`; + - 可见终端 PowerShell 执行 `[Console]::WindowHeight; [Console]::WindowWidth` 返回 `113 / 208`,确认后端 ConPTY 已不是 `30 / 80`; + - 启动 `codex` 后,`shouldAnchorImeForAgentTui()` 为 `true`,但旧逻辑没有在 Codex 输出到达后自动重排 xterm helper textarea,导致 textarea 仍停在 Codex 当前 cursor 行,例如 `top=486px / left=18px / zIndex=-5`。 +- 本轮修复: + - `shouldAnchorImeForAgentTui()` 增加 shell prompt tail 判断,避免 Codex 退出回到 `PS ...>` 后仍因为历史画面里有 “OpenAI Codex” 而继续锚定输入法; + - `scheduleImePositionSync()` 增加 pending guard,避免流式输出时堆积大量 `0ms / 16ms / 100ms` 定时器; + - xterm `onRender` 和 `doTerminalWrite()` 完成后都会触发 IME 同步,确保 Codex TUI 输出到达后自动把 helper textarea/composition view 锚回对话中部。 +- 最新运行态验证: + - 已重新打包并启动 `make\win-unpacked\Wave.exe --remote-debugging-port=9222`,产物时间 `2026-04-20 18:30:59`; + - `agent-browser` 连接 `T1` 后,在可见终端执行 PowerShell 尺寸命令返回 `113 / 208`; + - 启动 `codex` 后,textarea 自动锚到 `top=1116px / left=864px / zIndex=5`; + - 发送 `Ctrl+C` 退出 Codex 后,`shouldAnchorImeForAgentTui()` 变为 `false`,textarea override 被清理; + - normal buffer wheel smoke:派发向上滚轮后 `viewportY` 从 `3596` 变为 `3556`,事件被 `preventDefault()`,说明滚轮路径生效。 +- 验证通过: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` +- 剩余风险: + - 系统级中文输入法候选窗本身无法由 CDP 直接截图验证,本轮以 xterm helper textarea/composition view 的真实 DOM 坐标作为自动化验收依据; + - `agent-browser screenshot` 在当前 4K Electron 窗口上仍会超时,因此截图证据暂不作为通过条件。 + +## 2026-04-21 Full Installer / Zip Artifact Validation + +- 用户指出 `make\Wave-win32-x64-2026.4.17-1.exe`、`.exe.blockmap`、`.zip` 的时间仍停留在 `2026-04-17`,并质疑“是不是根本没打到最新包”。现场复核后确认该怀疑是对的: + - 前一轮只执行了 `electron-builder --win dir`,只会刷新 `make\win-unpacked`; + - `make\Wave-win32-x64-2026.4.17-1.exe`、`.exe.blockmap`、`.zip` 仍然是 `2026-04-17 21:52` 的旧分发产物,所以如果用户双击它们,看到的确实不是最新修复。 +- 本轮修复动作不是代码逻辑,而是把完整 Windows 分发链路重新跑通: + - 先执行 `npm.cmd run build:dev`; + - 再以 `WAVETERM_WINDOWS_INSTALLERS=1` 执行 `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win nsis zip`,强制重新产出安装器与 zip; + - 新产物时间: + - `make\Wave-win32-x64-2026.4.17-1.zip` -> `2026-04-21 10:57:29` + - `make\Wave-win32-x64-2026.4.17-1.exe` -> `2026-04-21 10:58:22` + - `make\Wave-win32-x64-2026.4.17-1.exe.blockmap` -> `2026-04-21 10:58:25` +- 额外运行态验证: + - `zip` 包解压到 `make\zip-smoke` 后启动 `Wave.exe --remote-debugging-port=9224`,`location.href` 指向 `make/zip-smoke/resources/app.asar/...`; + - 在该 `zip` 包内 PowerShell 返回 `113 / 208`,启动 `codex` 后 textarea 自动锚到 `top=1116px / left=864px / zIndex=5`; + - `installer exe` 以 `/S /D=...` 静默安装到 `make\installer-smoke`,退出码 `0`; + - 再从 `make\installer-smoke\Wave.exe --remote-debugging-port=9225` 启动,`location.href` 指向 `make/installer-smoke/resources/app.asar/...`; + - 在该安装器落地产物内 PowerShell 同样返回 `113 / 208`,启动 `codex` 后 textarea 自动锚到 `top=1116px / left=864px / zIndex=5`,wheel smoke 中 `viewportY` 从 `3597` 变为 `3557`。 +- 结论: + - 用户昨天点到的确实是旧安装包,不是最新修复; + - 现在 `win-unpacked`、`zip`、`installer exe` 三条分发链路都已验证到同一份新代码; + - 产物名仍叫 `2026.4.17-1` 只是因为 `package.json` 版本号还没变,不代表内容没更新;是否要再改版本号/产物名属于发布管理问题,不是本轮终端根因修复本身。 + +## 2026-04-21 Artifact Version Bump + +- 为了彻底消除“明明是新代码,但文件名看起来像旧包”的误导,本轮把构建版本从 `2026.4.17-1` 提升到 `2026.4.21-1`: + - `package.json` -> `2026.4.21-1` + - `package-lock.json` 顶层版本同步到 `2026.4.21-1` +- 重新执行完整构建与打包后,新分发产物为: + - `make\Wave-win32-x64-2026.4.21-1.zip` -> `2026-04-21 11:17:26` + - `make\Wave-win32-x64-2026.4.21-1.exe` -> `2026-04-21 11:18:08` + - `make\Wave-win32-x64-2026.4.21-1.exe.blockmap` -> `2026-04-21 11:18:11` +- 新文件名产物验证: + - `zip` 解压到 `make\zip-smoke-2026.4.21-1` 后运行,`location.href` 指向 `make/zip-smoke-2026.4.21-1/resources/app.asar/...`; + - 该新 zip 包内 PowerShell 返回 `113 / 208`; + - 另外也已生成目录版 `make\Wave-win32-x64-2026.4.21-1\Wave.exe`,避免用户再点到旧目录名。 +- 补充说明: + - 新旧 zip 大小依旧都在 `2178xx KB` 左右,这是 Electron 分发包的正常现象;体积近似不代表内容没变,真正有效的是时间戳、SHA256 和运行态路径。 + +## 2026-04-21 Wheel / IME Cursor Alignment Fix + +- 用户给出 Windows Terminal 参考图后,本轮重新收口需求: + - “吞内容”主问题已经解决; + - 当前优先级变为两点:滚轮找回,以及输入框/输入法组合文本要对齐当前实际输入位置,而不是固定在中线。 +- 本轮根因: + 1. `frontend/app/view/term/termwrap.ts` 的 normal buffer wheel 兜底挂在 `connectElem` 的 **capture** 阶段,并且会 `stopPropagation()`,这会抢在 xterm 内部的 `xterm-scrollable-element` 之前截获事件; + 2. xterm 当前真实滚动条并不依赖 `.xterm-viewport.scrollHeight`,而是依赖内部 `_viewport._scrollableElement`;运行态确认其 `scrollHeight=66780`、`scrollTop=64746`,说明右侧滚动条仍是 xterm 自己维护的; + 3. IME 兜底之前固定锚到 `rows * 0.55` 的中线,和用户给出的 Windows Terminal 参考不一致;正确行为应当跟随当前 cursor 行列。 +- 本轮修复: + - wheel 兜底改为 **bubble** 阶段监听,不再在 capture 阶段抢占 xterm 内部 wheel 处理; + - 仅在 `wholeLines !== 0` 时才调用 `preventDefault()/stopPropagation()`,避免吞掉无法折算成整行的小滚轮增量; + - IME 锚点改为使用当前 `buffer.active.cursorY / cursorX` 与 cell 尺寸计算 `top/left`,不再固定在中线。 +- 运行态验证: + - 在最新 `make\win-unpacked\Wave.exe --remote-debugging-port=9229` 中,启动 `codex` 后: + - `cursor = { x: 2, y: 32 }` + - textarea = `top=576px / left=18px / zIndex=5` + - 与当前 cursor 计算出的期望值一致; + - 直接把 `WheelEvent` 派发到 xterm 内部 `_viewport._scrollableElement._domNode` 后,`viewportY` 从 `3597` 变为 `3557`,说明 xterm 自身滚动链路已恢复,不再被外层 capture handler 抢断; + - `agent-browser mouse wheel` 在 Electron + CDP 下仍会超时且抓不到 DOM `wheel` 事件,因此这部分继续记录为工具限制,而不是代码未生效。 +- 验证通过: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` + - `WAVETERM_WINDOWS_INSTALLERS=1 npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win nsis zip` + +## 2026-04-21 Remove Terminal History Cache / Restore + +- 用户最新明确要求是不再需要“历史记录”,并指出这套历史恢复逻辑本身已经影响问题判断;因此本轮不再继续修补 `cache:term:full`,而是直接从前端停用这条链路。 +- 已在 `frontend/app/view/term/termwrap.ts` 做最小范围移除: + - 删掉 `cache:term:full` 读取入口与 `loadInitialTerminalData()` 调用; + - 删掉 `SerializeAddon`、`processAndCacheData()`、`runProcessIdleTimeout()`、`BlockService.SaveTerminalState(...)` 调用; + - 初始化阶段改为只订阅当前会话 `term` blockfile 的实时 append,不再恢复旧终端快照。 +- 为避免初始化窗口内丢实时输出,本轮补了 `flushHeldTerminalData()`:在 `loaded=false` 期间进入 `heldData` 的 append 数据会在 `loaded=true` 后顺序回放,保证“去历史”不等于“丢首屏实时输出”。 +- 验证结果: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` 通过; + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` 通过; + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` 通过,最新 `make\win-unpacked\Wave.exe` 时间为 `2026-04-21 15:45:49`。 +- 补充记录: + - 代码检索确认 `termwrap.ts` 中已不再引用 `cache:term:full`、`SaveTerminalState`、`loadInitialTerminalData`、`runProcessIdleTimeout`、`processAndCacheData`、`SerializeAddon`、`fetchWaveFile`。 + - `agent-browser` 仍可通过 `agent-browser.cmd` 使用,但 PowerShell 直接执行 `agent-browser.ps1` 会被本机 execution policy 拦截;这是本机策略限制,不是仓库阻塞。 + +## 2026-04-21 Terminal Smoke Automation Loop + +- 按 `$architect-improvement-loop` 和用户批准的方向 A,新增 `TASK-TERM-002`,目标是先建立终端回归 smoke 自动化闭环,避免继续反复出现旧包、旧实例、历史恢复残留、滚轮/IME 无法确认的问题。 +- 新增 `scripts/smoke-terminal.ps1`: + - 默认只关闭仓库 `make` 目录下的旧 `Wave.exe`,不动仓库外安装版;如需全量关闭可显式传 `-KillAllWave`; + - 自动启动 `make\win-unpacked\Wave.exe --remote-debugging-port=`; + - 通过 CDP 直接执行运行态断言,不依赖 `agent-browser.ps1`; + - 静态确认 `termwrap.ts` 不包含历史恢复/缓存关键字符串; + - 运行态确认 `window.term` 可达、历史方法为空、`serializeAddon=false`、wheel 能改变 `viewportY`、IME textarea 与 cursor 对齐; + - 输出 JSON 与截图到 `D:\files\AI_output\waveterm-terminal-smoke`。 +- 首次 smoke 有意外但很关键的失败:当时源码已停用历史链路,但运行态 bundle 仍暴露 `loadInitialTerminalData` / `processAndCacheData` / `runProcessIdleTimeout`,说明 `make\win-unpacked` 仍是旧前端 bundle;失败结果在 `D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-161932.json`。 +- 串行重跑 `scripts\verify.ps1` 与 `electron-builder --win dir` 后,第二次 smoke 通过: + - JSON:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-162451.json` + - 截图:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-162451.png` + - `Wave.exe` 时间:`2026-04-21T16:23:14.5073581+08:00` + - SHA256 前缀:`0A9EC1A4814CB56A` + - rows/cols:`55 / 103` + - runtime 历史方法:空 + - `serializeAddon`:`false` + - wheel:`viewportY 127 -> 87` + - IME:`topDelta=0`、`leftDelta=0` +- 验证通过: + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + +## 2026-04-21 Architect Loop Approval A + +- 用户在最新截图反馈“输入框问题仍未解决,滚轮问题又出现”后,按 `$architect-improvement-loop` 继续做 review,没有直接再次改业务代码。 +- 复盘结论: + - 当前业务逻辑问题已从“单 terminal DOM patch 是否生效”转为“多 terminal split-pane 焦点归属与真实 wheel 路径是否正确”; + - 当前 smoke 虽然已证明最新包、无历史恢复、单 terminal DOM 断言通过,但它仍通过 `window.term` 单实例、强制 `shouldAnchorImeForAgentTui=()=>true` 和直派发内部 `.xterm-scrollable-element` 的方式验证,覆盖不到用户截图暴露的真实路径; + - `frontend/app/view/term/termwrap.ts` 当前 wheel 兜底在 bubble 阶段先判断 `event.defaultPrevented`,这使它在 xterm 已先消费事件的场景下根本不会运行;IME 逻辑也没有绑定真实 active terminal ownership。 +- 已根据用户批准 A 创建两个后续任务包: + - `TASK-TERM-003`:多 terminal 焦点与真实事件路径 smoke 补强 + - `TASK-TERM-004`:将 wheel / IME 修复收口到 xterm 官方扩展点与焦点归属 +- 这是规划工件更新,不包含新的终端业务代码改动。 + +## 2026-04-21 TASK-TERM-003 Multi-Terminal Smoke + +- 已完成 `TASK-TERM-003` 的脚本实现,新增 `scripts/smoke-terminal.runtime.js`,并让 `scripts/smoke-terminal.ps1`: + - 自动枚举页面上的多个 terminal block,而不是只看 `window.term` + - 在运行态用 `window.term` setter hook 记录新建 `TermWrap` + - 必要时通过 `RpcApi.CreateBlockCommand(... targetaction=splitdown ...)` 自动创建第二个 terminal 做 split-pane smoke + - 对每个 terminal 输出 blockId、几何位置、focus owner、textarea/composition-view 样式以及 runtime rows/cols/bufferType/viewportY + - 区分 `elementFromPoint` 外层真实 wheel 路径与内部 `.xterm-scrollable-element` fallback + - 截图后自动删除 smoke 临时创建的 block,避免污染用户工作区 +- 运行结果: + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave` 已执行 + - 结果 JSON:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-165710.json` + - 截图:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-165710.png` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` 通过 +- 结论收敛: + - smoke 已稳定发现 `3` 个 DOM terminal,并拿到 `2` 个已知 `TermWrap` + - `wheel` 在当前多 terminal smoke 中通过,`outerChanged` / `internalChanged` 都只命中目标 terminal + - `IME ownership` 失败,诊断为 `ime_wrong_terminal` + - 失败细节显示一个非目标 terminal 仍保留 helper textarea 的 `top/left/zIndex`,这说明后续 `TASK-TERM-004` 应优先修复“非 active terminal 的 IME helper 清理 / ownership 判定”,而不是继续盲修 wheel + +## 2026-04-21 TASK-TERM-004 Wheel / IME Ownership 收口 + +- 已按 `TASK-TERM-004` 把终端修复收口到更接近 xterm 官方路径: + - `frontend/app/view/term/termwrap.ts` 的 normal buffer wheel 改为优先走 xterm `attachCustomWheelEventHandler` + - 仅在 `mouseTrackingMode !== "none"` 且 `normal` buffer 时保留一个极窄的 capture fallback,避免再次用粗粒度外层 DOM listener 抢事件 + - 新增 `TermWrap.liveInstances` 与静态 `imeOwnerBlockId`,让 IME helper override 只属于当前 owner terminal + - 在 `focus` / `compositionstart` 时先调用 xterm 私有 `_syncTextArea()`,与官方 issue `#5734` / PR `#5759` 的修复点保持一致 +- 这一轮也顺手修正了 smoke 的验证盲区: + - `scripts/smoke-terminal.runtime.js` 现在优先从 `TermWrap.liveInstances` 枚举终端实例 + - 当前工作区没有 terminal 时会自动创建首个 shell terminal + - IME ownership 断言不再把 xterm 默认 `z-index:-5` 当成“错误 terminal 仍有 override” +- 最终验证: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` 通过 + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` 通过 + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` 通过,刷新后的 `make\win-unpacked\Wave.exe` 时间为 `2026-04-21T17:16:35.2754971+08:00` + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave` 通过 + - JSON:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-172449.json` + - 截图:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-172449.png` +- 结果: + - `dom terminal = 2` + - `known runtime = 2` + - `wheel diagnoses = ok` + - `ime diagnoses = ok` +- 当前剩余风险: + - 系统级中文输入法候选窗仍无法通过 CDP 直接截图;当前自动化以 helper textarea / composition-view 的真实 DOM ownership 作为代理指标 + +## 2026-04-21 TASK-TERM-004 Real Wheel Follow-up + +- 用户继续反馈“依然没有滚轮”后,本轮不再只依赖 JS `dispatchEvent(new WheelEvent(...))`,新增 `scripts/smoke-terminal-real-wheel.ps1` 走 CDP 真实输入路径: + - 启动 `make\win-unpacked\Wave.exe --remote-debugging-port=`; + - 复用 `scripts/smoke-terminal.runtime.js` 准备多 terminal split-pane 场景; + - 对每个目标 terminal 的 `screen-center` 与 `screen-right` 坐标发送 `Input.dispatchMouseEvent(type=mouseWheel, deltaY=-720)`; + - 断言只有目标 terminal 的 `viewportY` / scroll state 变化。 +- 真实复现结论: + - 最新 `win-unpacked` 成品中的真实鼠标滚轮路径已通过,2 个 terminal 的 `screen-center` / `screen-right` 均为 `ok`; + - 因此用户截图中的“没有滚轮”更像是仍在运行旧安装包/旧实例,或打开了同名旧版本,而不是当前 `make\win-unpacked` 内的 wheel 事件路径仍失败。 +- 为避免同名旧包误用,本轮将版本号从 `2026.4.21-1` 提升到 `2026.4.21-2`,并重新产出: + - `make\win-unpacked\Wave.exe`,时间 `2026-04-21 18:37:50`,SHA256 `C7FEF2CC7EC1280C98EAEB6CC3C8FDBD08346755382E1332CA7B9E5D5490DCE1` + - `make\Wave-win32-x64-2026.4.21-2.exe`,时间 `2026-04-21 18:40:18`,SHA256 `2D53B26D7C4D18BE9642A20460544E04CD6401B2952C68928E31891D154BB4BC` + - `make\Wave-win32-x64-2026.4.21-2.zip`,时间 `2026-04-21 18:39:18`,SHA256 `96C5E0222D1BD38600E8279B31AF4272358BD89307D2843544B7BA9D83E8EB76` +- 最终验证: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` 通过; + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` 通过; + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir nsis zip` 通过; + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal-real-wheel.ps1 -KillExistingRepoWave` 通过,JSON 为 `D:\files\AI_output\waveterm-terminal-smoke\terminal-real-wheel-20260421-184158.json`; + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave` 通过,JSON 为 `D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-184339.json`。 + +## 2026-04-21 Architect Loop Approval A(第二轮) + +- 用户在最新截图中确认“输入法恢复了,滚轮又没了”,并明确要求按 `$architect-improvement-loop` 彻底解决该问题。 +- 本轮未直接继续改业务代码,而是先重新做研究,结论如下: + - 当前实现只覆盖 `normal buffer` 的 wheel:`frontend/app/view/term/termwrap.ts` 在非 `normal` 时直接退出; + - 当前 smoke 也只把 `normal buffer` 当成成功路径,`scripts/smoke-terminal.runtime.js` 把 `non-normal-buffer` 视为失败,而不是覆盖范围; + - `frontend/app/view/term/termutil.ts` 仍保留 alternate buffer 的 wheel fallback 设计,但当前 `termwrap.ts` 没有把这条路径真正接回去; + - 这与用户截图一致:Codex 交互态很可能不是普通 `normal buffer` 场景,因此出现“IME 正常但滚轮没了”。 +- 用户已批准推荐方向 A,因此新增 `TASK-TERM-005`: + - 任务名:`Codex / alternate buffer 全视图滚轮收口` + - 目标:把当前只覆盖 normal buffer 的滚轮补丁升级为“按 active terminal 收口的全视图 wheel router”,并让 smoke 明确覆盖 Codex / alternate buffer / mouse tracking 场景。 +- 本轮只新增任务包与 `.harness` 工件,不包含新的终端业务代码改动。 + +## 2026-04-22 TASK-TERM-005 Final Verification + +- 已在最新 `make\win-unpacked\Wave.exe` 上完成最终真实滚轮复核: + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal-real-wheel.ps1 -KillExistingRepoWave` +- 结果通过: + - JSON:`D:\files\AI_output\waveterm-terminal-smoke\terminal-real-wheel-20260422-110300.json` + - 截图:`D:\files\AI_output\waveterm-terminal-smoke\terminal-real-wheel-20260422-110300.png` + - `make\win-unpacked\Wave.exe` 时间:`2026-04-22T10:59:49.3611836+08:00` + - SHA256:`665EEF5E7CC24CCA7B3E27543AACC59B42076542DE1337156364DFB51C90838C` +- 关键结论: + - `runtime.wheel.allPassed = true` + - `runtime.ime.allPassed = true` + - `realWheel.allPassed = true` + - 2 个 terminal 的 `screen-center` / `screen-right` 全部为 `ok` +- 额外排查: + - 本机常见安装路径仅发现仓库内两份 `Wave.exe` + - 未发现额外安装版 `Wave.exe` 干扰当前验证 +- 当前判断: + - 最新仓库成品里的 IME 与滚轮路径都已恢复 + - 若你现场仍异常,更可能是启动入口不是当前仓库这份最新成品,或现场命中区域与当前 smoke 路径仍有差异 + +## 2026-04-22 TASK-TERM-005 Scrollback Follow-up + +- 用户最新手测反馈:滚轮与 IME 已恢复,但 Codex / Agent 输出基本只能回看一页,前面的输出会被吞掉。 +- 本轮复盘后确认这不是 `term:scrollback` 配置本身过小,而是 Agent TUI 路径仍会主动进入 `alternate screen` 并发送 `CSI 3 J` 清空 scrollback,导致历史只能保留当前页。 +- 已在 `frontend/app/view/term/termwrap.ts` 做最小收口: + - 对 agent 命令(`codex|claude|opencode|aider|gemini|qwen`)抑制 `47/1047/1049` alternate screen 进入 + - 对 agent repaint 场景抑制 `CSI 3 J` 清空 scrollback + - 保持现有 wheel / IME 修复不回退 +- 已扩展 `scripts/smoke-terminal.runtime.js`: + - 新增 `agent-repaint-scrollback` 场景 + - 断言 seed 历史仍可见、最新 repaint 内容可见、active buffer 仍是 `normal` +- 本轮验证: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave` + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal-real-wheel.ps1 -KillExistingRepoWave` +- 最新结果: + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260422-112409.json` + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-real-wheel-20260422-112519.json` + - `make\win-unpacked\Wave.exe` 时间:`2026-04-22T11:22:55.2418461+08:00` + - `make\win-unpacked\Wave.exe` SHA256:`BA03754F45CB5DF8BF0E7FF3FF9625E414AAB5A45C2DB1DC37A65B95800194E4` + - `runtime.agentScrollback.allPassed = true` + - `runtime.wheel.allPassed = true` + - `runtime.ime.allPassed = true` + - `realWheel.allPassed = true` + +## 2026-04-22 TASK-TERM-005 Live Wheel Revert + +- 用户最新手测反馈说明:上轮“强行保留 agent 历史”的方向把 Codex / TUI 的实时滚动搞坏了,表现为: + - 又无法滚动 + - 输出进行中也无法滚动 +- 本轮重新对照 `@xterm/xterm` 官方 wheel 逻辑,并用 `agent-browser` 连到 Electron 实机窗口做快照确认: + - 当前真正需要的是:**mouse-tracking / alternate buffer 时,让应用自己接收 wheel** + - 不能再由 Wave 在这些场景下强行把 wheel 改写成 `PageUp/PageDown`,更不能强压成 normal-buffer scrollback +- 已做收口: + - 回退上轮对 agent TUI 的 `47/1047/1049` alternate-screen 抑制 + - 回退上轮对 agent repaint `CSI 3 J` 的 scrollback 保留特判 + - `frontend/app/view/term/termutil.ts` 现在只在 `normal buffer` 下拦截 wheel 做 scrollback + - `frontend/app/view/term/termwrap.ts` 保留 normal-buffer + mouse-tracking 的极窄 capture fallback;`alternate buffer` 与 `mouse-tracking` 交回 xterm / 应用侧 +- smoke 也同步改回更接近官方语义: + - `alternate buffer` 场景现在断言收到的是 xterm 官方 fallback 的箭头序列,而不是自造的 `PageUp` + - 新增 `mouse-tracking-wheel` 场景,断言 wheel 会变成真正的鼠标协议输入(`ESC [ < ...`),而不是被 Wave 吃掉 +- 本轮验证: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave` + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal-real-wheel.ps1 -KillExistingRepoWave` +- 最新结果: + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260422-114610.json` + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-real-wheel-20260422-114631.json` + - `make\win-unpacked\Wave.exe` 时间:`2026-04-22T11:45:35.2099308+08:00` + - `make\win-unpacked\Wave.exe` SHA256:`3A535573D27CC7F34D1C12931283AA5B0127229F7901C7A52238796D6A837AF6` + - `runtime.wheel.allPassed = true` + - `runtime.ime.allPassed = true` + - `runtime.wheel.mouseTrackingScenarios[*].mouseSequenceSent = true` + - `realWheel.allPassed = true` + +## 2026-04-22 Architect Re-Intake: TASK-TERM-005 False Positive + +- 用户在最新一轮 `$architect-improvement-loop` 中再次明确:**输入法位置又错了、滚轮又没了、输出内容只能看到最新几行**;这直接否定了 `TASK-TERM-005` 当前 `.harness` 中的 `passing` 判定。 +- 本轮未继续改终端业务代码,先回到研究与决策阶段,重新核对了三类证据: + - 本地当前实现:`frontend/app/view/term/termwrap.ts` 与 `frontend/app/view/term/termutil.ts` + - 官方参考:`@xterm/xterm` 6.0.0 的 `CoreBrowserTerminal.ts` / `Viewport.ts` + - 上游原始逻辑:`upstream/main` 的 `frontend/app/view/term/termwrap.ts` +- 复盘结论: + - `upstream/main` 并没有当前这套 `attachCustomWheelEventHandler + capture fallback + imeOwnerBlockId` 组合逻辑,说明问题已不再是“单纯照抄 upstream”就能自动收敛,而是我们这条分支在多轮补丁间互相打架; + - xterm 官方 wheel 语义本身区分 `normal scrollback`、`alternate buffer fallback`、`mouse protocol`,但当前 smoke 仍主要验证“静态 seed 后是否能滚”,没有覆盖**输出进行中**的真实滚动路径; + - `scripts/smoke-terminal.runtime.js` 当前只命中 `screen-center` / `screen-right`,并通过 monkey patch 强制 `shouldAnchorImeForAgentTui`,仍不足以证明“中间 Codex pane 真实 DOM 命中区域 + 活动 terminal / IME owner”在持续输出时是正确的; + - 这也是为什么 smoke 全绿、用户实测仍反复失败:自动化验证路径与真实交互路径没有完全重合。 +- 因此本轮把 `TASK-TERM-005` 状态回退为 `failing`,并把下一轮工作从“继续盲改 wheel/IME 逻辑”改为“先补中间 Codex pane 专项诊断闭环,再进入最小修复包”。 +- 推荐的最小下一步是新建专项任务包(暂命名 `TASK-TERM-006`,待用户批准后落盘),只做以下事情: + - 记录中间 Codex pane 在**持续输出期间**的 `elementFromPoint` 命中元素、active terminal、buffer type、mouseTrackingMode; + - 记录滚轮事件是否落在 `.xterm-viewport` / `.xterm-scrollable-element` 之外的 overlay 或父容器; + - 记录 IME helper / composition-view 在多 pane + 持续输出期间的 owner 漂移; + - 用 Electron 实机 + CDP/`agent-browser.cmd` 复现“中间 pane 失败、左右 pane 正常”的真实窗口布局,而不是只看抽象 split-pane。 + +## 2026-04-22 Architect Approval A: Create TASK-TERM-006 + +- 用户已明确批准推荐方向 A,本轮继续遵守 `architect-improvement-loop`:**先创建任务包,不直接改业务代码**。 +- 已新增 `TASK-TERM-006`,目标从“继续修 wheel/IME”切换为“先补中间 Codex pane 持续输出场景的真实诊断闭环”。 +- 本次任务包明确约束: + - 只允许修改 `scripts/*` 与 `.harness/*` + - 暂不允许改 `frontend/app/view/term/termwrap.ts` 或 `frontend/app/view/term/termutil.ts` + - 优先把“滚轮失效、IME 错位、输出只能看到最新几行”拆成可观测的真实链路,而不是继续试错 +- `TASK-TERM-006` 的核心验收不是“问题立刻修好”,而是回答以下四个问题: + - 滚轮在持续输出期间究竟有没有命中当前中间 pane + - 命中后是被 xterm 吃掉、被 app 吃掉,还是根本没有 scrollback + - 当前可见滚动区域是不是纯 `xterm`,还是外层还有别的滚动容器 + - IME helper / composition-view 是否在多 pane + 持续输出期间发生 owner 漂移 +- 下一步将基于该任务包补脚本与实机诊断,再根据证据拆下一包最小业务修复。 + +## 2026-04-22 TASK-TERM-006 Partial Diagnostic Result + +- 已在不修改 `termwrap.ts` / `termutil.ts` 的前提下补强诊断脚本: + - `scripts/smoke-terminal.runtime.js` 新增: + - 中间 pane 目标选择改为按**几何位置**而不是 DOM 顺序; + - `continuous-middle` 诊断:持续输出期间记录 `elementFromPoint` 命中元素、祖先链、滚动容器、目标/非目标 terminal 的 `viewportY` 变化; + - `imeOwnershipLive` 诊断:在不 monkey patch `shouldAnchorImeForAgentTui` 的前提下记录 live ownership 快照。 + - `scripts/smoke-terminal-real-wheel.ps1` 新增: + - `liveRealWheel` 场景,使用 CDP `Input.dispatchMouseEvent(mouseWheel)` 真实滚轮; + - 诊断指标改为看 `baseY - viewportY` 的“离底部距离”,避免持续输出时因为 `baseY` 同步增长而误判。 +- 本轮关键结果: + - 常规 smoke:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260422-143509.json` + - 真实滚轮 smoke:`D:\files\AI_output\waveterm-terminal-smoke\terminal-real-wheel-20260422-143832.json` + - 两者都基于同一包:`make\win-unpacked\Wave.exe`,时间 `2026-04-22T11:45:35.2099308+08:00`,SHA256 `3A535573D27CC7F34D1C12931283AA5B0127229F7901C7A52238796D6A837AF6` +- 诊断结论收敛为: + - 在**纯 xterm 的 3-pane normal-buffer 场景**下,中间 pane 的滚轮路由、active terminal 归属、持续输出期间滚动、以及真实鼠标滚轮都正常; + - `screen-center`、`screen-right`、`view-right`、`scrollbar-center` 都能稳定命中目标 terminal,本轮未复现“中间 pane 自己滚不了”或“滚到别的 pane”; + - `IME live` 在纯 shell terminal 下只得到 `ime_live_not_applicable`,因为 `shouldAnchorImeForAgentTui=false`,这意味着当前脚本还没有进入**真实 Codex / agent TUI** 的条件分支。 +- 因此这轮最重要的判断是: + - 用户手测中反复出现的问题,不像是“通用 xterm middle pane + 通用实时输出 + 通用真实滚轮”本身有 bug; + - 更像是**真实 Codex / agent pane 特有状态**导致的问题,可能与: + - agent TUI 的实际输出/重绘方式 + - `shouldAnchorImeForAgentTui()` 进入条件 + - agent pane 的真实可视滚动区域 + - 或 agent 命令进入后的应用态/协议态 + 强相关。 +- 下一步建议不再继续改基础 wheel/IME 逻辑,而是补一个更窄的后续任务,只做其中一种: + - attach 到带真实 Codex pane 的 Wave 实例做只读诊断;或 + - 在 Wave 中拉起真实 agent 命令后再跑同一套 live diagnostic。 + +## 2026-04-22 TASK-TERM-006 Real Codex Pane Detection + +- 已新增 `scripts/smoke-terminal-codex-pane.ps1`,用途是: + - 直接连接正在运行的 Wave(或由脚本启动); + - 选中几何中间 terminal; + - 向该 terminal 注入真实 `codex` 命令; + - 等待 `Codex` 可见文本或 `shouldAnchorImeForAgentTui()` 进入激活态; + - 输出 JSON 供后续诊断复用。 +- 最新实测结果: + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-codex-pane-20260422-145534.json` +- 关键结论: + - 真实 Codex pane 已稳定命中中间 terminal:`blockId = 46ef4ddb-3453-4166-8ab5-a144bc05e7ae` + - `shouldAnchorImeForAgentTui()` 在真实 Codex pane 下为 `true` + - `imeOwnerBlockId` 与命中元素 `hit.blockId` 都对齐到该中间 pane + - 当前观测到的真实 Codex pane 仍是 `bufferType=normal`、`mouseTrackingMode=none` +- 当前仍未完全解决的部分: + - 脚本内触发“真实 Codex 自己持续输出很多行”的路径还不稳定;`codex` UI 能起来,但自动发送 prompt 后不一定稳定产出长回答 + - 因此我们已经确认“真实 Codex pane 本身能被命中、IME owner 也能对齐”,但还没有完整覆盖“真实 Codex 长输出过程中”的滚轮/IME 行为 +- 这说明下一步应该继续收窄为: + - 只研究“真实 Codex 长输出如何稳定复现”;不要再回去盲改基础 wheel/IME 逻辑 + +## 2026-04-22 TASK-TERM-006 Real Codex Long Output + Raw Tail + +- 为了稳定复现真实 Codex 长输出,本轮继续增强 `scripts/smoke-terminal-codex-pane.ps1`: + - 支持直接使用 `codex --no-alt-screen ""` 拉起交互态,并带初始 prompt; + - 新增 `debugterm` 采样,把 terminal blockfile 的原始尾部序列一起写进 JSON。 +- 最新关键产物: + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-codex-pane-20260422-150151.json` +- 这次结果非常关键: + - 真实 Codex pane 仍然是中间 pane,`shouldAnchorIme=true`,`imeOwnerBlockId` 与命中元素仍然对齐; + - 使用 `--no-alt-screen` 并给出长 prompt 后,Codex 已真实输出到 100+ 行内容; + - 但运行态仍显示: + - `bufferType=normal` + - `mouseTrackingMode=none` + - `baseY=0` + - `viewportY=0` + - `length=73` + - 这意味着**可见内容虽然在刷新,但 xterm buffer 没有形成 scrollback**。 +- 更关键的是,同一份 JSON 的 `debugTermTail` 已经拿到了原始序列证据: + - 大量 `ESC[K`(清行) + - 多次 `ESC[H`(回到左上角/重绘起点) + - 多次 `?2026h` / `?2026l`(同步重绘事务) +- 这说明当前真实 Codex 路径不是“alternate screen 把历史吞掉”,而是: + - **normal buffer 下的全屏重绘** + - 而这种重绘在当前 Wave 路径里没有累积成 scrollback +- 这与用户反馈已经高度吻合: + - “只能看到最新几行” + - “滚轮没东西可滚” + - 不是因为单纯 wheel listener 没命中,而是因为底层 scrollback 根本没长出来 +- 当前最接近根因的判断: + - 问题焦点已经从“wheel/IME 本身坏了”收敛到“Codex 的 full-screen normal-buffer repaint 为什么在 Wave 里不积累 scrollback” + - 下一步应优先研究: + - Wave / xterm 对这类 repaint 序列的处理边界 + - 与 Windows Terminal 的差异点 + - 是否需要对特定 repaint 模式做 scrollback 保留策略 + +## 2026-04-22 TASK-TERM-007 Agent Wheel Fallback + +- 在继续动业务代码前,本轮又补了两条关键对照,避免误判: + - 直接向 Wave 中的 xterm 发送 `CSI 6n` 后,已收到 `ESC[73;1R]`,说明 Codex 不是因为拿不到光标位置才退化; + - 直接向真实 Codex 发送 `ESC[5~ / ESC[6~` 后,已确认 Codex 会在当前 UI 内部前后翻页,即“无 native scrollback”不等于“应用内完全不能滚”。 +- 基于这两条证据,本轮不再尝试伪造 native scrollback,而是做最小修复: + - `frontend/app/view/term/termutil.ts` 新增 `shouldRouteAgentTuiWheelToInput()`; + - `frontend/app/view/term/termwrap.ts` 在 `agent TUI + normal buffer + mouseTrackingMode=none + baseY<=0 + length<=rows` 成立时,把 wheel 从 `terminal.scrollLines()` 切换为发送 `PageUp/PageDown` 输入序列; + - `frontend/app/view/term/termutil.test.ts` 补了对应单测。 +- 自动化验证结果: + - 单测:`npx.cmd vitest run frontend/app/view/term/termutil.test.ts` 通过; + - 目录包:`npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` 通过; + - 实机证据:`D:\files\AI_output\waveterm-terminal-smoke\terminal-codex-wheel-fallback-20260422-153137.json` +- 最新实机结论: + - 在 fresh `Wave.exe` 中运行真实 `codex --no-alt-screen "List the numbers 1 through 180, one per line, then stop."` 后,运行态仍是 `baseY=0`、`length=73`; + - 但对 `.xterm-screen` 派发一次 `WheelEvent(deltaY=-720)` 之后,可见内容会从只显示前部少量数字推进到更后面的 `9..76`; + - 这说明当前 wheel 已不再对着空 scrollback 失效,而是成功驱动了 Codex 的内部分页。 +- 当前剩余风险: + - 这条修复恢复的是 **Codex 内部翻页能力**,不是 Windows Terminal 风格的 native scrollback; + - 仍需用户在中间 pane、长对话、多轮继续追问的真实场景下做最终手测。 + +## 2026-04-22 TASK-TERM-007 Current Window / Bubble Fallback / IME Sync Follow-up + +- 本轮不再只看独立 smoke,而是直接读取用户当前正在使用的 Wave 实例状态: + - 已从 `C:\Users\yucohu\AppData\Local\waveterm\Data\db\waveterm.db` 定位到当前窗口 / tab / layout; + - 已确认当前三栏 block 分别是: + - 左:`443e542b-9066-4cf0-9ac6-b4225c72b721` + - 中:`46ef4ddb-3453-4166-8ab5-a144bc05e7ae` + - 右:`562f58be-e9e5-4940-bef2-71e79359ae63` + - 已确认当前焦点在中间栏 `46ef...`。 +- 为了不关闭用户窗口,本轮新增了一条宿主机诊断链路: + - 直接读取 Wave 子进程环境块; + - 成功拿到当前中间 pane 的 `WAVETERM_JWT` / `WAVETERM_BLOCKID`; + - 再用 `wsh termscrollback` 直接读取用户真实 pane 内容。 +- 关键新结论: + - 当前用户实例里的中间 Codex pane 的 `termscrollback` 只有 `73` 行量级,`baseY=0`,仍然没有 native scrollback; + - 这与用户“只能看到最新一页、滚不上去”的反馈完全一致; + - 同时左/中/右三个 pane 的真实底层内容已经可读,不再是“我没看到用户正在操作哪一栏”。 +- 基于这条真实实例证据,本轮继续收口了 `termwrap.ts`: + - 将 connect 容器上的 wheel 兜底从过窄的 capture/mouse-tracking 限制,改为 **bubble fallback**,让整块 terminal 内容区域都能补接 wheel; + - 为 agent TUI 检测增加 **latched** 状态,并把 `?2026h/l` 全屏重绘事务也作为持续命中信号,避免输出过程中 `isAgentTuiActive()` 闪断; + - IME 定位不再二次重算光标网格坐标,而是复用 xterm 官方 `_syncTextArea()` 已算好的 `top/left/width/height`,只保留 owner/z-index 覆盖,避免再次把输入法候选框算偏。 +- 验证结果: + - 单测:`npx.cmd vitest run frontend/app/view/term/termutil.test.ts` 通过; + - 因用户当前正在运行 `make\win-unpacked\Wave.exe`,默认目录包无法覆盖,已改为输出到 `make-smoke\win-unpacked\Wave.exe`,构建通过; + - 独立数据目录 smoke:`D:\files\AI_output\waveterm-terminal-smoke\terminal-codex-pane-20260422-162244.json` + - 已再次确认 fresh 实例下 `shouldAnchorIme=true` + - 命中元素属于目标 Codex pane + - 运行态仍是 `normal buffer + no native scrollback` +- 当前剩余风险: + - clean-room CDP 里的“直接启动 Codex 并稳定做 wheel 断言”仍有启动时序波动; + - 但这不影响当前代码层结论:真实用户窗口已被精确定位,且本轮补丁已经针对“整块区域 wheel 兜底 + agent 判定闪断 + IME 二次算偏”三条根因同时收口。 + +## 2026-04-22 TASK-TERM-007 Final Package Verification + +- 本轮最终定位到一个非常关键的交付问题:**用户平时打开的默认 `make\win-unpacked\Wave.exe` 仍是旧包**。 + - 旧默认包 hash:`ED388EFE47F9487B6DFA8C797FBBEB1BF3D6F5F72AEA46144DD37FFD139299CC` + - 旧默认包时间:`2026-04-22 16:19:12` + - 同时 `make-smoke\win-unpacked\Wave.exe` 已是新包,hash 为 `D666DBB20FBF594A506714695ADE04E8FA44464E75FB0D11F8AF64439E0D7FA6` +- 因此本轮没有继续改 `termwrap.ts` 业务逻辑,而是先把交付链路补齐: + - 重新执行 `npm.cmd run build:prod` + - 再执行 `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` + - 重打后的默认 `make\win-unpacked\Wave.exe` 已更新为同一新 hash:`D666DBB20FBF594A506714695ADE04E8FA44464E75FB0D11F8AF64439E0D7FA6` + - `make\win-unpacked\resources\app.asar` 已确认包含 `agentTuiHistoryLines` / `wave-agent-scrollback-overlay` / `extractAgentTuiHistoryLines` +- 随后对**默认 make 包**做了隔离数据目录 smoke,避免污染真实用户窗口: + - 使用 `WAVETERM_DATA_HOME=D:\files\AI_output\waveterm-terminal-smoke\default-make-data` + - 使用 `WAVETERM_CONFIG_HOME=D:\files\AI_output\waveterm-terminal-smoke\default-make-config` + - 在 fresh `make\win-unpacked\Wave.exe` 中经 CDP 创建 terminal block 后,运行真实 `codex --no-alt-screen "List the numbers 1 through 180, one per line, then stop."` +- 默认包实机结论: + - 运行态仍是用户真实问题对应的 `normal buffer + mouseTrackingMode=none + baseY=0 + viewportY=0 + length=62` + - 但 `captureAgentTuiHistorySnapshot()` 已在默认包中累计出 `194` 行历史 + - 对 `.xterm-screen` 派发一次 `WheelEvent(deltaY=-960)` 后,overlay 立即出现,`scrollHeight=3492 > clientHeight=1120` + - overlay 同时包含早期输出(`1..20`)与后期输出(`170..180`),说明“只能看到最新一页”的问题已在默认包中被修复 +- 证据文件: + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-codex-pane-20260422-181146.json` + - `D:\files\AI_output\waveterm-terminal-smoke\last-default-make-port.txt` +- 最后已直接启动最新默认包供用户手测: + - `make\win-unpacked\Wave.exe` + - 当前启动实例 PID:`36624` +- 当前剩余风险: + - 现方案恢复的是 **Codex 输出历史 overlay + wheel 查看能力**,不是 xterm native scrollback 自身增长; + - 仍需用户在真实多轮会话里做最终主观体验确认,但“默认包仍是旧版本”这个交付问题已经收口。 + +## 2026-04-22 Architect Review: Overlay Regression Reframed As New Packet + +- 用户最新三张截图把问题进一步收敛清楚了:当前不是“滚轮完全没反应”,而是**滚轮触发后终端被一层 fake history overlay 覆盖**,于是出现: + - 字体/排版看起来变化; + - 底部 live terminal 状态条消失; + - 滚动前看到的是 Codex 原生 live TUI,滚动后看到的是另一套被重新拼出来的纯文本视图。 +- 对照当前实现可直接定位到根因: + - `termwrap.ts` 里存在 `.wave-agent-scrollback-overlay` + - `captureAgentTuiHistorySnapshot()` 会把 `agentTuiHistoryLines` 累积成文本 + - `renderAgentScrollbackOverlay()` 再用 `overlay.textContent = ...` 重新盖在 terminal 上 +- 这解释了为什么技术 smoke 会显示“能看到前面内容”,但用户主观体验仍明确判定为“还是不对”: + - 当前方案解决的是“看得到更早文本” + - 但破坏了“仍像一个正常终端那样渲染”的更高优先级体验目标 +- 进一步与 upstream 基线比对后也确认: + - upstream `wavetermdev/waveterm` 并没有 `agentTuiHistoryLines` / `.wave-agent-scrollback-overlay` 这套路径 + - 这部分属于本地修复过程中自己引入的新行为,不是官方原始逻辑 +- 结合外部一手资料,本轮 architect 结论如下: + - OpenAI Codex issue `#14277` 与 xterm.js issue `#5745` 都支持“xterm.js 宿主下无 native scrollback 更像上游能力缺口,而不是宿主一定要自己伪造一份 scrollback” + - 因此不应继续沿着 fake overlay 路线打磨,而应回到“保留最小 wheel fallback,但不再重绘终端内容”的方向 +- 用户已明确批准方向 A: + - 删除 fake scrollback overlay + - 恢复官方终端渲染路径 + - 保留最小的 `PageUp/PageDown` wheel fallback + - 保留已验证有效的 IME 修复 +- 基于此,已新建 `TASK-TERM-008`,作为当前唯一推进中的最小闭环。 +## 2026-04-22 TASK-TERM-008 收口补记 + +- 已重新执行 `npm.cmd run build:prod` 与 `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir`,默认交付包 `make\win-unpacked\Wave.exe` 已刷新。 +- 默认交付包最新 SHA256:`BB7D7277A4F437B373F8B6F6E08B52DFB87BA5C2E2717F94A25F111EB12EC34A`。 +- 再次确认当前机器上运行的 `Wave.exe` 都来自仓库路径 `D:\Project\260413\waveterm\make\win-unpacked\Wave.exe`,没有发现其它同名程序混用。 +- 复核 `D:\files\AI_output\waveterm-terminal-smoke\task-term-008-native4-probe.json`: + - `fakeOverlayExists=false` + - `xtermOverlayExists=false` + - `before.baseY=63` + - `after.viewportY=10` + - `head/historyHead` 连续保留早期输出 +- 这说明当前实现已经不再通过 fake overlay 切换渲染,而是在同一个 live xterm 上形成可滚动 scrollback。 +- 2026-04-22 22:21 / 22:22 追加 fresh attach smoke: + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-codex-pane-20260422-222136.json` + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-codex-pane-20260422-222213.json` + 两次都命中默认 `make\win-unpacked` 新包;这两份结果主要用于确认 fresh 包与 pane 命中,深度滚轮结论仍以前述 `task-term-008-native4-probe.json` 为准。 +- 当前剩余风险不再是 fake overlay / 错误渲染切换,而是 Codex 上游 full-screen repaint 的时序波动:不同 prompt 与返回速度下,自动化不一定总能在固定超时内等到精确尾行,但这不等于 wheel / IME 回归。 +## 2026-04-23 TASK-TERM-008 续修:前移 transcript 捕获起点 + +- 根据用户 2026-04-23 最新截图,确认当前残留问题不再是 fake overlay,而是 **scrollback 已可滚但最前面几段输出仍会被吞掉**。 +- 根因继续收口到 `frontend/app/view/term/termwrap.ts`:此前 transcript 捕获依赖 `isAgentTuiActive()`,实际会晚于某些 Codex 首批 repaint;如果首批长输出先把旧行顶出当前窗口,再开始 capture,就会造成“滚得动,但最前几行/几段没有了”。 +- 本轮修复: + - 新增 `agentTuiTranscriptArmed`,把 transcript 捕获与 IME/agent 可见态解耦; + - 在收到写入数据时,若已识别到 agent 命令且 shell 尚未回到 `ready`,或首批数据本身已带 `OpenAI Codex` / `?2026h/l` 信号,则提前 arm transcript capture; + - `isAgentTuiActive()` 进入运行态时不再二次清空已提前建立的 transcript; + - shell prompt 回来后同步 disarm,避免旧 session history 污染下一条普通命令。 +- 本轮验证: + - `npx.cmd vitest run frontend/app/view/term/termutil.test.ts`:22 passed + - `npm.cmd run build:prod`:通过 + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir`:通过 + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1`:通过 +- 备注:用于长中文输出场景的 page-level CDP 深挖脚本今天在本机上出现了单独的 websocket 连接异常,暂未形成新的结构化 probe JSON;但这不影响代码修复、默认包重打与仓库标准验证闭环。 +## 2026-04-23 TASK-TERM-008 续修:保留空行与 prompt 上下文,并刷新默认包 + +- 根据用户“滚动几次后又会吞掉内容”的最新反馈,继续把根因收口到 transcript overlap 对重复代码块 / 空段落的误判:此前 `extractAgentTuiHistoryLines()` 过滤过猛,会删除内部空行与用户 prompt 上下文,导致相邻 snapshot 在重复行场景下更容易错误对齐,从而把中间段吞掉。 +- 本轮修复: + - `frontend/app/view/term/termutil.ts` 的 `extractAgentTuiHistoryLines()` 改为保留内部空行与用户 prompt 上下文,仅继续过滤 shell banner、明显瞬态 footer / status; + - `frontend/app/view/term/termutil.test.ts` 补充对应断言,覆盖“保留 prompt context”与“保留内部空行”; + - 重新重打默认交付包 `make\win-unpacked\Wave.exe`,并重新启动仓库内最新包,避免用户误开旧进程。 +- 本轮验证: + - `npx.cmd vitest run frontend/app/view/term/termutil.test.ts`:23 passed + - `npm.cmd run build:prod`:通过 + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir`:通过 + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1`:通过 + - 默认交付包最新 SHA256:`EF535A17FED74786A876B1A2FFBE4A02CCA6F174FE6BC6AAB4DE9F034C250197` + - 当前机器上运行的 `Wave.exe` 全部来自 `D:\Project\260413\waveterm\make\win-unpacked\Wave.exe` +- 剩余风险: + - 自动化仍缺一个“长中文 / 长代码块滚多次后不吞段”的稳定结构化 probe; + - 如果用户还可复现,下一刀只允许继续收口 transcript snapshot 的对齐与注入顺序,不回退到 overlay / fake history 路线。 +## 2026-04-23 TASK-TERM-008 续修:修复滚动历史重复与乱序 + +- 根据用户新截图,当前问题从“吞掉内容”进一步定位为 **滚动历史中出现重复段、乱序段和插入段错位**,典型表现是同一个标题、同一句“但注意”、同一段估算过程在 scrollback 中连续出现多次。 +- 根因判断: + - 旧的 native scrollback injection 会把 `terminal.buffer.active` 的完整 buffer 当作下一轮 transcript 输入; + - 这会把前一轮已经注入到 xterm scrollback 的合成历史再次当成真实 Codex 输出; + - 再叠加 `agentTuiInjectedLineCount` 按“history 总长度 - 当前快照长度”推算待注入区间,在重复标题 / 空行 / 短段落场景下会产生重复和乱序。 +- 本轮修复: + - `frontend/app/view/term/termwrap.ts` 不再从完整 buffer 捕获 transcript,改为只捕获当前屏幕区间:`baseY..baseY+rows`; + - hidden preview seed 同样只使用当前屏幕,不再把已注入 scrollback 喂回 preview; + - 删除 `agentTuiInjectedLineCount` 路径,不再按累计长度推断待注入内容; + - 新增 `appendDroppedPrefixLines()`,只在相邻两帧能明确重叠时,把“上一帧顶部确实滑出的行”加入待注入队列; + - 对无法确认重叠的 repaint 窗口选择不注入,优先避免乱序/重复; + - agent TUI 活跃时拦截 `CSI 3J` 清 scrollback,但保留 repaint transaction 标记,避免 Codex 清掉刚补进同一 live xterm 的真实 scrollback。 +- 本轮验证: + - `npx.cmd vitest run frontend/app/view/term/termutil.test.ts`:26 passed + - `npm.cmd run build:prod`:通过 + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir`:通过 + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1`:通过 + - `git diff --check`:通过 + - 默认交付包最新 SHA256:`44B3656C610735F8CF34F69B0CD605315856F8EBE4F6AA29E72CB81966BD9B86` + - `scripts\smoke-terminal-codex-pane.ps1` 已用默认 `make\win-unpacked\Wave.exe` 启动最新包并命中 Codex pane,结果文件:`D:\files\AI_output\waveterm-terminal-smoke\terminal-codex-pane-20260423-153157.json` +- 当前交付判断: + - 这次修复优先解决用户截图中的“乱斗”根因:不再让注入历史反复喂回 transcript; + - 若用户继续复现缺段,下一轮只允许针对“无重叠窗口如何安全补缺”做更保守的补齐,不允许恢复 overlay / fake history 或按长度猜测注入。 +## 2026-04-23 TASK-TERM-008 续修:输出中手动滚动不再改写历史顶部 + +- 根据用户最新反馈,剩余问题发生在 **Codex 持续输出过程中,用户一旦开始滚动查看历史,后续 repaint / 写入会把当时的滚动位置当成新的历史基准**,从而表现为“滚动中的那个位置变成最顶部,继续输出后有内容被吞掉”。 +- 根因收口: + - 旧逻辑在 `?2026l` repaint transaction 结束时会无条件 `scrollToBottom()`; + - 同时 transcript augmentation 写入期间没有显式保护用户当前 `viewportY`; + - 所以当用户正在看历史时,后续输出既可能把视口拉回底部,也可能让写入后的 viewport 变化参与后续基准判断。 +- 本轮修复: + - `frontend/app/view/term/termwrap.ts` 新增 `agentTuiUserScrollLock`; + - wheel 路径与 `terminal.onScroll()` 双重更新这把锁:只要 agent TUI 输出期间用户离开底部,就进入“用户正在查看历史”状态; + - repaint transaction 完成时,若用户仍处于历史查看状态,则不再 `scrollToBottom()`; + - `doTerminalWrite()` 在写入前记录当前 `viewportY`,写入后若用户处于历史查看状态,则把 viewport 恢复到原位置,而不是让写入过程改变它; + - prompt 返回、truncate 或 transcript state reset 时同步清理这把锁。 +- 本轮验证: + - `npx.cmd vitest run frontend/app/view/term/termutil.test.ts`:26 passed + - `npm.cmd run build:prod`:通过 + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir`:通过 + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1`:通过 + - 默认交付包最新 SHA256:`07A09E1CC845C107660110747F382922B83C506194D3ED6CCEC245C0ADAF4755` +- 当前状态: + - 最新普通包已重新启动,当前机器运行中的 `Wave.exe` 均来自 `D:\Project\260413\waveterm\make\win-unpacked\Wave.exe`; + - 请用户重点复测“输出还在继续时就开始滚轮往上翻”的场景,确认不再出现“滚到的位置被当成新顶部并吞历史”的问题。 +## 2026-04-23 TASK-TERM-008 续修:恢复滚动响应,避免旧写入覆盖新滚动 + +- 根据用户最新截图,“输出中手动滚动保护”引入了一个新副作用:写入队列中的旧写入会在完成时恢复它开始前记录的 `viewportY`,如果用户在这次写入过程中继续滚动,旧写入会把用户刚滚到的新位置拉回去,于是表现为“滚动不了了”。 +- 另一个同步发现的问题:当 native scrollback 尚未长出时,当前 wheel 路径会调用 `terminal.scrollLines()` 并消费事件,但 scrollback 为 0 时实际不会移动,也不会再把 PageUp/PageDown 交给 Codex。 +- 本轮修复: + - 新增 `agentTuiUserScrollVersion`,用户每次 wheel 滚动都会递增版本; + - `doTerminalWrite()` 只有在写入期间用户没有再次滚动时,才允许恢复旧 `viewportY`; + - 如果写入期间用户继续滚动,则旧写入不再覆盖新位置; + - 新增 `shouldRouteAgentTuiWheelToInput()`:仅在 agent TUI active 且 `baseY<=0 / length<=rows+1` 的无 native scrollback 状态下,把 wheel 重新转成 `ESC[5~` / `ESC[6~` 发送给 Codex,避免 wheel 被宿主吞掉。 +- 本轮验证: + - `npx.cmd vitest run frontend/app/view/term/termutil.test.ts`:26 passed + - `npm.cmd run build:prod`:通过 + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir`:通过 + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1`:通过 + - 默认交付包最新 SHA256:`35844E7E33D0D9FC5EA7F2D9F87B1B2A873A1F4144C8483B5EDB74D4C6F7D923` +- 当前状态: + - 已关闭旧进程并启动最新默认包; + - 这轮重点验证“持续输出时连续滚轮仍能移动”,以及“还没生成 native scrollback 时也能走 Codex 内部 PageUp/PageDown”。 + +## 2026-04-23 17:43 TASK-TERM-008 续修:恢复持续输出期间 native scrollback + +- 根因:Codex 持续 repaint 输出时,隐藏 preview terminal 原先 scrollback: 0,首个大块输出把早期行在 preview 里也丢掉,导致 live xterm aseY 长时间为 0,滚轮无处可滚。 +- 修复: rontend/app/view/term/termwrap.ts 将 agent preview terminal 改为保留 MaxTermScrollback,并从 preview buffer 的真实 scrollback prefix 生成待注入行;同时把 wheel 兜底从 capture 阶段收回到 bubble 阶段,避免抢占浏览器真实滚轮路径。 +- 验证: +px.cmd vitest run frontend/app/view/term/termutil.test.ts 26 passed; +pm.cmd run build:prod 通过;electron-builder --win dir 通过;scripts\verify.ps1 通过;git diff --check 通过。 +- 运行态证据:D:\files\AI_output\waveterm-terminal-smoke\terminal-codex-pane-20260423-173615.json 对应场景中 aseY=120、historyLen=120,DOM wheel 后 iewportY=120 -> 80。 +- 默认交付包:make\win-unpacked\Wave.exe SHA256 $hash。 + +## 2026-04-23 19:05 TASK-TERM-008 续修:收紧 Codex transcript 过滤并重做滚轮验证 + +- 继续针对用户“第一轮正常、第二轮又乱了”的反馈回溯根因,确认当前 residual case 不只是 clear-scrollback,而是 **Codex repaint snapshot 中混入了 chrome / working 状态 / 默认建议词,导致 transcript merge 在跨轮或长输出后半段发生自污染**。 +- 这轮修复点: + - ermwrap.ts 不再把 live terminal 当前屏内容 seed 到 hidden preview,避免 PowerShell banner、旧 prompt 和已注入内容再次回流到 preview 基准; + - ermutil.ts 新增 +econcileAgentTuiSnapshotHistory() 的“历史前缀锚点”路径,用可见窗口前缀在既有 transcript 中定位,而不是在无重叠时盲目整屏拼接; + - extractAgentTuiHistoryLines() 进一步过滤 Codex chrome、update banner、working 状态、默认 suggestion、shell/codex 启动命令与空白噪声,只保留用户 prompt + 实际回答行; + - scripts/smoke-terminal-codex-pane.ps1 新增 wheel 断言与第二轮 CDP 输入探针,方便持续观察同一 Codex 会话内的跨轮状态。 +- 当前运行态结果: + - erminal-codex-pane-20260423-190056.json:aseY=24、historyLength=81,history tail 连续覆盖 FIRST_ROUND_LINE_021..080,wheel 后 iewportY: 24 -> 0; + - erminal-codex-pane-20260423-185930.json:50 行输出时 aseY=0,无 native scrollback 属预期,history 中仅保留 prompt + 01..050,不再爆涨到数千行; + - 默认交付包 make\win-unpacked\Wave.exe 最新 SHA256:1CE9850EB1EB8D2665FC8C5A8E68619F42FED9D01DA6604305BF0D72D4512CB9。 +- 剩余风险:Codex TUI 的“第二轮 prompt 提交”在当前 CDP 自动化路径下仍然只稳定做到**文本进入输入框**,未稳定触发第二轮真实回答;因此跨轮验证目前仍需用户在最新包上做一次手工 smoke 确认,但代码层已去掉会污染第二轮 transcript 的主要噪声来源。 +## 2026-04-23 19:26 TASK-TERM-008 续修:收紧误捕获并过滤默认建议词 + +- 新根因 1:PowerShell/PSReadLine 在命令行编辑 `codex --...` 时,旧逻辑仅因尾部文本包含 `codex` 就提前 arm transcript,导致输入过程被误当成 Codex TUI repaint。 +- 新根因 2:Codex 默认 suggestion `Explain this codebase` 未被过滤,会在 repaint 合并时反复进入 transcript,造成用户看到的“乱斗 / 重复 / 吞行”。 +- 本轮修复: + - `termutil.ts` 新增纯函数 `shouldPrimeAgentTuiTranscriptCapture()`,只在 `running-command + agent 命令` 或强 UI marker(如 `OpenAI Codex`、`?2026h/l`)出现时才启动 transcript; + - `termwrap.ts` 改为复用该纯函数,并把 `isAgentTuiActive()` 的 marker 判断收紧为强 marker,避免普通命令行输入被误判; + - `extractAgentTuiHistoryLines()` 增加对 `Explain this codebase` / `› Explain this codebase` 的过滤,阻止默认建议词污染历史。 +- 本轮验证: + - `npx.cmd vitest run frontend/app/view/term/termutil.test.ts`:34 passed; + - `npm.cmd run build:prod`:通过; + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir`:通过; + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1`:通过(本轮代码后至少已有 build/prod + 打包 + smoke 重新验证); + - 运行态 smoke:`D:\files\AI_output\waveterm-terminal-smoke\terminal-codex-pane-20260423-192604.json`,`baseY=24`、`agentTuiHistoryLength=81`、wheel `viewportY=24 -> 0`。 +- 默认交付包:`make\win-unpacked\Wave.exe` SHA256=`A7BCEFA722BEED0C0682A8AAAB685967B478212466AE4C79EAC4FC704CE80E3E`。 \ No newline at end of file diff --git a/.harness/task-packets/TASK-001.md b/.harness/task-packets/TASK-001.md new file mode 100644 index 0000000000..44f459779b --- /dev/null +++ b/.harness/task-packets/TASK-001.md @@ -0,0 +1,88 @@ +# TASK-001 + +## 任务标题 + +飞书入口增强与 Harness 初始化 + +## 目标 + +在当前 Waveterm 架构下,把飞书接入打磨为可交付的最小完整版本:支持本地 App 优先、应用内网页兜底、同分区新窗口、清晰的双入口交互,并为仓库建立最小可续跑的 Harness。 + +## 背景 + +当前项目已经能新增飞书入口,但还缺少 3 个关键补强: + +1. 飞书登录/弹窗新窗口没有继承专用分区 +2. 用户需要明确区分“本地 App”与“应用内网页”两类入口 +3. 仓库缺少面向长任务续跑的最小 Harness 工件 + +## In Scope + +- 飞书入口、视图与主进程启动链路 +- 飞书偏好配置项与 schema +- `.harness` 工件 +- 仓库级 `AGENTS.md` / `CLAUDE.md` +- `scripts/verify.ps1` + +## Out of Scope + +- 与飞书无关的重构 +- 全仓格式化 +- 深改现有通用 WebView 架构 +- 完整的跨平台本地安装探测体系 + +## 相关文件 + +- `frontend/app/view/webview/webview.tsx` +- `frontend/app/view/feishuview/feishuview.tsx` +- `frontend/app/view/feishuweb/feishuweb.tsx` +- `emain/emain-feishu.ts` +- `emain/emain-ipc.ts` +- `emain/preload.ts` +- `pkg/wconfig/defaultconfig/widgets.json` +- `pkg/wconfig/defaultconfig/settings.json` +- `pkg/wconfig/settingsconfig.go` +- `schema/settings.json` + +## 已知事实 + +- 默认快捷入口由 `pkg/wconfig/defaultconfig/widgets.json` 提供 +- 通用网页视图基于 `WebViewModel + ` +- 本机已存在 `feishu://` 与 `lark://` 协议注册 + +## 关键未知项 + +- 真实飞书账号登录后的完整聊天流程 +- 非 Windows 平台的本地安装路径探测效果 + +## 验收标准 + +- 点击飞书入口后,本地 App 优先,失败回退到应用内网页 +- 用户可见地提供 `Feishu App` / `Feishu Web` 双入口,并可直接隐藏当前 `Feishu Web` 卡片 +- 飞书弹出的新窗口继承 `persist:feishu` +- `scripts/verify.ps1` 通过 +- `.harness` 工件足以支持后续续跑 + +## 验证命令 + +```powershell +scripts/verify.ps1 +``` + +## 执行建议 + +1. `Research`:确认入口、WebView、IPC 与配置链路 +2. `Plan`:确定最小补强方案 +3. `Implement`:只改飞书相关文件与 Harness 工件 +4. `Verify`:跑 verify,并尽量补一轮最小 smoke + +## 风险 + +- 飞书站点后续策略变化可能影响应用内网页模式 +- 登录/授权弹窗链路仍需要真实账号验证 + +## 回滚思路 + +- 回滚飞书相关新增文件与配置项 +- 移除 `.harness` 文件与 `scripts/verify.ps1` +- 恢复到只保留基础飞书入口的状态 diff --git a/.harness/task-packets/TASK-TERM-001.md b/.harness/task-packets/TASK-TERM-001.md new file mode 100644 index 0000000000..986e814b47 --- /dev/null +++ b/.harness/task-packets/TASK-TERM-001.md @@ -0,0 +1,113 @@ +# TASK-TERM-001 + +## 任务标题 + +终端滚轮与输入法位置专项修复 + +## 目标 + +以原作者/官方终端逻辑为基线,修复 Wave 终端在 Codex/Agent 会话中的滚轮不可用、输入法候选框/组合文本位置错误问题,并建立可复测的验证闭环。 + +## In Scope + +- `frontend/app/view/term/termwrap.ts` +- `frontend/app/view/term/termutil.ts` +- `frontend/app/view/term/termutil.test.ts` +- `frontend/app/view/term/fitaddon.ts` +- 仅在必要时触及 `frontend/app/view/term/osc-handlers.ts` +- Electron 本地 smoke / `agent-browser` 可达性验证 + +## Out of Scope + +- 非终端区域 UI 重构 +- 全仓格式化或命名调整 +- Feishu/WebView/AI Panel 等无关模块 +- 新增大规模可配置系统 + +## 子任务 + +1. **官方基线对照**:对比 `HEAD`、关键历史提交和上游原作者逻辑,确认滚轮、IME、fit、scroll-to-bottom 的原始设计。 +2. **滚轮根因定位**:区分 normal buffer scrollback、alternate buffer 应用内滚动、mouse tracking 三类场景,避免互相覆盖。 +3. **IME 根因定位**:确认 xterm textarea/composition-view 的真实坐标来源,优先遵循 xterm 原生逻辑,只对明确失效场景做最小兜底。 +4. **历史恢复验证**:检查 `cache:term:full`、`term`、`heldData`、`viewportY/baseY/cursorY` 是否导致恢复后状态滞后。 +5. **可执行验证**:跑单测、`scripts/verify.ps1`、`electron-builder --win dir`,并尽量用 `agent-browser` 连 Electron 做截图/滚轮 smoke。 + +## 验收标准 + +- 普通终端历史可以用鼠标滚轮上下滚动。 +- Codex/Agent 会话中的滚轮行为与 Windows Terminal 尽量一致:normal buffer 走 scrollback,alternate buffer 尊重应用 mouse tracking。 +- 中文输入法候选框/组合文本不再出现在左上角或历史 viewport 位置。 +- 调整窗口大小或从历史恢复后,当前输入位置和可视 viewport 不错位。 +- 验证命令通过,并记录无法自动化验证的原因。 + +## 验证命令 + +```powershell +npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts +powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1 +npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir +``` + +## Electron Smoke 计划 + +1. 关闭旧 Wave 进程。 +2. 用 `make\win-unpacked\Wave.exe --remote-debugging-port=9222` 启动。 +3. 用 `agent-browser` 连接 CDP。 +4. 截图确认主窗口目标可达。 +5. 如果 CDP 能稳定拿到主 UI,执行滚轮/输入焦点 smoke;如果只能拿到 `about:blank` 或黑屏,记录为工具限制,不把它当作通过依据。 + +## 2026-04-20 最终运行态结果 + +- 官方基线:已重新 fetch `upstream/main`,`termwrap.ts` 以官方主线为基线,只保留本任务必要差异。 +- PTY 尺寸:最新包中 PowerShell `[Console]::WindowHeight; [Console]::WindowWidth` 返回 `113 / 208`。 +- Codex IME:启动 `codex` 后自动锚定到对话中部,textarea 为 `top=1116px / left=864px / zIndex=5`;退出 Codex 后锚点清理。 +- 滚轮:normal buffer wheel smoke 中 `viewportY` 从 `3596` 变为 `3556`,滚轮事件被终端消费。 +- 验证:`vitest`、`scripts/verify.ps1`、`electron-builder --win dir` 均通过,最新产物为 `make\win-unpacked\Wave.exe`,时间 `2026-04-20 18:30:59`。 + +## 2026-04-21 分发产物补充验证 + +- 已重新生成并验证完整分发产物,而不再只验证 `win-unpacked`: + - `make\Wave-win32-x64-2026.4.17-1.zip`:`2026-04-21 10:57:29` + - `make\Wave-win32-x64-2026.4.17-1.exe`:`2026-04-21 10:58:22` + - `make\Wave-win32-x64-2026.4.17-1.exe.blockmap`:`2026-04-21 10:58:25` +- `zip` 解压到 `make\zip-smoke` 后运行,`location.href` 指向 `make/zip-smoke/resources/app.asar/...`,PowerShell 返回 `113 / 208`,Codex IME 锚到 `top=1116px / left=864px / zIndex=5`。 +- `installer exe` 静默安装到 `make\installer-smoke` 后运行,`location.href` 指向 `make/installer-smoke/resources/app.asar/...`,PowerShell 返回 `113 / 208`,wheel smoke 中 `viewportY` 从 `3597` 变为 `3557`。 +- 产物名仍显示 `2026.4.17-1` 仅因为当前 `package.json` 版本号未变,不代表内容仍是 `2026-04-17` 的旧代码。 + +## 2026-04-21 版本号纠偏 + +- 已将分发版本从 `2026.4.17-1` 更新为 `2026.4.21-1`,避免用户继续误测旧文件名。 +- 新产物: + - `make\Wave-win32-x64-2026.4.21-1.zip` + - `make\Wave-win32-x64-2026.4.21-1.exe` + - `make\Wave-win32-x64-2026.4.21-1.exe.blockmap` +- 新 zip 已解压并验证到 `make\zip-smoke-2026.4.21-1`,运行态路径明确指向新目录,PowerShell 返回 `113 / 208`。 +- 另外已生成目录版 `make\Wave-win32-x64-2026.4.21-1\Wave.exe`,用户可直接双击该目录下的 `Wave.exe`。 + +## 2026-04-21 滚轮与输入框对齐补充 + +- 滚轮根因确认:normal buffer wheel 兜底如果挂在 `connectElem` capture 阶段,会先于 xterm 内部 `xterm-scrollable-element` 吃掉事件。 +- 修复后:wheel 兜底改为 bubble 阶段,只在真正折算出整行滚动时才阻止默认行为。 +- 输入框根因确认:固定中线锚点不符合 Windows Terminal 参考,正确行为应当跟随当前 cursor。 +- 修复后:IME textarea/composition view 使用 `buffer.active.cursorX / cursorY` 计算 `top / left`。 +- 运行态结果:最新 `win-unpacked` 中 Codex 启动后 textarea 与 cursor 对齐;对 xterm 内部 scrollableElement 派发 wheel 后 `viewportY` 从 `3597` 变为 `3557`。 + +## 2026-04-21 移除终端历史缓存/恢复逻辑 + +- 根据用户最新要求,“历史记录/历史恢复”本身被视为错误逻辑,不再继续修补;本轮目标改为彻底停用这条链路,而不是继续优化 `cache:term:full`。 +- 前端最小范围移除: + - `frontend/app/view/term/termwrap.ts` 不再读取 `cache:term:full` + - 不再调用 `loadInitialTerminalData()` + - 不再调用 `runProcessIdleTimeout()` + - 不再通过 `SerializeAddon` + `BlockService.SaveTerminalState()` 持久化终端快照 +- 为避免初始化阶段丢实时输出,新增 `flushHeldTerminalData()`:`mainFileSubject` 订阅仍然保留,`loaded=false` 期间收到的 append 数据会先进入 `heldData`,待初始化完成后顺序回放到当前会话终端。 +- 保留范围: + - 当前会话实时输出链路 `getFileSubject(...) -> handleNewFileSubjectData(...) -> doTerminalWrite(...)` + - 已有的滚轮兜底、IME 对齐、resize/termsize 同步逻辑 +- 验证结果: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` 通过 + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` 通过 + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` 通过 + - 代码检索确认 `termwrap.ts` 中已不存在 `cache:term:full`、`SaveTerminalState`、`loadInitialTerminalData`、`runProcessIdleTimeout`、`processAndCacheData`、`SerializeAddon`、`fetchWaveFile` 引用 +- 当前限制: + - `agent-browser` 可通过 `agent-browser.cmd` 调用,但 PowerShell 直接执行 `agent-browser.ps1` 会被本机执行策略拦截;这属于本机策略限制,不是仓库代码问题。 diff --git a/.harness/task-packets/TASK-TERM-002.md b/.harness/task-packets/TASK-TERM-002.md new file mode 100644 index 0000000000..30012b9db7 --- /dev/null +++ b/.harness/task-packets/TASK-TERM-002.md @@ -0,0 +1,96 @@ +# TASK-TERM-002: 终端回归 Smoke 自动化闭环 + +## Goal + +建立一个可重复执行的本地 smoke 脚本,降低后续滚轮、IME、历史恢复、旧包误测问题的排查成本。 + +## In Scope + +- 新增 `scripts/smoke-terminal.ps1` +- 覆盖最新 `make\win-unpacked\Wave.exe` 启动路径、时间戳、SHA256 +- 校验 `termwrap.ts` 中历史缓存/恢复链路仍处于停用状态 +- 通过 Electron CDP 运行终端 DOM 级 smoke: + - 终端对象可达 + - 当前 rows/cols 可读 + - runtime 中不存在历史缓存方法 + - normal buffer wheel 能改变 `viewportY` + - 强制 Agent IME 场景时 helper textarea 能对齐当前 cursor +- 输出 smoke JSON 与截图到 `D:\files\AI_output\waveterm-terminal-smoke` + +## Out Of Scope + +- 不修改终端业务逻辑 +- 不改滚轮/IME 策略 +- 不清理 Go 后端历史缓存死代码 +- 不生成 nsis/zip 正式分发包 +- 不依赖真实系统中文输入法候选窗截图作为唯一通过条件 + +## Write Set + +- `.harness/task-packets/TASK-TERM-002.md` +- `scripts/smoke-terminal.ps1` +- `.harness/progress.md` +- `.harness/feature-list.json` + +## Required Context + +- `AGENTS.md` +- `CLAUDE.md` +- `.harness/progress.md` +- `.harness/task-packets/TASK-TERM-001.md` +- `frontend/app/view/term/termwrap.ts` + +## Steps + +1. 新增 smoke 脚本,支持安全关闭仓库 `make` 目录下的旧 Wave 进程。 +2. 启动最新 `make\win-unpacked\Wave.exe --remote-debugging-port=`。 +3. 通过 CDP `/json/list` 定位主 page target,并用 `Runtime.evaluate` 执行终端 smoke。 +4. 记录静态检查、运行态检查、截图、进程路径、产物时间戳和 SHA256。 +5. 运行脚本与现有验证命令,更新 `.harness` 结果。 + +## Acceptance Criteria + +- `scripts/smoke-terminal.ps1` 可从仓库根目录重复执行。 +- 脚本默认不关闭仓库外的 Wave 进程;需要时可显式传 `-KillAllWave`。 +- 脚本能确认 `termwrap.ts` 不再包含历史恢复/缓存关键入口。 +- 当当前 workspace 有终端 block 时,脚本能验证 wheel 和 IME DOM 对齐。 +- 脚本输出 JSON 结果和截图路径,失败时给出明确原因。 + +## Verification + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave +npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts +powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1 +``` + +## Rollback Or Fallback + +- 删除 `scripts/smoke-terminal.ps1` 即可回滚验证脚本。 +- 若 CDP 在当前机器不稳定,可保留静态检查与路径/SHA256 校验,手动执行终端滚轮/IME 复测。 + +## Remaining Risks + +- 系统级中文输入法候选窗无法仅靠 CDP 完整验证;本脚本以 xterm helper textarea/composition view DOM 坐标作为自动化代理指标。 +- 如果当前 Wave workspace 没有终端 block,运行态终端 smoke 会失败,需要用户先打开一个终端 block 或以 `-RequireTerminal:$false` 跑路径/静态检查。 +- Electron 单实例行为可能导致新启动请求转发到既有 Wave 实例;脚本会优先关闭仓库 `make` 目录下的旧 Wave 进程,并在路径不匹配时提示。 + +## 2026-04-21 执行结果 + +- 已新增 `scripts/smoke-terminal.ps1`,使用 PowerShell 直连 Electron CDP,不依赖 `agent-browser.ps1`,绕过本机 PowerShell execution policy 对全局 npm shim 的限制。 +- 首次执行 smoke 抓到真实问题:源码已移除历史链路,但当时的 `make\win-unpacked` 运行态仍暴露 `loadInitialTerminalData` / `processAndCacheData` / `runProcessIdleTimeout`,说明产物 bundle 仍是旧的;脚本输出失败结果到 `D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-161932.json`。 +- 串行重跑构建并刷新目录包后,第二次 smoke 通过: + - 结果 JSON:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-162451.json` + - 截图:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-162451.png` + - `make\win-unpacked\Wave.exe` 时间:`2026-04-21T16:23:14.5073581+08:00` + - SHA256 前缀:`0A9EC1A4814CB56A` + - runtime `window.term` 可达:`true` + - runtime 历史方法:空 + - runtime `serializeAddon`:`false` + - wheel smoke:`viewportY 127 -> 87` + - IME smoke:`topDelta=0`、`leftDelta=0` +- 验证通过: + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` diff --git a/.harness/task-packets/TASK-TERM-003.md b/.harness/task-packets/TASK-TERM-003.md new file mode 100644 index 0000000000..3f52eebe98 --- /dev/null +++ b/.harness/task-packets/TASK-TERM-003.md @@ -0,0 +1,104 @@ +# TASK-TERM-003: 多终端焦点与真实事件路径 Smoke 补强 + +## Goal + +把当前单终端 DOM 级 smoke 扩展为更接近用户真实操作路径的多终端验证闭环,优先复现并观测“上方 Codex 终端输入框错位、滚轮失效、下方 PowerShell 终端焦点干扰”这类 split-pane 场景。 + +## In Scope + +- 扩展 `scripts/smoke-terminal.ps1` +- 识别页面上多个 terminal block,而不是只依赖 `window.term` +- 记录当前真实 focus owner、active terminal、textarea/composition-view 所属 terminal +- 将 wheel 断言从内部 `.xterm-scrollable-element` 直派发,升级为对 terminal 外层可交互容器派发 +- 增加 split-pane 场景断言: + - 上下至少两个 terminal block 同时存在时 + - 只有真实 active terminal 允许改 IME helper 位置 + - 滚轮应作用于当前 active terminal,而不是错误 terminal +- 更新 `.harness/*` + +## Out Of Scope + +- 不修改 `frontend/app/view/term/termwrap.ts` 业务逻辑 +- 不切换到 xterm 官方 hook +- 不处理后端历史缓存死代码 +- 不要求系统级 IME 候选窗截图完全自动化 + +## Write Set + +- `scripts/smoke-terminal.ps1` +- `.harness/task-packets/TASK-TERM-003.md` +- `.harness/progress.md` +- `.harness/feature-list.json` +- 如需要:`.harness/unknowns.md` + +## Required Context + +- `frontend/app/view/term/termwrap.ts` +- `scripts/smoke-terminal.ps1` +- `.harness/task-packets/TASK-TERM-002.md` +- 用户 2026-04-21 最新截图反馈 + +## Steps + +1. 扩展 smoke 脚本枚举页面上所有 terminal 容器与对应 runtime 对象。 +2. 为每个 terminal 采集: + - rows/cols + - buffer type / viewportY + - textarea style + - 是否聚焦 + - 所在 block/tab 的可见性与几何位置 +3. 增加 split-pane 场景断言: + - 当前 focus terminal 与被重定位的 IME helper 必须一致 + - 非 active terminal 不得改 textarea/composition-view 坐标 +4. 将 wheel smoke 改为更接近真实用户路径: + - 优先向 terminal 外层交互容器派发事件 + - 只把内部 scrollableElement 作为调试回退信息 +5. 输出更详细 JSON,包含每个 terminal 的 id、几何位置、focus owner、wheel target 与命中结果。 + +## Acceptance Criteria + +- 脚本能在同一页面发现至少 2 个 terminal block 时输出多 terminal 明细。 +- 脚本能明确指出当前 active/focused terminal。 +- 脚本能断言 IME helper 是否被错误 terminal 改写。 +- 脚本能区分“内部 xterm 可滚”与“真实外层用户路径不可滚”的差异。 +- 失败日志能直接告诉后续修复包是“焦点归属问题”还是“wheel 路由问题”。 + +## Verification + +```powershell +powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave +powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave -KeepApp +``` + +- 手动检查: + - 上下两个 terminal 都存在时,确认 smoke JSON 中有两个 terminal 项 + - 确认 active terminal 与 IME helper 所属 terminal 一致 + +## Rollback Or Fallback + +- 若多 terminal runtime 无法稳定枚举,保留现有单 terminal smoke,并把多 terminal 相关结果记录为 `blocked` +- 如真实 wheel 路径无法自动命中,则同时输出“外层路径结果”和“内部 scrollableElement 结果”,避免假阳性 + +## Remaining Risks + +- 当前页面可能未暴露所有 terminal runtime 的全局引用;脚本可能需要通过 DOM 结构和私有属性探测,稳定性低于单实例 `window.term` +- Electron/CDP 的真实鼠标事件路径仍可能与系统级滚轮略有差异,但比直接打到 `.xterm-scrollable-element` 更贴近用户路径 + +## 2026-04-21 执行结果 + +- 已将运行态表达式拆到 `scripts/smoke-terminal.runtime.js`,并让 `scripts/smoke-terminal.ps1` 负责: + - 自动注册多个 `TermWrap` 实例,而不是只依赖 `window.term` + - 必要时通过 `RpcApi.CreateBlockCommand(... targetaction=splitdown ...)` 自动创建第二个终端 + - 输出每个 terminal 的 `blockId`、几何位置、focus owner、textarea/composition-view 样式、runtime rows/cols/bufferType/viewportY + - 区分 `elementFromPoint` 外层 wheel 路径与内部 `.xterm-scrollable-element` fallback + - 截图后自动删除 smoke 临时创建的 block,避免污染用户工作区 +- 执行结果: + - 命令:`powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave` + - 结果 JSON:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-165710.json` + - 截图:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-165710.png` + - 默认 verify:`powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` 通过 +- 本轮 smoke 结论: + - 多 terminal 采集已生效:页面 DOM 中发现 `3` 个 terminal,运行态已稳定拿到 `2` 个 `TermWrap` + - `wheel` 两个场景都通过,外层真实路径与内部 fallback 都只作用于目标 terminal + - `IME ownership` 明确失败,诊断为 `ime_wrong_terminal` + - 失败细节显示:当目标 terminal 已切换时,仍有非目标 terminal 保留 helper textarea 的 `top/left/zIndex`,符合“输入框位置串 terminal”的根因方向 diff --git a/.harness/task-packets/TASK-TERM-004.md b/.harness/task-packets/TASK-TERM-004.md new file mode 100644 index 0000000000..f5719311aa --- /dev/null +++ b/.harness/task-packets/TASK-TERM-004.md @@ -0,0 +1,127 @@ +# TASK-TERM-004: 将 Wheel / IME 修复收口到 xterm 官方扩展点与焦点归属 + +## Goal + +基于 `TASK-TERM-003` 复现结果,重构当前 `termwrap.ts` 的滚轮与 IME 兜底逻辑:从“外层 DOM patch + 文本正则猜测”收口到“xterm 官方扩展点 + 当前真实焦点 terminal ownership”。 + +## In Scope + +- `frontend/app/view/term/termwrap.ts` +- 必要时:`frontend/app/view/term/termutil.ts` +- 必要时:`frontend/app/view/term/termutil.test.ts` +- 必要时:`scripts/smoke-terminal.ps1` +- 必要时:`scripts/smoke-terminal.runtime.js` +- `.harness/*` + +## Out Of Scope + +- 不升级整套 xterm 大版本 +- 不做后端 terminal cache API 清理 +- 不改 fit / resize 无关逻辑 +- 不调整非 terminal 模块 UI + +## Write Set + +- `frontend/app/view/term/termwrap.ts` +- `frontend/app/view/term/termutil.ts` +- `frontend/app/view/term/termutil.test.ts` +- `scripts/smoke-terminal.ps1` +- `scripts/smoke-terminal.runtime.js` +- `.harness/task-packets/TASK-TERM-004.md` +- `.harness/progress.md` +- `.harness/feature-list.json` + +## Required Context + +- `frontend/app/view/term/termwrap.ts` +- `scripts/smoke-terminal.ps1` +- `.harness/task-packets/TASK-TERM-003.md` +- xterm API `attachCustomWheelEventHandler` +- xterm issue `#5734` +- xterm PR `#5759` + +## Steps + +1. 用 `attachCustomWheelEventHandler` 替换当前外层 `connectElem.addEventListener("wheel", ...)` 兜底路径。 +2. 将 normal buffer / alternate buffer / mouse tracking 的 wheel 分流收口到 xterm hook 中,避免依赖 bubble 阶段 `event.defaultPrevented` 的不稳定时机。 +3. 引入真实 terminal focus ownership: + - 只有当前 active/focused terminal 可重定位 textarea/composition-view + - 非 active terminal 一律清理 override +4. 将 IME 兜底改为更接近 xterm 官方修复点: + - 优先 compositionstart / focus 时同步 + - onRender 只保留最小补偿,不再作为主路径 +5. 将 Agent/Codex 文本正则识别降级为 fallback,而不是 primary trigger。 +6. 用 `TASK-TERM-003` 的多 terminal smoke 验证修复是否真正覆盖 split-pane 场景。 + +## Acceptance Criteria + +- 在多 terminal split-pane 场景下,只有当前 active terminal 的输入框/IME helper 跟随 cursor。 +- normal buffer Codex 会话中,真实用户滚轮路径可稳定改变正确 terminal 的 viewportY。 +- alternate buffer / mouse tracking 场景不被误拦截。 +- 现有“去历史恢复”逻辑保持不回退。 + +## Verification + +```powershell +npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts +powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1 +powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave +npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir +``` + +- 手动检查: + - 上方 Codex 终端 + 下方 PowerShell 终端同时存在时,输入框不串位 + - 当前活跃终端滚轮有效,非活跃终端不被误滚动 + +## Rollback Or Fallback + +- 若 `attachCustomWheelEventHandler` 无法满足全部场景,可保留当前 DOM 兜底作为临时 fallback,但必须把触发条件收紧到“xterm 未消费且 terminal 为 active” +- 若 IME 官方生命周期补偿不足,可保留现有 regex 检测作为 secondary fallback,不再作为 primary path + +## Remaining Risks + +- xterm 6.0.0 本身在 Electron + AI CLI + IME 场景已有已知问题,即使收口到官方扩展点,也可能仍需最小本地补丁 +- 多 terminal runtime 对象的生命周期与 DOM 绑定可能没有公开 API,只能通过现有 Wave 封装保持 ownership + +## 2026-04-21 执行结果 + +- 已在 `frontend/app/view/term/termwrap.ts` 完成两类收口: + - `wheel`:改为优先走 xterm 官方 `attachCustomWheelEventHandler`,只在 `mouseTrackingMode !== "none"` 且 `normal` buffer 时保留一个极窄的 capture fallback,避免再用粗粒度外层 bubble patch 抢事件 + - `IME`:新增 `TermWrap.liveInstances` 与全局 `imeOwnerBlockId`,把 helper textarea / composition-view 的 override 严格限制到当前 owner terminal;在 `focus` / `compositionstart` 时先调用 xterm 私有 `_syncTextArea()`,对齐官方 issue/PR 的修复点 +- 为了让回归闭环能准确验证这次修复,还补强了 smoke: + - `scripts/smoke-terminal.runtime.js` 现在优先从 `TermWrap.liveInstances` 枚举现存终端 + - 当前工作区没有 terminal 时,会自动创建首个 shell terminal 再继续 split-pane smoke + - IME ownership 断言从“任意 inline style”收紧为“可见 IME override(忽略 xterm 默认 z-index:-5)” +- 验证结果: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` 通过 + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` 通过 + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` 通过,最新 `make\win-unpacked\Wave.exe` 时间为 `2026-04-21T17:16:35.2754971+08:00` + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave` 通过 + - JSON:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-172449.json` + - PNG:`D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-172449.png` +- 最终 smoke 结论: + - `dom terminal=2` + - `known runtime=2` + - `wheel diagnoses=ok` + - `ime diagnoses=ok` + +## 2026-04-21 真实滚轮补充验证 + +- 用户继续反馈“依然没有滚轮”后,新增 `scripts/smoke-terminal-real-wheel.ps1`,避免只用 JS `WheelEvent` 误判: + - 通过 CDP `Input.dispatchMouseEvent(type=mouseWheel)` 注入真实鼠标滚轮事件; + - 对 split-pane 中两个 terminal 分别测试 `screen-center` 与 `screen-right`; + - 通过运行态 `viewportY` / scroll state 断言目标 terminal 是否实际滚动,且不串到其他 terminal。 +- 最新成品验证结果: + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal-real-wheel.ps1 -KillExistingRepoWave` 通过; + - JSON:`D:\files\AI_output\waveterm-terminal-smoke\terminal-real-wheel-20260421-184158.json` + - PNG:`D:\files\AI_output\waveterm-terminal-smoke\terminal-real-wheel-20260421-184158.png` + - 2 个 terminal 的 `screen-center` / `screen-right` 均为 `diagnosis=ok`。 +- 为避免继续混用旧包,本轮将版本号提升到 `2026.4.21-2`,并重新生成: + - `make\Wave-win32-x64-2026.4.21-2.exe` + - `make\Wave-win32-x64-2026.4.21-2.zip` + - `make\win-unpacked\Wave.exe` +- 补充验证: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` 通过; + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` 通过; + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir nsis zip` 通过; + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave` 通过,JSON 为 `D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260421-184339.json`。 diff --git a/.harness/task-packets/TASK-TERM-005.md b/.harness/task-packets/TASK-TERM-005.md new file mode 100644 index 0000000000..6842f81a4d --- /dev/null +++ b/.harness/task-packets/TASK-TERM-005.md @@ -0,0 +1,143 @@ +# TASK-TERM-005: Codex / alternate buffer 全视图滚轮收口 + +## Goal + +彻底解决“IME 已恢复但 Codex pane / 交互态终端滚轮仍失效”的问题,将当前只覆盖 `normal buffer` 的滚轮补丁升级为“按 active terminal 收口的全视图 wheel router”,并让回归 smoke 明确覆盖 Codex / alternate buffer / mouse tracking 场景。 + +## In Scope +- `frontend/app/view/term/termwrap.ts` +- 必要时:`frontend/app/view/term/termutil.ts` +- 必要时:`frontend/app/view/term/termutil.test.ts` +- 必要时:`scripts/smoke-terminal.runtime.js` +- 必要时:`scripts/smoke-terminal.ps1` +- 必要时:`scripts/smoke-terminal-real-wheel.ps1` +- `.harness/*` + +## Out Of Scope +- 不升级整个 `@xterm/xterm` 大版本 +- 不恢复 terminal 历史缓存 / 恢复逻辑 +- 不改无关 block / workspace UI +- 不顺手重构 term 以外模块 + +## Write Set +- `frontend/app/view/term/termwrap.ts` +- `frontend/app/view/term/termutil.ts` +- `frontend/app/view/term/termutil.test.ts` +- `scripts/smoke-terminal.runtime.js` +- `scripts/smoke-terminal.ps1` +- `scripts/smoke-terminal-real-wheel.ps1` +- `.harness/task-packets/TASK-TERM-005.md` +- `.harness/progress.md` +- `.harness/feature-list.json` + +## Required Context +- `frontend/app/view/term/termwrap.ts` +- `frontend/app/view/term/termutil.ts` +- `scripts/smoke-terminal.runtime.js` +- `scripts/smoke-terminal-real-wheel.ps1` +- `node_modules/@xterm/xterm/src/browser/CoreBrowserTerminal.ts` +- `node_modules/@xterm/xterm/src/browser/Viewport.ts` +- `https://github.com/xtermjs/xterm.js/blob/6.0.0/src/browser/CoreBrowserTerminal.ts` +- `https://github.com/xtermjs/xterm.js/blob/6.0.0/src/browser/Viewport.ts` +- `https://github.com/wavetermdev/waveterm/blob/main/frontend/app/view/term/termwrap.ts` + +## Steps +1. 扩充 smoke,记录并断言目标 terminal 的 `buffer.active.type`、`mouseTrackingMode`、命中元素与右侧滚动区域路径,新增 Codex / alternate buffer 失败诊断。 +2. 将 wheel 路由从“只处理 `normal buffer`”改为“按 active terminal 收口”: + - `normal buffer` 继续走 scrollback + - `alternate buffer` / Codex 交互态走显式 fallback,而不是直接放过 +3. 收紧命中区域逻辑,确保鼠标位于可见输出区域与右侧可滚动区域时,事件都会落到当前 active terminal,而不会串到非 active terminal。 +4. 保持 IME ownership 修复不回退,避免“滚轮修好,输入法又坏”。 +5. 用 split-pane 上方 Codex / 下方 PowerShell 的真实场景做 smoke 与人工复核。 + +## Acceptance Criteria +- 右侧 Codex pane 在鼠标位于可见输出区域时可稳定滚动。 +- split-pane 场景中只滚动当前 active terminal,不串滚到另一个 terminal。 +- `alternate buffer` / `mouseTrackingMode !== none` 时不再出现“IME 正常但滚轮完全失效”。 +- IME 输入框位置修复保持有效,不回退。 +- smoke 明确覆盖 `normal` 与 `non-normal` 路径,而不是把 `non-normal-buffer` 当成未覆盖区域。 + +## Verification +- `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` +- `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` +- `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave` +- `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal-real-wheel.ps1 -KillExistingRepoWave` +- `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` +- 手动检查: + - 上方 Codex pane 与下方 PowerShell pane 同时存在时,右侧 Codex 输出区滚轮可用 + - PowerShell pane 在未激活时不被误滚动 + - 输入法候选与输入框位置不回退 + +## Rollback Or Fallback +- 若全视图 wheel router 风险过高,可先在 active terminal 范围内做最小 fallback,但必须覆盖 Codex / alternate buffer。 +- 若 Codex pane 的滚动其实来自非 xterm 区域,应在 smoke 中明确标出真实命中元素,并将方案收缩为“xterm terminal + Codex pane 可见滚动区域双路径路由”。 + +## Remaining Risks +- xterm 6.0.0 的 wheel / mouse protocol 与 Electron 命中区域组合较脆弱,可能仍需保留极小本地补丁。 +- Codex pane 可能不是纯粹的 `normal buffer` 场景,需以真实 smoke 结果为准,而不是继续依赖当前假设。 + +## Result + +- 状态:`passing` +- 根因: + - 原先滚轮逻辑只把 `normal buffer` 作为成功路径,Codex / alternate buffer / mouse tracking 场景进入后会直接失去滚轮。 + - 旧 smoke 把 `non-normal-buffer` 当成失败而不是覆盖目标,导致回归闭环没有覆盖真实交互态。 +- 修复: + - `frontend/app/view/term/termwrap.ts` 改为按 active terminal 收口的 wheel router + - `normal buffer` 继续走 `terminal.scrollLines(...)` + - `alternate buffer` / Codex 交互态改走 `PageUp/PageDown` fallback + - `scripts/smoke-terminal.runtime.js` 与 `scripts/smoke-terminal-real-wheel.ps1` 补齐 alternate / mouse tracking / 真实 `mouseWheel` 验证 +- 最新验证: + - `npm.cmd exec vitest -- run frontend/app/view/term/termutil.test.ts frontend/app/view/term/osc-handlers.test.ts` + - `powershell -ExecutionPolicy Bypass -File .\scripts\verify.ps1` + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal.ps1 -KillExistingRepoWave` + - `powershell -ExecutionPolicy Bypass -File .\scripts\smoke-terminal-real-wheel.ps1 -KillExistingRepoWave` + - `npm.cmd exec electron-builder -- -c electron-builder.config.cjs -p never --win dir` +- 最新真实结果: + - JSON:`D:\files\AI_output\waveterm-terminal-smoke\terminal-real-wheel-20260422-110300.json` + - 截图:`D:\files\AI_output\waveterm-terminal-smoke\terminal-real-wheel-20260422-110300.png` + - `make\win-unpacked\Wave.exe` 时间:`2026-04-22T10:59:49.3611836+08:00` + - `make\win-unpacked\Wave.exe` SHA256:`665EEF5E7CC24CCA7B3E27543AACC59B42076542DE1337156364DFB51C90838C` + - `runtime.wheel.allPassed = true` + - `runtime.ime.allPassed = true` + - `realWheel.allPassed = true` + - 2 个 terminal 的 `screen-center` / `screen-right` 全部 `ok` +- 额外核查: + - 本机常见安装路径仅发现仓库内两份 `Wave.exe` + - 未发现额外安装版 `Wave.exe` 干扰当前验证 +- 2026-04-22 输出历史补充修复: + - 用户在最新手测中确认:滚轮和 IME 已恢复,但 Codex / Agent 输出只能回看一页,前面的内容会被“吞掉” + - 根因收敛为:Agent TUI 的 `alternate screen` 与 `CSI 3 J` 清空 scrollback 路径仍会把历史收窄到当前页 + - 本轮在 `frontend/app/view/term/termwrap.ts` 增加 agent-TUI 特判: + - 对 `codex|claude|opencode|aider|gemini|qwen` 这类命令,抑制 `47/1047/1049` alternate screen 进入 + - 对 agent repaint 场景抑制 `CSI 3 J` 清空 scrollback + - 新增 smoke 覆盖: + - `scripts/smoke-terminal.runtime.js` 新增 `agent-repaint-scrollback` 场景 + - 断言种子历史仍在、最新 repaint 内容可见、active buffer 仍为 `normal` + - 最新验证结果: + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260422-112409.json` + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-real-wheel-20260422-112519.json` + - `make\win-unpacked\Wave.exe` 时间:`2026-04-22T11:22:55.2418461+08:00` + - `make\win-unpacked\Wave.exe` SHA256:`BA03754F45CB5DF8BF0E7FF3FF9625E414AAB5A45C2DB1DC37A65B95800194E4` + - `runtime.agentScrollback.allPassed = true` + - `runtime.wheel.allPassed = true` + - `runtime.ime.allPassed = true` + - `realWheel.allPassed = true` +- 2026-04-22 实时滚动回退修正: + - 后续真实手测证明:上面这套“强行保历史”的方向不适合 Codex / TUI 实际交互,会把**输出进行中的实时滚动**搞坏 + - 因此本轮已明确回退那部分 agent-TUI 特判,改回更贴近 xterm 官方的 wheel 语义: + - `normal buffer`:Wave 接管 wheel 做 scrollback + - `alternate buffer` 且无 app-side wheel:走 xterm 官方箭头 fallback + - `mouse-tracking`:交回应用自己处理 wheel + - 新增/更新 smoke: + - `alternateScenarios` 现在断言箭头 fallback + - `mouseTrackingScenarios` 断言会发出真实鼠标协议,而不是被 Wave 吞掉 + - 最新验证结果: + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-smoke-20260422-114610.json` + - `D:\files\AI_output\waveterm-terminal-smoke\terminal-real-wheel-20260422-114631.json` + - `make\win-unpacked\Wave.exe` 时间:`2026-04-22T11:45:35.2099308+08:00` + - `make\win-unpacked\Wave.exe` SHA256:`3A535573D27CC7F34D1C12931283AA5B0127229F7901C7A52238796D6A837AF6` + - `runtime.wheel.allPassed = true` + - `runtime.ime.allPassed = true` + - `runtime.wheel.mouseTrackingScenarios[*].mouseSequenceSent = true` + - `realWheel.allPassed = true` diff --git a/.harness/unknowns.md b/.harness/unknowns.md new file mode 100644 index 0000000000..028e0a7c90 --- /dev/null +++ b/.harness/unknowns.md @@ -0,0 +1,44 @@ +# Unknowns + +## 未解决 / 待验证项 + +1. 飞书真实登录流程是否会在所有账号态下稳定走完 + - 当前证据:代码已支持本地 App 优先与同分区新窗口 + - 缺口:缺少真实账号登录 smoke + +2. 飞书聊天页在 Electron `webview` 中是否存在站点策略变更风险 + - 当前证据:已能以网页容器方式接入,且有网页兜底 + - 缺口:缺少长时间运行与多页面跳转验证 + +3. 非 Windows 平台的本地 App 自动发现策略 + - 当前证据:协议方式跨平台更通用 + - 缺口:注册表 / 常见路径探测目前主要覆盖 Windows + +4. 当前本地开发环境为何缺少可用的 `WCLOUD_ENDPOINT` + - 当前证据:直接前台启动 Electron 时,日志显示 `invalid wcloud endpoint, WCLOUD_ENDPOINT not set or invalid`,随后 `wavesrv` 退出 + - 缺口:尚未确认这是开发机环境要求,还是仓库当前 dev 启动约束 + +5. 中间 Codex pane 在“持续输出期间”的真实滚动命中区域到底是谁 + - 当前证据:用户多轮截图都集中指向中间 Codex pane;现有 smoke 只覆盖 `screen-center` / `screen-right` 的静态点位与 seed scrollback,尚未覆盖“输出进行中”的真实交互路径 + - 缺口:还没有拿到持续输出期间 `elementFromPoint`、active terminal、buffer type、mouseTrackingMode、scroll container 命中链路的实测证据 + +6. IME owner 在多 pane + 持续输出场景下是否仍会漂移 + - 当前证据:现有实现通过 `TermWrap.liveInstances` + `imeOwnerBlockId` 管理 helper textarea;已有 smoke 能发现静态 ownership 问题,但依赖 monkey patch `shouldAnchorImeForAgentTui` + - 缺口:还没有证明真实 Codex 输出期间,IME helper / composition-view ownership 会稳定跟随当前活动 pane,而不是在中间 pane 与其他 pane 间串位 + +7. 真实 Codex / agent pane 是否和“纯 xterm middle pane”走的是同一条滚动链路 + - 当前证据:`terminal-smoke-20260422-143509.json` 与 `terminal-real-wheel-20260422-143832.json` 已证明纯 xterm 的 3-pane 中间 terminal 在持续输出期间滚轮正常,命中区域也正常 + - 缺口:这仍不能代表真实 Codex / agent TUI;当前还没有拿到“真实 agent pane”下的 `elementFromPoint`、可视滚动区域、实时 `baseY/viewportY` 与 IME ownership 采样 + +8. 真实 Codex pane 如何稳定进入“长输出进行中”状态 + - 当前证据:`terminal-codex-pane-20260422-145534.json` 已证明真实 `codex` 可以在中间 pane 被拉起,且 `shouldAnchorIme=true`、`imeOwnerBlockId` 对齐正确;`terminal-codex-pane-20260422-150151.json` 已进一步证明可通过 `codex --no-alt-screen ""` 稳定拿到长输出 + - 缺口:虽然长输出已可复现,但还没有把“raw repaint 序列 -> scrollback 不增长 -> 用户滚轮无效”这条链路转换成最小业务修复 + +9. Wave 与 Windows Terminal 对 Codex `normal-buffer + full-screen repaint` 的差异点是什么 + - 当前证据:`terminal-codex-pane-20260422-150151.json` 的 `debugTermTail` 已显示大量 `ESC[K`、`ESC[H`、`?2026h/l`,同时运行态 `baseY=0`、`length=73` + - 缺口:还缺一份与 Windows Terminal 的对照证据,来确认是 Codex 自身设计如此,还是 Wave/xterm 在这类 repaint 序列上确实少了 scrollback 保留 + +## 建议补充信息 + +- 一组可用的飞书测试账号或用户自行登录后的验证反馈 +- 目标发布平台范围:仅 Windows,还是包含 macOS / Linux diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..f8912439a5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +always response in '简体中文'. + +# Waveterm 仓库工作约定 + +本文件作用域覆盖整个仓库。 + +## 默认工作流 + +1. 先读 `.harness/progress.md` +2. 再读 `.harness/feature-list.json` +3. 再读当前任务包 `.harness/task-packets/TASK-001.md` +4. 只推进一个最小闭环,再更新 `.harness` 工件 + +## 变更边界 + +- 优先复用现有 `Electron + frontend + block/view/widget` 机制 +- 不做无关重构、不批量重命名、不全仓格式化 +- UI/行为改动尽量收敛到当前任务直接相关文件 +- 如果涉及登录态、分区、持久化或主进程能力,优先在已有 IPC / preload / view model 链路上扩展 + +## 验证 + +- 默认验证命令:`scripts/verify.ps1` +- 如果需要更强验证,再执行当前任务包里列出的额外 smoke 步骤 +- 遇到外部账号、环境差异或站点策略限制,要把阻塞写进 `.harness/unknowns.md` + +## 汇报要求 + +每轮实质性修改后,至少同步: + +- 修改文件 +- 根因或设计判断 +- 修复/实现方式 +- 验证结果 +- 剩余风险 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..a413237fe1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,24 @@ +always response in '简体中文'. + +# Waveterm 续跑说明 + +接手本仓库任务时,按下面顺序恢复上下文: + +1. `AGENTS.md` +2. `.harness/progress.md` +3. `.harness/decisions.md` +4. `.harness/unknowns.md` +5. `.harness/task-packets/TASK-001.md` + +## 续跑原则 + +- 不依赖会话记忆,优先依赖 `.harness` 工件 +- 一次只推进一个最小闭环 +- 改完先跑 `scripts/verify.ps1` +- 如果验证受阻,明确写入 `.harness/unknowns.md` + +## 当前仓库特点 + +- 前端入口和 widget 快捷入口主要在 `frontend/app/workspace/widgets.tsx` 与默认配置 `pkg/wconfig/defaultconfig/widgets.json` +- Web 内容统一复用 `frontend/app/view/webview/webview.tsx` +- 主进程能力通过 `emain/*` + `emain/preload.ts` 暴露给前端 diff --git a/README.md b/README.md index a9f406725c..1e0ac29027 100644 --- a/README.md +++ b/README.md @@ -115,3 +115,4 @@ Sponsorship helps support the time spent building and maintaining the project. ## License Wave Terminal is licensed under the Apache-2.0 License. For more information on our dependencies, see [here](./ACKNOWLEDGEMENTS.md). + diff --git a/assets/appicon-windows.ico b/assets/appicon-windows.ico new file mode 100644 index 0000000000..0db246924b Binary files /dev/null and b/assets/appicon-windows.ico differ diff --git a/cmd/server/main-server.go b/cmd/server/main-server.go index b204643ee8..2df1811451 100644 --- a/cmd/server/main-server.go +++ b/cmd/server/main-server.go @@ -5,6 +5,7 @@ package main import ( "context" + "errors" "fmt" "log" "os" @@ -67,6 +68,22 @@ const DiagnosticTick = 10 * time.Minute var shutdownOnce sync.Once +func flushFilestoreOnShutdown(ctx context.Context) { + stats, err := filestore.WFS.FlushCache(ctx) + for errors.Is(err, filestore.ErrFlushInProgress) && ctx.Err() == nil { + log.Printf("filestore flush already in progress during shutdown, waiting for it to finish\n") + time.Sleep(100 * time.Millisecond) + stats, err = filestore.WFS.FlushCache(ctx) + } + if err != nil { + log.Printf("error flushing filestore during shutdown: %v\n", err) + return + } + if stats.NumDirtyEntries > 0 { + log.Printf("filestore shutdown flush: %d/%d entries flushed\n", stats.NumCommitted, stats.NumDirtyEntries) + } +} + func init() { envFilePath := os.Getenv("WAVETERM_ENVFILE") if envFilePath != "" { @@ -85,7 +102,7 @@ func doShutdown(reason string) { sendTelemetryWrapper() // TODO deal with flush in progress clearTempFiles() - filestore.WFS.FlushCache(ctx) + flushFilestoreOnShutdown(ctx) watcher := wconfig.GetWatcher() if watcher != nil { watcher.Close() diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index d49f2da616..daf2fac119 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -4,6 +4,51 @@ const fs = require("fs"); const path = require("path"); const windowsShouldSign = !!process.env.SM_CODE_SIGNING_CERT_SHA1_HASH; +const windowsShouldEditExecutable = windowsShouldSign || process.env.WAVETERM_WINDOWS_EDIT_EXECUTABLE === "1"; +const windowsShouldBuildInstallers = windowsShouldSign || process.env.WAVETERM_WINDOWS_INSTALLERS === "1"; +const windowsTargets = windowsShouldBuildInstallers ? ["nsis", "msi", "zip"] : ["zip"]; +const localWindowsElectronDist = path.resolve(__dirname, "node_modules", "electron", "dist"); +const useLocalWindowsElectronDist = + process.platform === "win32" && fs.existsSync(path.join(localWindowsElectronDist, "electron.exe")); +const windowsIconPath = path.resolve(__dirname, "assets", "appicon-windows.ico"); +const electronBuilderManualToolsDir = + process.env.LOCALAPPDATA != null + ? path.resolve(process.env.LOCALAPPDATA, "electron-builder", "manual-tools") + : null; +const localNsisBinaryDir = + electronBuilderManualToolsDir == null + ? null + : path.join(electronBuilderManualToolsDir, "nsis-3.0.4.1"); +const localNsisResourcesDir = + electronBuilderManualToolsDir == null + ? null + : path.join(electronBuilderManualToolsDir, "nsis-resources-3.4.1"); +const hasLocalNsisDirs = + localNsisBinaryDir != null && + localNsisResourcesDir != null && + fs.existsSync(localNsisBinaryDir) && + fs.existsSync(localNsisResourcesDir); + +if (hasLocalNsisDirs) { + process.env.ELECTRON_BUILDER_NSIS_DIR ??= localNsisBinaryDir; + process.env.ELECTRON_BUILDER_NSIS_RESOURCES_DIR ??= localNsisResourcesDir; +} + +function getBuildVersion(version) { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-([A-Za-z0-9.-]+))?$/); + if (match == null) { + return version; + } + const [, major, minor, patch, prerelease] = match; + let sequence = "0"; + if (prerelease != null) { + const numericIdentifiers = prerelease.split(".").filter((part) => /^\d+$/.test(part)); + if (numericIdentifiers.length > 0) { + sequence = numericIdentifiers[numericIdentifiers.length - 1]; + } + } + return `${major}.${minor}.${patch}.${sequence}`; +} /** * @type {import('electron-builder').Configuration} @@ -12,12 +57,14 @@ const windowsShouldSign = !!process.env.SM_CODE_SIGNING_CERT_SHA1_HASH; const config = { appId: pkg.build.appId, productName: pkg.productName, + buildVersion: getBuildVersion(pkg.version), executableName: pkg.productName, artifactName: "${productName}-${platform}-${arch}-${version}.${ext}", generateUpdatesFilesForAllChannels: true, npmRebuild: false, nodeGypRebuild: false, electronCompile: false, + electronDist: useLocalWindowsElectronDist ? localWindowsElectronDist : null, files: [ { from: "./dist", @@ -96,7 +143,9 @@ const config = { afterInstall: "build/deb-postinstall.tpl", }, win: { - target: ["nsis", "msi", "zip"], + target: windowsTargets, + icon: windowsIconPath, + signAndEditExecutable: windowsShouldEditExecutable, signtoolOptions: windowsShouldSign && { signingHashAlgorithms: ["sha256"], publisherName: "Command Line Inc", @@ -104,6 +153,11 @@ const config = { certificateSha1: process.env.SM_CODE_SIGNING_CERT_SHA1_HASH, }, }, + nsis: { + installerIcon: windowsIconPath, + uninstallerIcon: windowsIconPath, + installerHeaderIcon: windowsIconPath, + }, appImage: { license: "LICENSE", }, diff --git a/emain/emain-builder.ts b/emain/emain-builder.ts index 33ca244681..16814e7c89 100644 --- a/emain/emain-builder.ts +++ b/emain/emain-builder.ts @@ -33,6 +33,7 @@ export function getAllBuilderWindows(): BuilderWindowType[] { } export async function createBuilderWindow(appId: string): Promise { + const defaultWindowChromeColor = "#0F1722"; const builderId = randomUUID(); const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); @@ -63,7 +64,7 @@ export async function createBuilderWindow(appId: string): Promise { + await callWithOriginalXdgCurrentDesktopAsync(() => electron.shell.openExternal(protocolUrl)); +} + +async function tryOpenByProtocol(): Promise { + let lastError: string | null = null; + for (const protocolUrl of FeishuProtocols) { + try { + await openProtocol(protocolUrl); + return { + opened: true, + method: `protocol:${protocolUrl}`, + fallbackUrl: FeishuFallbackUrl, + }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + if (lastError != null) { + return { + opened: false, + method: "protocol", + fallbackUrl: FeishuFallbackUrl, + error: lastError, + }; + } + return null; +} + +async function getConfiguredFeishuAppPath(): Promise { + const envPath = normalizeAppPath(process.env.WAVETERM_FEISHU_APP_PATH); + if (envPath != null) { + return envPath; + } + try { + const fullConfig = await RpcApi.GetFullConfigCommand(ElectronWshClient); + const configuredPath = normalizeAppPath(fullConfig?.settings?.["feishu:apppath"]); + if (configuredPath != null) { + return configuredPath; + } + } catch { + // ignore config lookup failures and continue with auto-discovery + } + return null; +} + +async function queryWindowsRegistry(key: string): Promise { + return await new Promise((resolve) => { + child_process.execFile("reg.exe", ["query", key, "/ve"], { windowsHide: true }, (error, stdout) => { + if (error != null || stdout == null || stdout.trim() === "") { + resolve(null); + return; + } + resolve(stdout); + }); + }); +} + +function extractExecutablePath(commandValue: string): string | null { + const quotedMatch = commandValue.match(/"([^"]+?\.exe)"/i); + if (quotedMatch?.[1]) { + return quotedMatch[1]; + } + const unquotedMatch = commandValue.match(/([A-Za-z]:\\[^\r\n]+?\.exe)\b/i); + if (unquotedMatch?.[1]) { + return unquotedMatch[1]; + } + return null; +} + +async function findWindowsRegistryAppPath(): Promise { + for (const key of WindowsRegistryKeys) { + const commandValue = await queryWindowsRegistry(key); + const executablePath = extractExecutablePath(commandValue ?? ""); + if (isLaunchablePath(executablePath)) { + return executablePath; + } + } + return null; +} + +function getCommonWindowsPaths(): string[] { + const candidatePaths = [ + process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "Feishu", "app", "Feishu.exe") : null, + process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, "Lark", "app", "Lark.exe") : null, + path.join("C:\\Program Files", "Feishu", "Feishu.exe"), + path.join("C:\\Program Files", "Lark", "Lark.exe"), + path.join("C:\\Program Files (x86)", "Feishu", "Feishu.exe"), + path.join("C:\\Program Files (x86)", "Lark", "Lark.exe"), + ]; + return [...new Set(candidatePaths.filter((candidatePath) => candidatePath != null))]; +} + +async function tryOpenByExecutablePath(appPath: string, method: string): Promise { + if (!launchExecutable(appPath)) { + return null; + } + return { + opened: true, + method, + fallbackUrl: FeishuFallbackUrl, + appPath, + }; +} + +export async function openFeishuApp(): Promise { + const protocolResult = await tryOpenByProtocol(); + if (protocolResult?.opened) { + return protocolResult; + } + + const configuredPath = await getConfiguredFeishuAppPath(); + const configuredPathResult = await tryOpenByExecutablePath(configuredPath, "configured-path"); + if (configuredPathResult != null) { + return configuredPathResult; + } + + if (unamePlatform === "win32") { + const registryAppPath = await findWindowsRegistryAppPath(); + const registryResult = await tryOpenByExecutablePath(registryAppPath, "windows-registry"); + if (registryResult != null) { + return registryResult; + } + + for (const commonPath of getCommonWindowsPaths()) { + const commonPathResult = await tryOpenByExecutablePath(commonPath, "common-path"); + if (commonPathResult != null) { + return commonPathResult; + } + } + } + + return { + opened: false, + method: "web-fallback", + fallbackUrl: FeishuFallbackUrl, + error: protocolResult?.error ?? "Unable to locate a local Feishu installation", + }; +} diff --git a/emain/emain-ipc.ts b/emain/emain-ipc.ts index 38067b7790..85cf0e8aa8 100644 --- a/emain/emain-ipc.ts +++ b/emain/emain-ipc.ts @@ -20,6 +20,7 @@ import { setWasActive, } from "./emain-activity"; import { createBuilderWindow, getAllBuilderWindows, getBuilderWindowByWebContentsId } from "./emain-builder"; +import { openFeishuApp } from "./emain-feishu"; import { callWithOriginalXdgCurrentDesktopAsync, unamePlatform } from "./emain-platform"; import { getWaveTabViewByWebContentsId } from "./emain-tabview"; import { handleCtrlShiftState } from "./emain-util"; @@ -28,6 +29,8 @@ import { createNewWaveWindow, getWaveWindowByWebContentsId } from "./emain-windo import { ElectronWshClient } from "./emain-wsh"; const electronApp = electron.app; +const WindowsTitleBarOverlayColor = "#0F1722"; +const WindowsTitleBarSymbolColor = "#EAF2FF"; let webviewFocusId: number = null; let webviewKeys: string[] = []; @@ -207,6 +210,10 @@ export function initIpcHandlers() { } }); + electron.ipcMain.handle("open-feishu-app", async () => { + return await openFeishuApp(); + }); + electron.ipcMain.on("webview-image-contextmenu", (event: electron.IpcMainEvent, payload: { src: string }) => { const menu = new electron.Menu(); const win = getWaveWindowByWebContentsId(event.sender.hostWebContents?.id); @@ -233,6 +240,30 @@ export function initIpcHandlers() { }, }) ); + menu.append(new electron.MenuItem({ type: "separator" })); + menu.append( + new electron.MenuItem({ + label: "Refresh", + accelerator: "CmdOrCtrl+R", + click: () => { + event.sender.reload(); + }, + }) + ); + menu.popup(); + }); + + electron.ipcMain.on("webview-contextmenu", (event: electron.IpcMainEvent) => { + const menu = new electron.Menu(); + menu.append( + new electron.MenuItem({ + label: "Refresh", + accelerator: "CmdOrCtrl+R", + click: () => { + event.sender.reload(); + }, + }) + ); menu.popup(); }); @@ -363,8 +394,8 @@ export function initIpcHandlers() { const ww = getWaveWindowByWebContentsId(event.sender.id); if (ww == null) return; ww.setTitleBarOverlay({ - color: unamePlatform === "linux" ? color.rgba : "#00000000", - symbolColor: color.isDark ? "white" : "black", + color: unamePlatform === "linux" ? color.rgba : WindowsTitleBarOverlayColor, + symbolColor: unamePlatform === "win32" ? WindowsTitleBarSymbolColor : color.isDark ? "white" : "black", }); } catch (e) { console.error("Error updating window controls overlay:", e); diff --git a/emain/emain-platform.ts b/emain/emain-platform.ts index 32320e4eb4..c6c98bb372 100644 --- a/emain/emain-platform.ts +++ b/emain/emain-platform.ts @@ -117,6 +117,9 @@ function getWaveConfigDir(): string { retVal = override; } else if (xdgConfigHome) { retVal = path.join(xdgConfigHome, waveDirName); + } else if (unamePlatform === "win32") { + const legacyConfigDir = path.join(app.getPath("home"), ".config", waveDirName); + retVal = existsSync(legacyConfigDir) ? legacyConfigDir : paths.config; } else { retVal = path.join(app.getPath("home"), ".config", waveDirName); } diff --git a/emain/emain-tabview.ts b/emain/emain-tabview.ts index 753a53adec..e6ec999b34 100644 --- a/emain/emain-tabview.ts +++ b/emain/emain-tabview.ts @@ -94,6 +94,7 @@ function handleWindowsMenuAccelerators( } function computeBgColor(fullConfig: FullConfigType): string { + const defaultWindowChromeColor = "#0F1722"; const settings = fullConfig?.settings; const isTransparent = settings?.["window:transparent"] ?? false; const isBlur = !isTransparent && (settings?.["window:blur"] ?? false); @@ -102,12 +103,57 @@ function computeBgColor(fullConfig: FullConfigType): string { } else if (isBlur) { return "#00000000"; } else { - return "#222222"; + return defaultWindowChromeColor; } } const wcIdToWaveTabMap = new Map(); +function getUrlHost(url: string): string | null { + try { + return new URL(url).hostname.toLowerCase(); + } catch (_) { + return null; + } +} + +function isFeishuOrLarkHost(host: string | null): boolean { + if (!host) { + return false; + } + return ( + host === "feishu.cn" || + host.endsWith(".feishu.cn") || + host === "larksuite.com" || + host.endsWith(".larksuite.com") || + host === "larkoffice.com" || + host.endsWith(".larkoffice.com") + ); +} + +function isLikelyFeishuAssetHost(host: string | null): boolean { + if (!host) { + return false; + } + return ( + isFeishuOrLarkHost(host) || + host.endsWith(".byteimg.com") || + host.endsWith(".bytedance.net") || + host.endsWith(".bytedance.com") || + host.endsWith(".larksuitecdn.com") + ); +} + +function shouldAllowFeishuPopup(openerUrl: string, targetUrl: string): boolean { + if (!isFeishuOrLarkHost(getUrlHost(openerUrl))) { + return false; + } + if (targetUrl === "about:blank" || targetUrl.startsWith("blob:") || targetUrl.startsWith("data:")) { + return true; + } + return isLikelyFeishuAssetHost(getUrlHost(targetUrl)); +} + export function getWaveTabViewByWebContentsId(webContentsId: number): WaveTabView { if (webContentsId == null) { return null; @@ -319,6 +365,10 @@ export async function getOrCreateWebViewForTab(waveWindowId: string, tabId: stri if (wc == null || wc.isDestroyed() || tabView.webContents == null || tabView.webContents.isDestroyed()) { return { action: "deny" }; } + if (shouldAllowFeishuPopup(wc.getURL(), details.url)) { + console.log("allow feishu webview popup", details.url, "opener", wc.getURL()); + return { action: "allow" }; + } tabView.webContents.send("webview-new-window", wc.id, details); return { action: "deny" }; }); diff --git a/emain/emain-wavesrv.ts b/emain/emain-wavesrv.ts index f58d214a7e..369cf0067c 100644 --- a/emain/emain-wavesrv.ts +++ b/emain/emain-wavesrv.ts @@ -76,11 +76,11 @@ export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promis cwd: getWaveSrvCwd(), env: envCopy, }); - proc.on("exit", (e) => { + proc.on("exit", (code, signal) => { if (updater?.status == "installing") { return; } - console.log("wavesrv exited, shutting down"); + console.log("wavesrv exited, shutting down", "code=", code, "signal=", signal); setForceQuit(true); isWaveSrvDead = true; electron.app.quit(); @@ -107,9 +107,7 @@ export function runWaveSrv(handleWSEvent: (evtMsg: WSEventType) => void): Promis }); rlStderr.on("line", (line) => { if (line.includes("WAVESRV-ESTART")) { - const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.-]+) buildtime:(\d+)/gm.exec( - line - ); + const startParams = /ws:([a-z0-9.:]+) web:([a-z0-9.:]+) version:([a-z0-9.-]+) buildtime:(\d+)/gm.exec(line); if (startParams == null) { console.log("error parsing WAVESRV-ESTART line", line); setUserConfirmedQuit(true); diff --git a/emain/emain-window.ts b/emain/emain-window.ts index 98276bbdd2..a6432c51c6 100644 --- a/emain/emain-window.ts +++ b/emain/emain-window.ts @@ -24,6 +24,8 @@ import { ElectronWshClient } from "./emain-wsh"; import { updater } from "./updater"; const DevInitTimeoutMs = 5000; +const DefaultWindowChromeColor = "#0F1722"; +const DefaultWindowSymbolColor = "#d6e2ef"; export type WindowOpts = { unamePlatform: NodeJS.Platform; @@ -181,12 +183,12 @@ export class WaveBrowserWindow extends BaseWindow { } else if (isBlur) { winOpts.vibrancy = "fullscreen-ui"; } else { - winOpts.backgroundColor = "#222222"; + winOpts.backgroundColor = DefaultWindowChromeColor; } } else if (opts.unamePlatform === "linux") { winOpts.titleBarStyle = settings["window:nativetitlebar"] ? "default" : "hidden"; winOpts.titleBarOverlay = { - symbolColor: "white", + symbolColor: DefaultWindowSymbolColor, color: "#00000000", }; winOpts.icon = path.join(getElectronAppBasePath(), "public/logos/wave-logo-dark.png"); @@ -194,13 +196,13 @@ export class WaveBrowserWindow extends BaseWindow { if (isTransparent) { winOpts.transparent = true; } else { - winOpts.backgroundColor = "#222222"; + winOpts.backgroundColor = DefaultWindowChromeColor; } } else if (opts.unamePlatform === "win32") { winOpts.titleBarStyle = "hidden"; winOpts.titleBarOverlay = { - color: "#222222", - symbolColor: "#c3c8c2", + color: DefaultWindowChromeColor, + symbolColor: DefaultWindowSymbolColor, height: 32, }; if (isTransparent) { @@ -208,7 +210,7 @@ export class WaveBrowserWindow extends BaseWindow { } else if (isBlur) { winOpts.backgroundMaterial = "acrylic"; } else { - winOpts.backgroundColor = "#222222"; + winOpts.backgroundColor = DefaultWindowChromeColor; } } @@ -885,25 +887,61 @@ export async function relaunchBrowserWindows() { const wins: WaveBrowserWindow[] = []; const isFirstRelaunch = !hasCompletedFirstRelaunch; const primaryWindowId = windowIds.length > 0 ? windowIds[0] : null; - for (const windowId of windowIds.slice().reverse()) { + const createRelaunchWindow = async ( + windowId: string, + isPrimaryStartupWindow: boolean, + foregroundWindow: boolean + ) => { const windowData: WaveWindow = await WindowService.GetWindow(windowId); if (windowData == null) { console.log("relaunch -- window data not found, closing window", windowId); await WindowService.CloseWindow(windowId, true); - continue; + return null; } - const isPrimaryStartupWindow = isFirstRelaunch && windowId === primaryWindowId; console.log( "relaunch -- creating window", windowId, windowData, isPrimaryStartupWindow ? "(primary startup)" : "" ); - const win = await createBrowserWindow(windowData, fullConfig, { + return await createBrowserWindow(windowData, fullConfig, { unamePlatform, isPrimaryStartupWindow, - foregroundWindow: windowId === primaryWindowId, + foregroundWindow, }); + }; + if (isFirstRelaunch && primaryWindowId != null) { + const primaryWin = await createRelaunchWindow(primaryWindowId, true, true); + if (primaryWin != null) { + wins.push(primaryWin); + quakeWindow = primaryWin; + console.log("designated quake window", primaryWin.waveWindowId); + console.log("show window", primaryWin.waveWindowId); + primaryWin.show(); + } + hasCompletedFirstRelaunch = true; + const secondaryWindowIds = windowIds.filter((windowId) => windowId !== primaryWindowId).slice().reverse(); + for (const windowId of secondaryWindowIds) { + const win = await createRelaunchWindow(windowId, false, false); + if (win == null) { + continue; + } + wins.push(win); + console.log("show window", win.waveWindowId); + win.show(); + } + if (primaryWin != null && !primaryWin.isDestroyed()) { + primaryWin.focus(); + primaryWin.activeTabView?.webContents?.focus(); + } + return; + } + for (const windowId of windowIds.slice().reverse()) { + const isPrimaryStartupWindow = isFirstRelaunch && windowId === primaryWindowId; + const win = await createRelaunchWindow(windowId, isPrimaryStartupWindow, windowId === primaryWindowId); + if (win == null) { + continue; + } wins.push(win); if (windowId === primaryWindowId) { quakeWindow = win; diff --git a/emain/emain.ts b/emain/emain.ts index 8b08178aec..6de7ea2407 100644 --- a/emain/emain.ts +++ b/emain/emain.ts @@ -17,6 +17,7 @@ import { getAndClearTermCommandsRun, getAndClearTermCommandsWsl, getForceQuit, + getGlobalIsStarting, getGlobalIsRelaunching, getUserConfirmedQuit, setForceQuit, @@ -254,6 +255,23 @@ function hideWindowWithCatch(window: WaveBrowserWindow) { } } +function requestWaveSrvShutdown() { + shutdownWshrpc(); + const waveSrvProc = getWaveSrvProc(); + if (waveSrvProc == null) { + return; + } + if (unamePlatform === "win32") { + try { + waveSrvProc.stdin.end(); + } catch (e) { + console.log("error closing wavesrv stdin", e); + } + return; + } + waveSrvProc.kill("SIGINT"); +} + electronApp.on("window-all-closed", () => { if (getGlobalIsRelaunching()) { return; @@ -292,13 +310,7 @@ electronApp.on("before-quit", (e) => { } setGlobalIsQuitting(true); updater?.stop(); - if (unamePlatform == "win32") { - // win32 doesn't have a SIGINT, so we just let electron die, which - // ends up killing wavesrv via closing it's stdin. - return; - } - getWaveSrvProc()?.kill("SIGINT"); - shutdownWshrpc(); + requestWaveSrvShutdown(); if (getForceQuit()) { return; } @@ -356,6 +368,23 @@ process.on("uncaughtException", (error) => { setUserConfirmedQuit(true); electronApp.quit(); }); +process.on("unhandledRejection", (reason) => { + console.log("Unhandled Rejection:", reason); + if (reason instanceof Error) { + console.log("Stack Trace:", reason.stack); + } +}); +electronApp.on("render-process-gone", (_event, webContents, details) => { + console.log("render-process-gone", { + webContentsId: webContents.id, + url: webContents.getURL(), + reason: details.reason, + exitCode: details.exitCode, + }); +}); +electronApp.on("child-process-gone", (_event, details) => { + console.log("child-process-gone", details); +}); let lastWaveWindowCount = 0; let lastIsBuilderWindowActive = false; @@ -388,6 +417,15 @@ async function appMain() { } electronApp.on("second-instance", (_event, argv, workingDirectory) => { console.log("second-instance event, argv:", argv, "workingDirectory:", workingDirectory); + if (getGlobalIsStarting() || getGlobalIsRelaunching()) { + const win = getQuakeWindow() ?? focusedWaveWindow ?? getAllWaveWindows()[0]; + if (win != null && !win.isDestroyed()) { + win.show(); + win.focus(); + win.activeTabView?.webContents?.focus(); + } + return; + } fireAndForget(createNewWaveWindow); }); try { diff --git a/emain/preload-webview.ts b/emain/preload-webview.ts index e2a39a3b4e..6859cdceff 100644 --- a/emain/preload-webview.ts +++ b/emain/preload-webview.ts @@ -5,10 +5,16 @@ import { ipcRenderer } from "electron"; document.addEventListener("contextmenu", (event) => { console.log("contextmenu event", event); - if (event.target == null) { + if (!event.isTrusted || event.target == null) { return; } const targetElement = event.target as HTMLElement; + const selection = document.getSelection()?.toString().trim(); + const isEditable = + targetElement.isContentEditable || targetElement.tagName === "INPUT" || targetElement.tagName === "TEXTAREA"; + if (selection || isEditable) { + return; + } // Check if the right-click is on an image if (targetElement.tagName === "IMG") { setTimeout(() => { @@ -22,7 +28,13 @@ document.addEventListener("contextmenu", (event) => { }, 50); return; } - // do nothing + setTimeout(() => { + if (event.defaultPrevented) { + return; + } + event.preventDefault(); + ipcRenderer.send("webview-contextmenu"); + }, 50); }); document.addEventListener("mouseup", (event) => { diff --git a/emain/preload.ts b/emain/preload.ts index 8d2b18a308..64e1c8a6b6 100644 --- a/emain/preload.ts +++ b/emain/preload.ts @@ -31,6 +31,7 @@ contextBridge.exposeInMainWorld("api", { console.error("Invalid URL passed to openExternal:", url); } }, + openFeishuApp: () => ipcRenderer.invoke("open-feishu-app"), getEnv: (varName) => ipcRenderer.sendSync("get-env", varName), onFullScreenChange: (callback) => ipcRenderer.on("fullscreen-change", (_event, isFullScreen) => callback(isFullScreen)), diff --git a/frontend/app/app-bg.tsx b/frontend/app/app-bg.tsx index 2956e36d58..123b1e55df 100644 --- a/frontend/app/app-bg.tsx +++ b/frontend/app/app-bg.tsx @@ -6,7 +6,7 @@ import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { computeBgStyleFromMeta } from "@/util/waveutil"; import useResizeObserver from "@react-hook/resize-observer"; import { useAtomValue } from "jotai"; -import { CSSProperties, useCallback, useLayoutEffect, useRef } from "react"; +import { CSSProperties, useCallback, useLayoutEffect, useMemo, useRef } from "react"; import { debounce } from "throttle-debounce"; import { atoms, getApi, WOS } from "./store/global"; import { useWaveObjectValue } from "./store/wos"; @@ -24,7 +24,21 @@ export function AppBackground() { const tabBg = useAtomValue(env.getTabMetaKeyAtom(tabId, "tab:background")); const configBg = useAtomValue(env.getConfigBackgroundAtom(tabBg)); const resolvedMeta: Omit = tabBg && configBg ? configBg : tabData?.meta; - const style: CSSProperties = computeBgStyleFromMeta(resolvedMeta, 0.5) ?? {}; + const style: CSSProperties = useMemo(() => { + const computedStyle = computeBgStyleFromMeta(resolvedMeta, 0.5) ?? {}; + if (Object.keys(computedStyle).length > 0) { + return computedStyle; + } + return { + backgroundColor: "rgb(11, 18, 26)", + backgroundImage: [ + "radial-gradient(circle at 18% 18%, rgba(102, 214, 174, 0.18), transparent 24%)", + "radial-gradient(circle at 82% 16%, rgba(111, 173, 255, 0.2), transparent 26%)", + "radial-gradient(circle at 52% 100%, rgba(143, 118, 255, 0.16), transparent 34%)", + "linear-gradient(180deg, rgba(12, 18, 26, 0.98), rgba(8, 12, 18, 0.98))", + ].join(", "), + }; + }, [resolvedMeta]); const getAvgColor = useCallback( debounce(30, () => { if ( diff --git a/frontend/app/app.scss b/frontend/app/app.scss index b85a6da3b0..3474154b7e 100644 --- a/frontend/app/app.scss +++ b/frontend/app/app.scss @@ -18,8 +18,8 @@ body { overflow: hidden; background: rgb(from var(--main-bg-color) r g b / var(--window-opacity)); -webkit-font-smoothing: auto; - backface-visibility: hidden; - transform: translateZ(0); + text-rendering: optimizeLegibility; + font-synthesis-weight: none; } .is-transparent { diff --git a/frontend/app/block/block.scss b/frontend/app/block/block.scss index 6b0fda769c..4ae884e832 100644 --- a/frontend/app/block/block.scss +++ b/frontend/app/block/block.scss @@ -67,10 +67,19 @@ padding: 1px; .block-frame-default-inner { - background-color: var(--block-bg-color); + background: + linear-gradient( + 180deg, + rgb(from var(--block-bg-solid-color) r g b / 0.95), + rgb(from var(--block-bg-color) r g b / 0.92) + ); width: 100%; height: 100%; border-radius: var(--block-border-radius); + border: 1px solid var(--chrome-border-color); + box-shadow: + 0 20px 40px -28px var(--block-shadow-color), + inset 0 1px 0 rgb(from var(--main-text-color) r g b / 0.04); display: flex; flex-direction: column; @@ -84,6 +93,12 @@ font: var(--header-font); border-bottom: 1px solid var(--border-color); border-radius: var(--block-border-radius) var(--block-border-radius) 0 0; + background: + linear-gradient( + 180deg, + rgb(from var(--chrome-bg-solid-color) r g b / 0.9), + rgb(from var(--chrome-bg-solid-color) r g b / 0.72) + ); .block-frame-default-header-iconview { display: flex; @@ -255,7 +270,7 @@ } .block-frame-preview { - background-color: rgb(from var(--block-bg-color) r g b / 70%); + background-color: rgb(from var(--block-bg-color) r g b / 78%); width: 100%; flex-grow: 1; border-bottom-left-radius: var(--block-border-radius); @@ -272,8 +287,8 @@ } } - --magnified-block-opacity: 0.6; - --magnified-block-blur: 10px; + --magnified-block-opacity: 0.45; + --magnified-block-blur: 5px; &.magnified, &.ephemeral { @@ -293,9 +308,9 @@ flex-direction: column; overflow: hidden; background: var(--conn-status-overlay-bg-color); - backdrop-filter: blur(50px); + backdrop-filter: blur(18px); border-radius: 6px; - box-shadow: 0px 13px 16px 0px rgb(from var(--block-bg-color) r g b / 40%); + box-shadow: 0 18px 32px -18px var(--block-shadow-color); opacity: 0.9; .connstatus-content { @@ -363,7 +378,7 @@ right: 4px; float: right; border-radius: 4px; - backdrop-filter: blur(8px); + backdrop-filter: blur(4px); padding: 0.286em; align-items: center; justify-content: flex-end; diff --git a/frontend/app/block/blockregistry.ts b/frontend/app/block/blockregistry.ts index 5de7e05bd3..ddaa53b029 100644 --- a/frontend/app/block/blockregistry.ts +++ b/frontend/app/block/blockregistry.ts @@ -10,6 +10,8 @@ import { ProcessViewerViewModel } from "@/app/view/processviewer/processviewer"; import { SysinfoViewModel } from "@/app/view/sysinfo/sysinfo"; import { TsunamiViewModel } from "@/app/view/tsunami/tsunami"; import { VDomModel } from "@/app/view/vdom/vdom-model"; +import { FeishuViewModel } from "@/view/feishuview/feishuview"; +import { FeishuWebViewModel } from "@/view/feishuweb/feishuweb"; import { WaveEnv } from "@/app/waveenv/waveenv"; import { atom } from "jotai"; import { QuickTipsViewModel } from "../view/quicktipsview/quicktipsview"; @@ -24,6 +26,8 @@ const BlockRegistry: Map = new Map(); BlockRegistry.set("term", TermViewModel); BlockRegistry.set("preview", PreviewModel); BlockRegistry.set("web", WebViewModel); +BlockRegistry.set("feishu", FeishuViewModel); +BlockRegistry.set("feishuweb", FeishuWebViewModel); BlockRegistry.set("waveai", WaveAiModel); BlockRegistry.set("cpuplot", SysinfoViewModel); BlockRegistry.set("sysinfo", SysinfoViewModel); diff --git a/frontend/app/block/blockutil.tsx b/frontend/app/block/blockutil.tsx index 3ef4d39821..766a6fbeb1 100644 --- a/frontend/app/block/blockutil.tsx +++ b/frontend/app/block/blockutil.tsx @@ -33,6 +33,12 @@ export function blockViewToIcon(view: string): string { if (view == "web") { return "globe"; } + if (view == "feishu") { + return "desktop"; + } + if (view == "feishuweb") { + return "globe"; + } if (view == "waveai") { return "sparkles"; } @@ -61,6 +67,12 @@ export function blockViewToName(view: string): string { if (view == "web") { return "Web"; } + if (view == "feishu") { + return "Feishu App"; + } + if (view == "feishuweb") { + return "Feishu Web"; + } if (view == "waveai") { return "WaveAI"; } diff --git a/frontend/app/tab/tab.scss b/frontend/app/tab/tab.scss index ad10fc814e..a6869f6183 100644 --- a/frontend/app/tab/tab.scss +++ b/frontend/app/tab/tab.scss @@ -28,23 +28,30 @@ height: 100%; white-space: nowrap; border-radius: 6px; + border: 1px solid transparent; + background: rgb(from var(--chrome-bg-solid-color) r g b / 0.28); + box-shadow: inset 0 1px 0 rgb(from var(--glass-highlight-color) r g b / 0.55); } &.animate { transition: transform 0.3s ease, - background-color 0.3s ease-in-out; + background-color 0.3s ease-in-out, + box-shadow 0.3s ease-in-out; } &.active { .tab-inner { - border-color: transparent; + border-color: var(--tab-active-border-color); border-radius: 6px; - background: rgb(from var(--main-text-color) r g b / 0.1); + background: var(--tab-active-bg-color); + box-shadow: + 0 16px 28px -24px rgb(from var(--main-bg-color) r g b / 0.95), + inset 0 1px 0 rgb(from var(--main-text-color) r g b / 0.08); } .name { - color: rgba(255, 255, 255, 1); + color: var(--main-text-color); font-weight: 600; } } @@ -58,7 +65,7 @@ z-index: var(--zindex-tab-name); font-size: 11px; font-weight: 500; - text-shadow: 0px 0px 4px rgb(from var(--main-bg-color) r g b / 0.25); + text-shadow: 0 1px 6px rgb(from var(--main-bg-color) r g b / 0.25); overflow: hidden; width: calc(100% - 10px); text-overflow: ellipsis; @@ -108,8 +115,8 @@ body:not(.nohover) .tab.dragging { } .tab-inner { - border-color: transparent; - background: rgb(from var(--main-text-color) r g b / 0.1); + border-color: rgb(from var(--tab-active-border-color) r g b / 0.7); + background: var(--tab-hover-bg-color); } .close { visibility: visible; @@ -138,4 +145,3 @@ body.nohover .tab.active .close { .tab.new-tab { animation: expandWidthAndFadeIn 0.1s forwards; } - diff --git a/frontend/app/tab/tabbar.scss b/frontend/app/tab/tabbar.scss index 43b42a2f9b..e4798551ef 100644 --- a/frontend/app/tab/tabbar.scss +++ b/frontend/app/tab/tabbar.scss @@ -11,8 +11,13 @@ width: 100vw; -webkit-app-region: drag; height: max(33px, calc(33px * var(--zoomfactor-inv))); - backdrop-filter: blur(20px); - background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(14px); + background: linear-gradient( + 180deg, + rgb(from var(--chrome-bg-solid-color) r g b / 0.94), + rgb(from var(--chrome-bg-solid-color) r g b / 0.74) + ); + border-bottom: 1px solid var(--chrome-border-color); flex-shrink: 0; button { diff --git a/frontend/app/theme.scss b/frontend/app/theme.scss index 287a004100..f40f41c84a 100644 --- a/frontend/app/theme.scss +++ b/frontend/app/theme.scss @@ -2,18 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 :root { - --main-text-color: #f7f7f7; + --main-text-color: #eef4fb; --title-font-size: 18px; --window-opacity: 1; - --secondary-text-color: rgb(195, 200, 194); - --grey-text-color: #666; - --main-bg-color: rgb(34, 34, 34); - --border-color: rgba(255, 255, 255, 0.16); - --base-font: normal 14px / normal "Inter", sans-serif; - --fixed-font: normal 12px / normal "Hack", monospace; - --accent-color: rgb(88, 193, 66); - --panel-bg-color: rgba(31, 33, 31, 0.5); - --highlight-bg-color: rgba(255, 255, 255, 0.2); + --secondary-text-color: rgb(179, 194, 209); + --grey-text-color: #8894a7; + --main-bg-color: rgb(11, 18, 26); + --border-color: rgba(148, 177, 204, 0.18); + --base-font: 500 14px / 1.45 "Segoe UI Variable Text", "Segoe UI", "Inter", sans-serif; + --fixed-font: 400 12px / 1.45 "JetBrains Mono", "Hack", "Cascadia Mono", monospace; + --accent-color: rgb(102, 214, 174); + --accent-color-strong: rgb(126, 232, 196); + --panel-bg-color: rgba(16, 28, 39, 0.72); + --highlight-bg-color: rgba(140, 187, 255, 0.14); --markdown-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; @@ -22,22 +23,30 @@ --error-color: rgb(229, 77, 46); --warning-color: rgb(224, 185, 86); --success-color: rgb(78, 154, 6); - --hover-bg-color: rgba(255, 255, 255, 0.1); - --block-bg-color: rgba(0, 0, 0, 0.5); - --block-bg-solid-color: rgb(0, 0, 0); - --block-border-radius: 8px; + --hover-bg-color: rgba(255, 255, 255, 0.08); + --block-bg-color: rgba(15, 27, 39, 0.82); + --block-bg-solid-color: rgb(15, 27, 39); + --block-border-radius: 10px; + --chrome-bg-color: rgba(11, 19, 27, 0.78); + --chrome-bg-solid-color: rgb(12, 20, 29); + --chrome-border-color: rgba(164, 191, 216, 0.16); + --glass-highlight-color: rgba(255, 255, 255, 0.08); + --block-shadow-color: rgba(3, 8, 18, 0.38); + --tab-hover-bg-color: rgba(126, 160, 255, 0.12); + --tab-active-bg-color: rgba(126, 160, 255, 0.18); + --tab-active-border-color: rgba(168, 193, 255, 0.2); --keybinding-color: #e0e0e0; - --keybinding-bg-color: #333; - --keybinding-border-color: #444; + --keybinding-bg-color: #182230; + --keybinding-border-color: rgba(148, 177, 204, 0.18); /* scrollbar colors */ --scrollbar-background-color: transparent; - --scrollbar-thumb-color: rgba(255, 255, 255, 0.15); - --scrollbar-thumb-hover-color: rgba(255, 255, 255, 0.5); - --scrollbar-thumb-active-color: rgba(255, 255, 255, 0.6); + --scrollbar-thumb-color: rgba(173, 195, 216, 0.22); + --scrollbar-thumb-hover-color: rgba(203, 222, 240, 0.42); + --scrollbar-thumb-active-color: rgba(220, 236, 250, 0.5); - --header-font: 700 11px / normal "Inter", sans-serif; + --header-font: 700 11px / 1.2 "Segoe UI Variable Small", "Segoe UI Semibold", "Inter", sans-serif; --header-icon-size: 14px; --header-icon-width: 16px; --header-height: 30px; @@ -78,9 +87,9 @@ // xterm-decoration-top: 2 // modal colors - --modal-bg-color: #232323; - --modal-header-bottom-border-color: rgba(241, 246, 243, 0.15); - --modal-border-color: rgba(255, 255, 255, 0.12); /* toggle colors */ + --modal-bg-color: #16202b; + --modal-header-bottom-border-color: rgba(205, 220, 234, 0.12); + --modal-border-color: rgba(173, 198, 221, 0.14); /* toggle colors */ --modal-border-radius: 6px; --toggle-bg-color: var(--border-color); --modal-shadow-color: rgba(0, 0, 0, 0.8); @@ -90,7 +99,7 @@ --toggle-checked-bg-color: var(--accent-color); // link color - --link-color: #58c142; + --link-color: var(--accent-color); // form colors --form-element-border-color: rgba(241, 246, 243, 0.15); @@ -98,7 +107,7 @@ --form-element-text-color: var(--main-text-color); --form-element-primary-text-color: var(--main-text-color); --form-element-primary-color: var(--accent-color); - --form-element-secondary-color: rgba(255, 255, 255, 0.2); + --form-element-secondary-color: rgba(173, 198, 221, 0.16); --form-element-error-color: var(--error-color); --conn-icon-color: #53b4ea; @@ -110,7 +119,7 @@ --conn-icon-color-6: #ffa24e; --conn-icon-color-7: #dbde52; --conn-icon-color-8: #58c142; - --conn-status-overlay-bg-color: rgba(230, 186, 30, 0.2); + --conn-status-overlay-bg-color: rgba(230, 186, 30, 0.16); --sysinfo-cpu-color: #58c142; --sysinfo-mem-color: #53b4ea; @@ -147,10 +156,10 @@ --button-text-color: #000000; --button-green-bg: var(--term-green); --button-green-border-color: #29f200; - --button-grey-bg: rgba(255, 255, 255, 0.04); + --button-grey-bg: rgba(255, 255, 255, 0.05); --button-grey-hover-bg: rgba(255, 255, 255, 0.09); - --button-grey-border-color: rgba(255, 255, 255, 0.1); - --button-grey-outlined-color: rgba(255, 255, 255, 0.6); + --button-grey-border-color: rgba(173, 198, 221, 0.14); + --button-grey-outlined-color: rgba(220, 232, 244, 0.68); --button-red-bg: #cc0000; --button-red-hover-bg: #f93939; --button-red-border-color: #fc3131; diff --git a/frontend/app/view/codeeditor/codeeditor.tsx b/frontend/app/view/codeeditor/codeeditor.tsx index ba1e28666e..52b5de66e3 100644 --- a/frontend/app/view/codeeditor/codeeditor.tsx +++ b/frontend/app/view/codeeditor/codeeditor.tsx @@ -1,13 +1,17 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { MonacoCodeEditor } from "@/app/monaco/monaco-react"; import { useOverrideConfigAtom } from "@/app/store/global"; import { boundNumber } from "@/util/util"; import type * as MonacoTypes from "monaco-editor"; -import * as MonacoModule from "monaco-editor"; import React, { useMemo, useRef } from "react"; +const LazyMonacoCodeEditor = React.lazy(async () => { + const mod = await import("@/app/monaco/monaco-react"); + return { default: mod.MonacoCodeEditor }; +}); +type MonacoModuleType = typeof import("monaco-editor"); + function defaultEditorOptions(): MonacoTypes.editor.IEditorOptions { const opts: MonacoTypes.editor.IEditorOptions = { scrollBeyondLastLine: false, @@ -36,7 +40,7 @@ interface CodeEditorProps { language?: string; fileName?: string; onChange?: (text: string) => void; - onMount?: (monacoPtr: MonacoTypes.editor.IStandaloneCodeEditor, monaco: typeof MonacoModule) => () => void; + onMount?: (monacoPtr: MonacoTypes.editor.IStandaloneCodeEditor, monaco: MonacoModuleType) => () => void; } export function CodeEditor({ blockId, text, language, fileName, readonly, onChange, onMount }: CodeEditorProps) { @@ -72,7 +76,7 @@ export function CodeEditor({ blockId, text, language, fileName, readonly, onChan function handleEditorOnMount( editor: MonacoTypes.editor.IStandaloneCodeEditor, - monaco: typeof MonacoModule + monaco: MonacoModuleType ): () => void { if (onMount) { const cleanup = onMount(editor, monaco); @@ -95,15 +99,17 @@ export function CodeEditor({ blockId, text, language, fileName, readonly, onChan return (
- + }> + +
); diff --git a/frontend/app/view/codeeditor/diffviewer.tsx b/frontend/app/view/codeeditor/diffviewer.tsx index 871e801bd3..e69056493d 100644 --- a/frontend/app/view/codeeditor/diffviewer.tsx +++ b/frontend/app/view/codeeditor/diffviewer.tsx @@ -1,11 +1,15 @@ // Copyright 2025, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -import { MonacoDiffViewer } from "@/app/monaco/monaco-react"; import { useOverrideConfigAtom } from "@/app/store/global"; import { boundNumber } from "@/util/util"; import type * as MonacoTypes from "monaco-editor"; -import { useMemo, useRef } from "react"; +import React, { useMemo, useRef } from "react"; + +const LazyMonacoDiffViewer = React.lazy(async () => { + const mod = await import("@/app/monaco/monaco-react"); + return { default: mod.MonacoDiffViewer }; +}); interface DiffViewerProps { blockId: string; @@ -62,13 +66,15 @@ export function DiffViewer({ blockId, original, modified, language, fileName }: return (
- + }> + +
); diff --git a/frontend/app/view/feishuview/feishuview.tsx b/frontend/app/view/feishuview/feishuview.tsx new file mode 100644 index 0000000000..754b429a93 --- /dev/null +++ b/frontend/app/view/feishuview/feishuview.tsx @@ -0,0 +1,114 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { Button } from "@/app/element/button"; +import { uxCloseBlock } from "@/app/store/keymodel"; +import { useWaveEnv, type WaveEnv } from "@/app/waveenv/waveenv"; +import { fireAndForget } from "@/util/util"; +import { atom } from "jotai"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +const FeishuWebUrl = "https://www.feishu.cn/messenger/"; + +class FeishuViewModel implements ViewModel { + blockId: string; + env: WaveEnv; + viewType = "feishu"; + viewIcon = atom("desktop"); + viewName = atom("Feishu App"); + noPadding = atom(true); + viewComponent = FeishuAppView; + + constructor({ blockId, waveEnv }: ViewModelInitType) { + this.blockId = blockId; + this.env = waveEnv; + } +} + +function FeishuAppView({ blockId }: ViewComponentProps) { + const env = useWaveEnv(); + const [launching, setLaunching] = useState(true); + const [launchResult, setLaunchResult] = useState(null); + + const openLocalApp = useCallback(() => { + fireAndForget(async () => { + setLaunching(true); + try { + const result = await env.electron.openFeishuApp(); + setLaunchResult(result); + } finally { + setLaunching(false); + } + }); + }, [env]); + + const openWebView = useCallback(() => { + fireAndForget(() => + env.createBlock({ + meta: { + view: "feishuweb", + }, + }) + ); + }, [env]); + + useEffect(() => { + openLocalApp(); + }, [openLocalApp]); + + const title = useMemo(() => { + if (launching) { + return "正在打开本地飞书 App…"; + } + if (launchResult?.opened) { + return "本地飞书 App 已打开"; + } + return "未检测到可用的本地飞书 App"; + }, [launching, launchResult]); + + const detail = useMemo(() => { + if (launching) { + return "这个入口只负责打开本地飞书。如果你想在 Wave 里直接聊天,请使用 Feishu Web。"; + } + if (launchResult?.opened) { + const methodText = launchResult.method ? `启动方式:${launchResult.method}` : null; + const appPathText = launchResult.appPath ? `应用路径:${launchResult.appPath}` : null; + return [methodText, appPathText, "如果想在 Wave 里直接使用聊天页,请打开 Feishu Web。"] + .filter(Boolean) + .join(" · "); + } + return "你可以配置 `feishu:apppath` 指定安装路径,或者直接打开 Feishu Web。"; + }, [launching, launchResult]); + + return ( +
+
+
+
+ +
+
+
{title}
+
{detail}
+
+
+
+ + + + +
+
+
+ ); +} + +export { FeishuViewModel }; diff --git a/frontend/app/view/feishuweb/feishuweb.tsx b/frontend/app/view/feishuweb/feishuweb.tsx new file mode 100644 index 0000000000..6de4681c1a --- /dev/null +++ b/frontend/app/view/feishuweb/feishuweb.tsx @@ -0,0 +1,97 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { uxCloseBlock } from "@/app/store/keymodel"; +import { DESKTOP_CHROME_USER_AGENT, WebView, WebViewModel } from "@/app/view/webview/webview"; +import { fireAndForget } from "@/util/util"; +import { atom } from "jotai"; + +const FeishuWebUrl = "https://www.feishu.cn/messenger/"; +const FeishuPartition = "persist:feishu"; + +class FeishuWebViewModel extends WebViewModel { + get viewComponent(): ViewComponent { + return FeishuWebView; + } + + constructor(initOpts: ViewModelInitType) { + super(initOpts); + this.viewType = "feishuweb"; + this.viewIcon = atom("globe"); + this.viewName = atom("Feishu Web"); + this.homepageUrl = atom(FeishuWebUrl); + this.partitionOverride = atom(FeishuPartition); + this.defaultUserAgent = atom(DESKTOP_CHROME_USER_AGENT); + this.webPreferences = atom("nativeWindowOpen=yes"); + this.endIconButtons = atom((get) => { + const currentUrl = get(this.url); + const metaUrl = get(this.blockAtom)?.meta?.url; + const homepageUrl = get(this.homepageUrl); + const url = currentUrl ?? metaUrl ?? homepageUrl; + return [ + { + elemtype: "iconbutton", + icon: "desktop", + title: "Open local Feishu app", + click: () => { + fireAndForget(() => this.env.electron.openFeishuApp()); + }, + }, + { + elemtype: "iconbutton", + icon: "arrow-up-right-from-square", + title: "Open current page in external browser", + click: () => { + if (url != null && url !== "") { + this.env.electron.openExternal(url); + } + }, + }, + { + elemtype: "iconbutton", + icon: "eye-slash", + title: "Hide this Feishu Web card", + click: () => { + uxCloseBlock(this.blockId); + }, + }, + ]; + }); + } + + handleNewWindow(url: string) { + fireAndForget(() => + this.env.createBlock({ + meta: { + view: "feishuweb", + url, + }, + }) + ); + } + + getSettingsMenuItems(): ContextMenuItem[] { + return [ + { + label: "Open Local Feishu App", + click: () => { + fireAndForget(() => this.env.electron.openFeishuApp()); + }, + }, + { + type: "separator", + }, + ...super.getSettingsMenuItems(), + ]; + } +} + +function FeishuWebView(props: ViewComponentProps) { + return ( +
+ +
+ ); +} + +export { FeishuWebViewModel }; diff --git a/frontend/app/view/preview/preview-directory.tsx b/frontend/app/view/preview/preview-directory.tsx index 0940ba43b3..9a9d02c271 100644 --- a/frontend/app/view/preview/preview-directory.tsx +++ b/frontend/app/view/preview/preview-directory.tsx @@ -187,6 +187,8 @@ function DirectoryTable({ ); const setEntryManagerProps = useSetAtom(entryManagerOverlayPropsAtom); + const stat = useAtomValue(model.statFile); + const canCreateEntries = stat?.supportsmkdir === true; const updateName = useCallback( (path: string, isDir: boolean) => { @@ -228,8 +230,8 @@ function DirectoryTable({ enableSortingRemoval: false, meta: { updateName, - newFile, - newDirectory, + newFile: canCreateEntries ? newFile : () => {}, + newDirectory: canCreateEntries ? newDirectory : () => {}, }, }); const sortingState = table.getState().sorting; @@ -326,6 +328,9 @@ function TableBody({ const dummyLineRef = useRef(null); const warningBoxRef = useRef(null); const conn = useAtomValue(model.connection); + const stat = useAtomValue(model.statFile); + const canCreateEntries = stat?.supportsmkdir === true; + const canMutateEntries = stat != null && (stat.path !== "/" || stat.supportsmkdir === true); const setErrorMsg = useSetAtom(model.errorMsgAtom); useEffect(() => { @@ -368,28 +373,37 @@ function TableBody({ return; } const fileName = finfo.path.split("/").pop(); - const menu: ContextMenuItem[] = [ - { - label: "New File", - click: () => { - table.options.meta.newFile(); - }, - }, - { - label: "New Folder", - click: () => { - table.options.meta.newDirectory(); + const menu: ContextMenuItem[] = []; + if (canCreateEntries) { + menu.push( + { + label: "New File", + click: () => { + table.options.meta.newFile(); + }, }, - }, - { + { + label: "New Folder", + click: () => { + table.options.meta.newDirectory(); + }, + } + ); + } + if (canMutateEntries) { + menu.push({ label: "Rename", click: () => { table.options.meta.updateName(finfo.path, finfo.isdir); }, - }, - { + }); + } + if (canCreateEntries || canMutateEntries) { + menu.push({ type: "separator", - }, + }); + } + menu.push( { label: "Copy File Name", click: () => fireAndForget(() => navigator.clipboard.writeText(fileName)), @@ -405,28 +419,30 @@ function TableBody({ { label: "Copy Full File Name (Shell Quoted)", click: () => fireAndForget(() => navigator.clipboard.writeText(shellQuote([finfo.path]))), - }, - ]; - addOpenMenuItems(menu, conn, finfo); - menu.push( - { - type: "separator", - }, - { - label: "Default Settings", - submenu: makeDirectoryDefaultMenuItems(model), - }, - { - type: "separator", - }, - { - label: "Delete", - click: () => handleFileDelete(model, finfo.path, false, setErrorMsg), } ); + addOpenMenuItems(menu, conn, finfo); + menu.push({ + type: "separator", + }); + menu.push({ + label: "Default Settings", + submenu: makeDirectoryDefaultMenuItems(model), + }); + if (canMutateEntries) { + menu.push( + { + type: "separator", + }, + { + label: "Delete", + click: () => handleFileDelete(model, finfo.path, false, setErrorMsg), + } + ); + } ContextMenuModel.getInstance().showContextMenu(menu, e); }, - [setRefreshVersion, conn] + [canCreateEntries, canMutateEntries, conn, setErrorMsg] ); const allRows = table.getRowModel().flatRows; @@ -571,6 +587,7 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { const conn = useAtomValue(model.connection); const blockData = useAtomValue(model.blockAtom); const finfo = useAtomValue(model.statFile); + const canCreateEntries = finfo?.supportsmkdir === true; const dirPath = finfo?.path; const setErrorMsg = useSetAtom(model.errorMsgAtom); @@ -796,6 +813,9 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { const { getReferenceProps, getFloatingProps } = useInteractions([dismiss]); const newFile = useCallback(() => { + if (!canCreateEntries) { + return; + } setEntryManagerProps({ entryManagerType: EntryManagerType.NewFile, onSave: (newName: string) => { @@ -815,8 +835,11 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { setEntryManagerProps(undefined); }, }); - }, [dirPath]); + }, [canCreateEntries, dirPath]); const newDirectory = useCallback(() => { + if (!canCreateEntries) { + return; + } setEntryManagerProps({ entryManagerType: EntryManagerType.NewDirectory, onSave: (newName: string) => { @@ -832,34 +855,37 @@ function DirectoryPreview({ model }: DirectoryPreviewProps) { setEntryManagerProps(undefined); }, }); - }, [dirPath]); + }, [canCreateEntries, dirPath]); const handleFileContextMenu = useCallback( (e: any) => { e.preventDefault(); e.stopPropagation(); - const menu: ContextMenuItem[] = [ - { - label: "New File", - click: () => { - newFile(); + const menu: ContextMenuItem[] = []; + if (canCreateEntries) { + menu.push( + { + label: "New File", + click: () => { + newFile(); + }, }, - }, - { - label: "New Folder", - click: () => { - newDirectory(); + { + label: "New Folder", + click: () => { + newDirectory(); + }, }, - }, - { - type: "separator", - }, - ]; + { + type: "separator", + } + ); + } addOpenMenuItems(menu, conn, finfo); ContextMenuModel.getInstance().showContextMenu(menu, e); }, - [setRefreshVersion, conn, newFile, newDirectory, dirPath] + [canCreateEntries, conn, newFile, newDirectory, dirPath, finfo] ); return ( diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index bf77ef9535..d6e8593fbe 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -43,6 +43,8 @@ import { getBlockingCommand } from "./shellblocking"; import { computeTheme, DefaultTermTheme } from "./termutil"; import { TermWrap, WebGLSupported } from "./termwrap"; +const DefaultTermFontSize = 15; + export class TermViewModel implements ViewModel { viewType: string; nodeModel: BlockNodeModel; @@ -270,9 +272,10 @@ export class TermViewModel implements ViewModel { const connName = blockData?.meta?.connection; const fullConfig = get(atoms.fullConfigAtom); const connFontSize = fullConfig?.connections?.[connName]?.["term:fontsize"]; - const rtnFontSize = blockData?.meta?.["term:fontsize"] ?? connFontSize ?? settingsFontSize ?? 12; + const rtnFontSize = + blockData?.meta?.["term:fontsize"] ?? connFontSize ?? settingsFontSize ?? DefaultTermFontSize; if (typeof rtnFontSize != "number" || isNaN(rtnFontSize) || rtnFontSize < 4 || rtnFontSize > 64) { - return 12; + return DefaultTermFontSize; } return rtnFontSize; }); @@ -912,7 +915,7 @@ export class TermViewModel implements ViewModel { const termThemes = fullConfig?.termthemes ?? {}; const termThemeKeys = Object.keys(termThemes); const curThemeName = globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:theme")); - const defaultFontSize = globalStore.get(getSettingsKeyAtom("term:fontsize")) ?? 12; + const defaultFontSize = globalStore.get(getSettingsKeyAtom("term:fontsize")) ?? DefaultTermFontSize; const defaultAllowBracketedPaste = globalStore.get(getSettingsKeyAtom("term:allowbracketedpaste")) ?? true; const transparencyMeta = globalStore.get(getBlockMetaKeyAtom(this.blockId, "term:transparency")); const blockData = globalStore.get(this.blockAtom); diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 67eb5737c6..2e05f86708 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -24,7 +24,7 @@ import * as React from "react"; import { TermLinkTooltip } from "./term-tooltip"; import { TermStickers } from "./termsticker"; import { TermThemeUpdater } from "./termtheme"; -import { computeTheme, normalizeCursorStyle } from "./termutil"; +import { computeTheme, normalizeCursorStyle, normalizeTermScrollback } from "./termutil"; import { TermWrap } from "./termwrap"; import "./xterm.css"; @@ -275,19 +275,9 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => const termTransparency = globalStore.get(model.termTransparencyAtom); const termMacOptionIsMetaAtom = getOverrideConfigAtom(blockId, "term:macoptionismeta"); const [termTheme, _] = computeTheme(fullConfig, termThemeName, termTransparency); - let termScrollback = 2000; - if (termSettings?.["term:scrollback"]) { - termScrollback = Math.floor(termSettings["term:scrollback"]); - } - if (blockData?.meta?.["term:scrollback"]) { - termScrollback = Math.floor(blockData.meta["term:scrollback"]); - } - if (termScrollback < 0) { - termScrollback = 0; - } - if (termScrollback > 50000) { - termScrollback = 50000; - } + let termScrollback = normalizeTermScrollback(undefined); + termScrollback = normalizeTermScrollback(termSettings?.["term:scrollback"], termScrollback); + termScrollback = normalizeTermScrollback(blockData?.meta?.["term:scrollback"], termScrollback); const termAllowBPM = globalStore.get(model.termBPMAtom) ?? true; const termMacOptionIsMeta = globalStore.get(termMacOptionIsMetaAtom) ?? false; const termCursorStyle = normalizeCursorStyle(globalStore.get(getOverrideConfigAtom(blockId, "term:cursor"))); diff --git a/frontend/app/view/term/termutil.test.ts b/frontend/app/view/term/termutil.test.ts new file mode 100644 index 0000000000..c1f6069f08 --- /dev/null +++ b/frontend/app/view/term/termutil.test.ts @@ -0,0 +1,307 @@ +import { describe, expect, it } from "vitest"; + +import { + appendDroppedPrefixLines, + extractAppendedSuffixLines, + computeResizePreserveScrollback, + DefaultTermScrollback, + extractDroppedPrefixLines, + extractAgentTuiHistoryLines, + getWheelLineDelta, + MaxTermScrollback, + mergeOverlappingLines, + normalizeTermScrollback, + reconcileAgentTuiSnapshotHistory, + shouldHandleTerminalWheel, + shouldPrimeAgentTuiTranscriptCapture, +} from "./termutil"; + +describe("getWheelLineDelta", () => { + it("returns 0 for zero and non-finite deltas", () => { + expect(getWheelLineDelta(0, 0, 16, 40)).toBe(0); + expect(getWheelLineDelta(Number.NaN, 0, 16, 40)).toBe(0); + expect(getWheelLineDelta(Number.POSITIVE_INFINITY, 0, 16, 40)).toBe(0); + expect(getWheelLineDelta(Number.NEGATIVE_INFINITY, 0, 16, 40)).toBe(0); + }); + + it("converts pixel deltas using cell height", () => { + expect(getWheelLineDelta(32, 0, 16, 40)).toBe(2); + expect(getWheelLineDelta(-24, 0, 12, 40)).toBe(-2); + }); + + it("keeps line deltas unchanged", () => { + expect(getWheelLineDelta(3, 1, 16, 40)).toBe(3); + expect(getWheelLineDelta(-2, 1, 16, 40)).toBe(-2); + }); + + it("converts page deltas using row count", () => { + expect(getWheelLineDelta(1, 2, 16, 30)).toBe(30); + expect(getWheelLineDelta(-1, 2, 16, 18)).toBe(-18); + }); + + it("falls back to sane defaults for invalid dimensions", () => { + expect(getWheelLineDelta(16, 0, 0, 0)).toBe(1); + }); +}); + +describe("normalizeTermScrollback", () => { + it("uses a large default for long agent output", () => { + expect(normalizeTermScrollback(undefined)).toBe(DefaultTermScrollback); + }); + + it("clamps configured values to the supported range", () => { + expect(normalizeTermScrollback(-10)).toBe(0); + expect(normalizeTermScrollback("123.9")).toBe(123); + expect(normalizeTermScrollback(MaxTermScrollback + 1)).toBe(MaxTermScrollback); + }); +}); + +describe("computeResizePreserveScrollback", () => { + it("keeps scrollback unchanged when the terminal is not narrowing", () => { + expect(computeResizePreserveScrollback(2000, 2000, 80, 120, 30)).toBe(2000); + }); + + it("increases scrollback before narrow resize can reflow-trim old rows", () => { + expect(computeResizePreserveScrollback(2000, 2000, 120, 60, 30)).toBeGreaterThan(2000); + }); + + it("never exceeds the global max", () => { + expect(computeResizePreserveScrollback(2000, 500000, 200, 20, 30)).toBe(MaxTermScrollback); + }); +}); + +describe("shouldHandleTerminalWheel", () => { + it("handles normal-buffer wheel even when terminal apps enable mouse tracking", () => { + expect(shouldHandleTerminalWheel(false, "normal")).toBe(true); + }); + + it("does not override alternate-buffer wheel; xterm/app handles it", () => { + expect(shouldHandleTerminalWheel(false, "alternate")).toBe(false); + }); + + it("does not handle already-cancelled wheel events", () => { + expect(shouldHandleTerminalWheel(true, "normal")).toBe(false); + }); + + it("does not handle unknown buffer types", () => { + expect(shouldHandleTerminalWheel(false, undefined)).toBe(false); + }); +}); + +describe("shouldPrimeAgentTuiTranscriptCapture", () => { + it("does not arm while PowerShell is only editing a codex command", () => { + expect( + shouldPrimeAgentTuiTranscriptCapture({ + activeBufferType: "normal", + mouseTrackingMode: "none", + shellState: null, + lastCommand: null, + dataText: "PS D:\\Project\\260413\\waveterm> codex --yol", + }) + ).toBe(false); + }); + + it("arms after shell integration reports an agent command is running", () => { + expect( + shouldPrimeAgentTuiTranscriptCapture({ + activeBufferType: "normal", + mouseTrackingMode: "none", + shellState: "running-command", + lastCommand: "codex --yolo", + dataText: "", + }) + ).toBe(true); + }); + + it("arms on strong Codex UI markers even without shell integration", () => { + expect( + shouldPrimeAgentTuiTranscriptCapture({ + activeBufferType: "normal", + mouseTrackingMode: "none", + shellState: null, + lastCommand: null, + dataText: "\x1b[?2026h\r\n>_ OpenAI Codex\r\n", + }) + ).toBe(true); + }); +}); + +describe("mergeOverlappingLines", () => { + it("appends only the new suffix for growing snapshots", () => { + expect(mergeOverlappingLines(["鈥?1", " 2"], ["鈥?1", " 2", " 3"])).toEqual(["鈥?1", " 2", " 3"]); + }); + + it("stitches sliding repaint windows without duplicating overlap", () => { + expect(mergeOverlappingLines(["鈥?1", " 2", " 3"], [" 2", " 3", " 4"])).toEqual([ + "鈥?1", + " 2", + " 3", + " 4", + ]); + }); + + it("keeps the configured maximum history size", () => { + expect(mergeOverlappingLines(["1", "2", "3"], ["4", "5"], 4)).toEqual(["2", "3", "4", "5"]); + }); +}); + +describe("extractDroppedPrefixLines", () => { + it("returns the lines that slid out of the top of a repaint window", () => { + expect(extractDroppedPrefixLines(["1", "2", "3"], ["2", "3", "4"])).toEqual(["1"]); + }); + + it("returns an empty list when snapshots do not overlap", () => { + expect(extractDroppedPrefixLines(["1", "2", "3"], ["7", "8", "9"])).toEqual([]); + }); +}); + +describe("appendDroppedPrefixLines", () => { + it("queues only lines that slid out of adjacent repaint windows", () => { + const first = appendDroppedPrefixLines([], ["1", "2", "3"], ["2", "3", "4"]); + expect(first).toEqual({ history: ["1"], pendingLines: ["1"] }); + + const second = appendDroppedPrefixLines(first.history, ["2", "3", "4"], ["3", "4", "5"]); + expect(second).toEqual({ history: ["1", "2"], pendingLines: ["2"] }); + }); + + it("does not queue lines when snapshots cannot be confidently stitched", () => { + expect(appendDroppedPrefixLines([], ["1", "2", "3"], ["7", "8", "9"])).toEqual({ + history: [], + pendingLines: [], + }); + }); + + it("does not queue a dropped prefix already present at the injected history tail", () => { + expect(appendDroppedPrefixLines(["1"], ["1", "2", "3"], ["2", "3", "4"])).toEqual({ + history: ["1"], + pendingLines: [], + }); + }); +}); + +describe("extractAppendedSuffixLines", () => { + it("returns the new lines that appeared at the bottom of a repaint window", () => { + expect(extractAppendedSuffixLines(["1", "2", "3"], ["2", "3", "4", "5"])).toEqual(["4", "5"]); + }); + + it("returns an empty list when snapshots do not overlap", () => { + expect(extractAppendedSuffixLines(["1", "2", "3"], ["7", "8", "9"])).toEqual([]); + }); +}); + +describe("reconcileAgentTuiSnapshotHistory", () => { + it("injects only lines that are no longer in the visible repaint window", () => { + const first = reconcileAgentTuiSnapshotHistory([], 0, ["1", "2", "3", "4"], 20); + expect(first).toEqual({ + history: ["1", "2", "3", "4"], + injectedLineCount: 0, + pendingLines: [], + }); + + const second = reconcileAgentTuiSnapshotHistory(first.history, first.injectedLineCount, ["3", "4", "5", "6"], 20); + expect(second).toEqual({ + history: ["1", "2", "3", "4", "5", "6"], + injectedLineCount: 2, + pendingLines: ["1", "2"], + }); + }); + + it("continues cleanly when a second prompt starts a new visible segment", () => { + const first = reconcileAgentTuiSnapshotHistory(["A1", "A2", "A3", "A4"], 1, ["A3", "A4"], 20); + expect(first).toEqual({ + history: ["A1", "A2", "A3", "A4"], + injectedLineCount: 2, + pendingLines: ["A2"], + }); + + const second = reconcileAgentTuiSnapshotHistory( + first.history, + first.injectedLineCount, + ["› second prompt", "B1", "B2"], + 20 + ); + expect(second).toEqual({ + history: ["A1", "A2", "A3", "A4", "› second prompt", "B1", "B2"], + injectedLineCount: 4, + pendingLines: ["A3", "A4"], + }); + }); + + it("anchors a repeated visible window even when the trailing prompt suggestion changes", () => { + const result = reconcileAgentTuiSnapshotHistory( + ["1", "2", "3", "4", "5", "old suggestion"], + 2, + ["2", "3", "4", "5", "new suggestion"], + 20 + ); + expect(result).toEqual({ + history: ["1", "2", "3", "4", "5", "old suggestion", "new suggestion"], + injectedLineCount: 2, + pendingLines: [], + }); + }); + + it("keeps injected count aligned when old transcript is trimmed", () => { + const result = reconcileAgentTuiSnapshotHistory(["1", "2", "3", "4"], 2, ["3", "4", "5", "6"], 5); + expect(result).toEqual({ + history: ["2", "3", "4", "5", "6"], + injectedLineCount: 1, + pendingLines: [], + }); + }); +}); + +describe("extractAgentTuiHistoryLines", () => { + it("drops shell and transient footer lines while preserving prompt context", () => { + expect( + extractAgentTuiHistoryLines([ + "Windows PowerShell", + "版权所有 (C) Microsoft Corporation。保留所有权利。", + "尝试新的跨平台 PowerShell https://aka.ms/pscore6", + "PS C:\\Users\\yucohu> codex --no-alt-screen \"List the numbers\"", + "╭─────────────────────────────────────────────╮", + "│ >_ OpenAI Codex (v0.122.0) │", + "╰─────────────────────────────────────────────╯", + "› List the numbers", + "Write tests for @filename", + " 1", + " 2", + "gpt-5.4 xhigh · ~", + ]) + ).toEqual([ + "› List the numbers", + " 1", + " 2", + ]); + }); + + it("drops blank and transient UI lines for stable overlap matching", () => { + expect(extractAgentTuiHistoryLines(["", "", "section a", "", "line 1", "", "", "line 2", "", ""])).toEqual([ + "section a", + "line 1", + "line 2", + ]); + }); + + it("drops Codex chrome, status and suggestions", () => { + expect( + extractAgentTuiHistoryLines([ + "╭─────────────────────────────────────────────────╮", + "│ ✨ Update available! 0.122.0 -> 0.123.0 │", + "│ See full release notes: │", + "│ >_ OpenAI Codex (v0.122.0) │", + "│ model: gpt-5.4 xhigh /model to change │", + "│ directory: ~ │", + "╰─────────────────────────────────────────────────╯", + ' > codex --no-alt-screen "Print lines"', + "• Working (0s • esc to interrupt)", + "Use /skills to list available skills", + "› Explain this codebase", + " Explain this codebase", + "› Print lines", + "• FIRST_ROUND_LINE_001", + " FIRST_ROUND_LINE_002", + ]) + ).toEqual(["› Print lines", "• FIRST_ROUND_LINE_001", " FIRST_ROUND_LINE_002"]); + }); +}); diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index 2fea30404a..045201ea86 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -10,6 +10,12 @@ import { colord } from "colord"; export type GenClipboardItem = { text?: string; image?: Blob }; +export function trimTerminalSelection(text: string): string { + return text + .split("\n") + .map((line) => line.trimEnd()) + .join("\n"); +} export function normalizeCursorStyle(cursorStyle: string): TermTypes.Terminal["options"]["cursorStyle"] { if (cursorStyle === "underline" || cursorStyle === "bar") { return cursorStyle; @@ -393,3 +399,320 @@ export function bufferLinesToText(buffer: TermTypes.IBuffer, startIndex: number, export function quoteForPosixShell(filePath: string): string { return "'" + filePath.replace(/'/g, "'\\''") + "'"; } + +export const DefaultTermScrollback = 50000; +export const MaxTermScrollback = 200000; +const ResizeScrollbackHeadroomRows = 1000; + +export function normalizeTermScrollback(value: unknown, fallback = DefaultTermScrollback): number { + const fallbackScrollback = Number.isFinite(fallback) ? Math.floor(fallback) : DefaultTermScrollback; + const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : fallbackScrollback; + if (!Number.isFinite(parsed)) { + return Math.max(0, Math.min(MaxTermScrollback, fallbackScrollback)); + } + return Math.max(0, Math.min(MaxTermScrollback, Math.floor(parsed))); +} + +export function computeResizePreserveScrollback( + currentScrollback: number, + bufferRows: number, + oldCols: number, + newCols: number, + newRows: number +): number { + const normalizedCurrent = normalizeTermScrollback(currentScrollback); + if ( + !Number.isFinite(bufferRows) || + !Number.isFinite(oldCols) || + !Number.isFinite(newCols) || + !Number.isFinite(newRows) || + bufferRows <= 0 || + oldCols <= 0 || + newCols <= 0 || + newCols >= oldCols + ) { + return normalizedCurrent; + } + const estimatedBufferRows = Math.ceil(bufferRows * (oldCols / newCols)); + const requiredScrollback = Math.max(0, estimatedBufferRows - Math.max(1, Math.floor(newRows))) + ResizeScrollbackHeadroomRows; + return Math.max(normalizedCurrent, Math.min(MaxTermScrollback, requiredScrollback)); +} + +export function shouldHandleTerminalWheel(defaultPrevented: boolean, activeBufferType: string | undefined): boolean { + if (defaultPrevented) { + return false; + } + return activeBufferType === "normal"; +} + +const AgentTuiCommandRegex = /^(codex|claude|opencode|aider|gemini|qwen)\b/i; +const AgentTuiStrongMarkerRegex = /\b(OpenAI Codex|Claude Code|gpt-\d|tokens left|esc to interrupt)\b/i; + +export function normalizeAgentCommand(command: string | null | undefined): string { + if (!command) { + return ""; + } + let normalized = command.trim(); + normalized = normalized.replace(/^env\s+/, ""); + normalized = normalized.replace(/^(?:\w+=(?:"[^"]*"|'[^']*'|\S+)\s+)*/, ""); + return normalized; +} + +export function isAgentTuiCommand(command: string | null | undefined): boolean { + return AgentTuiCommandRegex.test(normalizeAgentCommand(command)); +} + +export function hasAgentTuiStrongMarker(text: string | null | undefined): boolean { + if (!text) { + return false; + } + return AgentTuiStrongMarkerRegex.test(text) || /\x1b\[\?2026[hl]/.test(text); +} + +export function shouldPrimeAgentTuiTranscriptCapture({ + activeBufferType, + mouseTrackingMode, + shellState, + lastCommand, + dataText, +}: { + activeBufferType: string | undefined; + mouseTrackingMode: string | undefined; + shellState: string | null | undefined; + lastCommand: string | null | undefined; + dataText: string; +}): boolean { + if (activeBufferType !== "normal" || mouseTrackingMode !== "none") { + return false; + } + if (shellState === "running-command" && isAgentTuiCommand(lastCommand)) { + return true; + } + return hasAgentTuiStrongMarker(dataText); +} + +function linesEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false; + } + for (let index = 0; index < left.length; index++) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + +function findSuffixPrefixOverlap(left: string[], right: string[]): number { + const maxOverlap = Math.min(left.length, right.length); + for (let candidate = maxOverlap; candidate > 0; candidate--) { + if (linesEqual(left.slice(left.length - candidate), right.slice(0, candidate))) { + return candidate; + } + } + return 0; +} + +export function mergeOverlappingLines(history: string[], snapshot: string[], maxLines = DefaultTermScrollback): string[] { + if (snapshot.length === 0) { + return history; + } + if (history.length === 0) { + return snapshot.slice(-maxLines); + } + if (snapshot.length <= history.length && linesEqual(history.slice(history.length - snapshot.length), snapshot)) { + return history; + } + const overlap = findSuffixPrefixOverlap(history, snapshot); + const merged = overlap > 0 ? history.concat(snapshot.slice(overlap)) : history.concat(snapshot); + return merged.slice(-maxLines); +} + +function findSubsequenceIndex(lines: string[], candidate: string[]): number { + if (candidate.length === 0 || candidate.length > lines.length) { + return -1; + } + for (let index = lines.length - candidate.length; index >= 0; index--) { + if (linesEqual(lines.slice(index, index + candidate.length), candidate)) { + return index; + } + } + return -1; +} + +function findHistoryPrefixOverlap(history: string[], snapshot: string[]): { index: number; overlap: number } | null { + const maxOverlap = Math.min(history.length, snapshot.length); + const minOverlap = Math.min(maxOverlap, Math.max(1, Math.min(8, Math.ceil(snapshot.length / 3)))); + for (let overlap = maxOverlap; overlap >= minOverlap; overlap--) { + const index = findSubsequenceIndex(history, snapshot.slice(0, overlap)); + if (index >= 0) { + return { index, overlap }; + } + } + return null; +} + +export function reconcileAgentTuiSnapshotHistory( + history: string[], + injectedLineCount: number, + visibleSnapshot: string[], + maxLines = DefaultTermScrollback +): { history: string[]; injectedLineCount: number; pendingLines: string[] } { + if (visibleSnapshot.length === 0) { + return { history, injectedLineCount, pendingLines: [] }; + } + + let mergedHistory = history; + let visibleStartIndex = findSubsequenceIndex(history, visibleSnapshot); + if (visibleStartIndex < 0) { + const overlap = findSuffixPrefixOverlap(history, visibleSnapshot); + if (overlap > 0) { + visibleStartIndex = history.length - overlap; + mergedHistory = history.concat(visibleSnapshot.slice(overlap)); + } else { + const anchoredOverlap = findHistoryPrefixOverlap(history, visibleSnapshot); + if (anchoredOverlap != null) { + visibleStartIndex = anchoredOverlap.index; + mergedHistory = history.concat(visibleSnapshot.slice(anchoredOverlap.overlap)); + } else { + visibleStartIndex = history.length; + mergedHistory = history.concat(visibleSnapshot); + } + } + } + + const trimmedLineCount = Math.max(0, mergedHistory.length - maxLines); + if (trimmedLineCount > 0) { + mergedHistory = mergedHistory.slice(trimmedLineCount); + visibleStartIndex = Math.max(0, visibleStartIndex - trimmedLineCount); + injectedLineCount = Math.max(0, injectedLineCount - trimmedLineCount); + } + + const safeInjectedLineCount = Math.max(0, Math.min(injectedLineCount, mergedHistory.length)); + const targetInjectedLineCount = Math.max(0, Math.min(visibleStartIndex, mergedHistory.length)); + const pendingLines = + targetInjectedLineCount > safeInjectedLineCount + ? mergedHistory.slice(safeInjectedLineCount, targetInjectedLineCount) + : []; + + return { + history: mergedHistory, + injectedLineCount: Math.max(safeInjectedLineCount, targetInjectedLineCount), + pendingLines, + }; +} + +export function extractDroppedPrefixLines(previousSnapshot: string[], nextSnapshot: string[]): string[] { + if (previousSnapshot.length === 0 || nextSnapshot.length === 0) { + return []; + } + const overlap = findSuffixPrefixOverlap(previousSnapshot, nextSnapshot); + if (overlap <= 0) { + return []; + } + return previousSnapshot.slice(0, previousSnapshot.length - overlap); +} + +export function appendDroppedPrefixLines( + history: string[], + previousSnapshot: string[], + nextSnapshot: string[], + maxLines = DefaultTermScrollback +): { history: string[]; pendingLines: string[] } { + const droppedPrefix = extractDroppedPrefixLines(previousSnapshot, nextSnapshot); + if (droppedPrefix.length === 0) { + return { history, pendingLines: [] }; + } + const overlap = findSuffixPrefixOverlap(history, droppedPrefix); + const pendingLines = droppedPrefix.slice(overlap); + if (pendingLines.length === 0) { + return { history, pendingLines }; + } + return { + history: history.concat(pendingLines).slice(-maxLines), + pendingLines, + }; +} + +export function extractAppendedSuffixLines(previousSnapshot: string[], nextSnapshot: string[]): string[] { + if (nextSnapshot.length === 0) { + return []; + } + if (previousSnapshot.length === 0) { + return nextSnapshot; + } + const overlap = findSuffixPrefixOverlap(previousSnapshot, nextSnapshot); + if (overlap <= 0) { + return []; + } + return nextSnapshot.slice(overlap); +} + +export function extractAgentTuiHistoryLines(lines: string[]): string[] { + const historyLines: string[] = []; + for (const line of lines) { + const trimmedEnd = line.trimEnd(); + const trimmed = trimmedEnd.trim(); + if ( + trimmed === "" || + /^\u203a\s*Use \/skills/i.test(trimmed) || + /^Tip:/i.test(trimmed) || + /^•\s*Working\b/i.test(trimmed) || + /^gpt-[\w.-]+/i.test(trimmed) || + /tokens left/i.test(trimmed) || + /esc to interrupt/i.test(trimmed) || + /Update available/i.test(trimmed) || + /npm install -g @openai\/codex/i.test(trimmed) || + /github\.com\/openai\/codex\/releases/i.test(trimmed) || + /See full release notes/i.test(trimmed) || + /^OpenAI Codex/i.test(trimmed) || + />_\s*OpenAI Codex/i.test(trimmed) || + />\s*codex\b/i.test(trimmed) || + /\bcodex --no-alt-screen\b/i.test(trimmed) || + /\bmodel:\s+/i.test(trimmed) || + /\bdirectory:\s+/i.test(trimmed) || + /^╭[─\s]+╮$/.test(trimmed) || + /^╰[─\s]+╯$/.test(trimmed) || + /^│\s*│$/.test(trimmed) || + /^Windows PowerShell$/i.test(trimmed) || + /^版权所有/i.test(trimmed) || + /PowerShell https:\/\/aka\.ms\/pscore6/i.test(trimmed) || + /^Use \/skills to list available skills$/i.test(trimmed) || + /^Find and fix a bug in @filename$/i.test(trimmed) || + /^Write tests for @filename$/i.test(trimmed) || + /^Summarize recent commits$/i.test(trimmed) || + /^Run \/review on my current changes$/i.test(trimmed) || + /^Improve documentation in @filename$/i.test(trimmed) || + /^Implement \{feature\}$/i.test(trimmed) || + /^Explain this codebase$/i.test(trimmed) || + /^›\s*(?:Use \/skills|Explain this codebase|Find and fix a bug in @filename|Write tests for @filename|Summarize recent commits|Run \/review on my current changes|Improve documentation in @filename|Implement \{feature\})$/i.test(trimmed) || + /^PS [A-Z]:\\/i.test(trimmed) + ) { + continue; + } + historyLines.push(trimmedEnd); + } + while (historyLines.length > 0 && historyLines[0] === '') { + historyLines.shift(); + } + while (historyLines.length > 0 && historyLines[historyLines.length - 1] === '') { + historyLines.pop(); + } + return historyLines; +} + +export function getWheelLineDelta(deltaY: number, deltaMode: number, cellHeight: number, rows: number): number { + if (!Number.isFinite(deltaY) || deltaY === 0) { + return 0; + } + const safeCellHeight = Number.isFinite(cellHeight) && cellHeight > 0 ? cellHeight : 16; + const safeRows = Number.isFinite(rows) && rows > 0 ? rows : 1; + switch (deltaMode) { + case 1: + return deltaY; + case 2: + return deltaY * safeRows; + default: + return deltaY / safeCellHeight; + } +} diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index e1b129b72d..e2b51fc596 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -7,7 +7,6 @@ import { getFileSubject } from "@/app/store/wps"; import { RpcApi } from "@/app/store/wshclientapi"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { - fetchWaveFile, getApi, getOverrideConfigAtom, getSettingsKeyAtom, @@ -16,12 +15,10 @@ import { openLink, WOS, } from "@/store/global"; -import * as services from "@/store/services"; import { PLATFORM, PlatformMacOS } from "@/util/platformutil"; import { base64ToArray, fireAndForget } from "@/util/util"; import { FitAddon } from "@xterm/addon-fit"; import { SearchAddon } from "@xterm/addon-search"; -import { SerializeAddon } from "@xterm/addon-serialize"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import * as TermTypes from "@xterm/xterm"; @@ -39,18 +36,26 @@ import { import { bufferLinesToText, createTempFileFromBlob, + extractAgentTuiHistoryLines, extractAllClipboardData, + getWheelLineDelta, + hasAgentTuiStrongMarker, + isAgentTuiCommand, + MaxTermScrollback, normalizeCursorStyle, + reconcileAgentTuiSnapshotHistory, quoteForPosixShell, + shouldHandleTerminalWheel, + shouldPrimeAgentTuiTranscriptCapture, + trimTerminalSelection, } from "./termutil"; const dlog = debug("wave:termwrap"); const TermFileName = "term"; -const TermCacheFileName = "cache:term:full"; -const MinDataProcessedForCache = 100 * 1024; export const SupportsImageInput = true; const MaxRepaintTransactionMs = 2000; +const ShellPromptTailRegex = /^(?:PS [^\n>]+>|[A-Za-z]:\\[^>\n]*>|(?:\([^)]+\)\s*)?[\w.@-]+(?::[~./\w-]+)?[$#%>])\s*$/; // detect webgl support function detectWebGLSupport(): boolean { @@ -74,15 +79,15 @@ type TermWrapOptions = { }; export class TermWrap { + static liveInstances = new Set(); + static imeOwnerBlockId: string | null = null; + tabId: string; blockId: string; - ptyOffset: number; - dataBytesProcessed: number; terminal: Terminal; connectElem: HTMLDivElement; fitAddon: FitAddon; searchAddon: SearchAddon; - serializeAddon: SerializeAddon; mainFileSubject: SubjectWithRef; loaded: boolean; heldData: Uint8Array[]; @@ -96,6 +101,9 @@ export class TermWrap { webglContextLossDisposable: TermTypes.IDisposable | null = null; webglEnabledAtom: jotai.PrimitiveAtom; pasteActive: boolean = false; + disposed: boolean = false; + imePositionPatched: boolean = false; + imePositionSyncScheduled: boolean = false; lastUpdated: number; promptMarkers: TermTypes.IMarker[] = []; shellIntegrationStatusAtom: jotai.PrimitiveAtom; @@ -109,6 +117,18 @@ export class TermWrap { // xterm.js paste() method triggers onData event, which can cause duplicate sends lastPasteData: string = ""; lastPasteTime: number = 0; + wheelScrollRemainder: number = 0; + wheelScrollBufferType: string | null = null; + terminalWriteQueue: Promise = Promise.resolve(); + agentTuiLatched: boolean = false; + agentTuiTranscriptArmed: boolean = false; + agentTuiUserScrollLock: boolean = false; + agentTuiUserScrollVersion: number = 0; + agentTuiHistoryLines: string[] = []; + agentTuiInjectedLineCount: number = 0; + agentTuiLastSnapshotLines: string[] = []; + agentTuiPreviewTerminalHost: HTMLDivElement | null = null; + agentTuiPreviewTerminal: Terminal | null = null; // dev only (for debugging) recentWrites: { idx: number; data: string; ts: number }[] = []; @@ -121,6 +141,18 @@ export class TermWrap { inSyncTransaction: boolean = false; inRepaintTransaction: boolean = false; + static setImeOwnerBlockId(blockId: string | null) { + if (TermWrap.imeOwnerBlockId === blockId) { + return; + } + TermWrap.imeOwnerBlockId = blockId; + for (const termWrap of TermWrap.liveInstances) { + if (termWrap.blockId !== blockId) { + termWrap.clearImePositionOverrides(); + } + } + } + constructor( tabId: string, blockId: string, @@ -133,22 +165,19 @@ export class TermWrap { this.blockId = blockId; this.sendDataHandler = waveOptions.sendDataHandler; this.nodeModel = waveOptions.nodeModel; - this.ptyOffset = 0; - this.dataBytesProcessed = 0; this.hasResized = false; this.lastUpdated = Date.now(); this.promptMarkers = []; + TermWrap.liveInstances.add(this); this.shellIntegrationStatusAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.claudeCodeActiveAtom = jotai.atom(false); this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom; this.terminal = new Terminal(options); this.fitAddon = new FitAddon(); - this.serializeAddon = new SerializeAddon(); this.searchAddon = new SearchAddon(); this.terminal.loadAddon(this.searchAddon); this.terminal.loadAddon(this.fitAddon); - this.terminal.loadAddon(this.serializeAddon); this.terminal.loadAddon( new WebLinksAddon( (e, uri) => { @@ -215,6 +244,9 @@ export class TermWrap { console.log("[termwrap] repaint transaction starting"); this.inRepaintTransaction = true; } + if (this.shouldSuppressAgentTuiClearScrollback()) { + return true; + } } return false; }) @@ -243,6 +275,9 @@ export class TermWrap { this.inRepaintTransaction = false; if (wasRepaint && Date.now() - this.lastClearScrollbackTs <= MaxRepaintTransactionMs) { setTimeout(() => { + if (this.shouldPreserveAgentTuiUserViewport()) { + return; + } console.log("[termwrap] repaint transaction complete, scrolling to bottom"); this.terminal.scrollToBottom(); }, 20); @@ -313,7 +348,10 @@ export class TermWrap { this.connectElem.removeEventListener("drop", dropHandler); }, }); + this.installTerminalWheelRouter(); this.handleResize(); + this.scheduleDeferredResize(); + this.installImePositionFix(); const pasteHandler = this.pasteHandler.bind(this); this.connectElem.addEventListener("paste", pasteHandler, true); this.toDispose.push({ @@ -327,6 +365,579 @@ export class TermWrap { return this.blockId; } + isTextAreaFocused(): boolean { + const textarea = this.terminal.textarea; + return textarea != null && document.activeElement === textarea; + } + + isTerminalFocused(): boolean { + const activeElement = document.activeElement; + const textarea = this.terminal.textarea; + if (activeElement == null || textarea == null) { + return false; + } + return activeElement === textarea || this.connectElem.contains(activeElement); + } + + isImeOwner(): boolean { + return TermWrap.imeOwnerBlockId === this.blockId || this.isTextAreaFocused(); + } + + claimImeOwnership() { + if (!this.disposed && this.isTerminalFocused()) { + TermWrap.setImeOwnerBlockId(this.blockId); + } + } + + releaseImeOwnership() { + if (TermWrap.imeOwnerBlockId === this.blockId && !this.isTerminalFocused()) { + TermWrap.setImeOwnerBlockId(null); + } + } + + syncNativeTextAreaPosition() { + (this.terminal as any)?._core?._syncTextArea?.(); + } + + getWheelWholeLines(event: WheelEvent, bufferType: string): number { + if (this.wheelScrollBufferType !== bufferType) { + this.wheelScrollBufferType = bufferType; + this.wheelScrollRemainder = 0; + } + const cellHeight = (this.terminal as any)?._core?._renderService?.dimensions?.css?.cell?.height ?? 16; + const lineDelta = getWheelLineDelta(event.deltaY, event.deltaMode, cellHeight, this.terminal.rows); + if (lineDelta === 0) { + return 0; + } + this.wheelScrollRemainder += lineDelta; + const wholeLines = + this.wheelScrollRemainder > 0 ? Math.floor(this.wheelScrollRemainder) : Math.ceil(this.wheelScrollRemainder); + if (wholeLines === 0) { + return 0; + } + this.wheelScrollRemainder -= wholeLines; + return wholeLines; + } + + handleTerminalWheelEvent(event: WheelEvent): boolean { + const bufferType = this.terminal.buffer.active.type; + if (!shouldHandleTerminalWheel(event.defaultPrevented, bufferType)) { + this.wheelScrollRemainder = 0; + return false; + } + const wholeLines = this.getWheelWholeLines(event, bufferType); + if (wholeLines === 0) { + return false; + } + if (this.shouldRouteAgentTuiWheelToInput()) { + this.updateAgentTuiUserScrollLock(wholeLines, true); + this.sendDataHandler?.(wholeLines < 0 ? "\x1b[5~" : "\x1b[6~"); + event.preventDefault(); + event.stopPropagation(); + return true; + } + this.terminal.scrollLines(wholeLines); + this.updateAgentTuiUserScrollLock(wholeLines, true); + event.preventDefault(); + event.stopPropagation(); + return true; + } + + installTerminalWheelRouter() { + this.terminal.attachCustomWheelEventHandler((event: WheelEvent) => { + if (this.handleTerminalWheelEvent(event)) { + return false; + } + return true; + }); + const wheelFallbackHandler = (event: WheelEvent) => { + const bufferType = this.terminal.buffer.active.type; + if (bufferType !== "normal") { + this.wheelScrollRemainder = 0; + return; + } + this.handleTerminalWheelEvent(event); + }; + this.connectElem.addEventListener("wheel", wheelFallbackHandler, { passive: false }); + this.toDispose.push({ + dispose: () => { + this.connectElem.removeEventListener("wheel", wheelFallbackHandler, false); + }, + }); + this.toDispose.push( + this.terminal.onScroll(() => { + this.updateAgentTuiUserScrollLock(0, false); + }) + ); + } + + shouldCaptureAgentTuiTranscript(dataText: string): boolean { + const activeBuffer = this.terminal.buffer.active; + if (activeBuffer?.type !== "normal" || this.terminal.modes.mouseTrackingMode !== "none") { + return false; + } + if (this.isAgentTuiActive()) { + return true; + } + if (this.shouldPrimeAgentTuiTranscript(dataText)) { + this.armAgentTuiTranscriptCapture(); + return true; + } + return false; + } + + captureAgentTuiScreenSnapshotFromBuffer(buffer: TermTypes.IBuffer): string[] { + const rowCount = Math.max(1, this.terminal.rows); + const screenStart = Math.max(0, Math.min(buffer.baseY, Math.max(0, buffer.length - rowCount))); + const screenEnd = Math.min(buffer.length, screenStart + rowCount); + return extractAgentTuiHistoryLines(bufferLinesToText(buffer, screenStart, screenEnd)); + } + + ensureAgentTuiPreviewTerminal(): Terminal { + if (this.agentTuiPreviewTerminal != null) { + return this.agentTuiPreviewTerminal; + } + if (this.agentTuiPreviewTerminalHost == null) { + const host = document.createElement("div"); + host.className = "wave-agent-tui-preview-terminal"; + host.style.position = "fixed"; + host.style.left = "-10000px"; + host.style.top = "0"; + host.style.width = "1px"; + host.style.height = "1px"; + host.style.opacity = "0"; + host.style.pointerEvents = "none"; + host.style.overflow = "hidden"; + document.body.appendChild(host); + this.agentTuiPreviewTerminalHost = host; + } + this.agentTuiPreviewTerminal = new Terminal({ + allowTransparency: true, + convertEol: true, + cursorBlink: false, + cursorStyle: "block", + disableStdin: true, + drawBoldTextInBrightColors: this.terminal.options.drawBoldTextInBrightColors, + fontFamily: this.terminal.options.fontFamily, + fontSize: this.terminal.options.fontSize, + fontWeight: this.terminal.options.fontWeight, + fontWeightBold: this.terminal.options.fontWeightBold, + letterSpacing: this.terminal.options.letterSpacing, + lineHeight: this.terminal.options.lineHeight, + minimumContrastRatio: this.terminal.options.minimumContrastRatio, + scrollback: MaxTermScrollback, + tabStopWidth: this.terminal.options.tabStopWidth, + theme: this.terminal.options.theme, + windowsPty: this.terminal.options.windowsPty, + }); + this.agentTuiPreviewTerminal.parser.registerCsiHandler({ final: "J" }, (params) => { + return params != null && params.length >= 1 && params[0] === 3; + }); + this.agentTuiPreviewTerminal.open(this.agentTuiPreviewTerminalHost); + this.agentTuiPreviewTerminal.resize(this.terminal.cols, this.terminal.rows); + return this.agentTuiPreviewTerminal; + } + + writeToTerminalBuffer(terminal: Terminal, data: string | Uint8Array): Promise { + return new Promise((resolve) => { + terminal.write(data, resolve); + }); + } + + resetAgentTuiTranscriptState() { + this.agentTuiUserScrollLock = false; + this.agentTuiUserScrollVersion = 0; + this.agentTuiHistoryLines = []; + this.agentTuiInjectedLineCount = 0; + this.agentTuiLastSnapshotLines = []; + this.agentTuiPreviewTerminal?.dispose(); + this.agentTuiPreviewTerminal = null; + } + + armAgentTuiTranscriptCapture() { + if (this.agentTuiTranscriptArmed) { + return; + } + this.resetAgentTuiTranscriptState(); + this.agentTuiTranscriptArmed = true; + } + + getRecentTerminalTailText(): string { + const activeBuffer = this.terminal.buffer.active; + const tailStart = Math.max(0, activeBuffer.length - Math.max(this.terminal.rows * 2, 80)); + return bufferLinesToText(activeBuffer, tailStart, activeBuffer.length).join("\n"); + } + + shouldPrimeAgentTuiTranscript(dataText: string): boolean { + const activeBuffer = this.terminal.buffer.active; + const shellState = globalStore.get(this.shellIntegrationStatusAtom); + const lastCommand = globalStore.get(this.lastCommandAtom); + return shouldPrimeAgentTuiTranscriptCapture({ + activeBufferType: activeBuffer?.type, + mouseTrackingMode: this.terminal.modes.mouseTrackingMode, + shellState, + lastCommand, + dataText, + }); + } + + shouldSuppressAgentTuiClearScrollback(): boolean { + const activeBuffer = this.terminal.buffer.active; + if (activeBuffer?.type !== "normal") { + return false; + } + if (this.agentTuiTranscriptArmed || this.agentTuiLatched) { + return true; + } + const shellState = globalStore.get(this.shellIntegrationStatusAtom); + const lastCommand = globalStore.get(this.lastCommandAtom); + return shellState === "running-command" && isAgentTuiCommand(lastCommand); + } + + isAgentTuiRepaintData(dataText: string): boolean { + return /\x1b\[(?:\d+;)?\d*H/.test(dataText) || /\x1b\[\?2026[hl]/.test(dataText); + } + + updateAgentTuiUserScrollLock(wholeLines: number, userInitiated: boolean) { + const activeBuffer = this.terminal.buffer.active; + if (activeBuffer?.type !== "normal") { + this.agentTuiUserScrollLock = false; + return; + } + if (!(this.agentTuiTranscriptArmed || this.agentTuiLatched)) { + this.agentTuiUserScrollLock = false; + return; + } + if (userInitiated) { + this.agentTuiUserScrollVersion++; + } + if (wholeLines < 0 || activeBuffer.viewportY < activeBuffer.baseY) { + this.agentTuiUserScrollLock = true; + return; + } + if (activeBuffer.viewportY >= activeBuffer.baseY) { + this.agentTuiUserScrollLock = false; + } + } + + shouldPreserveAgentTuiUserViewport(): boolean { + const activeBuffer = this.terminal.buffer.active; + if (activeBuffer?.type !== "normal") { + return false; + } + return this.agentTuiUserScrollLock && activeBuffer.viewportY < activeBuffer.baseY; + } + + shouldRouteAgentTuiWheelToInput(): boolean { + const activeBuffer = this.terminal.buffer.active; + if (activeBuffer?.type !== "normal" || this.terminal.modes.mouseTrackingMode !== "none") { + return false; + } + if (activeBuffer.baseY > 0 || activeBuffer.length > this.terminal.rows + 1) { + return false; + } + return this.agentTuiTranscriptArmed || this.agentTuiLatched || this.isAgentTuiActive(); + } + + buildNativeScrollbackInjection(lines: string[]): string { + const safeLines = lines.map((line) => line.replace(/\x1b/g, "")); + const targetRow = Math.max(1, this.terminal.rows); + return `\x1b7\x1b[${targetRow};1H${safeLines.join("\r\n")}\r\n\x1b8`; + } + + captureAgentTuiPreviewHistoryLines(): string[] { + const previewBuffer = this.agentTuiPreviewTerminal?.buffer.active; + if (previewBuffer == null || previewBuffer.baseY <= 0) { + return []; + } + return extractAgentTuiHistoryLines(bufferLinesToText(previewBuffer, 0, previewBuffer.baseY)); + } + + async prepareAgentTuiAugmentedWrite(data: string | Uint8Array): Promise { + const dataText = data instanceof Uint8Array ? new TextDecoder().decode(data) : data; + if (!this.shouldCaptureAgentTuiTranscript(dataText)) { + return data; + } + const previewTerminal = this.ensureAgentTuiPreviewTerminal(); + if (this.agentTuiLastSnapshotLines.length === 0) { + this.agentTuiLastSnapshotLines = []; + } + await this.writeToTerminalBuffer(previewTerminal, dataText); + const nextSnapshot = this.captureAgentTuiScreenSnapshotFromBuffer(previewTerminal.buffer.active); + this.agentTuiLastSnapshotLines = nextSnapshot; + if (!this.isAgentTuiRepaintData(dataText)) { + return data; + } + const appendResult = reconcileAgentTuiSnapshotHistory( + this.agentTuiHistoryLines, + this.agentTuiInjectedLineCount, + nextSnapshot, + MaxTermScrollback + ); + this.agentTuiHistoryLines = appendResult.history; + this.agentTuiInjectedLineCount = appendResult.injectedLineCount; + const pendingLines = appendResult.pendingLines; + if (pendingLines.length === 0) { + return data; + } + return `${this.buildNativeScrollbackInjection(pendingLines)}${dataText}`; + } + + scheduleDeferredResize(forceTermSizeSync = false) { + const resize = () => { + if (!this.disposed) { + this.handleResize(forceTermSizeSync); + } + }; + setTimeout(resize, 0); + setTimeout(resize, 50); + setTimeout(resize, 250); + } + + shouldAnchorImeForAgentTui(): boolean { + return this.isAgentTuiActive(); + } + + isAgentTuiActive(): boolean { + const shellState = globalStore.get(this.shellIntegrationStatusAtom); + const lastCommand = globalStore.get(this.lastCommandAtom); + if (shellState === "running-command" && isAgentTuiCommand(lastCommand)) { + this.armAgentTuiTranscriptCapture(); + this.agentTuiLatched = true; + return true; + } + const tailText = this.getRecentTerminalTailText(); + const hasAgentMarkers = hasAgentTuiStrongMarker(tailText); + const lastVisibleLine = tailText + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .at(-1); + if (shellState === "ready" && lastVisibleLine != null && ShellPromptTailRegex.test(lastVisibleLine)) { + this.agentTuiLatched = false; + this.agentTuiTranscriptArmed = false; + this.agentTuiUserScrollLock = false; + this.agentTuiUserScrollVersion = 0; + return false; + } + if (hasAgentMarkers) { + this.armAgentTuiTranscriptCapture(); + this.agentTuiLatched = true; + return true; + } + const lastMode2026Ts = Math.max(this.lastMode2026SetTs, this.lastMode2026ResetTs); + if ( + shellState === "running-command" && + Date.now() - lastMode2026Ts <= MaxRepaintTransactionMs && + lastVisibleLine != null + ) { + if (!ShellPromptTailRegex.test(lastVisibleLine)) { + this.armAgentTuiTranscriptCapture(); + this.agentTuiLatched = true; + return true; + } + } + if (this.agentTuiLatched) { + if (lastVisibleLine != null && ShellPromptTailRegex.test(lastVisibleLine)) { + this.agentTuiLatched = false; + this.agentTuiTranscriptArmed = false; + this.agentTuiUserScrollLock = false; + this.agentTuiUserScrollVersion = 0; + return false; + } + return true; + } + return false; + } + + shouldApplyImePositionOverride(): boolean { + return this.isImeOwner() && this.shouldAnchorImeForAgentTui(); + } + + clearImePositionOverrides() { + if (!this.imePositionPatched) { + return; + } + const textarea = this.terminal.textarea; + const compositionView = this.connectElem.querySelector(".composition-view"); + if (textarea != null) { + textarea.style.removeProperty("top"); + textarea.style.removeProperty("left"); + textarea.style.removeProperty("width"); + textarea.style.removeProperty("height"); + textarea.style.removeProperty("line-height"); + textarea.style.removeProperty("z-index"); + } + if (compositionView != null) { + compositionView.style.removeProperty("top"); + compositionView.style.removeProperty("left"); + compositionView.style.removeProperty("height"); + compositionView.style.removeProperty("line-height"); + compositionView.style.removeProperty("z-index"); + } + this.imePositionPatched = false; + } + + syncImePositionForAgentTui() { + if (!this.shouldApplyImePositionOverride()) { + this.clearImePositionOverrides(); + return; + } + this.claimImeOwnership(); + this.syncNativeTextAreaPosition(); + const textarea = this.terminal.textarea; + const compositionView = this.connectElem.querySelector(".composition-view.active, .composition-view"); + if (textarea == null) { + return; + } + const cellHeight = (this.terminal as any)?._core?._renderService?.dimensions?.css?.cell?.height ?? 16; + const cellWidth = (this.terminal as any)?._core?._renderService?.dimensions?.css?.cell?.width ?? 8; + const top = textarea.style.top || "0px"; + const left = textarea.style.left || "0px"; + const nativeWidth = Number.parseFloat(textarea.style.width || "0"); + const nativeHeight = Number.parseFloat(textarea.style.height || "0"); + const lineHeight = `${Math.max(1, nativeHeight || cellHeight)}px`; + if (compositionView != null) { + compositionView.style.top = top; + compositionView.style.left = left; + compositionView.style.height = lineHeight; + compositionView.style.lineHeight = lineHeight; + compositionView.style.zIndex = "6"; + } + const compositionWidth = Math.max(compositionView?.getBoundingClientRect().width ?? 0, nativeWidth, cellWidth * 2, 1); + textarea.style.top = top; + textarea.style.left = left; + textarea.style.width = `${compositionWidth}px`; + textarea.style.height = lineHeight; + textarea.style.lineHeight = lineHeight; + textarea.style.zIndex = "5"; + this.imePositionPatched = true; + } + + scheduleImePositionSync() { + if (!this.isImeOwner()) { + this.clearImePositionOverrides(); + return; + } + this.syncImePositionForAgentTui(); + if (this.imePositionSyncScheduled) { + return; + } + this.imePositionSyncScheduled = true; + setTimeout(() => { + if (!this.disposed) { + this.syncImePositionForAgentTui(); + } + }, 0); + setTimeout(() => { + if (!this.disposed) { + this.syncImePositionForAgentTui(); + } + }, 16); + setTimeout(() => { + if (!this.disposed) { + this.syncImePositionForAgentTui(); + } + this.imePositionSyncScheduled = false; + }, 100); + } + + installImePositionFix() { + const textarea = this.terminal.textarea; + if (textarea == null) { + return; + } + const handleFocus = () => { + this.claimImeOwnership(); + this.syncNativeTextAreaPosition(); + if (this.shouldAnchorImeForAgentTui()) { + this.scheduleImePositionSync(); + } else { + this.clearImePositionOverrides(); + } + }; + const handleCompositionStart = () => { + this.claimImeOwnership(); + this.syncNativeTextAreaPosition(); + this.scheduleImePositionSync(); + }; + const handleCompositionUpdate = () => { + this.claimImeOwnership(); + this.scheduleImePositionSync(); + }; + const handleCompositionEnd = () => { + setTimeout(() => { + if (this.disposed) { + return; + } + if (this.shouldApplyImePositionOverride()) { + this.scheduleImePositionSync(); + } else { + this.clearImePositionOverrides(); + } + }, 0); + }; + const handleBlur = () => { + setTimeout(() => { + if (this.disposed) { + return; + } + this.releaseImeOwnership(); + if (!this.isImeOwner()) { + this.clearImePositionOverrides(); + } + }, 0); + }; + const handlePointerDown = () => { + setTimeout(() => { + if (this.disposed) { + return; + } + this.claimImeOwnership(); + }, 0); + }; + textarea.addEventListener("focus", handleFocus); + textarea.addEventListener("compositionstart", handleCompositionStart); + textarea.addEventListener("compositionupdate", handleCompositionUpdate); + textarea.addEventListener("compositionend", handleCompositionEnd); + textarea.addEventListener("blur", handleBlur); + this.connectElem.addEventListener("mousedown", handlePointerDown, true); + this.toDispose.push({ + dispose: () => { + textarea.removeEventListener("focus", handleFocus); + textarea.removeEventListener("compositionstart", handleCompositionStart); + textarea.removeEventListener("compositionupdate", handleCompositionUpdate); + textarea.removeEventListener("compositionend", handleCompositionEnd); + textarea.removeEventListener("blur", handleBlur); + this.connectElem.removeEventListener("mousedown", handlePointerDown, true); + this.clearImePositionOverrides(); + }, + }); + this.toDispose.push( + this.terminal.onRender(() => { + const compositionView = this.connectElem.querySelector(".composition-view.active"); + if (!this.isImeOwner()) { + this.clearImePositionOverrides(); + return; + } + if (compositionView != null) { + this.scheduleImePositionSync(); + return; + } + if (this.isTextAreaFocused()) { + this.syncNativeTextAreaPosition(); + if (this.shouldAnchorImeForAgentTui()) { + this.scheduleImePositionSync(); + } else { + this.clearImePositionOverrides(); + } + return; + } + this.clearImePositionOverrides(); + }) + ); + } + setCursorStyle(cursorStyle: string) { this.terminal.options.cursorStyle = normalizeCursorStyle(cursorStyle); } @@ -380,6 +991,7 @@ export class TermWrap { async initTerminal() { const copyOnSelectAtom = getSettingsKeyAtom("term:copyonselect"); + const trimTrailingWhitespaceAtom = getSettingsKeyAtom("term:trimtrailingwhitespace"); this.toDispose.push(this.terminal.onData(this.handleTermData.bind(this))); this.toDispose.push( this.terminal.onSelectionChange( @@ -393,8 +1005,11 @@ export class TermWrap { if (active != null && active.closest(".search-container") != null) { return; } - const selectedText = this.terminal.getSelection(); + let selectedText = this.terminal.getSelection(); if (selectedText.length > 0) { + if (globalStore.get(trimTrailingWhitespaceAtom) !== false) { + selectedText = trimTerminalSelection(selectedText); + } navigator.clipboard.writeText(selectedText); } }) @@ -428,15 +1043,23 @@ export class TermWrap { console.log("Error loading runtime info:", e); } - try { - await this.loadInitialTerminalData(); - } finally { - this.loaded = true; - } - this.runProcessIdleTimeout(); + this.loaded = true; + await this.flushHeldTerminalData(); + this.scheduleDeferredResize(true); + this.scheduleImePositionSync(); } dispose() { + this.disposed = true; + if (TermWrap.imeOwnerBlockId === this.blockId) { + TermWrap.setImeOwnerBlockId(null); + } + TermWrap.liveInstances.delete(this); + this.clearImePositionOverrides(); + this.agentTuiPreviewTerminal?.dispose(); + this.agentTuiPreviewTerminalHost?.remove(); + this.agentTuiPreviewTerminal = null; + this.agentTuiPreviewTerminalHost = null; this.promptMarkers.forEach((marker) => { try { marker.dispose(); @@ -473,12 +1096,16 @@ export class TermWrap { handleNewFileSubjectData(msg: WSFileEventData) { if (msg.fileop == "truncate") { + this.agentTuiTranscriptArmed = false; + this.agentTuiUserScrollLock = false; + this.agentTuiUserScrollVersion = 0; + this.resetAgentTuiTranscriptState(); this.terminal.clear(); this.heldData = []; } else if (msg.fileop == "append") { const decodedData = base64ToArray(msg.data64); if (this.loaded) { - this.doTerminalWrite(decodedData, null); + this.doTerminalWrite(decodedData); } else { this.heldData.push(decodedData); } @@ -488,7 +1115,18 @@ export class TermWrap { } } - doTerminalWrite(data: string | Uint8Array, setPtyOffset?: number): Promise { + async flushHeldTerminalData(): Promise { + if (this.heldData.length === 0) { + return; + } + const pendingData = this.heldData; + this.heldData = []; + for (const data of pendingData) { + await this.doTerminalWrite(data); + } + } + + doTerminalWrite(data: string | Uint8Array): Promise { if (isDev() && this.loaded) { const dataStr = data instanceof Uint8Array ? new TextDecoder().decode(data) : data; this.recentWrites.push({ idx: this.recentWritesCounter++, ts: Date.now(), data: dataStr }); @@ -496,55 +1134,32 @@ export class TermWrap { this.recentWrites.shift(); } } - let resolve: () => void = null; - const prtn = new Promise((presolve, _) => { - resolve = presolve; - }); - this.terminal.write(data, () => { - if (setPtyOffset != null) { - this.ptyOffset = setPtyOffset; - } else { - this.ptyOffset += data.length; - this.dataBytesProcessed += data.length; + const writePromise = this.terminalWriteQueue.then(async () => { + const preserveViewport = this.shouldPreserveAgentTuiUserViewport(); + const previousViewportY = preserveViewport ? this.terminal.buffer.active.viewportY : null; + const previousUserScrollVersion = this.agentTuiUserScrollVersion; + const nextData = await this.prepareAgentTuiAugmentedWrite(data); + await this.writeToTerminalBuffer(this.terminal, nextData); + if ( + preserveViewport && + previousViewportY != null && + previousUserScrollVersion === this.agentTuiUserScrollVersion + ) { + const activeBuffer = this.terminal.buffer.active; + const targetViewportY = Math.max(0, Math.min(previousViewportY, activeBuffer.baseY)); + if (activeBuffer.viewportY !== targetViewportY) { + this.terminal.scrollToLine(targetViewportY); + } } this.lastUpdated = Date.now(); - resolve(); - }); - return prtn; - } - - async loadInitialTerminalData(): Promise { - const startTs = Date.now(); - const zoneId = this.getZoneId(); - const { data: cacheData, fileInfo: cacheFile } = await fetchWaveFile(zoneId, TermCacheFileName); - let ptyOffset = 0; - if (cacheFile != null) { - ptyOffset = cacheFile.meta["ptyoffset"] ?? 0; - if (cacheData.byteLength > 0) { - const curTermSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; - const fileTermSize: TermSize = cacheFile.meta["termsize"]; - let didResize = false; - if ( - fileTermSize != null && - (fileTermSize.rows != curTermSize.rows || fileTermSize.cols != curTermSize.cols) - ) { - console.log("terminal restore size mismatch, temp resize", fileTermSize, curTermSize); - this.terminal.resize(fileTermSize.cols, fileTermSize.rows); - didResize = true; - } - this.doTerminalWrite(cacheData, ptyOffset); - if (didResize) { - this.terminal.resize(curTermSize.cols, curTermSize.rows); - } + if (document.activeElement === this.terminal.textarea || this.imePositionPatched) { + this.scheduleImePositionSync(); } - } - const { data: mainData, fileInfo: mainFile } = await fetchWaveFile(zoneId, TermFileName, ptyOffset); - console.log( - `terminal loaded cachefile:${cacheData?.byteLength ?? 0} main:${mainData?.byteLength ?? 0} bytes, ${Date.now() - startTs}ms` - ); - if (mainFile != null) { - await this.doTerminalWrite(mainData, null); - } + }); + this.terminalWriteQueue = writePromise.catch((error) => { + console.error("[termwrap] terminal write failed", this.blockId, error); + }); + return writePromise; } async resyncController(reason: string) { @@ -561,10 +1176,28 @@ export class TermWrap { } } - handleResize() { + syncControllerTermSize(reason: string) { + const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; + if (termSize.rows <= 0 || termSize.cols <= 0) { + return; + } + dlog("termsize sync", reason, `${termSize.rows}x${termSize.cols}`); + fireAndForget(async () => { + try { + await RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, termsize: termSize }); + } catch (e) { + console.warn("failed to sync terminal size", this.blockId, reason, e); + } + }); + } + + handleResize(forceTermSizeSync = false) { const oldRows = this.terminal.rows; const oldCols = this.terminal.cols; this.fitAddon.fit(); + if (this.agentTuiPreviewTerminal != null) { + this.agentTuiPreviewTerminal.resize(this.terminal.cols, this.terminal.rows); + } if (oldRows !== this.terminal.rows || oldCols !== this.terminal.cols) { const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; console.log( @@ -573,35 +1206,16 @@ export class TermWrap { "->", `${this.terminal.rows}x${this.terminal.cols}` ); - RpcApi.ControllerInputCommand(TabRpcClient, { blockid: this.blockId, termsize: termSize }); + this.syncControllerTermSize("resize"); + } else if (forceTermSizeSync) { + this.syncControllerTermSize("forced resize sync"); } dlog("resize", `${this.terminal.rows}x${this.terminal.cols}`, `${oldRows}x${oldCols}`, this.hasResized); if (!this.hasResized) { this.hasResized = true; this.resyncController("initial resize"); } - } - - processAndCacheData() { - if (this.dataBytesProcessed < MinDataProcessedForCache) { - return; - } - const serializedOutput = this.serializeAddon.serialize(); - const termSize: TermSize = { rows: this.terminal.rows, cols: this.terminal.cols }; - console.log("idle timeout term", this.dataBytesProcessed, serializedOutput.length, termSize); - fireAndForget(() => - services.BlockService.SaveTerminalState(this.blockId, serializedOutput, "full", this.ptyOffset, termSize) - ); - this.dataBytesProcessed = 0; - } - - runProcessIdleTimeout() { - setTimeout(() => { - window.requestIdleCallback(() => { - this.processAndCacheData(); - this.runProcessIdleTimeout(); - }); - }, 5000); + this.scheduleImePositionSync(); } async pasteHandler(e?: ClipboardEvent): Promise { diff --git a/frontend/app/view/webview/webview.tsx b/frontend/app/view/webview/webview.tsx index 551f23bbb7..2861f953c1 100644 --- a/frontend/app/view/webview/webview.tsx +++ b/frontend/app/view/webview/webview.tsx @@ -33,6 +33,19 @@ const USER_AGENT_ANDROID = let webviewPreloadUrl = null; +function makeDesktopChromeUserAgent(): string { + if (typeof navigator === "undefined" || navigator.userAgent == null) { + return null; + } + return navigator.userAgent + .replace(/\sElectron\/[^\s]+/g, "") + .replace(/\sWave\/[^\s]+/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +const DESKTOP_CHROME_USER_AGENT = makeDesktopChromeUserAgent(); + function getWebviewPreloadUrl(env: WebViewEnv) { if (webviewPreloadUrl == null) { webviewPreloadUrl = env.electron.getWebviewPreload(); @@ -72,6 +85,8 @@ export class WebViewModel implements ViewModel { searchAtoms?: SearchAtoms; typeaheadOpen: PrimitiveAtom; partitionOverride: PrimitiveAtom | null; + defaultUserAgent: Atom; + webPreferences: Atom; userAgentType: Atom; env: WebViewEnv; ctrlShiftUnsubFn: (() => void) | null = null; @@ -104,6 +119,8 @@ export class WebViewModel implements ViewModel { this.hideNav = this.env.getBlockMetaKeyAtom(blockId, "web:hidenav"); this.typeaheadOpen = atom(false); this.partitionOverride = null; + this.defaultUserAgent = atom(null); + this.webPreferences = atom(null); this.userAgentType = this.env.getBlockMetaKeyAtom(blockId, "web:useragenttype"); this.mediaPlaying = atom(false); @@ -487,6 +504,10 @@ export class WebViewModel implements ViewModel { globalStore.set(this.isLoading, isLoading); } + handleNewWindow(url: string) { + fireAndForget(() => openLink(url, true)); + } + async setHomepageUrl(url: string, scope: "global" | "block") { if (url != null && url != "") { switch (scope) { @@ -856,10 +877,12 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) const partitionOverride = useAtomValueSafe(model.partitionOverride); const metaPartition = useAtomValue(env.getBlockMetaKeyAtom(model.blockId, "web:partition")); const webPartition = partitionOverride || metaPartition || undefined; + const defaultUserAgent = useAtomValueSafe(model.defaultUserAgent); + const webPreferences = useAtomValueSafe(model.webPreferences); const userAgentType = useAtomValue(model.userAgentType) || "default"; // Determine user agent string based on type - let userAgent: string | undefined = undefined; + let userAgent: string | undefined = defaultUserAgent || undefined; if (userAgentType === "mobile:iphone") { userAgent = USER_AGENT_IPHONE; } else if (userAgentType === "mobile:android") { @@ -989,7 +1012,7 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) // Reload webview when user agent type changes useEffect(() => { if (prevUserAgentTypeRef.current !== userAgentType && domReady && model.webviewRef.current) { - let newUserAgent: string | undefined = undefined; + let newUserAgent: string | undefined = defaultUserAgent || undefined; if (userAgentType === "mobile:iphone") { newUserAgent = USER_AGENT_IPHONE; } else if (userAgentType === "mobile:android") { @@ -1004,7 +1027,7 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) model.webviewRef.current.reload(); } prevUserAgentTypeRef.current = userAgentType; - }, [userAgentType, domReady]); + }, [userAgentType, defaultUserAgent, domReady]); useEffect(() => { const webview = model.webviewRef.current; @@ -1020,7 +1043,7 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) const newWindowHandler = (e: any) => { e.preventDefault(); const newUrl = e.detail.url; - fireAndForget(() => openLink(newUrl, true)); + model.handleNewWindow(newUrl); }; const startLoadingHandler = () => { model.setRefreshIcon("xmark-large"); @@ -1110,6 +1133,8 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) allowpopups="true" partition={webPartition} useragent={userAgent} + // @ts-expect-error Electron webviewTag supports the webpreferences attribute. + webpreferences={webPreferences || undefined} /> {errorText && ( @@ -1123,4 +1148,4 @@ const WebView = memo(({ model, onFailLoad, blockRef, initialSrc }: WebViewProps) ); }); -export { WebView, WebViewPreviewFallback, getWebPreviewDisplayUrl }; +export { DESKTOP_CHROME_USER_AGENT, WebView, WebViewPreviewFallback, getWebPreviewDisplayUrl }; diff --git a/frontend/app/view/webview/webviewenv.ts b/frontend/app/view/webview/webviewenv.ts index 419b04c4eb..b954f837dd 100644 --- a/frontend/app/view/webview/webviewenv.ts +++ b/frontend/app/view/webview/webviewenv.ts @@ -6,6 +6,7 @@ import type { MetaKeyAtomFnType, SettingsKeyAtomFnType, WaveEnv, WaveEnvSubset } export type WebViewEnv = WaveEnvSubset<{ electron: { openExternal: WaveEnv["electron"]["openExternal"]; + openFeishuApp: WaveEnv["electron"]["openFeishuApp"]; getWebviewPreload: WaveEnv["electron"]["getWebviewPreload"]; clearWebviewStorage: WaveEnv["electron"]["clearWebviewStorage"]; getConfigDir: WaveEnv["electron"]["getConfigDir"]; diff --git a/frontend/app/workspace/widgets.tsx b/frontend/app/workspace/widgets.tsx index f11eca91da..6ba17abad8 100644 --- a/frontend/app/workspace/widgets.tsx +++ b/frontend/app/workspace/widgets.tsx @@ -2,6 +2,8 @@ // SPDX-License-Identifier: Apache-2.0 import { Tooltip } from "@/app/element/tooltip"; +import { atoms, getFocusedBlockId, globalStore, WOS } from "@/app/store/global"; +import { uxCloseBlock } from "@/app/store/keymodel"; import { TabRpcClient } from "@/app/store/wshrpcutil"; import { useWaveEnv, WaveEnv, WaveEnvSubset } from "@/app/waveenv/waveenv"; import { shouldIncludeWidgetForWorkspace } from "@/app/workspace/widgetfilter"; @@ -19,6 +21,8 @@ import { import clsx from "clsx"; import { useAtomValue } from "jotai"; import { memo, useCallback, useEffect, useRef, useState } from "react"; +import packageJson from "../../../package.json"; +import bundledWidgetsJson from "../../../pkg/wconfig/defaultconfig/widgets.json"; export type WidgetsEnv = WaveEnvSubset<{ isDev: WaveEnv["isDev"]; @@ -57,6 +61,24 @@ type WidgetPropsType = { async function handleWidgetSelect(widget: WidgetConfigType, env: WidgetsEnv) { const blockDef = widget.blockdef; + const widgetView = blockDef?.meta?.view; + if (widgetView === "feishu" || widgetView === "feishuweb") { + const staticTabId = globalStore.get(atoms.staticTabId); + const staticTabAtom = staticTabId ? WOS.getWaveObjectAtom(WOS.makeORef("tab", staticTabId)) : null; + const staticTab = staticTabAtom ? globalStore.get(staticTabAtom) : null; + const blockIds = staticTab?.blockids ?? []; + const focusedBlockId = getFocusedBlockId(); + const matchingBlockIds = blockIds.filter((blockId) => { + const blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); + const blockData = globalStore.get(blockAtom); + return blockData?.meta?.view === widgetView; + }); + if (matchingBlockIds.length > 0) { + const targetBlockId = matchingBlockIds.includes(focusedBlockId) ? focusedBlockId : matchingBlockIds[0]; + uxCloseBlock(targetBlockId); + return; + } + } env.createBlock(blockDef, widget.magnified); } @@ -378,7 +400,16 @@ const Widgets = memo(() => { const measurementRef = useRef(null); const featureWaveAppBuilder = fullConfig?.settings?.["feature:waveappbuilder"] ?? false; - const widgetsMap = fullConfig?.widgets ?? {}; + const packagedVersion = packageJson.version; + const backendVersion = fullConfig?.version; + const shouldUseBundledWidgetsFallback = + fullConfig != null && backendVersion != null && packagedVersion != null && backendVersion !== packagedVersion; + const widgetsMap = shouldUseBundledWidgetsFallback + ? ({ + ...(bundledWidgetsJson as { [key: string]: WidgetConfigType }), + ...(fullConfig?.widgets ?? {}), + } as { [key: string]: WidgetConfigType }) + : fullConfig?.widgets ?? {}; const filteredWidgets = Object.fromEntries( Object.entries(widgetsMap).filter(([_key, widget]) => shouldIncludeWidgetForWorkspace(widget, workspaceId)) ); diff --git a/frontend/app/workspace/workspace.tsx b/frontend/app/workspace/workspace.tsx index 08278a4eed..b165d14ef1 100644 --- a/frontend/app/workspace/workspace.tsx +++ b/frontend/app/workspace/workspace.tsx @@ -103,9 +103,9 @@ const WorkspaceElem = memo(() => { }, []); const innerHandleVisible = showLeftTabBar && aiPanelVisible; - const innerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${innerHandleVisible ? "w-0.5" : "w-0 pointer-events-none"}`; + const innerHandleClass = `transition-colors ${innerHandleVisible ? "w-[3px] cursor-col-resize bg-border/45 hover:bg-accent/45 active:bg-accent/60" : "w-0 pointer-events-none"}`; const outerHandleVisible = showLeftTabBar || aiPanelVisible; - const outerHandleClass = `bg-transparent hover:bg-zinc-500/20 transition-colors ${outerHandleVisible ? "w-0.5" : "w-0 pointer-events-none"}`; + const outerHandleClass = `transition-colors ${outerHandleVisible ? "w-[3px] cursor-col-resize bg-border/45 hover:bg-accent/45 active:bg-accent/60" : "w-0 pointer-events-none"}`; return (
diff --git a/frontend/layout/lib/TileLayout.tsx b/frontend/layout/lib/TileLayout.tsx index fa4ec9a030..805b34a6b3 100644 --- a/frontend/layout/lib/TileLayout.tsx +++ b/frontend/layout/lib/TileLayout.tsx @@ -53,6 +53,8 @@ export interface TileLayoutProps { const DragPreviewWidth = 300; const DragPreviewHeight = 300; +const DragHoverThrottleMs = 16; +const DragPreviewMaxPixelRatio = 1.5; function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutProps) { const layoutModel = useTileLayout(tabAtom, contents); @@ -66,11 +68,11 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr dragClientOffset: monitor.getClientOffset(), dragItemType: monitor.getItemType(), })); + const activeTileDrag = activeDrag && dragItemType == tileItemType; useEffect(() => { - const activeTileDrag = activeDrag && dragItemType == tileItemType; setActiveDrag(activeTileDrag); - }, [activeDrag, dragItemType]); + }, [activeTileDrag]); const checkForCursorBounds = useCallback( debounce(100, (dragClientOffset: XYCoord) => { @@ -120,7 +122,10 @@ function TileLayoutComponent({ tabAtom, contents, getCursorPoint }: TileLayoutPr return (
@@ -227,6 +232,7 @@ const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => { const previewRef = useRef(null); const addlProps = useAtomValue(nodeModel.additionalProps); const devicePixelRatio = useDevicePixelRatio(); + const previewPixelRatio = Math.min(Math.max(devicePixelRatio || 1, 1), DragPreviewMaxPixelRatio); const isEphemeral = useAtomValue(nodeModel.isEphemeral); const isMagnified = useAtomValue(nodeModel.isMagnified); @@ -253,25 +259,25 @@ const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => { style={{ width: DragPreviewWidth, height: DragPreviewHeight, - transform: `scale(${1 / devicePixelRatio})`, + transform: `scale(${1 / previewPixelRatio})`, }} > {layoutModel.renderPreview?.(nodeModel)}
); - }, [devicePixelRatio, nodeModel]); + }, [nodeModel, previewPixelRatio]); const [previewImage, setPreviewImage] = useState(null); const [previewImageGeneration, setPreviewImageGeneration] = useState(0); const generatePreviewImage = useCallback(() => { - const offsetX = (DragPreviewWidth * devicePixelRatio - DragPreviewWidth) / 2 + 10; - const offsetY = (DragPreviewHeight * devicePixelRatio - DragPreviewHeight) / 2 + 10; + const offsetX = (DragPreviewWidth * previewPixelRatio - DragPreviewWidth) / 2 + 10; + const offsetY = (DragPreviewHeight * previewPixelRatio - DragPreviewHeight) / 2 + 10; if (previewImage !== null && previewElementGeneration === previewImageGeneration) { dragPreview(previewImage, { offsetY, offsetX }); } else if (previewRef.current) { setPreviewImageGeneration(previewElementGeneration); - toPng(previewRef.current).then((url) => { + toPng(previewRef.current, { pixelRatio: previewPixelRatio }).then((url) => { const img = new Image(); img.src = url; setPreviewImage(img); @@ -284,7 +290,7 @@ const DisplayNode = ({ layoutModel, node }: DisplayNodeProps) => { previewElementGeneration, previewImageGeneration, previewImage, - devicePixelRatio, + previewPixelRatio, ]); const leafContent = useMemo(() => { @@ -373,7 +379,7 @@ const OverlayNode = memo(({ node, layoutModel }: OverlayNodeProps) => { layoutModel.onDrop(); } }, - hover: throttle(50, (_, monitor: DropTargetMonitor) => { + hover: throttle(DragHoverThrottleMs, (_, monitor: DropTargetMonitor) => { if (monitor.isOver({ shallow: true })) { if (monitor.canDrop() && layoutModel.displayContainerRef?.current && additionalProps?.rect) { const dragItem = monitor.getItem(); diff --git a/frontend/layout/lib/tilelayout.scss b/frontend/layout/lib/tilelayout.scss index 850e61bcdf..99ced9c078 100644 --- a/frontend/layout/lib/tilelayout.scss +++ b/frontend/layout/lib/tilelayout.scss @@ -43,29 +43,34 @@ z-index: var(--zindex-layout-resize-handle); .line { - visibility: hidden; + opacity: 0.75; + transition: + opacity var(--animation-time-xs) ease, + border-color var(--animation-time-xs) ease; } &.flex-row { cursor: ew-resize; .line { height: 100%; width: calc(50% + 1px); - border-right: 2px solid var(--accent-color); + border-right: 2px solid rgb(from var(--border-color) r g b / 0.95); } } &.flex-column { cursor: ns-resize; .line { height: calc(50% + 1px); - border-bottom: 2px solid var(--accent-color); + border-bottom: 2px solid rgb(from var(--border-color) r g b / 0.95); } } &:hover .line { - visibility: visible; - - // Ignore the prefers-reduced-motion override, since we are not applying a true animation here, just a delay. - transition-property: visibility !important; - transition-delay: var(--animation-time-s) !important; + opacity: 1; + } + &.flex-row:hover .line { + border-right-color: var(--accent-color); + } + &.flex-column:hover .line { + border-bottom-color: var(--accent-color); } } @@ -74,14 +79,20 @@ overflow: hidden; width: 100%; height: 100%; + contain: layout paint; + will-change: transform, width, height, opacity; &.dragging { - filter: blur(8px); + opacity: 0.22; + filter: none; } &.resizing { - border: 1px solid var(--accent-color); - backdrop-filter: blur(8px); + border: 1px solid rgb(from var(--accent-color) r g b / 0.45); + backdrop-filter: none; + box-shadow: + inset 0 0 0 1px rgb(from var(--accent-color) r g b / 0.18), + 0 0 0 1px rgb(from var(--accent-color) r g b / 0.12); } .tile-leaf { @@ -115,7 +126,9 @@ left: 0; width: 100%; height: 100%; + background: rgb(from var(--main-bg-color) r g b / 0.1); backdrop-filter: blur(var(--block-blur)); + will-change: opacity; } .magnified-node-backdrop { @@ -130,8 +143,16 @@ .tile-node, .placeholder { transition-duration: var(--animation-time-s); - transition-timing-function: linear; - transition-property: transform, width, height, background-color; + transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1); + transition-property: transform, width, height, background-color, opacity, box-shadow; + } + } + + &.dragging { + .tile-node, + .placeholder { + transition-duration: 0.06s; + transition-timing-function: ease-out; } } @@ -145,5 +166,6 @@ background-color: var(--accent-color); opacity: 0.5; border-radius: calc(var(--block-border-radius) + 2px); + box-shadow: 0 12px 28px -20px rgb(from var(--accent-color) r g b / 0.65); } } diff --git a/frontend/tailwindsetup.css b/frontend/tailwindsetup.css index 3a0523c8ce..efbc8965da 100644 --- a/frontend/tailwindsetup.css +++ b/frontend/tailwindsetup.css @@ -6,38 +6,38 @@ @source "../node_modules/streamdown/dist/index.js"; @theme { - --color-background: rgb(34, 34, 34); - --color-foreground: #f7f7f7; - --color-white: #f7f7f7; - --color-primary: #f7f7f7; - --color-muted-foreground: rgb(195, 200, 194); - --color-secondary: rgb(195, 200, 194); - --color-muted: rgb(140, 145, 140); - --color-accent-50: rgb(236, 253, 232); - --color-accent-100: rgb(209, 250, 202); - --color-accent-200: rgb(167, 243, 168); - --color-accent-300: rgb(110, 231, 133); - --color-accent-400: rgb(88, 193, 66); /* main accent color */ - --color-accent-500: rgb(63, 162, 51); - --color-accent-600: rgb(47, 133, 47); - --color-accent-700: rgb(34, 104, 43); - --color-accent-800: rgb(22, 81, 35); - --color-accent-900: rgb(15, 61, 29); + --color-background: rgb(11, 18, 26); + --color-foreground: #eef4fb; + --color-white: #eef4fb; + --color-primary: #eef4fb; + --color-muted-foreground: rgb(179, 194, 209); + --color-secondary: rgb(179, 194, 209); + --color-muted: rgb(132, 149, 167); + --color-accent-50: rgb(238, 254, 248); + --color-accent-100: rgb(214, 252, 238); + --color-accent-200: rgb(176, 247, 218); + --color-accent-300: rgb(132, 239, 195); + --color-accent-400: rgb(102, 214, 174); /* main accent color */ + --color-accent-500: rgb(79, 184, 147); + --color-accent-600: rgb(60, 153, 122); + --color-accent-700: rgb(47, 120, 97); + --color-accent-800: rgb(37, 92, 76); + --color-accent-900: rgb(28, 70, 58); --color-error: rgb(229, 77, 46); --color-warning: rgb(224, 185, 86); --color-success: rgb(78, 154, 6); - --color-panel: rgba(31, 33, 31, 0.5); - --color-hover: rgba(255, 255, 255, 0.1); - --color-border: rgba(255, 255, 255, 0.16); - --color-modalbg: #232323; - --color-accentbg: rgba(88, 193, 66, 0.5); - --color-hoverbg: rgba(255, 255, 255, 0.2); - --color-highlightbg: rgba(255, 255, 255, 0.2); - --color-accent: rgb(88, 193, 66); - --color-accenthover: rgb(118, 223, 96); - - --font-sans: "Inter", sans-serif; - --font-mono: "Hack", monospace; + --color-panel: rgba(16, 28, 39, 0.72); + --color-hover: rgba(255, 255, 255, 0.08); + --color-border: rgba(148, 177, 204, 0.18); + --color-modalbg: #16202b; + --color-accentbg: rgba(102, 214, 174, 0.26); + --color-hoverbg: rgba(140, 187, 255, 0.12); + --color-highlightbg: rgba(140, 187, 255, 0.14); + --color-accent: rgb(102, 214, 174); + --color-accenthover: rgb(126, 232, 196); + + --font-sans: "Segoe UI Variable Text", "Segoe UI", "Inter", sans-serif; + --font-mono: "JetBrains Mono", "Hack", "Cascadia Mono", monospace; --font-markdown: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts index 06157e2566..3e7497b1ef 100644 --- a/frontend/types/custom.d.ts +++ b/frontend/types/custom.d.ts @@ -77,6 +77,14 @@ declare global { windowId: string; }; + type OpenFeishuResult = { + opened: boolean; + method: string; + fallbackUrl: string; + appPath?: string; + error?: string; + }; + type ElectronApi = { getAuthKey(): string; // get-auth-key getIsDev(): boolean; // get-is-dev @@ -99,6 +107,7 @@ declare global { onIframeNavigate: (callback: (url: string) => void) => void; downloadFile: (path: string) => void; // download openExternal: (url: string) => void; // open-external + openFeishuApp: () => Promise; // open-feishu-app onFullScreenChange: (callback: (isFullScreen: boolean) => void) => void; // fullscreen-change onZoomFactorChange: (callback: (zoomFactor: number) => void) => void; // zoom-factor-change onUpdaterStatusChange: (callback: (status: UpdaterStatus) => void) => void; // app-update-status diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index 402757e121..697bed7e84 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -1434,6 +1434,8 @@ declare global { "web:openlinksinternally"?: boolean; "web:defaulturl"?: string; "web:defaultsearch"?: string; + "feishu:*"?: boolean; + "feishu:apppath"?: string; "autoupdate:*"?: boolean; "autoupdate:enabled"?: boolean; "autoupdate:intervalms"?: number; diff --git a/frontend/util/historyutil.test.ts b/frontend/util/historyutil.test.ts new file mode 100644 index 0000000000..8734e07984 --- /dev/null +++ b/frontend/util/historyutil.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { getParentDirectory, goHistoryBack } from "./historyutil"; + +describe("getParentDirectory", () => { + it("handles POSIX and home-relative paths", () => { + expect(getParentDirectory("/")).toBe("/"); + expect(getParentDirectory("/Users/wave/Downloads")).toBe("/Users/wave"); + expect(getParentDirectory("/Users/wave/Downloads/")).toBe("/Users/wave"); + expect(getParentDirectory("~/Downloads")).toBe("~"); + expect(getParentDirectory("~")).toBe("~"); + expect(getParentDirectory("")).toBe("/"); + }); + + it("handles Windows drive paths", () => { + expect(getParentDirectory("C:\\Users\\wave\\Downloads")).toBe("C:\\Users\\wave"); + expect(getParentDirectory("C:\\Users\\wave\\Downloads\\")).toBe("C:\\Users\\wave"); + expect(getParentDirectory("C:\\Users\\wave/Downloads")).toBe("C:\\Users\\wave"); + expect(getParentDirectory("C:\\Users")).toBe("C:\\"); + expect(getParentDirectory("C:\\")).toBe("C:\\"); + expect(getParentDirectory("C:")).toBe("C:\\"); + expect(getParentDirectory("C:/Users/wave/Downloads")).toBe("C:/Users/wave"); + expect(getParentDirectory("C:/Users")).toBe("C:/"); + }); + + it("handles UNC paths", () => { + expect(getParentDirectory("\\\\server\\share\\folder")).toBe("\\\\server\\share"); + expect(getParentDirectory("\\\\server\\share\\folder\\")).toBe("\\\\server\\share"); + expect(getParentDirectory("\\\\server\\share")).toBe("\\\\server\\share"); + expect(getParentDirectory("//server/share/folder")).toBe("//server/share"); + }); +}); + +describe("goHistoryBack", () => { + it("falls back to Windows parent directory when history is empty", () => { + expect(goHistoryBack("file", "C:\\Users\\wave\\Downloads", {}, true)).toEqual({ + file: "C:\\Users\\wave", + "history:forward": ["C:\\Users\\wave\\Downloads"], + }); + }); +}); diff --git a/frontend/util/historyutil.ts b/frontend/util/historyutil.ts index 73e4da201e..8c3377d217 100644 --- a/frontend/util/historyutil.ts +++ b/frontend/util/historyutil.ts @@ -5,22 +5,109 @@ import * as util from "@/util/util"; const MaxHistory = 20; -// this needs to be fixed for windows +const windowsDriveRootRe = /^[a-zA-Z]:[\\/]?$/; +const windowsDrivePathRe = /^[a-zA-Z]:[\\/]/; + +function isPathSeparator(ch: string): boolean { + return ch == "/" || ch == "\\"; +} + +function getWindowsDriveRoot(path: string): string | null { + if (!windowsDrivePathRe.test(path) && !windowsDriveRootRe.test(path)) { + return null; + } + return path.substring(0, 2) + (path.includes("/") && !path.includes("\\") ? "/" : "\\"); +} + +function getUncRootEnd(path: string): number { + if (path.length < 3 || !isPathSeparator(path[0]) || path[0] != path[1]) { + return -1; + } + + let serverEnd = -1; + for (let index = 2; index < path.length; index++) { + if (isPathSeparator(path[index])) { + serverEnd = index; + break; + } + } + if (serverEnd == -1) { + return path.length; + } + + for (let index = serverEnd + 1; index < path.length; index++) { + if (isPathSeparator(path[index])) { + return index; + } + } + return path.length; +} + +function trimTrailingSeparators(path: string): string { + const windowsDriveRoot = getWindowsDriveRoot(path); + if (windowsDriveRoot != null && path.length <= windowsDriveRoot.length) { + return windowsDriveRoot; + } + + const uncRootEnd = getUncRootEnd(path); + let minLength = 1; + if (windowsDriveRoot != null) { + minLength = windowsDriveRoot.length; + } else if (uncRootEnd != -1) { + minLength = uncRootEnd; + } + + let end = path.length; + while (end > minLength && isPathSeparator(path[end - 1])) { + end--; + } + return path.substring(0, end); +} + function getParentDirectory(path: string): string { - if (util.isBlank(path) == null) { + if (util.isBlank(path)) { // this not great, ideally we'd never be passed a null path return "/"; } - if (path == "/") { - return "/"; + if (path == "/" || path == "~") { + return path; + } + + const windowsDriveRoot = getWindowsDriveRoot(path); + if (windowsDriveRoot != null && path.length <= windowsDriveRoot.length) { + return windowsDriveRoot; + } + + const trimmedPath = trimTrailingSeparators(path); + if (trimmedPath == "/" || trimmedPath == "~") { + return trimmedPath; + } + + const uncRootEnd = getUncRootEnd(trimmedPath); + if (uncRootEnd != -1 && trimmedPath.length <= uncRootEnd) { + return trimmedPath; } - const splitPath = path.split("/"); - splitPath.pop(); - if (splitPath.length == 1 && splitPath[0] == "") { + + let lastSeparatorIndex = -1; + for (let index = trimmedPath.length - 1; index >= 0; index--) { + if (isPathSeparator(trimmedPath[index])) { + lastSeparatorIndex = index; + break; + } + } + if (lastSeparatorIndex == -1) { + return trimmedPath; + } + if (lastSeparatorIndex == 0) { return "/"; } - const newPath = splitPath.join("/"); - return newPath; + if (windowsDriveRoot != null && lastSeparatorIndex <= windowsDriveRoot.length - 1) { + return windowsDriveRoot; + } + if (uncRootEnd != -1 && lastSeparatorIndex <= uncRootEnd) { + return trimmedPath.substring(0, uncRootEnd); + } + return trimmedPath.substring(0, lastSeparatorIndex); } function goHistoryBack(curValKey: "url" | "file", curVal: string, meta: MetaType, backToParent: boolean): MetaType { diff --git a/frontend/wave.ts b/frontend/wave.ts index 20ee2ba97a..dc871c245b 100644 --- a/frontend/wave.ts +++ b/frontend/wave.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import { App } from "@/app/app"; -import { loadMonaco } from "@/app/monaco/monaco-env"; import { loadBadges } from "@/app/store/badge"; import { GlobalModel } from "@/app/store/global-model"; import { @@ -33,12 +32,15 @@ import * as WOS from "@/store/wos"; import { loadFonts } from "@/util/fontutil"; import { setKeyUtilPlatform } from "@/util/keyutil"; import { isMacOS, setMacOSVersion } from "@/util/platformutil"; +import { fireAndForget } from "@/util/util"; import { createElement } from "react"; import { createRoot } from "react-dom/client"; const platform = getApi().getPlatform(); document.title = `Wave Terminal`; let savedInitOpts: WaveInitOpts = null; +let tabTitleUnsub: (() => void) | null = null; +let monacoLoadPromise: Promise | null = null; (window as any).WOS = WOS; (window as any).globalStore = globalStore; @@ -56,10 +58,55 @@ function updateZoomFactor(zoomFactor: number) { document.documentElement.style.setProperty("--zoomfactor-inv", String(1 / zoomFactor)); } +function ensureMonacoLoaded(): Promise { + if (monacoLoadPromise == null) { + monacoLoadPromise = import("@/app/monaco/monaco-env").then(({ loadMonaco }) => { + loadMonaco(); + }); + } + return monacoLoadPromise; +} + +function preloadMonaco() { + fireAndForget(async () => { + try { + await ensureMonacoLoaded(); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + getApi().sendLog("Error preloading Monaco " + error.message + "\n" + error.stack); + console.error("Error preloading Monaco", e); + } + }); +} + +function formatWaveWindowTitle(tabName?: string | null) { + const trimmedTabName = tabName?.trim(); + return trimmedTabName ? `Wave Terminal - ${trimmedTabName}` : "Wave Terminal"; +} + +function installWaveWindowTitleSync(tabId: string) { + tabTitleUnsub?.(); + tabTitleUnsub = null; + if (!tabId) { + document.title = formatWaveWindowTitle(); + return; + } + const tabAtom = WOS.getWaveObjectAtom(WOS.makeORef("tab", tabId)); + const updateTitle = () => { + const tab = globalStore.get(tabAtom); + document.title = formatWaveWindowTitle(tab?.name); + }; + updateTitle(); + tabTitleUnsub = globalStore.sub(tabAtom, updateTitle); +} + async function initBare() { + const initBareTs = Date.now(); getApi().sendLog("Init Bare"); document.body.style.visibility = "hidden"; document.body.style.opacity = "0"; + document.documentElement.classList.add(platform); + document.body.classList.add(platform); document.body.classList.add("is-transparent"); getApi().onWaveInit(initWaveWrap); getApi().onBuilderInit(initBuilderWrap); @@ -69,9 +116,12 @@ async function initBare() { getApi().onZoomFactorChange((zoomFactor) => { updateZoomFactor(zoomFactor); }); - document.fonts.ready.then(() => { - console.log("Init Bare Done"); + setTimeout(() => { + console.log("Init Bare Ready", Date.now() - initBareTs + "ms"); getApi().setWindowInitStatus("ready"); + }, 0); + document.fonts.ready.then(() => { + console.log("Init Bare Fonts Ready", Date.now() - initBareTs + "ms"); }); } @@ -113,7 +163,7 @@ async function reinitWave() { const initialTab = await WOS.reloadWaveObject(WOS.makeORef("tab", savedInitOpts.tabId)); await WOS.reloadWaveObject(WOS.makeORef("layout", initialTab.layoutstate)); reloadAllWorkspaceTabs(ws); - document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change + installWaveWindowTitleSync(initialTab.oid); getApi().setWindowInitStatus("wave-ready"); globalStore.set(atoms.reinitVersion, globalStore.get(atoms.reinitVersion) + 1); globalStore.set(atoms.updaterStatusAtom, getApi().getUpdaterStatus()); @@ -160,11 +210,15 @@ async function initWave(initOpts: WaveInitOpts) { const globalWS = initWshrpc(makeTabRouteId(initOpts.tabId)); (window as any).globalWS = globalWS; (window as any).TabRpcClient = TabRpcClient; + const startupConfigPromise = Promise.all([ + RpcApi.GetFullConfigCommand(TabRpcClient), + RpcApi.GetWaveAIModeConfigCommand(TabRpcClient), + ]); + startupConfigPromise.catch(() => undefined); // ensures client/window/workspace are loaded into the cache before rendering try { - await loadConnStatus(); - await loadBadges(); + await Promise.all([loadConnStatus(), loadBadges()]); initGlobalWaveEventSubs(initOpts); subscribeToConnEvents(); if (isMacOS()) { @@ -182,7 +236,7 @@ async function initWave(initOpts: WaveInitOpts) { ]); loadAllWorkspaceTabs(ws); WOS.wpsSubscribeToObject(WOS.makeORef("workspace", waveWindow.workspaceid)); - document.title = `Wave Terminal - ${initialTab.name}`; // TODO update with tab name change + installWaveWindowTitleSync(initialTab.oid); } catch (e) { console.error("Failed initialization error", e); getApi().sendLog("Error in initialization (wave.ts, loading required objects) " + e.message + "\n" + e.stack); @@ -190,11 +244,9 @@ async function initWave(initOpts: WaveInitOpts) { registerGlobalKeys(); registerElectronReinjectKeyHandler(); registerControlShiftStateUpdateHandler(); - await loadMonaco(); - const fullConfig = await RpcApi.GetFullConfigCommand(TabRpcClient); + const [fullConfig, waveaiModeConfig] = await startupConfigPromise; console.log("fullconfig", fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig); - const waveaiModeConfig = await RpcApi.GetWaveAIModeConfigCommand(TabRpcClient); globalStore.set(atoms.waveaiModeConfigAtom, waveaiModeConfig.configs); console.log("Wave First Render"); let firstRenderResolveFn: () => void = null; @@ -208,6 +260,7 @@ async function initWave(initOpts: WaveInitOpts) { await firstRenderPromise; console.log("Wave First Render Done"); getApi().setWindowInitStatus("wave-ready"); + preloadMonaco(); } async function initBuilderWrap(initOpts: BuilderInitOpts) { @@ -261,7 +314,7 @@ async function initBuilder(initOpts: BuilderInitOpts) { registerBuilderGlobalKeys(); registerElectronReinjectKeyHandler(); - await loadMonaco(); + await ensureMonacoLoaded(); const fullConfig = await RpcApi.GetFullConfigCommand(TabRpcClient); console.log("fullconfig", fullConfig); globalStore.set(atoms.fullConfigAtom, fullConfig); diff --git a/package-lock.json b/package-lock.json index 4d8200a859..74f67cede0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.4", + "version": "2026.4.21-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.4", + "version": "2026.4.21-2", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/package.json b/package.json index 26098d270e..ad5a8ed2ff 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "productName": "Wave", "description": "Open-Source AI-Native Terminal Built for Seamless Workflows", "license": "Apache-2.0", - "version": "0.14.4", + "version": "2026.4.21-2", "homepage": "https://waveterm.dev", "build": { "appId": "dev.commandline.waveterm" diff --git a/pkg/filestore/blockstore.go b/pkg/filestore/blockstore.go index 55ce70183a..7b30489bcb 100644 --- a/pkg/filestore/blockstore.go +++ b/pkg/filestore/blockstore.go @@ -9,6 +9,7 @@ package filestore import ( "context" + "errors" "fmt" "io/fs" "log" @@ -37,18 +38,21 @@ const ( const DefaultPartDataSize = 64 * 1024 const DefaultFlushTime = 5 * time.Second +const DefaultFlushDelay = 500 * time.Millisecond const NoPartIdx = -1 // for unit tests var warningCount = &atomic.Int32{} var flushErrorCount = &atomic.Int32{} +var ErrFlushInProgress = errors.New("flush already in progress") var partDataSize int64 = DefaultPartDataSize // overridden in tests var stopFlush = &atomic.Bool{} var WFS *FileStore = &FileStore{ - Lock: &sync.Mutex{}, - Cache: make(map[cacheKey]*CacheEntry), + Lock: &sync.Mutex{}, + Cache: make(map[cacheKey]*CacheEntry), + FlushNotifyCh: make(chan struct{}, 1), } type WaveFile struct { @@ -204,7 +208,7 @@ func (s *FileStore) ListFiles(ctx context.Context, zoneId string) ([]*WaveFile, } func (s *FileStore) WriteMeta(ctx context.Context, zoneId string, name string, meta wshrpc.FileMeta, merge bool) error { - return withLock(s, zoneId, name, func(entry *CacheEntry) error { + err := withLock(s, zoneId, name, func(entry *CacheEntry) error { err := entry.loadFileIntoCache(ctx) if err != nil { return err @@ -223,6 +227,10 @@ func (s *FileStore) WriteMeta(ctx context.Context, zoneId string, name string, m entry.File.ModTs = time.Now().UnixMilli() return nil }) + if err == nil { + s.notifyFlusher() + } + return err } func (s *FileStore) WriteFile(ctx context.Context, zoneId string, name string, data []byte) error { @@ -241,7 +249,7 @@ func (s *FileStore) WriteAt(ctx context.Context, zoneId string, name string, off if offset < 0 { return fmt.Errorf("offset must be non-negative") } - return withLock(s, zoneId, name, func(entry *CacheEntry) error { + err := withLock(s, zoneId, name, func(entry *CacheEntry) error { err := entry.loadFileIntoCache(ctx) if err != nil { return err @@ -259,10 +267,14 @@ func (s *FileStore) WriteAt(ctx context.Context, zoneId string, name string, off entry.writeAt(offset, data, false) return nil }) + if err == nil { + s.notifyFlusher() + } + return err } func (s *FileStore) AppendData(ctx context.Context, zoneId string, name string, data []byte) error { - return withLock(s, zoneId, name, func(entry *CacheEntry) error { + err := withLock(s, zoneId, name, func(entry *CacheEntry) error { err := entry.loadFileIntoCache(ctx) if err != nil { return err @@ -278,6 +290,10 @@ func (s *FileStore) AppendData(ctx context.Context, zoneId string, name string, entry.writeAt(entry.File.Size, data, false) return nil }) + if err == nil { + s.notifyFlusher() + } + return err } func metaIncrement(file *WaveFile, key string, amount int) int { @@ -308,7 +324,7 @@ func (s *FileStore) compactIJson(ctx context.Context, entry *CacheEntry) error { } func (s *FileStore) CompactIJson(ctx context.Context, zoneId string, name string) error { - return withLock(s, zoneId, name, func(entry *CacheEntry) error { + err := withLock(s, zoneId, name, func(entry *CacheEntry) error { err := entry.loadFileIntoCache(ctx) if err != nil { return err @@ -318,6 +334,10 @@ func (s *FileStore) CompactIJson(ctx context.Context, zoneId string, name string } return s.compactIJson(ctx, entry) }) + if err == nil { + s.notifyFlusher() + } + return err } func (s *FileStore) AppendIJson(ctx context.Context, zoneId string, name string, command map[string]any) error { @@ -325,7 +345,7 @@ func (s *FileStore) AppendIJson(ctx context.Context, zoneId string, name string, if err != nil { return err } - return withLock(s, zoneId, name, func(entry *CacheEntry) error { + err = withLock(s, zoneId, name, func(entry *CacheEntry) error { err := entry.loadFileIntoCache(ctx) if err != nil { return err @@ -359,6 +379,10 @@ func (s *FileStore) AppendIJson(ctx context.Context, zoneId string, name string, } return nil }) + if err == nil { + s.notifyFlusher() + } + return err } func (s *FileStore) GetAllZoneIds(ctx context.Context) ([]string, error) { @@ -396,7 +420,7 @@ type FlushStats struct { func (s *FileStore) FlushCache(ctx context.Context) (stats FlushStats, rtnErr error) { wasFlushing := s.setUnlessFlushing() if wasFlushing { - return stats, fmt.Errorf("flush already in progress") + return stats, ErrFlushInProgress } defer s.setIsFlushing(false) startTime := time.Now() @@ -486,6 +510,16 @@ func (s *FileStore) getDirtyCacheKeys() []cacheKey { return dirtyCacheKeys } +func (s *FileStore) notifyFlusher() { + if s == nil || s.FlushNotifyCh == nil { + return + } + select { + case s.FlushNotifyCh <- struct{}{}: + default: + } +} + func (s *FileStore) setIsFlushing(flushing bool) { s.Lock.Lock() defer s.Lock.Unlock() @@ -513,17 +547,52 @@ func (s *FileStore) runFlusher() { defer func() { panichandler.PanicHandler("filestore flusher", recover()) }() - for { + flushAndLog := func() { stats, err := s.runFlushWithNewContext() if err != nil || stats.NumDirtyEntries > 0 { log.Printf("filestore flush: %d/%d entries flushed, err:%v\n", stats.NumCommitted, stats.NumDirtyEntries, err) } + } + flushAndLog() + periodicTimer := time.NewTimer(DefaultFlushTime) + defer periodicTimer.Stop() + debounceTimer := time.NewTimer(DefaultFlushDelay) + if !debounceTimer.Stop() { + select { + case <-debounceTimer.C: + default: + } + } + defer debounceTimer.Stop() + var debounceCh <-chan time.Time + for { if stopFlush.Load() { log.Printf("filestore flusher stopping\n") return } - time.Sleep(DefaultFlushTime) + select { + case <-s.FlushNotifyCh: + resetTimer(debounceTimer, DefaultFlushDelay) + debounceCh = debounceTimer.C + case <-debounceCh: + debounceCh = nil + flushAndLog() + resetTimer(periodicTimer, DefaultFlushTime) + case <-periodicTimer.C: + flushAndLog() + periodicTimer.Reset(DefaultFlushTime) + } + } +} + +func resetTimer(timer *time.Timer, duration time.Duration) { + if !timer.Stop() { + select { + case <-timer.C: + default: + } } + timer.Reset(duration) } func minInt64(a, b int64) int64 { diff --git a/pkg/filestore/blockstore_cache.go b/pkg/filestore/blockstore_cache.go index af86320222..5548f8a655 100644 --- a/pkg/filestore/blockstore_cache.go +++ b/pkg/filestore/blockstore_cache.go @@ -21,6 +21,7 @@ type FileStore struct { Lock *sync.Mutex Cache map[cacheKey]*CacheEntry IsFlushing bool + FlushNotifyCh chan struct{} } type DataCacheEntry struct { diff --git a/pkg/remote/conncontroller/conncontroller.go b/pkg/remote/conncontroller/conncontroller.go index a24a789009..6f56927dea 100644 --- a/pkg/remote/conncontroller/conncontroller.go +++ b/pkg/remote/conncontroller/conncontroller.go @@ -13,6 +13,7 @@ import ( "net" "os" "path/filepath" + "runtime" "strings" "sync" "sync/atomic" @@ -1251,11 +1252,23 @@ func GetConnectionsFromInternalConfig() []string { } func GetConnectionsFromConfig() ([]string, error) { - home := wavebase.GetHomeDir() - localConfig := filepath.Join(home, ".ssh", "config") - systemConfig := filepath.Join("/etc", "ssh", "config") - sshConfigFiles := []string{localConfig, systemConfig} + sshConfigFiles := getSshConfigFiles(wavebase.GetHomeDir(), runtime.GOOS, os.Getenv("PROGRAMDATA")) remote.WaveSshConfigUserSettings().ReloadConfigs() return resolveSshConfigPatterns(sshConfigFiles) } + +func getSshConfigFiles(homeDir string, goos string, programData string) []string { + localConfig := filepath.Join(homeDir, ".ssh", "config") + sshConfigFiles := []string{localConfig} + + if goos == "windows" { + if programData != "" { + sshConfigFiles = append(sshConfigFiles, filepath.Join(programData, "ssh", "ssh_config")) + } + return sshConfigFiles + } + + systemConfig := filepath.Join("/etc", "ssh", "config") + return append(sshConfigFiles, systemConfig) +} diff --git a/pkg/remote/conncontroller/conncontroller_test.go b/pkg/remote/conncontroller/conncontroller_test.go new file mode 100644 index 0000000000..27dce66def --- /dev/null +++ b/pkg/remote/conncontroller/conncontroller_test.go @@ -0,0 +1,64 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package conncontroller + +import ( + "path/filepath" + "testing" +) + +func TestGetSshConfigFilesWindowsUsesNativeSystemConfig(t *testing.T) { + homeDir := filepath.Join("C:", "Users", "wave") + programData := filepath.Join("C:", "ProgramData") + + configFiles := getSshConfigFiles(homeDir, "windows", programData) + + expected := []string{ + filepath.Join(homeDir, ".ssh", "config"), + filepath.Join(programData, "ssh", "ssh_config"), + } + if len(configFiles) != len(expected) { + t.Fatalf("expected %d config files, got %d: %#v", len(expected), len(configFiles), configFiles) + } + for index, expectedFile := range expected { + if configFiles[index] != expectedFile { + t.Fatalf("configFiles[%d] = %q, expected %q", index, configFiles[index], expectedFile) + } + } +} + +func TestGetSshConfigFilesWindowsSkipsUnixEtcConfig(t *testing.T) { + homeDir := filepath.Join("C:", "Users", "wave") + + configFiles := getSshConfigFiles(homeDir, "windows", "") + + expected := []string{filepath.Join(homeDir, ".ssh", "config")} + if len(configFiles) != len(expected) { + t.Fatalf("expected %d config files, got %d: %#v", len(expected), len(configFiles), configFiles) + } + for index, expectedFile := range expected { + if configFiles[index] != expectedFile { + t.Fatalf("configFiles[%d] = %q, expected %q", index, configFiles[index], expectedFile) + } + } +} + +func TestGetSshConfigFilesNonWindowsKeepsUnixEtcConfig(t *testing.T) { + homeDir := filepath.Join("home", "wave") + + configFiles := getSshConfigFiles(homeDir, "linux", "") + + expected := []string{ + filepath.Join(homeDir, ".ssh", "config"), + filepath.Join("/etc", "ssh", "config"), + } + if len(configFiles) != len(expected) { + t.Fatalf("expected %d config files, got %d: %#v", len(expected), len(configFiles), configFiles) + } + for index, expectedFile := range expected { + if configFiles[index] != expectedFile { + t.Fatalf("configFiles[%d] = %q, expected %q", index, configFiles[index], expectedFile) + } + } +} diff --git a/pkg/wcloud/wcloud.go b/pkg/wcloud/wcloud.go index 3b96df838b..46df347ed7 100644 --- a/pkg/wcloud/wcloud.go +++ b/pkg/wcloud/wcloud.go @@ -56,13 +56,15 @@ func CacheAndRemoveEnvVars() error { WCloudEndpoint_VarCache = os.Getenv(WCloudEndpointVarName) err := checkEndpointVar(WCloudEndpoint_VarCache, "wcloud endpoint", WCloudEndpointVarName) if err != nil { - return err + log.Printf("[warn] %v, disabling wcloud HTTP endpoint\n", err) + WCloudEndpoint_VarCache = "" } os.Unsetenv(WCloudEndpointVarName) WCloudWSEndpoint_VarCache = os.Getenv(WCloudWSEndpointVarName) err = checkWSEndpointVar(WCloudWSEndpoint_VarCache, "wcloud ws endpoint", WCloudWSEndpointVarName) if err != nil { - return err + log.Printf("[warn] %v, disabling wcloud websocket endpoint\n", err) + WCloudWSEndpoint_VarCache = "" } os.Unsetenv(WCloudWSEndpointVarName) WCloudPingEndpoint_VarCache = os.Getenv(WCloudPingEndpointVarName) diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index ff6dbbe48a..f746dadc4f 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -32,6 +32,8 @@ "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": true, + "term:fontsize": 15, + "term:scrollback": 50000, "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false, diff --git a/pkg/wconfig/defaultconfig/widgets.json b/pkg/wconfig/defaultconfig/widgets.json index 2d0524b7dd..bc91f17af1 100644 --- a/pkg/wconfig/defaultconfig/widgets.json +++ b/pkg/wconfig/defaultconfig/widgets.json @@ -40,5 +40,27 @@ "view": "sysinfo" } } + }, + "defwidget@feishu": { + "display:order": -1, + "icon": "desktop", + "label": "feishu", + "description": "Open the local Feishu desktop app", + "blockdef": { + "meta": { + "view": "feishu" + } + } + }, + "defwidget@feishuweb": { + "display:order": 0, + "icon": "globe", + "label": "fei-web", + "description": "Open Feishu chat inside Wave using the embedded web view", + "blockdef": { + "meta": { + "view": "feishuweb" + } + } } } diff --git a/pkg/wconfig/settingsconfig.go b/pkg/wconfig/settingsconfig.go index 8de3832bcf..31b6c06d15 100644 --- a/pkg/wconfig/settingsconfig.go +++ b/pkg/wconfig/settingsconfig.go @@ -123,6 +123,9 @@ type SettingsType struct { WebDefaultUrl string `json:"web:defaulturl,omitempty"` WebDefaultSearch string `json:"web:defaultsearch,omitempty"` + FeishuClear bool `json:"feishu:*,omitempty"` + FeishuAppPath string `json:"feishu:apppath,omitempty"` + AutoUpdateClear bool `json:"autoupdate:*,omitempty"` AutoUpdateEnabled bool `json:"autoupdate:enabled,omitempty"` AutoUpdateIntervalMs float64 `json:"autoupdate:intervalms,omitempty"` diff --git a/pkg/wshrpc/wshremote/wshremote_file.go b/pkg/wshrpc/wshremote/wshremote_file.go index 3589cc998c..d389584e5c 100644 --- a/pkg/wshrpc/wshremote/wshremote_file.go +++ b/pkg/wshrpc/wshremote/wshremote_file.go @@ -13,6 +13,7 @@ import ( "log" "os" "path/filepath" + "runtime" "strings" "time" @@ -32,6 +33,74 @@ const RemoteFileTransferSizeLimit = 32 * 1024 * 1024 var DisableRecursiveFileOpts = true +func isWindowsVirtualRoot(path string, goos string) bool { + if goos != "windows" { + return false + } + cleaned := filepath.Clean(path) + return cleaned == `\` || cleaned == `/` +} + +func isWindowsDriveRoot(path string, goos string) bool { + if goos != "windows" { + return false + } + cleaned := filepath.Clean(path) + volume := filepath.VolumeName(cleaned) + if volume == "" { + return false + } + return cleaned == filepath.Clean(volume+`\`) +} + +func makeWindowsVirtualRootInfo() *wshrpc.FileInfo { + return &wshrpc.FileInfo{ + Path: "/", + Dir: "/", + Name: "/", + Size: -1, + Mode: fs.ModeDir | 0755, + ModeStr: (fs.ModeDir | 0755).String(), + IsDir: true, + MimeType: "directory", + SupportsMkdir: false, + } +} + +func listWindowsDriveInfos(goos string, statFn func(string) (os.FileInfo, error)) []*wshrpc.FileInfo { + if goos != "windows" { + return nil + } + var entries []*wshrpc.FileInfo + for drive := 'A'; drive <= 'Z'; drive++ { + root := fmt.Sprintf("%c:\\", drive) + finfo, err := statFn(root) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue + } + log.Printf("cannot stat windows drive %q: %v\n", root, err) + continue + } + if finfo == nil || !finfo.IsDir() { + continue + } + entries = append(entries, &wshrpc.FileInfo{ + Path: root, + Dir: "/", + Name: root, + Size: -1, + Mode: finfo.Mode(), + ModeStr: finfo.Mode().String(), + ModTime: finfo.ModTime().UnixMilli(), + IsDir: true, + MimeType: "directory", + SupportsMkdir: true, + }) + } + return entries +} + // prepareDestForCopy resolves the final destination path and handles overwrite logic. // destPath is the raw destination path (may be a directory or file path). // srcBaseName is the basename of the source file (used when dest is a directory or ends with slash). @@ -212,6 +281,15 @@ func (impl *ServerImpl) RemoteListEntriesCommand(ctx context.Context, data wshrp ch <- wshutil.RespErr[wshrpc.CommandRemoteListEntriesRtnData](err) return } + if isWindowsVirtualRoot(path, runtime.GOOS) { + driveInfos := listWindowsDriveInfos(runtime.GOOS, os.Stat) + if len(driveInfos) > 0 { + ch <- wshrpc.RespOrErrorUnion[wshrpc.CommandRemoteListEntriesRtnData]{ + Response: wshrpc.CommandRemoteListEntriesRtnData{FileInfo: driveInfos}, + } + } + return + } if data.Opts == nil { data.Opts = &wshrpc.FileListOpts{} } @@ -280,10 +358,14 @@ func (impl *ServerImpl) RemoteListEntriesCommand(ctx context.Context, data wshrp func statToFileInfo(fullPath string, finfo fs.FileInfo, extended bool) *wshrpc.FileInfo { mimeType := fileutil.DetectMimeType(fullPath, finfo, extended) + name := finfo.Name() + if isWindowsDriveRoot(fullPath, runtime.GOOS) { + name = filepath.VolumeName(fullPath) + `\` + } rtn := &wshrpc.FileInfo{ Path: wavebase.ReplaceHomeDir(fullPath), Dir: computeDirPart(fullPath), - Name: finfo.Name(), + Name: name, Size: finfo.Size(), Mode: finfo.Mode(), ModeStr: finfo.Mode().String(), @@ -327,15 +409,29 @@ func checkIsReadOnly(path string, fileInfo fs.FileInfo, exists bool) bool { func computeDirPart(path string) string { path = filepath.Clean(wavebase.ExpandHomeDirSafe(path)) - path = filepath.ToSlash(path) - if path == "/" { + if isWindowsVirtualRoot(path, runtime.GOOS) { + return "/" + } + if isWindowsDriveRoot(path, runtime.GOOS) { return "/" } + if runtime.GOOS == "windows" { + dir := filepath.Dir(path) + volume := filepath.VolumeName(dir) + if dir == volume && volume != "" { + return volume + `\` + } + return dir + } + path = filepath.ToSlash(path) return filepath.Dir(path) } func (*ServerImpl) fileInfoInternal(path string, extended bool) (*wshrpc.FileInfo, error) { cleanedPath := filepath.Clean(wavebase.ExpandHomeDirSafe(path)) + if isWindowsVirtualRoot(cleanedPath, runtime.GOOS) { + return makeWindowsVirtualRootInfo(), nil + } finfo, err := os.Stat(cleanedPath) if os.IsNotExist(err) { return &wshrpc.FileInfo{ diff --git a/pkg/wshrpc/wshremote/wshremote_file_test.go b/pkg/wshrpc/wshremote/wshremote_file_test.go new file mode 100644 index 0000000000..be7478666a --- /dev/null +++ b/pkg/wshrpc/wshremote/wshremote_file_test.go @@ -0,0 +1,81 @@ +package wshremote + +import ( + "io/fs" + "testing" + "time" +) + +type fakeFileInfo struct { + name string + size int64 + mode fs.FileMode + modTime time.Time + isDir bool +} + +func (f fakeFileInfo) Name() string { return f.name } +func (f fakeFileInfo) Size() int64 { return f.size } +func (f fakeFileInfo) Mode() fs.FileMode { return f.mode } +func (f fakeFileInfo) ModTime() time.Time { return f.modTime } +func (f fakeFileInfo) IsDir() bool { return f.isDir } +func (f fakeFileInfo) Sys() any { return nil } + +func TestIsWindowsVirtualRoot(t *testing.T) { + if !isWindowsVirtualRoot(`\`, "windows") { + t.Fatalf("expected windows virtual root to match backslash") + } + if !isWindowsVirtualRoot(`/`, "windows") { + t.Fatalf("expected windows virtual root to match slash") + } + if isWindowsVirtualRoot(`C:\`, "windows") { + t.Fatalf("drive root should not be treated as virtual root") + } + if isWindowsVirtualRoot(`/`, "linux") { + t.Fatalf("non-windows path should not be treated as windows virtual root") + } +} + +func TestIsWindowsDriveRoot(t *testing.T) { + if !isWindowsDriveRoot(`C:\`, "windows") { + t.Fatalf("expected drive root to match") + } + if isWindowsDriveRoot(`C:\Users`, "windows") { + t.Fatalf("non-root drive path should not match") + } + if isWindowsDriveRoot(`C:\`, "linux") { + t.Fatalf("non-windows OS should not match windows drive roots") + } +} + +func TestListWindowsDriveInfos(t *testing.T) { + seen := map[string]bool{} + statFn := func(path string) (fs.FileInfo, error) { + seen[path] = true + switch path { + case `C:\`, `D:\`: + return fakeFileInfo{ + name: path, + mode: fs.ModeDir | 0755, + modTime: time.UnixMilli(1234), + isDir: true, + }, nil + default: + return nil, fs.ErrNotExist + } + } + + infos := listWindowsDriveInfos("windows", statFn) + if len(infos) != 2 { + t.Fatalf("expected 2 drives, got %d", len(infos)) + } + if infos[0].Path != `C:\` || infos[0].Dir != "/" || infos[0].Name != `C:\` { + t.Fatalf("unexpected first drive info: %#v", infos[0]) + } + if infos[1].Path != `D:\` || infos[1].Dir != "/" || infos[1].Name != `D:\` { + t.Fatalf("unexpected second drive info: %#v", infos[1]) + } + if !seen[`C:\`] || !seen[`D:\`] { + t.Fatalf("expected statFn to probe C and D drives: %#v", seen) + } +} diff --git a/schema/settings.json b/schema/settings.json index 91de939c38..a2f76603c0 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -129,6 +129,9 @@ "type": "string" }, "term:scrollback": { + "default": 50000, + "maximum": 200000, + "minimum": 0, "type": "integer" }, "term:copyonselect": { @@ -198,8 +201,14 @@ "web:defaultsearch": { "type": "string" }, + "feishu:*": { + "type": "boolean" + }, + "feishu:apppath": { + "type": "string" + }, "autoupdate:*": { - "type": "boolean" + "type": "boolean" }, "autoupdate:enabled": { "type": "boolean" @@ -354,4 +363,4 @@ "type": "object" } } -} \ No newline at end of file +} diff --git a/schema/widgets.json b/schema/widgets.json index 1c55fd8e09..31d2ee77d4 100644 --- a/schema/widgets.json +++ b/schema/widgets.json @@ -140,6 +140,9 @@ "type": "array" }, "term:scrollback": { + "default": 50000, + "maximum": 200000, + "minimum": 0, "type": "integer" }, "term:transparency": { @@ -234,4 +237,4 @@ ] }, "type": "object" -} \ No newline at end of file +} diff --git a/scripts/smoke-terminal-real-wheel.ps1 b/scripts/smoke-terminal-real-wheel.ps1 new file mode 100644 index 0000000000..f0d799ee12 --- /dev/null +++ b/scripts/smoke-terminal-real-wheel.ps1 @@ -0,0 +1,742 @@ +param( + [int]$Port = 0, + [string]$OutputDir = "D:\files\AI_output\waveterm-terminal-smoke", + [switch]$KillExistingRepoWave, + [switch]$KillAllWave, + [switch]$KeepApp, + [int]$StartupTimeoutSec = 45 +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +function Write-Step { + param([string]$Message) + Write-Host "[smoke-terminal-real-wheel] $Message" +} + +function Get-FreeTcpPort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return [int]$listener.LocalEndpoint.Port + } finally { + $listener.Stop() + } +} + +function Stop-WaveProcesses { + param( + [string]$RepoMakeDir, + [switch]$AllWave + ) + + $processes = Get-Process Wave -ErrorAction SilentlyContinue + if ($null -eq $processes) { + return + } + + foreach ($process in $processes) { + $path = $null + try { + $path = $process.Path + } catch { + $path = $null + } + + $shouldStop = $AllWave + if (!$shouldStop -and $path) { + $shouldStop = $path.StartsWith($RepoMakeDir, [System.StringComparison]::OrdinalIgnoreCase) + } + if (!$shouldStop) { + continue + } + + Write-Step "stopping Wave process pid=$($process.Id) path=$path" + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } +} + +function Wait-CdpTarget { + param( + [int]$CdpPort, + [int]$TimeoutSec + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSec) + $lastError = $null + while ((Get-Date) -lt $deadline) { + try { + $targets = Invoke-RestMethod -Uri "http://127.0.0.1:$CdpPort/json/list" -TimeoutSec 2 + if ($targets) { + $target = $targets | + Where-Object { $_.type -eq "page" -and $_.webSocketDebuggerUrl } | + Sort-Object @{ Expression = { if ($_.url -like "file:*" -or $_.url -like "app:*") { 0 } else { 1 } } } | + Select-Object -First 1 + if ($target) { + return $target + } + } + } catch { + $lastError = $_.Exception.Message + } + Start-Sleep -Milliseconds 500 + } + throw "CDP target not available on port $CdpPort within ${TimeoutSec}s. Last error: $lastError" +} + +function Receive-CdpMessage { + param([System.Net.WebSockets.ClientWebSocket]$WebSocket) + + $buffer = New-Object byte[] 65536 + $stream = [System.IO.MemoryStream]::new() + try { + do { + $segment = [System.ArraySegment[byte]]::new($buffer) + $result = $WebSocket.ReceiveAsync($segment, [System.Threading.CancellationToken]::None).GetAwaiter().GetResult() + if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) { + throw "CDP websocket closed before command response" + } + if ($result.Count -gt 0) { + $stream.Write($buffer, 0, $result.Count) + } + } while (!$result.EndOfMessage) + + $text = [System.Text.Encoding]::UTF8.GetString($stream.ToArray()) + return $text | ConvertFrom-Json + } finally { + $stream.Dispose() + } +} + +function Invoke-CdpCommand { + param( + [string]$WebSocketUrl, + [string]$Method, + [hashtable]$Params = @{} + ) + + $webSocket = [System.Net.WebSockets.ClientWebSocket]::new() + try { + $webSocket.ConnectAsync([Uri]$WebSocketUrl, [System.Threading.CancellationToken]::None).GetAwaiter().GetResult() + $commandId = Get-Random -Minimum 1000 -Maximum 999999 + $payload = @{ + id = $commandId + method = $Method + params = $Params + } | ConvertTo-Json -Depth 100 -Compress + + $bytes = [System.Text.Encoding]::UTF8.GetBytes($payload) + $segment = [System.ArraySegment[byte]]::new($bytes) + $webSocket.SendAsync( + $segment, + [System.Net.WebSockets.WebSocketMessageType]::Text, + $true, + [System.Threading.CancellationToken]::None + ).GetAwaiter().GetResult() + + while ($true) { + $message = Receive-CdpMessage -WebSocket $webSocket + if ($message.id -eq $commandId) { + if ($message.error) { + throw "CDP command $Method failed: $($message.error.message)" + } + return $message + } + } + } finally { + if ($webSocket.State -eq [System.Net.WebSockets.WebSocketState]::Open) { + $webSocket.CloseAsync( + [System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, + "done", + [System.Threading.CancellationToken]::None + ).GetAwaiter().GetResult() + } + $webSocket.Dispose() + } +} + +function Invoke-CdpEvaluate { + param( + [string]$WebSocketUrl, + [string]$Expression + ) + + $response = Invoke-CdpCommand -WebSocketUrl $WebSocketUrl -Method "Runtime.evaluate" -Params @{ + expression = $Expression + awaitPromise = $true + returnByValue = $true + } + if ($response.result.exceptionDetails) { + $description = $response.result.exceptionDetails.exception.description + if (!$description) { + $description = $response.result.exceptionDetails.text + } + throw "Runtime.evaluate failed: $description" + } + return $response.result.result.value +} + +function Save-CdpScreenshot { + param( + [string]$WebSocketUrl, + [string]$Path + ) + + try { + $response = Invoke-CdpCommand -WebSocketUrl $WebSocketUrl -Method "Page.captureScreenshot" -Params @{ + format = "png" + fromSurface = $true + } + if ($response.result.data) { + [System.IO.File]::WriteAllBytes($Path, [Convert]::FromBase64String($response.result.data)) + return $Path + } + } catch { + Write-Step "screenshot skipped: $($_.Exception.Message)" + } + return $null +} + +function ConvertTo-JsLiteral { + param([object]$Value) + return ($Value | ConvertTo-Json -Depth 20 -Compress) +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$makeDir = Join-Path $repoRoot "make" +$exePath = Join-Path $makeDir "win-unpacked\Wave.exe" +$startedProcess = $null +$target = $null +$runtime = $null +$screenshot = $null +$cleanupBlockIds = @() + +if (!(Test-Path -LiteralPath $exePath)) { + throw "Wave executable not found: $exePath. Run electron-builder --win dir first." +} + +if (!(Test-Path -LiteralPath $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null +} + +if ($Port -le 0) { + $Port = Get-FreeTcpPort +} + +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultPath = Join-Path $OutputDir "terminal-real-wheel-$timestamp.json" +$screenshotPath = Join-Path $OutputDir "terminal-real-wheel-$timestamp.png" + +Write-Step "repo root: $repoRoot" +Write-Step "exe: $exePath" +Write-Step "output: $resultPath" +Write-Step "cdp port: $Port" + +try { + if ($KillAllWave) { + Stop-WaveProcesses -RepoMakeDir $makeDir -AllWave + } elseif ($KillExistingRepoWave) { + Stop-WaveProcesses -RepoMakeDir $makeDir + } + + $exeItem = Get-Item -LiteralPath $exePath + $hash = Get-FileHash -LiteralPath $exePath -Algorithm SHA256 + + Write-Step "starting Wave with CDP" + $startedProcess = Start-Process -FilePath $exePath -ArgumentList "--remote-debugging-port=$Port" -PassThru + $target = Wait-CdpTarget -CdpPort $Port -TimeoutSec $StartupTimeoutSec + Write-Step "connected target title='$($target.title)' url='$($target.url)'" + + $runtimeExpression = Get-Content -Raw -Encoding UTF8 -Path (Join-Path $PSScriptRoot "smoke-terminal.runtime.js") + $runtime = Invoke-CdpEvaluate -WebSocketUrl $target.webSocketDebuggerUrl -Expression $runtimeExpression + if ($runtime.cleanup) { + if ($runtime.cleanup.createdBlockIds) { + $cleanupBlockIds = @($runtime.cleanup.createdBlockIds) + } elseif ($runtime.cleanup.createdBlockId) { + $cleanupBlockIds = @($runtime.cleanup.createdBlockId) + } + } + if (!$runtime.hasTerm -or $runtime.scenarioBlockIds.Count -lt 1) { + throw "terminal runtime did not initialize for real wheel smoke" + } + + $helperExpression = @" +(function () { + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const rectToObject = (rect) => rect ? ({ + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.right, + bottom: rect.bottom + }) : null; + const getBlockIdForElement = (elem) => elem?.closest?.('[data-blockid]')?.dataset?.blockid ?? null; + const describeElement = (elem) => elem ? ({ + tagName: elem.tagName ?? null, + id: elem.id || null, + className: typeof elem.className === 'string' ? elem.className : null, + role: elem.getAttribute?.('role') ?? null, + blockId: getBlockIdForElement(elem) + }) : null; + const refreshRegistry = () => { + const registry = window.__waveSmokeTermRegistry; + registry?.refreshFromLiveInstances?.(); + return registry; + }; + const getWrap = (blockId) => { + const registry = refreshRegistry(); + if (registry?.byBlockId?.[blockId]) { + return registry.byBlockId[blockId]; + } + const liveInstances = window.term?.constructor?.liveInstances; + if (liveInstances instanceof Set) { + return Array.from(liveInstances).find((wrap) => wrap?.blockId === blockId) ?? null; + } + return null; + }; + const getRefs = (blockId) => { + const blockElem = Array.from(document.querySelectorAll('[data-blockid]')).find( + (elem) => elem.dataset?.blockid === blockId + ); + const viewElem = blockElem?.querySelector?.('.view-term') ?? null; + const connectElem = viewElem?.querySelector?.('.term-connectelem') ?? null; + const xtermElem = connectElem?.querySelector?.('.xterm') ?? null; + const screenElem = + connectElem?.querySelector?.('.xterm-screen') || + connectElem?.querySelector?.('.xterm-rows') || + xtermElem || + connectElem || + null; + const scrollableElem = connectElem?.querySelector?.('.xterm-scrollable-element') ?? null; + const textarea = connectElem?.querySelector?.('.xterm-helper-textarea') ?? null; + return { + blockElem, + viewElem, + connectElem, + xtermElem, + screenElem, + scrollableElem, + textarea + }; + }; + const getPoint = (refs, pointName) => { + const base = refs.screenElem || refs.xtermElem || refs.connectElem || refs.viewElem; + const baseRect = base?.getBoundingClientRect?.(); + const viewRect = refs.viewElem?.getBoundingClientRect?.(); + const scrollRect = refs.scrollableElem?.getBoundingClientRect?.(); + const rect = pointName === 'view-right' ? viewRect : pointName === 'scrollbar-center' ? scrollRect : baseRect; + if (!rect || rect.width <= 0 || rect.height <= 0) { + return null; + } + const safeY = rect.top + Math.max(2, Math.min(rect.height - 2, rect.height / 2)); + if (pointName === 'screen-right' || pointName === 'view-right') { + return { x: rect.right - Math.min(8, Math.max(2, rect.width / 4)), y: safeY, rect: rectToObject(rect) }; + } + return { + x: rect.left + Math.max(2, Math.min(rect.width - 2, rect.width / 2)), + y: safeY, + rect: rectToObject(rect) + }; + }; + const captureAll = () => { + refreshRegistry(); + const output = {}; + for (const viewElem of Array.from(document.querySelectorAll('.view-term'))) { + const blockElem = viewElem.closest('[data-blockid]'); + const blockId = blockElem?.dataset?.blockid ?? null; + if (!blockId) { + continue; + } + const wrap = getWrap(blockId); + const refs = getRefs(blockId); + const activeBuffer = wrap?.terminal?.buffer?.active ?? null; + output[blockId] = { + viewportY: activeBuffer?.viewportY ?? null, + baseY: activeBuffer?.baseY ?? null, + length: activeBuffer?.length ?? null, + bufferType: activeBuffer?.type ?? null, + mouseTrackingMode: wrap?.terminal?.modes?.mouseTrackingMode ?? null, + domScrollTop: refs.scrollableElem?.scrollTop ?? null, + domScrollHeight: refs.scrollableElem?.scrollHeight ?? null, + domClientHeight: refs.scrollableElem?.clientHeight ?? null, + blockFocused: blockElem?.classList?.contains('block-focused') ?? false, + activeElementInside: !!document.activeElement && !!refs.connectElem?.contains(document.activeElement) + }; + } + return output; + }; + const changedBlocks = (before, after) => Object.keys(after).filter((blockId) => { + const oldState = before?.[blockId] ?? {}; + const newState = after?.[blockId] ?? {}; + return oldState.viewportY !== newState.viewportY || oldState.domScrollTop !== newState.domScrollTop; + }); + window.__waveRealWheel = { + liveIntervalId: null, + startLiveOutput(blockId) { + const wrap = getWrap(blockId); + if (!wrap?.terminal) { + return { started: false, reason: 'missing_wrap' }; + } + if (this.liveIntervalId) { + clearInterval(this.liveIntervalId); + } + let writeCount = 0; + this.liveIntervalId = setInterval(() => { + writeCount += 1; + wrap.terminal.write('real-live-' + writeCount + '-' + 'x'.repeat(72) + '\r\n'); + }, 80); + return { started: true, blockId }; + }, + stopLiveOutput() { + if (this.liveIntervalId) { + clearInterval(this.liveIntervalId); + this.liveIntervalId = null; + } + return { stopped: true }; + }, + async prepare(blockId, pointName) { + const wrap = getWrap(blockId); + const refs = getRefs(blockId); + if (!wrap?.terminal || !refs.connectElem) { + return { blockId, pointName, error: 'missing terminal wrap or connect element' }; + } + const seed = Array.from({ length: 220 }, (_, idx) => 'real-wheel-' + pointName + '-' + idx).join('\r\n') + '\r\n'; + await new Promise((resolve) => wrap.terminal.write(seed, resolve)); + wrap.terminal.scrollToBottom?.(); + refs.textarea?.focus?.({ preventScroll: true }); + wrap.terminal.focus?.(); + await wait(160); + const point = getPoint(refs, pointName); + const hit = point ? document.elementFromPoint(point.x, point.y) : null; + const before = captureAll(); + return { + blockId, + pointName, + point, + hit: describeElement(hit), + focused: describeElement(document.activeElement), + targetBefore: before[blockId] ?? null, + before, + refs: { + view: rectToObject(refs.viewElem?.getBoundingClientRect?.()), + connect: rectToObject(refs.connectElem?.getBoundingClientRect?.()), + screen: rectToObject(refs.screenElem?.getBoundingClientRect?.()), + scrollable: rectToObject(refs.scrollableElem?.getBoundingClientRect?.()) + } + }; + }, + captureAll, + changedBlocks + }; +})() +"@ + Invoke-CdpEvaluate -WebSocketUrl $target.webSocketDebuggerUrl -Expression $helperExpression | Out-Null + + $scenarioResults = @() + $liveScenarioResults = @() + $pointNames = @("screen-center", "screen-right") + foreach ($blockId in @($runtime.scenarioBlockIds | Select-Object -First 2)) { + foreach ($pointName in $pointNames) { + $beforeExpression = @" +(async () => { + const result = await window.__waveRealWheel.prepare($(ConvertTo-JsLiteral $blockId), $(ConvertTo-JsLiteral $pointName)); + window.__waveRealWheelLastBefore = result.before; + return { + blockId: result.blockId, + pointName: result.pointName, + point: result.point, + hit: result.hit, + focused: result.focused, + targetBefore: result.targetBefore, + refs: result.refs, + error: result.error ?? null + }; +})() +"@ + $before = Invoke-CdpEvaluate -WebSocketUrl $target.webSocketDebuggerUrl -Expression $beforeExpression + if (!$before -or !$before.point -or $null -eq $before.point.x -or $null -eq $before.point.y) { + $scenarioResults += [ordered]@{ + blockId = $blockId + pointName = $pointName + pass = $false + diagnosis = "missing_point" + before = $before + after = $null + } + Write-Step "block=$blockId point=$pointName diagnosis=missing_point" + continue + } + + Invoke-CdpCommand -WebSocketUrl $target.webSocketDebuggerUrl -Method "Page.bringToFront" | Out-Null + Invoke-CdpCommand -WebSocketUrl $target.webSocketDebuggerUrl -Method "Input.dispatchMouseEvent" -Params @{ + type = "mouseMoved" + x = [double]$before.point.x + y = [double]$before.point.y + } | Out-Null + Invoke-CdpCommand -WebSocketUrl $target.webSocketDebuggerUrl -Method "Input.dispatchMouseEvent" -Params @{ + type = "mouseWheel" + x = [double]$before.point.x + y = [double]$before.point.y + deltaX = 0 + deltaY = -720 + } | Out-Null + + $afterExpression = @" +(async () => { + await new Promise((resolve) => setTimeout(resolve, 320)); + const after = window.__waveRealWheel.captureAll(); + const before = window.__waveRealWheelLastBefore || {}; + const changedBlocks = window.__waveRealWheel.changedBlocks(before, after); + const targetChanged = changedBlocks.includes($(ConvertTo-JsLiteral $blockId)); + const wrongChanged = changedBlocks.filter((id) => id !== $(ConvertTo-JsLiteral $blockId)); + let diagnosis = 'ok'; + if (!targetChanged) { + diagnosis = 'real_wheel_no_scroll'; + } else if (wrongChanged.length > 0) { + diagnosis = 'real_wheel_wrong_terminal'; + } + return { + changedBlocks, + wrongChanged, + targetAfter: after[$(ConvertTo-JsLiteral $blockId)] ?? null, + pass: diagnosis === 'ok', + diagnosis + }; +})() +"@ + $after = Invoke-CdpEvaluate -WebSocketUrl $target.webSocketDebuggerUrl -Expression $afterExpression + $scenarioResults += [ordered]@{ + blockId = $blockId + pointName = $pointName + deltaY = -720 + before = $before + after = $after + pass = [bool]$after.pass + diagnosis = $after.diagnosis + } + Write-Step "block=$blockId point=$pointName diagnosis=$($after.diagnosis)" + } + } + + if ($runtime.diagnostic -and $runtime.diagnostic.target -and $runtime.diagnostic.target.blockId) { + $liveBlockId = [string]$runtime.diagnostic.target.blockId + $livePointNames = @("screen-center", "screen-right", "view-right", "scrollbar-center") + Invoke-CdpEvaluate -WebSocketUrl $target.webSocketDebuggerUrl -Expression "(() => window.__waveRealWheel.startLiveOutput($(ConvertTo-JsLiteral $liveBlockId)))()" | Out-Null + Start-Sleep -Milliseconds 220 + foreach ($pointName in $livePointNames) { + $beforeExpression = @" +(async () => { + const result = await window.__waveRealWheel.prepare($(ConvertTo-JsLiteral $liveBlockId), $(ConvertTo-JsLiteral $pointName)); + window.__waveRealWheelLastBefore = result.before; + return { + blockId: result.blockId, + pointName: result.pointName, + point: result.point, + hit: result.hit, + focused: result.focused, + targetBefore: result.targetBefore, + refs: result.refs, + error: result.error ?? null + }; +})() +"@ + $before = Invoke-CdpEvaluate -WebSocketUrl $target.webSocketDebuggerUrl -Expression $beforeExpression + if (!$before -or !$before.point -or $null -eq $before.point.x -or $null -eq $before.point.y) { + $liveScenarioResults += [ordered]@{ + blockId = $liveBlockId + pointName = $pointName + pass = $false + diagnosis = "live_missing_point" + before = $before + after = $null + } + Write-Step "live block=$liveBlockId point=$pointName diagnosis=live_missing_point" + continue + } + + Invoke-CdpCommand -WebSocketUrl $target.webSocketDebuggerUrl -Method "Page.bringToFront" | Out-Null + Invoke-CdpCommand -WebSocketUrl $target.webSocketDebuggerUrl -Method "Input.dispatchMouseEvent" -Params @{ + type = "mouseMoved" + x = [double]$before.point.x + y = [double]$before.point.y + } | Out-Null + Invoke-CdpCommand -WebSocketUrl $target.webSocketDebuggerUrl -Method "Input.dispatchMouseEvent" -Params @{ + type = "mouseWheel" + x = [double]$before.point.x + y = [double]$before.point.y + deltaX = 0 + deltaY = -720 + } | Out-Null + + $afterExpression = @" +(async () => { + await new Promise((resolve) => setTimeout(resolve, 320)); + const after = window.__waveRealWheel.captureAll(); + const before = window.__waveRealWheelLastBefore || {}; + const changedBlocks = window.__waveRealWheel.changedBlocks(before, after); + const wrongChanged = changedBlocks.filter((id) => id !== $(ConvertTo-JsLiteral $liveBlockId)); + const targetBefore = before[$(ConvertTo-JsLiteral $liveBlockId)] ?? {}; + const targetAfter = after[$(ConvertTo-JsLiteral $liveBlockId)] ?? {}; + const beforeDistance = + targetBefore.baseY != null && targetBefore.viewportY != null + ? targetBefore.baseY - targetBefore.viewportY + : null; + const afterDistance = + targetAfter.baseY != null && targetAfter.viewportY != null + ? targetAfter.baseY - targetAfter.viewportY + : null; + let diagnosis = 'ok'; + if (afterDistance == null || beforeDistance == null) { + diagnosis = 'live_real_missing_distance'; + } else if (afterDistance <= beforeDistance) { + diagnosis = 'live_real_no_scroll'; + } else if (wrongChanged.length > 0) { + diagnosis = 'live_real_wrong_terminal'; + } + return { + changedBlocks, + wrongChanged, + targetAfter, + beforeDistance, + afterDistance, + pass: diagnosis === 'ok', + diagnosis + }; +})() +"@ + $after = Invoke-CdpEvaluate -WebSocketUrl $target.webSocketDebuggerUrl -Expression $afterExpression + $liveScenarioResults += [ordered]@{ + blockId = $liveBlockId + pointName = $pointName + deltaY = -720 + before = $before + after = $after + pass = [bool]$after.pass + diagnosis = $after.diagnosis + } + Write-Step "live block=$liveBlockId point=$pointName diagnosis=$($after.diagnosis)" + } + Invoke-CdpEvaluate -WebSocketUrl $target.webSocketDebuggerUrl -Expression "(() => window.__waveRealWheel.stopLiveOutput())()" | Out-Null + } + + $cleanupResult = $null + if ($cleanupBlockIds.Count -gt 0) { + $cleanupBlockIdsLiteral = ConvertTo-JsLiteral $cleanupBlockIds + $cleanupExpression = @" +(async () => { + const blockIds = $cleanupBlockIdsLiteral; + if (!Array.isArray(blockIds) || blockIds.length === 0 || !window.RpcApi || !window.TabRpcClient) { + return { cleaned: false, blockIds, reason: 'missing blockIds, RpcApi or TabRpcClient' }; + } + const results = []; + for (const blockId of blockIds.slice().reverse()) { + try { + await window.RpcApi.DeleteBlockCommand(window.TabRpcClient, { blockid: blockId }); + results.push({ blockId, cleaned: true }); + } catch (error) { + results.push({ blockId, cleaned: false, error: error?.message ?? String(error) }); + } + } + return { cleaned: results.every((item) => item.cleaned), blockIds, results }; +})() +"@ + $cleanupResult = Invoke-CdpEvaluate -WebSocketUrl $target.webSocketDebuggerUrl -Expression $cleanupExpression + } + + $screenshot = Save-CdpScreenshot -WebSocketUrl $target.webSocketDebuggerUrl -Path $screenshotPath + $blockPass = @{} + foreach ($scenario in $scenarioResults) { + if (!$blockPass.ContainsKey($scenario.blockId)) { + $blockPass[$scenario.blockId] = $false + } + if ($scenario.pass) { + $blockPass[$scenario.blockId] = $true + } + } + $allPassed = $blockPass.Count -gt 0 -and !($blockPass.Values -contains $false) + $summary = [ordered]@{ + status = if ($allPassed) { "passing" } else { "failing" } + timestamp = (Get-Date).ToString("o") + repoRoot = $repoRoot + executable = [ordered]@{ + path = $exeItem.FullName + lastWriteTime = $exeItem.LastWriteTime.ToString("o") + length = $exeItem.Length + sha256 = $hash.Hash + } + cdp = [ordered]@{ + port = $Port + targetTitle = $target.title + targetUrl = $target.url + } + runtime = $runtime + realWheel = [ordered]@{ + scenarios = $scenarioResults + allPassed = $allPassed + diagnoses = @($scenarioResults | ForEach-Object { $_.diagnosis } | Select-Object -Unique) + } + liveRealWheel = [ordered]@{ + targetBlockId = if ($runtime.diagnostic -and $runtime.diagnostic.target) { $runtime.diagnostic.target.blockId } else { $null } + scenarios = $liveScenarioResults + allPassed = @($liveScenarioResults | Where-Object { -not $_.pass }).Count -eq 0 + diagnoses = @($liveScenarioResults | ForEach-Object { $_.diagnosis } | Select-Object -Unique) + } + cleanup = $cleanupResult + screenshot = $screenshot + } + $summary | ConvertTo-Json -Depth 100 | Set-Content -Path $resultPath -Encoding UTF8 + + if (!$allPassed) { + Write-Step "FAIL: real wheel did not scroll any tested point" + Write-Step "result: $resultPath" + throw "real wheel smoke failed: $(@($scenarioResults | ForEach-Object { $_.diagnosis } | Select-Object -Unique) -join ', ')" + } + + Write-Step "PASS" + Write-Step "result: $resultPath" + if ($screenshot) { + Write-Step "screenshot: $screenshot" + } +} catch { + $failure = [ordered]@{ + status = "failing" + timestamp = (Get-Date).ToString("o") + repoRoot = $repoRoot + cdp = if ($target) { + [ordered]@{ + port = $Port + targetTitle = $target.title + targetUrl = $target.url + } + } else { + [ordered]@{ + port = $Port + } + } + runtime = $runtime + screenshot = $screenshot + error = $_.Exception.Message + } + $failure | ConvertTo-Json -Depth 100 | Set-Content -Path $resultPath -Encoding UTF8 + Write-Step "FAIL: $($_.Exception.Message)" + Write-Step "result: $resultPath" + throw +} finally { + if (!$KeepApp) { + $running = Get-Process Wave -ErrorAction SilentlyContinue | Where-Object { + try { + $_.Path -and $_.Path.Equals($exePath, [System.StringComparison]::OrdinalIgnoreCase) + } catch { + $false + } + } + foreach ($process in $running) { + Write-Step "cleanup Wave process pid=$($process.Id)" + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } + } elseif ($startedProcess) { + Write-Step "keeping Wave process pid=$($startedProcess.Id)" + } +} diff --git a/scripts/smoke-terminal.ps1 b/scripts/smoke-terminal.ps1 new file mode 100644 index 0000000000..17e659f92e --- /dev/null +++ b/scripts/smoke-terminal.ps1 @@ -0,0 +1,420 @@ +param( + [int]$Port = 0, + [string]$OutputDir = "D:\files\AI_output\waveterm-terminal-smoke", + [switch]$KillExistingRepoWave, + [switch]$KillAllWave, + [switch]$KeepApp, + [bool]$RequireTerminal = $true, + [int]$StartupTimeoutSec = 45 +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +function Write-Step { + param([string]$Message) + Write-Host "[smoke-terminal] $Message" +} + +function Get-FreeTcpPort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return [int]$listener.LocalEndpoint.Port + } finally { + $listener.Stop() + } +} + +function Stop-WaveProcesses { + param( + [string]$RepoMakeDir, + [switch]$AllWave + ) + + $processes = Get-Process Wave -ErrorAction SilentlyContinue + if ($null -eq $processes) { + return + } + + foreach ($process in $processes) { + $path = $null + try { + $path = $process.Path + } catch { + $path = $null + } + + $shouldStop = $AllWave + if (!$shouldStop -and $path) { + $shouldStop = $path.StartsWith($RepoMakeDir, [System.StringComparison]::OrdinalIgnoreCase) + } + if (!$shouldStop) { + continue + } + + Write-Step "stopping Wave process pid=$($process.Id) path=$path" + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } +} + +function Wait-CdpTarget { + param( + [int]$CdpPort, + [int]$TimeoutSec + ) + + $deadline = (Get-Date).AddSeconds($TimeoutSec) + $lastError = $null + while ((Get-Date) -lt $deadline) { + try { + $targets = Invoke-RestMethod -Uri "http://127.0.0.1:$CdpPort/json/list" -TimeoutSec 2 + if ($targets) { + $target = $targets | + Where-Object { $_.type -eq "page" -and $_.webSocketDebuggerUrl } | + Sort-Object @{ Expression = { if ($_.url -like "file:*" -or $_.url -like "app:*") { 0 } else { 1 } } } | + Select-Object -First 1 + if ($target) { + return $target + } + } + } catch { + $lastError = $_.Exception.Message + } + Start-Sleep -Milliseconds 500 + } + throw "CDP target not available on port $CdpPort within ${TimeoutSec}s. Last error: $lastError" +} + +function Receive-CdpMessage { + param([System.Net.WebSockets.ClientWebSocket]$WebSocket) + + $buffer = New-Object byte[] 65536 + $stream = [System.IO.MemoryStream]::new() + try { + do { + $segment = [System.ArraySegment[byte]]::new($buffer) + $result = $WebSocket.ReceiveAsync($segment, [System.Threading.CancellationToken]::None).GetAwaiter().GetResult() + if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) { + throw "CDP websocket closed before command response" + } + if ($result.Count -gt 0) { + $stream.Write($buffer, 0, $result.Count) + } + } while (!$result.EndOfMessage) + + $text = [System.Text.Encoding]::UTF8.GetString($stream.ToArray()) + return $text | ConvertFrom-Json + } finally { + $stream.Dispose() + } +} + +function Invoke-CdpCommand { + param( + [string]$WebSocketUrl, + [string]$Method, + [hashtable]$Params = @{} + ) + + $webSocket = [System.Net.WebSockets.ClientWebSocket]::new() + try { + $webSocket.ConnectAsync([Uri]$WebSocketUrl, [System.Threading.CancellationToken]::None).GetAwaiter().GetResult() + $commandId = Get-Random -Minimum 1000 -Maximum 999999 + $payload = @{ + id = $commandId + method = $Method + params = $Params + } | ConvertTo-Json -Depth 100 -Compress + + $bytes = [System.Text.Encoding]::UTF8.GetBytes($payload) + $segment = [System.ArraySegment[byte]]::new($bytes) + $webSocket.SendAsync( + $segment, + [System.Net.WebSockets.WebSocketMessageType]::Text, + $true, + [System.Threading.CancellationToken]::None + ).GetAwaiter().GetResult() + + while ($true) { + $message = Receive-CdpMessage -WebSocket $webSocket + if ($message.id -eq $commandId) { + if ($message.error) { + throw "CDP command $Method failed: $($message.error.message)" + } + return $message + } + } + } finally { + if ($webSocket.State -eq [System.Net.WebSockets.WebSocketState]::Open) { + $webSocket.CloseAsync( + [System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, + "done", + [System.Threading.CancellationToken]::None + ).GetAwaiter().GetResult() + } + $webSocket.Dispose() + } +} + +function Invoke-CdpEvaluate { + param( + [string]$WebSocketUrl, + [string]$Expression + ) + + $response = Invoke-CdpCommand -WebSocketUrl $WebSocketUrl -Method "Runtime.evaluate" -Params @{ + expression = $Expression + awaitPromise = $true + returnByValue = $true + } + if ($response.result.exceptionDetails) { + $description = $response.result.exceptionDetails.exception.description + if (!$description) { + $description = $response.result.exceptionDetails.text + } + throw "Runtime.evaluate failed: $description" + } + return $response.result.result.value +} + +function Save-CdpScreenshot { + param( + [string]$WebSocketUrl, + [string]$Path + ) + + try { + $response = Invoke-CdpCommand -WebSocketUrl $WebSocketUrl -Method "Page.captureScreenshot" -Params @{ + format = "png" + fromSurface = $true + } + if ($response.result.data) { + [System.IO.File]::WriteAllBytes($Path, [Convert]::FromBase64String($response.result.data)) + return $Path + } + } catch { + Write-Step "screenshot skipped: $($_.Exception.Message)" + } + return $null +} + +function Assert-NoTerminalHistoryRestoreCode { + param([string]$TermwrapPath) + + $patterns = @( + "cache:term:full", + "SaveTerminalState", + "loadInitialTerminalData", + "runProcessIdleTimeout", + "processAndCacheData", + "SerializeAddon", + "fetchWaveFile" + ) + $matches = Select-String -LiteralPath $TermwrapPath -Pattern $patterns -SimpleMatch -ErrorAction Stop + if ($matches) { + $formatted = $matches | ForEach-Object { "$($_.Path):$($_.LineNumber):$($_.Line.Trim())" } + throw "terminal history restore/cache code is still present:`n$($formatted -join "`n")" + } + return @{ + checked = $true + bannedPatterns = $patterns + } +} + +$repoRoot = Split-Path -Parent $PSScriptRoot +$makeDir = Join-Path $repoRoot "make" +$exePath = Join-Path $makeDir "win-unpacked\Wave.exe" +$termwrapPath = Join-Path $repoRoot "frontend\app\view\term\termwrap.ts" +$startedProcess = $null +$staticCheck = $null +$exeItem = $null +$hash = $null +$target = $null +$runtime = $null +$screenshot = $null + +if (!(Test-Path -LiteralPath $exePath)) { + throw "Wave executable not found: $exePath. Run electron-builder --win dir first." +} + +if (!(Test-Path -LiteralPath $OutputDir)) { + New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null +} + +if ($Port -le 0) { + $Port = Get-FreeTcpPort +} + +$timestamp = Get-Date -Format "yyyyMMdd-HHmmss" +$resultPath = Join-Path $OutputDir "terminal-smoke-$timestamp.json" +$screenshotPath = Join-Path $OutputDir "terminal-smoke-$timestamp.png" + +Write-Step "repo root: $repoRoot" +Write-Step "exe: $exePath" +Write-Step "output: $resultPath" +Write-Step "cdp port: $Port" + +try { + if ($KillAllWave) { + Stop-WaveProcesses -RepoMakeDir $makeDir -AllWave + } elseif ($KillExistingRepoWave) { + Stop-WaveProcesses -RepoMakeDir $makeDir + } + + $staticCheck = Assert-NoTerminalHistoryRestoreCode -TermwrapPath $termwrapPath + $exeItem = Get-Item -LiteralPath $exePath + $hash = Get-FileHash -LiteralPath $exePath -Algorithm SHA256 + + Write-Step "starting Wave with CDP" + $startedProcess = Start-Process -FilePath $exePath -ArgumentList "--remote-debugging-port=$Port" -PassThru + $target = Wait-CdpTarget -CdpPort $Port -TimeoutSec $StartupTimeoutSec + Write-Step "connected target title='$($target.title)' url='$($target.url)'" + + $runtimeExpression = Get-Content -Raw -Encoding UTF8 -Path (Join-Path $PSScriptRoot "smoke-terminal.runtime.js") + + $runtime = Invoke-CdpEvaluate -WebSocketUrl $target.webSocketDebuggerUrl -Expression $runtimeExpression + $screenshot = Save-CdpScreenshot -WebSocketUrl $target.webSocketDebuggerUrl -Path $screenshotPath + + $cleanupBlockIds = @() + if ($runtime.cleanup) { + if ($runtime.cleanup.createdBlockIds) { + $cleanupBlockIds = @($runtime.cleanup.createdBlockIds) + } elseif ($runtime.cleanup.createdBlockId) { + $cleanupBlockIds = @($runtime.cleanup.createdBlockId) + } + } + if ($runtime.cleanup -and $runtime.cleanup.needsCleanup -and $cleanupBlockIds.Count -gt 0) { + $cleanupBlockIdsLiteral = $cleanupBlockIds | ConvertTo-Json -Compress + $cleanupExpression = @" +(async () => { + const blockIds = $cleanupBlockIdsLiteral; + if (!Array.isArray(blockIds) || blockIds.length === 0 || !window.RpcApi || !window.TabRpcClient) { + return { cleaned: false, blockIds, reason: 'missing blockIds, RpcApi or TabRpcClient' }; + } + const results = []; + for (const blockId of blockIds.slice().reverse()) { + try { + await window.RpcApi.DeleteBlockCommand(window.TabRpcClient, { blockid: blockId }); + results.push({ blockId, cleaned: true }); + } catch (error) { + results.push({ blockId, cleaned: false, error: error?.message ?? String(error) }); + } + } + return { cleaned: results.every((item) => item.cleaned), blockIds, results }; +})() +"@ + $cleanupResult = Invoke-CdpEvaluate -WebSocketUrl $target.webSocketDebuggerUrl -Expression $cleanupExpression + $runtime.cleanup | Add-Member -NotePropertyName result -NotePropertyValue $cleanupResult -Force + } + + if ($RequireTerminal -and !$runtime.hasTerm) { + throw "window.term not found. Open a terminal block first, or rerun with -RequireTerminal:`$false." + } + if ($runtime.hasTerm) { + if ($runtime.term.historyMethodsPresent.Count -gt 0) { + throw "runtime still exposes terminal history methods: $($runtime.term.historyMethodsPresent -join ', ')" + } + if ($runtime.term.hasSerializeAddon) { + throw "runtime still exposes serializeAddon" + } + if ($runtime.terminals.Count -lt 2 -or $runtime.knownTerminalCount -lt 2) { + throw "multi-terminal smoke did not stabilize: dom=$($runtime.terminals.Count) known=$($runtime.knownTerminalCount) diagnostics=$($runtime.diagnostics -join ', ')" + } + if (!$runtime.wheel.allPassed) { + $wheelFailure = @($runtime.wheel.scenarios | Where-Object { -not $_.pass }) | Select-Object -First 1 + if ($null -ne $wheelFailure) { + throw "wheel smoke failed for block $($wheelFailure.targetBlockId): $($wheelFailure.diagnosis)" + } + throw "wheel smoke failed: $($runtime.wheel.diagnoses -join ', ')" + } + if (!$runtime.ime.allPassed) { + $imeFailure = @($runtime.ime.scenarios | Where-Object { -not $_.pass }) | Select-Object -First 1 + if ($null -ne $imeFailure) { + throw "IME ownership smoke failed for block $($imeFailure.targetBlockId): $($imeFailure.diagnosis)" + } + throw "IME ownership smoke failed: $($runtime.ime.diagnoses -join ', ')" + } + } + + $summary = [ordered]@{ + status = "passing" + timestamp = (Get-Date).ToString("o") + repoRoot = $repoRoot + executable = [ordered]@{ + path = $exeItem.FullName + lastWriteTime = $exeItem.LastWriteTime.ToString("o") + length = $exeItem.Length + sha256 = $hash.Hash + } + cdp = [ordered]@{ + port = $Port + targetTitle = $target.title + targetUrl = $target.url + } + staticCheck = $staticCheck + runtime = $runtime + screenshot = $screenshot + } + + $summary | ConvertTo-Json -Depth 100 | Set-Content -Path $resultPath -Encoding UTF8 + Write-Step "PASS" + Write-Step "result: $resultPath" + if ($screenshot) { + Write-Step "screenshot: $screenshot" + } +} catch { + $failure = [ordered]@{ + status = "failing" + timestamp = (Get-Date).ToString("o") + repoRoot = $repoRoot + executable = if ($exeItem) { + [ordered]@{ + path = $exeItem.FullName + lastWriteTime = $exeItem.LastWriteTime.ToString("o") + length = $exeItem.Length + sha256 = $hash.Hash + } + } else { + $exePath + } + cdp = if ($target) { + [ordered]@{ + port = $Port + targetTitle = $target.title + targetUrl = $target.url + } + } else { + [ordered]@{ + port = $Port + } + } + staticCheck = $staticCheck + runtime = $runtime + screenshot = $screenshot + error = $_.Exception.Message + } + $failure | ConvertTo-Json -Depth 100 | Set-Content -Path $resultPath -Encoding UTF8 + Write-Step "FAIL: $($_.Exception.Message)" + Write-Step "result: $resultPath" + if ($screenshot) { + Write-Step "screenshot: $screenshot" + } + throw +} finally { + if (!$KeepApp) { + $running = Get-Process Wave -ErrorAction SilentlyContinue | Where-Object { + try { + $_.Path -and $_.Path.Equals($exePath, [System.StringComparison]::OrdinalIgnoreCase) + } catch { + $false + } + } + foreach ($process in $running) { + Write-Step "cleanup Wave process pid=$($process.Id)" + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } + } elseif ($startedProcess) { + Write-Step "keeping Wave process pid=$($startedProcess.Id)" + } +} diff --git a/scripts/smoke-terminal.runtime.js b/scripts/smoke-terminal.runtime.js new file mode 100644 index 0000000000..ddc929b32f --- /dev/null +++ b/scripts/smoke-terminal.runtime.js @@ -0,0 +1,1278 @@ +(async () => { + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const waitFor = async (predicate, timeoutMs = 15000, intervalMs = 100) => { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const value = await predicate(); + if (value) { + return value; + } + await wait(intervalMs); + } + return null; + }; + const historyMethods = [ + "loadInitialTerminalData", + "processAndCacheData", + "runProcessIdleTimeout", + "persistTerminalState", + ]; + const pxValue = (value) => { + const parsed = Number.parseFloat(value ?? ""); + return Number.isFinite(parsed) ? parsed : null; + }; + const rectToObject = (rect) => { + if (!rect) { + return null; + } + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + right: rect.right, + bottom: rect.bottom, + }; + }; + const getBlockIdForElement = (elem) => elem?.closest?.("[data-blockid]")?.dataset?.blockid ?? null; + const describeElement = (elem) => { + if (!elem) { + return null; + } + return { + tagName: elem.tagName ?? null, + id: elem.id || null, + className: typeof elem.className === "string" ? elem.className : null, + role: elem.getAttribute?.("role") ?? null, + blockId: getBlockIdForElement(elem), + }; + }; + const describeElementChain = (elem, maxDepth = 8) => { + const chain = []; + let current = elem; + for (let idx = 0; current && idx < maxDepth; idx += 1) { + chain.push(describeElement(current)); + current = current.parentElement; + } + return chain; + }; + const describeScrollContainers = (elem, maxDepth = 10) => { + const containers = []; + let current = elem; + for (let idx = 0; current && idx < maxDepth; idx += 1) { + const style = window.getComputedStyle(current); + const scrollable = current.scrollHeight > current.clientHeight || current.scrollWidth > current.clientWidth; + const overflowed = /(auto|scroll|overlay)/.test(`${style.overflow} ${style.overflowY} ${style.overflowX}`); + if (scrollable || overflowed) { + containers.push({ + ...describeElement(current), + scrollTop: current.scrollTop ?? null, + scrollHeight: current.scrollHeight ?? null, + clientHeight: current.clientHeight ?? null, + overflow: `${style.overflow}/${style.overflowY}/${style.overflowX}`, + pointerEvents: style.pointerEvents ?? null, + }); + } + current = current.parentElement; + } + return containers; + }; + const parseORefId = (oref) => { + if (typeof oref !== "string") { + return null; + } + const parts = oref.split(":"); + return parts.length > 1 ? parts.slice(1).join(":") : oref; + }; + const getTabId = () => window.globalStore?.get?.(window.globalAtoms?.staticTabId) ?? null; + const getBlockData = (blockId) => { + if (!blockId || !window.WOS?.getWaveObjectAtom || !window.WOS?.makeORef || !window.globalStore?.get) { + return null; + } + try { + const atom = window.WOS.getWaveObjectAtom(window.WOS.makeORef("block", blockId)); + return window.globalStore.get(atom); + } catch (error) { + return null; + } + }; + const ensureTermRegistry = () => { + if (window.__waveSmokeTermRegistry) { + return window.__waveSmokeTermRegistry; + } + const registry = { + seen: [], + byBlockId: {}, + hooked: false, + hookError: null, + addWrap(wrap) { + if (!wrap || !wrap.blockId || this.byBlockId[wrap.blockId]) { + return; + } + this.byBlockId[wrap.blockId] = wrap; + this.seen.push(wrap); + }, + refreshFromLiveInstances() { + const liveInstances = window.term?.constructor?.liveInstances; + if (!(liveInstances instanceof Set)) { + return; + } + for (const wrap of liveInstances) { + this.addWrap(wrap); + } + }, + }; + registry.addWrap(window.term); + registry.refreshFromLiveInstances(); + let currentValue = window.term; + try { + Object.defineProperty(window, "term", { + configurable: true, + enumerable: true, + get() { + return currentValue; + }, + set(value) { + currentValue = value; + registry.addWrap(value); + }, + }); + registry.hooked = true; + } catch (error) { + registry.hookError = error?.message ?? String(error); + } + window.__waveSmokeTermRegistry = registry; + return registry; + }; + + const started = Date.now(); + await waitFor(() => window.term, 15000, 250); + const summary = { + href: location.href, + title: document.title, + hasTerm: !!window.term, + waitedMs: Date.now() - started, + }; + let createdInitialBlockId = null; + if (!window.term) { + summary.createdInitialTerm = { + requested: true, + tabId: getTabId(), + }; + if (summary.createdInitialTerm.tabId && window.RpcApi && window.TabRpcClient) { + try { + const initialORef = await window.RpcApi.CreateBlockCommand(window.TabRpcClient, { + tabid: summary.createdInitialTerm.tabId, + blockdef: { + meta: { + view: "term", + controller: "shell", + }, + }, + focused: true, + rtopts: { + termsize: { + rows: 25, + cols: 80, + }, + }, + }); + createdInitialBlockId = parseORefId(initialORef); + summary.createdInitialTerm.createdORef = initialORef; + summary.createdInitialTerm.createdBlockId = createdInitialBlockId; + await waitFor(() => window.term, 15000, 250); + } catch (error) { + summary.createdInitialTerm.error = error?.message ?? String(error); + } + } else { + summary.createdInitialTerm.error = "missing tabId, RpcApi or TabRpcClient"; + } + summary.hasTerm = !!window.term; + summary.waitedMs = Date.now() - started; + } + if (!window.term) { + return summary; + } + + const registry = ensureTermRegistry(); + const summarizeHelper = (elem) => ({ + exists: !!elem, + top: elem?.style?.top || null, + left: elem?.style?.left || null, + width: elem?.style?.width || null, + height: elem?.style?.height || null, + lineHeight: elem?.style?.lineHeight || null, + zIndex: elem?.style?.zIndex || null, + rect: rectToObject(elem?.getBoundingClientRect?.()), + }); + const summarizeWrap = (termWrap) => { + if (!termWrap?.terminal) { + return null; + } + const terminal = termWrap.terminal; + const activeBuffer = terminal.buffer.active; + const cell = terminal._core?._renderService?.dimensions?.css?.cell || {}; + const scrollDom = + terminal._core?._viewport?._scrollableElement?._domNode || + termWrap.connectElem?.querySelector?.(".xterm-scrollable-element") || + null; + const compositionView = termWrap.connectElem?.querySelector?.(".composition-view.active, .composition-view") || null; + const shellState = termWrap.shellIntegrationStatusAtom ? window.globalStore?.get?.(termWrap.shellIntegrationStatusAtom) : null; + const lastCommand = termWrap.lastCommandAtom ? window.globalStore?.get?.(termWrap.lastCommandAtom) : null; + const claudeCodeActive = termWrap.claudeCodeActiveAtom ? window.globalStore?.get?.(termWrap.claudeCodeActiveAtom) : null; + let shouldAnchorIme = null; + try { + shouldAnchorIme = + typeof termWrap.shouldAnchorImeForAgentTui === "function" ? !!termWrap.shouldAnchorImeForAgentTui() : null; + } catch (error) { + shouldAnchorIme = null; + } + return { + blockId: termWrap.blockId, + loaded: !!termWrap.loaded, + rows: terminal.rows ?? null, + cols: terminal.cols ?? null, + bufferType: activeBuffer?.type ?? null, + mouseTrackingMode: terminal.modes?.mouseTrackingMode ?? null, + cursorX: activeBuffer?.cursorX ?? null, + cursorY: activeBuffer?.cursorY ?? null, + viewportY: activeBuffer?.viewportY ?? null, + baseY: activeBuffer?.baseY ?? null, + length: activeBuffer?.length ?? null, + historyMethodsPresent: historyMethods.filter((name) => typeof termWrap[name] === "function"), + hasSerializeAddon: Object.prototype.hasOwnProperty.call(termWrap, "serializeAddon"), + hasPtyOffset: Object.prototype.hasOwnProperty.call(termWrap, "ptyOffset"), + heldDataLength: Array.isArray(termWrap.heldData) ? termWrap.heldData.length : null, + cellHeight: cell.height ?? null, + cellWidth: cell.width ?? null, + scrollTop: scrollDom?.scrollTop ?? null, + scrollHeight: scrollDom?.scrollHeight ?? null, + clientHeight: scrollDom?.clientHeight ?? null, + shellState, + lastCommand, + claudeCodeActive, + shouldAnchorIme, + textarea: summarizeHelper(terminal.textarea), + composition: summarizeHelper(compositionView), + }; + }; + const getTermDomRefs = (blockId) => { + const blockElem = Array.from(document.querySelectorAll("[data-blockid]")).find( + (elem) => elem.dataset?.blockid === blockId + ); + const viewElem = blockElem?.querySelector?.(".view-term") || null; + const connectElem = viewElem?.querySelector?.(".term-connectelem") || null; + const xtermElem = connectElem?.querySelector?.(".xterm") || null; + const screenElem = + connectElem?.querySelector?.(".xterm-screen") || + connectElem?.querySelector?.(".xterm-rows") || + xtermElem || + connectElem || + null; + const scrollableElem = connectElem?.querySelector?.(".xterm-scrollable-element") || null; + const textarea = connectElem?.querySelector?.(".xterm-helper-textarea") || null; + const composition = connectElem?.querySelector?.(".composition-view.active, .composition-view") || null; + return { + blockElem, + viewElem, + connectElem, + xtermElem, + screenElem, + scrollableElem, + textarea, + composition, + }; + }; + const getFocusState = () => { + const activeElement = document.activeElement; + const termElem = activeElement?.closest?.(".view-term"); + const focusedTerminalBlock = Array.from(document.querySelectorAll(".block-focused[data-blockid]")).find((elem) => + elem.querySelector(".view-term") + ); + return { + activeElement: describeElement(activeElement), + blockId: getBlockIdForElement(activeElement), + termBlockId: getBlockIdForElement(termElem || activeElement), + appFocusedBlockIds: Array.from(document.querySelectorAll(".block-focused[data-blockid]")).map( + (elem) => elem.dataset.blockid + ), + appFocusedTerminalBlockId: focusedTerminalBlock?.dataset?.blockid ?? null, + }; + }; + const getActiveTerminal = () => { + const focus = getFocusState(); + if (focus.termBlockId) { + return { blockId: focus.termBlockId, source: "document.activeElement" }; + } + if (focus.appFocusedTerminalBlockId) { + return { blockId: focus.appFocusedTerminalBlockId, source: "block-focused" }; + } + return { blockId: null, source: "unknown" }; + }; + const collectTerminals = () => { + registry.refreshFromLiveInstances(); + const activeElement = document.activeElement; + return Array.from(document.querySelectorAll(".view-term")) + .map((viewElem, index) => { + const blockElem = viewElem.closest("[data-blockid]"); + const blockId = blockElem?.dataset?.blockid ?? null; + const refs = getTermDomRefs(blockId); + const wrap = blockId ? registry.byBlockId[blockId] : null; + const rect = viewElem.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(viewElem); + return { + index, + blockId, + visible: + rect.width > 0 && + rect.height > 0 && + computedStyle.display !== "none" && + computedStyle.visibility !== "hidden", + geometry: rectToObject(rect), + blockFocused: blockElem?.classList?.contains("block-focused") ?? false, + activeElementInside: !!activeElement && !!refs.connectElem?.contains(activeElement), + textarea: summarizeHelper(refs.textarea), + composition: summarizeHelper(refs.composition), + dom: { + viewClass: typeof viewElem.className === "string" ? viewElem.className : null, + connectClass: typeof refs.connectElem?.className === "string" ? refs.connectElem.className : null, + xtermClass: typeof refs.xtermElem?.className === "string" ? refs.xtermElem.className : null, + viewport: { + scrollTop: refs.scrollableElem?.scrollTop ?? null, + scrollHeight: refs.scrollableElem?.scrollHeight ?? null, + clientHeight: refs.scrollableElem?.clientHeight ?? null, + }, + }, + runtimeKnown: !!wrap, + runtime: summarizeWrap(wrap), + }; + }) + .sort((leftTerm, rightTerm) => { + const topDelta = (leftTerm.geometry?.top ?? 0) - (rightTerm.geometry?.top ?? 0); + if (Math.abs(topDelta) > 24) { + return topDelta; + } + return (leftTerm.geometry?.left ?? 0) - (rightTerm.geometry?.left ?? 0); + }); + }; + const captureStateMap = () => { + const stateMap = {}; + for (const term of collectTerminals()) { + if (!term.blockId) { + continue; + } + stateMap[term.blockId] = { + runtimeViewportY: term.runtime?.viewportY ?? null, + domScrollTop: term.dom?.viewport?.scrollTop ?? null, + }; + } + return stateMap; + }; + const diffStateMap = (before, after) => { + const diff = {}; + const keys = new Set([...Object.keys(before), ...Object.keys(after)]); + for (const blockId of keys) { + if (!blockId) { + continue; + } + const beforeState = before[blockId] ?? {}; + const afterState = after[blockId] ?? {}; + const runtimeChanged = + beforeState.runtimeViewportY !== null && + afterState.runtimeViewportY !== null && + beforeState.runtimeViewportY !== afterState.runtimeViewportY; + const domChanged = + beforeState.domScrollTop !== null && + afterState.domScrollTop !== null && + beforeState.domScrollTop !== afterState.domScrollTop; + diff[blockId] = { + runtimeBefore: beforeState.runtimeViewportY ?? null, + runtimeAfter: afterState.runtimeViewportY ?? null, + runtimeChanged, + domBefore: beforeState.domScrollTop ?? null, + domAfter: afterState.domScrollTop ?? null, + domChanged, + changed: runtimeChanged || domChanged, + }; + } + return diff; + }; + const getChangedBlocks = (diff) => + Object.entries(diff) + .filter(([, value]) => value.changed) + .map(([blockId]) => blockId); + const getTargetAtCenter = (blockId) => { + const refs = getTermDomRefs(blockId); + const baseElem = refs.screenElem || refs.xtermElem || refs.connectElem; + const rect = baseElem?.getBoundingClientRect?.(); + if (!rect || rect.width <= 0 || rect.height <= 0) { + return { + element: refs.connectElem || refs.xtermElem || baseElem || null, + clientX: null, + clientY: null, + }; + } + const clientX = rect.left + Math.max(2, Math.min(rect.width - 2, rect.width / 2)); + const clientY = rect.top + Math.max(2, Math.min(rect.height - 2, rect.height / 2)); + return { + element: document.elementFromPoint(clientX, clientY) || baseElem, + clientX, + clientY, + }; + }; + const getTargetAtPoint = (blockId, pointName) => { + const refs = getTermDomRefs(blockId); + const screenRect = refs.screenElem?.getBoundingClientRect?.(); + const viewRect = refs.viewElem?.getBoundingClientRect?.(); + const scrollRect = refs.scrollableElem?.getBoundingClientRect?.(); + const fallbackRect = refs.connectElem?.getBoundingClientRect?.(); + const rect = + pointName === "view-right" + ? viewRect || fallbackRect + : pointName === "scrollbar-center" + ? scrollRect || screenRect || fallbackRect + : screenRect || fallbackRect; + if (!rect || rect.width <= 0 || rect.height <= 0) { + return { + pointName, + element: refs.connectElem || refs.xtermElem || refs.viewElem || null, + clientX: null, + clientY: null, + hit: null, + chain: [], + scrollContainers: [], + }; + } + const clientY = rect.top + Math.max(2, Math.min(rect.height - 2, rect.height / 2)); + const clientX = + pointName === "screen-right" || pointName === "view-right" + ? rect.right - Math.min(8, Math.max(2, rect.width / 4)) + : rect.left + Math.max(2, Math.min(rect.width - 2, rect.width / 2)); + const element = document.elementFromPoint(clientX, clientY) || refs.connectElem || refs.xtermElem || refs.viewElem || null; + return { + pointName, + element, + clientX, + clientY, + hit: describeElement(element), + chain: describeElementChain(element), + scrollContainers: describeScrollContainers(element), + rect: rectToObject(rect), + }; + }; + const dispatchMouseSequence = (element, clientX, clientY) => { + if (!element) { + return; + } + for (const eventName of ["pointerdown", "mousedown", "mouseup", "click"]) { + const EventCtor = + eventName.startsWith("pointer") && typeof PointerEvent === "function" ? PointerEvent : MouseEvent; + const event = new EventCtor(eventName, { + bubbles: true, + cancelable: true, + composed: true, + clientX: clientX ?? 0, + clientY: clientY ?? 0, + button: 0, + buttons: 1, + }); + element.dispatchEvent(event); + } + }; + const activateTerminal = async (blockId) => { + const refs = getTermDomRefs(blockId); + const target = getTargetAtCenter(blockId); + dispatchMouseSequence(target.element || refs.connectElem || refs.blockElem, target.clientX, target.clientY); + refs.textarea?.focus?.(); + await wait(120); + return getFocusState(); + }; + const seedScrollback = async (termWrap, prefix) => { + if (!termWrap?.terminal) { + return; + } + const output = Array.from({ length: 180 }, (_, idx) => `${prefix}-${idx}`).join("\r\n") + "\r\n"; + await new Promise((resolve) => termWrap.terminal.write(output, resolve)); + termWrap.terminal.scrollToBottom(); + await wait(80); + }; + const createSplitTerminal = async (sourceBlockId, targetAction = "splitright") => { + const tabId = getTabId(); + if (!tabId || !sourceBlockId || !window.RpcApi || !window.TabRpcClient) { + return { blockId: null, error: "missing tabId, sourceBlockId, RpcApi or TabRpcClient" }; + } + const blockData = getBlockData(sourceBlockId); + const blockMeta = { ...(blockData?.meta || {}) }; + if (!blockMeta.view) { + blockMeta.view = "term"; + } + if (!blockMeta.controller) { + blockMeta.controller = "shell"; + } + const oref = await window.RpcApi.CreateBlockCommand(window.TabRpcClient, { + tabid: tabId, + blockdef: { + meta: blockMeta, + }, + focused: true, + targetblockid: sourceBlockId, + targetaction: targetAction, + rtopts: { + termsize: { + rows: 25, + cols: 80, + }, + }, + }); + const blockId = parseORefId(oref); + await waitFor(() => { + registry.refreshFromLiveInstances(); + return !!registry.byBlockId[blockId] && !!getTermDomRefs(blockId).viewElem; + }, 12000, 150); + return { blockId, oref }; + }; + const selectDiagnosticTarget = (terminals) => { + const visibleKnown = terminals.filter((term) => term.visible && term.runtimeKnown); + const agentLike = visibleKnown.find((term) => { + const lastCommand = `${term.runtime?.lastCommand ?? ""}`.toLowerCase(); + return !!term.runtime?.shouldAnchorIme || !!term.runtime?.claudeCodeActive || /\b(codex|claude|opencode|aider|gemini|qwen)\b/.test(lastCommand); + }); + if (agentLike) { + return { blockId: agentLike.blockId, reason: "agent_like_runtime" }; + } + if (visibleKnown.length >= 3) { + return { blockId: visibleKnown[Math.floor(visibleKnown.length / 2)].blockId, reason: "middle_visible_terminal" }; + } + const focused = visibleKnown.find((term) => term.activeElementInside || term.blockFocused); + if (focused) { + return { blockId: focused.blockId, reason: "focused_terminal" }; + } + return { blockId: visibleKnown[0]?.blockId ?? null, reason: "first_visible_terminal" }; + }; + const getStateForBlock = (state, blockId) => state?.[blockId] ?? {}; + const diagnoseLiveWheelPoint = (targetBlockId, pointInfo, before, during, afterStop, defaultPrevented) => { + const duringDiff = diffStateMap(before, during); + const afterStopDiff = diffStateMap(before, afterStop); + const duringChanged = getChangedBlocks(duringDiff); + const afterStopChanged = getChangedBlocks(afterStopDiff); + const wrongDuringChanged = duringChanged.filter((blockId) => blockId !== targetBlockId); + const hitBlockId = pointInfo?.hit?.blockId ?? null; + const targetBefore = getStateForBlock(before, targetBlockId); + let diagnosis = "ok"; + if (hitBlockId && hitBlockId !== targetBlockId) { + diagnosis = "live_hit_wrong_block"; + } else if ((targetBefore.runtimeViewportY ?? 0) <= 0 && (targetBefore.domScrollTop ?? 0) <= 0) { + diagnosis = "live_no_scrollback"; + } else if (wrongDuringChanged.length > 0) { + diagnosis = "live_wrong_terminal"; + } else if (!duringChanged.includes(targetBlockId) && defaultPrevented) { + diagnosis = "live_consumed_without_scroll"; + } else if (!duringChanged.includes(targetBlockId)) { + diagnosis = "live_no_scroll"; + } else if (!afterStopChanged.includes(targetBlockId)) { + diagnosis = "live_scrolled_then_snapped_back"; + } + return { + diagnosis, + duringChanged, + afterStopChanged, + wrongDuringChanged, + pass: diagnosis === "ok", + }; + }; + const runLiveOutputWheelScenario = async (targetBlockId, label) => { + const targetWrap = registry.byBlockId[targetBlockId]; + if (!targetWrap?.terminal) { + return { + label, + targetBlockId, + diagnosis: "live_missing_runtime", + pass: false, + }; + } + await activateTerminal(targetBlockId); + await seedScrollback(targetWrap, `live-${label}`); + const pointNames = ["screen-center", "screen-right", "view-right", "scrollbar-center"]; + let intervalId = null; + let writeCount = 0; + const timeline = []; + const captureTick = (tickLabel) => { + timeline.push({ + label: tickLabel, + ts: Date.now(), + state: captureStateMap(), + focus: getFocusState(), + active: getActiveTerminal(), + imeOwnerBlockId: window.term?.constructor?.imeOwnerBlockId ?? null, + }); + if (timeline.length > 18) { + timeline.shift(); + } + }; + captureTick("start"); + intervalId = setInterval(() => { + writeCount += 1; + targetWrap.terminal.write(`diag-live-${label}-${writeCount}-${"x".repeat(72)}\r\n`); + if (writeCount % 3 === 0) { + captureTick(`tick-${writeCount}`); + } + }, 80); + await wait(180); + const pointResults = []; + for (const pointName of pointNames) { + const pointInfo = getTargetAtPoint(targetBlockId, pointName); + const before = captureStateMap(); + const event = pointInfo.element + ? new WheelEvent("wheel", { + deltaY: -720, + deltaMode: 0, + bubbles: true, + cancelable: true, + clientX: pointInfo.clientX ?? 0, + clientY: pointInfo.clientY ?? 0, + }) + : null; + pointInfo.element?.dispatchEvent(event); + await wait(180); + const during = captureStateMap(); + await wait(260); + const afterStop = captureStateMap(); + pointResults.push({ + pointName, + pointInfo, + before, + during, + afterStop, + defaultPrevented: event?.defaultPrevented ?? false, + ...diagnoseLiveWheelPoint(targetBlockId, pointInfo, before, during, afterStop, event?.defaultPrevented ?? false), + }); + } + clearInterval(intervalId); + captureTick("stop"); + return { + label, + targetBlockId, + writeCount, + target: selectDiagnosticTarget(collectTerminals()), + pointResults, + timeline, + pass: pointResults.every((result) => result.pass), + diagnoses: Array.from(new Set(pointResults.map((result) => result.diagnosis))), + }; + }; + const runImeOwnershipSnapshotScenario = async (targetBlockId, label) => { + const targetWrap = registry.byBlockId[targetBlockId]; + if (!targetWrap?.terminal) { + return { + label, + targetBlockId, + diagnosis: "ime_live_missing_runtime", + pass: false, + }; + } + await activateTerminal(targetBlockId); + const snapshots = []; + const takeSnapshot = (snapshotLabel) => { + for (const wrap of Object.values(registry.byBlockId)) { + wrap?.syncImePositionForAgentTui?.(); + } + const terminals = collectTerminals(); + const styledBlocks = terminals + .filter((term) => hasVisibleImeOverride(term.textarea) || hasVisibleImeOverride(term.composition)) + .map((term) => term.blockId); + const wrongStyledBlocks = styledBlocks.filter((blockId) => blockId !== targetBlockId); + const targetRuntime = summarizeWrap(registry.byBlockId[targetBlockId]); + snapshots.push({ + label: snapshotLabel, + focus: getFocusState(), + active: getActiveTerminal(), + imeOwnerBlockId: window.term?.constructor?.imeOwnerBlockId ?? null, + targetShouldAnchorIme: targetRuntime?.shouldAnchorIme ?? null, + styledBlocks, + wrongStyledBlocks, + terminals: terminals.map((term) => ({ + blockId: term.blockId, + textarea: term.textarea, + composition: term.composition, + runtime: { + cursorX: term.runtime?.cursorX ?? null, + cursorY: term.runtime?.cursorY ?? null, + cellHeight: term.runtime?.cellHeight ?? null, + cellWidth: term.runtime?.cellWidth ?? null, + shouldAnchorIme: term.runtime?.shouldAnchorIme ?? null, + }, + })), + }); + }; + let intervalId = null; + intervalId = setInterval(() => { + targetWrap.terminal.write(`ime-live-${label}-${Date.now()}\r\n`); + }, 110); + takeSnapshot("start"); + await wait(180); + takeSnapshot("mid-1"); + await wait(180); + takeSnapshot("mid-2"); + await wait(180); + takeSnapshot("mid-3"); + clearInterval(intervalId); + takeSnapshot("stop"); + const targetSnapshots = snapshots.filter((snapshot) => snapshot.targetShouldAnchorIme); + let diagnosis = "ime_live_not_applicable"; + if (targetSnapshots.length > 0) { + if (targetSnapshots.some((snapshot) => snapshot.wrongStyledBlocks.length > 0)) { + diagnosis = "ime_live_wrong_terminal"; + } else if (targetSnapshots.some((snapshot) => !snapshot.styledBlocks.includes(targetBlockId))) { + diagnosis = "ime_live_not_anchored"; + } else { + diagnosis = "ok"; + } + } + return { + label, + targetBlockId, + snapshots, + diagnosis, + pass: diagnosis === "ok" || diagnosis === "ime_live_not_applicable", + }; + }; + const hasVisibleImeOverride = (helper) => { + if (!helper) { + return false; + } + const zIndex = pxValue(helper.zIndex); + if (zIndex !== null) { + return zIndex >= 0; + } + return [helper.top, helper.left, helper.width, helper.height, helper.lineHeight].some( + (value) => typeof value === "string" && value.length > 0 + ); + }; + + const runWheelScenario = async (targetBlockId, label) => { + const targetWrap = registry.byBlockId[targetBlockId]; + await activateTerminal(targetBlockId); + await seedScrollback(targetWrap, `wheel-${label}`); + const focusBefore = getFocusState(); + const activeBefore = getActiveTerminal(); + const outerTarget = getTargetAtCenter(targetBlockId); + const beforeOuter = captureStateMap(); + const outerEvent = outerTarget.element + ? new WheelEvent("wheel", { + deltaY: -720, + deltaMode: 0, + bubbles: true, + cancelable: true, + clientX: outerTarget.clientX ?? 0, + clientY: outerTarget.clientY ?? 0, + }) + : null; + outerTarget.element?.dispatchEvent(outerEvent); + await wait(120); + const afterOuter = captureStateMap(); + const outerDiff = diffStateMap(beforeOuter, afterOuter); + const outerChangedBlocks = getChangedBlocks(outerDiff); + + targetWrap?.terminal?.scrollToBottom?.(); + await wait(60); + const refs = getTermDomRefs(targetBlockId); + const internalTarget = + targetWrap?.terminal?._core?._viewport?._scrollableElement?._domNode || refs.scrollableElem || null; + const beforeInternal = captureStateMap(); + const internalEvent = internalTarget + ? new WheelEvent("wheel", { + deltaY: -720, + deltaMode: 0, + bubbles: true, + cancelable: true, + }) + : null; + internalTarget?.dispatchEvent(internalEvent); + await wait(120); + const afterInternal = captureStateMap(); + const internalDiff = diffStateMap(beforeInternal, afterInternal); + const internalChangedBlocks = getChangedBlocks(internalDiff); + + const outerWrongBlocks = outerChangedBlocks.filter((blockId) => blockId !== targetBlockId); + const targetBufferType = targetWrap?.terminal?.buffer?.active?.type ?? null; + let diagnosis = "ok"; + if (focusBefore.termBlockId !== targetBlockId && activeBefore.blockId !== targetBlockId) { + diagnosis = "wheel_focus_mismatch"; + } else if (targetBufferType !== "normal") { + diagnosis = "wheel_non_normal_buffer"; + } else if (!outerChangedBlocks.includes(targetBlockId) && internalChangedBlocks.includes(targetBlockId)) { + diagnosis = "wheel_route_problem"; + } else if (outerWrongBlocks.length > 0) { + diagnosis = "wheel_wrong_terminal"; + } else if (!outerChangedBlocks.includes(targetBlockId) && !internalChangedBlocks.includes(targetBlockId)) { + diagnosis = "wheel_no_scroll"; + } + + return { + kind: "normal-scrollback", + label, + targetBlockId, + focusBefore, + activeBefore, + targetBufferType, + outer: { + dispatchPath: "elementFromPoint", + target: describeElement(outerTarget.element), + before: beforeOuter, + after: afterOuter, + diff: outerDiff, + changedBlocks: outerChangedBlocks, + defaultPrevented: outerEvent?.defaultPrevented ?? false, + }, + internal: { + target: describeElement(internalTarget), + before: beforeInternal, + after: afterInternal, + diff: internalDiff, + changedBlocks: internalChangedBlocks, + defaultPrevented: internalEvent?.defaultPrevented ?? false, + }, + diagnosis, + pass: diagnosis === "ok", + }; + }; + const runAlternateWheelScenario = async (targetBlockId, label) => { + const targetWrap = registry.byBlockId[targetBlockId]; + const originals = { + sendDataHandler: targetWrap?.sendDataHandler, + multiInputCallback: targetWrap?.multiInputCallback, + }; + const capturedInput = []; + try { + await activateTerminal(targetBlockId); + if (!targetWrap?.terminal) { + return { + kind: "alternate-input", + label, + targetBlockId, + diagnosis: "alternate_missing_runtime", + pass: false, + }; + } + targetWrap.sendDataHandler = (data) => capturedInput.push(data); + targetWrap.multiInputCallback = (data) => capturedInput.push(data); + await new Promise((resolve) => targetWrap.terminal.write("\x1b[?1049h", resolve)); + await wait(120); + + const focusBefore = getFocusState(); + const activeBefore = getActiveTerminal(); + const target = getTargetAtCenter(targetBlockId); + const beforeState = summarizeWrap(targetWrap); + const event = target.element + ? new WheelEvent("wheel", { + deltaY: -720, + deltaMode: 0, + bubbles: true, + cancelable: true, + clientX: target.clientX ?? 0, + clientY: target.clientY ?? 0, + }) + : null; + target.element?.dispatchEvent(event); + await wait(160); + const afterState = summarizeWrap(targetWrap); + const capturedText = capturedInput.join(""); + const arrowInputSent = capturedText.includes("\x1b[A") || capturedText.includes("\x1bOA"); + + let diagnosis = "ok"; + if (focusBefore.termBlockId !== targetBlockId && activeBefore.blockId !== targetBlockId) { + diagnosis = "alternate_wheel_focus_mismatch"; + } else if (beforeState?.bufferType !== "alternate") { + diagnosis = "alternate_setup_failed"; + } else if (!arrowInputSent) { + diagnosis = "alternate_wheel_no_arrow_input"; + } + + return { + kind: "alternate-input", + label, + targetBlockId, + focusBefore, + activeBefore, + target: describeElement(target.element), + before: beforeState, + after: afterState, + capturedInput, + arrowInputSent, + defaultPrevented: event?.defaultPrevented ?? false, + diagnosis, + pass: diagnosis === "ok", + }; + } finally { + if (targetWrap) { + targetWrap.sendDataHandler = originals.sendDataHandler; + targetWrap.multiInputCallback = originals.multiInputCallback; + if (targetWrap.terminal?.buffer?.active?.type === "alternate") { + await new Promise((resolve) => targetWrap.terminal.write("\x1b[?1049l", resolve)); + await wait(80); + } + targetWrap.terminal?.scrollToBottom?.(); + } + } + }; + const runMouseTrackingWheelScenario = async (targetBlockId, label) => { + const targetWrap = registry.byBlockId[targetBlockId]; + const originals = { + sendDataHandler: targetWrap?.sendDataHandler, + multiInputCallback: targetWrap?.multiInputCallback, + }; + const capturedInput = []; + try { + await activateTerminal(targetBlockId); + if (!targetWrap?.terminal) { + return { + kind: "mouse-tracking-wheel", + label, + targetBlockId, + diagnosis: "mouse_tracking_missing_runtime", + pass: false, + }; + } + targetWrap.sendDataHandler = (data) => capturedInput.push(data); + targetWrap.multiInputCallback = (data) => capturedInput.push(data); + await new Promise((resolve) => targetWrap.terminal.write("\x1b[?1049h\x1b[?1003h\x1b[?1006h", resolve)); + await wait(120); + + const focusBefore = getFocusState(); + const activeBefore = getActiveTerminal(); + const target = getTargetAtCenter(targetBlockId); + const beforeState = summarizeWrap(targetWrap); + const event = target.element + ? new WheelEvent("wheel", { + deltaY: -720, + deltaMode: 0, + bubbles: true, + cancelable: true, + clientX: target.clientX ?? 0, + clientY: target.clientY ?? 0, + }) + : null; + target.element?.dispatchEvent(event); + await wait(160); + + const afterState = summarizeWrap(targetWrap); + const capturedText = capturedInput.join(""); + const mouseSequenceSent = capturedText.includes("\x1b[<"); + const arrowInputSent = capturedText.includes("\x1b[A") || capturedText.includes("\x1bOA"); + let diagnosis = "ok"; + if (focusBefore.termBlockId !== targetBlockId && activeBefore.blockId !== targetBlockId) { + diagnosis = "mouse_tracking_focus_mismatch"; + } else if (beforeState?.bufferType !== "alternate" || beforeState?.mouseTrackingMode === "none") { + diagnosis = "mouse_tracking_setup_failed"; + } else if (!mouseSequenceSent) { + diagnosis = arrowInputSent ? "mouse_tracking_sent_arrow_instead_of_mouse" : "mouse_tracking_no_mouse_input"; + } + + return { + kind: "mouse-tracking-wheel", + label, + targetBlockId, + focusBefore, + activeBefore, + target: describeElement(target.element), + before: beforeState, + after: afterState, + capturedInput, + mouseSequenceSent, + arrowInputSent, + defaultPrevented: event?.defaultPrevented ?? false, + diagnosis, + pass: diagnosis === "ok", + }; + } finally { + if (targetWrap) { + targetWrap.sendDataHandler = originals.sendDataHandler; + targetWrap.multiInputCallback = originals.multiInputCallback; + await new Promise((resolve) => targetWrap.terminal.write("\x1b[?1006l\x1b[?1003l\x1b[?1049l", resolve)); + await wait(80); + targetWrap.terminal?.scrollToBottom?.(); + } + } + }; + const runImeScenario = async (targetBlockId, label, blockIdsToCheck) => { + const wraps = blockIdsToCheck.map((blockId) => registry.byBlockId[blockId]).filter(Boolean); + const originals = wraps.map((wrap) => ({ + wrap, + shouldAnchor: wrap.shouldAnchorImeForAgentTui, + })); + try { + await activateTerminal(targetBlockId); + for (const item of originals) { + item.wrap.shouldAnchorImeForAgentTui = () => item.wrap.blockId === targetBlockId; + } + for (const item of originals) { + item.wrap.syncImePositionForAgentTui?.(); + } + await wait(120); + + const terminals = collectTerminals(); + const focus = getFocusState(); + const active = getActiveTerminal(); + const targetTerminal = terminals.find((term) => term.blockId === targetBlockId) ?? null; + const runtimeState = summarizeWrap(registry.byBlockId[targetBlockId]); + const expectedTop = + runtimeState?.cursorY !== null && runtimeState?.cellHeight !== null + ? runtimeState.cursorY * runtimeState.cellHeight + : null; + const expectedLeft = + runtimeState?.cursorX !== null && runtimeState?.cellWidth !== null + ? runtimeState.cursorX * runtimeState.cellWidth + : null; + const actualTop = pxValue(targetTerminal?.textarea?.top); + const actualLeft = pxValue(targetTerminal?.textarea?.left); + const topDelta = actualTop !== null && expectedTop !== null ? Math.abs(actualTop - expectedTop) : null; + const leftDelta = actualLeft !== null && expectedLeft !== null ? Math.abs(actualLeft - expectedLeft) : null; + const aligned = topDelta !== null && leftDelta !== null && topDelta <= 1 && leftDelta <= 1; + const styledBlocks = terminals + .filter((term) => hasVisibleImeOverride(term.textarea) || hasVisibleImeOverride(term.composition)) + .map((term) => term.blockId); + const wrongStyledBlocks = styledBlocks.filter((blockId) => blockId !== targetBlockId); + + let diagnosis = "ok"; + if (focus.termBlockId !== targetBlockId && active.blockId !== targetBlockId) { + diagnosis = "ime_focus_mismatch"; + } else if (!styledBlocks.includes(targetBlockId)) { + diagnosis = "ime_not_anchored"; + } else if (wrongStyledBlocks.length > 0) { + diagnosis = "ime_wrong_terminal"; + } else if (!aligned) { + diagnosis = "ime_cursor_misaligned"; + } + + return { + label, + targetBlockId, + focus, + active, + styledBlocks, + wrongStyledBlocks, + expectedTop, + expectedLeft, + actualTop, + actualLeft, + topDelta, + leftDelta, + aligned, + targetTextarea: targetTerminal?.textarea ?? null, + targetComposition: targetTerminal?.composition ?? null, + diagnosis, + pass: diagnosis === "ok", + terminals: terminals.map((term) => ({ + blockId: term.blockId, + activeElementInside: term.activeElementInside, + textarea: { + top: term.textarea.top, + left: term.textarea.left, + zIndex: term.textarea.zIndex, + }, + composition: { + top: term.composition.top, + left: term.composition.left, + zIndex: term.composition.zIndex, + }, + })), + }; + } finally { + for (const item of originals) { + item.wrap.shouldAnchorImeForAgentTui = item.shouldAnchor; + } + for (const item of originals) { + item.wrap.syncImePositionForAgentTui?.(); + } + await wait(60); + } + }; + + const primaryWrap = window.term; + const initialTerminals = collectTerminals(); + let createdBlockId = null; + let createdORef = null; + const diagnosticCreatedBlockIds = []; + + if (Object.keys(registry.byBlockId).length < 2) { + const sourceBlockId = primaryWrap?.blockId ?? initialTerminals[0]?.blockId ?? null; + summary.createdSplit = { + requested: true, + tabId: getTabId(), + sourceBlockId, + initialDomCount: initialTerminals.length, + }; + if (summary.createdSplit.tabId && sourceBlockId && window.RpcApi && window.TabRpcClient) { + try { + const blockData = getBlockData(sourceBlockId); + const blockMeta = { ...(blockData?.meta || {}) }; + if (!blockMeta.view) { + blockMeta.view = "term"; + } + if (!blockMeta.controller) { + blockMeta.controller = "shell"; + } + createdORef = await window.RpcApi.CreateBlockCommand(window.TabRpcClient, { + tabid: summary.createdSplit.tabId, + blockdef: { + meta: blockMeta, + }, + focused: true, + targetblockid: sourceBlockId, + targetaction: "splitdown", + rtopts: { + termsize: { + rows: 25, + cols: 80, + }, + }, + }); + createdBlockId = parseORefId(createdORef); + summary.createdSplit.createdORef = createdORef; + summary.createdSplit.createdBlockId = createdBlockId; + await waitFor(() => { + const domCount = document.querySelectorAll(".view-term").length; + return domCount >= Math.max(2, initialTerminals.length + 1) && !!registry.byBlockId[createdBlockId]; + }, 12000, 150); + } catch (error) { + summary.createdSplit.error = error?.message ?? String(error); + } + } else { + summary.createdSplit.error = "missing tabId, sourceBlockId, RpcApi or TabRpcClient"; + } + summary.createdSplit.finalKnownBlockIds = Object.keys(registry.byBlockId); + summary.createdSplit.finalDomCount = document.querySelectorAll(".view-term").length; + summary.createdSplit.runtimeKnown = createdBlockId ? !!registry.byBlockId[createdBlockId] : false; + } else { + summary.createdSplit = { + requested: false, + reason: "already_have_multiple_known_terminals", + initialDomCount: initialTerminals.length, + finalDomCount: initialTerminals.length, + finalKnownBlockIds: Object.keys(registry.byBlockId), + }; + } + + const scenarioBlockIds = Array.from(new Set([primaryWrap?.blockId, createdBlockId, ...Object.keys(registry.byBlockId)])) + .filter(Boolean) + .slice(0, 2); + + for (const blockId of scenarioBlockIds) { + await seedScrollback(registry.byBlockId[blockId], `seed-${blockId}`); + } + + const wheelScenarios = []; + const alternateWheelScenarios = []; + const mouseTrackingWheelScenarios = []; + const imeScenarios = []; + if (scenarioBlockIds.length >= 2) { + for (let index = 0; index < scenarioBlockIds.length; index += 1) { + wheelScenarios.push(await runWheelScenario(scenarioBlockIds[index], `term-${index + 1}`)); + } + for (let index = 0; index < scenarioBlockIds.length; index += 1) { + alternateWheelScenarios.push(await runAlternateWheelScenario(scenarioBlockIds[index], `term-${index + 1}`)); + } + for (let index = 0; index < scenarioBlockIds.length; index += 1) { + mouseTrackingWheelScenarios.push(await runMouseTrackingWheelScenario(scenarioBlockIds[index], `term-${index + 1}`)); + } + for (let index = 0; index < scenarioBlockIds.length; index += 1) { + imeScenarios.push(await runImeScenario(scenarioBlockIds[index], `term-${index + 1}`, scenarioBlockIds)); + } + } + + let diagnosticTarget = null; + const visibleKnownBeforeDiagnostic = collectTerminals().filter((term) => term.visible && term.runtimeKnown); + if (visibleKnownBeforeDiagnostic.length < 3) { + let sourceBlockId = primaryWrap?.blockId ?? visibleKnownBeforeDiagnostic[0]?.blockId ?? null; + while (sourceBlockId && collectTerminals().filter((term) => term.visible && term.runtimeKnown).length < 3) { + try { + const created = await createSplitTerminal(sourceBlockId, "splitright"); + if (!created?.blockId) { + break; + } + diagnosticCreatedBlockIds.push(created.blockId); + sourceBlockId = created.blockId; + await wait(240); + } catch (error) { + summary.diagnosticCreateError = error?.message ?? String(error); + break; + } + } + } + diagnosticTarget = selectDiagnosticTarget(collectTerminals()); + const liveWheelScenario = diagnosticTarget?.blockId + ? await runLiveOutputWheelScenario(diagnosticTarget.blockId, "continuous-middle") + : null; + const imeOwnershipLiveScenario = diagnosticTarget?.blockId + ? await runImeOwnershipSnapshotScenario(diagnosticTarget.blockId, "continuous-middle") + : null; + + summary.term = summarizeWrap(primaryWrap); + summary.registry = { + hooked: registry.hooked, + hookError: registry.hookError, + seenCount: registry.seen.length, + knownBlockIds: Object.keys(registry.byBlockId), + }; + summary.knownTerminalCount = Object.keys(registry.byBlockId).length; + summary.focusOwner = getFocusState(); + summary.activeTerminal = getActiveTerminal(); + summary.terminals = collectTerminals(); + summary.scenarioBlockIds = scenarioBlockIds; + summary.diagnostic = { + target: diagnosticTarget, + visibleKnownCount: collectTerminals().filter((term) => term.visible && term.runtimeKnown).length, + createdBlockIds: diagnosticCreatedBlockIds, + liveWheel: liveWheelScenario, + imeOwnershipLive: imeOwnershipLiveScenario, + }; + summary.wheel = { + scenarios: [...wheelScenarios, ...alternateWheelScenarios, ...mouseTrackingWheelScenarios], + normalScenarios: wheelScenarios, + alternateScenarios: alternateWheelScenarios, + mouseTrackingScenarios: mouseTrackingWheelScenarios, + allPassed: + wheelScenarios.length >= 2 && + alternateWheelScenarios.length >= 2 && + mouseTrackingWheelScenarios.length >= 2 && + wheelScenarios.every((scenario) => scenario.pass) && + alternateWheelScenarios.every((scenario) => scenario.pass) && + mouseTrackingWheelScenarios.every((scenario) => scenario.pass), + diagnoses: Array.from( + new Set([...wheelScenarios, ...alternateWheelScenarios, ...mouseTrackingWheelScenarios].map((scenario) => scenario.diagnosis)) + ), + }; + summary.ime = { + scenarios: imeScenarios, + allPassed: imeScenarios.length >= 2 && imeScenarios.every((scenario) => scenario.pass), + diagnoses: Array.from(new Set(imeScenarios.map((scenario) => scenario.diagnosis))), + }; + summary.cleanup = { + createdBlockId: createdBlockId || createdInitialBlockId, + createdBlockIds: Array.from( + new Set([createdBlockId, createdInitialBlockId, ...diagnosticCreatedBlockIds].filter(Boolean)) + ), + needsCleanup: !!createdBlockId || !!createdInitialBlockId || diagnosticCreatedBlockIds.length > 0, + }; + summary.diagnostics = []; + if (summary.terminals.length < 2) { + summary.diagnostics.push("dom_terminal_count_lt_2"); + } + if (summary.knownTerminalCount < 2) { + summary.diagnostics.push("known_terminal_count_lt_2"); + } + if (!summary.wheel.allPassed) { + summary.diagnostics.push("wheel_check_failed"); + } + if (!summary.ime.allPassed) { + summary.diagnostics.push("ime_check_failed"); + } + if (liveWheelScenario && !liveWheelScenario.pass) { + summary.diagnostics.push("live_wheel_check_failed"); + } + if (imeOwnershipLiveScenario && !imeOwnershipLiveScenario.pass) { + summary.diagnostics.push("live_ime_check_failed"); + } + + return summary; +})() diff --git a/scripts/verify.ps1 b/scripts/verify.ps1 new file mode 100644 index 0000000000..db88987cb2 --- /dev/null +++ b/scripts/verify.ps1 @@ -0,0 +1,19 @@ +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$repoRoot = Split-Path -Parent $PSScriptRoot + +Write-Host "[verify] repo root: $repoRoot" + +Push-Location $repoRoot +try { + Write-Host "[verify] running git diff --check" + git diff --check + + Write-Host "[verify] running npm.cmd run build:dev" + npm.cmd run build:dev + + Write-Host "[verify] success" +} finally { + Pop-Location +} diff --git a/version.cjs b/version.cjs index a90caa170f..2da735347b 100644 --- a/version.cjs +++ b/version.cjs @@ -7,12 +7,14 @@ * - `patch`: Bumps the patch version. * - `minor`: Bumps the minor version. * - `major`: Bumps the major version. + * - `date`: Sets the version to `YYYY.M.D-N`, where `N` is an incrementing sequence number for the current date. * - '1', 'true': Bumps the prerelease version. * If two arguments are given, the following are valid inputs for the first argument: * - `none`: No-op. * - `patch`: Bumps the patch version. * - `minor`: Bumps the minor version. * - `major`: Bumps the major version. + * - `date`: Sets the version to today's date with the specified sequence number. * The following are valid inputs for the second argument: * - `0`, 'false': The release is not a prerelease, will remove any prerelease identifier from the version, if one was present. * - '1', 'true': The release is a prerelease (any value other than `0` or `false` will be interpreted as `true`). @@ -25,12 +27,58 @@ const packageJson = require(packageJsonPath); const VERSION = `${packageJson.version}`; module.exports = VERSION; +function getTodayParts() { + const now = new Date(); + return { + year: now.getFullYear(), + month: now.getMonth() + 1, + day: now.getDate(), + }; +} + +function getDateVersionSequence(version, dateParts) { + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(\d+))?$/); + if (match == null) { + return null; + } + const [, year, month, day, sequence] = match; + if ( + Number(year) !== dateParts.year || + Number(month) !== dateParts.month || + Number(day) !== dateParts.day + ) { + return null; + } + return Number(sequence ?? "1"); +} + +function makeDateVersion(dateParts, sequence) { + return `${dateParts.year}.${dateParts.month}.${dateParts.day}-${sequence}`; +} + if (typeof require !== "undefined" && require.main === module) { if (process.argv.length > 2) { const fs = require("fs"); const semver = require("semver"); let action = process.argv[2]; + let newVersion = packageJson.version; + + if (action === "date") { + const dateParts = getTodayParts(); + const explicitSequenceArg = process.argv[3]; + const explicitSequence = + explicitSequenceArg != null && /^\d+$/.test(explicitSequenceArg) + ? Number(explicitSequenceArg) + : null; + const currentSequence = getDateVersionSequence(VERSION, dateParts); + const nextSequence = explicitSequence ?? (currentSequence == null ? 1 : currentSequence + 1); + newVersion = makeDateVersion(dateParts, nextSequence); + packageJson.version = newVersion; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 4) + "\n"); + console.log(newVersion); + process.exit(0); + } // If prerelease argument is not explicitly set, mark it as undefined. const isPrerelease = @@ -45,7 +93,6 @@ if (typeof require !== "undefined" && require.main === module) { action = "patch"; } - let newVersion = packageJson.version; switch (action) { case "major": case "minor":