From e1d5b238a38b4cfca99c22346286ea454dd7832c Mon Sep 17 00:00:00 2001 From: mountain Date: Sun, 12 Apr 2026 09:31:50 +0800 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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", From b2f668d8eed1c1ba1bc637f3c3b98cecade7bd3e Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 13 Apr 2026 19:08:11 +0800 Subject: [PATCH 12/17] Reduce streaming markdown parse cost - Hoist MarkdownIt to a module-level singleton instead of rebuilding it on every call to render() - Re-render the Live region only when a text delta contains a newline, using the content up to the last newline as the visible snapshot. Flush the full segment once before stopping Live around tool calls and in the finally block so the trailing partial line is not lost --- openkb/agent/_markdown.py | 6 ++++-- openkb/agent/chat.py | 10 +++++++++- openkb/agent/query.py | 10 +++++++++- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/openkb/agent/_markdown.py b/openkb/agent/_markdown.py index a64a5ee..5cd65ae 100644 --- a/openkb/agent/_markdown.py +++ b/openkb/agent/_markdown.py @@ -20,9 +20,11 @@ BLOCKQUOTE_BAR = "\u258e" +_MD = MarkdownIt("commonmark").enable("table") + + def render(content: str) -> RenderableType: - md = MarkdownIt("commonmark").enable("table") - tokens = md.parse(content) + tokens = _MD.parse(content) tree = SyntaxTreeNode(tokens) blocks: list[RenderableType] = [] diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index b6bb421..50f3ff2 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -258,7 +258,11 @@ def _start_live() -> Any: segment.append(text) last_was_text = True if live: - live.update(_make_markdown("".join(segment))) + if "\n" in text: + joined = "".join(segment) + visible = joined[: joined.rfind("\n") + 1] + if visible: + live.update(_make_markdown(visible)) else: sys.stdout.write(text) sys.stdout.flush() @@ -267,6 +271,8 @@ def _start_live() -> Any: if item.type == "tool_call_item": if last_was_text: if live: + if segment: + live.update(_make_markdown("".join(segment))) live.stop() live = None else: @@ -283,6 +289,8 @@ def _start_live() -> Any: need_blank_before_text = True finally: if live: + if segment: + live.update(_make_markdown("".join(segment))) live.stop() print() diff --git a/openkb/agent/query.py b/openkb/agent/query.py index 7eb79e0..1bbb7a6 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -153,7 +153,11 @@ def _start_live() -> Live | None: if console is not None: if live is None: live = _start_live() - live.update(_make_markdown("".join(segment))) + if "\n" in text: + joined = "".join(segment) + visible = joined[: joined.rfind("\n") + 1] + if visible: + live.update(_make_markdown(visible)) else: sys.stdout.write(text) sys.stdout.flush() @@ -163,6 +167,8 @@ def _start_live() -> Live | None: raw = item.raw_item args = getattr(raw, "arguments", "{}") if live: + if segment: + live.update(_make_markdown("".join(segment))) live.stop() live = None sys.stdout.write(f"\n[tool call] {raw.name}({args})\n\n") @@ -172,6 +178,8 @@ def _start_live() -> Live | None: pass finally: if live: + if segment: + live.update(_make_markdown("".join(segment))) live.stop() print() return "".join(collected) if collected else result.final_output or "" From 6c44a5e9aacf02d507ca04c3f3adcad5a6ff2e31 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 13 Apr 2026 19:27:46 +0800 Subject: [PATCH 13/17] Align query tool-call line styling with chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `[tool call] name(args)` written via bare sys.stdout.write with chat's `_format_tool_line` (truncated to 78 chars, marker `ยท`) printed through `_fmt` with the `class:tool` style, so the tool-call line inherits the same color as in chat. Build the prompt-toolkit Style from chat's `_build_style` based on use_color. --- openkb/agent/query.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/openkb/agent/query.py b/openkb/agent/query.py index 1bbb7a6..faac044 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -123,9 +123,18 @@ async def run_query(question: str, kb_dir: Path, model: str, stream: bool = Fals import os use_color = sys.stdout.isatty() and not os.environ.get("NO_COLOR", "") + from openkb.agent.chat import ( + _build_style, + _fmt, + _format_tool_line, + _make_markdown, + _make_rich_console, + ) + + style = _build_style(use_color) + if use_color: from rich.live import Live - from openkb.agent.chat import _make_markdown, _make_rich_console console = _make_rich_console() else: console = None # type: ignore[assignment] @@ -164,14 +173,18 @@ def _start_live() -> Live | None: elif isinstance(event, RunItemStreamEvent): item = event.item if item.type == "tool_call_item": - raw = item.raw_item - args = getattr(raw, "arguments", "{}") + raw_item = item.raw_item + name = getattr(raw_item, "name", "?") + args = getattr(raw_item, "arguments", "") or "" if live: if segment: live.update(_make_markdown("".join(segment))) live.stop() live = None - sys.stdout.write(f"\n[tool call] {raw.name}({args})\n\n") + sys.stdout.write("\n") + sys.stdout.flush() + _fmt(style, ("class:tool", _format_tool_line(name, args) + "\n")) + sys.stdout.write("\n") sys.stdout.flush() segment = [] elif item.type == "tool_call_output_item": From bf640a0b922748b2713eb8d440240ee9a987fdf3 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 13 Apr 2026 19:28:46 +0800 Subject: [PATCH 14/17] Add --raw flag to chat and query When set, skips Rich Live + markdown rendering and writes the model's markdown source directly to stdout. Other styling is preserved: the chat prompt remains colored and the tool-call line keeps its `class:tool` style. This is distinct from --no-color / NO_COLOR, which strips all coloring. --- openkb/agent/chat.py | 7 ++++--- openkb/agent/query.py | 16 +++++++++++++--- openkb/cli.py | 18 ++++++++++++++---- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 50f3ff2..c07295f 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -203,7 +203,7 @@ def _make_markdown(text: str) -> Any: async def _run_turn( agent: Any, session: ChatSession, user_input: str, style: Style, - *, use_color: bool = True, + *, use_color: bool = True, raw: bool = False, ) -> None: """Run one agent turn with streaming output and persist the new history.""" from agents import ( @@ -223,7 +223,7 @@ async def _run_turn( last_was_text = False need_blank_before_text = False - if use_color: + if use_color and not raw: from rich.console import Console from rich.live import Live @@ -377,6 +377,7 @@ async def run_chat( session: ChatSession, *, no_color: bool = False, + raw: bool = False, ) -> None: """Run the chat REPL against ``session`` until the user exits.""" from openkb.config import load_config @@ -429,7 +430,7 @@ async def run_chat( append_log(kb_dir / "wiki", "query", user_input) try: - await _run_turn(agent, session, user_input, style, use_color=use_color) + await _run_turn(agent, session, user_input, style, use_color=use_color, raw=raw) except KeyboardInterrupt: _fmt(style, ("class:error", "\n[aborted]\n")) except Exception as exc: diff --git a/openkb/agent/query.py b/openkb/agent/query.py index faac044..b02cfd0 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -91,7 +91,14 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText: ) -async def run_query(question: str, kb_dir: Path, model: str, stream: bool = False) -> str: +async def run_query( + question: str, + kb_dir: Path, + model: str, + stream: bool = False, + *, + raw: bool = False, +) -> str: """Run a Q&A query against the knowledge base. Args: @@ -99,6 +106,8 @@ async def run_query(question: str, kb_dir: Path, model: str, stream: bool = Fals kb_dir: Root of the knowledge base. model: LLM model name. stream: If True, print response tokens to stdout as they arrive. + raw: If True, write raw markdown source instead of rendering it + (still keeps tool-call line styling). Returns: The agent's final answer as a string. @@ -133,8 +142,9 @@ async def run_query(question: str, kb_dir: Path, model: str, stream: bool = Fals style = _build_style(use_color) - if use_color: - from rich.live import Live + from rich.live import Live + + if use_color and not raw: console = _make_rich_console() else: console = None # type: ignore[assignment] diff --git a/openkb/cli.py b/openkb/cli.py index d91789f..1336e23 100644 --- a/openkb/cli.py +++ b/openkb/cli.py @@ -360,8 +360,13 @@ def add(ctx, path): @cli.command() @click.argument("question") @click.option("--save", is_flag=True, default=False, help="Save the answer to wiki/explorations/.") +@click.option( + "--raw", "raw", + is_flag=True, default=False, + help="Show raw markdown source instead of rendered output (keeps tool-call colors).", +) @click.pass_context -def query(ctx, question, save): +def query(ctx, question, save, raw): """Query the knowledge base with QUESTION.""" kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override")) if kb_dir is None: @@ -376,7 +381,7 @@ def query(ctx, question, save): model: str = config.get("model", DEFAULT_CONFIG["model"]) try: - answer = asyncio.run(run_query(question, kb_dir, model, stream=True)) + answer = asyncio.run(run_query(question, kb_dir, model, stream=True, raw=raw)) except Exception as exc: click.echo(f"[ERROR] Query failed: {exc}") return @@ -416,8 +421,13 @@ def query(ctx, question, save): is_flag=True, default=False, help="Disable colored output.", ) +@click.option( + "--raw", "raw", + is_flag=True, default=False, + help="Show raw markdown source instead of rendered output (keeps prompt and tool-call colors).", +) @click.pass_context -def chat(ctx, resume, list_sessions_flag, delete_id, no_color): +def chat(ctx, resume, list_sessions_flag, delete_id, no_color, raw): """Start an interactive chat with the knowledge base.""" kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override")) if kb_dir is None: @@ -491,7 +501,7 @@ def chat(ctx, resume, list_sessions_flag, delete_id, no_color): from openkb.agent.chat import run_chat try: - asyncio.run(run_chat(kb_dir, session, no_color=no_color)) + asyncio.run(run_chat(kb_dir, session, no_color=no_color, raw=raw)) except Exception as exc: click.echo(f"[ERROR] Chat failed: {exc}") From 497ec2fa4c96211e2b582ab407d9349637b18fcd Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 13 Apr 2026 19:56:21 +0800 Subject: [PATCH 15/17] Rename raw to raw_item in chat tool-call branch Avoid shadowing the new raw: bool parameter, matching query.py. --- openkb/agent/chat.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index c07295f..bd67c62 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -279,9 +279,9 @@ def _start_live() -> Any: sys.stdout.write("\n") sys.stdout.flush() last_was_text = False - raw = item.raw_item - name = getattr(raw, "name", "?") - args = getattr(raw, "arguments", "") or "" + raw_item = item.raw_item + name = getattr(raw_item, "name", "?") + args = getattr(raw_item, "arguments", "") or "" if live: live.stop() live = None From 51dc5326e2897d82189fc8b4f8aba53955ed9846 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 13 Apr 2026 20:12:28 +0800 Subject: [PATCH 16/17] Align query blank-line handling with chat Adopt the same last_was_text / need_blank_before_text state machine used in chat.py so tool-call separator blank lines are written lazily. This removes the extra blank line above tool lines and avoids a stray blank between consecutive tool calls or at the end of a turn. --- openkb/agent/query.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/openkb/agent/query.py b/openkb/agent/query.py index b02cfd0..762c314 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -157,6 +157,8 @@ def _start_live() -> Live | None: return lv live: Live | None = None + last_was_text = False + need_blank_before_text = False result = Runner.run_streamed(agent, question, max_turns=MAX_TURNS) collected: list[str] = [] segment: list[str] = [] @@ -167,11 +169,18 @@ def _start_live() -> Live | None: if isinstance(event.data, ResponseTextDeltaEvent): text = event.data.delta if text: + if need_blank_before_text: + 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) - if console is not None: - if live is None: - live = _start_live() + last_was_text = True + if live: if "\n" in text: joined = "".join(segment) visible = joined[: joined.rfind("\n") + 1] @@ -183,20 +192,24 @@ def _start_live() -> Live | None: elif isinstance(event, RunItemStreamEvent): item = event.item if item.type == "tool_call_item": + if last_was_text: + if live: + if segment: + live.update(_make_markdown("".join(segment))) + live.stop() + live = None + else: + sys.stdout.write("\n") + sys.stdout.flush() + last_was_text = False raw_item = item.raw_item name = getattr(raw_item, "name", "?") args = getattr(raw_item, "arguments", "") or "" if live: - if segment: - live.update(_make_markdown("".join(segment))) live.stop() live = None - sys.stdout.write("\n") - sys.stdout.flush() _fmt(style, ("class:tool", _format_tool_line(name, args) + "\n")) - sys.stdout.write("\n") - sys.stdout.flush() - segment = [] + need_blank_before_text = True elif item.type == "tool_call_output_item": pass finally: From 3f1c6764004cfeefc0afd6e68b0d876c048cec11 Mon Sep 17 00:00:00 2001 From: Ray Date: Mon, 13 Apr 2026 20:17:37 +0800 Subject: [PATCH 17/17] Preserve HTML in markdown renderer --- openkb/agent/_markdown.py | 4 +++- tests/test_markdown_renderer.py | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/test_markdown_renderer.py diff --git a/openkb/agent/_markdown.py b/openkb/agent/_markdown.py index 5cd65ae..6fa96e3 100644 --- a/openkb/agent/_markdown.py +++ b/openkb/agent/_markdown.py @@ -80,6 +80,8 @@ def _render_block(node: Any) -> RenderableType | None: return _render_blockquote(node) if t == "table": return _render_table(node) + if t == "html_block": + return Text(node.content.rstrip("\n")) return None @@ -142,7 +144,7 @@ def _append_inline(node: Any, out: Text) -> None: href = node.attrGet("src") or "" out.append(href) elif t in ("html_inline", "html_block"): - return + out.append(node.content) else: content = getattr(node, "content", "") if content: diff --git a/tests/test_markdown_renderer.py b/tests/test_markdown_renderer.py new file mode 100644 index 0000000..3cbe80e --- /dev/null +++ b/tests/test_markdown_renderer.py @@ -0,0 +1,38 @@ +from rich.console import Group +from rich.text import Text + +from openkb.agent._markdown import render + + +def _group_text(renderable: Group) -> list[str]: + return [part.plain for part in renderable.renderables if isinstance(part, Text)] + + +def test_render_preserves_inline_html(): + rendered = render("hello
world") + + assert isinstance(rendered, Group) + assert _group_text(rendered) == ["hello
world"] + + +def test_render_preserves_inline_html_tags(): + rendered = render("H2O and x2") + + assert isinstance(rendered, Group) + assert _group_text(rendered) == ["H2O and x2"] + + +def test_render_preserves_html_block(): + rendered = render("
\nMore\nHidden text\n
") + + assert isinstance(rendered, Group) + assert _group_text(rendered) == [ + "
\nMore\nHidden text\n
", + ] + + +def test_render_keeps_html_block_between_paragraphs(): + rendered = render("before\n\n
hello
\n\nafter") + + assert isinstance(rendered, Group) + assert _group_text(rendered) == ["before", "", "
hello
", "", "after"]