From 7215fc6a9328cfd2de1199911dfa23c78797f831 Mon Sep 17 00:00:00 2001 From: Dragan Bozanovic Date: Thu, 28 May 2026 00:41:26 +0200 Subject: [PATCH 1/4] fix(tui): preserve characters when wrapping markdown lists --- src/kimi_cli/utils/rich/markdown.py | 140 ++++++++++++++-------------- tests/utils/test_rich_markdown.py | 46 +++++++++ 2 files changed, 116 insertions(+), 70 deletions(-) diff --git a/src/kimi_cli/utils/rich/markdown.py b/src/kimi_cli/utils/rich/markdown.py index 5724092ca..a3f02e619 100644 --- a/src/kimi_cli/utils/rich/markdown.py +++ b/src/kimi_cli/utils/rich/markdown.py @@ -4,17 +4,19 @@ from __future__ import annotations import sys +import textwrap from collections.abc import Iterable, Mapping from typing import ClassVar, get_args from markdown_it import MarkdownIt from markdown_it.token import Token from rich import box -from rich._loop import loop_first from rich._stack import Stack -from rich.console import Console, ConsoleOptions, JustifyMethod, RenderResult +from rich.cells import cell_len +from rich.console import Console, ConsoleOptions, Group, JustifyMethod, RenderableType, RenderResult from rich.containers import Renderables from rich.jupyter import JupyterMixin +from rich.padding import Padding from rich.rule import Rule from rich.segment import Segment from rich.style import Style, StyleStack @@ -22,6 +24,7 @@ from rich.table import Table from rich.text import Text, TextType +from kimi_cli.utils.rich.columns import BulletColumns from kimi_cli.utils.rich.syntax import KIMI_ANSI_THEME_NAME, resolve_code_theme LIST_INDENT_WIDTH = 2 @@ -157,7 +160,11 @@ def on_enter(self, context: MarkdownContext) -> None: self.text = Text(justify="left") def on_text(self, context: MarkdownContext, text: TextType) -> None: - self.text.append(text, context.current_style if isinstance(text, str) else None) + if isinstance(text, str): + style = context.current_style + self.text.append(text, None if style._null else style) + else: + self.text.append_text(text) def on_leave(self, context: MarkdownContext) -> None: context.leave_style() @@ -406,22 +413,6 @@ class ListItem(TextElement): style_name = "markdown.item" - @staticmethod - def _line_starts_with_list_marker(text: str) -> bool: - stripped = text.lstrip() - if not stripped: - return False - if stripped.startswith(("• ", "- ", "* ")): - return True - index = 0 - while index < len(stripped) and stripped[index].isdigit(): - index += 1 - if index == 0 or index >= len(stripped): - return False - marker = stripped[index] - has_space = index + 1 < len(stripped) and stripped[index + 1] == " " - return marker in {".", ")"} and has_space - @classmethod def create(cls, markdown: Markdown, token: Token) -> MarkdownElement: # `list_item_open` levels grow by 2 for each nested list depth. @@ -430,66 +421,76 @@ def create(cls, markdown: Markdown, token: Token) -> MarkdownElement: def __init__(self, indent: int = 0) -> None: self.indent = indent - self.elements: Renderables = Renderables() + self.elements: list[MarkdownElement] = [] def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bool: self.elements.append(child) return False + def _render_list_item(self, renderable: Text, padding: int = 1) -> RenderResult: + item_body = Group(*self.elements) + item: RenderableType = BulletColumns(item_body, bullet=renderable, padding=padding) + if self.indent: + item = Padding(item, (0, 0, 0, LIST_INDENT_WIDTH * self.indent), expand=False) + yield item + + def _render_wrapped_text_item( + self, + options: ConsoleOptions, + text: Text, + first_prefix: str, + rest_prefix: str, + ) -> RenderResult: + available_width = max( + 1, + options.max_width - max(cell_len(first_prefix), cell_len(rest_prefix)), + ) + wrapped_plain_lines = textwrap.wrap( + text.plain, + width=available_width, + break_long_words=False, + break_on_hyphens=False, + drop_whitespace=False, + ) + offsets: list[int] = [] + offset = 0 + for line in wrapped_plain_lines[:-1]: + offset += len(line) + offsets.append(offset) + wrapped_lines = text.divide(offsets) + if not wrapped_lines: + wrapped_lines = [Text("")] + for line_index, line in enumerate(wrapped_lines): + prefixed = Text(first_prefix if line_index == 0 else rest_prefix, end="") + prefixed.append_text(line) + yield prefixed + yield Segment.line() + def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult: - lines = console.render_lines(self.elements, options, style=self.style) - indent_padding_len = LIST_INDENT_WIDTH * self.indent - indent_text = " " * indent_padding_len - bullet = Segment("• ") - new_line = Segment("\n") - bullet_width = len(bullet.text) - for first, line in loop_first(lines): - if first: - if indent_text: - yield Segment(indent_text) - yield bullet - else: - plain = "".join(segment.text for segment in line) - if self._line_starts_with_list_marker(plain): - prefix = "" - else: - existing = len(plain) - len(plain.lstrip(" ")) - target = indent_padding_len + bullet_width - missing = max(0, target - existing) - prefix = " " * missing - if prefix: - yield Segment(prefix) - yield from line - yield new_line + elements = list(self.elements) + if len(elements) == 1 and isinstance(elements[0], Paragraph): + indent = " " * (LIST_INDENT_WIDTH * self.indent) + text = elements[0].text.copy() + yield from self._render_wrapped_text_item(options, text, f"{indent}• ", f"{indent} ") + return + yield from self._render_list_item(Text("•")) def render_number( self, console: Console, options: ConsoleOptions, number: int, last_number: int ) -> RenderResult: - lines = console.render_lines(self.elements, options, style=self.style) - new_line = Segment("\n") - indent_padding_len = LIST_INDENT_WIDTH * self.indent - indent_text = " " * indent_padding_len - numeral_text = f"{number}. " - numeral = Segment(numeral_text) - numeral_width = len(numeral_text) - for first, line in loop_first(lines): - if first: - if indent_text: - yield Segment(indent_text) - yield numeral - else: - plain = "".join(segment.text for segment in line) - if self._line_starts_with_list_marker(plain): - prefix = "" - else: - existing = len(plain) - len(plain.lstrip(" ")) - target = indent_padding_len + numeral_width - missing = max(0, target - existing) - prefix = " " * missing - if prefix: - yield Segment(prefix) - yield from line - yield new_line + elements = list(self.elements) + if len(elements) == 1 and isinstance(elements[0], Paragraph): + indent = " " * (LIST_INDENT_WIDTH * self.indent) + numeral = f"{number}. " + text = elements[0].text.copy() + yield from self._render_wrapped_text_item( + options, + text, + f"{indent}{numeral}", + f"{indent}{' ' * len(numeral)}", + ) + return + yield from self._render_list_item(Text(f"{number}."), padding=1) class Link(TextElement): @@ -777,7 +778,6 @@ def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderR if should_render: if new_line and render_started: yield _new_line_segment - rendered = console.render(element, context.options) for segment in rendered: render_started = True diff --git a/tests/utils/test_rich_markdown.py b/tests/utils/test_rich_markdown.py index f96a9acfc..56bd55f1e 100644 --- a/tests/utils/test_rich_markdown.py +++ b/tests/utils/test_rich_markdown.py @@ -1,5 +1,7 @@ from rich.console import Console +from rich.text import Text +from kimi_cli.utils.rich.columns import BulletColumns from kimi_cli.utils.rich.markdown import Markdown @@ -9,3 +11,47 @@ def test_markdown_html_block_renders_without_stack_error() -> None: segments = list(console.render(markdown)) rendered = "".join(segment.text for segment in segments) assert "" in rendered + + +def _normalize_text(text: str) -> str: + return " ".join(text.replace("•", " ").split()) + + +def test_markdown_list_wrapping_preserves_text() -> None: + console = Console(width=60, record=True) + markdown = Markdown( + "- **What it does:** Acts as an autonomous agent that can write code, run shell commands, edit files, browse the web, and manage multi-step tasks through an interactive chat interface\n" + "- **Architecture:** Built around a core agent loop (KimiSoul) that orchestrates LLM calls, tool execution, context management, and conversation compaction\n" + ) + + console.print(markdown) + rendered = _normalize_text(console.export_text()) + + assert "run shell commands, edit files, browse the web" in rendered + assert "core agent loop (KimiSoul)" in rendered + + +def test_markdown_list_wrapping_preserves_text_inside_outer_bullet() -> None: + console = Console(width=60, record=True) + markdown = Markdown( + "- **What it does:** Acts as an autonomous agent that can write code, run shell commands, edit files, browse the web, and manage multi-step tasks through an interactive chat interface\n" + "- **Architecture:** Built around a core agent loop (KimiSoul) that orchestrates LLM calls, tool execution, context management, and conversation compaction\n" + ) + + console.print(BulletColumns(markdown, bullet=Text("•"))) + rendered = _normalize_text(console.export_text()) + + assert "run shell commands, edit files, browse the web" in rendered + assert "core agent loop (KimiSoul)" in rendered + + +def test_markdown_list_wrapping_preserves_inline_styling() -> None: + console = Console(width=80, record=True, force_terminal=True) + markdown = Markdown( + "- **What it does:** Acts as an autonomous agent that can write code, run shell commands, edit files, browse the web, and manage multi-step tasks through an interactive chat interface\n" + ) + + console.print(markdown) + rendered = console.export_text(styles=True) + + assert "\x1b[1mWhat it does:\x1b[0m" in rendered From d49b1f291675c3bda8f405ccd8a7d15b8907e184 Mon Sep 17 00:00:00 2001 From: Dragan Bozanovic Date: Thu, 28 May 2026 01:30:31 +0200 Subject: [PATCH 2/4] preserve hard breaks in markdown list items --- src/kimi_cli/utils/rich/markdown.py | 35 ++++++++++++++++------------- tests/utils/test_rich_markdown.py | 18 +++++++++++++++ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/src/kimi_cli/utils/rich/markdown.py b/src/kimi_cli/utils/rich/markdown.py index a3f02e619..b7b0aa6c4 100644 --- a/src/kimi_cli/utils/rich/markdown.py +++ b/src/kimi_cli/utils/rich/markdown.py @@ -445,21 +445,26 @@ def _render_wrapped_text_item( 1, options.max_width - max(cell_len(first_prefix), cell_len(rest_prefix)), ) - wrapped_plain_lines = textwrap.wrap( - text.plain, - width=available_width, - break_long_words=False, - break_on_hyphens=False, - drop_whitespace=False, - ) - offsets: list[int] = [] - offset = 0 - for line in wrapped_plain_lines[:-1]: - offset += len(line) - offsets.append(offset) - wrapped_lines = text.divide(offsets) - if not wrapped_lines: - wrapped_lines = [Text("")] + wrapped_lines: list[Text] = [] + for explicit_line in text.split(allow_blank=True): + if not explicit_line.plain: + wrapped_lines.append(Text("")) + continue + wrapped_plain_lines = textwrap.wrap( + explicit_line.plain, + width=available_width, + break_long_words=False, + break_on_hyphens=False, + drop_whitespace=False, + replace_whitespace=False, + ) + offsets: list[int] = [] + offset = 0 + for line in wrapped_plain_lines[:-1]: + offset += len(line) + offsets.append(offset) + pieces = explicit_line.divide(offsets) if offsets else [explicit_line.copy()] + wrapped_lines.extend(pieces) for line_index, line in enumerate(wrapped_lines): prefixed = Text(first_prefix if line_index == 0 else rest_prefix, end="") prefixed.append_text(line) diff --git a/tests/utils/test_rich_markdown.py b/tests/utils/test_rich_markdown.py index 56bd55f1e..f64f8d182 100644 --- a/tests/utils/test_rich_markdown.py +++ b/tests/utils/test_rich_markdown.py @@ -55,3 +55,21 @@ def test_markdown_list_wrapping_preserves_inline_styling() -> None: rendered = console.export_text(styles=True) assert "\x1b[1mWhat it does:\x1b[0m" in rendered + + +def test_markdown_list_hard_break_preserves_continuation_line() -> None: + console = Console(width=60, record=True) + markdown = Markdown("- first\\\n second\n") + + console.print(markdown) + + assert console.export_text() == "• first\n second\n" + + +def test_markdown_list_soft_break_collapses_to_space() -> None: + console = Console(width=60, record=True) + markdown = Markdown("- first\n second\n") + + console.print(markdown) + + assert console.export_text() == "• first second\n" From e2582f82574c09d8c826f1098778959712a99d5b Mon Sep 17 00:00:00 2001 From: Dragan Bozanovic Date: Thu, 28 May 2026 01:57:19 +0200 Subject: [PATCH 3/4] preserve word boundaries in markdown list wrapping --- src/kimi_cli/utils/rich/markdown.py | 43 +++++++++++++++++++---------- tests/utils/test_rich_markdown.py | 27 ++++++++++++++++++ 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/kimi_cli/utils/rich/markdown.py b/src/kimi_cli/utils/rich/markdown.py index b7b0aa6c4..f598c6c89 100644 --- a/src/kimi_cli/utils/rich/markdown.py +++ b/src/kimi_cli/utils/rich/markdown.py @@ -3,8 +3,8 @@ from __future__ import annotations +import re import sys -import textwrap from collections.abc import Iterable, Mapping from typing import ClassVar, get_args @@ -12,7 +12,7 @@ from markdown_it.token import Token from rich import box from rich._stack import Stack -from rich.cells import cell_len +from rich.cells import cell_len, chop_cells from rich.console import Console, ConsoleOptions, Group, JustifyMethod, RenderableType, RenderResult from rich.containers import Renderables from rich.jupyter import JupyterMixin @@ -28,6 +28,7 @@ from kimi_cli.utils.rich.syntax import KIMI_ANSI_THEME_NAME, resolve_code_theme LIST_INDENT_WIDTH = 2 +_WORD_WITH_TRAILING_SPACE = re.compile(r"\S+\s*") _FALLBACK_STYLES: Mapping[str, Style] = { "markdown.paragraph": Style(), @@ -450,20 +451,34 @@ def _render_wrapped_text_item( if not explicit_line.plain: wrapped_lines.append(Text("")) continue - wrapped_plain_lines = textwrap.wrap( - explicit_line.plain, - width=available_width, - break_long_words=False, - break_on_hyphens=False, - drop_whitespace=False, - replace_whitespace=False, - ) offsets: list[int] = [] - offset = 0 - for line in wrapped_plain_lines[:-1]: - offset += len(line) - offsets.append(offset) + line_width = 0 + for match in _WORD_WITH_TRAILING_SPACE.finditer(explicit_line.plain): + token = match.group(0) + visible = token.rstrip() + visible_width = cell_len(visible) + token_width = cell_len(token) + token_start = match.start() + remaining_width = available_width - line_width + if visible_width <= remaining_width: + line_width += token_width + continue + if visible_width > available_width: + if line_width: + offsets.append(token_start) + char_offset = token_start + for piece in chop_cells(visible, available_width)[:-1]: + char_offset += len(piece) + offsets.append(char_offset) + trailing = token[len(visible) :] + line_width = cell_len(chop_cells(visible, available_width)[-1] + trailing) + continue + if line_width: + offsets.append(token_start) + line_width = token_width pieces = explicit_line.divide(offsets) if offsets else [explicit_line.copy()] + for piece in pieces: + piece.rstrip() wrapped_lines.extend(pieces) for line_index, line in enumerate(wrapped_lines): prefixed = Text(first_prefix if line_index == 0 else rest_prefix, end="") diff --git a/tests/utils/test_rich_markdown.py b/tests/utils/test_rich_markdown.py index f64f8d182..7337576ce 100644 --- a/tests/utils/test_rich_markdown.py +++ b/tests/utils/test_rich_markdown.py @@ -57,6 +57,21 @@ def test_markdown_list_wrapping_preserves_inline_styling() -> None: assert "\x1b[1mWhat it does:\x1b[0m" in rendered +def test_markdown_list_wrapping_preserves_word_boundaries() -> None: + console = Console(width=80, record=True) + markdown = Markdown( + "- **What it does:** Acts as an autonomous agent that can write code, run shell commands, edit files, browse the web, and manage multi-step tasks through an interactive chat interface\n" + ) + + console.print(markdown) + + assert console.export_text() == ( + "• What it does: Acts as an autonomous agent that can write code, run shell\n" + " commands, edit files, browse the web, and manage multi-step tasks through an\n" + " interactive chat interface\n" + ) + + def test_markdown_list_hard_break_preserves_continuation_line() -> None: console = Console(width=60, record=True) markdown = Markdown("- first\\\n second\n") @@ -73,3 +88,15 @@ def test_markdown_list_soft_break_collapses_to_space() -> None: console.print(markdown) assert console.export_text() == "• first second\n" + + +def test_markdown_list_long_unspaced_content_keeps_continuation_indent() -> None: + console = Console(width=30, record=True) + markdown = Markdown("- /very/long/path/without/any/spaces/that/should/not/reset/indentation\n") + + console.print(markdown) + lines = console.export_text().splitlines() + + assert lines[0].startswith("• ") + assert len(lines) > 1 + assert all(line.startswith(" ") for line in lines[1:]) From a2df5f82caa6f8691426ffbae48ebdb72960256e Mon Sep 17 00:00:00 2001 From: Dragan Bozanovic Date: Thu, 28 May 2026 02:25:55 +0200 Subject: [PATCH 4/4] preserve nested markdown list indentation --- src/kimi_cli/utils/rich/markdown.py | 81 ++++++++++++++++++++++++----- tests/utils/test_rich_markdown.py | 26 +++++++++ 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/src/kimi_cli/utils/rich/markdown.py b/src/kimi_cli/utils/rich/markdown.py index f598c6c89..ad7a768ef 100644 --- a/src/kimi_cli/utils/rich/markdown.py +++ b/src/kimi_cli/utils/rich/markdown.py @@ -13,10 +13,9 @@ from rich import box from rich._stack import Stack from rich.cells import cell_len, chop_cells -from rich.console import Console, ConsoleOptions, Group, JustifyMethod, RenderableType, RenderResult +from rich.console import Console, ConsoleOptions, Group, JustifyMethod, RenderResult from rich.containers import Renderables from rich.jupyter import JupyterMixin -from rich.padding import Padding from rich.rule import Rule from rich.segment import Segment from rich.style import Style, StyleStack @@ -24,7 +23,6 @@ from rich.table import Table from rich.text import Text, TextType -from kimi_cli.utils.rich.columns import BulletColumns from kimi_cli.utils.rich.syntax import KIMI_ANSI_THEME_NAME, resolve_code_theme LIST_INDENT_WIDTH = 2 @@ -414,6 +412,22 @@ class ListItem(TextElement): style_name = "markdown.item" + @staticmethod + def _line_starts_with_list_marker(text: str) -> bool: + stripped = text.lstrip() + if not stripped: + return False + if stripped.startswith(("• ", "- ", "* ")): + return True + index = 0 + while index < len(stripped) and stripped[index].isdigit(): + index += 1 + if index == 0 or index >= len(stripped): + return False + marker = stripped[index] + has_space = index + 1 < len(stripped) and stripped[index + 1] == " " + return marker in {".", ")"} and has_space + @classmethod def create(cls, markdown: Markdown, token: Token) -> MarkdownElement: # `list_item_open` levels grow by 2 for each nested list depth. @@ -428,12 +442,48 @@ def on_child_close(self, context: MarkdownContext, child: MarkdownElement) -> bo self.elements.append(child) return False - def _render_list_item(self, renderable: Text, padding: int = 1) -> RenderResult: - item_body = Group(*self.elements) - item: RenderableType = BulletColumns(item_body, bullet=renderable, padding=padding) - if self.indent: - item = Padding(item, (0, 0, 0, LIST_INDENT_WIDTH * self.indent), expand=False) - yield item + def _render_complex_item( + self, + console: Console, + options: ConsoleOptions, + first_prefix: str, + rest_prefix: str, + ) -> RenderResult: + available_width = max( + 1, + options.max_width - max(cell_len(first_prefix), cell_len(rest_prefix)), + ) + lines = console.render_lines( + Group(*self.elements), + options.update(width=available_width), + style=self.style, + pad=False, + ) + for line_index, line in enumerate(lines): + line_segments = list(line) + while line_segments: + segment = line_segments[-1] + if segment.control is not None: + break + trimmed_text = segment.text.rstrip(" ") + if trimmed_text != segment.text: + if trimmed_text: + line_segments[-1] = Segment(trimmed_text, segment.style, segment.control) + else: + line_segments.pop() + continue + break + if line_index == 0: + yield Segment(first_prefix) + else: + plain = "".join(segment.text for segment in line_segments) + if not self._line_starts_with_list_marker(plain): + existing = len(plain) - len(plain.lstrip(" ")) + missing = max(0, cell_len(rest_prefix) - existing) + if missing: + yield Segment(" " * missing) + yield from line_segments + yield Segment.line() def _render_wrapped_text_item( self, @@ -493,15 +543,16 @@ def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResu text = elements[0].text.copy() yield from self._render_wrapped_text_item(options, text, f"{indent}• ", f"{indent} ") return - yield from self._render_list_item(Text("•")) + indent = " " * (LIST_INDENT_WIDTH * self.indent) + yield from self._render_complex_item(console, options, f"{indent}• ", f"{indent} ") def render_number( self, console: Console, options: ConsoleOptions, number: int, last_number: int ) -> RenderResult: elements = list(self.elements) + numeral = f"{number}. " if len(elements) == 1 and isinstance(elements[0], Paragraph): indent = " " * (LIST_INDENT_WIDTH * self.indent) - numeral = f"{number}. " text = elements[0].text.copy() yield from self._render_wrapped_text_item( options, @@ -510,7 +561,13 @@ def render_number( f"{indent}{' ' * len(numeral)}", ) return - yield from self._render_list_item(Text(f"{number}."), padding=1) + indent = " " * (LIST_INDENT_WIDTH * self.indent) + yield from self._render_complex_item( + console, + options, + f"{indent}{numeral}", + f"{indent}{' ' * len(numeral)}", + ) class Link(TextElement): diff --git a/tests/utils/test_rich_markdown.py b/tests/utils/test_rich_markdown.py index 7337576ce..db9d5277c 100644 --- a/tests/utils/test_rich_markdown.py +++ b/tests/utils/test_rich_markdown.py @@ -100,3 +100,29 @@ def test_markdown_list_long_unspaced_content_keeps_continuation_indent() -> None assert lines[0].startswith("• ") assert len(lines) > 1 assert all(line.startswith(" ") for line in lines[1:]) + + +def test_markdown_nested_list_keeps_expected_indent() -> None: + console = Console(width=40, record=True) + markdown = Markdown("- a\n - b\n") + + console.print(markdown) + + assert console.export_text() == "• a\n • b\n" + + +def test_markdown_wrapped_nested_list_does_not_add_parent_indent() -> None: + console = Console(width=30, record=True) + markdown = Markdown( + "- parent long text that wraps around in width\n" + " - child long text also wraps around width\n" + ) + + console.print(markdown) + + assert console.export_text() == ( + "• parent long text that wraps\n" + " around in width\n" + " • child long text also\n" + " wraps around width\n" + )