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
55impl Default for OutputFormat {
56    /// Sensible terminal defaults: tool args shown, thinking hidden, colors on.
57    fn default() -> Self {
58        OutputFormat::Terminal { show_thinking: false, show_tool_args: true, color: true }
59    }
60}
61
62/// Create the corresponding renderer for a given output format.
63///
64/// `writer` defaults to stdout (CLI scenario). Web consumers can pass a
65/// custom writer (e.g. a WebSocket sink).
66pub fn create_renderer(format: &OutputFormat, writer: Option<Box<dyn Write + Send>>) -> Box<dyn EventRenderer> {
67    match format {
68        OutputFormat::Terminal { show_thinking, show_tool_args, color } => {
69            let w = writer.unwrap_or_else(|| Box::new(io::stdout()));
70            Box::new(TerminalRenderer::new(*show_thinking, *show_tool_args, *color, w))
71        },
72        OutputFormat::Json => {
73            let w = writer.unwrap_or_else(|| Box::new(io::stdout()));
74            Box::new(JsonStreamRenderer::new(w))
75        },
76        OutputFormat::Quiet => Box::new(NullRenderer),
77    }
78}
79
80/// Create a renderer using stdout (backward-compatible).
81pub fn create_stdout_renderer(format: &OutputFormat) -> Box<dyn EventRenderer> {
82    create_renderer(format, None)
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use agent_base::SessionId;
89    use std::io::Write;
90    use std::sync::{Arc, Mutex};
91
92    struct SharedWriter {
93        inner: Arc<Mutex<Vec<u8>>>,
94    }
95
96    impl Write for SharedWriter {
97        fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
98            self.inner.lock().unwrap().extend_from_slice(data);
99            Ok(data.len())
100        }
101        fn flush(&mut self) -> std::io::Result<()> {
102            Ok(())
103        }
104    }
105
106    fn session_id() -> SessionId {
107        SessionId { id: 1, external_id: None }
108    }
109
110    #[test]
111    fn test_create_terminal_renderer() {
112        let (writer, buf) = {
113            let inner = Arc::new(Mutex::new(Vec::new()));
114            (SharedWriter { inner: inner.clone() }, inner)
115        };
116        let format = OutputFormat::Terminal { show_thinking: true, show_tool_args: true, color: false };
117        let mut r = create_renderer(&format, Some(Box::new(writer)));
118        r.render(RuntimeEvent::TextDelta {
119            session_id: session_id(),
120            text: "hello".into(),
121            agent_id: None,
122            trace_id: None,
123        })
124        .unwrap();
125        drop(r);
126        let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
127        assert!(out.contains("hello"));
128        assert!(!out.contains('\x1b')); // color disabled
129    }
130
131    #[test]
132    fn test_create_json_renderer() {
133        let (writer, buf) = {
134            let inner = Arc::new(Mutex::new(Vec::new()));
135            (SharedWriter { inner: inner.clone() }, inner)
136        };
137        let mut r = create_renderer(&OutputFormat::Json, Some(Box::new(writer)));
138        r.render(RuntimeEvent::TextDelta {
139            session_id: session_id(),
140            text: "world".into(),
141            agent_id: None,
142            trace_id: None,
143        })
144        .unwrap();
145        drop(r);
146        let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
147        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
148        assert_eq!(v["type"], "text_delta");
149    }
150
151    #[test]
152    fn test_create_quiet_renderer() {
153        let (writer, buf) = {
154            let inner = Arc::new(Mutex::new(Vec::new()));
155            (SharedWriter { inner: inner.clone() }, inner)
156        };
157        let mut r = create_renderer(&OutputFormat::Quiet, Some(Box::new(writer)));
158        r.render(RuntimeEvent::TextDelta {
159            session_id: session_id(),
160            text: "quiet".into(),
161            agent_id: None,
162            trace_id: None,
163        })
164        .unwrap();
165        r.finish_turn().unwrap();
166        drop(r);
167        let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
168        assert!(out.is_empty());
169    }
170
171    #[test]
172    fn test_null_renderer_consumes_events_silently() {
173        let mut r = NullRenderer;
174        let sid = session_id();
175
176        // Representative framework events should all succeed and produce no output.
177        assert!(
178            r.render(RuntimeEvent::TextDelta {
179                session_id: sid.clone(),
180                text: "hello".into(),
181                agent_id: None,
182                trace_id: None
183            })
184            .is_ok()
185        );
186        assert!(
187            r.render(RuntimeEvent::ThoughtDelta {
188                session_id: sid.clone(),
189                text: "thinking".into(),
190                agent_id: None,
191                trace_id: None
192            })
193            .is_ok()
194        );
195        assert!(
196            r.render(RuntimeEvent::ToolCallStarted {
197                session_id: sid.clone(),
198                tool_name: "test_tool".into(),
199                args_json: "{}".into(),
200                agent_id: None,
201                trace_id: None
202            })
203            .is_ok()
204        );
205        assert!(
206            r.render(RuntimeEvent::ToolCallFinished {
207                session_id: sid.clone(),
208                tool_name: "test_tool".into(),
209                summary: "done".into(),
210                agent_id: None,
211                trace_id: None,
212                denied: false,
213                details: None,
214            })
215            .is_ok()
216        );
217        assert!(
218            r.render(RuntimeEvent::UserEvent {
219                session_id: sid.clone(),
220                event: agent_base::UserEvent::Progress { text: "progress".into() },
221                agent_id: None,
222                trace_id: None
223            })
224            .is_ok()
225        );
226        assert!(
227            r.render(RuntimeEvent::PlanUpdated {
228                session_id: sid.clone(),
229                objective: "obj".into(),
230                explanation: None,
231                plan: vec![],
232                agent_id: None,
233                trace_id: None
234            })
235            .is_ok()
236        );
237        assert!(
238            r.render(RuntimeEvent::RunFinished { session_id: sid.clone(), agent_id: None, trace_id: None }).is_ok()
239        );
240        assert!(r.finish_turn().is_ok());
241        assert!(r.finish_session().is_ok());
242    }
243}