Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 115 additions & 33 deletions interpreter/terminal_interface/components/message_block.py
Original file line number Diff line number Diff line change
@@ -1,49 +1,131 @@
import re

from rich.box import MINIMAL
from rich.box import MINIMAL, ROUNDED
from rich.markdown import Markdown
from rich.panel import Panel
from rich.text import Text
from rich.console import Group
from rich.padding import Padding

from .base_block import BaseBlock
from ..utils.streaming_markdown import (
detect_complete_block,
calculate_window_size,
create_sliding_window_display,
create_live_display,
textify_markdown_code_blocks,
)


class MessageBlock(BaseBlock):
def __init__(self):
super().__init__()

# Override the Live display with our streaming configuration
self.live.stop() # Stop the base Live display
self.live = create_live_display(self.live.console) # Use our streaming Live display
self.live.start()

self.type = "message"
self.message = ""
self.buffer = ""
self.completed_blocks = []
self.viewport_fraction = 0.3 # Increase from 0.2 to 0.3 for better visibility
self.debug = False # Enable debug mode to show colored borders

def refresh(self, cursor=True):
# De-stylize any code blocks in markdown,
# to differentiate from our Code Blocks
content = textify_markdown_code_blocks(self.message)

if cursor:
content += "●"

markdown = Markdown(content.strip())
panel = Panel(markdown, box=MINIMAL)
self.live.update(panel)
self.live.refresh()


def textify_markdown_code_blocks(text):
"""
To distinguish CodeBlocks from markdown code, we simply turn all markdown code
(like '```python...') into text code blocks ('```text') which makes the code black and white.
"""
replacement = "```text"
lines = text.split("\n")
inside_code_block = False

for i in range(len(lines)):
# If the line matches ``` followed by optional language specifier
if re.match(r"^```(\w*)$", lines[i].strip()):
inside_code_block = not inside_code_block

# If we just entered a code block, replace the marker
if inside_code_block:
lines[i] = replacement

return "\n".join(lines)
"""Process new content and render complete blocks incrementally."""
# Try to detect a complete block
block_result = detect_complete_block(self.buffer)

if block_result:
block_text, next_line_begin = block_result

# De-stylize any code blocks in markdown to differentiate from Code Blocks
content = textify_markdown_code_blocks(block_text)

# Render the complete block directly to console (above the Live viewport)
markdown = Markdown(content.strip())
if self.debug:
# In debug mode, still use panel for visual distinction
panel = Panel(markdown, box=ROUNDED, border_style="green")
self.live.console.print(panel)
else:
# Print markdown directly with horizontal padding only (2 chars left/right)
padded_markdown = Padding(markdown, (1, 2, 0, 2))
self.live.console.print(padded_markdown)

# Store the completed block
self.completed_blocks.append(content)

# Remove the rendered block from buffer using line numbers
lines = self.buffer.split('\n')
remaining_lines = lines[next_line_begin:]
self.buffer = '\n'.join(remaining_lines)

# If we removed content, refresh the viewport with remaining content
if remaining_lines:
# Continue to the streaming section below
pass

# Stream the remaining buffer content in the Live viewport
if self.buffer.strip():
# Calculate viewport size
viewport_lines = calculate_window_size(self.live.console, self.viewport_fraction)

# Ensure we have a reasonable viewport size
if viewport_lines < 1:
viewport_lines = 3 # Minimum viewport size

# Create sliding window display for the buffer
formatted_buffer = create_sliding_window_display(
self.live.console, self.buffer.split('\n'), viewport_lines, self.debug)

# Add cursor if requested
if cursor and isinstance(formatted_buffer, Text):
formatted_buffer += "●"
elif cursor and isinstance(formatted_buffer, Group):
# If it's a Group with ellipsis, add cursor to the text part
formatted_buffer.renderables[-1] += "●"

# Wrap streaming content in a panel to match rendered content indentation
if self.debug:
streaming_panel = Panel(formatted_buffer, box=ROUNDED, border_style="blue")
self.live.update(streaming_panel)
else:
# Print streaming content directly with horizontal padding only (2 chars left/right)
padded_buffer = Padding(formatted_buffer, (1, 2, 0, 2))
self.live.update(padded_buffer)
else:
# Clear the live display if no buffer content
self.live.update("")

def add_content(self, content):
"""Add new content to the buffer and process it."""
self.buffer += content
self.refresh(cursor=True)

def finalize(self):
"""Render any remaining content when the message is complete."""
# Clear the live display to remove the streaming raw text
self.live.update("")

