1use monoloop_contracts::{
4 CanonicalUnit, CanonicalUnitEvent, InterpretationEnd, InterpreterOutputEvent, TextChannel,
5 ToolRequestState, UnitState,
6};
7use std::sync::Arc;
8use tokio::sync::mpsc;
9
10#[derive(Clone, Debug)]
12pub struct ConsoleRendererConfig {
13 pub verbose: bool,
15 pub show_tool_payloads: bool,
17 pub max_content_chars: usize,
19}
20
21impl Default for ConsoleRendererConfig {
22 fn default() -> Self {
23 Self {
24 verbose: false,
25 show_tool_payloads: false,
26 max_content_chars: 500,
27 }
28 }
29}
30
31pub trait ConsoleSink: Send + Sync {
33 fn write_line(&self, line: &str);
35}
36
37#[derive(Clone, Default)]
39pub struct SyncMemorySink {
40 lines: Arc<std::sync::Mutex<Vec<String>>>,
41}
42
43impl SyncMemorySink {
44 pub fn new() -> Self {
46 Self::default()
47 }
48
49 pub fn lines(&self) -> Vec<String> {
51 self.lines.lock().expect("sink").clone()
52 }
53
54 pub fn join(&self) -> String {
56 self.lines.lock().expect("sink").join("")
57 }
58}
59
60impl ConsoleSink for SyncMemorySink {
61 fn write_line(&self, line: &str) {
62 self.lines.lock().expect("sink").push(line.to_string());
63 }
64}
65
66pub struct StdoutSink;
68
69impl ConsoleSink for StdoutSink {
70 fn write_line(&self, line: &str) {
71 print!("{line}");
72 let _ = std::io::Write::flush(&mut std::io::stdout());
73 }
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct ConsoleRenderRecord {
79 pub line: String,
81}
82
83pub struct ConsoleRenderer {
85 config: ConsoleRendererConfig,
86 sink: Arc<dyn ConsoleSink>,
87}
88
89impl ConsoleRenderer {
90 pub fn new(config: ConsoleRendererConfig, sink: Arc<dyn ConsoleSink>) -> Self {
92 Self { config, sink }
93 }
94
95 pub fn render(&self, event: &InterpreterOutputEvent) -> ConsoleRenderRecord {
97 let line = match event {
98 InterpreterOutputEvent::Unit(u) => self.format_unit(u),
99 InterpreterOutputEvent::Ended(end) => self.format_end(end),
100 };
101 self.sink.write_line(&line);
102 ConsoleRenderRecord { line }
103 }
104
105 pub fn spawn_consumer(
107 self: &Arc<Self>,
108 mut rx: mpsc::Receiver<InterpreterOutputEvent>,
109 ) -> tokio::task::JoinHandle<()> {
110 let this = Arc::clone(self);
111 tokio::spawn(async move {
112 while let Some(ev) = rx.recv().await {
113 let is_end = matches!(ev, InterpreterOutputEvent::Ended(_));
114 this.render(&ev);
115 if is_end {
116 break;
117 }
118 }
119 })
120 }
121
122 fn format_unit(&self, event: &CanonicalUnitEvent) -> String {
123 let s = event.snapshot();
124 let corr = format!(
125 "[c:{} i:{} f:{} l:{} u:{} g:{}]",
126 short_id(s.connection_id.as_str()),
127 short_id(s.interpretation_id.as_str()),
128 s.flow_id.as_str(),
129 s.lane_id.as_str(),
130 short_id(s.unit_id.as_str()),
131 s.unit_generation,
132 );
133 let (kind_state, label, content) = match &s.unit {
134 CanonicalUnit::Text(t) => {
135 let state = unit_state_label(s.unit_state);
136 (
137 format!("text/{state}"),
138 t.channel.label().to_string(),
139 escape_content(&t.content, self.config.max_content_chars),
140 )
141 }
142 CanonicalUnit::Tool(t) => {
143 let state = tool_state_label(t);
144 let name = t.tool_name.as_deref().unwrap_or("?");
145 let mut content = t.waiting_for.clone().unwrap_or_else(|| state.clone());
146 if self.config.show_tool_payloads {
147 if let Some(ref p) = t.request_payload {
148 content.push_str(" args=");
149 content.push_str(&escape_content(p, self.config.max_content_chars));
150 }
151 }
152 (
153 format!("tool/{state}"),
154 name.to_string(),
155 escape_content(&content, self.config.max_content_chars),
156 )
157 }
158 CanonicalUnit::Boundary(b) => (
159 "boundary/complete".into(),
160 format!("{:?}", b.kind),
161 String::new(),
162 ),
163 CanonicalUnit::Diagnostic(d) => (
164 "diagnostic".into(),
165 format!("{:?}", d.kind),
166 escape_content(&d.message, self.config.max_content_chars),
167 ),
168 CanonicalUnit::Structure(st) => (
169 "structure/complete".into(),
170 format!("{:?}", st.kind),
171 escape_content(&st.content, self.config.max_content_chars),
172 ),
173 CanonicalUnit::Paragraph(p) => (
174 format!("paragraph/{:?}", p.kind),
175 TextChannel::PublicResponse.label().into(),
176 String::new(),
177 ),
178 CanonicalUnit::Usage(u) => ("usage".into(), "tokens".into(), format!("{u:?}")),
179 };
180 format!("{corr} {kind_state} {label} {content}\n")
181 }
182
183 fn format_end(&self, end: &InterpretationEnd) -> String {
184 format!(
185 "[c:{} i:{}] interpretation/{:?} events={} sentences={} unresolved_bytes={}\n",
186 short_id(end.connection_id.as_str()),
187 short_id(end.interpretation_id.as_str()),
188 end.kind,
189 end.canonical_event_count,
190 end.completed_sentence_count,
191 end.unresolved_text_bytes,
192 )
193 }
194}
195
196fn tool_state_label(t: &monoloop_contracts::ToolActionEvent) -> String {
197 if t.terminal_outcome.is_some() {
198 return "complete".into();
199 }
200 match t.request_state {
201 ToolRequestState::Ready => "ready".into(),
202 ToolRequestState::Assembling => "waiting".into(),
203 ToolRequestState::Incomplete => "incomplete".into(),
204 ToolRequestState::Malformed => "malformed".into(),
205 }
206}
207
208fn unit_state_label(s: UnitState) -> &'static str {
209 match s {
210 UnitState::Complete => "complete",
211 UnitState::Waiting => "waiting",
212 UnitState::Incomplete => "incomplete",
213 UnitState::Malformed => "malformed",
214 }
215}
216
217fn short_id(id: &str) -> &str {
218 if id.len() <= 8 {
219 id
220 } else {
221 &id[..8]
222 }
223}
224
225fn escape_content(s: &str, max: usize) -> String {
226 let mut out = String::with_capacity(s.len().min(max) + 8);
227 for (i, ch) in s.chars().enumerate() {
228 if i >= max {
229 out.push('…');
230 break;
231 }
232 match ch {
233 '\n' => out.push_str("\\n"),
234 '\r' => out.push_str("\\r"),
235 '\t' => out.push_str("\\t"),
236 '\u{1b}' => out.push_str("\\e"),
237 c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
238 c => out.push(c),
239 }
240 }
241 out
242}