Skip to main content

comindly_engine/
ui.rs

1//! Terminal UI helpers — styled status lines, spinners, and progress bars; with
2//! `COMINDLY_LOG_FORMAT=json`, the same messages as one JSON event per line.
3//!
4//! Everything writes to **stderr** so `comindly serve`'s stdout stays pure JSON-RPC; a server's log
5//! lines go to stdout. Styling and animation degrade when stderr is not a TTY (pipes, CI).
6
7use std::sync::{Mutex, OnceLock};
8use std::time::Duration;
9
10use console::style;
11use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
12use tracing::Level;
13
14/// One JSON object per line instead of styled text, for platform logs (`COMINDLY_LOG_FORMAT=json`).
15pub 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
25/// LLM tokens and cost so far, attached to progress events.
26pub 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
30/// Tokens in, tokens out and their cost in USD, when prices are set.
31type 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/// The fields a log line may carry besides its level, kind and message.
39#[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
60/// One log line as a `tracing` event, rendered by [`crate::telemetry`]; `line` is its readable
61/// form. Without a subscriber (tests, embedders) the line is written to the log stream directly.
62fn 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
109/// Bold section header.
110pub 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
121/// Success line (`✓`).
122pub fn ok(msg: &str) {
123    emit(Level::INFO, "done", msg, Extra::here(), || {
124        format!("{} {msg}", style("✓").green().bold())
125    });
126}
127
128/// Warning line (`!`).
129pub fn warn(msg: &str) {
130    emit(Level::WARN, "warning", msg, Extra::here(), || {
131        format!("{} {msg}", style("!").yellow().bold())
132    });
133}
134
135/// Error line (`✗`).
136pub fn err(msg: &str) {
137    emit(Level::ERROR, "error", msg, Extra::here(), || {
138        format!("{} {msg}", style("✗").red().bold())
139    });
140}
141
142/// In-progress step (`→`).
143pub fn step(msg: &str) {
144    emit(Level::INFO, "note", msg, Extra::here(), || {
145        format!("{} {msg}", style("→").cyan().bold())
146    });
147}
148
149/// Dim, indented detail line.
150pub fn note(msg: &str) {
151    emit(Level::INFO, "note", msg, Extra::here(), || {
152        format!("  {}", style(msg).dim())
153    });
154}
155
156/// Dim `label:` followed by a value.
157pub 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
167/// Indeterminate spinner on stderr with a steady tick.
168pub 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
183/// A preset unicode table that wraps to the terminal width. Add rows, then print it.
184pub 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
192/// Right-align the given (0-based) columns — for numbers, which read best flush-right.
193pub 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
201/// Determinate `pos/len` progress bar on stderr.
202pub 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    // A log (the app, a container) gets progress every 30 s instead of a bar it cannot draw.
219    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
245/// Renders core's events: styled lines and progress bars at a terminal, one JSON object per line
246/// with `COMINDLY_LOG_FORMAT=json`, plain progress lines every 30 s otherwise.
247pub 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}