forked from genewildish/Mainline
Add REPL effect detection and input handling to pipeline runner
- Detect REPL effect in pipeline and enable interactive mode - Enable raw terminal mode for REPL input capture - Add keyboard input loop for REPL commands (return, up/down arrows, backspace) - Process commands and handle pipeline mutations from REPL - Fix lint issues in graph and REPL modules (type annotations, imports)
This commit is contained in:
@@ -433,6 +433,16 @@ def run_pipeline_mode(preset_name: str = "demo", graph_config: str | None = None
|
|||||||
ui_panel = None
|
ui_panel = None
|
||||||
render_ui_panel_in_terminal = False
|
render_ui_panel_in_terminal = False
|
||||||
|
|
||||||
|
# Check for REPL effect in pipeline
|
||||||
|
repl_effect = None
|
||||||
|
for stage in pipeline.stages.values():
|
||||||
|
if isinstance(stage, EffectPluginStage) and stage._effect.name == "repl":
|
||||||
|
repl_effect = stage._effect
|
||||||
|
print(
|
||||||
|
" \033[38;5;46mREPL effect detected - Interactive mode enabled\033[0m"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
if need_ui_controller:
|
if need_ui_controller:
|
||||||
from engine.display import render_ui_panel
|
from engine.display import render_ui_panel
|
||||||
|
|
||||||
@@ -448,6 +458,10 @@ def run_pipeline_mode(preset_name: str = "demo", graph_config: str | None = None
|
|||||||
if hasattr(display, "set_raw_mode"):
|
if hasattr(display, "set_raw_mode"):
|
||||||
display.set_raw_mode(True)
|
display.set_raw_mode(True)
|
||||||
|
|
||||||
|
# Enable raw mode for REPL if present and not already enabled
|
||||||
|
elif repl_effect and hasattr(display, "set_raw_mode"):
|
||||||
|
display.set_raw_mode(True)
|
||||||
|
|
||||||
# Register effect plugin stages from pipeline for UI control
|
# Register effect plugin stages from pipeline for UI control
|
||||||
for stage in pipeline.stages.values():
|
for stage in pipeline.stages.values():
|
||||||
if isinstance(stage, EffectPluginStage):
|
if isinstance(stage, EffectPluginStage):
|
||||||
@@ -966,6 +980,38 @@ def run_pipeline_mode(preset_name: str = "demo", graph_config: str | None = None
|
|||||||
else:
|
else:
|
||||||
display.show(result.data, border=show_border)
|
display.show(result.data, border=show_border)
|
||||||
|
|
||||||
|
# --- REPL Input Handling ---
|
||||||
|
if repl_effect and hasattr(display, "get_input_keys"):
|
||||||
|
# Get keyboard input (non-blocking)
|
||||||
|
keys = display.get_input_keys(timeout=0.0)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
if key == "return":
|
||||||
|
# Get command string before processing
|
||||||
|
cmd_str = repl_effect.state.current_command
|
||||||
|
if cmd_str:
|
||||||
|
repl_effect.process_command(cmd_str, ctx)
|
||||||
|
# Check for pending pipeline mutations
|
||||||
|
pending = repl_effect.get_pending_command()
|
||||||
|
if pending:
|
||||||
|
_handle_pipeline_mutation(pipeline, pending)
|
||||||
|
# Broadcast state update if WebSocket is active
|
||||||
|
if web_control_active and isinstance(
|
||||||
|
display, WebSocketDisplay
|
||||||
|
):
|
||||||
|
state = display._get_state_snapshot()
|
||||||
|
if state:
|
||||||
|
display.broadcast_state(state)
|
||||||
|
elif key == "up":
|
||||||
|
repl_effect.navigate_history(-1)
|
||||||
|
elif key == "down":
|
||||||
|
repl_effect.navigate_history(1)
|
||||||
|
elif key == "backspace":
|
||||||
|
repl_effect.backspace()
|
||||||
|
elif len(key) == 1:
|
||||||
|
repl_effect.append_to_command(key)
|
||||||
|
# --- End REPL Input Handling ---
|
||||||
|
|
||||||
if hasattr(display, "is_quit_requested") and display.is_quit_requested():
|
if hasattr(display, "is_quit_requested") and display.is_quit_requested():
|
||||||
if hasattr(display, "clear_quit_request"):
|
if hasattr(display, "clear_quit_request"):
|
||||||
display.clear_quit_request()
|
display.clear_quit_request()
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ class TerminalDisplay:
|
|||||||
keys.append("escape")
|
keys.append("escape")
|
||||||
elif char.isprintable():
|
elif char.isprintable():
|
||||||
keys.append(char)
|
keys.append(char)
|
||||||
except (OSError, IOError):
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return keys
|
return keys
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ Keyboard:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from engine.effects.types import (
|
from engine.effects.types import (
|
||||||
EffectConfig,
|
EffectConfig,
|
||||||
@@ -69,7 +68,7 @@ class ReplEffect(EffectPlugin):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.state = REPLState()
|
self.state = REPLState()
|
||||||
self._last_metrics: Optional[dict] = None
|
self._last_metrics: dict | None = None
|
||||||
|
|
||||||
def process_partial(
|
def process_partial(
|
||||||
self, buf: list[str], ctx: EffectContext, partial: PartialUpdate
|
self, buf: list[str], ctx: EffectContext, partial: PartialUpdate
|
||||||
@@ -82,8 +81,6 @@ class ReplEffect(EffectPlugin):
|
|||||||
|
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
"""Render buffer with REPL overlay."""
|
"""Render buffer with REPL overlay."""
|
||||||
result = list(buf)
|
|
||||||
|
|
||||||
# Get display dimensions from context
|
# Get display dimensions from context
|
||||||
height = ctx.terminal_height if hasattr(ctx, "terminal_height") else len(buf)
|
height = ctx.terminal_height if hasattr(ctx, "terminal_height") else len(buf)
|
||||||
width = ctx.terminal_width if hasattr(ctx, "terminal_width") else 80
|
width = ctx.terminal_width if hasattr(ctx, "terminal_width") else 80
|
||||||
@@ -94,7 +91,6 @@ class ReplEffect(EffectPlugin):
|
|||||||
|
|
||||||
# Reserve space for REPL at bottom
|
# Reserve space for REPL at bottom
|
||||||
# HUD uses top 3 lines if enabled
|
# HUD uses top 3 lines if enabled
|
||||||
hud_lines = 3 if show_hud else 0
|
|
||||||
content_height = max(1, height - repl_height)
|
content_height = max(1, height - repl_height)
|
||||||
|
|
||||||
# Build output
|
# Build output
|
||||||
@@ -220,9 +216,7 @@ class ReplEffect(EffectPlugin):
|
|||||||
|
|
||||||
return {"fps": 0.0, "frame_time": 0.0}
|
return {"fps": 0.0, "frame_time": 0.0}
|
||||||
|
|
||||||
def process_command(
|
def process_command(self, command: str, ctx: EffectContext | None = None) -> None:
|
||||||
self, command: str, ctx: Optional[EffectContext] = None
|
|
||||||
) -> None:
|
|
||||||
"""Process a REPL command."""
|
"""Process a REPL command."""
|
||||||
cmd = command.strip()
|
cmd = command.strip()
|
||||||
if not cmd:
|
if not cmd:
|
||||||
@@ -283,7 +277,7 @@ class ReplEffect(EffectPlugin):
|
|||||||
self.state.output_buffer.append(" clear - Clear output buffer")
|
self.state.output_buffer.append(" clear - Clear output buffer")
|
||||||
self.state.output_buffer.append(" quit - Show exit message")
|
self.state.output_buffer.append(" quit - Show exit message")
|
||||||
|
|
||||||
def _cmd_status(self, ctx: Optional[EffectContext]):
|
def _cmd_status(self, ctx: EffectContext | None):
|
||||||
"""Show pipeline status."""
|
"""Show pipeline status."""
|
||||||
if ctx:
|
if ctx:
|
||||||
metrics = self._get_metrics(ctx)
|
metrics = self._get_metrics(ctx)
|
||||||
@@ -299,7 +293,7 @@ class ReplEffect(EffectPlugin):
|
|||||||
f"History: {len(self.state.command_history)} commands"
|
f"History: {len(self.state.command_history)} commands"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _cmd_effects(self, ctx: Optional[EffectContext]):
|
def _cmd_effects(self, ctx: EffectContext | None):
|
||||||
"""List all effects."""
|
"""List all effects."""
|
||||||
if ctx:
|
if ctx:
|
||||||
# Try to get effect list from context
|
# Try to get effect list from context
|
||||||
@@ -313,7 +307,7 @@ class ReplEffect(EffectPlugin):
|
|||||||
else:
|
else:
|
||||||
self.state.output_buffer.append("No context available")
|
self.state.output_buffer.append("No context available")
|
||||||
|
|
||||||
def _cmd_effect(self, args: list[str], ctx: Optional[EffectContext]):
|
def _cmd_effect(self, args: list[str], ctx: EffectContext | None):
|
||||||
"""Toggle effect on/off."""
|
"""Toggle effect on/off."""
|
||||||
if len(args) < 2:
|
if len(args) < 2:
|
||||||
self.state.output_buffer.append("Usage: effect <name> <on|off>")
|
self.state.output_buffer.append("Usage: effect <name> <on|off>")
|
||||||
@@ -336,7 +330,7 @@ class ReplEffect(EffectPlugin):
|
|||||||
"stage": effect_name,
|
"stage": effect_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _cmd_param(self, args: list[str], ctx: Optional[EffectContext]):
|
def _cmd_param(self, args: list[str], ctx: EffectContext | None):
|
||||||
"""Set effect parameter."""
|
"""Set effect parameter."""
|
||||||
if len(args) < 3:
|
if len(args) < 3:
|
||||||
self.state.output_buffer.append("Usage: param <effect> <param> <value>")
|
self.state.output_buffer.append("Usage: param <effect> <param> <value>")
|
||||||
@@ -362,7 +356,7 @@ class ReplEffect(EffectPlugin):
|
|||||||
"delta": param_value, # Note: This sets absolute value, need adjustment
|
"delta": param_value, # Note: This sets absolute value, need adjustment
|
||||||
}
|
}
|
||||||
|
|
||||||
def _cmd_pipeline(self, ctx: Optional[EffectContext]):
|
def _cmd_pipeline(self, ctx: EffectContext | None):
|
||||||
"""Show current pipeline order."""
|
"""Show current pipeline order."""
|
||||||
if ctx:
|
if ctx:
|
||||||
pipeline_order = ctx.get_state("pipeline_order")
|
pipeline_order = ctx.get_state("pipeline_order")
|
||||||
@@ -375,7 +369,7 @@ class ReplEffect(EffectPlugin):
|
|||||||
else:
|
else:
|
||||||
self.state.output_buffer.append("No context available")
|
self.state.output_buffer.append("No context available")
|
||||||
|
|
||||||
def get_pending_command(self) -> Optional[dict]:
|
def get_pending_command(self) -> dict | None:
|
||||||
"""Get and clear pending command for external handling."""
|
"""Get and clear pending command for external handling."""
|
||||||
cmd = getattr(self, "_pending_command", None)
|
cmd = getattr(self, "_pending_command", None)
|
||||||
if cmd:
|
if cmd:
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Optional, Union
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
class NodeType(Enum):
|
class NodeType(Enum):
|
||||||
@@ -45,7 +45,7 @@ class Node:
|
|||||||
|
|
||||||
name: str
|
name: str
|
||||||
type: NodeType
|
type: NodeType
|
||||||
config: Dict[str, Any] = field(default_factory=dict)
|
config: dict[str, Any] = field(default_factory=dict)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
optional: bool = False
|
optional: bool = False
|
||||||
|
|
||||||
@@ -59,17 +59,17 @@ class Connection:
|
|||||||
|
|
||||||
source: str
|
source: str
|
||||||
target: str
|
target: str
|
||||||
data_type: Optional[str] = None # Optional data type constraint
|
data_type: str | None = None # Optional data type constraint
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Graph:
|
class Graph:
|
||||||
"""Pipeline graph representation."""
|
"""Pipeline graph representation."""
|
||||||
|
|
||||||
nodes: Dict[str, Node] = field(default_factory=dict)
|
nodes: dict[str, Node] = field(default_factory=dict)
|
||||||
connections: List[Connection] = field(default_factory=list)
|
connections: list[Connection] = field(default_factory=list)
|
||||||
|
|
||||||
def node(self, name: str, node_type: Union[NodeType, str], **config) -> "Graph":
|
def node(self, name: str, node_type: NodeType | str, **config) -> "Graph":
|
||||||
"""Add a node to the graph."""
|
"""Add a node to the graph."""
|
||||||
if isinstance(node_type, str):
|
if isinstance(node_type, str):
|
||||||
# Try to parse as NodeType
|
# Try to parse as NodeType
|
||||||
@@ -82,7 +82,7 @@ class Graph:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
def connect(
|
def connect(
|
||||||
self, source: str, target: str, data_type: Optional[str] = None
|
self, source: str, target: str, data_type: str | None = None
|
||||||
) -> "Graph":
|
) -> "Graph":
|
||||||
"""Add a connection between nodes."""
|
"""Add a connection between nodes."""
|
||||||
if source not in self.nodes:
|
if source not in self.nodes:
|
||||||
@@ -99,7 +99,7 @@ class Graph:
|
|||||||
self.connect(names[i], names[i + 1])
|
self.connect(names[i], names[i + 1])
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def from_dict(self, data: Dict[str, Any]) -> "Graph":
|
def from_dict(self, data: dict[str, Any]) -> "Graph":
|
||||||
"""Load graph from dictionary (TOML-compatible)."""
|
"""Load graph from dictionary (TOML-compatible)."""
|
||||||
# Parse nodes
|
# Parse nodes
|
||||||
nodes_data = data.get("nodes", {})
|
nodes_data = data.get("nodes", {})
|
||||||
@@ -127,7 +127,7 @@ class Graph:
|
|||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
"""Convert graph to dictionary."""
|
"""Convert graph to dictionary."""
|
||||||
return {
|
return {
|
||||||
"nodes": {
|
"nodes": {
|
||||||
@@ -140,7 +140,7 @@ class Graph:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
def validate(self) -> List[str]:
|
def validate(self) -> list[str]:
|
||||||
"""Validate graph structure and return list of errors."""
|
"""Validate graph structure and return list of errors."""
|
||||||
errors = []
|
errors = []
|
||||||
|
|
||||||
@@ -166,9 +166,8 @@ class Graph:
|
|||||||
|
|
||||||
temp.add(node_name)
|
temp.add(node_name)
|
||||||
for conn in self.connections:
|
for conn in self.connections:
|
||||||
if conn.source == node_name:
|
if conn.source == node_name and has_cycle(conn.target):
|
||||||
if has_cycle(conn.target):
|
return True
|
||||||
return True
|
|
||||||
temp.remove(node_name)
|
temp.remove(node_name)
|
||||||
visited.add(node_name)
|
visited.add(node_name)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ This module bridges the new graph-based abstraction with the existing
|
|||||||
Stage-based pipeline system for backward compatibility.
|
Stage-based pipeline system for backward compatibility.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from engine.pipeline.graph import Graph, NodeType
|
from engine.camera import Camera
|
||||||
from engine.pipeline.controller import Pipeline, PipelineConfig
|
from engine.data_sources.sources import EmptyDataSource, HeadlinesDataSource
|
||||||
from engine.pipeline.core import PipelineContext
|
from engine.display import DisplayRegistry
|
||||||
from engine.pipeline.params import PipelineParams
|
from engine.effects import get_registry
|
||||||
from engine.pipeline.adapters import (
|
from engine.pipeline.adapters import (
|
||||||
CameraStage,
|
CameraStage,
|
||||||
DataSourceStage,
|
DataSourceStage,
|
||||||
@@ -18,15 +18,12 @@ from engine.pipeline.adapters import (
|
|||||||
FontStage,
|
FontStage,
|
||||||
MessageOverlayStage,
|
MessageOverlayStage,
|
||||||
PositionStage,
|
PositionStage,
|
||||||
ViewportFilterStage,
|
|
||||||
create_stage_from_display,
|
|
||||||
create_stage_from_effect,
|
|
||||||
)
|
)
|
||||||
from engine.pipeline.adapters.positioning import PositioningMode
|
from engine.pipeline.adapters.positioning import PositioningMode
|
||||||
from engine.display import DisplayRegistry
|
from engine.pipeline.controller import Pipeline, PipelineConfig
|
||||||
from engine.effects import get_registry
|
from engine.pipeline.core import PipelineContext
|
||||||
from engine.data_sources.sources import EmptyDataSource, HeadlinesDataSource
|
from engine.pipeline.graph import Graph, NodeType
|
||||||
from engine.camera import Camera
|
from engine.pipeline.params import PipelineParams
|
||||||
|
|
||||||
|
|
||||||
class GraphAdapter:
|
class GraphAdapter:
|
||||||
@@ -34,8 +31,8 @@ class GraphAdapter:
|
|||||||
|
|
||||||
def __init__(self, graph: Graph):
|
def __init__(self, graph: Graph):
|
||||||
self.graph = graph
|
self.graph = graph
|
||||||
self.pipeline: Optional[Pipeline] = None
|
self.pipeline: Pipeline | None = None
|
||||||
self.context: Optional[PipelineContext] = None
|
self.context: PipelineContext | None = None
|
||||||
|
|
||||||
def build_pipeline(
|
def build_pipeline(
|
||||||
self, viewport_width: int = 80, viewport_height: int = 24
|
self, viewport_width: int = 80, viewport_height: int = 24
|
||||||
@@ -154,7 +151,7 @@ def graph_to_pipeline(
|
|||||||
|
|
||||||
|
|
||||||
def dict_to_pipeline(
|
def dict_to_pipeline(
|
||||||
data: Dict[str, Any], viewport_width: int = 80, viewport_height: int = 24
|
data: dict[str, Any], viewport_width: int = 80, viewport_height: int = 24
|
||||||
) -> Pipeline:
|
) -> Pipeline:
|
||||||
"""Convert a dictionary to a Pipeline."""
|
"""Convert a dictionary to a Pipeline."""
|
||||||
graph = Graph().from_dict(data)
|
graph = Graph().from_dict(data)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""TOML-based graph configuration loader."""
|
"""TOML-based graph configuration loader."""
|
||||||
|
|
||||||
import tomllib
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict
|
from typing import Any
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
|
||||||
from engine.pipeline.graph import Graph, NodeType
|
from engine.pipeline.graph import Graph, NodeType
|
||||||
from engine.pipeline.graph_adapter import graph_to_pipeline
|
from engine.pipeline.graph_adapter import graph_to_pipeline
|
||||||
@@ -23,7 +24,7 @@ def load_graph_from_toml(toml_path: str | Path) -> Graph:
|
|||||||
return graph_from_dict(data)
|
return graph_from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
def graph_from_dict(data: Dict[str, Any]) -> Graph:
|
def graph_from_dict(data: dict[str, Any]) -> Graph:
|
||||||
"""Create a graph from a dictionary (TOML-compatible structure).
|
"""Create a graph from a dictionary (TOML-compatible structure).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ providing the same flexibility.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from engine.pipeline.graph import Graph, NodeType
|
from engine.pipeline.graph import Graph, NodeType
|
||||||
from engine.pipeline.graph_adapter import graph_to_pipeline
|
from engine.pipeline.graph_adapter import graph_to_pipeline
|
||||||
@@ -32,7 +32,7 @@ class EffectConfig:
|
|||||||
name: str
|
name: str
|
||||||
intensity: float = 1.0
|
intensity: float = 1.0
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
params: Dict[str, Any] = field(default_factory=dict)
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -70,9 +70,9 @@ class PipelineConfig:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
source: str = "headlines"
|
source: str = "headlines"
|
||||||
camera: Optional[CameraConfig] = None
|
camera: CameraConfig | None = None
|
||||||
effects: List[EffectConfig] = field(default_factory=list)
|
effects: list[EffectConfig] = field(default_factory=list)
|
||||||
display: Optional[DisplayConfig] = None
|
display: DisplayConfig | None = None
|
||||||
viewport_width: int = 80
|
viewport_width: int = 80
|
||||||
viewport_height: int = 24
|
viewport_height: int = 24
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ def load_hybrid_config(toml_path: str | Path) -> PipelineConfig:
|
|||||||
return parse_hybrid_config(data)
|
return parse_hybrid_config(data)
|
||||||
|
|
||||||
|
|
||||||
def parse_hybrid_config(data: Dict[str, Any]) -> PipelineConfig:
|
def parse_hybrid_config(data: dict[str, Any]) -> PipelineConfig:
|
||||||
"""Parse hybrid configuration from dictionary.
|
"""Parse hybrid configuration from dictionary.
|
||||||
|
|
||||||
Expected format:
|
Expected format:
|
||||||
|
|||||||
Reference in New Issue
Block a user