Skip to main content

jev_repl/
format.rs

1//! Turning answers, questions and errors into styled transcript lines.
2
3use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use serde_json::Value;
6use typesafe::{Answer, Error, Question};
7
8use crate::cost::{Estimate, Rates, format_rates, price_estimate, usd};
9
10pub const NOUL: Color = Color::Cyan;
11pub const CHOICE: Color = Color::Magenta;
12pub const SCORE: Color = Color::Green;
13pub const DIM: Color = Color::DarkGray;
14pub const WARN: Color = Color::Yellow;
15pub const BAD: Color = Color::Red;
16pub const ACCENT: Color = Color::LightBlue;
17
18pub fn dim(text: impl Into<String>) -> Span<'static> {
19    Span::styled(text.into(), Style::new().fg(DIM))
20}
21
22pub fn plain(text: impl Into<String>) -> Line<'static> {
23    Line::from(text.into())
24}
25
26pub fn styled(text: impl Into<String>, color: Color) -> Line<'static> {
27    Line::from(Span::styled(text.into(), Style::new().fg(color)))
28}
29
30pub fn bold(text: impl Into<String>) -> Span<'static> {
31    Span::styled(text.into(), Style::new().add_modifier(Modifier::BOLD))
32}
33
34/// A probability meter. Eighteen columns is enough to read a distribution at a glance.
35pub fn bar(p: f64, width: usize) -> String {
36    let filled = ((p.clamp(0.0, 1.0) * width as f64).round() as usize).min(width);
37    format!("{}{}", "█".repeat(filled), "░".repeat(width - filled))
38}
39
40pub fn color_for(kind: &str) -> Color {
41    match kind {
42        "noul" => NOUL,
43        "choice" => CHOICE,
44        "score" => SCORE,
45        _ => DIM,
46    }
47}
48
49/// Render one answer: the number, the distribution it came from, and what it means.
50pub fn answer_lines(name: &str, answer: &Answer, threshold: f64) -> Vec<Line<'static>> {
51    let kind = answer.kind();
52    let mut out = vec![Line::from(vec![
53        Span::raw("  "),
54        bold(name.to_owned()),
55        Span::raw("  "),
56        Span::styled(kind.to_owned(), Style::new().fg(color_for(kind))),
57    ])];
58
59    match answer {
60        Answer::Noul(a) => {
61            let yes = a.is_yes(threshold);
62            out.push(Line::from(vec![
63                Span::raw("    "),
64                bold(format!("{:.2}", a.noul)),
65                Span::raw("  "),
66                Span::styled(bar(a.noul, 18), Style::new().fg(NOUL)),
67                Span::raw("  "),
68                Span::styled(
69                    if yes { "yes" } else { "no" }.to_owned(),
70                    Style::new()
71                        .fg(if yes { SCORE } else { DIM })
72                        .add_modifier(Modifier::BOLD),
73                ),
74                dim(format!(" at threshold {threshold:.2}")),
75            ]));
76        }
77        Answer::Choice(a) => {
78            out.push(Line::from(vec![
79                Span::raw("    → "),
80                Span::styled(
81                    a.choice.clone(),
82                    Style::new().fg(CHOICE).add_modifier(Modifier::BOLD),
83                ),
84                Span::raw("   "),
85                dim("confidence "),
86                confidence_span(a.confidence),
87            ]));
88            let ranked = a.ranked();
89            let pad = ranked.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
90            for (label, p) in ranked {
91                out.push(Line::from(vec![
92                    Span::raw("      "),
93                    Span::styled(
94                        format!("{label:pad$}"),
95                        Style::new().fg(if label == a.choice { Color::Reset } else { DIM }),
96                    ),
97                    Span::raw("  "),
98                    Span::raw(format!("{p:.2}")),
99                    Span::raw("  "),
100                    Span::styled(bar(p, 18), Style::new().fg(CHOICE)),
101                ]));
102            }
103        }
104        Answer::Score(a) => {
105            let top = a.legend.keys().next_back().copied().unwrap_or(0);
106            out.push(Line::from(vec![
107                Span::raw("    "),
108                Span::styled(
109                    format!("{:.2}", a.score),
110                    Style::new().fg(SCORE).add_modifier(Modifier::BOLD),
111                ),
112                dim(format!(" of {top}")),
113                Span::raw("   "),
114                dim("confidence "),
115                confidence_span(a.confidence),
116                dim(format!(
117                    "   most likely level {}",
118                    a.most_likely_level()
119                        .map(|l| l.to_string())
120                        .unwrap_or_else(|| "-".into())
121                )),
122            ]));
123            let labels: Vec<(u32, String)> =
124                a.legend.iter().map(|(i, v)| (*i, text_of(v))).collect();
125            let pad = labels
126                .iter()
127                .map(|(_, l)| l.len())
128                .max()
129                .unwrap_or(0)
130                .min(40);
131            for (level, label) in labels {
132                let p = a.probabilities.get(&level).copied().unwrap_or(0.0);
133                let marker = if a.rounded_level() == level {
134                    "▸"
135                } else {
136                    " "
137                };
138                out.push(Line::from(vec![
139                    Span::raw(format!("     {marker} ")),
140                    dim(format!("{level} ")),
141                    Span::styled(format!("{label:pad$}"), Style::new().fg(Color::Reset)),
142                    Span::raw("  "),
143                    Span::raw(format!("{p:.2}")),
144                    Span::raw("  "),
145                    Span::styled(bar(p, 18), Style::new().fg(SCORE)),
146                ]));
147            }
148        }
149        _ => out.push(styled(
150            format!("    (this SDK version does not model {kind} answers; see :last)"),
151            WARN,
152        )),
153    }
154    out
155}
156
157fn confidence_span(c: f64) -> Span<'static> {
158    let color = if c >= 0.6 {
159        SCORE
160    } else if c >= 0.35 {
161        WARN
162    } else {
163        BAD
164    };
165    Span::styled(format!("{c:.2}"), Style::new().fg(color))
166}
167
168/// The cost estimate as a small table: which question spends what, and — when rates are set — the
169/// money at the bottom. Tokens are estimated, so the numbers are a shape, not a bill. `hint` is how
170/// this host sets rates, since the terminal has `:cost` and other hosts have their own.
171pub fn cost_lines(estimate: &Estimate, rates: Option<Rates>, hint: &str) -> Vec<Line<'static>> {
172    let pad = estimate
173        .questions
174        .iter()
175        .map(|q| q.name.chars().count())
176        .chain([5, 8, 5])
177        .max()
178        .unwrap_or(8);
179    let row = |name: &str, kind: &str, input: String, output: String, color: Option<Color>| {
180        Line::from(vec![
181            Span::raw("  "),
182            match color {
183                Some(color) => Span::styled(pad_end(name, pad), Style::new().fg(color)),
184                None => Span::raw(pad_end(name, pad)),
185            },
186            Span::raw("  "),
187            dim(pad_end(kind, 7)),
188            Span::raw(format!("{input:>6}")),
189            Span::raw(format!("{output:>6}")),
190        ])
191    };
192
193    let mut out = vec![Line::from(vec![
194        Span::raw("  "),
195        dim(pad_end("", pad)),
196        Span::raw("  "),
197        dim(pad_end("", 7)),
198        dim(format!("{:>6}", "in")),
199        dim(format!("{:>6}", "out")),
200    ])];
201    for q in &estimate.questions {
202        out.push(row(
203            &q.name,
204            &q.kind,
205            q.input_tokens.to_string(),
206            if q.assumed {
207                format!("~{}", q.output_tokens)
208            } else {
209                q.output_tokens.to_string()
210            },
211            Some(color_for(&q.kind)),
212        ));
213    }
214    out.push(row(
215        "state",
216        "",
217        estimate.state_tokens.to_string(),
218        "·".to_owned(),
219        None,
220    ));
221    out.push(row(
222        "envelope",
223        "",
224        estimate.envelope_tokens.to_string(),
225        estimate.answer_envelope_tokens.to_string(),
226        None,
227    ));
228    out.push(Line::from(vec![
229        Span::raw("  "),
230        bold(pad_end("total", pad)),
231        Span::raw("  "),
232        dim(pad_end("", 7)),
233        bold(format!("{:>6}", estimate.input_tokens)),
234        bold(format!("{:>6}", estimate.output_tokens)),
235        dim(format!(
236            "   {} tokens per call",
237            estimate.input_tokens + estimate.output_tokens
238        )),
239    ]));
240
241    let Some(rates) = rates else {
242        out.push(Line::from(vec![
243            Span::raw("    "),
244            dim(format!("no rates set — {hint}")),
245        ]));
246        return out;
247    };
248    let cost = price_estimate(estimate, rates);
249    out.push(Line::from(vec![
250        Span::raw("    "),
251        Span::styled(
252            usd(cost.total),
253            Style::new().fg(SCORE).add_modifier(Modifier::BOLD),
254        ),
255        dim(" per call   ·   "),
256        Span::styled(usd(cost.total * 1000.0), Style::new().fg(SCORE)),
257        dim(" per 1,000 calls"),
258    ]));
259    out.push(Line::from(vec![
260        Span::raw("    "),
261        dim(format!("at {}", format_rates(rates))),
262    ]));
263    out
264}
265
266fn pad_end(text: &str, width: usize) -> String {
267    let len = text.chars().count();
268    if len >= width {
269        text.to_owned()
270    } else {
271        format!("{text}{}", " ".repeat(width - len))
272    }
273}
274
275/// One line per question, the way it will go on the wire.
276pub fn question_lines(index: usize, name: &str, question: &Question) -> Vec<Line<'static>> {
277    let v = serde_json::to_value(question).unwrap_or(Value::Null);
278    let kind = v.get("type").and_then(Value::as_str).unwrap_or("raw");
279    let instructions = v.get("instructions").map(text_of).unwrap_or_default();
280    let mut lines = vec![Line::from(vec![
281        dim(format!("  {}. ", index + 1)),
282        bold(name.to_owned()),
283        Span::raw("  "),
284        Span::styled(kind.to_owned(), Style::new().fg(color_for(kind))),
285        Span::raw("  "),
286        dim(instructions),
287    ])];
288    match (kind, v.get("criteria")) {
289        ("choice", Some(Value::Object(map))) => {
290            for (label, desc) in map {
291                lines.push(Line::from(vec![
292                    Span::raw("       "),
293                    Span::styled(label.clone(), Style::new().fg(CHOICE)),
294                    dim(match desc {
295                        Value::Null => String::new(),
296                        v => format!(" — {}", text_of(v)),
297                    }),
298                ]));
299            }
300        }
301        ("score", Some(Value::Array(levels))) => {
302            for (i, level) in levels.iter().enumerate() {
303                lines.push(Line::from(vec![
304                    Span::raw("       "),
305                    Span::styled(i.to_string(), Style::new().fg(SCORE)),
306                    dim(format!(" {}", text_of(level))),
307                ]));
308            }
309        }
310        ("noul", Some(Value::Object(map))) => {
311            for (key, v) in map {
312                let label = if key == "true" { "yes" } else { "no" };
313                lines.push(Line::from(vec![
314                    Span::raw("       "),
315                    Span::styled(label.to_owned(), Style::new().fg(NOUL)),
316                    dim(format!(" — {}", text_of(v))),
317                ]));
318            }
319        }
320        _ => {}
321    }
322    lines
323}
324
325/// Errors are part of the lesson: show the variant, what it means, and what to do.
326pub fn error_lines(err: &Error) -> Vec<Line<'static>> {
327    let (variant, advice) = match err {
328        Error::Config(_) => ("Config", "Fix the client settings — :key sets an API key."),
329        Error::InvalidRequest(_) => (
330            "InvalidRequest",
331            "Rejected before anything was sent; nothing reached the API.",
332        ),
333        Error::Api(e) => (
334            "Api",
335            match e.status {
336                401 => "The API key is missing or wrong.",
337                403 => "The key is valid but not allowed to do this.",
338                422 => "The server rejected the body — check the question criteria.",
339                429 => "Rate limited; the SDK already retried with backoff.",
340                s if s >= 500 => "Server-side; the SDK already retried with backoff.",
341                _ => "Non-2xx after retries.",
342            },
343        ),
344        Error::Connection(_) => (
345            "Connection",
346            "No response: DNS, TLS, reset or a dropped body.",
347        ),
348        Error::Timeout(_) => (
349            "Timeout",
350            "An attempt ran past its per-attempt timeout — see :timeout.",
351        ),
352        Error::ResponseValidation(_) => (
353            "ResponseValidation",
354            "A 2xx body was missing required data; field_path points at it.",
355        ),
356        _ => ("Error", "Unhandled variant."),
357    };
358
359    let mut lines = vec![Line::from(vec![
360        Span::styled(
361            format!("  {variant}  "),
362            Style::new().fg(BAD).add_modifier(Modifier::BOLD),
363        ),
364        Span::raw(err.to_string()),
365    ])];
366    if let Error::ResponseValidation(e) = err {
367        lines.push(Line::from(vec![
368            Span::raw("    "),
369            dim(format!("field_path: {}", e.field_path)),
370        ]));
371    }
372    if let Some(api) = err.as_api() {
373        lines.push(Line::from(vec![
374            Span::raw("    "),
375            dim(format!("kind: {:?}", api.kind)),
376            dim(match api.retry_after() {
377                Some(d) => format!("   retry after {:.1}s", d.as_secs_f64()),
378                None => String::new(),
379            }),
380        ]));
381    }
382    if let Some(id) = err.request_id() {
383        lines.push(Line::from(vec![
384            Span::raw("    "),
385            dim(format!("request_id: {id}")),
386        ]));
387    }
388    lines.push(Line::from(vec![Span::raw("    "), dim(advice)]));
389    lines
390}
391
392/// JSON strings read better unquoted; everything else stays JSON.
393pub fn text_of(v: &Value) -> String {
394    match v {
395        Value::String(s) => s.clone(),
396        other => other.to_string(),
397    }
398}