Skip to content

fix: 补全会话建立时的终端尺寸同步,修复内嵌终端画面错位 - #8

Closed
LeaFluorine wants to merge 2 commits into
Mai-with-u:mainfrom
LeaFluorine:fix-pty-size-sync
Closed

LeaFluorine wants to merge 2 commits into
Mai-with-u:mainfrom
LeaFluorine:fix-pty-size-sync

Conversation

@LeaFluorine

@LeaFluorine LeaFluorine commented Sep 14, 2026 •

Copy link
Copy Markdown

现象

开启「使用应用内终端」时,终端会出现光标跳到中间某一行、随后把下面的内容覆盖掉的画面错乱,
在服务启动阶段(依赖检查、pip / uv 进度条)尤其明显。

把「使用应用内终端」关掉、让服务跑独立的 cmd 窗口后完全正常 —— 因为外部终端模式走
startExternalTerminal(),用 child_process.spawn 开真正的控制台窗口,完全不经过 node-pty。
这个对照把范围锁定在 node-pty + xterm 这条链上。

根因

174a871038 fix: 稳定终端尺寸同步(2026-05-20)给 terminal.onResize 加了缓存比对后提前返回:

const currentSession = sessionsRef.current.get(sessionId);
if (currentSession?.cols === cols && currentSession.rows === rows) {
  return;
}

这个改动本身是对的,但漏掉了一条关键路径:onResize 只在 xterm 自身尺寸发生变化时才触发,
而 PTY 是主进程按固定尺寸创建的 —— 创建 PTY 并不会改变 xterm 的尺寸。于是:

  1. 主进程按 SERVICE_TERMINAL_COLS = 260 / SERVICE_TERMINAL_ROWS = 36
    (src/main/services/service-manager.ts)创建 PTY;
  2. xterm 此时早已按终端面板尺寸 fit 完毕(常见约 110×30),尺寸不会再变化;
  3. onResize 不触发 → pty.resize 从不发出;
  4. bridge.pty.onSnapshot 的处理函数只记录快照并加载缓冲区,没有任何尺寸回传。

结果 PTY 长期停留在 260×36:子进程按 260 列排版,而 xterm 按约 110 列渲染,一条 200 字符的行会被
折成 2 个视觉行。凡是按行数做相对光标移动的输出(tqdm / uv / rich 等的多行进度条,ESC[nA
上移 + \r 重绘)都会算错行 —— 子进程以为上移 3 行,视觉上是 7 行,于是覆盖了中间的内容。

只有用户手动拉窗口 / 改字号(真的让 xterm 尺寸变了)时 onResize 才触发、尺寸才被纠正。
这也解释了为什么这个 bug 表现为「时好时坏、和窗口操作有关」。

修复

1. 会话建立时把渲染层真实尺寸推给 PTY(TerminalPanel.tsx)

         sessionsRef.current.set(snapshot.id, snapshot);
         upsertUserTerminal(snapshot);
         void loadSessionBuffer(snapshot.id);
+
+        // PTY 由主进程按固定尺寸创建(service-manager 的 SERVICE_TERMINAL_COLS/ROWS)。
+        // 此时 xterm 通常已按面板尺寸 fit 完毕,尺寸不会再变化,terminal.onResize
+        // 也就不会触发,PTY 会长期停留在主进程的默认尺寸上:子进程按错误宽度排版,
+        // 依赖行数的相对光标移动(tqdm/rich 等进度条重绘)会算错行,画面被覆盖。
+        // 快照是在 spawn 之后、子进程尚未输出时发出的,在这里同步一次真实尺寸最干净。
+        const snapshotInstance = terminalsRef.current.get(snapshot.id);
+        if (snapshotInstance?.opened) {
+          const { cols, rows } = snapshotInstance.terminal;
+          if (cols > 0 && rows > 0 && (snapshot.cols !== cols || snapshot.rows !== rows)) {
+            void bridge.pty.resize({ sessionId: snapshot.id, cols, rows }).catch(() => undefined);
+          }
+        }
+
         notifySessionsChanged();
       }),

快照是在 spawn() 之后、子进程尚未产生任何输出时发出的(pty-session-manager.ts 的 emitSnapshot()),
因此这次 resize 是干净的重排,不会触发 reflow 损坏。主进程 PtySession.resize() 之后会回发快照,
届时尺寸已一致、条件不成立,所以不会形成循环。

附带的收益:SERVICE_TERMINAL_COLS 的取值不再影响正确性,不需要改动它。

2. 为 xterm 声明 ConPTY 后端(TerminalPanel.tsx + 导出 platform.ts 的 detectPlatformSync)

主进程是无条件 useConpty: true(src/main/pty/pty-session-manager.ts),但 xterm 没有被告知这一点,
windowsPty 默认为 {},导致 Buffer.resize 在行数增长时走「消耗 scrollback 上移整屏」分支
而不是 Windows 的「底部追加」,与 ConPTY 的语义不一致:

if (windowsMode || windowsPty.backend !== undefined || windowsPty.buildNumber !== undefined) {
  lines.push(new BufferLine(cols, nullCell));   // Windows:底部追加
} else {
  if (ybase > 0 && lines.length <= ybase + y + o2 + 1) { ybase--; ... }  // 非 Windows:上移整屏
}

已按平台条件化设置,macOS / Linux 行为不变。

