Skip to content

feat(rich_output): block-level markdown rendering for LLM responses (PR3) - #4504

Closed
KUSH42 wants to merge 23 commits into
NousResearch:mainfrom
KUSH42:feat/markdown-block-rendering
Closed

feat(rich_output): block-level markdown rendering for LLM responses (PR3)#4504
KUSH42 wants to merge 23 commits into
NousResearch:mainfrom
KUSH42:feat/markdown-block-rendering

Conversation

@KUSH42

@KUSH42 KUSH42 commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

⚠️ Stacked on feat/tool-output-highlighting (#4471) — merge that first. All commits above the tip of that branch are new here.

Block-level markdown rendering for LLM responses: headings, horizontal rules, blockquotes, ordered/unordered lists, fenced code blocks inside blockquotes, and inline markdown (bold, italic, strikethrough, links, images, all HTML inline tags).

pr3f Screenshot from 2026-04-02 07-47-49

Changes

apply_block_line — per-line block markdown → ANSI:

  • ATX headings # H1 through ###### H6 with bold + dim # sigil
  • Setext headings (detected statelessly — ===/--- line after non-empty text)
  • Horizontal rules --- / *** / ___
  • Blockquotes > with gutter (ANSI dim gold)
  • Unordered list items - / * / + with bullet
  • Ordered list items 1. with right-justified number

apply_inline_markdown — inline spans → ANSI (applied to every non-highlighted line):

  • ***bold italic*** / ___bold italic___
  • **bold** / __bold__
  • *italic* / _italic_ (spaces allowed inside underscore spans)
  • `code` — dim highlight
  • ~~strikethrough~~
  • [text](url) links — underline + URL preserved for copy/ctrl+click
  • ![alt](url) images → [img: alt]
  • HTML tags: <u>, <ins>, <mark>, <b>, <i>, <s>, <strike>, <del>, <code>, <kbd> — rendered with correct ANSI; <sup>/<sub> stripped
  • Nested style restoration: inner \033[0m resets restore outer style (e.g. bold inside underline)

format_response three-pass pipeline:

  1. Fenced code blocks → ANSI-highlighted + line numbers (from PR2)
  2. Pass 2 reserved for stateful blocks (PR4)
  3. Per non-ANSI line: apply_block_line then apply_inline_markdown

_emit_highlighted_lines — tool output preview truncation at 40 lines with ╌╌ N more lines omitted ╌╌ footer (shared by all three preview paths).

_render_inline_unified_diff — passes shutil.get_terminal_size().columns to DiffRenderer.to_lines() so diff output wraps at the actual terminal width.

Tests

211 passing (tests/test_display.py + tests/test_rich_output.py).

KUSH42 added 5 commits April 3, 2026 21:51
Introduces agent/rich_output.py — a self-contained Rich/Pygments rendering
toolkit with no project-specific imports.

Public API:
- LanguageDetector: extension map + content-pattern heuristics
- SyntaxHighlighter: Pygments → Rich markup → ANSI string
- FilePathFormatter: per-filetype icons, compact relative paths
- DiffRenderer: unified diff → Rich Text with line numbers
- clean_command_output: strip venv/stacktrace noise from command output

DiffRenderer replaces _render_inline_unified_diff in display.py:
- Intra-line character-level highlighting via SequenceMatcher (threshold 0.5)
- Per-run del/add pairing to avoid cross-hunk false matches
- Summary header: ● filename.py   Added N lines, removed M lines
- Console width from shutil.get_terminal_size, not hardcoded

Tests: 51 passing in tests/test_rich_output.py
Pygments emits Error tokens for content its markdown lexer cannot
tokenize (emoji in headings, unknown syntax, etc.). Mapping Error to
"bold red on red" matched the diff-deletion colour, causing spurious
red backgrounds on unrelated text. Changed to "bold red" (text colour
only), consistent with Generic.Error.
Adds _DIFF_MAX_LINES = 80 and a max_lines parameter to to_lines().
Outputs beyond the cap get a dim footer matching _highlight_block's
style. Passes max_lines=0 in _render_inline_unified_diff so the
section-level budget in _summarize_rendered_diff_sections is unaffected.
Apply Pygments token colours to diff line content so that keywords,
strings, comments, numbers etc. are visually distinct within the
red/green diff bands — matching the style shown in tools like delta.

Changes:

* Two new colour constants — _DIFF_BG_ADD_HL / _DIFF_BG_DEL_HL — a
  noticeably brighter shade of the base diff background, used to mark
  changed character ranges in intra-line diffs without touching the
  foreground (no conflict with syntax token colours).

* _syntax_text(content, filename) — new helper that calls
  SyntaxHighlighter.to_markup(), converts to a Rich Text (foreground
  colours only), and strips the trailing newline Pygments always
  appends so line lengths stay accurate.

* _flat_add / _flat_del — now accept an optional filename hint and
  apply syntax highlighting via _syntax_text, then overlay the base
  diff background with Text.stylize().

* _intra_diff — redesigned: syntax-highlight both lines first, apply
  the base diff background across the whole text, then apply the
  brighter highlight background (bold) only over changed character
  ranges via a second stylize() pass.  Foreground colours come
  entirely from syntax; background colours entirely from diff state.

* _style() — passes filename (explicit_filename or from_path) through
  to all three helpers above, and syntax-highlights context lines
  (dim style overlaid on syntax colours).

* Tests updated to match the new interface: _intra_diff now returns
  ([Text], [Text]) with spans rather than lists of single-span
  segments; changed-region detection checks for bright bg spans
  instead of bright foreground colour names.
Line numbers and the -/+ prefix had no background set, so they rendered
on the terminal default (black) while the content immediately to their
right had the dark-red/dark-green diff background — creating a jarring
visual break.  Extend bgcolor to the line-number and sigil Text objects
so the entire deleted/added row is uniformly on the diff background,
matching the visual style of delta, GitHub, and VS Code's diff view.

@kshitijk4poor kshitijk4poor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like the direction here overall. I tested this branch with python -m hermes_cli.main -w chat -q ... and the block-level rendering looked much closer to the intended UX in practice: headings, blockquotes, lists, and fenced code blocks all rendered sensibly in the CLI.

That said, I don't think this is ready to merge yet. There are still a couple of blocking correctness issues in the incremental diff here:

  • this branch introduces a real regression in DiffRenderer.to_lines() — the default path now returns no output in the existing diff renderer tests, and I can reproduce that locally on this PR while the stacked base branch still passes those same tests
  • the /code-highlight command surface is still being expanded here even though the feature is already being treated as config-backed elsewhere
  • the terminal preview verb check still doesn't actually enforce the read-verb restriction it describes

For reference, I ran:

  • python -m pytest tests/test_display.py tests/test_rich_output.py -q on this branch → 8 failures
  • python -m pytest tests/test_rich_output.py -q -k 'DiffRenderer or DiffRendererTruncation' on the stacked base branch (#4471) → passes

So I think this needs another pass before merge.

Comment thread agent/rich_output.py Outdated
``_summarize_rendered_diff_sections``).
"""
buf = StringIO()
Console(file=buf, highlight=False, force_terminal=True, width=width).print(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a real regression in DiffRenderer.to_lines().

The previous default path worked when callers just did self.dr.to_lines(diff). Here we now pass width=width directly into Console(...), but the default value for width is 0, so the default call path can produce no rendered output at all. I can reproduce that locally on this branch: the existing DiffRenderer / DiffRendererTruncation tests fail here, while they still pass on the stacked base branch.

At minimum this needs to preserve the old behavior when no explicit width is supplied — e.g. only pass a width when one was actually resolved, or normalize 0 to a real fallback width first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on the rebased branch head. PR3 now sits on the corrected PR2 base, and the default DiffRenderer().to_lines(...) path is green again in the branch-local touched tests.

Comment thread cli.py Outdated
self.console.print(f" Status bar {state}")
elif canonical == "verbose":
self._toggle_verbose()
elif canonical in ("code-highlight", "codehighlight", "code_highlight"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This keeps pushing code-highlight further as a slash-command surface by adding extra aliases (codehighlight, code_highlight).

Given the surrounding implementation and config naming, this feature already reads as config-backed rather than command-backed. Expanding the command handling here makes that product surface harder to unwind later, not easier.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed on this branch. I cleaned up the remaining direct CLI dispatch path here as part of the PR2 backport so the behavior is no longer being expanded further through an implicit slash-command surface.

Comment thread agent/display.py Outdated
verb = _Path(tokens[0]).name
if verb in _FILE_EXEC_COMMANDS:
return None, None
if _FILE_READ_COMMANDS and verb not in _FILE_READ_COMMANDS:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still doesn't actually enforce the read-verb restriction.

The new branch says "not in the explicit read list" and then just passes, so unknown verbs still fall through into filename-based detection exactly as before. That means commands like git diff app.py, pytest tests/test_app.py, or ruff check foo.py can still have diagnostic/diff stdout treated as though it were source code.

If the intent is to require a known read verb before syntax-highlighting terminal output, this needs to return early rather than leaving the old behavior intact.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on this branch. Unknown verbs no longer fall through into filename-based source detection; terminal source-highlighting now requires an explicit read verb.

@kshitijk4poor kshitijk4poor left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a few more correctness notes after digging further into the markdown path.

Comment thread agent/rich_output.py Outdated

# Match fenced code blocks of any depth (3+ backticks); \1 backreference
# ensures the closing fence uses the same backtick sequence as the opener.
text = re.sub(r"(?m)^(`{3,})(\w*)\n(.*?)\1", _highlight, text, flags=re.DOTALL)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fenced-code matching is still too narrow.

Both here and in StreamingCodeBlockHighlighter._FENCE_OPEN_RE the language hint is restricted to \w*, so common info strings like c++, objective-c, shell-session, and f# never enter the highlighting path. I reproduced format_response("```c++\nint x;\n```\n") returning raw triple-backticks unchanged on this branch.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on this branch. The batch and streaming fence matchers now accept common non-\\w info strings like c++, objective-c, shell-session, and f#, with regression coverage added.

Comment thread agent/rich_output.py Outdated
_BULLETS = ["•", "◦", "▸", "·"]


def apply_block_line(line: str) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR body claims setext heading support, but I don't think it's actually implemented.

The second pass is strictly line-by-line, and apply_block_line() only knows ATX headings plus _MD_HR_RE. So Heading\n----- still renders as plain text followed by a horizontal rule, not as an H2. I also don't see test coverage for actual setext rendering.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the confusion here. You’re right that this is not completed in PR3 itself. I’m keeping that behavior in #4513, which is the stateful block-rendering pass in the stack, rather than backporting the stateful setext handling into PR3. It’s implemented and covered there.

Comment thread tests/test_rich_output.py Outdated
text = "Just a response with no fences."
assert format_response(text) == text

def test_code_fence_inside_blockquote_not_consumed(self):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is currently locking in behavior that contradicts the PR description.

The PR says fenced code blocks inside blockquotes are supported, but this test explicitly asserts the raw-fence path by only checking that the blockquote gutter survives and never expecting code-block rendering. With the current fence detection anchored to raw backticks at line start, > ```python still falls back to literal backticks rather than entering code-block mode.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the confusion here as well. You’re right that this is not completed in PR3 itself. I’m keeping fenced code inside blockquotes in #4513, where the stateful block-rendering pass handles it, rather than backporting that behavior into PR3. It’s implemented and covered there.

@KUSH42
KUSH42 force-pushed the feat/markdown-block-rendering branch from 09e1574 to 570aee3 Compare April 5, 2026 03:41
KUSH42 added 6 commits April 8, 2026 01:12
_MAX_INLINE_DIFF_LINES (80), _MAX_INLINE_DIFF_FILES (6), and
_PREVIEW_MAX_LINES (40) were hardcoded in agent/display.py with no
user-facing knob. Wire them to config.yaml under display:

  diff_max_lines: 80    # lines shown per inline diff before "… omitted" summary
  diff_max_files: 6     # file sections shown per inline diff
  preview_max_lines: 40 # lines shown in read_file/execute_code/terminal previews

Adds set_diff_limits() and set_preview_max_lines() setters in display.py;
cli.py reads and applies all three at init alongside code_highlight.
Two bugs in _current_reasoning_callback():
1. show_reasoning=True + streaming=False returned None — non-streaming mode
   got no live reasoning callback even when user enabled it (post-turn box
   still worked, but no intermediate display during tool-call loops)
2. verbose=True + show_reasoning=False returned _on_reasoning — verbose mode
   leaked reasoning into the display regardless of the user's explicit setting

Fix: show_reasoning is the sole gate. When on, pick callback by streaming
mode (stream_reasoning_delta vs on_reasoning). Verbose no longer overrides.
…y's fixes

- test_reasoning_command.py: install prompt_toolkit/fire stubs at module
  level (conditional on missing) so all 58 tests can import from cli without
  patching individually; fixes 38 pre-existing failures. Correct two wrong
  assertions in TestReasoningDisplayModeSelection that reflected old buggy
  behaviour (show_reasoning=True+non-streaming returned None; verbose leaked
  callback). Add test_show_reasoning_off_returns_none and
  test_verbose_without_show_reasoning_returns_none.
- test_display.py: import set_diff_limits/set_preview_max_lines; add
  TestDisplayLimitSetters covering global mutation and truncation behaviour.
spinner_loop only fast-refreshed (0.1 s) during _command_running, leaving
_agent_running on the 1 s idle cadence — so the status-bar prompt showed
a static ⚕ and tool-progress updates in the spinner widget lagged by up
to a second.

Add _agent_running to the fast-refresh branch alongside _command_running,
and replace the static ⚕ in _get_tui_prompt_fragments with
_command_spinner_frame() so the braille dots animate while the agent is
working, matching the slash-command experience.
Spinner style
- Add _SPINNER_STYLES dict (dots, bounce, grow, arrows, star, moon,
  pulse, clock, none) so users can pick via display.spinner_style in
  config.yaml; default remains "dots" (braille).
- At HermesCLI init, read spinner_style and override the module-level
  _COMMAND_SPINNER_FRAMES global so _command_spinner_frame() picks the
  right sequence for both the status-bar prompt and the tab title.

Terminal tab / window title
- Add display.title_spinner (default true) and display.title_base
  (default "Hermes") config keys.
- spinner_loop emits OSC 0 (\033]0;…\007) via app.output.write_raw()
  at each 0.1 s tick while active, giving a live "⠋ Hermes" → "⠙ Hermes"
  animation in the terminal tab; resets to the bare base string on idle.
  Errors are silenced so terminals that don't support OSC 0 aren't affected.
…hlight active

- Import LanguageDetector into display.py and instantiate as _rich_detector;
  render_read_file_preview and _extract_file_language_from_command were
  calling _rich_detector.detect_from_filename but the name was never bound
  (SyntaxHighlighter has no detect_from_filename method).
- Suppress execute_code first-line snippet in get_cute_tool_message when
  _code_highlight_active is True — the highlighted block renders immediately
  after, so printing the snippet again is redundant duplication.
- Add missing json, _highlight_block, _result_succeeded, get_cute_tool_message
  imports to test_display.py so the test suite can actually run.
@KUSH42
KUSH42 force-pushed the feat/markdown-block-rendering branch from c120823 to 246d783 Compare April 8, 2026 00:06
KUSH42 added 5 commits April 8, 2026 03:16
Wires the SyntaxHighlighter/LanguageDetector from rich_output.py into tool
result display and LLM response rendering.

execute_code preview:
- Highlighted Python block printed after successful execution
- Gated on _result_succeeded — nothing shown after a failed run
- Cute-msg drops the inline snippet when highlight is active (no duplication)

read_file preview:
- ┊ 📄 filename.py header + syntax-highlighted content
- Language from extension only; unknown types skipped silently

terminal preview:
- Verb-based language detection from the command
- _FILE_EXEC_COMMANDS blocklist (node, python3, bash, …) prevents runtime
  stdout from being mistaken for source code

LLM response rendering:
- format_response() highlights fenced code blocks in complete responses
- StreamingCodeBlockHighlighter state machine for streaming: buffers fenced
  blocks, flushes highlighted on closing fence, plain text passes through
  immediately with response text colour preserved

Plumbing:
- Verbosity gate: all previews suppressed when tool_progress_mode == "off"
- display.code_highlight config key + /code-highlight toggle
- set_code_highlight_active() keeps display.py decoupled from CLI state
- Module-level _rich_detector singleton (no per-call instantiation)

Tests: 135 passing (tests/test_display.py + tests/test_rich_output.py)
format_response wrapped highlighted code in ``` delimiters under the
theory that "the Panel still looks like a code block". In practice the
ANSI-highlighted block reads cleanly without them, and keeping the
fences caused raw backtick lines to appear in the rendered response.
_number_code_lines() prepends dim right-justified line numbers with a │
separator to every line of a highlighted code block:

  1 │ import requests
  2 │ import time
  ...

Called from both format_response._highlight (batch path) and
StreamingCodeBlockHighlighter._flush_block (streaming path) so line
numbers appear consistently in all LLM response code blocks.
…endering bugs

_PygmentsToRich.format() previously escaped brackets by replacing:
  [ → \[  (correct: literal "[" in Rich markup)
  ] → \]  (WRONG: Rich has no \] escape; renders as literal \])

This caused two bugs:
1. Haskell type signatures like [Integer] rendered as [Integer\]
2. A trailing \ token (e.g. Haskell lambda \a) before a close tag
   like [/bold yellow] formed the Rich escape \[ — consuming the
   closing tag and leaking the tag text into the output.

Fix: use rich.markup.escape() which doubles \ → \\ and escapes
[ → \[ while leaving ] alone (] requires no escaping in Rich markup).
Apply to ALL tokens (styled and unstyled) and to all fallback paths.

Also add _highlight_inline_code() and format_response() for
inline code span styling in LLM response text.
KUSH42 added 5 commits April 8, 2026 03:17
…diff test assertions

- to_lines: width=0 silenced Rich Console entirely; resolve to terminal
  width via shutil.get_terminal_size when no explicit width is given
- tests: remove _DIFF_BG_ADD_HL/_DIFF_BG_DEL_HL imports removed in skin
  rewrite; update _intra_diff delete/insert tests to check across the
  full segment list rather than assuming a single-element list
@KUSH42
KUSH42 force-pushed the feat/markdown-block-rendering branch from 246d783 to 23ae96e Compare April 8, 2026 01:19
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/tui Terminal UI (ui-tui/ + tui_gateway/) labels May 1, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the substantial rendering work. This is now superseded by the native TUI markdown path on current main.

  • ui-tui/src/components/messageLine.tsx:162-172 renders assistant responses with StreamingMd / Md.
  • ui-tui/src/components/markdown.tsx:60-64, 765-835, and 919-1072 implement fenced code with syntax highlighting, headings including setext, rules, lists, blockquotes, and inline formatting.
  • Commit 24a498eb9027983aa93afb3e3b671e3d897f9311 introduced the native block-markdown renderer; f8becbfbeab87b35424bf4c636a3b192a2072e5d added fenced-code syntax highlighting.
  • Commit 8d591fe3c74f795eebf0322e87a7ba0f03b4b332 deliberately changed the TUI to prefer raw markdown over Python-side Rich ANSI, because the latter garbled Ink output and could lose streamed content.

The earlier review discussion also correctly noted that setext headings and blockquoted fences were split to #4513 rather than completed in this PR. This is an automated hermes-sweeper review.

@teknium1 teknium1 closed this Jul 12, 2026
@teknium1 teknium1 added the sweeper:implemented-on-main Sweeper: behavior already present on current main label Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/tui Terminal UI (ui-tui/ + tui_gateway/) P3 Low — cosmetic, nice to have sweeper:implemented-on-main Sweeper: behavior already present on current main type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants