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, 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
35pub 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
50pub 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
169pub 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 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
284fn 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
316pub 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
338pub 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
388pub 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
455pub fn text_of(v: &Value) -> String {
457 match v {
458 Value::String(s) => s.clone(),
459 other => other.to_string(),
460 }
461}