66import os
77import shutil
88import re
9+ import time
910from typing import Optional , TextIO , List , Dict , Any
1011from io import StringIO
1112import 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+
213340def 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
237360def 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