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
32 changes: 32 additions & 0 deletions src/ccbot/handlers/card_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
28 changes: 22 additions & 6 deletions tests/test_kb_prompt_sanitize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -224,16 +239,17 @@ 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
out = _render_card(_sess(), st, user_id=1)
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
Loading