From f88f72485ab089a119ccbb9292a2b3c8ac6484be Mon Sep 17 00:00:00 2001 From: Time4Mind <119820237+Time4Mind@users.noreply.github.com> Date: Sat, 23 May 2026 15:15:00 +0300 Subject: [PATCH] fix(kb-mode): only sanitize prompts that contain box-drawing FRAMES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #101. That fix sanitized + code-fenced EVERY kb-mode prompt, which also changed the rendering of normal, well-behaved prompts (→ monospace). The garble only ever occurred for AskUserQuestions whose option previews were drawn inside box-drawing frames; a normal prompt (even one with a benign ── divider) renders fine as-is. Gate the sanitize/code-fence path on _BOX_FRAME_RE — vertical/corner/junction box glyphs that indicate a FRAME, excluding the plain horizontals ─ ━. When no frame is present the prompt is rendered verbatim (byte-identical to pre-#101 behaviour), so the fix can't degrade the working case. Adds TestNormalPromptUnchanged (no-frame → no code fence, verbatim). 550 tests pass · ruff clean · pyright 0. Co-Authored-By: Claude Opus 4.7 --- src/ccbot/handlers/card_model.py | 27 +++++++++++++++++----- tests/test_kb_prompt_sanitize.py | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/ccbot/handlers/card_model.py b/src/ccbot/handlers/card_model.py index cc5eb74e..ecaf29b0 100644 --- a/src/ccbot/handlers/card_model.py +++ b/src/ccbot/handlers/card_model.py @@ -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: @@ -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) diff --git a/tests/test_kb_prompt_sanitize.py b/tests/test_kb_prompt_sanitize.py index ab7bc29c..cf6ddff0 100644 --- a/tests/test_kb_prompt_sanitize.py +++ b/tests/test_kb_prompt_sanitize.py @@ -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