# Render any remaining buffer content as markdown
if self.buffer.strip():
try:
# De-stylize any code blocks in markdown
content = textify_markdown_code_blocks(self.buffer)
markdown = Markdown(content.strip())
if self.debug:
panel = Panel(markdown, box=ROUNDED, border_style="red")
self.live.console.print(panel)
else:
# Print markdown directly with horizontal padding only (2 chars left/right)
padded_markdown = Padding(markdown, (1, 2, 0, 2))
self.live.console.print(padded_markdown)
except (IndexError, ValueError, TypeError):
# Fallback to plain text if markdown parsing fails
self.live.console.print(self.buffer)

# Ensure no further streaming occurs during end()'s refresh
self.buffer = ""
92 changes: 50 additions & 42 deletions interpreter/terminal_interface/terminal_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,11 @@ def terminal_interface(interpreter, message):
continue

if "end" in chunk and active_block:
active_block.refresh(cursor=False)
# For message blocks, finalize before ending (skip refresh to avoid duplication)
if chunk["type"] == "message" and hasattr(active_block, 'finalize'):
active_block.finalize()
else:
active_block.refresh(cursor=False)

if chunk["type"] in [
"message",
Expand All @@ -295,52 +299,55 @@ def terminal_interface(interpreter, message):
if chunk["type"] == "message":
if "start" in chunk:
active_block = MessageBlock()
# Enable debug mode if environment variable is set
active_block.debug = os.environ.get("OI_DEBUG_MARKDOWN", "").lower() in ("1", "true", "yes")
render_cursor = True

if "content" in chunk:
active_block.message += chunk["content"]
if active_block:
active_block.add_content(chunk["content"])

if "end" in chunk and interpreter.os:
last_message = interpreter.messages[-1]["content"]

# Remove markdown lists and the line above markdown lists
lines = last_message.split("\n")
i = 0
while i < len(lines):
# Match markdown lists starting with hyphen, asterisk or number
if re.match(r"^\s*([-*]|\d+\.)\s", lines[i]):
del lines[i]
if i > 0:
del lines[i - 1]
i -= 1
else:
i += 1
message = "\n".join(lines)
# Replace newlines with spaces, escape double quotes and backslashes
sanitized_message = (
message.replace("\\", "\\\\")
.replace("\n", " ")
.replace('"', '\\"')
)

# Display notification in OS mode
interpreter.computer.os.notify(sanitized_message)

# Speak message aloud
if platform.system() == "Darwin" and interpreter.speak_messages:
if voice_subprocess:
voice_subprocess.terminate()
voice_subprocess = subprocess.Popen(
[
"osascript",
"-e",
f'say "{sanitized_message}" using "Fred"',
]
last_message = interpreter.messages[-1]["content"]

# Remove markdown lists and the line above markdown lists
lines = last_message.split("\n")
i = 0
while i < len(lines):
# Match markdown lists starting with hyphen, asterisk or number
if re.match(r"^\s*([-*]|\d+\.)\s", lines[i]):
del lines[i]
if i > 0:
del lines[i - 1]
i -= 1
else:
i += 1
message = "\n".join(lines)
# Replace newlines with spaces, escape double quotes and backslashes
sanitized_message = (
message.replace("\\", "\\\\")
.replace("\n", " ")
.replace('"', '\\"')
)
else:
pass
# User isn't on a Mac, so we can't do this. You should tell them something about that when they first set this up.
# Or use a universal TTS library.

# Display notification in OS mode
interpreter.computer.os.notify(sanitized_message)

# Speak message aloud
if platform.system() == "Darwin" and interpreter.speak_messages:
if voice_subprocess:
voice_subprocess.terminate()
voice_subprocess = subprocess.Popen(
[
"osascript",
"-e",
f'say "{sanitized_message}" using "Fred"',
]
)
else:
pass
# User isn't on a Mac, so we can't do this. You should tell them something about that when they first set this up.
# Or use a universal TTS library.

# Assistant code blocks
elif chunk["role"] == "assistant" and chunk["type"] == "code":
Expand Down Expand Up @@ -510,7 +517,8 @@ def terminal_interface(interpreter, message):
active_block.end()
active_block = CodeBlock()

if active_block:
if active_block and not isinstance(active_block, MessageBlock):
# MessageBlock handles its own refresh internally
active_block.refresh(cursor=render_cursor)

# (Sometimes -- like if they CTRL-C quickly -- active_block is still None here)
Expand Down
Loading