From e1d5b238a38b4cfca99c22346286ea454dd7832c Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 09:31:50 +0800 Subject: [PATCH 01/11] Render chat responses as Markdown using rich Live display Use rich's Live + Markdown to progressively render LLM responses in the chat REPL with proper formatting (headings, bold, code blocks, lists, etc.) instead of raw text output. Falls back to plain text when --no-color is set. Tool call lines still use prompt_toolkit styled output. --- openkb/agent/chat.py | 53 +++++++++++++++++++++++++++++++++++--------- pyproject.toml | 1 + 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 42ac9f9..e630136 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -189,7 +189,10 @@ def _make_prompt_session(session: ChatSession, style: Style, use_color: bool) -> ) -async def _run_turn(agent: Any, session: ChatSession, user_input: str, style: Style) -> None: +async def _run_turn( + agent: Any, session: ChatSession, user_input: str, style: Style, + *, use_color: bool = True, +) -> None: """Run one agent turn with streaming output and persist the new history.""" from agents import ( RawResponsesStreamEvent, @@ -202,11 +205,22 @@ async def _run_turn(agent: Any, session: ChatSession, user_input: str, style: St result = Runner.run_streamed(agent, new_input, max_turns=MAX_TURNS) - sys.stdout.write("\n") - sys.stdout.flush() + print() collected: list[str] = [] last_was_text = False need_blank_before_text = False + + if use_color: + from rich.console import Console + from rich.live import Live + from rich.markdown import Markdown + + console = Console() + live = Live(console=console, vertical_overflow="visible") + live.start() + else: + live = None + try: async for event in result.stream_events(): if isinstance(event, RawResponsesStreamEvent): @@ -214,27 +228,44 @@ async def _run_turn(agent: Any, session: ChatSession, user_input: str, style: St text = event.data.delta if text: if need_blank_before_text: - sys.stdout.write("\n") + if live: + live.stop() + print() + live.start() + else: + sys.stdout.write("\n") need_blank_before_text = False - sys.stdout.write(text) - sys.stdout.flush() collected.append(text) last_was_text = True + if live: + live.update(Markdown("".join(collected))) + else: + sys.stdout.write(text) + sys.stdout.flush() elif isinstance(event, RunItemStreamEvent): item = event.item if item.type == "tool_call_item": if last_was_text: - sys.stdout.write("\n") - sys.stdout.flush() + if live: + live.stop() + live.start() + else: + sys.stdout.write("\n") + sys.stdout.flush() last_was_text = False raw = item.raw_item name = getattr(raw, "name", "?") args = getattr(raw, "arguments", "") or "" + if live: + live.stop() _fmt(style, ("class:tool", _format_tool_line(name, args) + "\n")) + if live: + live.start() need_blank_before_text = True finally: - sys.stdout.write("\n\n") - sys.stdout.flush() + if live: + live.stop() + print("\n") answer = "".join(collected).strip() if not answer: @@ -371,7 +402,7 @@ async def run_chat( append_log(kb_dir / "wiki", "query", user_input) try: - await _run_turn(agent, session, user_input, style) + await _run_turn(agent, session, user_input, style, use_color=use_color) except KeyboardInterrupt: _fmt(style, ("class:error", "\n[aborted]\n")) except Exception as exc: diff --git a/pyproject.toml b/pyproject.toml index 4af87be..e368a97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ dependencies = [ "python-dotenv", "json-repair", "prompt_toolkit>=3.0", + "rich>=13.0", ] [project.urls] From 5d2b4b48d0759b8791e3dcf0d90799b1f6f97f91 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 09:32:29 +0800 Subject: [PATCH 02/11] Add rich Markdown rendering to single-shot query output Apply the same rich Live + Markdown streaming render to `openkb query` as was added to chat. Falls back to plain text when stdout is not a tty. --- openkb/agent/query.py | 60 +++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 19 deletions(-) diff --git a/openkb/agent/query.py b/openkb/agent/query.py index 39e0e40..f3d857e 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -120,25 +120,47 @@ async def run_query(question: str, kb_dir: Path, model: str, stream: bool = Fals result = await Runner.run(agent, question, max_turns=MAX_TURNS) return result.final_output or "" + use_color = sys.stdout.isatty() + + if use_color: + from rich.console import Console + from rich.live import Live + from rich.markdown import Markdown + console = Console() + live = Live(console=console, vertical_overflow="visible") + live.start() + else: + live = None + result = Runner.run_streamed(agent, question, max_turns=MAX_TURNS) - collected = [] - async for event in result.stream_events(): - if isinstance(event, RawResponsesStreamEvent): - if isinstance(event.data, ResponseTextDeltaEvent): - text = event.data.delta - if text: - sys.stdout.write(text) + collected: list[str] = [] + try: + async for event in result.stream_events(): + if isinstance(event, RawResponsesStreamEvent): + if isinstance(event.data, ResponseTextDeltaEvent): + text = event.data.delta + if text: + collected.append(text) + if live: + live.update(Markdown("".join(collected))) + else: + sys.stdout.write(text) + sys.stdout.flush() + elif isinstance(event, RunItemStreamEvent): + item = event.item + if item.type == "tool_call_item": + raw = item.raw_item + args = getattr(raw, "arguments", "{}") + if live: + live.stop() + sys.stdout.write(f"\n[tool call] {raw.name}({args})\n\n") sys.stdout.flush() - collected.append(text) - elif isinstance(event, RunItemStreamEvent): - item = event.item - if item.type == "tool_call_item": - raw = item.raw_item - args = getattr(raw, "arguments", "{}") - sys.stdout.write(f"\n[tool call] {raw.name}({args})\n\n") - sys.stdout.flush() - elif item.type == "tool_call_output_item": - pass - sys.stdout.write("\n") - sys.stdout.flush() + if live: + live.start() + elif item.type == "tool_call_output_item": + pass + finally: + if live: + live.stop() + print() return "".join(collected) if collected else result.final_output or "" From 34e066239bb418fb9827e926699509ead3c2331e Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 09:48:19 +0800 Subject: [PATCH 03/11] Fix Live display overwriting tool call text and respect NO_COLOR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create a fresh Live instance after each tool call instead of stop/start on the same instance, preventing the auto-refresh thread from overwriting stdout text with CURSOR_UP sequences - Respect NO_COLOR env var in query.py (consistent with chat.py) - Fix print("\n") → print() in chat.py finally block to avoid extra blank line when Live is active --- openkb/agent/chat.py | 24 ++++++++++++++++-------- openkb/agent/query.py | 20 ++++++++++++++------ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index e630136..3d35097 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -216,10 +216,17 @@ async def _run_turn( from rich.markdown import Markdown console = Console() - live = Live(console=console, vertical_overflow="visible") - live.start() else: - live = None + console = None # type: ignore[assignment] + + def _start_live() -> Any: + if console is None: + return None + lv = Live(console=console, vertical_overflow="visible") + lv.start() + return lv + + live = _start_live() try: async for event in result.stream_events(): @@ -230,8 +237,9 @@ async def _run_turn( if need_blank_before_text: if live: live.stop() + live = None print() - live.start() + live = _start_live() else: sys.stdout.write("\n") need_blank_before_text = False @@ -248,7 +256,7 @@ async def _run_turn( if last_was_text: if live: live.stop() - live.start() + live = None else: sys.stdout.write("\n") sys.stdout.flush() @@ -258,14 +266,14 @@ async def _run_turn( args = getattr(raw, "arguments", "") or "" if live: live.stop() + live = None _fmt(style, ("class:tool", _format_tool_line(name, args) + "\n")) - if live: - live.start() + live = _start_live() need_blank_before_text = True finally: if live: live.stop() - print("\n") + print() answer = "".join(collected).strip() if not answer: diff --git a/openkb/agent/query.py b/openkb/agent/query.py index f3d857e..e85a8c7 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -120,17 +120,25 @@ async def run_query(question: str, kb_dir: Path, model: str, stream: bool = Fals result = await Runner.run(agent, question, max_turns=MAX_TURNS) return result.final_output or "" - use_color = sys.stdout.isatty() + import os + use_color = sys.stdout.isatty() and not os.environ.get("NO_COLOR", "") if use_color: from rich.console import Console from rich.live import Live from rich.markdown import Markdown console = Console() - live = Live(console=console, vertical_overflow="visible") - live.start() else: - live = None + console = None # type: ignore[assignment] + + def _start_live() -> Live | None: + if console is None: + return None + lv = Live(console=console, vertical_overflow="visible") + lv.start() + return lv + + live = _start_live() result = Runner.run_streamed(agent, question, max_turns=MAX_TURNS) collected: list[str] = [] @@ -153,10 +161,10 @@ async def run_query(question: str, kb_dir: Path, model: str, stream: bool = Fals args = getattr(raw, "arguments", "{}") if live: live.stop() + live = None sys.stdout.write(f"\n[tool call] {raw.name}({args})\n\n") sys.stdout.flush() - if live: - live.start() + live = _start_live() elif item.type == "tool_call_output_item": pass finally: From 3e4d8d67ebfe09faa5443f3b1bd298483a1ffbd7 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 10:43:35 +0800 Subject: [PATCH 04/11] Apply Claude-Code-like theme to rich Markdown rendering Custom Rich Theme: bold headings without color, dark background for inline code, subtle link/list/blockquote colors. Shared via _make_rich_console() used by both chat and query. --- openkb/agent/chat.py | 32 +++++++++++++++++++++++++++++++- openkb/agent/query.py | 4 ++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 3d35097..ac22461 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -189,6 +189,36 @@ def _make_prompt_session(session: ChatSession, style: Style, use_color: bool) -> ) +def _make_rich_console() -> Any: + """Create a Rich Console with a Claude-Code-like Markdown theme.""" + from rich.console import Console + from rich.theme import Theme + + theme = Theme({ + # Headings: bold, no color (clean and minimal) + "markdown.h1": "bold", + "markdown.h2": "bold", + "markdown.h3": "bold", + "markdown.h4": "bold dim", + # Code: dark background, no border + "markdown.code": "bold #c0c0c0 on #1e1e1e", + # Links + "markdown.link": "underline #5fa0e0", + "markdown.link_url": "#5fa0e0", + # Emphasis + "markdown.bold": "bold", + "markdown.italic": "italic", + "markdown.strong": "bold", + # Lists and block quotes + "markdown.item.bullet": "#8a8a8a", + "markdown.item.number": "#8a8a8a", + "markdown.block_quote": "italic #8a8a8a", + # Horizontal rule + "markdown.hr": "#4a4a4a", + }) + return Console(theme=theme) + + async def _run_turn( agent: Any, session: ChatSession, user_input: str, style: Style, *, use_color: bool = True, @@ -215,7 +245,7 @@ async def _run_turn( from rich.live import Live from rich.markdown import Markdown - console = Console() + console = _make_rich_console() else: console = None # type: ignore[assignment] diff --git a/openkb/agent/query.py b/openkb/agent/query.py index e85a8c7..27c13ec 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -124,10 +124,10 @@ async def run_query(question: str, kb_dir: Path, model: str, stream: bool = Fals use_color = sys.stdout.isatty() and not os.environ.get("NO_COLOR", "") if use_color: - from rich.console import Console from rich.live import Live from rich.markdown import Markdown - console = Console() + from openkb.agent.chat import _make_rich_console + console = _make_rich_console() else: console = None # type: ignore[assignment] From e6aeedeb53f7305ed216473cf9864b71285d03b7 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 10:48:05 +0800 Subject: [PATCH 05/11] Add colors to Markdown theme for better readability - Headings: blue (#5fa0e0) - Code: yellow-ish on dark background - List bullets: green (#6ac0a0) - Bold: bright white, italic: light gray - Paragraph text: light gray for visibility --- openkb/agent/chat.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index ac22461..acca69e 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -195,26 +195,27 @@ def _make_rich_console() -> Any: from rich.theme import Theme theme = Theme({ - # Headings: bold, no color (clean and minimal) - "markdown.h1": "bold", - "markdown.h2": "bold", - "markdown.h3": "bold", - "markdown.h4": "bold dim", - # Code: dark background, no border - "markdown.code": "bold #c0c0c0 on #1e1e1e", + # Headings: bold with blue tint + "markdown.h1": "bold #5fa0e0", + "markdown.h2": "bold #5fa0e0", + "markdown.h3": "bold #7ab0e8", + "markdown.h4": "bold #8abae0", + # Code + "markdown.code": "#e8c87a on #1e1e1e", # Links "markdown.link": "underline #5fa0e0", "markdown.link_url": "#5fa0e0", # Emphasis - "markdown.bold": "bold", - "markdown.italic": "italic", - "markdown.strong": "bold", + "markdown.bold": "bold #e0e0e0", + "markdown.italic": "italic #c0c0c0", # Lists and block quotes - "markdown.item.bullet": "#8a8a8a", - "markdown.item.number": "#8a8a8a", + "markdown.item.bullet": "#6ac0a0", + "markdown.item.number": "#6ac0a0", "markdown.block_quote": "italic #8a8a8a", # Horizontal rule "markdown.hr": "#4a4a4a", + # Paragraphs — ensure normal text is visible + "markdown.paragraph": "#d0d0d0", }) return Console(theme=theme) From 8cdc5a7d7c40fd597734dbf43f616e60f5961866 Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 10:49:31 +0800 Subject: [PATCH 06/11] Explicitly set code_theme=monokai for syntax highlighting in code blocks --- openkb/agent/chat.py | 2 +- openkb/agent/query.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index acca69e..6a36d66 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -277,7 +277,7 @@ def _start_live() -> Any: collected.append(text) last_was_text = True if live: - live.update(Markdown("".join(collected))) + live.update(Markdown("".join(collected), code_theme="monokai")) else: sys.stdout.write(text) sys.stdout.flush() diff --git a/openkb/agent/query.py b/openkb/agent/query.py index 27c13ec..a7f2053 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -150,7 +150,7 @@ def _start_live() -> Live | None: if text: collected.append(text) if live: - live.update(Markdown("".join(collected))) + live.update(Markdown("".join(collected), code_theme="monokai")) else: sys.stdout.write(text) sys.stdout.flush() From 8708fc496c64bebf922b633a539d494ba5606cb3 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 13 Apr 2026 17:55:44 +0800 Subject: [PATCH 07/11] Fix streaming render bugs in chat and query Two issues in the Live-based markdown streaming: 1. After a tool call, the new Live instance re-rendered the entire response so far because `collected` was never reset. Text before and after the tool call would be rendered together as one markdown block in the new Live region, with no separator between the two parts. Track the current segment separately from the full answer. 2. The tool_call_item handler started a new Live immediately after printing the tool line, which then had to be stopped in the text handler before the blank line. The empty Live start/stop plus the explicit `print()` produced two blank lines. Drop the premature start and let the next text delta create the new Live. --- openkb/agent/chat.py | 10 +++++----- openkb/agent/query.py | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 6a36d66..791e270 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -238,6 +238,7 @@ async def _run_turn( print() collected: list[str] = [] + segment: list[str] = [] last_was_text = False need_blank_before_text = False @@ -266,18 +267,18 @@ def _start_live() -> Any: text = event.data.delta if text: if need_blank_before_text: - if live: - live.stop() - live = None + if console is not None: print() + segment = [] live = _start_live() else: sys.stdout.write("\n") need_blank_before_text = False collected.append(text) + segment.append(text) last_was_text = True if live: - live.update(Markdown("".join(collected), code_theme="monokai")) + live.update(Markdown("".join(segment), code_theme="monokai")) else: sys.stdout.write(text) sys.stdout.flush() @@ -299,7 +300,6 @@ def _start_live() -> Any: live.stop() live = None _fmt(style, ("class:tool", _format_tool_line(name, args) + "\n")) - live = _start_live() need_blank_before_text = True finally: if live: diff --git a/openkb/agent/query.py b/openkb/agent/query.py index a7f2053..ee85ad6 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -142,6 +142,7 @@ def _start_live() -> Live | None: result = Runner.run_streamed(agent, question, max_turns=MAX_TURNS) collected: list[str] = [] + segment: list[str] = [] try: async for event in result.stream_events(): if isinstance(event, RawResponsesStreamEvent): @@ -149,8 +150,9 @@ def _start_live() -> Live | None: text = event.data.delta if text: collected.append(text) + segment.append(text) if live: - live.update(Markdown("".join(collected), code_theme="monokai")) + live.update(Markdown("".join(segment), code_theme="monokai")) else: sys.stdout.write(text) sys.stdout.flush() @@ -164,6 +166,7 @@ def _start_live() -> Live | None: live = None sys.stdout.write(f"\n[tool call] {raw.name}({args})\n\n") sys.stdout.flush() + segment = [] live = _start_live() elif item.type == "tool_call_output_item": pass From dab73797ec6f05edf5318fbf97330973756891ab Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 13 Apr 2026 17:56:28 +0800 Subject: [PATCH 08/11] Replace Rich Markdown with custom terminal renderer The previous approach used rich.markdown.Markdown with a hand-written Theme. Two problems: - Rich centers h1/h2 headings in a Panel, which looks out of place in chat output. - The Theme pinned a gray paragraph color (`#d0d0d0`) that overrode the terminal's default foreground for every line of assistant text. Replace with a renderer (openkb/agent/_markdown.py) that parses with markdown-it-py and maps each token to Rich primitives directly: Text for inline content, Syntax for code blocks, Group for block stacking. Plain text, bold, italic, and strikethrough keep the terminal's default color and only carry style attributes. Headings are left- aligned (bold, with h1 also italic + underline), list nesting uses decimal / letters / roman numerals by depth, blockquotes prefix non-blank lines with a dim vertical bar, tables render as pipes and dashes, and links emit OSC 8 hyperlinks where applicable. _make_rich_console loses the Theme (no reason to carry one now) and _make_markdown wraps the new renderer. query.py imports it from chat.py instead of constructing Markdown itself. --- openkb/agent/_markdown.py | 343 ++++++++++++++++++++++++++++++++++++++ openkb/agent/chat.py | 38 +---- openkb/agent/query.py | 5 +- 3 files changed, 354 insertions(+), 32 deletions(-) create mode 100644 openkb/agent/_markdown.py diff --git a/openkb/agent/_markdown.py b/openkb/agent/_markdown.py new file mode 100644 index 0000000..ff46879 --- /dev/null +++ b/openkb/agent/_markdown.py @@ -0,0 +1,343 @@ +"""Markdown rendering in Claude Code's terminal style. + +Mirrors claude-code's utils/markdown.ts: parse with markdown-it, then map +each token to Rich primitives. No colors for plain text / bold / italic -- +just terminal styling. Headings are left-aligned. +""" + +from __future__ import annotations + +from typing import Any + +from markdown_it import MarkdownIt +from markdown_it.tree import SyntaxTreeNode +from rich.console import Group, RenderableType +from rich.syntax import Syntax +from rich.text import Text + + +INLINE_CODE_STYLE = "blue" +BLOCKQUOTE_BAR = "\u258e" + + +def render(content: str) -> RenderableType: + md = MarkdownIt("commonmark").enable("table") + tokens = md.parse(content) + tree = SyntaxTreeNode(tokens) + + blocks: list[RenderableType] = [] + for child in tree.children: + rendered = _render_block(child) + if rendered is not None: + blocks.append(rendered) + + if not blocks: + return Text("") + parts: list[RenderableType] = [blocks[0]] + for block in blocks[1:]: + parts.append(Text("")) + parts.append(block) + return Group(*parts) + + +def _render_block(node: Any) -> RenderableType | None: + t = node.type + if t == "heading": + depth = int(node.tag[1:]) + text = _render_inline_container(node) + if depth == 1: + text.stylize("bold italic underline") + else: + text.stylize("bold") + return text + if t == "paragraph": + return _render_inline_container(node) + if t == "fence": + lang = (node.info or "").strip().split()[0] if node.info else "" + return Syntax( + node.content.rstrip("\n"), + lang or "text", + theme="monokai", + background_color="default", + word_wrap=True, + ) + if t == "code_block": + return Syntax( + node.content.rstrip("\n"), + "text", + theme="monokai", + background_color="default", + word_wrap=True, + ) + if t == "hr": + return Text("---") + if t in ("bullet_list", "ordered_list"): + return _render_list(node, ordered=(t == "ordered_list"), depth=0) + if t == "blockquote": + return _render_blockquote(node) + if t == "table": + return _render_table(node) + return None + + +def _render_inline_container(node: Any) -> Text: + if not node.children: + return Text("") + inline = node.children[0] + out = Text() + for child in inline.children or []: + _append_inline(child, out) + return out + + +def _append_inline(node: Any, out: Text) -> None: + t = node.type + if t == "text": + out.append(node.content) + elif t == "softbreak": + out.append("\n") + elif t == "hardbreak": + out.append("\n") + elif t == "strong": + piece = Text() + for child in node.children or []: + _append_inline(child, piece) + piece.stylize("bold") + out.append_text(piece) + elif t == "em": + piece = Text() + for child in node.children or []: + _append_inline(child, piece) + piece.stylize("italic") + out.append_text(piece) + elif t == "code_inline": + out.append(node.content, style=INLINE_CODE_STYLE) + elif t == "link": + href = node.attrGet("href") or "" + piece = Text() + for child in node.children or []: + _append_inline(child, piece) + if href.startswith("mailto:"): + out.append(href[len("mailto:") :]) + return + if href: + plain = piece.plain + if plain and plain != href: + piece.stylize(f"link {href}") + out.append_text(piece) + else: + out.append(href, style=f"link {href}") + else: + out.append_text(piece) + elif t == "image": + href = node.attrGet("src") or "" + out.append(href) + elif t in ("html_inline", "html_block"): + return + else: + content = getattr(node, "content", "") + if content: + out.append(content) + + +def _render_list(node: Any, ordered: bool, depth: int) -> Text: + result = Text() + items = list(node.children) + start = 1 + if ordered: + try: + start = int(node.attrGet("start") or 1) + except (TypeError, ValueError): + start = 1 + + for i, item in enumerate(items): + indent = " " * depth + if ordered: + prefix = f"{_list_number(depth, start + i)}. " + else: + prefix = "- " + result.append(indent + prefix) + first = True + for child in item.children or []: + if child.type == "paragraph": + if not first: + result.append("\n" + indent + " ") + result.append_text(_render_inline_container(child)) + first = False + elif child.type in ("bullet_list", "ordered_list"): + result.append("\n") + result.append_text( + _render_list( + child, + ordered=(child.type == "ordered_list"), + depth=depth + 1, + ) + ) + else: + rendered = _render_block(child) + if rendered is None: + continue + if not first: + result.append("\n" + indent + " ") + if isinstance(rendered, Text): + result.append_text(rendered) + else: + result.append(str(rendered)) + first = False + if i < len(items) - 1: + result.append("\n") + return result + + +def _list_number(depth: int, n: int) -> str: + if depth == 0: + return str(n) + if depth == 1: + return _to_letters(n) + if depth == 2: + return _to_roman(n) + return str(n) + + +def _to_letters(n: int) -> str: + result = "" + while n > 0: + n -= 1 + result = chr(ord("a") + (n % 26)) + result + n //= 26 + return result or "a" + + +_ROMAN = [ + (1000, "m"), + (900, "cm"), + (500, "d"), + (400, "cd"), + (100, "c"), + (90, "xc"), + (50, "l"), + (40, "xl"), + (10, "x"), + (9, "ix"), + (5, "v"), + (4, "iv"), + (1, "i"), +] + + +def _to_roman(n: int) -> str: + out = "" + for value, numeral in _ROMAN: + while n >= value: + out += numeral + n -= value + return out + + +def _render_blockquote(node: Any) -> Text: + inner_blocks: list[Text] = [] + for child in node.children or []: + rendered = _render_block(child) + if isinstance(rendered, Text): + inner_blocks.append(rendered) + elif rendered is not None: + inner_blocks.append(Text(str(rendered))) + + combined = Text() + for i, block in enumerate(inner_blocks): + if i > 0: + combined.append("\n\n") + combined.append_text(block) + combined.stylize("italic") + + lines = combined.split("\n", allow_blank=True) + out = Text() + for i, line in enumerate(lines): + if i > 0: + out.append("\n") + if line.plain.strip(): + out.append(f"{BLOCKQUOTE_BAR} ", style="dim") + out.append_text(line) + else: + out.append_text(line) + return out + + +def _render_table(node: Any) -> Text: + header_row: list[Text] = [] + rows: list[list[Text]] = [] + aligns: list[str | None] = [] + + thead = next((c for c in node.children if c.type == "thead"), None) + tbody = next((c for c in node.children if c.type == "tbody"), None) + + if thead and thead.children: + tr = thead.children[0] + for th in tr.children or []: + header_row.append(_render_inline_container(th)) + aligns.append(th.attrGet("style")) + if tbody: + for tr in tbody.children or []: + row: list[Text] = [] + for td in tr.children or []: + row.append(_render_inline_container(td)) + rows.append(row) + + if not header_row: + return Text("") + + widths = [max(3, cell.cell_len) for cell in header_row] + for row in rows: + for i, cell in enumerate(row): + if i < len(widths): + widths[i] = max(widths[i], cell.cell_len) + + out = Text() + out.append("| ") + for i, cell in enumerate(header_row): + out.append_text(_pad(cell, widths[i], aligns[i] if i < len(aligns) else None)) + out.append(" | ") + out = _rstrip_trailing_space(out) + out.append("\n|") + for w in widths: + out.append("-" * (w + 2)) + out.append("|") + for row in rows: + out.append("\n| ") + for i, cell in enumerate(row): + width = widths[i] if i < len(widths) else cell.cell_len + align = aligns[i] if i < len(aligns) else None + out.append_text(_pad(cell, width, align)) + out.append(" | ") + out = _rstrip_trailing_space(out) + return out + + +def _pad(cell: Text, width: int, align: str | None) -> Text: + padding = max(0, width - cell.cell_len) + if not padding: + return cell + if align and "center" in align: + left = padding // 2 + right = padding - left + out = Text(" " * left) + out.append_text(cell) + out.append(" " * right) + return out + if align and "right" in align: + out = Text(" " * padding) + out.append_text(cell) + return out + out = Text() + out.append_text(cell) + out.append(" " * padding) + return out + + +def _rstrip_trailing_space(text: Text) -> Text: + plain = text.plain + stripped = plain.rstrip(" ") + trim = len(plain) - len(stripped) + if trim: + return text[: len(plain) - trim] + return text diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 791e270..b6bb421 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -190,34 +190,15 @@ def _make_prompt_session(session: ChatSession, style: Style, use_color: bool) -> def _make_rich_console() -> Any: - """Create a Rich Console with a Claude-Code-like Markdown theme.""" from rich.console import Console - from rich.theme import Theme - - theme = Theme({ - # Headings: bold with blue tint - "markdown.h1": "bold #5fa0e0", - "markdown.h2": "bold #5fa0e0", - "markdown.h3": "bold #7ab0e8", - "markdown.h4": "bold #8abae0", - # Code - "markdown.code": "#e8c87a on #1e1e1e", - # Links - "markdown.link": "underline #5fa0e0", - "markdown.link_url": "#5fa0e0", - # Emphasis - "markdown.bold": "bold #e0e0e0", - "markdown.italic": "italic #c0c0c0", - # Lists and block quotes - "markdown.item.bullet": "#6ac0a0", - "markdown.item.number": "#6ac0a0", - "markdown.block_quote": "italic #8a8a8a", - # Horizontal rule - "markdown.hr": "#4a4a4a", - # Paragraphs — ensure normal text is visible - "markdown.paragraph": "#d0d0d0", - }) - return Console(theme=theme) + + return Console() + + +def _make_markdown(text: str) -> Any: + from openkb.agent._markdown import render + + return render(text) async def _run_turn( @@ -245,7 +226,6 @@ async def _run_turn( if use_color: from rich.console import Console from rich.live import Live - from rich.markdown import Markdown console = _make_rich_console() else: @@ -278,7 +258,7 @@ def _start_live() -> Any: segment.append(text) last_was_text = True if live: - live.update(Markdown("".join(segment), code_theme="monokai")) + live.update(_make_markdown("".join(segment))) else: sys.stdout.write(text) sys.stdout.flush() diff --git a/openkb/agent/query.py b/openkb/agent/query.py index ee85ad6..8ba442a 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -125,8 +125,7 @@ async def run_query(question: str, kb_dir: Path, model: str, stream: bool = Fals if use_color: from rich.live import Live - from rich.markdown import Markdown - from openkb.agent.chat import _make_rich_console + from openkb.agent.chat import _make_markdown, _make_rich_console console = _make_rich_console() else: console = None # type: ignore[assignment] @@ -152,7 +151,7 @@ def _start_live() -> Live | None: collected.append(text) segment.append(text) if live: - live.update(Markdown("".join(segment), code_theme="monokai")) + live.update(_make_markdown("".join(segment))) else: sys.stdout.write(text) sys.stdout.flush() From 80e7e7a350fa29ee7cf5451510333a806f94cd2c Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 13 Apr 2026 18:29:53 +0800 Subject: [PATCH 09/11] Fix Live lifecycle bugs in query.py streaming - Drop eager Live restart after a tool call, mirroring the lazy pattern used in chat.py (commit 8708fc4 applied the fix only to chat.py) - Start Live inside the try block so a synchronous raise cannot leak it --- openkb/agent/query.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openkb/agent/query.py b/openkb/agent/query.py index 8ba442a..7eb79e0 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -137,12 +137,12 @@ def _start_live() -> Live | None: lv.start() return lv - live = _start_live() - + live: Live | None = None result = Runner.run_streamed(agent, question, max_turns=MAX_TURNS) collected: list[str] = [] segment: list[str] = [] try: + live = _start_live() async for event in result.stream_events(): if isinstance(event, RawResponsesStreamEvent): if isinstance(event.data, ResponseTextDeltaEvent): @@ -150,7 +150,9 @@ def _start_live() -> Live | None: if text: collected.append(text) segment.append(text) - if live: + if console is not None: + if live is None: + live = _start_live() live.update(_make_markdown("".join(segment))) else: sys.stdout.write(text) @@ -166,7 +168,6 @@ def _start_live() -> Live | None: sys.stdout.write(f"\n[tool call] {raw.name}({args})\n\n") sys.stdout.flush() segment = [] - live = _start_live() elif item.type == "tool_call_output_item": pass finally: From a9823fda19e1b7de61f80223046f2491b8f03837 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 13 Apr 2026 18:29:57 +0800 Subject: [PATCH 10/11] Fix list and blockquote rendering bugs - Preserve list item indent on multi-line paragraphs and block children - Render fenced/code blocks inside lists and blockquotes as plain text instead of leaking repr - Preserve mailto link text when it differs from the email address --- openkb/agent/_markdown.py | 41 ++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/openkb/agent/_markdown.py b/openkb/agent/_markdown.py index ff46879..e7e0a4b 100644 --- a/openkb/agent/_markdown.py +++ b/openkb/agent/_markdown.py @@ -118,7 +118,13 @@ def _append_inline(node: Any, out: Text) -> None: for child in node.children or []: _append_inline(child, piece) if href.startswith("mailto:"): - out.append(href[len("mailto:") :]) + email = href[len("mailto:") :] + plain = piece.plain + if plain and plain != email and plain != href: + piece.stylize(f"link {href}") + out.append_text(piece) + else: + out.append(email, style=f"link {href}") return if href: plain = piece.plain @@ -140,6 +146,18 @@ def _append_inline(node: Any, out: Text) -> None: out.append(content) +def _append_with_cont_indent(target: Text, source: Text, cont_indent: str) -> None: + lines = source.split("\n", allow_blank=True) + for i, line in enumerate(lines): + if i > 0: + target.append("\n" + cont_indent) + target.append_text(line) + + +def _render_code_as_text(node: Any) -> Text: + return Text(node.content.rstrip("\n"), style="dim") + + def _render_list(node: Any, ordered: bool, depth: int) -> Text: result = Text() items = list(node.children) @@ -152,6 +170,7 @@ def _render_list(node: Any, ordered: bool, depth: int) -> Text: for i, item in enumerate(items): indent = " " * depth + cont = indent + " " if ordered: prefix = f"{_list_number(depth, start + i)}. " else: @@ -161,8 +180,8 @@ def _render_list(node: Any, ordered: bool, depth: int) -> Text: for child in item.children or []: if child.type == "paragraph": if not first: - result.append("\n" + indent + " ") - result.append_text(_render_inline_container(child)) + result.append("\n" + cont) + _append_with_cont_indent(result, _render_inline_container(child), cont) first = False elif child.type in ("bullet_list", "ordered_list"): result.append("\n") @@ -173,16 +192,19 @@ def _render_list(node: Any, ordered: bool, depth: int) -> Text: depth=depth + 1, ) ) + elif child.type in ("fence", "code_block"): + if not first: + result.append("\n" + cont) + _append_with_cont_indent(result, _render_code_as_text(child), cont) + first = False else: rendered = _render_block(child) if rendered is None: continue if not first: - result.append("\n" + indent + " ") + result.append("\n" + cont) if isinstance(rendered, Text): - result.append_text(rendered) - else: - result.append(str(rendered)) + _append_with_cont_indent(result, rendered, cont) first = False if i < len(items) - 1: result.append("\n") @@ -237,11 +259,12 @@ def _to_roman(n: int) -> str: def _render_blockquote(node: Any) -> Text: inner_blocks: list[Text] = [] for child in node.children or []: + if child.type in ("fence", "code_block"): + inner_blocks.append(_render_code_as_text(child)) + continue rendered = _render_block(child) if isinstance(rendered, Text): inner_blocks.append(rendered) - elif rendered is not None: - inner_blocks.append(Text(str(rendered))) combined = Text() for i, block in enumerate(inner_blocks): From 88b61fc1b268cef67457e7bb395e762029002d9c Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 13 Apr 2026 18:40:57 +0800 Subject: [PATCH 11/11] Fix IndexError on whitespace-only fence info during streaming --- openkb/agent/_markdown.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openkb/agent/_markdown.py b/openkb/agent/_markdown.py index e7e0a4b..a64a5ee 100644 --- a/openkb/agent/_markdown.py +++ b/openkb/agent/_markdown.py @@ -53,7 +53,8 @@ def _render_block(node: Any) -> RenderableType | None: if t == "paragraph": return _render_inline_container(node) if t == "fence": - lang = (node.info or "").strip().split()[0] if node.info else "" + info_parts = (node.info or "").strip().split() + lang = info_parts[0] if info_parts else "" return Syntax( node.content.rstrip("\n"), lang or "text",