Skip to main content

phi_agent/render/
mod.rs

1pub mod json_stream;
2pub mod null;
3pub mod terminal;
4
5pub use json_stream::JsonStreamRenderer;
6pub use null::NullRenderer;
7pub use terminal::TerminalRenderer;
8
9use std::io::{self, Write};
10
11use agent_base::{AgentResult, RuntimeEvent};
12
13/// Event renderer: converts RuntimeEvents into a specific output format.
14///
15/// Each renderer is a pure consumer — it only reads events and produces
16/// output, without modifying Agent state.
17pub trait EventRenderer: Send {
18    /// Process one runtime event.
19    fn render(&mut self, event: RuntimeEvent) -> AgentResult<()>;
20
21    /// End of current turn — renderer may flush / output summary.
22    fn finish_turn(&mut self) -> AgentResult<()>;
23
24    /// End of entire session.
25    fn finish_session(&mut self) -> AgentResult<()> {
26        Ok(())
27    }
28}
29
30/// Output format
31#[derive(Clone, Debug)]
32pub enum OutputFormat {
33    /// Rich terminal output (with colors and emoji)
34    Terminal { show_thinking: bool, show_tool_args: bool, color: bool },
35    /// One JSON object per line
36    Json,
37    /// No output
38    Quiet,
39}
40
41/// Create the corresponding renderer for a given output format.
42///
43/// `writer` defaults to stdout (CLI scenario). Web consumers can pass a
44/// custom writer (e.g. a WebSocket sink).
45pub fn create_renderer(format: &OutputFormat, writer: Option<Box<dyn Write + Send>>) -> Box<dyn EventRenderer> {
46    match format {
47        OutputFormat::Terminal { show_thinking, show_tool_args, color } => {
48            let w = writer.unwrap_or_else(|| Box::new(io::stdout()));
49            Box::new(TerminalRenderer::new(*show_thinking, *show_tool_args, *color, w))
50        },
51        OutputFormat::Json => {
52            let w = writer.unwrap_or_else(|| Box::new(io::stdout()));
53            Box::new(JsonStreamRenderer::new(w))
54        },
55        OutputFormat::Quiet => Box::new(NullRenderer),
56    }
57}
58
59/// Create a renderer using stdout (backward-compatible).
60pub fn create_stdout_renderer(format: &OutputFormat) -> Box<dyn EventRenderer> {
61    create_renderer(format, None)
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use agent_base::SessionId;
68    use std::io::Write;
69    use std::sync::{Arc, Mutex};
70
71    struct SharedWriter {
72        inner: Arc<Mutex<Vec<u8>>>,
73    }
74
75    impl Write for SharedWriter {
76        fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
77            self.inner.lock().unwrap().extend_from_slice(data);
78            Ok(data.len())
79        }
80        fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
81    }
82
83    fn session_id() -> SessionId {
84        SessionId { id: 1, external_id: None }
85    }
86
87    #[test]
88    fn test_create_terminal_renderer() {
89        let (writer, buf) = {
90            let inner = Arc::new(Mutex::new(Vec::new()));
91            (SharedWriter { inner: inner.clone() }, inner)
92        };
93        let format = OutputFormat::Terminal { show_thinking: true, show_tool_args: true, color: false };
94        let mut r = create_renderer(&format, Some(Box::new(writer)));
95        r.render(RuntimeEvent::TextDelta { session_id: session_id(), text: "hello".into() }).unwrap();
96        drop(r);
97        let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
98        assert!(out.contains("hello"));
99        assert!(!out.contains('\x1b')); // color disabled
100    }
101
102    #[test]
103    fn test_create_json_renderer() {
104        let (writer, buf) = {
105            let inner = Arc::new(Mutex::new(Vec::new()));
106            (SharedWriter { inner: inner.clone() }, inner)
107        };
108        let mut r = create_renderer(&OutputFormat::Json, Some(Box::new(writer)));
109        r.render(RuntimeEvent::TextDelta { session_id: session_id(), text: "world".into() }).unwrap();
110        drop(r);
111        let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
112        let v: serde_json::Value = serde_json::from_str(out.trim()).unwrap();
113        assert_eq!(v["type"], "text_delta");
114    }
115
116    #[test]
117    fn test_create_quiet_renderer() {
118        let (writer, buf) = {
119            let inner = Arc::new(Mutex::new(Vec::new()));
120            (SharedWriter { inner: inner.clone() }, inner)
121        };
122        let mut r = create_renderer(&OutputFormat::Quiet, Some(Box::new(writer)));
123        r.render(RuntimeEvent::TextDelta { session_id: session_id(), text: "quiet".into() }).unwrap();
124        r.finish_turn().unwrap();
125        drop(r);
126        let out = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
127        assert!(out.is_empty());
128    }
129}