From 72dfa55f5219383dbfb7555f31536553786c67dc Mon Sep 17 00:00:00 2001 From: Time4Mind <119820237+Time4Mind@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:31:50 +0300 Subject: [PATCH] =?UTF-8?q?fix(card):=20show=20=E2=94=80=E2=94=80=E2=94=80?= =?UTF-8?q?=E2=94=80=E2=94=80=20dividers=20between=20options=20in=20kb-mod?= =?UTF-8?q?e=20box-frame=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #134 archive-style option dividers only ran in _render_card's frameless branch. But real AskUserQuestion panes frame each option's preview in box-drawing glyphs (│ ┌ ├), so _BOX_FRAME_RE always matched and rendering took the code-fence branch — which emitted the sanitized body as one monospace block with NO dividers. The feature never fired for the panes users actually see (the frameless tests passed because they call _format_kb_prompt directly or feed frameless prompts). Splice literal ───── rule lines between numbered options inside the fenced path too (new _rule_between_options). Inside the fence they render as plain monospace dividers — same visible separation without dropping the fence, which still prevents telegramify's blockquote-collapse ("✂ N lines hidden") and MarkdownV2 escaping on the long boxed region. Each option keeps its trailing preview lines; existing source rules are absorbed so separators never double up. Updates test_box_frame_path_unaffected -> test_box_frame_path_no_hard_breaks (hard-break trick still excluded; dividers now expected) and adds test_options_separated_by_divider. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ccbot/handlers/card_model.py | 32 ++++++++++++++++++++++++++++++++ tests/test_kb_prompt_sanitize.py | 28 ++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/ccbot/handlers/card_model.py b/src/ccbot/handlers/card_model.py index d2fe45ff..a042a761 100644 --- a/src/ccbot/handlers/card_model.py +++ b/src/ccbot/handlers/card_model.py @@ -1058,6 +1058,34 @@ def _flush() -> None: return sep.join(_KB_HARD_BREAK_JOIN.join(p) for p in refined) +def _rule_between_options(body: str) -> str: + """Splice a ``─────`` rule before each numbered option (after the first). + + Used by the box-frame branch of ``_render_card``. That branch renders + the de-framed prompt inside a code fence, which suppresses MarkdownV2 — + so options can't be separated by markup the way the frameless path does. + Instead we splice literal ``─────`` rule lines between options; inside + the fence they render as plain monospace dividers, giving the same + archive-style separation without re-introducing the blockquote-collapse + the fence exists to prevent. + + Each option keeps its trailing preview/description lines (they ride with + the option until the next numbered row). Pre-existing source rule lines + are dropped so separators never double up. + """ + out: list[str] = [] + seen_option = False + for line in body.splitlines(): + if _RULE_LINE_RE.match(line.strip()): + continue # absorbed by the generated rules + if _NUMBERED_OPTION_RE.match(line): + if seen_option: + out.append("─────") + seen_option = True + out.append(line) + return "\n".join(out) + + def _sanitize_prompt_block(text: str) -> str: """Strip terminal box-drawing borders from a captured interactive prompt. @@ -1114,6 +1142,10 @@ def _render_card( # and render as a fenced code block — literal monospace, no # MarkdownV2 escaping, no blockquote collapse. Guard a stray ```. body = _sanitize_prompt_block(raw) + # Splice ───── rules between numbered options so they're visibly + # separated inside the fence (which suppresses MarkdownV2, so the + # frameless path's markup dividers can't apply here). + body = _rule_between_options(body) prompt_part = body if "```" in body else f"```\n{body}\n```" else: # Format pane lines into explicit blocks: each numbered diff --git a/tests/test_kb_prompt_sanitize.py b/tests/test_kb_prompt_sanitize.py index 5d874ec2..3667ce4e 100644 --- a/tests/test_kb_prompt_sanitize.py +++ b/tests/test_kb_prompt_sanitize.py @@ -93,6 +93,21 @@ def test_content_survives_render(self): out = _render_card(_sess(), st, user_id=1) assert "/data/adb/service.d/99-ccbot.sh" in out + def test_options_separated_by_divider(self): + # Even inside the fenced box-frame body, numbered options are split + # by a literal ───── rule (the fence suppresses MarkdownV2, so the + # frameless path's markup dividers can't apply). The rule precedes + # options 2 and 3 only — option 1 keeps its trailing preview line and + # gets no leading divider → exactly two dividers in the prompt body. + st = CardState() + st.in_kb_mode = True + st.kb_prompt = BOXED_PROMPT + out = _render_card(_sess(), st, user_id=1) + body = out.split("⌨ *Waiting for your input:*", 1)[1] + assert "─────\n2. Termux:Boot" in body + assert "─────\n3. Оба слоя" in body + assert body.count("─────") == 2 + # 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. @@ -224,10 +239,13 @@ def test_outer_parts_separated_by_paragraph_break(self): out = _render_card(_sess(), st, user_id=1) assert "\n\n─────\n\n⌨ *Waiting for your input:*\n\n" in out - def test_box_frame_path_unaffected(self): - # Code-fenced rendering preserves whitespace natively; the - # divider/hard-break tricks must NOT be applied there (would - # inject the spaces / dividers INTO the code block). + def test_box_frame_path_no_hard_breaks(self): + # Code-fenced rendering preserves whitespace natively, so the + # frameless path's CommonMark hard-break trick (`` \n``) must NOT + # leak into the fence — it would surface as literal trailing spaces + # in the monospace block. Option ``─────`` dividers DO apply here + # (see TestKbModeRender.test_options_separated_by_divider); they're + # plain ``\n``-joined rule lines, no hard breaks. st = CardState() st.in_kb_mode = True st.kb_prompt = BOXED_PROMPT @@ -235,5 +253,3 @@ def test_box_frame_path_unaffected(self): body_after_fence = out.split("```", 1)[1] body_inside = body_after_fence.split("```", 1)[0] assert " \n" not in body_inside - # The split-by-divider helper must not touch box-frame paths. - assert "─────" not in body_inside