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:
@@ -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
@@ -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/APC:ESC 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":
|
||||||
|
|||||||
Reference in New Issue
Block a user