"""UI Node: stateful renderer — manages UI state, handles local actions, renders controls.""" import json import logging from .base import Node from ..types import ThoughtResult log = logging.getLogger("runtime") class UINode(Node): name = "ui" # No model — pure code, no LLM calls def __init__(self, send_hud): super().__init__(send_hud) self.current_controls: list[dict] = [] self.state: dict = {} # {"count": 0, "theme": "dark", ...} self.bindings: dict = {} # {"increment": {"op": "inc", "var": "count"}, ...} # --- State operations --- def set_var(self, name: str, value) -> str: self.state[name] = value log.info(f"[ui] state: {name} = {value}") return f"{name} = {value}" def get_var(self, name: str): return self.state.get(name) def handle_local_action(self, action: str, payload: dict = None) -> str | None: """Try to handle an action locally. Returns result text or None if not local.""" binding = self.bindings.get(action) if not binding: return None op = binding.get("op") var = binding.get("var") step = binding.get("step", 1) if var not in self.state: return None if op == "inc": self.state[var] += step elif op == "dec": self.state[var] -= step elif op == "set" and payload and "value" in payload: self.state[var] = payload["value"] elif op == "toggle": self.state[var] = not self.state[var] else: return None log.info(f"[ui] local action {action}: {var} = {self.state[var]}") return f"{var} is now {self.state[var]}" # --- Action parsing from Thinker --- def _parse_thinker_actions(self, actions: list[dict]) -> list[dict]: """Parse Thinker's actions, register bindings, return controls.""" controls = [] for action in actions: act = action.get("action", "") label = action.get("label", "Action") payload = action.get("payload", {}) # Detect state-binding hints in payload if "var" in payload and "op" in payload: self.bindings[act] = { "op": payload["op"], "var": payload["var"], "step": payload.get("step", 1), } # Auto-create state var if it doesn't exist if payload["var"] not in self.state: self.set_var(payload["var"], payload.get("initial", 0)) log.info(f"[ui] bound action '{act}' -> {payload['op']} {payload['var']}") controls.append({ "type": "button", "label": label, "action": act, "payload": payload, }) return controls # --- Table extraction --- def _extract_table(self, tool_output: str) -> dict | None: if not tool_output: return None lines = [l.strip() for l in tool_output.strip().split("\n") if l.strip()] if len(lines) < 2: return None if " | " in lines[0]: columns = [c.strip() for c in lines[0].split(" | ")] data = [] for line in lines[1:]: if line.startswith("-") or line.startswith("="): continue vals = [v.strip() for v in line.split(" | ")] if len(vals) == len(columns): data.append(dict(zip(columns, vals))) if data: return {"type": "table", "columns": columns, "data": data} if lines[0].startswith("Table:"): if len(lines) >= 2 and " | " in lines[1]: columns = [c.strip() for c in lines[1].split(" | ")] data = [] for line in lines[2:]: vals = [v.strip() for v in line.split(" | ")] if len(vals) == len(columns): data.append(dict(zip(columns, vals))) if data: return {"type": "table", "columns": columns, "data": data} return None # --- Render controls --- def _build_controls(self, thought: ThoughtResult) -> list[dict]: controls = [] # 1. Parse actions from Thinker (registers bindings) if thought.actions: controls.extend(self._parse_thinker_actions(thought.actions)) # 2. Add labels for bound state variables for var, value in self.state.items(): controls.append({ "type": "label", "id": f"var_{var}", "text": var, "value": str(value), }) # 3. Extract tables from tool output if thought.tool_output: table = self._extract_table(thought.tool_output) if table: controls.append(table) # 4. Add label for short tool results (if no table and no state vars) if thought.tool_used and thought.tool_output and not any(c["type"] == "table" for c in controls): output = thought.tool_output.strip() if "\n" not in output and len(output) < 100 and not self.state: controls.append({ "type": "label", "id": "tool_result", "text": thought.tool_used, "value": output, }) return controls async def process(self, thought: ThoughtResult, history: list[dict], memory_context: str = "") -> list[dict]: controls = self._build_controls(thought) if controls: self.current_controls = controls await self.hud("controls", controls=controls) log.info(f"[ui] emitting {len(controls)} controls") else: if self.current_controls: controls = self.current_controls await self.hud("decided", instruction="no new controls") return controls async def process_local_action(self, action: str, payload: dict = None) -> tuple[str | None, list[dict]]: """Handle a local action. Returns (result_text, updated_controls) or (None, []) if not local.""" result = self.handle_local_action(action, payload) if result is None: return None, [] # Re-render controls with updated state controls = [] # Re-add existing buttons for ctrl in self.current_controls: if ctrl["type"] == "button": controls.append(ctrl) # Update labels with current state for var, value in self.state.items(): controls.append({ "type": "label", "id": f"var_{var}", "text": var, "value": str(value), }) self.current_controls = controls await self.hud("controls", controls=controls) log.info(f"[ui] re-rendered after local action: {action}") return result, controls