Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion src/ccbot/handlers/card_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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``).
Expand Down
93 changes: 93 additions & 0 deletions tests/test_kb_prompt_sanitize.py
Original file line number Diff line number Diff line change
@@ -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
Loading