Skip to content
Open
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
183 changes: 130 additions & 53 deletions src/kimi_cli/utils/rich/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,17 @@

from __future__ import annotations

import re
import sys
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, chop_cells
from rich.console import Console, ConsoleOptions, Group, JustifyMethod, RenderResult
from rich.containers import Renderables
from rich.jupyter import JupyterMixin
from rich.rule import Rule
Expand All @@ -25,6 +26,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(),
Expand Down Expand Up @@ -157,7 +159,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()
Expand Down Expand Up @@ -430,66 +436,138 @@ 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_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
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)
if self._line_starts_with_list_marker(plain):
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(" "))
target = indent_padding_len + bullet_width
missing = max(0, target - existing)
prefix = " " * missing
if prefix:
yield Segment(prefix)
yield from line
yield new_line
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,
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_lines: list[Text] = []
for explicit_line in text.split(allow_blank=True):
if not explicit_line.plain:
wrapped_lines.append(Text(""))
continue
offsets: list[int] = []
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="")
prefixed.append_text(line)
yield prefixed
yield Segment.line()

def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
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
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:
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)
numeral = f"{number}. "
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}{numeral}",
f"{indent}{' ' * len(numeral)}",
)
return
indent = " " * (LIST_INDENT_WIDTH * self.indent)
yield from self._render_complex_item(
console,
options,
f"{indent}{numeral}",
f"{indent}{' ' * len(numeral)}",
)


class Link(TextElement):
Expand Down Expand Up @@ -777,7 +855,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
Expand Down
117 changes: 117 additions & 0 deletions tests/utils/test_rich_markdown.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -9,3 +11,118 @@ def test_markdown_html_block_renders_without_stack_error() -> None:
segments = list(console.render(markdown))
rendered = "".join(segment.text for segment in segments)
assert "<analysis>" 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


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")

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"


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:])


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"
)