验证

  • bun run typecheck、bun run build 通过
  • 复现与对照步骤:
    1. 启动 MaiBot 服务,观察启动阶段(依赖检查 / 进度条)是否出现光标跳到中间行并覆盖内容
    2. 拉大 / 缩小窗口、改终端字号、切换会话标签页
    3. 关闭「使用应用内终端」,让服务跑外部 cmd 窗口 → 完全正常(对照组)
  • 环境:Windows 11 Pro(build 26200)+ v0.5.0
  • 如果需要,我可以补一段复现录屏

影响面

  • 只涉及渲染层终端面板与一个平台判断工具的导出;不改 IPC 协议
    (PtyResizeRequest 已有 sessionId / cols / rows),不新增依赖,不改 bun.lock
  • 不改 SERVICE_TERMINAL_COLS / ROWS
  • macOS / Linux 行为不变(windowsPty 仅在 Windows 设置)

后续(可选,未包含在本 PR)

xterm 的 _isReflowEnabled 只有在同时提供 buildNumber 时才会按 ConPTY 版本决定是否允许自身 reflow
(>= 21376 才开启)。若希望 Windows 10(build < 21376,旧 ConPTY 有 rewrap 问题)也走到
「关闭 xterm reflow + 开启 wrapping heuristics」这个组合,需要把系统构建号透出到渲染层
(例如给 DesktopSnapshot 加一个 osRelease)。这超出本 PR 的范围,如需要我可以另开一个。


对应问题记录:Fixes #10

@LeaFluorine

Copy link
Copy Markdown
Author

本地验证结果(首次向本仓库提交,CI 需要维护者批准后才会执行,先附上本地跑的结果):

$ bun install --frozen-lockfile
424 packages installed [299.64s]

$ bun run typecheck
$ tsc --noEmit -p tsconfig.json
(无输出,退出码 0)

$ bun run build
$ tsc --noEmit -p tsconfig.json && electron-vite build
out/main/index.cjs                  597.16 kB
out/preload/index.cjs                12.78 kB
out/renderer/assets/index-*.js    2,044.96 kB
✓ built in 3.94s(退出码 0)

环境:Windows 11 Pro(build 26200)、bun 1.4.2、基点 main @ 3659093。

@LeaFluorine

Copy link
Copy Markdown
Author

已针对上游 0.6.1(2026-10-11 发布)重新验证:分支已 rebase 到 0.6.1,无冲突。

本地验证结果:

$ bun install --frozen-lockfile
Checked 455 installs across 541 packages (no changes) [3.98s]

$ bun run typecheck
$ tsc --noEmit -p tsconfig.json
(无输出,退出码 0)

$ bun run build
out/main/index.cjs                  619.02 kB
out/preload/index.cjs                13.27 kB
out/renderer/assets/index-*.js    1,958.51 kB
✓ built in 4.35s(退出码 0)

环境:Windows 11 Pro(build 26200)、bun 1.4.3、基点 main @ 98c4e3a。

补充复现范围:该问题在 v0.5.0 / v0.5.1 / v0.6.0 / v0.6.1 上均可复现。0.6.1 里这两处相关代码与 0.5.x 逐字一致,说明后续版本都没触及这条路径。

@LeaFluorine

Copy link
Copy Markdown
Author

补两条 review 可能用得上的信息。

一、为什么只写 { backend: "conpty" },不带 buildNumber

从 0.6.1 打包后的渲染层里读出的三处相关判断(xterm 内部):

// 1) Buffer.resize 行增长分支 —— 决定 reflow 行为的就是这一处
windowsMode || windowsPty.backend !== void 0 || windowsPty.buildNumber !== void 0
  ? lines.push(new BufferLine(cols, nullCell))          // Windows:底部追加
  : (ybase > 0 && lines.length <= ybase + y + o2 + 1)
      ? (ybase--, o2++, ...)                            // 非 Windows:上移整屏
      : lines.push(new BufferLine(cols, nullCell));

// 2) _isReflowEnabled
buildNumber ? (hasScrollback && backend === "conpty" && buildNumber >= 21376)
            : (hasScrollback && !windowsMode)           // 本机两条路径都是 true

// 3) _handleWindowsPtyOptionChange
buildNumber ? (backend === "conpty" && buildNumber < 21376)
            : (windowsMode)                             // 本机两条路径都是 false

第 1 条只要 backend 存在就成立;第 2、3 条在提供与不提供 buildNumber 时,本机(build 26200)
结果完全一致。所以 backend 足够,不需要把系统版本号硬编码进来。

这也正好对应正文「后续」一节:只有想让 build < 21376 的老系统走到
「关闭 xterm reflow + 开启 wrapping heuristics」那个组合时,才需要把构建号透出到渲染层。

二、实机验证方式

除了源码上的 typecheck / build,我把这套改动以二进制补丁的形式打进了本机 0.6.1 的
resources/app.asar(打包产物里的 out/renderer/assets/index-*.js 就是同一段代码),用来验证
运行时行为。

校验方式是用官方 asar 库读取修改后的归档:

  • asar.listPackage 条目数与原包一致
  • out/main/index.cjs、out/preload/index.cjs、package.json、out/renderer/index.html
    与原包逐字节相同(只有渲染层那一个文件发生变化)
  • 渲染层里能读回这两处改动

这当然不能替代在源码上的测试,只是说明这段逻辑在真实运行环境里是通的、且改动范围确实只落在
渲染层。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

内嵌终端 PTY 尺寸未同步,导致终端画面错位(光标跳到中间行并覆盖内容)

2 participants