diff --git a/src/ccbot/handlers/card_model.py b/src/ccbot/handlers/card_model.py index 695a2c59..cc5eb74e 100644 --- a/src/ccbot/handlers/card_model.py +++ b/src/ccbot/handlers/card_model.py @@ -817,6 +817,37 @@ def render_page(events: list[Event], now: float) -> str: # ─── Card composition ───────────────────────────────────────────────── +# Box-drawing / block-element glyphs (U+2500–U+259F). Claude Code's +# AskUserQuestion renders each option's ``preview`` inside a box-drawing +# frame (``┌ │ ├ ─ …``); captured verbatim into the kb-mode card those +# borders mangle the body. We strip them on the kb-mode path. +_BOX_DRAWING_RE = re.compile(r"[─-▟]") +_BORDER_ONLY_LINE_RE = re.compile(r"^[\s─-▟]*$") + + +def _sanitize_prompt_block(text: str) -> str: + """Strip terminal box-drawing borders from a captured interactive prompt. + + Drops border-only lines and removes box-drawing glyphs from content + lines (preserving indentation + internal spacing). Collapses 3+ blank + lines that the border removal can leave behind. + """ + out: list[str] = [] + for line in text.splitlines(): + if _BORDER_ONLY_LINE_RE.match(line): + # Keep a single blank as a paragraph break, drop runs. + if out and out[-1] != "": + out.append("") + continue + cleaned = _BOX_DRAWING_RE.sub("", line).rstrip() + out.append(cleaned) + while out and out[0] == "": + out.pop(0) + while out and out[-1] == "": + out.pop() + return "\n".join(out) + + def _render_card( sess: Session, state: CardState, @@ -841,7 +872,13 @@ def _render_card( # keyboard. The regular event log is BELOW the keyboard (footer'd by # the keyboard rather than by switcher/pagination). See Task #41. if state.in_kb_mode and state.kb_prompt: - parts = [header, "─────", "⌨ *Waiting for your input:*", state.kb_prompt] + body = _sanitize_prompt_block(state.kb_prompt) + # Render the prompt as a fenced code block so telegramify treats it + # as literal monospace: no MarkdownV2 escaping noise, and no + # auto-collapse into an expandable blockquote (the "✂ N lines + # hidden" artifact). Guard the rare case of a ``` inside the prompt. + prompt_part = body if "```" in body else f"```\n{body}\n```" + parts = [header, "─────", "⌨ *Waiting for your input:*", prompt_part] return "\n".join(parts) # Budget is in LINES (per user setting ``card_page_lines``). diff --git a/tests/test_kb_prompt_sanitize.py b/tests/test_kb_prompt_sanitize.py new file mode 100644 index 00000000..ab7bc29c --- /dev/null +++ b/tests/test_kb_prompt_sanitize.py @@ -0,0 +1,93 @@ +"""Regression test: kb-mode prompt sanitization. + +Claude Code's AskUserQuestion renders each option's ``preview`` inside +box-drawing frames (``┌ │ ├ ─ …``). Captured verbatim into the kb-mode +card those borders mangled the body, and telegramify auto-collapsed the +long region into an expandable blockquote ("✂ N lines hidden"). The +kb-mode render now strips box-drawing borders and wraps the prompt in a +fenced code block so it renders as literal monospace. +""" + +from __future__ import annotations + +from ccbot.handlers.card_model import ( + CardState, + _render_card, + _sanitize_prompt_block, +) +from ccbot.session_models import Session + +# Reconstruction of the reported pane: option previews inside box frames. +BOXED_PROMPT = ( + "□ Механизм\n" + "Каким механизмом сделать автозапуск ccbot при загрузке телефона?\n" + "\n" + "1. Magisk service.d\n" + " ┌─────────────────────────────────┐\n" + " │ /data/adb/service.d/99-ccbot.sh │\n" + " ├─────────────────────────────────┤\n" + "2. Termux:Boot\n" + "3. Оба слоя\n" + "Enter to select · ↑/↓ to navigate · Esc to cancel\n" +) + + +def _has_box_drawing(text: str) -> bool: + return any(0x2500 <= ord(c) <= 0x259F for c in text) + + +def _sess() -> Session: + return Session(id="s1", name="t", window_id="@1", workdir="/tmp", state="active") + + +class TestSanitizePromptBlock: + def test_strips_all_box_drawing(self): + out = _sanitize_prompt_block(BOXED_PROMPT) + assert not _has_box_drawing(out) + + def test_preserves_content(self): + out = _sanitize_prompt_block(BOXED_PROMPT) + assert "/data/adb/service.d/99-ccbot.sh" in out + assert "Magisk service.d" in out + assert "Termux:Boot" in out + assert "Оба слоя" in out + + def test_keeps_checkbox_header_glyph(self): + # □ (U+25A1) is outside the stripped U+2500–U+259F range → kept. + out = _sanitize_prompt_block(BOXED_PROMPT) + assert "□ Механизм" in out + + def test_border_only_lines_dropped(self): + # The ┌──┐ / ├──┤ frame lines must not survive as content. + out = _sanitize_prompt_block(BOXED_PROMPT) + for line in out.splitlines(): + assert "┌" not in line and "┐" not in line and "├" not in line + + def test_empty_input(self): + assert _sanitize_prompt_block("") == "" + + +class TestKbModeRender: + def test_prompt_wrapped_in_code_fence(self): + st = CardState() + st.in_kb_mode = True + st.kb_prompt = BOXED_PROMPT + out = _render_card(_sess(), st, user_id=1) + assert "⌨" in out # waiting-for-input header present + assert "```" in out # prompt rendered as a fenced code block + + def test_no_frame_chars_in_rendered_body(self): + st = CardState() + st.in_kb_mode = True + st.kb_prompt = BOXED_PROMPT + out = _render_card(_sess(), st, user_id=1) + # Frame glyphs (only from kb_prompt) are gone. The card's own + # "─────" separator (U+2500) is allowed and not asserted against. + assert "┌" not in out and "│" not in out and "├" not in out + + def test_content_survives_render(self): + st = CardState() + st.in_kb_mode = True + st.kb_prompt = BOXED_PROMPT + out = _render_card(_sess(), st, user_id=1) + assert "/data/adb/service.d/99-ccbot.sh" in out