Rewrite output to use Buffer for native scrolling

FormattedTextControl doesn't support scrolling - always renders from top.
Switch to Buffer + BufferControl which has proper scroll support:
- Output buffer uses read-only Buffer with cursor at end
- Tab/Shift-Tab to switch focus between output and input
- Arrow keys scroll when output is focused
- Scrollbar margin shows position
- Auto-scroll on new content (cursor stays at end)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
dullfig 2026-01-11 15:07:07 -08:00
parent f58197c83f
commit fbb6c28ab7

View file

@ -85,50 +85,42 @@ STYLE = Style.from_dict({
# ============================================================================ # ============================================================================
class OutputBuffer: class OutputBuffer:
"""Manages scrolling output history.""" """Manages scrolling output history using a text Buffer."""
def __init__(self, max_lines: int = 1000): def __init__(self, max_lines: int = 1000):
self.lines: List[tuple] = [] # (style_class, text)
self.max_lines = max_lines self.max_lines = max_lines
self._lines: List[str] = []
# Create a read-only buffer for display
self.buffer = Buffer(read_only=True, name="output")
def append(self, text: str, style: str = "output"): def append(self, text: str, style: str = "output"):
"""Add a line to output.""" """Add a line to output."""
timestamp = datetime.now().strftime("%H:%M:%S") timestamp = datetime.now().strftime("%H:%M:%S")
self.lines.append((style, f"[{timestamp}] {text}")) self._lines.append(f"[{timestamp}] {text}")
if len(self.lines) > self.max_lines: self._update_buffer()
self.lines = self.lines[-self.max_lines:]
def append_raw(self, text: str, style: str = "output"): def append_raw(self, text: str, style: str = "output"):
"""Add without timestamp.""" """Add without timestamp."""
self.lines.append((style, text)) self._lines.append(text)
if len(self.lines) > self.max_lines: self._update_buffer()
self.lines = self.lines[-self.max_lines:]
def get_formatted_text(self, scroll_offset: int = 0) -> FormattedText: def _update_buffer(self):
"""Get formatted text for display. """Update the buffer content and scroll to bottom."""
# Trim if needed
if len(self._lines) > self.max_lines:
self._lines = self._lines[-self.max_lines:]
scroll_offset: 0 = show bottom, positive = scroll up N lines # Update buffer text
""" text = "\n".join(self._lines)
result = [] self.buffer.set_document(
Document(text=text, cursor_position=len(text)),
if scroll_offset == 0: bypass_readonly=True
# Show all lines (newest at bottom) )
for style, text in self.lines:
result.append((f"class:{style}", text))
result.append(("", "\n"))
else:
# Show lines up to scroll_offset from end
end_idx = len(self.lines) - scroll_offset
if end_idx > 0:
for style, text in self.lines[:end_idx]:
result.append((f"class:{style}", text))
result.append(("", "\n"))
return FormattedText(result)
def clear(self): def clear(self):
"""Clear output.""" """Clear output."""
self.lines.clear() self._lines.clear()
self.buffer.set_document(Document(text=""), bypass_readonly=True)
# ============================================================================ # ============================================================================
@ -198,45 +190,29 @@ class TUIConsole:
def handle_ctrl_l(event): def handle_ctrl_l(event):
"""Handle Ctrl+L - clear output.""" """Handle Ctrl+L - clear output."""
self.output.clear() self.output.clear()
self.scroll_offset = 0
# Track scroll offset (0 = bottom, positive = lines from bottom) @kb.add("tab")
self.scroll_offset = 0 def handle_tab(event):
"""Switch focus between output and input."""
event.app.layout.focus_next()
@kb.add("pageup") @kb.add("s-tab")
def handle_page_up(event): def handle_shift_tab(event):
"""Scroll output up.""" """Switch focus between output and input."""
max_offset = max(0, len(self.output.lines) - 5) event.app.layout.focus_previous()
self.scroll_offset = min(self.scroll_offset + 10, max_offset)
@kb.add("pagedown") # Output uses BufferControl for native scrolling
def handle_page_down(event): output_control = BufferControl(
"""Scroll output down.""" buffer=self.output.buffer,
self.scroll_offset = max(0, self.scroll_offset - 10) focusable=True, # Allow focus for scrolling
include_default_input_processors=False,
@kb.add("end")
def handle_end(event):
"""Scroll to bottom."""
self.scroll_offset = 0
@kb.add("home")
def handle_home(event):
"""Scroll to top."""
self.scroll_offset = max(0, len(self.output.lines) - 5)
# Output control - uses scroll_offset to show correct portion
def get_visible_output():
return self.output.get_formatted_text(self.scroll_offset)
output_control = FormattedTextControl(
text=get_visible_output,
focusable=False,
) )
# Output window - takes all available space # Output window - takes all available space, scrolls with cursor
self.output_window = Window( self.output_window = Window(
content=output_control, content=output_control,
wrap_lines=True, wrap_lines=True,
right_margins=[ScrollbarMargin(display_arrows=True)],
) )
# Separator line with status (shows scroll hint if not at bottom) # Separator line with status (shows scroll hint if not at bottom)
@ -447,8 +423,8 @@ class TUIConsole:
self.print_raw(" Ctrl+C / Ctrl+D Quit", "output.dim") self.print_raw(" Ctrl+C / Ctrl+D Quit", "output.dim")
self.print_raw(" Ctrl+L Clear output", "output.dim") self.print_raw(" Ctrl+L Clear output", "output.dim")
self.print_raw(" Up/Down Command history", "output.dim") self.print_raw(" Up/Down Command history", "output.dim")
self.print_raw(" Page Up/Down Scroll output history", "output.dim") self.print_raw(" Tab Switch focus (input/output)", "output.dim")
self.print_raw(" Home/End Jump to top/bottom", "output.dim") self.print_raw(" Arrow keys Scroll when output focused", "output.dim")
async def _cmd_status(self, args: str): async def _cmd_status(self, args: str):
"""Show status.""" """Show status."""