📌 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:
2026-05-18 14:07:24 -03:00
parent 9446bafc0c
commit 5a8b744aa8
6 changed files with 210 additions and 94 deletions
+1 -1
View File
@@ -1 +1 @@
__version__ = "6.0.0b8" __version__ = "6.0.0b9"
+15 -24
View File
@@ -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
+83 -25
View File
@@ -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))
try:
wrapped_chunk = lambda t: (on_chunk(t), update_live(live_text)) while not ai_task.done():
await asyncio.sleep(0.05)
# Check for interruption during AI call result = await ai_task
ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, wrapped_chunk, merged_node_info)) except asyncio.CancelledError:
status_spinner.stop()
try: return "cancel", None, None
while not ai_task.done():
await asyncio.sleep(0.05) # Ensure spinner is stopped if no chunks arrived
result = await ai_task if first_chunk:
except asyncio.CancelledError: status_spinner.stop()
return "cancel", None, None
# 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 not result or result.get("error"):
if result and result.get("error"): self.console.print(f"[red]Error: {result['error']}[/red]") if first_chunk and 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 # If no chunks were streamed but we have a guide, print it as a panel
if live_text == "Thinking..." and result.get("guide"): 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]")
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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