Nico 3f8886cbd2 v0.10.4: stateful UI engine — TDD counter test green (36/36)
RED->GREEN->REFACTOR cycle:
- UI node has state store (key-value), action bindings (op/var), and
  local action handlers (inc/dec/set/toggle — no LLM round-trip)
- Thinker self-model: knows its environment, that ACTIONS create real
  buttons, that UI handles state locally. Emits var/op payload for
  stateful actions.
- Thinker's context includes UI state so it can report current values
- /api/clear resets UI state, bindings, and controls
- Test runner: action_match for fuzzy action names, persistent actions
  across steps, _stream_text restored
- Counter test: 16/16 passed (create, read, inc, inc, dec, verify)
- Pub test: 20/20 passed (conversation, language switch, tool use, mood)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:50:37 +01:00

202 lines
7.0 KiB
Python

"""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