diff --git a/test_terminal.py b/test_terminal.py index 0282739..7315c4f 100644 --- a/test_terminal.py +++ b/test_terminal.py @@ -99,6 +99,25 @@ def main(): assert wait_for_text(panel, "GREEN", timeout_s=5) 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] 验证本地历史(↑键)") panel.history.append("ls /tmp") # 模拟已经执行过的命令 panel.history_idx = -1 diff --git a/ui/terminal_panel.py b/ui/terminal_panel.py index 7ea1931..5dd0d42 100644 --- a/ui/terminal_panel.py +++ b/ui/terminal_panel.py @@ -36,13 +36,22 @@ ANSI_BG = { "100": "#78909c", } -# ANSI 转义序列正则(CSI ... 结尾) +# ANSI CSI 序列:ESC [ ... _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: - """去掉所有 ANSI 转义,得到纯文本(用于历史回放、长度计算)""" - return _ANSI_RE.sub("", text) + """去掉所有 ANSI/OSC/DCS 转义,得到纯文本(用于历史回放、长度计算)""" + text = _OSC_RE.sub("", text) + text = _DCS_RE.sub("", text) + text = _ANSI_RE.sub("", text) + return text class _ShellReader(QObject): @@ -122,6 +131,8 @@ class TerminalPanel(QWidget): self._ansi_bold = False self._ansi_underline = False self._ansi_invert = False + # OSC 0/2 设置的窗口标题 + self._window_title = "" self._build_ui() self._apply_style() @@ -246,12 +257,37 @@ class TerminalPanel(QWidget): i = 0 while i < len(text): ch = text[i] - if ch == "\x1b" and i + 1 < len(text) and text[i+1] == "[": - # CSI 序列 - m = _ANSI_RE.match(text, i) - if m: - self._apply_ansi(m.group(1), m.group(3)) - i = m.end() + if ch == "\x1b" and i + 1 < len(text): + nxt = text[i + 1] + # CSI: ESC [ ... + if nxt == "[": + m = _ANSI_RE.match(text, i) + if m: + self._apply_ansi(m.group(1), m.group(3)) + i = m.end() + 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": # \r 单独:回到行首;\n 真的换行