1use 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
34pub 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
49pub 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
168pub 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
275pub 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
325pub 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
392pub fn text_of(v: &Value) -> String {
394 match v {
395 Value::String(s) => s.clone(),
396 other => other.to_string(),
397 }
398}