Skip to content

Commit 51445ed

Browse files
committed
Make the TUI faster and interactive
1 parent ad5af03 commit 51445ed

10 files changed

Lines changed: 4139 additions & 203 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Project-specific
22
.benchmarks/
33
.kiro/
4+
Reference/
45

56
# Byte-compiled / optimized / DLL files
67
__pycache__/

rlm_code/ui/agent_collab_view.py

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
"""
2+
Agent collaboration / pipeline view for the RLM Code TUI.
3+
4+
Visualizes multi-step RLM runs, delegation chains, and agent state
5+
as a Rich renderable that can be written into a RichLog.
6+
7+
Based on SuperQode's agent_collab.py (predefined agent roles with icons/colors,
8+
animated active state, issue tracking, ASCII art boxes) and Toad's conversation
9+
widget patterns.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from dataclasses import dataclass, field
15+
from enum import Enum
16+
from time import time
17+
from typing import Sequence
18+
19+
from rich.console import Console, ConsoleOptions, RenderResult
20+
from rich.panel import Panel
21+
from rich.table import Table
22+
from rich.text import Text
23+
24+
from .design_system import PALETTE, ICONS, SPINNER_FRAMES
25+
26+
27+
class AgentState(Enum):
28+
"""Lifecycle state of an agent node in the pipeline."""
29+
30+
IDLE = "idle"
31+
ACTIVE = "active"
32+
COMPLETE = "complete"
33+
ERROR = "error"
34+
PENDING = "pending"
35+
36+
37+
# (symbol, color) per state.
38+
STATE_SYMBOLS: dict[AgentState, tuple[str, str]] = {
39+
AgentState.IDLE: (ICONS["idle"], PALETTE.text_disabled),
40+
AgentState.ACTIVE: (ICONS["active"], PALETTE.info_bright),
41+
AgentState.COMPLETE: (ICONS["complete"], PALETTE.success),
42+
AgentState.ERROR: (ICONS["error"], PALETTE.error),
43+
AgentState.PENDING: (ICONS["pending"], PALETTE.warning),
44+
}
45+
46+
47+
# Predefined agent roles (from SuperQode).
48+
@dataclass(frozen=True)
49+
class AgentRole:
50+
"""A predefined role with icon and color."""
51+
icon: str
52+
label: str
53+
color: str
54+
55+
56+
AGENT_ROLES: dict[str, AgentRole] = {
57+
"scout": AgentRole(icon=ICONS["scout"], label="Scout", color="#f59e0b"),
58+
"verifier": AgentRole(icon=ICONS["verifier"], label="Verifier", color="#3b82f6"),
59+
"reviewer": AgentRole(icon=ICONS["reviewer"], label="Reviewer", color="#8b5cf6"),
60+
"fixer": AgentRole(icon=ICONS["fixer"], label="Fixer", color="#22c55e"),
61+
"tester": AgentRole(icon=ICONS["tester"], label="Tester", color="#06b6d4"),
62+
"guardian": AgentRole(icon=ICONS["guardian"], label="Guardian", color="#ef4444"),
63+
"runner": AgentRole(icon=ICONS["agent"], label="Runner", color=PALETTE.primary),
64+
"planner": AgentRole(icon="\U0001f4cb", label="Planner", color=PALETTE.info_bright),
65+
"coder": AgentRole(icon="\U0001f4bb", label="Coder", color=PALETTE.success),
66+
"delegate": AgentRole(icon=ICONS["arrow_right"], label="Delegate", color=PALETTE.text_muted),
67+
}
68+
69+
70+
@dataclass
71+
class AgentNode:
72+
"""A single agent in the collaboration pipeline."""
73+
74+
name: str
75+
role: str = ""
76+
state: AgentState = AgentState.IDLE
77+
current_task: str = ""
78+
step: int = 0
79+
total_steps: int = 0
80+
reward: float = 0.0
81+
started_at: float = 0.0
82+
finished_at: float = 0.0
83+
error_message: str = ""
84+
issues_found: int = 0
85+
issues_verified: int = 0
86+
87+
@property
88+
def elapsed(self) -> float:
89+
end = self.finished_at if self.finished_at else time()
90+
return end - self.started_at if self.started_at else 0.0
91+
92+
@property
93+
def role_info(self) -> AgentRole:
94+
"""Return the role metadata, falling back to a default."""
95+
return AGENT_ROLES.get(self.role.lower(), AgentRole(
96+
icon=ICONS["agent"], label=self.role or "Agent", color=PALETTE.text_muted
97+
))
98+
99+
100+
@dataclass
101+
class Handoff:
102+
"""A message passed between two agents."""
103+
104+
from_agent: str
105+
to_agent: str
106+
message: str = ""
107+
issue_count: int = 0
108+
timestamp: float = field(default_factory=time)
109+
110+
111+
class AgentPipeline:
112+
"""Manages an ordered list of agents and their handoffs."""
113+
114+
def __init__(self) -> None:
115+
self.nodes: list[AgentNode] = []
116+
self.handoffs: list[Handoff] = []
117+
self._node_map: dict[str, AgentNode] = {}
118+
self._frame_index: int = 0
119+
120+
def add_agent(self, name: str, role: str = "") -> AgentNode:
121+
node = AgentNode(name=name, role=role)
122+
self.nodes.append(node)
123+
self._node_map[name] = node
124+
return node
125+
126+
def get_agent(self, name: str) -> AgentNode | None:
127+
return self._node_map.get(name)
128+
129+
def set_state(self, name: str, state: AgentState, task: str = "") -> None:
130+
node = self._node_map.get(name)
131+
if not node:
132+
return
133+
node.state = state
134+
if task:
135+
node.current_task = task
136+
if state == AgentState.ACTIVE and not node.started_at:
137+
node.started_at = time()
138+
if state in (AgentState.COMPLETE, AgentState.ERROR):
139+
node.finished_at = time()
140+
141+
def add_handoff(
142+
self,
143+
from_agent: str,
144+
to_agent: str,
145+
message: str = "",
146+
issue_count: int = 0,
147+
) -> None:
148+
self.handoffs.append(Handoff(from_agent, to_agent, message, issue_count))
149+
150+
def update_progress(self, name: str, step: int, total: int, reward: float = 0.0) -> None:
151+
node = self._node_map.get(name)
152+
if not node:
153+
return
154+
node.step = step
155+
node.total_steps = total
156+
node.reward = reward
157+
158+
def update_issues(self, name: str, found: int = 0, verified: int = 0) -> None:
159+
"""Update issue tracking for an agent."""
160+
node = self._node_map.get(name)
161+
if not node:
162+
return
163+
node.issues_found = found
164+
node.issues_verified = verified
165+
166+
def set_status(self, message: str) -> None:
167+
"""Set a status message on the currently active agent."""
168+
for node in self.nodes:
169+
if node.state == AgentState.ACTIVE:
170+
node.current_task = message
171+
break
172+
173+
def clear(self) -> None:
174+
"""Reset the pipeline."""
175+
self.nodes.clear()
176+
self.handoffs.clear()
177+
self._node_map.clear()
178+
self._frame_index = 0
179+
180+
@property
181+
def has_active(self) -> bool:
182+
return any(n.state == AgentState.ACTIVE for n in self.nodes)
183+
184+
def tick_animation(self) -> None:
185+
"""Advance the animation frame counter."""
186+
self._frame_index += 1
187+
188+
@property
189+
def active_symbol(self) -> str:
190+
"""Animated symbol for ACTIVE state."""
191+
return SPINNER_FRAMES[self._frame_index % len(SPINNER_FRAMES)]
192+
193+
194+
class PipelineRenderable:
195+
"""Rich renderable showing the agent pipeline as a vertical flow.
196+
197+
Usage: ``chat_log.write(PipelineRenderable(pipeline))``
198+
"""
199+
200+
def __init__(self, pipeline: AgentPipeline, title: str = "Agent Pipeline") -> None:
201+
self.pipeline = pipeline
202+
self.title = title
203+
204+
def __rich_console__(
205+
self, console: Console, options: ConsoleOptions
206+
) -> RenderResult:
207+
table = Table(
208+
show_header=True,
209+
header_style=f"bold {PALETTE.primary_lighter}",
210+
border_style=PALETTE.border_default,
211+
expand=True,
212+
padding=(0, 1),
213+
)
214+
table.add_column("", width=3, justify="center")
215+
table.add_column("Agent", min_width=12)
216+
table.add_column("Role", min_width=10)
217+
table.add_column("Task", min_width=14)
218+
table.add_column("Progress", min_width=10, justify="center")
219+
table.add_column("Reward", min_width=8, justify="right")
220+
table.add_column("Time", min_width=8, justify="right")
221+
222+
for node in self.pipeline.nodes:
223+
if node.state == AgentState.ACTIVE:
224+
symbol = self.pipeline.active_symbol
225+
color = PALETTE.info_bright
226+
else:
227+
symbol, color = STATE_SYMBOLS[node.state]
228+
status_text = Text(symbol, style=f"bold {color}")
229+
230+
role_info = node.role_info
231+
role_display = Text()
232+
role_display.append(f"{role_info.icon} ", style=role_info.color)
233+
role_display.append(role_info.label, style=role_info.color)
234+
235+
progress = ""
236+
if node.total_steps > 0:
237+
progress = f"{node.step}/{node.total_steps}"
238+
239+
elapsed = ""
240+
if node.elapsed > 0:
241+
secs = node.elapsed
242+
if secs >= 60:
243+
elapsed = f"{int(secs // 60)}m {int(secs % 60)}s"
244+
else:
245+
elapsed = f"{secs:.1f}s"
246+
247+
reward_text = Text()
248+
if node.state in (AgentState.ACTIVE, AgentState.COMPLETE):
249+
rcolor = PALETTE.success if node.reward >= 0.5 else PALETTE.warning
250+
reward_text = Text(f"{node.reward:.2f}", style=rcolor)
251+
252+
task_text = Text()
253+
if node.current_task:
254+
truncated = node.current_task[:20] + "..." if len(node.current_task) > 23 else node.current_task
255+
task_text = Text(truncated, style=PALETTE.text_hint)
256+
257+
table.add_row(
258+
status_text,
259+
Text(node.name, style=f"bold {PALETTE.text_body}"),
260+
role_display,
261+
task_text,
262+
Text(progress, style=PALETTE.text_secondary),
263+
reward_text,
264+
Text(elapsed, style=PALETTE.text_dim),
265+
)
266+
267+
# Show handoffs below the table if any.
268+
content = Text()
269+
if self.pipeline.handoffs:
270+
content.append("\n")
271+
for ho in self.pipeline.handoffs[-5:]: # last 5 handoffs
272+
content.append(f" {ho.from_agent}", style=f"bold {PALETTE.info_bright}")
273+
content.append(f" {ICONS['arrow_right']} ", style=PALETTE.text_dim)
274+
content.append(f"{ho.to_agent}", style=f"bold {PALETTE.accent_light}")
275+
if ho.issue_count:
276+
content.append(f" [{ho.issue_count} issues]", style=PALETTE.warning)
277+
if ho.message:
278+
content.append(f" {ho.message}", style=PALETTE.text_hint)
279+
content.append("\n")
280+
281+
yield Panel(
282+
table,
283+
title=f"[{PALETTE.primary_lighter}]{self.title}[/]",
284+
border_style=PALETTE.border_primary,
285+
padding=(0, 0),
286+
)
287+
if self.pipeline.handoffs:
288+
yield content
289+
290+
291+
def create_pipeline_from_events(events: list[dict]) -> AgentPipeline:
292+
"""Build an AgentPipeline from a list of RLM event dicts.
293+
294+
Expected event dict keys: event_type, agent_name, step, total_steps,
295+
reward, from_agent, to_agent, message.
296+
"""
297+
pipeline = AgentPipeline()
298+
299+
for ev in events:
300+
event_type = ev.get("event_type", "")
301+
name = ev.get("agent_name", "")
302+
303+
if event_type == "RUN_START":
304+
node = pipeline.add_agent(name, role=ev.get("role", "runner"))
305+
node.state = AgentState.ACTIVE
306+
node.started_at = ev.get("timestamp", time())
307+
308+
elif event_type == "ITERATION_END":
309+
pipeline.update_progress(
310+
name,
311+
step=ev.get("step", 0),
312+
total=ev.get("total_steps", 0),
313+
reward=ev.get("reward", 0.0),
314+
)
315+
316+
elif event_type == "RUN_END":
317+
pipeline.set_state(name, AgentState.COMPLETE)
318+
319+
elif event_type == "RUN_ERROR":
320+
node = pipeline.get_agent(name)
321+
if node:
322+
node.error_message = ev.get("error", "")
323+
pipeline.set_state(name, AgentState.ERROR)
324+
325+
elif event_type == "DELEGATE":
326+
from_name = ev.get("from_agent", name)
327+
to_name = ev.get("to_agent", "")
328+
if to_name:
329+
if not pipeline.get_agent(to_name):
330+
pipeline.add_agent(to_name, role="delegate")
331+
pipeline.set_state(to_name, AgentState.PENDING)
332+
pipeline.add_handoff(
333+
from_name, to_name,
334+
ev.get("message", ""),
335+
ev.get("issue_count", 0),
336+
)
337+
338+
return pipeline

0 commit comments

Comments
 (0)