Skip to main content

phi_agent/render/
mod.rs

1//! Event renderers — transform [`RuntimeEvent`] streams into display output.
2//!
3//! Three renderers are provided:
4//! - [`TerminalRenderer`] — colored terminal output with duration/tool-count
5//! - [`JsonStreamRenderer`] — newline-delimited JSON for programmatic consumers
6//! - [`NullRenderer`] — silent no-op for automated/CI scenarios
7
8pub mod json_stream;
9pub mod null;
10pub mod terminal;
11
12pub use json_stream::JsonStreamRenderer;
13pub use null::NullRenderer;
14pub use terminal::TerminalRenderer;
15
16use std::io::{self, Write};
17
18use agent_base::{AgentResult, RuntimeEvent};
19
20/// Event renderer: converts RuntimeEvents into a specific output format.
21///
22/// Each renderer is a pure consumer — it only reads events and produces
23/// output, without modifying Agent state.
24pub trait EventRenderer: Send {
25    /// Process one runtime event.
26    fn render(&mut self, event: RuntimeEvent) -> AgentResult<()>;
27
28    /// End of current turn — renderer may flush / output summary.
29    fn finish_turn(&mut self) -> AgentResult<()>;
30
31    /// End of entire session.
32    fn finish_session(&mut self) -> AgentResult<()> {
33        Ok(())
34    }
35}
36
37/// Output format
38#[derive(Clone, Debug)]
39pub enum OutputFormat {
40    /// Rich terminal output (with colors and emoji)
41    Terminal {
42        /// Whether to display the model's thinking/reasoning content.
43        show_thinking: bool,
44        /// Whether to display tool call arguments.
45        show_tool_args: bool,
46        /// Whether to use ANSI color codes.
47        color: bool,
48    },
49    /// One JSON object per line
50    Json,
51    /// No output
52    Quiet,
53}
54
55/// Create the corresponding renderer for a given output format.
56///
57/// `writer` defaults to stdout (CLI scenario). Web consumers can pass a
58/// custom writer (e.g. a WebSocket sink).
59pub fn create_renderer(format: &OutputFormat, writer: Option<Box<dyn Write + Send>>) -> Box<dyn EventRenderer> {
60    match format {
61        OutputFormat::Terminal { show_thinking, show_tool_args, color } => {
62            let w = writer.unwrap_or_else(|| Box::new(io::stdout()));
63            Box::new(TerminalRenderer::new(*show_thinking, *show_tool_args, *color, w))
64        },
65        OutputFormat::Json => {
66            let w = writer.unwrap_or_else(|| Box::new(io::stdout()));
67            Box::new(JsonStreamRenderer::new(w))
68        },
69        OutputFormat::Quiet => Box::new(NullRenderer),
70    }
71}
72
73/// Create a renderer using stdout (backward-compatible).
74pub fn create_stdout_renderer(format: &OutputFormat) -> Box<dyn EventRenderer> {
75    create_renderer(format, None)
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use agent_base::SessionId;
82    use std::io::Write;
83    use std::sync::{Arc, Mutex};
84
85    struct SharedWriter {
86        inner: Arc<Mutex<Vec<u8>>>,
87    }
88
89    impl Write for SharedWriter {
90        fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
91            self.inner.lock().unwrap().extend_from_slice(data);
92            Ok(data.len())
93        }
94        fn flush(&mut self) -> std::io::Result<()> {
95            Ok(())
96        }
97    }
98
99    fn session_id() -> SessionId {
100        SessionId { id: 1, external_id: None }
101    }
102
103    #[test]
104    fn test_create_terminal_renderer() {
105        let (writer, buf) = {
106            let inner = Arc::new(Mutex::new(Vec::new()));
107            (SharedWriter { inner: inner.clone() }, inner)
108        };
109        let format = OutputFormat::Terminal { show_thinking: true, show_tool_args: true, color: false };
110        let mut r = create_renderer(&format, Some(Box::new(writer)));
111        r.render(RuntimeEvent::TextDelta {
112            session_id: session_id(),
113            text: "hello".into(),
114            agent_id: None,
115            trace_id: None,
116        })
117        .unwrap();
118        drop(r);
119        let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
120        assert!(out.contains("hello"));
121        assert!(!out.contains('\x1b')); // color disabled
122    }
123
124    #[test]
125    fn test_create_json_renderer() {
126        let (writer, buf) = {
127            let inner = Arc::new(Mutex::new(Vec::new()));
128            (SharedWriter { inner: inner.clone() }, inner)
129        };
130        let mut r = create_renderer(&OutputFormat::Json, Some(Box::new(writer)));
131        r.render(RuntimeEvent::TextDelta {
132            session_id: session_id(),
133            text: "world".into(),
134            agent_id: None,
135            trace_id: None,
136        })
137        .unwrap();
138        drop(r);
139        let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
140        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
141        assert_eq!(v["type"], "text_delta");
142    }
143
144    #[test]
145    fn test_create_quiet_renderer() {
146        let (writer, buf) = {
147            let inner = Arc::new(Mutex::new(Vec::new()));
148            (SharedWriter { inner: inner.clone() }, inner)
149        };
150        let mut r = create_renderer(&OutputFormat::Quiet, Some(Box::new(writer)));
151        r.render(RuntimeEvent::TextDelta {
152            session_id: session_id(),
153            text: "quiet".into(),
154            agent_id: None,
155            trace_id: None,
156        })
157        .unwrap();
158        r.finish_turn().unwrap();
159        drop(r);
160        let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
161        assert!(out.is_empty());
162    }
163
164    #[test]
165    fn test_null_renderer_consumes_events_silently() {
166        let mut r = NullRenderer;
167        let sid = session_id();
168
169        // Representative framework events should all succeed and produce no output.
170        assert!(
171            r.render(RuntimeEvent::TextDelta {
172                session_id: sid.clone(),
173                text: "hello".into(),
174                agent_id: None,
175                trace_id: None
176            })
177            .is_ok()
178        );
179        assert!(
180            r.render(RuntimeEvent::ThoughtDelta {
181                session_id: sid.clone(),
182                text: "thinking".into(),
183                agent_id: None,
184                trace_id: None
185            })
186            .is_ok()
187        );
188        assert!(
189            r.render(RuntimeEvent::ToolCallStarted {
190                session_id: sid.clone(),
191                tool_name: "test_tool".into(),
192                args_json: "{}".into(),
193                agent_id: None,
194                trace_id: None
195            })
196            .is_ok()
197        );
198        assert!(
199            r.render(RuntimeEvent::ToolCallFinished {
200                session_id: sid.clone(),
201                tool_name: "test_tool".into(),
202                summary: "done".into(),
203                agent_id: None,
204                trace_id: None
205            })
206            .is_ok()
207        );
208        assert!(
209            r.render(RuntimeEvent::UserEvent {
210                session_id: sid.clone(),
211                event: agent_base::UserEvent::Progress { text: "progress".into() },
212                agent_id: None,
213                trace_id: None
214            })
215            .is_ok()
216        );
217        assert!(
218            r.render(RuntimeEvent::PlanUpdated {
219                session_id: sid.clone(),
220                objective: "obj".into(),
221                explanation: None,
222                plan: vec![],
223                agent_id: None,
224                trace_id: None
225            })
226            .is_ok()
227        );
228        assert!(
229            r.render(RuntimeEvent::RunFinished { session_id: sid.clone(), agent_id: None, trace_id: None }).is_ok()
230        );
231        assert!(r.finish_turn().is_ok());
232        assert!(r.finish_session().is_ok());
233    }
234}