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..f8e910437d --- /dev/null +++ b/.harness/decisions.md @@ -0,0 +1,63 @@ +# 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 diff --git a/.harness/feature-list.json b/.harness/feature-list.json new file mode 100644 index 0000000000..89dec25291 --- /dev/null +++ b/.harness/feature-list.json @@ -0,0 +1,111 @@ +[ + { + "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->87,IME topDelta/leftDelta 均为 0。" + }, + { + "id": "TASK-TERM-003", + "title": "多终端焦点与真实事件路径 Smoke 补强", + "status": "in_progress", + "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": "来自批准的方向 A。该任务只扩展 smoke 覆盖范围,不改业务逻辑,目标是稳定复现用户最新截图中的多终端输入框错位与滚轮回归。" + }, + { + "id": "TASK-TERM-004", + "title": "将 Wheel / IME 修复收口到 xterm 官方扩展点与焦点归属", + "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": [ + "使用 xterm 官方 wheel hook 处理 normal buffer 滚轮兜底", + "只有 active terminal 可重定位 IME helper", + "多 terminal split-pane 场景中输入框不串位,滚轮不串 terminal", + "vitest、verify、smoke、electron-builder 验证通过" + ], + "notes": "这是批准方向 A 的第二个闭环任务;需在 TASK-TERM-003 给出更真实复现证据后再改业务逻辑。" + } +] 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..71f456c418 --- /dev/null +++ b/.harness/progress.md @@ -0,0 +1,466 @@ +# 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 官方扩展点与焦点归属 +- 这是规划工件更新,不包含新的终端业务代码改动。 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..e2b6b34d1d --- /dev/null +++ b/.harness/task-packets/TASK-TERM-003.md @@ -0,0 +1,85 @@ +# 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` 更贴近用户路径 diff --git a/.harness/task-packets/TASK-TERM-004.md b/.harness/task-packets/TASK-TERM-004.md new file mode 100644 index 0000000000..80a40cd087 --- /dev/null +++ b/.harness/task-packets/TASK-TERM-004.md @@ -0,0 +1,80 @@ +# 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` +- `.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` +- `.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 diff --git a/.harness/unknowns.md b/.harness/unknowns.md new file mode 100644 index 0000000000..bf9ccc34f6 --- /dev/null +++ b/.harness/unknowns.md @@ -0,0 +1,24 @@ +# 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 启动约束 + +## 建议补充信息 + +- 一组可用的飞书测试账号或用户自行登录后的验证反馈 +- 目标发布平台范围:仅 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..433e122dd3 --- /dev/null +++ b/frontend/app/view/term/termutil.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; + +import { + computeResizePreserveScrollback, + DefaultTermScrollback, + getAlternateWheelInputSequence, + getWheelLineDelta, + MaxTermScrollback, + normalizeTermScrollback, + shouldHandleTerminalWheel, +} 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("handles alternate-buffer wheel for full-screen terminal apps", () => { + expect(shouldHandleTerminalWheel(false, "alternate")).toBe(true); + }); + + it("does not handle already-cancelled wheel events", () => { + expect(shouldHandleTerminalWheel(true, "normal")).toBe(false); + }); +}); + +describe("getAlternateWheelInputSequence", () => { + it("maps upward wheel movement to PageUp", () => { + expect(getAlternateWheelInputSequence(-1)).toBe("\x1b[5~"); + }); + + it("maps downward wheel movement to PageDown", () => { + expect(getAlternateWheelInputSequence(1)).toBe("\x1b[6~"); + }); + + it("scales large wheel deltas into multiple page inputs", () => { + expect(getAlternateWheelInputSequence(-12)).toBe("\x1b[5~\x1b[5~"); + }); + + it("ignores invalid wheel deltas", () => { + expect(getAlternateWheelInputSequence(0)).toBe(""); + expect(getAlternateWheelInputSequence(Number.NaN)).toBe(""); + }); +}); diff --git a/frontend/app/view/term/termutil.ts b/frontend/app/view/term/termutil.ts index 2fea30404a..7fff4b2211 100644 --- a/frontend/app/view/term/termutil.ts +++ b/frontend/app/view/term/termutil.ts @@ -10,6 +10,13 @@ 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 +400,75 @@ 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 true; +} + +const AlternateWheelLinesPerPage = 6; + +export function getAlternateWheelInputSequence(lineDelta: number): string { + if (!Number.isFinite(lineDelta) || lineDelta === 0) { + return ""; + } + const pageCount = Math.max(1, Math.floor(Math.abs(lineDelta) / AlternateWheelLinesPerPage)); + const pageSeq = lineDelta < 0 ? "\x1b[5~" : "\x1b[6~"; + return pageSeq.repeat(pageCount); +} + +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..e48199c638 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"; @@ -40,17 +37,30 @@ import { bufferLinesToText, createTempFileFromBlob, extractAllClipboardData, + getWheelLineDelta, normalizeCursorStyle, quoteForPosixShell, + 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 AgentImeCommandRegex = /^(codex|claude|opencode|aider|gemini|qwen)\b/i; +const AgentImeVisibleRegex = /\b(OpenAI Codex|Codex|Claude Code|opencode|gpt-\d|tokens left|esc to interrupt)\b/i; +const ShellPromptTailRegex = /^(?:PS [^\n>]+>|[A-Za-z]:\\[^>\n]*>|(?:\([^)]+\)\s*)?[\w.@-]+(?::[~./\w-]+)?[$#%>])\s*$/; + +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; +} // detect webgl support function detectWebGLSupport(): boolean { @@ -76,13 +86,10 @@ type TermWrapOptions = { export class TermWrap { 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 +103,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 +119,7 @@ export class TermWrap { // xterm.js paste() method triggers onData event, which can cause duplicate sends lastPasteData: string = ""; lastPasteTime: number = 0; + wheelScrollRemainder: number = 0; // dev only (for debugging) recentWrites: { idx: number; data: string; ts: number }[] = []; @@ -133,8 +144,6 @@ 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 = []; @@ -144,11 +153,9 @@ export class TermWrap { 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) => { @@ -313,7 +320,10 @@ export class TermWrap { this.connectElem.removeEventListener("drop", dropHandler); }, }); + this.installNormalBufferWheelScrollback(); this.handleResize(); + this.scheduleDeferredResize(); + this.installImePositionFix(); const pasteHandler = this.pasteHandler.bind(this); this.connectElem.addEventListener("paste", pasteHandler, true); this.toDispose.push({ @@ -327,6 +337,188 @@ export class TermWrap { return this.blockId; } + installNormalBufferWheelScrollback() { + const wheelHandler = (event: WheelEvent) => { + if (event.defaultPrevented || this.terminal.buffer.active.type !== "normal") { + this.wheelScrollRemainder = 0; + return; + } + 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; + } + this.wheelScrollRemainder += lineDelta; + const wholeLines = + this.wheelScrollRemainder > 0 + ? Math.floor(this.wheelScrollRemainder) + : Math.ceil(this.wheelScrollRemainder); + if (wholeLines === 0) { + return; + } + this.wheelScrollRemainder -= wholeLines; + this.terminal.scrollLines(wholeLines); + event.preventDefault(); + event.stopPropagation(); + }; + this.connectElem.addEventListener("wheel", wheelHandler, { passive: false }); + this.toDispose.push({ + dispose: () => { + this.connectElem.removeEventListener("wheel", wheelHandler, false); + }, + }); + } + + scheduleDeferredResize(forceTermSizeSync = false) { + const resize = () => { + if (!this.disposed) { + this.handleResize(forceTermSizeSync); + } + }; + setTimeout(resize, 0); + setTimeout(resize, 50); + setTimeout(resize, 250); + } + + shouldAnchorImeForAgentTui(): boolean { + const shellState = globalStore.get(this.shellIntegrationStatusAtom); + if (shellState === "ready") { + return false; + } + const lastCommand = normalizeAgentCommand(globalStore.get(this.lastCommandAtom)); + if (shellState === "running-command" && AgentImeCommandRegex.test(lastCommand)) { + return true; + } + const activeBuffer = this.terminal.buffer.active; + const tailStart = Math.max(0, activeBuffer.length - Math.max(this.terminal.rows * 2, 80)); + const tailText = bufferLinesToText(activeBuffer, tailStart, activeBuffer.length).join("\n"); + const lastVisibleLine = tailText + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .at(-1); + if (lastVisibleLine != null && ShellPromptTailRegex.test(lastVisibleLine)) { + return false; + } + return AgentImeVisibleRegex.test(tailText); + } + + clearImePositionOverrides() { + if (!this.imePositionPatched) { + return; + } + const textarea = this.terminal.textarea; + const compositionView = this.connectElem.querySelector(".composition-view.active"); + 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.shouldAnchorImeForAgentTui()) { + this.clearImePositionOverrides(); + return; + } + const textarea = this.terminal.textarea; + const compositionView = this.connectElem.querySelector(".composition-view.active"); + 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 activeBuffer = this.terminal.buffer.active; + const cursorRow = Math.max(0, Math.min(this.terminal.rows - 1, activeBuffer.cursorY ?? 0)); + const cursorCol = Math.max(0, Math.min(this.terminal.cols - 1, activeBuffer.cursorX ?? 0)); + const top = `${cursorRow * cellHeight}px`; + const left = `${cursorCol * cellWidth}px`; + const lineHeight = `${Math.max(1, 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, 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() { + 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 sync = () => this.scheduleImePositionSync(); + const clear = () => this.clearImePositionOverrides(); + for (const eventName of ["focus", "compositionstart", "compositionupdate"]) { + textarea.addEventListener(eventName, sync); + } + textarea.addEventListener("blur", clear); + this.toDispose.push({ + dispose: () => { + for (const eventName of ["focus", "compositionstart", "compositionupdate"]) { + textarea.removeEventListener(eventName, sync); + } + textarea.removeEventListener("blur", clear); + this.clearImePositionOverrides(); + }, + }); + this.toDispose.push( + this.terminal.onRender(() => { + const compositionView = this.connectElem.querySelector(".composition-view.active"); + const shouldAnchorIme = this.shouldAnchorImeForAgentTui(); + if (shouldAnchorIme || document.activeElement === textarea || compositionView != null) { + this.scheduleImePositionSync(); + } else { + this.clearImePositionOverrides(); + } + }) + ); + } + setCursorStyle(cursorStyle: string) { this.terminal.options.cursorStyle = normalizeCursorStyle(cursorStyle); } @@ -380,6 +572,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 +586,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 +624,15 @@ 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; + this.clearImePositionOverrides(); this.promptMarkers.forEach((marker) => { try { marker.dispose(); @@ -478,7 +674,7 @@ export class TermWrap { } 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 +684,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 }); @@ -501,52 +708,15 @@ export class TermWrap { resolve = presolve; }); this.terminal.write(data, () => { - if (setPtyOffset != null) { - this.ptyOffset = setPtyOffset; - } else { - this.ptyOffset += data.length; - this.dataBytesProcessed += data.length; - } this.lastUpdated = Date.now(); resolve(); + if (document.activeElement === this.terminal.textarea || this.imePositionPatched) { + this.scheduleImePositionSync(); + } }); 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); - } - } - } - 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); - } - } - async resyncController(reason: string) { dlog("resync controller", this.blockId, reason); const rtOpts: RuntimeOpts = { termsize: { rows: this.terminal.rows, cols: this.terminal.cols } }; @@ -561,7 +731,22 @@ 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(); @@ -573,35 +758,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..f83e5539c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.14.4", + "version": "2026.4.21-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.14.4", + "version": "2026.4.21-1", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ diff --git a/package.json b/package.json index 26098d270e..f44e3ae9b3 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-1", "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.ps1 b/scripts/smoke-terminal.ps1 new file mode 100644 index 0000000000..490db48e7c --- /dev/null +++ b/scripts/smoke-terminal.ps1 @@ -0,0 +1,463 @@ +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 + +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 = @' +(async () => { + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const started = Date.now(); + while (!window.term && Date.now() - started < 15000) { + await wait(250); + } + const summary = { + href: location.href, + title: document.title, + hasTerm: !!window.term, + waitedMs: Date.now() - started + }; + if (!window.term) { + return summary; + } + + const termWrap = window.term; + const terminal = termWrap.terminal; + const activeBuffer = terminal.buffer.active; + const historyMethodsPresent = [ + 'loadInitialTerminalData', + 'processAndCacheData', + 'runProcessIdleTimeout', + 'persistTerminalState' + ].filter((name) => typeof termWrap[name] === 'function'); + + summary.term = { + loaded: termWrap.loaded, + rows: terminal.rows, + cols: terminal.cols, + bufferType: activeBuffer.type, + cursorX: activeBuffer.cursorX, + cursorY: activeBuffer.cursorY, + viewportY: activeBuffer.viewportY, + baseY: activeBuffer.baseY, + length: activeBuffer.length, + historyMethodsPresent, + hasSerializeAddon: Object.prototype.hasOwnProperty.call(termWrap, 'serializeAddon'), + hasPtyOffset: Object.prototype.hasOwnProperty.call(termWrap, 'ptyOffset'), + heldDataLength: Array.isArray(termWrap.heldData) ? termWrap.heldData.length : null + }; + + const output = Array.from({ length: 180 }, (_, idx) => `smoke-scroll-${idx}`).join('\r\n') + '\r\n'; + await new Promise((resolve) => terminal.write(output, resolve)); + terminal.scrollToBottom(); + await wait(80); + + const beforeViewportY = terminal.buffer.active.viewportY; + const scrollTarget = + terminal._core?._viewport?._scrollableElement?._domNode || + document.querySelector('.xterm-scrollable-element') || + document.querySelector('.xterm-screen') || + terminal.element; + const wheelEvent = new WheelEvent('wheel', { + deltaY: -720, + deltaMode: 0, + bubbles: true, + cancelable: true + }); + scrollTarget?.dispatchEvent(wheelEvent); + await wait(120); + const afterViewportY = terminal.buffer.active.viewportY; + summary.wheel = { + targetClass: scrollTarget?.className || null, + beforeViewportY, + afterViewportY, + changed: beforeViewportY !== afterViewportY, + defaultPrevented: wheelEvent.defaultPrevented + }; + + const originalShouldAnchor = termWrap.shouldAnchorImeForAgentTui; + let forcedImeSync = false; + try { + if (typeof termWrap.syncImePositionForAgentTui === 'function') { + termWrap.shouldAnchorImeForAgentTui = () => true; + termWrap.syncImePositionForAgentTui(); + forcedImeSync = true; + await wait(80); + } + } finally { + termWrap.shouldAnchorImeForAgentTui = originalShouldAnchor; + } + + const textarea = terminal.textarea; + const compositionView = document.querySelector('.composition-view.active'); + const cell = terminal._core?._renderService?.dimensions?.css?.cell || {}; + const cellHeight = cell.height || 16; + const cellWidth = cell.width || 8; + const cursorX = terminal.buffer.active.cursorX || 0; + const cursorY = terminal.buffer.active.cursorY || 0; + const expectedTop = cursorY * cellHeight; + const expectedLeft = cursorX * cellWidth; + const actualTop = Number.parseFloat(textarea?.style?.top || 'NaN'); + const actualLeft = Number.parseFloat(textarea?.style?.left || 'NaN'); + const topDelta = Number.isFinite(actualTop) ? Math.abs(actualTop - expectedTop) : null; + const leftDelta = Number.isFinite(actualLeft) ? Math.abs(actualLeft - expectedLeft) : null; + + summary.ime = { + forcedImeSync, + cursorX, + cursorY, + cellHeight, + cellWidth, + expectedTop, + expectedLeft, + textareaTop: textarea?.style?.top || null, + textareaLeft: textarea?.style?.left || null, + compositionTop: compositionView?.style?.top || null, + compositionLeft: compositionView?.style?.left || null, + topDelta, + leftDelta, + aligned: topDelta !== null && leftDelta !== null && topDelta <= 1 && leftDelta <= 1 + }; + + return summary; +})() +'@ + + $runtime = Invoke-CdpEvaluate -WebSocketUrl $target.webSocketDebuggerUrl -Expression $runtimeExpression + $screenshot = Save-CdpScreenshot -WebSocketUrl $target.webSocketDebuggerUrl -Path $screenshotPath + + 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.wheel.changed) { + throw "wheel smoke did not change viewportY" + } + if (!$runtime.ime.aligned) { + throw "IME textarea is not aligned with cursor" + } + } + + $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 = $exePath + cdpPort = $Port + error = $_.Exception.Message + } + $failure | ConvertTo-Json -Depth 20 | 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/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":