📌 1. Uncommitted Changes (Staged for next commit)
Focus: UI stability and efficient rendering.
* Markdown Rendering Rewrite: Removed the dependency on rich.live.Live (which caused flickering and
high CPU usage by constantly re-rendering the entire panel).
* New BlockMarkdownRenderer: Implemented in printer.py (alias IncrementalMarkdownParser), it
accumulates text in a buffer and only prints to the screen when it detects a complete block (e.g.,
line breaks or
` code blocks).
* UI Optimizations (terminal_ui.py & stubs.py): Waiting spinners now stop cleanly, and the UI
transitions smoothly to block printing. Fixed visual truncation issues in the bottom "Tab prompt"
bar for excessively long commands.
📦 2. Commit History (Last 4 Commits)
9446baf - improve ai rules
* Strict Anti-Hallucination (ai.py): Injected MANDATORY rules into the System Prompt for both
Architect and Engineer agents. Now, if the terminal buffer is empty or only contains an idle prompt
(e.g., iol#), the AI is strictly forced to state that it lacks data instead of inventing topologies
or configurations.
* Language Preference: Explicitly instructed agents to always respond in the same language the user
used to ask the question.
64377f7 - move context block logic to server and improvements
* Context Precision (ai_service.py): Moved context partitioning logic to the service/server side. It
now calculates exact start_pos and end_pos based on identified commands, preventing mixed outputs
or residual text from bleeding into the AI's prompt.
* Token Savings (server.py): The server now selectively strips garbage metadata and UI caches that
add no value to the LLM before sending the payload over the wire.
e4fd1ad - fix logclean for 6wind
* Full ANSI/CSI Support (utils.py): Replaced the legacy rigid escape filter with a complete CSI
(Control Sequence Introducer) parser. The client can now accurately process numeric cursor
movements (C, D), inline dynamic erasures (K), and absolute shifts (G), ensuring connpy understands
exactly what a 6WIND router or other VNFs render on the screen without garbage characters.
b0a914a - updates al copilot
* Copilot <> gRPC Integration: Implemented the async plumbing required for the Copilot to work over
the gRPC tunnel (server.py & stubs.py).
* Initial Streaming UI: Added the first iteration of chunk callbacks to provide real-time feedback
while the LLM generates responses, laying the groundwork for the uncommitted block-renderer
optimizations.
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
|||||||
__version__ = "6.0.0b8"
|
__version__ = "6.0.0b9"
|
||||||
|
|||||||
+15
-24
@@ -299,7 +299,6 @@ class ai:
|
|||||||
- response: reconstructed ModelResponse (same as non-streaming)
|
- response: reconstructed ModelResponse (same as non-streaming)
|
||||||
- streamed: True if text was rendered to console during streaming
|
- streamed: True if text was rendered to console during streaming
|
||||||
"""
|
"""
|
||||||
from rich.live import Live
|
|
||||||
|
|
||||||
stream_resp = completion(model=model, messages=messages, tools=tools, api_key=api_key, stream=True, **kwargs)
|
stream_resp = completion(model=model, messages=messages, tools=tools, api_key=api_key, stream=True, **kwargs)
|
||||||
|
|
||||||
@@ -307,7 +306,7 @@ class ai:
|
|||||||
full_content = ""
|
full_content = ""
|
||||||
is_streaming_text = False
|
is_streaming_text = False
|
||||||
has_tool_calls = False
|
has_tool_calls = False
|
||||||
live_display = None
|
header_printed = False
|
||||||
|
|
||||||
# Determine styling based on current brain
|
# Determine styling based on current brain
|
||||||
role_label = "Network Architect" if "architect" in label.lower() else "Network Engineer"
|
role_label = "Network Architect" if "architect" in label.lower() else "Network Engineer"
|
||||||
@@ -336,7 +335,6 @@ class ai:
|
|||||||
|
|
||||||
if not chunk_callback:
|
if not chunk_callback:
|
||||||
if not is_streaming_text:
|
if not is_streaming_text:
|
||||||
# Stop spinner definitively
|
|
||||||
if status:
|
if status:
|
||||||
try:
|
try:
|
||||||
status.stop()
|
status.stop()
|
||||||
@@ -345,35 +343,28 @@ class ai:
|
|||||||
|
|
||||||
# Create a stable, direct Console to bypass _ConsoleProxy recreation bugs
|
# Create a stable, direct Console to bypass _ConsoleProxy recreation bugs
|
||||||
from rich.console import Console as RichConsole
|
from rich.console import Console as RichConsole
|
||||||
from .printer import connpy_theme, get_original_stdout
|
from rich.rule import Rule
|
||||||
|
from .printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
|
||||||
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||||
|
|
||||||
live_display = Live(
|
stable_console.print(Rule(f"[bold {border}]{title}[/bold {border}]", style=border))
|
||||||
Panel(Markdown(full_content), title=title, border_style=border, expand=False),
|
header_printed = True
|
||||||
console=stable_console,
|
md_parser = IncrementalMarkdownParser(console=stable_console)
|
||||||
refresh_per_second=8,
|
|
||||||
transient=False
|
|
||||||
)
|
|
||||||
live_display.start()
|
|
||||||
is_streaming_text = True
|
is_streaming_text = True
|
||||||
else:
|
|
||||||
live_display.update(
|
md_parser.feed(delta.content)
|
||||||
Panel(Markdown(full_content), title=title, border_style=border, expand=False)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not chunks:
|
if not chunks:
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
if live_display:
|
if header_printed:
|
||||||
# Render final state with complete content
|
|
||||||
try:
|
try:
|
||||||
live_display.update(
|
md_parser.flush()
|
||||||
Panel(Markdown(full_content), title=title, border_style=border, expand=False)
|
from rich.console import Console as RichConsole
|
||||||
)
|
from rich.rule import Rule
|
||||||
except Exception:
|
from .printer import connpy_theme, get_original_stdout
|
||||||
pass
|
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||||
try:
|
stable_console.print(Rule(style=border))
|
||||||
live_display.stop()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
+84
-26
@@ -12,7 +12,6 @@ from textwrap import dedent
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
from rich.live import Live
|
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from prompt_toolkit.key_binding import KeyBindings
|
from prompt_toolkit.key_binding import KeyBindings
|
||||||
from prompt_toolkit.formatted_text import HTML
|
from prompt_toolkit.formatted_text import HTML
|
||||||
@@ -174,7 +173,40 @@ class CopilotInterface:
|
|||||||
base_str = f'\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]'
|
base_str = f'\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]'
|
||||||
else:
|
else:
|
||||||
idx = max(0, state['total_cmds'] - state['context_cmd'])
|
idx = max(0, state['total_cmds'] - state['context_cmd'])
|
||||||
desc = blocks[idx][2]
|
import re
|
||||||
|
|
||||||
|
def clean_preview(text):
|
||||||
|
# Limpia saltos de línea y el prompt inicial (todo hasta #, > o $) para que quede solo el comando
|
||||||
|
original = text.strip().replace('\r', '').replace('\n', ' ')
|
||||||
|
cleaned = re.sub(r'^.*?[#>\$]\s*', '', original)
|
||||||
|
# Si limpiar el prompt nos deja con un string vacío (ej: era solo "iol#"), devolvemos el original
|
||||||
|
return cleaned if cleaned else original
|
||||||
|
|
||||||
|
if state['context_mode'] == self.mode_range:
|
||||||
|
range_blocks = blocks[idx:]
|
||||||
|
# Si hay más de un bloque, el último es siempre el prompt vacío/actual. Lo omitimos visualmente.
|
||||||
|
if len(range_blocks) > 1:
|
||||||
|
range_blocks = range_blocks[:-1]
|
||||||
|
|
||||||
|
# Limpiar y truncar comandos muy largos para que no rompan la UI
|
||||||
|
previews = []
|
||||||
|
for b in range_blocks:
|
||||||
|
p = clean_preview(b[2])
|
||||||
|
if p:
|
||||||
|
# Truncar comandos individuales largos
|
||||||
|
if len(p) > 25: p = p[:22] + "..."
|
||||||
|
previews.append(p)
|
||||||
|
|
||||||
|
if not previews:
|
||||||
|
desc = clean_preview(blocks[idx][2])
|
||||||
|
elif len(previews) <= 3:
|
||||||
|
desc = " + ".join(previews)
|
||||||
|
else:
|
||||||
|
desc = f"{previews[0]} + {previews[1]} + {previews[2]} ... (+{len(previews)-3})"
|
||||||
|
else:
|
||||||
|
# Modo SINGLE original
|
||||||
|
desc = clean_preview(blocks[idx][2])
|
||||||
|
|
||||||
base_str = f'\u25b6 {desc} [Tab: {m_label}]'
|
base_str = f'\u25b6 {desc} [Tab: {m_label}]'
|
||||||
|
|
||||||
# Wrap base_str in a style to maintain consistency and avoid glitches
|
# Wrap base_str in a style to maintain consistency and avoid glitches
|
||||||
@@ -304,36 +336,63 @@ class CopilotInterface:
|
|||||||
persona_title = "Network Architect" if active_persona == "architect" else "Network Engineer"
|
persona_title = "Network Architect" if active_persona == "architect" else "Network Engineer"
|
||||||
|
|
||||||
active_buffer = get_active_buffer()
|
active_buffer = get_active_buffer()
|
||||||
live_text = "Thinking..."
|
live_text = ""
|
||||||
panel = Panel(live_text, title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color)
|
first_chunk = True
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from rich.rule import Rule
|
||||||
|
from rich.status import Status
|
||||||
|
from connpy.printer import IncrementalMarkdownParser
|
||||||
|
|
||||||
|
md_parser = IncrementalMarkdownParser(console=self.console)
|
||||||
|
|
||||||
|
status_spinner = Status(
|
||||||
|
f"[bold {persona_color}]{persona_title}:[/bold {persona_color}] [dim]Thinking...[/dim]",
|
||||||
|
console=self.console,
|
||||||
|
spinner="dots"
|
||||||
|
)
|
||||||
|
status_spinner.start()
|
||||||
|
|
||||||
def on_chunk(text):
|
def on_chunk(text):
|
||||||
nonlocal live_text
|
nonlocal live_text, first_chunk
|
||||||
if live_text == "Thinking...": live_text = ""
|
if first_chunk:
|
||||||
|
status_spinner.stop()
|
||||||
|
# Print header rule before first chunk arrives
|
||||||
|
self.console.print(Rule(
|
||||||
|
f"[bold {persona_color}]{persona_title}[/bold {persona_color}]",
|
||||||
|
style=persona_color
|
||||||
|
))
|
||||||
|
first_chunk = False
|
||||||
live_text += text
|
live_text += text
|
||||||
|
md_parser.feed(text)
|
||||||
|
|
||||||
with Live(panel, console=self.console, refresh_per_second=10) as live:
|
# Check for interruption during AI call
|
||||||
def update_live(t):
|
ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, on_chunk, merged_node_info))
|
||||||
live.update(Panel(Markdown(t), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color))
|
|
||||||
|
|
||||||
wrapped_chunk = lambda t: (on_chunk(t), update_live(live_text))
|
try:
|
||||||
|
while not ai_task.done():
|
||||||
# Check for interruption during AI call
|
await asyncio.sleep(0.05)
|
||||||
ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, wrapped_chunk, merged_node_info))
|
result = await ai_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
try:
|
status_spinner.stop()
|
||||||
while not ai_task.done():
|
|
||||||
await asyncio.sleep(0.05)
|
|
||||||
result = await ai_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return "cancel", None, None
|
|
||||||
|
|
||||||
if not result or result.get("error"):
|
|
||||||
if result and result.get("error"): self.console.print(f"[red]Error: {result['error']}[/red]")
|
|
||||||
return "cancel", None, None
|
return "cancel", None, None
|
||||||
|
|
||||||
# 4. Handle result
|
# Ensure spinner is stopped if no chunks arrived
|
||||||
if live_text == "Thinking..." and result.get("guide"):
|
if first_chunk:
|
||||||
|
status_spinner.stop()
|
||||||
|
|
||||||
|
# Close the streamed output with a Rule
|
||||||
|
if not first_chunk:
|
||||||
|
md_parser.flush()
|
||||||
|
self.console.print(Rule(style=persona_color))
|
||||||
|
|
||||||
|
if not result or result.get("error"):
|
||||||
|
if first_chunk and result and result.get("error"):
|
||||||
|
self.console.print(f"[red]Error: {result['error']}[/red]")
|
||||||
|
return "cancel", None, None
|
||||||
|
|
||||||
|
# If no chunks were streamed but we have a guide, print it as a panel
|
||||||
|
if first_chunk and result and result.get("guide"):
|
||||||
self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color))
|
self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color))
|
||||||
|
|
||||||
commands = result.get("commands", [])
|
commands = result.get("commands", [])
|
||||||
@@ -437,5 +496,4 @@ class CopilotInterface:
|
|||||||
|
|
||||||
finally:
|
finally:
|
||||||
state['cancelled'] = True
|
state['cancelled'] = True
|
||||||
self.console.print("[dim]Returning to session...[/dim]")
|
|
||||||
|
|
||||||
|
|||||||
@@ -689,6 +689,7 @@ class node:
|
|||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
finally:
|
finally:
|
||||||
|
print("\033[2m Returning to session...\033[0m", flush=True)
|
||||||
# Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet
|
# Reiniciar el lector de la terminal para volver al modo interactivo SSH/Telnet
|
||||||
if hasattr(stream, 'start_reading'):
|
if hasattr(stream, 'start_reading'):
|
||||||
stream.start_reading()
|
stream.start_reading()
|
||||||
|
|||||||
+28
-43
@@ -102,6 +102,7 @@ class NodeStub:
|
|||||||
with copilot_terminal_mode():
|
with copilot_terminal_mode():
|
||||||
action, commands, custom_cmd = asyncio.run(run_remote_copilot())
|
action, commands, custom_cmd = asyncio.run(run_remote_copilot())
|
||||||
|
|
||||||
|
print("\033[2m Returning to session...\033[0m", flush=True)
|
||||||
# Prepare final action for server
|
# Prepare final action for server
|
||||||
action_sent = "cancel"
|
action_sent = "cancel"
|
||||||
if action == "send_all" and commands:
|
if action == "send_all" and commands:
|
||||||
@@ -726,7 +727,6 @@ class AIStub:
|
|||||||
import queue
|
import queue
|
||||||
from rich.prompt import Prompt
|
from rich.prompt import Prompt
|
||||||
from rich.text import Text
|
from rich.text import Text
|
||||||
from rich.live import Live
|
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.markdown import Markdown
|
from rich.markdown import Markdown
|
||||||
|
|
||||||
@@ -757,7 +757,7 @@ class AIStub:
|
|||||||
responses = self.stub.ask(request_generator())
|
responses = self.stub.ask(request_generator())
|
||||||
|
|
||||||
full_content = ""
|
full_content = ""
|
||||||
live_display = None
|
header_printed = False
|
||||||
final_result = {"response": "", "chat_history": []}
|
final_result = {"response": "", "chat_history": []}
|
||||||
|
|
||||||
# Background thread to pull responses from gRPC into a local queue
|
# Background thread to pull responses from gRPC into a local queue
|
||||||
@@ -822,69 +822,53 @@ class AIStub:
|
|||||||
|
|
||||||
if response.debug_message:
|
if response.debug_message:
|
||||||
if debug:
|
if debug:
|
||||||
if live_display:
|
|
||||||
try: live_display.stop()
|
|
||||||
except: pass
|
|
||||||
if status:
|
if status:
|
||||||
try: status.stop()
|
try: status.stop()
|
||||||
except: pass
|
except: pass
|
||||||
printer.console.print(Text.from_ansi(response.debug_message))
|
printer.console.print(Text.from_ansi(response.debug_message))
|
||||||
if live_display:
|
if status:
|
||||||
try: live_display.start()
|
|
||||||
except: pass
|
|
||||||
elif status:
|
|
||||||
try: status.start()
|
try: status.start()
|
||||||
except: pass
|
except: pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if response.important_message:
|
if response.important_message:
|
||||||
if live_display:
|
|
||||||
try: live_display.stop()
|
|
||||||
except: pass
|
|
||||||
if status:
|
if status:
|
||||||
try: status.stop()
|
try: status.stop()
|
||||||
except: pass
|
except: pass
|
||||||
printer.console.print(Text.from_ansi(response.important_message))
|
printer.console.print(Text.from_ansi(response.important_message))
|
||||||
if live_display:
|
if status:
|
||||||
try: live_display.start()
|
|
||||||
except: pass
|
|
||||||
elif status:
|
|
||||||
try: status.start()
|
try: status.start()
|
||||||
except: pass
|
except: pass
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not response.is_final:
|
if not response.is_final:
|
||||||
if response.text_chunk:
|
if response.text_chunk:
|
||||||
full_content += response.text_chunk
|
if not header_printed:
|
||||||
|
|
||||||
if not live_display:
|
|
||||||
if status:
|
if status:
|
||||||
try: status.stop()
|
try: status.stop()
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
from rich.console import Console as RichConsole
|
from rich.console import Console as RichConsole
|
||||||
from ..printer import connpy_theme, get_original_stdout
|
from rich.rule import Rule
|
||||||
|
from ..printer import connpy_theme, get_original_stdout, IncrementalMarkdownParser
|
||||||
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||||
|
|
||||||
# We default to Engineer title during stream, final result will correct it if needed
|
# Print header on first chunk
|
||||||
live_display = Live(
|
stable_console.print(Rule("[bold engineer]Network Engineer[/bold engineer]", style="engineer"))
|
||||||
Panel(Markdown(full_content), title="[bold engineer]Network Engineer[/bold engineer]", border_style="engineer", expand=False),
|
header_printed = True
|
||||||
console=stable_console,
|
|
||||||
refresh_per_second=8,
|
# Initialize parser
|
||||||
transient=False
|
md_parser = IncrementalMarkdownParser(console=stable_console)
|
||||||
)
|
|
||||||
live_display.start()
|
full_content += response.text_chunk
|
||||||
else:
|
md_parser.feed(response.text_chunk)
|
||||||
live_display.update(
|
|
||||||
Panel(Markdown(full_content), title="[bold engineer]Network Engineer[/bold engineer]", border_style="engineer", expand=False)
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if response.is_final:
|
if response.is_final:
|
||||||
if live_display:
|
if header_printed:
|
||||||
try: live_display.stop()
|
from rich.rule import Rule
|
||||||
except: pass
|
md_parser.flush()
|
||||||
# Final stop for status to ensure it disappears before the panel
|
|
||||||
if status:
|
if status:
|
||||||
try: status.stop()
|
try: status.stop()
|
||||||
except: pass
|
except: pass
|
||||||
@@ -895,13 +879,14 @@ class AIStub:
|
|||||||
role_label = "Network Architect" if responder == "architect" else "Network Engineer"
|
role_label = "Network Architect" if responder == "architect" else "Network Engineer"
|
||||||
title = f"[bold {alias}]{role_label}[/bold {alias}]"
|
title = f"[bold {alias}]{role_label}[/bold {alias}]"
|
||||||
|
|
||||||
content_to_print = full_content or final_result.get("response", "")
|
if header_printed:
|
||||||
if content_to_print:
|
from rich.console import Console as RichConsole
|
||||||
if live_display:
|
from ..printer import connpy_theme, get_original_stdout
|
||||||
# Re-render the final frame with correct title/colors
|
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||||
live_display.update(Panel(Markdown(content_to_print), title=title, border_style=alias, expand=False))
|
stable_console.print(Rule(style=alias))
|
||||||
else:
|
elif not full_content and final_result.get("response"):
|
||||||
printer.console.print(Panel(Markdown(content_to_print), title=title, border_style=alias, expand=False))
|
# If nothing streamed but we have response (e.g. error or direct guide)
|
||||||
|
printer.console.print(Panel(Markdown(final_result["response"]), title=title, border_style=alias, expand=False))
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Check if it was a gRPC error that we should let handle_errors catch
|
# Check if it was a gRPC error that we should let handle_errors catch
|
||||||
|
|||||||
+82
-1
@@ -15,7 +15,17 @@ class ThreadLocalStream:
|
|||||||
def write(self, data):
|
def write(self, data):
|
||||||
stream = self._get_stream()
|
stream = self._get_stream()
|
||||||
if stream:
|
if stream:
|
||||||
stream.write(data)
|
import time
|
||||||
|
retries = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
stream.write(data)
|
||||||
|
break
|
||||||
|
except BlockingIOError:
|
||||||
|
if retries > 50:
|
||||||
|
raise
|
||||||
|
time.sleep(0.01)
|
||||||
|
retries += 1
|
||||||
|
|
||||||
def flush(self):
|
def flush(self):
|
||||||
stream = self._get_stream()
|
stream = self._get_stream()
|
||||||
@@ -496,3 +506,74 @@ class _ThemeProxy:
|
|||||||
return getattr(local.theme, name)
|
return getattr(local.theme, name)
|
||||||
|
|
||||||
connpy_theme = _ThemeProxy()
|
connpy_theme = _ThemeProxy()
|
||||||
|
|
||||||
|
class BlockMarkdownRenderer:
|
||||||
|
"""
|
||||||
|
Block-buffered streaming markdown renderer.
|
||||||
|
Accumulates text until block boundaries are detected,
|
||||||
|
then renders complete blocks using Rich's Markdown.
|
||||||
|
"""
|
||||||
|
def __init__(self, console=None):
|
||||||
|
from rich.console import Console as RichConsole
|
||||||
|
from .printer import connpy_theme, get_original_stdout
|
||||||
|
self._console = console or RichConsole(
|
||||||
|
theme=connpy_theme, file=get_original_stdout()
|
||||||
|
)
|
||||||
|
self._line_buf = "" # chars waiting for \n
|
||||||
|
self._block_lines = [] # complete lines for current block
|
||||||
|
self._in_code_block = False
|
||||||
|
|
||||||
|
def feed(self, text):
|
||||||
|
self._line_buf += text
|
||||||
|
while '\n' in self._line_buf:
|
||||||
|
idx = self._line_buf.index('\n')
|
||||||
|
line = self._line_buf[:idx + 1]
|
||||||
|
self._line_buf = self._line_buf[idx + 1:]
|
||||||
|
self._process_line(line)
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
if self._line_buf:
|
||||||
|
self._block_lines.append(self._line_buf)
|
||||||
|
self._line_buf = ""
|
||||||
|
self._flush_block()
|
||||||
|
|
||||||
|
def _process_line(self, line):
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
if stripped.startswith('```'):
|
||||||
|
if not self._in_code_block:
|
||||||
|
# Flush accumulated text before code block
|
||||||
|
self._flush_block()
|
||||||
|
self._in_code_block = True
|
||||||
|
self._block_lines.append(line)
|
||||||
|
else:
|
||||||
|
# Include closing fence and flush code block
|
||||||
|
self._block_lines.append(line)
|
||||||
|
self._in_code_block = False
|
||||||
|
self._flush_block()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._in_code_block:
|
||||||
|
self._block_lines.append(line)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Blank line = paragraph break
|
||||||
|
if stripped == '':
|
||||||
|
self._block_lines.append(line)
|
||||||
|
self._flush_block()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._block_lines.append(line)
|
||||||
|
|
||||||
|
def _flush_block(self):
|
||||||
|
if not self._block_lines:
|
||||||
|
return
|
||||||
|
block_text = ''.join(self._block_lines).strip()
|
||||||
|
self._block_lines = []
|
||||||
|
if not block_text:
|
||||||
|
return
|
||||||
|
from rich.markdown import Markdown
|
||||||
|
self._console.print(Markdown(block_text))
|
||||||
|
|
||||||
|
# Alias for backward compatibility
|
||||||
|
IncrementalMarkdownParser = BlockMarkdownRenderer
|
||||||
|
|||||||
Reference in New Issue
Block a user