1use std::sync::{Mutex, OnceLock};
8use std::time::Duration;
9
10use console::style;
11use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
12use tracing::Level;
13
14pub fn json_logs() -> bool {
16 static JSON: OnceLock<bool> = OnceLock::new();
17 *JSON.get_or_init(|| std::env::var("COMINDLY_LOG_FORMAT").is_ok_and(|v| v == "json"))
18}
19
20fn current_step() -> &'static Mutex<String> {
21 static STEP: OnceLock<Mutex<String>> = OnceLock::new();
22 STEP.get_or_init(Default::default)
23}
24
25pub fn usage(tokens_in: u64, tokens_out: u64, usd: Option<f64>) {
27 *usage_now().lock().unwrap_or_else(|e| e.into_inner()) = Some((tokens_in, tokens_out, usd));
28}
29
30type Usage = (u64, u64, Option<f64>);
32
33fn usage_now() -> &'static Mutex<Option<Usage>> {
34 static USAGE: OnceLock<Mutex<Option<Usage>>> = OnceLock::new();
35 USAGE.get_or_init(Default::default)
36}
37
38#[derive(Default)]
40struct Extra {
41 step: Option<String>,
42 job_id: Option<String>,
43 done: Option<u64>,
44 total: Option<u64>,
45 tokens_in: Option<u64>,
46 tokens_out: Option<u64>,
47 usd: Option<f64>,
48}
49
50impl Extra {
51 fn here() -> Self {
52 let step = current_step().lock().map(|s| s.clone()).unwrap_or_default();
53 Extra {
54 step: (!step.is_empty()).then_some(step),
55 ..Default::default()
56 }
57 }
58}
59
60fn emit(level: Level, kind: &str, message: &str, x: Extra, line: impl FnOnce() -> String) {
63 let line = (!json_logs()).then(line);
64 let routed = tracing::dispatcher::get_default(|d| !d.is::<tracing::subscriber::NoSubscriber>());
65 if !routed {
66 let mut f = serde_json::Map::new();
67 f.insert("kind".into(), kind.into());
68 f.insert("message".into(), message.into());
69 let opt = |f: &mut serde_json::Map<_, _>, k: &str, v: Option<serde_json::Value>| {
70 if let Some(v) = v {
71 f.insert(k.to_string(), v);
72 }
73 };
74 opt(&mut f, "step", x.step.map(Into::into));
75 opt(&mut f, "job_id", x.job_id.map(Into::into));
76 opt(&mut f, "done", x.done.map(Into::into));
77 opt(&mut f, "total", x.total.map(Into::into));
78 opt(&mut f, "tokens_in", x.tokens_in.map(Into::into));
79 opt(&mut f, "tokens_out", x.tokens_out.map(Into::into));
80 opt(&mut f, "usd", x.usd.map(Into::into));
81 opt(&mut f, "line", line.map(Into::into));
82 crate::telemetry::write(&crate::telemetry::render(level, f, json_logs()));
83 return;
84 }
85 macro_rules! at {
86 ($l:expr) => {
87 tracing::event!(
88 $l,
89 kind,
90 step = x.step.as_deref(),
91 job_id = x.job_id.as_deref(),
92 done = x.done,
93 total = x.total,
94 tokens_in = x.tokens_in,
95 tokens_out = x.tokens_out,
96 usd = x.usd,
97 line = line.as_deref(),
98 "{message}"
99 )
100 };
101 }
102 match level {
103 Level::ERROR => at!(Level::ERROR),
104 Level::WARN => at!(Level::WARN),
105 _ => at!(Level::INFO),
106 }
107}
108
109pub fn header(title: &str) {
111 if json_logs() {
112 if let Ok(mut s) = current_step().lock() {
113 *s = title.to_string();
114 }
115 }
116 emit(Level::INFO, "step", title, Extra::here(), || {
117 format!("\n{}", style(title).bold().cyan())
118 });
119}
120
121pub fn ok(msg: &str) {
123 emit(Level::INFO, "done", msg, Extra::here(), || {
124 format!("{} {msg}", style("✓").green().bold())
125 });
126}
127
128pub fn warn(msg: &str) {
130 emit(Level::WARN, "warning", msg, Extra::here(), || {
131 format!("{} {msg}", style("!").yellow().bold())
132 });
133}
134
135pub fn err(msg: &str) {
137 emit(Level::ERROR, "error", msg, Extra::here(), || {
138 format!("{} {msg}", style("✗").red().bold())
139 });
140}
141
142pub fn step(msg: &str) {
144 emit(Level::INFO, "note", msg, Extra::here(), || {
145 format!("{} {msg}", style("→").cyan().bold())
146 });
147}
148
149pub fn note(msg: &str) {
151 emit(Level::INFO, "note", msg, Extra::here(), || {
152 format!(" {}", style(msg).dim())
153 });
154}
155
156pub fn field(label: &str, value: &str) {
158 emit(
159 Level::INFO,
160 "note",
161 &format!("{label}: {value}"),
162 Extra::here(),
163 || format!(" {} {value}", style(format!("{label}:")).dim()),
164 );
165}
166
167pub fn spinner(msg: &str) -> ProgressBar {
169 if json_logs() {
170 note(msg);
171 return ProgressBar::hidden();
172 }
173 let pb = ProgressBar::with_draw_target(None, ProgressDrawTarget::stderr());
174 pb.set_style(
175 ProgressStyle::with_template("{spinner:.cyan} {msg} {elapsed:.dim}")
176 .unwrap_or_else(|_| ProgressStyle::default_spinner()),
177 );
178 pb.set_message(msg.to_string());
179 pb.enable_steady_tick(Duration::from_millis(90));
180 pb
181}
182
183pub fn table(headers: &[&str]) -> comfy_table::Table {
185 let mut t = comfy_table::Table::new();
186 t.load_style(comfy_table::presets::UTF8_FULL)
187 .set_content_arrangement(comfy_table::ContentArrangement::Dynamic)
188 .set_header(headers.to_vec());
189 t
190}
191
192pub fn right_align(t: &mut comfy_table::Table, cols: &[usize]) {
194 for &c in cols {
195 if let Some(col) = t.column_mut(c) {
196 col.set_cell_alignment(comfy_table::CellAlignment::Right);
197 }
198 }
199}
200
201pub fn progress(len: u64, msg: &str) -> ProgressBar {
203 let target = if json_logs() {
204 ProgressDrawTarget::hidden()
205 } else {
206 ProgressDrawTarget::stderr()
207 };
208 let pb = ProgressBar::with_draw_target(Some(len), target);
209 pb.set_style(
210 ProgressStyle::with_template(
211 "{spinner:.cyan} [{elapsed_precise}] [{bar:32.cyan/blue}] {pos}/{len} {msg} {eta:.dim}",
212 )
213 .unwrap_or_else(|_| ProgressStyle::default_bar())
214 .progress_chars("=>-"),
215 );
216 pb.set_message(msg.to_string());
217 pb.enable_steady_tick(Duration::from_millis(90));
218 if json_logs() || !std::io::IsTerminal::is_terminal(&std::io::stderr()) {
220 let (weak, msg) = (pb.downgrade(), msg.to_string());
221 std::thread::spawn(move || loop {
222 std::thread::sleep(Duration::from_secs(30));
223 let Some(p) = weak.upgrade().filter(|p| !p.is_finished()) else {
224 break;
225 };
226 let (done, total) = (p.position(), p.length().unwrap_or(0));
227 let mut x = Extra {
228 done: Some(done),
229 total: Some(total),
230 ..Extra::here()
231 };
232 if let Some((i, o, usd)) = *usage_now().lock().unwrap_or_else(|e| e.into_inner()) {
233 x.tokens_in = Some(i);
234 x.tokens_out = Some(o);
235 x.usd = usd.map(|u| (u * 10_000.0).round() / 10_000.0);
236 }
237 emit(Level::INFO, "progress", &msg, x, || {
238 format!(" {msg}: {done}/{total} · {}", p.message())
239 });
240 });
241 }
242 pb
243}
244
245pub struct Terminal {
248 bar: Mutex<Option<(String, ProgressBar)>>,
249 last_progress: Mutex<Option<std::time::Instant>>,
250}
251
252impl Terminal {
253 pub fn new() -> Self {
254 Terminal {
255 bar: Mutex::new(None),
256 last_progress: Mutex::new(None),
257 }
258 }
259
260 fn finish_bar(&self) {
261 if let Some((_, pb)) = self.bar.lock().ok().and_then(|mut b| b.take()) {
262 pb.finish_and_clear();
263 }
264 }
265
266 fn due(&self) -> bool {
267 let mut last = self.last_progress.lock().unwrap_or_else(|e| e.into_inner());
268 let now = std::time::Instant::now();
269 let due = last.is_none_or(|t| now.duration_since(t) >= Duration::from_secs(30));
270 if due {
271 *last = Some(now);
272 }
273 due
274 }
275}
276
277impl Default for Terminal {
278 fn default() -> Self {
279 Self::new()
280 }
281}
282
283impl crate::events::EventSink for Terminal {
284 fn emit(&self, e: crate::events::Event) {
285 use crate::events::Kind;
286 if e.kind != Kind::Progress {
287 self.finish_bar();
288 }
289 let usage = e.tokens_in.map(|i| {
290 crate::events::Usage {
291 tokens_in: i,
292 tokens_out: e.tokens_out.unwrap_or(0),
293 usd: e.usd,
294 }
295 .to_string()
296 });
297 let level = match e.level {
298 crate::events::Level::Info => Level::INFO,
299 crate::events::Level::Warn => Level::WARN,
300 crate::events::Level::Error => Level::ERROR,
301 };
302 let kind = serde_json::to_value(e.kind)
303 .ok()
304 .and_then(|k| k.as_str().map(str::to_string))
305 .unwrap_or_default();
306 let x = Extra {
307 step: e.step.clone(),
308 job_id: e.job_id.clone(),
309 done: e.done,
310 total: e.total,
311 tokens_in: e.tokens_in,
312 tokens_out: e.tokens_out,
313 usd: e.usd,
314 };
315 let m = &e.message;
316 let tty = std::io::IsTerminal::is_terminal(&std::io::stderr());
317 match e.kind {
318 Kind::Step => emit(level, &kind, m, x, || {
319 format!("\n{}", style(m).bold().cyan())
320 }),
321 Kind::Done => emit(level, &kind, m, x, || {
322 format!("{} {m}", style("✓").green().bold())
323 }),
324 Kind::Note => emit(level, &kind, m, x, || format!(" {}", style(m).dim())),
325 Kind::Warning => emit(level, &kind, m, x, || {
326 format!("{} {m}", style("!").yellow().bold())
327 }),
328 Kind::Error => emit(level, &kind, m, x, || {
329 format!("{} {m}", style("✗").red().bold())
330 }),
331 Kind::Progress => {
332 let (done, total) = (e.done.unwrap_or(0), e.total.unwrap_or(0));
333 if json_logs() || !tty {
334 if self.due() {
335 emit(level, &kind, m, x, || {
336 format!(
337 " {m}: {done}/{total}{}",
338 usage.map(|u| format!(" · {u}")).unwrap_or_default()
339 )
340 });
341 }
342 return;
343 }
344 let mut bar = self.bar.lock().unwrap_or_else(|p| p.into_inner());
345 if bar.as_ref().is_none_or(|(m, _)| *m != e.message) {
346 if let Some((_, old)) = bar.take() {
347 old.finish_and_clear();
348 }
349 let pb =
350 ProgressBar::with_draw_target(Some(total), ProgressDrawTarget::stderr());
351 pb.set_style(
352 ProgressStyle::with_template(
353 "{spinner:.cyan} [{elapsed_precise}] [{bar:32.cyan/blue}] {pos}/{len} {msg} {eta:.dim}",
354 )
355 .unwrap_or_else(|_| ProgressStyle::default_bar())
356 .progress_chars("=>-"),
357 );
358 pb.enable_steady_tick(Duration::from_millis(90));
359 *bar = Some((e.message.clone(), pb));
360 }
361 if let Some((_, pb)) = bar.as_ref() {
362 pb.set_length(total);
363 pb.set_position(done);
364 pb.set_message(usage.unwrap_or_else(|| e.message.clone()));
365 }
366 }
367 }
368 }
369}