Skip to content

Commit b142cfe

Browse files
committed
Add StreamingRenderer for unified markdown rendering in md2term
Introduce a new StreamingRenderer class to handle both streaming and non-streaming markdown rendering with minimal flickering. Update process_stream and process_character_stream functions to utilize the new renderer, allowing for character-by-character streaming with backtracking support. Enhance CLI options to include a --stream flag for character streaming mode. Update project documentation to reflect these changes and usage instructions.
1 parent e65d381 commit b142cfe

File tree

2 files changed

+222
-42
lines changed

2 files changed

+222
-42
lines changed

.cursor/rules/project-overview.mdc

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ md2term/
3535
# Setup
3636
uv sync --all-extras
3737

38-
# Run the tool
38+
# Run the tool (DO NOT use py_compile - just run md2term directly)
39+
md2term [file] # Direct execution (preferred)
40+
echo "text" | md2term # Pipe input
41+
md2term --help # Show help
42+
md2term --stream # Character streaming mode
43+
44+
# Alternative: run via uv (if not installed globally)
3945
uv run md2term [file]
4046
echo "text" | uv run md2term
4147
uv run md2term --help
@@ -67,6 +73,12 @@ uv build
6773
4. **Update version** in both `md2term.py` and `pyproject.toml`
6874
5. **Update CHANGELOG.md** for notable changes
6975

76+
## Important Notes
77+
78+
- **DO NOT run `py_compile`** - it's not needed and not part of our workflow
79+
- **Use `md2term` directly** for running the tool (preferred method)
80+
- **Use `uv run md2term`** only if not installed globally
81+
7082
## CLI Interface Rules
7183

7284
- Use Click decorators for all CLI functionality

md2term.py

Lines changed: 209 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import os
77
import shutil
88
import re
9+
import time
910
from typing import Optional, TextIO, List, Dict, Any
1011
from io import StringIO
1112
import click
@@ -210,6 +211,132 @@ def _render_inline_tokens(self, tokens: List[Dict[str, Any]]) -> Text:
210211
return text
211212

212213

