pi-agent输入框bug

输入法是fcitx5, 在pi coding agent使用的时候输入框出现如图固定的偏移。
检索了网络,没找到方法,问了ai,也没解决。
不知道问题出在哪里,一头雾水。

解决问题后重述:
详细请看:pi-agent输入框bug - #10,来自 lulala
解决方法:终端输入pi --tui-mode fullscreen,或,在pi中 /settings → TUI mode → fullscreen

可以试试其他终端看看,例如 ghostty 之类的是否能复现,排除一下是否是 konsole 的问题

已确认,kitty输入正常,ghostty输入不正常(如图:输入框固定在最左端)。
可能确实是konsole的问题,但仍然不知道问题是什么 :distorted_face:

那你可以试试运行 vim,是否也有这个问题?如果是的话,感觉应该可以通过配置 Fcitx5 来解决,要不然应该早有人报 bug 了,不过我不用 KDE 和 Fcitx5 就不能帮你测了

好的,谢谢帮忙排查问题。
vim 输入中文无问题。

建议你附上一份 fcitx5-diagnose 命令的输出

好的,谢谢指点,现在感觉有点方向了,我再试着让ai排除下bug。

嗯嗯,可以看看是否和 Pi 的硬件光标有关系 PI_HARDWARE_CURSOR=1 pi

神医啊,找到问题了,修好了,多谢指点。
待我明天我总结一下,把原因和解决方式贴出来。

本文由AI排查bug并提出方案,经过本人检阅修改,不保证信息完全准确,但保证解决方法完全可用。

判断是不是该bug

首先终端输入PI_HARDWARE_CURSOR=1 pi 切换光标可见性,再输入文字,看光标在哪里。
终端输入pi --tui-mode fullscreen,如果fullscreen 正常 / regular 异常 ⇒ 问题在 regular 渲染路径;与插件、与光标可见性无关。


根本原因

简单来说:

  1. pi 每次重画输入框,都是先写一整行。为了让行尾整齐,它会用空格把这行补满到屏幕最右边——所以刚写完时,"笔尖"在输入框最右边。
  2. 补完空格之后,pi 才把笔尖挪回到你正在打字的位置(就是那个闪烁的方块)。这一步是写在"这一行画完了"之后才做的。
  3. 而终端有个规矩:一看到"这一行画完了"的信号,就赶紧拍一张照片记录笔尖现在在哪,并把这张照片交给输入法(输入法要靠它决定候选框摆哪儿)。
  4. 问题就在这:拍照的时候,那一步"挪回打字位置"还没执行。照片上记的是"最右边"。
  5. 之后 pi 确实把笔尖挪回去了,但它只挪了笔尖,没有告诉终端"位置变了"。终端也不会再拍第二张照片。
  6. 于是输入法一直照着那张旧照片,把候选框摆在最右边。屏幕上那个真光标也一样——因为终端画面里画的光标,用的也是这张旧照片里的位置。

一句话:程序先摆好东西、再挪光标;终端却在"摆好东西"那一刻就把光标位置报给了输入法,之后不再更新。

顺带解释为什么全屏模式没事:全屏那套代码是先把光标挪好、再发"这一行画完了",所以照片拍到的是正确位置。


如何修改

方案 A:使用全屏模式(推荐)

临时使用使用全屏模式 pi --tui-mode fullscreen
或用 /settings → TUI mode → fullscreen,永久修改

方案 B:修改源码,pi更新时会覆盖(不推荐)

把「计算光标字节」与「写出」拆开,让调用点能把字节 append 到 ?2026l 之前。

// 新增:只计算字节 + 维护 hardwareCursorRow,不直接 write
cursorSequences(cursorPos: { row: number; col: number } | null, totalLines: number) {
  const hidden = "\x1B[?25l";                      // 无可定位行时维持旧行为:隐藏
  if (!cursorPos || totalLines <= 0) return { moves: "", visibility: hidden };
  const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
  const targetCol = Math.max(0, cursorPos.col);
  const rowDelta = targetRow - this.hardwareCursorRow;
  let moves = "";
  if (rowDelta > 0) moves += `\x1B[${rowDelta}B`;
  else if (rowDelta < 0) moves += `\x1B[${-rowDelta}A`;
  moves += `\x1B[${targetCol + 1}G`;
  this.hardwareCursorRow = targetRow;
  return { moves, visibility: this.getShowHardwareCursor() ? "\x1B[?25h" : hidden };
}

// 保留:仅用于「没有同步块可并入」的分支(firstChanged === -1)
positionHardwareCursor(cursorPos: any, totalLines: number) {
  const { moves, visibility } = this.cursorSequences(cursorPos, totalLines);
  if (moves) this.terminal.write(moves);
  this.terminal.write(visibility);
}

调用点改法(增量路径示例,fullRender 与删行路径同理):

-    output.append("\x1B[?2026l");
-    output.flush();
     this.cursorRow = Math.max(0, newLines.length - 1);
     this.hardwareCursorRow = finalCursorRow;
     this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
     this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
-    this.positionHardwareCursor(cursorPos, newLines.length);
+    // 光标定位必须留在同步块内:终端会在 ?2026l 处 flush 并把锚点交给 IME
+    const cur = this.cursorSequences(cursorPos, newLines.length);
+    output.append(cur.moves);
+    output.append(cur.visibility);
+    output.append("\x1B[?2026l");
+    output.flush();

注意事项:

  • 顺序:先设 hardwareCursorRow = finalCursorRow(帧写完后光标实际所在行)再算 rowDelta,否则下一帧的相对位移会错。
  • 只改 3 个「先 ?2026l 后定位」的调用点;firstChanged === -1 分支不写帧,保持 positionHardwareCursor 即可。
  • ?25h/?25l 一起进同步块是安全的(TuiAltScreen 已如此),还能顺带修掉上游在 Windows Terminal 上报告的「中间帧闪烁」。
  • 行为面:除 cursorPos 为空外与旧实现等价,只改变字节顺序;CURSOR_MARKER、cursorPos 计算、编辑器渲染都不动。

相关 issue / PR

pi 上游(直接相关)

编号 标题 状态 相关性
:white_check_mark: earendil-works/pi#5200 IME candidate window stuck at right edge in WezTerm (TUI) closed(维护者判为 docs 不一致,指派 badlogic) 症状完全相同。其 follow-up 评论即本 bug 的根因:regular 渲染器在定位/显示光标之前就结束了 ?2026h/?2026l;把定位移入同步块可修好(并在 Windows Terminal 上表现为闪烁)。WezTerm 那半边的根因不同(终端要求硬件光标可见)
:magnifying_glass_tilted_right: earendil-works/pi-mono#5200 同上(旧仓库镜像) closed 同 issue 的另一份记录
:magnifying_glass_tilted_right: earendil-works/pi#5102 IME candidate window misplaced when slash-command autocomplete is active — 同族:斜杠补全激活时 marker 未发出 → 锚点错位
:magnifying_glass_tilted_right: earendil-works/pi#5283 fix(tui): keep hardware cursor marker during slash-command autocomplete PR 上一条的修复:去掉 !autocompleteState 守卫,让 CURSOR_MARKER 始终发出
:magnifying_glass_tilted_right: earendil-works/pi#525 sync real cursor to fake cursor position in editor/input PR 硬件光标定位的早期方案(提到 #491:DSR 查询纠正假光标)
:magnifying_glass_tilted_right: badlogic/pi-mono#719 feat(tui): added hardware cursor positioning PR 最终采用的 CURSOR_MARKER + Focusable 方案(本 bug 所在的机制)
:magnifying_glass_tilted_right: badlogic/pi-mono#800 Add hardwareCursor setting PR showHardwareCursor / PI_HARDWARE_CURSOR / /settings 开关的来源

终端侧(Konsole)

编号 标题 说明
:magnifying_glass_tilted_right: KDE bug 420799 Konsole: input method window are not shown under cursor RESOLVED FIXED;Konsole 的 IME 定位历史
:magnifying_glass_tilted_right: KDE bug 517032 [konsole] Fcitx5 may work unexpectedly in Konsole fcitx5 + Konsole 候选框/预编辑异常
:magnifying_glass_tilted_right: KDE bug 506095 Input Method Occasionally Breaks in X11 Applications in Wayland Session Wayland 会话下输入法偶发失效

