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
27 changes: 21 additions & 6 deletions src/ccbot/handlers/card_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,13 @@ def render_page(events: list[Event], now: float) -> str:
# 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─-▟]*$")
# Box-drawing FRAME glyphs (verticals + corners + junctions + double-line),
# EXCLUDING the plain horizontals ─ ━ which show up as benign dividers in
# otherwise-normal prompts. Their presence is the signal that Claude Code
# framed the option previews in boxes (the case that mangles the card). A
# normal prompt — even one carrying a ── divider — matches none of these, so
# the sanitize/code-fence path stays a strict no-op for the well-behaved case.
_BOX_FRAME_RE = re.compile(r"[│┃┌-╋═-╬]")


def _sanitize_prompt_block(text: str) -> str:
Expand Down Expand Up @@ -872,12 +879,20 @@ 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:
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```"
raw = state.kb_prompt
if _BOX_FRAME_RE.search(raw):
# Claude Code framed the option previews in box-drawing boxes
# (┌ │ ├ …). Captured verbatim those borders mangle the body and
# telegramify collapses the long region into an expandable
# blockquote (the "✂ N lines hidden" artifact). Strip the borders
# and render as a fenced code block — literal monospace, no
# MarkdownV2 escaping, no blockquote collapse. Guard a stray ```.
body = _sanitize_prompt_block(raw)
prompt_part = body if "```" in body else f"```\n{body}\n```"
else:
# No box frame → the long-standing well-behaved prompt. Render it
# exactly as before so this fix never alters a normal prompt.
prompt_part = raw
parts = [header, "─────", "⌨ *Waiting for your input:*", prompt_part]
return "\n".join(parts)

Expand Down
39 changes: 39 additions & 0 deletions tests/test_kb_prompt_sanitize.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,42 @@ def test_content_survives_render(self):
st.kb_prompt = BOXED_PROMPT
out = _render_card(_sess(), st, user_id=1)
assert "/data/adb/service.d/99-ccbot.sh" in out


# A normal AskUserQuestion with NO box frame — the long-standing working
# case (incl. a benign ── divider). The fix must be a strict no-op here.
NORMAL_PROMPT = (
"☐ Which approach?\n"
"Pick a migration strategy:\n"
"❯ 1. Incremental\n"
" 2. Big-bang\n"
"─────\n"
" 3. Chat about this\n"
"Enter to select · ↑/↓ to navigate · Esc to cancel\n"
)


class TestNormalPromptUnchanged:
"""The box-frame gate: a prompt without frame glyphs renders exactly as
before — no sanitization, no code fence. Guards the working case."""

def test_no_frame_means_no_code_fence(self):
st = CardState()
st.in_kb_mode = True
st.kb_prompt = NORMAL_PROMPT
out = _render_card(_sess(), st, user_id=1)
# The card chrome (header + the literal "─────" separator) is added
# by _render_card; the prompt body itself must NOT be code-fenced.
body = out.split("⌨ *Waiting for your input:*\n", 1)[1]
assert "```" not in body
# ...and the prompt is carried verbatim (incl. its own divider).
assert NORMAL_PROMPT in out

def test_divider_only_does_not_trip_the_gate(self):
# A ── divider (U+2500) alone is not a frame → no sanitization.
assert "❯ 1. Incremental" in NORMAL_PROMPT # premise
st = CardState()
st.in_kb_mode = True
st.kb_prompt = NORMAL_PROMPT
out = _render_card(_sess(), st, user_id=1)
assert "❯ 1. Incremental" in out # cursor/options untouched
Loading