214+
class StreamingRenderer:
215+
"""Unified renderer that handles both streaming and non-streaming with minimal flickering."""
216+
217+
def __init__(self, console: Console):
218+
self.console = console
219+
self.buffer = ""
220+
self.last_output_lines = 0
221+
self.last_was_incomplete = False
222+
223+
# Patterns for detecting incomplete markdown syntax
224+
self.incomplete_patterns = [
225+
r'\*[^*\n]*$', # Incomplete bold/italic (single *)
226+
r'\*\*[^*\n]*$', # Incomplete bold (**)
227+
r'_[^_\n]*$', # Incomplete italic (single _)
228+
r'__[^_\n]*$', # Incomplete italic (__)
229+
r'`[^`\n]*$', # Incomplete inline code
230+
r'\[[^\]\n]*$', # Incomplete link text
231+
r'\]\([^)\n]*$', # Incomplete link URL
232+
r'^#{1,6}\s*[^\n]*$', # Incomplete heading (no newline yet)
233+
]
234+
235+
def add_text(self, text: str) -> None:
236+
"""Add new text to the buffer and re-render if needed."""
237+
self.buffer += text
238+
self._render_if_needed()
239+
240+
def render_complete(self, text: str) -> None:
241+
"""Render complete text (for non-streaming mode)."""
242+
self.buffer = text
243+
self._render_markdown()
244+
245+
def _render_if_needed(self) -> None:
246+
"""Only re-render when transitioning between incomplete and complete states."""
247+
is_incomplete = self._has_incomplete_syntax()
248+
249+
# Only re-render if:
250+
# 1. We transition from incomplete to complete (backtracking)
251+
# 2. We have complete syntax and this is the first render
252+
# 3. We end with newline (paragraph completion)
253+
should_render = (
254+
(self.last_was_incomplete and not is_incomplete) or # Transition to complete
255+
(not is_incomplete and self.last_output_lines == 0) or # First complete render
256+
self.buffer.endswith('\n') # Paragraph completion
257+
)
258+
259+
if should_render:
260+
if is_incomplete:
261+
self._render_plain_text()
262+
else:
263+
self._render_markdown()
264+
265+
self.last_was_incomplete = is_incomplete
266+
267+
def _has_incomplete_syntax(self) -> bool:
268+
"""Check if the buffer contains incomplete markdown syntax."""
269+
# Don't treat as incomplete if we end with whitespace or newline
270+
if self.buffer.endswith(('\n', ' ', '\t')):
271+
return False
272+
273+
# Get the current line being typed
274+
lines = self.buffer.split('\n')
275+
current_line = lines[-1] if lines else ""
276+
277+
# Check for incomplete patterns
278+
for pattern in self.incomplete_patterns:
279+
if re.search(pattern, current_line):
280+
return True
281+
282+
# Special case: check for incomplete code blocks
283+
code_block_count = self.buffer.count('```')
284+
if code_block_count % 2 == 1: # Odd number means incomplete code block
285+
return True
286+
287+
return False
288+
289+
def _render_plain_text(self) -> None:
290+
"""Render the buffer as plain text."""
291+
self._clear_previous_output()
292+
self.console.print(self.buffer, end="")
293+
self.last_output_lines = self.buffer.count('\n')
294+
if self.buffer and not self.buffer.endswith('\n'):
295+
self.last_output_lines += 1
296+
297+
def _render_markdown(self) -> None:
298+
"""Render the buffer as parsed markdown using TerminalRenderer."""
299+
try:
300+
self._clear_previous_output()
301+
302+
# Parse and render the markdown using the same TerminalRenderer
303+
markdown = mistune.create_markdown(renderer=None)
304+
tokens = markdown(self.buffer)
305+
306+
renderer = TerminalRenderer(self.console)
307+
renderer.render(tokens)
308+
309+
# Count lines in the output by capturing it
310+
output_buffer = StringIO()
311+
temp_console = Console(file=output_buffer, width=self.console.size.width, force_terminal=True)
312+
temp_renderer = TerminalRenderer(temp_console)
313+
temp_renderer.render(tokens)
314+
output = output_buffer.getvalue()
315+
316+
self.last_output_lines = output.count('\n')
317+
if output and not output.endswith('\n'):
318+
self.last_output_lines += 1
319+
320+
except Exception:
321+
# Fallback to plain text if parsing fails
322+
self._render_plain_text()
323+
324+
def _clear_previous_output(self) -> None:
325+
"""Clear the previous output by moving cursor up and clearing lines."""
326+
if self.last_output_lines > 0:
327+
for _ in range(self.last_output_lines):
328+
self.console.file.write("\033[1A\033[2K")
329+
self.console.file.flush()
330+
331+
def finalize(self) -> None:
332+
"""Finalize the rendering (called when input is complete)."""
333+
# Force a final markdown render
334+
self._render_markdown()
335+
# Ensure we end with a newline if we don't already
336+
if self.buffer and not self.buffer.endswith('\n'):
337+
self.console.print()
338+
339+
213340
def convert(markdown_text: str, width: Optional[int] = None) -> None:
214341
"""
215342
Convert markdown text to terminal-formatted text and print it.
@@ -225,53 +352,81 @@ def convert(markdown_text: str, width: Optional[int] = None) -> None:
225352
# Create console with proper width
226353
console = Console(width=width, force_terminal=True)
227354

228-
# Parse markdown
229-
markdown = mistune.create_markdown(renderer=None)
230-
tokens = markdown(markdown_text)
231-
232-
# Render to terminal
233-
renderer = TerminalRenderer(console)
234-
renderer.render(tokens)
355+
# Use the unified streaming renderer for consistent output
356+
renderer = StreamingRenderer(console)
357+
renderer.render_complete(markdown_text)
235358

236359

237360
def process_stream(input_stream: TextIO, width: Optional[int] = None) -> None:
238361
"""
239-
Process markdown from a stream line by line, handling code blocks properly.
362+
Process markdown from a stream line by line using the unified streaming renderer.
240363
241-
This function reads the input stream and processes it in a way that handles
242-
multi-line constructs like code blocks correctly.
364+
This function reads the input stream line by line and uses the streaming
365+
renderer to provide consistent output with minimal flickering.
243366
"""
244-
buffer = []
245-
in_code_block = False
246-
code_fence_pattern = re.compile(r'^```')
247-
248-
for line in input_stream:
249-
buffer.append(line)
250-
251-
# Check for code fence
252-
if code_fence_pattern.match(line.strip()):
253-
in_code_block = not in_code_block
254-
255-
# If we're not in a code block and hit a blank line, process the buffer
256-
if not in_code_block and line.strip() == '':
257-
if buffer:
258-
content = ''.join(buffer)
259-
if content.strip(): # Only process non-empty content
260-
convert(content, width)
261-
buffer = []
367+
# Get terminal width
368+
if width is None:
369+
width = shutil.get_terminal_size().columns
262370

263-
# Process any remaining content
264-
if buffer:
265-
content = ''.join(buffer)
266-
if content.strip():
267-
convert(content, width)
371+
# Create console with proper width
372+
console = Console(width=width, force_terminal=True)
373+
374+
# Create streaming renderer
375+
renderer = StreamingRenderer(console)
376+
377+
try:
378+
# Read line by line and add to renderer
379+
for line in input_stream:
380+
renderer.add_text(line)
381+
except KeyboardInterrupt:
382+
pass
383+
finally:
384+
# Finalize the rendering
385+
renderer.finalize()
386+
387+
388+
def process_character_stream(input_stream: TextIO, width: Optional[int] = None) -> None:
389+
"""
390+
Process markdown from a stream character by character with backtracking support.
391+
392+
This function is designed for LLM streaming where text arrives in small chunks
393+
and markdown syntax may be incomplete until more text arrives.
394+
"""
395+
# Get terminal width
396+
if width is None:
397+
width = shutil.get_terminal_size().columns
398+
399+
# Create console with proper width
400+
console = Console(width=width, force_terminal=True)
401+
402+
# Create streaming renderer
403+
renderer = StreamingRenderer(console)
404+
405+
try:
406+
# Read character by character
407+
while True:
408+
char = input_stream.read(1)
409+
if not char: # EOF
410+
break
411+
412+
renderer.add_text(char)
413+
414+
# Small delay to simulate streaming (can be removed in production)
415+
# time.sleep(0.01)
416+
417+
except KeyboardInterrupt:
418+
pass
419+
finally:
420+
# Finalize the rendering
421+
renderer.finalize()
268422

269423

270424
@click.command()
271425
@click.argument("input_file", type=click.File("r"), required=False)
272426
@click.option("--width", "-w", type=int, help="Override terminal width")
427+
@click.option("--stream", "-s", is_flag=True, help="Enable character-by-character streaming mode with backtracking")
273428
@click.version_option(version=__version__)
274-
def main(input_file: Optional[TextIO], width: Optional[int]) -> None:
429+
def main(input_file: Optional[TextIO], width: Optional[int], stream: bool) -> None:
275430
"""
276431
Parse Markdown and turn it into nicely-formatted text for terminal display.
277432
@@ -282,24 +437,37 @@ def main(input_file: Optional[TextIO], width: Optional[int]) -> None:
282437
- Syntax highlighting for code blocks
283438
- Proper word wrapping based on terminal width
284439
- Support for all standard markdown elements
285-
- Streaming input processing
440+
- Unified streaming renderer for consistent output
441+
- Character-based streaming with backtracking (--stream mode)
442+
443+
The --stream mode is designed for LLM output where text arrives in small
444+
chunks and markdown syntax may be incomplete until more text arrives.
445+
All modes now use the same renderer for consistent beautiful output.
286446
"""
287447
try:
288448
# Read the input
289449
if input_file is None:
290-
# For stdin, check if it's a real terminal or test input
291-
if hasattr(sys.stdin, 'isatty') and sys.stdin.isatty():
292-
# Real terminal - use streaming processing
450+
# For stdin, choose processing mode
451+
if stream:
452+
# Character-by-character streaming mode
453+
process_character_stream(sys.stdin, width)
454+
elif hasattr(sys.stdin, 'isatty') and sys.stdin.isatty():
455+
# Real terminal - use line-based streaming processing
293456
process_stream(sys.stdin, width)
294457
else:
295458
# Test input or pipe - read all at once
296459
content = sys.stdin.read()
297460
if content.strip():
298461
convert(content, width)
299462
else:
300-
# For files, we can read the entire content at once
301-
content = input_file.read()
302-
convert(content, width)
463+
# For files, choose processing mode
464+
if stream:
465+
# Character-by-character streaming mode
466+
process_character_stream(input_file, width)
467+
else:
468+
# Read the entire content at once
469+
content = input_file.read()
470+
convert(content, width)
303471

304472
except KeyboardInterrupt:
305473
# Handle Ctrl+C gracefully

0 commit comments

Comments
 (0)