源码要点Konsole master:

  • src/Vt102Emulation.cpp:?2026h/l → programRequestedSynchronizedUpdate;CSI pn G(CHA) → Screen::setCursorX(p)
  • src/Emulation.cpp:receiveData() 开头 bufferedUpdate();synchronizedUpdateChanged() → showBulk();showBulk() 停定时器并 Q_EMIT outputChanged()
  • src/terminalDisplay/TerminalDisplay.cpp:outputChanged → QGuiApplication::inputMethod()->update(Qt::ImCursorRectangle);inputMethodQuery(ImCursorRectangle) = 终端光标格(与光标可见性无关)
  • src/Screen.cpp:setCursorX()(1-based,正确);getImage() 把 cursor 标记烧进图像缓存
  • src/ScreenWindow.cpp:cursorPosition();notifyOutputChanged()

输入法侧(fcitx5)

编号 标题
:magnifying_glass_tilted_right: fcitx/fcitx5#1609 fcitx5 never sees the caret pixel position from any Wayland app(cursorRect=(0,0,0,0))
:magnifying_glass_tilted_right: fcitx/fcitx5#1568 候选框不跟随文本 / 过低的输入框无法显示
:magnifying_glass_tilted_right: fcitx/fcitx5#1313 Candidate window position mismatch(在 A 处输入,候选框出现在 B)
:magnifying_glass_tilted_right: fcitx/fcitx5#1135 竖排候选列表时候选框位置不对

同类问题(其它 TUI / 终端,作为该 bug 类别的图谱)

编号 标题 说明
:magnifying_glass_tilted_right: zed-industries/zed#46592 terminal: Fix IME position when cursor is hidden 同构根因:cursor shape == Hidden 时跳过光标位置计算
:magnifying_glass_tilted_right: zellij-org/zellij#4995 fix: keep IME anchor aligned when terminal cursor is hidden 同类:TUI 在渲染周期里隐藏光标导致锚点漂移
:magnifying_glass_tilted_right: anthropics/claude-code#50650 Chinese IME candidate window position incorrect inside Zellij 同类现象(TUI + 隐藏光标 + IME)

归纳成一句可复用的规则:任何「会被终端当作逻辑光标消费的状态」(IME 锚点、光标位置上报、光标绘制)都必须在同步输出块内部写入。

详细BUG原因

字节级时序

pi 每帧(tui-main-screen.ts):
  write#1:  ESC[?2026h  …rows…  ESC[?2026l        ← 写完满宽 padding 后光标在右边缘
  write#2:  ESC[2A ESC[5G ESC[?25h                 ← positionHardwareCursor(),在 ?2026l 之后

终端:      ?2026l 边界 → flush + outputChanged → inputMethod()->update(ImCursorRectangle)
            → 锚点 = 右边缘;此后不再更新(write#2 不触发任何重画/通知)

对应到 tui-main-screen.ts 的三处「先 ?2026l、后定位」的调用点:

  • 增量渲染结尾(主路径)
  • fullRender()(全量重画)
  • firstChanged >= newLines.length 的删行路径

tui-alt-screen.ts 恰好相反:它把 ESC[r;cH + ?25h/?25l 追加进 buffer 之后才 buffer += END_SYNCHRONIZED_OUTPUT —— 这就是 fullscreen 正常的全部原因。

为什么 regular 是两次 write():BoundedTerminalWriter.flush() 写出帧(含 ?2026l),随后 positionHardwareCursor() 才单独 terminal.write()。
两次 write 相隔微秒级,Konsole 通常在一次 read() 里读走;若被拆成两次 read,Konsole 的 10ms 合并定时器会「兜住」并纠正 —— 所以这个 bug 具有时序敏感性(也解释了为什么不同终端/机器表现不一致)。§6 的复现程序把这个变量显式固定下来。

为什么连「真光标」也卡在右边缘

  • IME 锚点:inputMethodQuery(ImCursorRectangle) 虽然实时取 cursorPosition(),但只在 outputChanged 时才被查询,而最后一次 outputChanged 发生在 ?2026l(此时 _cuX 还在右边缘)。
  • 可见光标:Screen::getImage() 会把 rendition.f.cursor 烧进图像缓存,TerminalDisplay 重画用的是这份 _image;updateImage() 只由 outputChanged/resize 触发,而 Screen::setCursorX() 不发信号。⇒ 光标块也停在 ?2026l 时的位置,直到下一次输出。
  • 下一帧的 ?2026h 会短暂把两者纠正到「上一帧的光标处」,但该帧自己的 ?2026l 又把它钉回右边缘 ⇒ 稳态 = 右边缘。