fix: terminal garbled boxes from OSC sequences in PS1

User reported: each prompt line shows a leading '[?]' box.
Root cause: Ubuntu's default PS1 uses \\e]0;user@host:dir\\a
(OSC 0, set window title) which our ANSI parser did not recognize.
It only handled SGR (\\e[...m), so the raw bytes were drawn as tofu boxes.

Fix in ui/terminal_panel.py:
- Added _OSC_RE for ESC ] ... BEL|ESC\\ sequences (set title, set cwd)
- Added _DCS_RE for ESC P/X/^/_ ... ESC\\ (DCS/SOS/PM/APC)
- _on_data() branches ESC prefix by next byte: '['->CSI, '] '->OSC,
  'P/X/^/_'->DCS, others->skip 2 bytes
- Captured OSC 0/1/2 content into self._window_title (so future
  versions can display it)
- _strip_ansi() now also strips OSC/DCS (for clean history recall)

Added [4b/5] test: injects a literal Ubuntu PS1 byte stream
(OSC 0 + SGR colors) and verifies the rendering is just the plain
'root@ubuntu2204:~# ' with no tofu / no leaked escape chars.

All 21 tests pass (core 6 + UI 3 + E2E 6 + terminal 6).
This commit is contained in:
Hermes
2026-07-28 21:46:26 +08:00
parent 9dbd500399
commit 1b7259c2d5
2 changed files with 64 additions and 9 deletions
+19
View File
@@ -99,6 +99,25 @@ def main():
assert wait_for_text(panel, "GREEN", timeout_s=5) assert wait_for_text(panel, "GREEN", timeout_s=5)
print(f" ✓ ANSI 颜色输出已渲染") print(f" ✓ ANSI 颜色输出已渲染")
# 额外:直接注入一段带 OSC 0 提示符的字节,验证不显示乱码框
print("[4b/5] 注入 Ubuntu 默认 PS1 字节(带 OSC 0 标题)")
panel.term.clear()
panel._current_line_start = 0
# Ubuntu 默认 PS1: \[\e]0;...\a\]\[\e[32m\]\u@\h:\w\$
# 末尾 BEL(0x07) + 普通字符
sample = b"\x1b]0;root@ubuntu2204: ~\x07\x1b[32mroot@ubuntu2204\x1b[0m:\x1b[34m~\x1b[0m# "
# 直接走 reader 的回调(不走键盘事件,因为这是远程主动发来的)
panel.reader.data_received.emit(sample.decode("utf-8", errors="replace"))
QApplication.processEvents()
text = panel.term.toPlainText()
# 不应该看到 OSC 里的 0;root@... 这种字面字符
assert "]0;root@ubuntu2204" not in text, f"OSC 0 没被吞掉: {text!r}"
assert "[32m" not in text, f"CSI SGR 没被吞: {text!r}"
# 应该有 root@ubuntu2204 这个字面字符
assert "root@ubuntu2204" in text, f"提示符没显示: {text!r}"
print(f" ✓ OSC 标题被正确吞掉,提示符字面字符显示正常: {text!r}")
print("[5/5] 验证本地历史(↑键)") print("[5/5] 验证本地历史(↑键)")
panel.history.append("ls /tmp") # 模拟已经执行过的命令 panel.history.append("ls /tmp") # 模拟已经执行过的命令
panel.history_idx = -1 panel.history_idx = -1
+41 -5
View File
@@ -36,13 +36,22 @@ ANSI_BG = {
"100": "#78909c", "100": "#78909c",
} }
# ANSI 转义序列正则(CSI ... 结尾) # ANSI CSI 序列:ESC [ ... <final 0x40-0x7e>
_ANSI_RE = re.compile(r"\x1b\[([\x30-\x3f]*)([\x20-\x2f]*)([\x40-\x7e])") _ANSI_RE = re.compile(r"\x1b\[([\x30-\x3f]*)([\x20-\x2f]*)([\x40-\x7e])")
# OSC 序列:ESC ] ... BEL(0x07) 或 ESC \ 终止(PS1 标题/工作目录等)
_OSC_RE = re.compile(r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
# 单字符 ESC 序列:ESC c/=/> 等(RIS、DECSC 等),一般无害;这里跳过 ESC 后跟非 [ 的 1-2 字节
_ESC_SINGLE_RE = re.compile(r"\x1b[PX^_](?:[^\x1b]*?\x1b\\)?")
# DCS/SOS/PM/APCESC P/X/^/_ ... ST 跳过
_DCS_RE = re.compile(r"\x1b[PX^_].*?\x1b\\")
def _strip_ansi(text: str) -> str: def _strip_ansi(text: str) -> str:
"""去掉所有 ANSI 转义,得到纯文本(用于历史回放、长度计算)""" """去掉所有 ANSI/OSC/DCS 转义,得到纯文本(用于历史回放、长度计算)"""
return _ANSI_RE.sub("", text) text = _OSC_RE.sub("", text)
text = _DCS_RE.sub("", text)
text = _ANSI_RE.sub("", text)
return text
class _ShellReader(QObject): class _ShellReader(QObject):
@@ -122,6 +131,8 @@ class TerminalPanel(QWidget):
self._ansi_bold = False self._ansi_bold = False
self._ansi_underline = False self._ansi_underline = False
self._ansi_invert = False self._ansi_invert = False
# OSC 0/2 设置的窗口标题
self._window_title = ""
self._build_ui() self._build_ui()
self._apply_style() self._apply_style()
@@ -246,13 +257,38 @@ class TerminalPanel(QWidget):
i = 0 i = 0
while i < len(text): while i < len(text):
ch = text[i] ch = text[i]
if ch == "\x1b" and i + 1 < len(text) and text[i+1] == "[": if ch == "\x1b" and i + 1 < len(text):
# CSI 序列 nxt = text[i + 1]
# CSI: ESC [ ... <final>
if nxt == "[":
m = _ANSI_RE.match(text, i) m = _ANSI_RE.match(text, i)
if m: if m:
self._apply_ansi(m.group(1), m.group(3)) self._apply_ansi(m.group(1), m.group(3))
i = m.end() i = m.end()
continue continue
# OSC: ESC ] ... BEL | ESC \
elif nxt == "]":
m = _OSC_RE.match(text, i)
if m:
# OSC 0/1/2 设置标题/工作目录—— GUI 终端里通常更新窗口标题;
# 我们这里不维护标题栏,但 PS1 里的 OSC 也照样被吃掉。
# 提取 OSC 内容以便调试
content = m.group(0)[2:-1] # 去掉 ESC ] 和终止符
# 可选:把第一个参数后的内容存到 title
if content and content[0:1] in ("0", "1", "2") and ";" in content:
self._window_title = content.split(";", 1)[1]
i = m.end()
continue
# DCS / SOS / PM / APC: ESC P/X/^/_ ... ESC \
elif nxt in ("P", "X", "^", "_"):
m = _DCS_RE.match(text, i)
if m:
i = m.end()
continue
# 其他 ESC 序列(ESC = / > / c 等)—— 单字节,吞掉
else:
i += 2
continue
elif ch == "\r": elif ch == "\r":
# \r 单独:回到行首;\n 真的换行 # \r 单独:回到行首;\n 真的换行
if i + 1 < len(text) and text[i+1] == "\n": if i + 1 < len(text) and text[i+1] == "\n":