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