Add mouse wheel and keyboard scrolling support to REPL

- Add scroll_offset to REPLState (max 50 lines)
- Modify _render_repl() to use manual scroll position
- Add scroll_output(delta) method for scroll control
- Add PageUp/PageDown keyboard support (scroll 10 lines)
- Add mouse wheel support via SGR mouse tracking
- Update HUD to show scroll percentage (like vim) and position
- Reset scroll when new output arrives
- Add tests for scroll functionality
This commit is contained in:
2026-03-22 17:26:14 -07:00
parent e5799a346a
commit bfcad4963a
6 changed files with 257 additions and 21 deletions

View File

@@ -47,8 +47,9 @@ class REPLState:
current_command: str = ""
history_index: int = -1
output_buffer: list[str] = field(default_factory=list)
scroll_offset: int = 0 # Manual scroll position (0 = bottom of buffer)
max_history: int = 50
max_output_lines: int = 20
max_output_lines: int = 50 # 50 lines excluding empty lines
class ReplEffect(EffectPlugin):
@@ -137,10 +138,23 @@ class ReplEffect(EffectPlugin):
# Line 1: Title + FPS + Frame time
fps_str = f"FPS: {fps:.1f}" if fps > 0 else "FPS: --"
time_str = f"{frame_time:.1f}ms" if frame_time > 0 else "--ms"
# Calculate scroll percentage (like vim)
scroll_pct = 0
if len(self.state.output_buffer) > 1:
max_scroll = len(self.state.output_buffer) - 1
scroll_pct = (
int((self.state.scroll_offset / max_scroll) * 100)
if max_scroll > 0
else 0
)
scroll_str = f"{scroll_pct}%"
line1 = (
f"\033[38;5;46mMAINLINE REPL\033[0m "
f"\033[38;5;245m|\033[0m \033[38;5;39m{fps_str}\033[0m "
f"\033[38;5;245m|\033[0m \033[38;5;208m{time_str}\033[0m"
f"\033[38;5;245m|\033[0m \033[38;5;208m{time_str}\033[0m "
f"\033[38;5;245m|\033[0m \033[38;5;220m{scroll_str}\033[0m"
)
lines.append(line1[:width])
@@ -156,9 +170,14 @@ class ReplEffect(EffectPlugin):
)
lines.append(line2[:width])
# Line 3: Output buffer count
# Line 3: Output buffer count with scroll indicator
out_count = len(self.state.output_buffer)
line3 = f"\033[38;5;44mOUTPUT:\033[0m \033[1;38;5;227m{out_count}\033[0m lines"
scroll_pos = f"({self.state.scroll_offset}/{out_count})"
line3 = (
f"\033[38;5;44mOUTPUT:\033[0m "
f"\033[1;38;5;227m{out_count}\033[0m lines "
f"\033[38;5;245m{scroll_pos}\033[0m"
)
lines.append(line3[:width])
return lines
@@ -170,12 +189,16 @@ class ReplEffect(EffectPlugin):
# Calculate how many output lines to show
# Reserve 1 line for input prompt
output_height = height - 1
output_start = max(0, len(self.state.output_buffer) - output_height)
# Manual scroll: scroll_offset=0 means show bottom of buffer
# scroll_offset increases as you scroll up through history
buffer_len = len(self.state.output_buffer)
output_start = max(0, buffer_len - output_height - self.state.scroll_offset)
# Render output buffer
for i in range(output_height):
idx = output_start + i
if idx < len(self.state.output_buffer):
if idx < buffer_len:
line = self.state.output_buffer[idx][:width]
lines.append(line)
else:
@@ -191,6 +214,25 @@ class ReplEffect(EffectPlugin):
return lines
def scroll_output(self, delta: int) -> None:
"""Scroll the output buffer by delta lines.
Args:
delta: Positive to scroll up (back in time), negative to scroll down
"""
if not self.state.output_buffer:
return
# Calculate max scroll (can't scroll past top of buffer)
max_scroll = max(0, len(self.state.output_buffer) - 1)
# Update scroll offset
self.state.scroll_offset = max(
0, min(max_scroll, self.state.scroll_offset + delta)
)
# Reset scroll when new output arrives (handled in process_command)
def _get_metrics(self, ctx: EffectContext) -> dict:
"""Get pipeline metrics from context."""
metrics = ctx.get_state("metrics")
@@ -230,6 +272,9 @@ class ReplEffect(EffectPlugin):
# Add to output buffer
self.state.output_buffer.append(f"> {cmd}")
# Reset scroll offset when new output arrives (scroll to bottom)
self.state.scroll_offset = 0
# Parse command
parts = cmd.split()
cmd_name = parts[0].lower()
@@ -322,13 +367,17 @@ class ReplEffect(EffectPlugin):
self.state.output_buffer.append("No context available")
def _cmd_available(self, ctx: EffectContext | None):
"""List all available effect types."""
"""List all available effect types and stage categories."""
try:
from engine.effects import get_registry
from engine.effects.plugins import discover_plugins
from engine.pipeline.registry import StageRegistry, discover_stages
# Discover plugins if not already done
# Discover plugins and stages if not already done
discover_plugins()
discover_stages()
# List effect types from registry
registry = get_registry()
all_effects = registry.list_all()
@@ -338,8 +387,20 @@ class ReplEffect(EffectPlugin):
self.state.output_buffer.append(f" - {name}")
else:
self.state.output_buffer.append("No effects registered")
# List stage categories and their types
categories = StageRegistry.list_categories()
if categories:
self.state.output_buffer.append("")
self.state.output_buffer.append("Stage categories:")
for category in sorted(categories):
stages = StageRegistry.list(category)
if stages:
self.state.output_buffer.append(f" {category}:")
for stage_name in sorted(stages):
self.state.output_buffer.append(f" - {stage_name}")
except Exception as e:
self.state.output_buffer.append(f"Error listing effects: {e}")
self.state.output_buffer.append(f"Error listing available types: {e}")
def _cmd_effect(self, args: list[str], ctx: EffectContext | None):
"""Toggle effect on/off."""