From 01760b73858a0eb4bf611928dfd25f7cd4eb9a48 Mon Sep 17 00:00:00 2001 From: PP1 <74917296+pengpengyi92@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:46:38 +0800 Subject: [PATCH] fix(channels): preserve indentation across message splits Signed-off-by: PP1 <74917296+pengpengyi92@users.noreply.github.com> --- nanobot/channels/signal/runtime.py | 17 ++++++------- .../signal/tests/test_signal_markdown.py | 9 +++++++ nanobot/utils/helpers.py | 25 +++++++++++++------ tests/utils/test_helpers.py | 12 +++++++++ 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/nanobot/channels/signal/runtime.py b/nanobot/channels/signal/runtime.py index c3358e44e..18bebc6f3 100644 --- a/nanobot/channels/signal/runtime.py +++ b/nanobot/channels/signal/runtime.py @@ -269,19 +269,18 @@ def _partition_styles( if not text_styles: return [[] for _ in chunks] - # Locate each chunk's UTF-16 start in plain_text. split_message lstrips at - # boundaries (but not before the first chunk), so we skip whitespace - # between chunks to mirror that. + # Locate each chunk in the original text. This accounts for delimiters + # removed at split points while retaining indentation inside a chunk. chunk_ranges: list[tuple[int, int]] = [] cursor = 0 # Python codepoint cursor in plain_text - for i, chunk in enumerate(chunks): - if i > 0: - while cursor < len(plain_text) and plain_text[cursor].isspace(): - cursor += 1 - utf16_start = _utf16_len(plain_text[:cursor]) + for chunk in chunks: + chunk_start = plain_text.find(chunk, cursor) + if chunk_start < 0: + chunk_start = cursor + utf16_start = _utf16_len(plain_text[:chunk_start]) utf16_end = utf16_start + _utf16_len(chunk) chunk_ranges.append((utf16_start, utf16_end)) - cursor += len(chunk) + cursor = chunk_start + len(chunk) result: list[list[str]] = [[] for _ in chunks] for entry in text_styles: diff --git a/nanobot/channels/signal/tests/test_signal_markdown.py b/nanobot/channels/signal/tests/test_signal_markdown.py index 2ab8d4cce..33a4d7495 100644 --- a/nanobot/channels/signal/tests/test_signal_markdown.py +++ b/nanobot/channels/signal/tests/test_signal_markdown.py @@ -384,6 +384,15 @@ def test_partition_styles_drops_styles_outside_chunks(): assert parts == [[], []] +def test_partition_styles_keeps_offset_in_indented_chunk(): + """Styles after preserved indentation remain relative to the chunk.""" + plain = "header\n code" + chunks = split_message(plain, 10) + + assert chunks == ["header", " code"] + assert _partition_styles(plain, chunks, ["11:4:BOLD"]) == [[], ["4:4:BOLD"]] + + def test_partition_styles_long_message_preserves_chunk_one_styles(): """A bold span deep in the message must follow the message into chunk 1.""" # Two ~30-char paragraphs separated by a blank line, then **tail**. diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index de6f89862..97f3df0b1 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -639,14 +639,23 @@ def split_message(content: str, max_len: int = 2000) -> list[str]: chunks.append(content) break cut = content[:max_len] - # Try to break at newline first, then space, then hard break - pos = cut.rfind("\n") - if pos <= 0: - pos = cut.rfind(" ") - if pos <= 0: - pos = max_len - chunks.append(content[:pos]) - content = content[pos:].lstrip() + # Consume only the newline itself so indentation starts the next chunk. + newline_pos = cut.rfind("\n") + if newline_pos > 0: + chunks.append(content[:newline_pos]) + content = content[newline_pos + 1 :] + continue + + # Keep the existing word-boundary behavior, but avoid emitting a + # whitespace-only chunk when an indented line exceeds max_len. + space_pos = cut.rfind(" ") + if space_pos > 0 and cut[:space_pos].strip(): + chunks.append(content[:space_pos]) + content = content[space_pos:].lstrip() + continue + + chunks.append(content[:max_len]) + content = content[max_len:] return chunks diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index c43d760c4..f813a7170 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -17,6 +17,18 @@ def test_split_message_no_code_blocks_unchanged(): assert split_message(content, max_len=12) == ["alpha beta", "gamma delta"] +def test_split_message_preserves_indentation_after_newline(): + content = "header\n indented code" + + assert split_message(content, max_len=18) == ["header", " indented code"] + + +def test_split_message_preserves_indentation_across_hard_break(): + content = "head\n abcdefghij" + + assert split_message(content, max_len=8) == ["head", " abcd", "efghij"] + + def test_split_message_nonpositive_maxlen_returns_unsplit(): content = "alpha beta gamma delta"