1use ratatui::style::{Color, Modifier, Style};
4use ratatui::text::{Line, Span};
5use serde_json::Value;
6use typesafe::{Answer, Error, Question};
7
8pub const NOUL: Color = Color::Cyan;
9pub const CHOICE: Color = Color::Magenta;
10pub const SCORE: Color = Color::Green;
11pub const DIM: Color = Color::DarkGray;
12pub const WARN: Color = Color::Yellow;
13pub const BAD: Color = Color::Red;
14pub const ACCENT: Color = Color::LightBlue;
15
16pub fn dim(text: impl Into<String>) -> Span<'static> {
17 Span::styled(text.into(), Style::new().fg(DIM))
18}
19
20pub fn plain(text: impl Into<String>) -> Line<'static> {
21 Line::from(text.into())
22}
23
24pub fn styled(text: impl Into<String>, color: Color) -> Line<'static> {
25 Line::from(Span::styled(text.into(), Style::new().fg(color)))
26}
27
28pub fn bold(text: impl Into<String>) -> Span<'static> {
29 Span::styled(text.into(), Style::new().add_modifier(Modifier::BOLD))
30}
31
32pub fn bar(p: f64, width: usize) -> String {
34 let filled = ((p.clamp(0.0, 1.0) * width as f64).round() as usize).min(width);
35 format!("{}{}", "█".repeat(filled), "░".repeat(width - filled))
36}
37
38pub fn color_for(kind: &str) -> Color {
39 match kind {
40 "noul" => NOUL,
41 "choice" => CHOICE,
42 "score" => SCORE,
43 _ => DIM,
44 }
45}
46
47pub fn answer_lines(name: &str, answer: &Answer, threshold: f64) -> Vec<Line<'static>> {
49 let kind = answer.kind();
50 let mut out = vec![Line::from(vec![
51 Span::raw(" "),
52 bold(name.to_owned()),
53 Span::raw(" "),
54 Span::styled(kind.to_owned(), Style::new().fg(color_for(kind))),
55 ])];
56
57 match answer {
58 Answer::Noul(a) => {
59 let yes = a.is_yes(threshold);
60 out.push(Line::from(vec![
61 Span::raw(" "),
62 bold(format!("{:.2}", a.noul)),
63 Span::raw(" "),
64 Span::styled(bar(a.noul, 18), Style::new().fg(NOUL)),
65 Span::raw(" "),
66 Span::styled(
67 if yes { "yes" } else { "no" }.to_owned(),
68 Style::new()
69 .fg(if yes { SCORE } else { DIM })
70 .add_modifier(Modifier::BOLD),
71 ),
72 dim(format!(" at threshold {threshold:.2}")),
73 ]));
74 }
75 Answer::Choice(a) => {
76 out.push(Line::from(vec![
77 Span::raw(" → "),
78 Span::styled(
79 a.choice.clone(),
80 Style::new().fg(CHOICE).add_modifier(Modifier::BOLD),
81 ),
82 Span::raw(" "),
83 dim("confidence "),
84 confidence_span(a.confidence),
85 ]));
86 let ranked = a.ranked();
87 let pad = ranked.iter().map(|(l, _)| l.len()).max().unwrap_or(0);
88 for (label, p) in ranked {
89 out.push(Line::from(vec![
90 Span::raw(" "),
91 Span::styled(
92 format!("{label:pad$}"),
93 Style::new().fg(if label == a.choice { Color::Reset } else { DIM }),
94 ),
95 Span::raw(" "),
96 Span::raw(format!("{p:.2}")),
97 Span::raw(" "),
98 Span::styled(bar(p, 18), Style::new().fg(CHOICE)),
99 ]));
100 }
101 }
102 Answer::Score(a) => {
103 let top = a.legend.keys().next_back().copied().unwrap_or(0);
104 out.push(Line::from(vec![
105 Span::raw(" "),
106 Span::styled(
107 format!("{:.2}", a.score),
108 Style::new().fg(SCORE).add_modifier(Modifier::BOLD),
109 ),
110 dim(format!(" of {top}")),
111 Span::raw(" "),
112 dim("confidence "),
113 confidence_span(a.confidence),
114 dim(format!(
115 " most likely level {}",
116 a.most_likely_level()
117 .map(|l| l.to_string())
118 .unwrap_or_else(|| "-".into())
119 )),
120 ]));
121 let labels: Vec<(u32, String)> =
122 a.legend.iter().map(|(i, v)| (*i, text_of(v))).collect();
123 let pad = labels
124 .iter()
125 .map(|(_, l)| l.len())
126 .max()
127 .unwrap_or(0)
128 .min(40);
129 for (level, label) in labels {
130 let p = a.probabilities.get(&level).copied().unwrap_or(0.0);
131 let marker = if a.rounded_level() == level {
132 "▸"
133 } else {
134 " "
135 };
136 out.push(Line::from(vec![
137 Span::raw(format!(" {marker} ")),
138 dim(format!("{level} ")),
139 Span::styled(format!("{label:pad$}"), Style::new().fg(Color::Reset)),
140 Span::raw(" "),
141 Span::raw(format!("{p:.2}")),
142 Span::raw(" "),
143 Span::styled(bar(p, 18), Style::new().fg(SCORE)),
144 ]));
145 }
146 }
147 _ => out.push(styled(
148 format!(" (this SDK version does not model {kind} answers; see :last)"),
149 WARN,
150 )),
151 }
152 out
153}
154
155fn confidence_span(c: f64) -> Span<'static> {
156 let color = if c >= 0.6 {
157 SCORE
158 } else if c >= 0.35 {
159 WARN
160 } else {
161 BAD
162 };
163 Span::styled(format!("{c:.2}"), Style::new().fg(color))
164}
165
166pub fn question_lines(index: usize, name: &str, question: &Question) -> Vec<Line<'static>> {
168 let v = serde_json::to_value(question).unwrap_or(Value::Null);
169 let kind = v.get("type").and_then(Value::as_str).unwrap_or("raw");
170 let instructions = v.get("instructions").map(text_of).unwrap_or_default();
171 let mut lines = vec![Line::from(vec![
172 dim(format!(" {}. ", index + 1)),
173 bold(name.to_owned()),
174 Span::raw(" "),
175 Span::styled(kind.to_owned(), Style::new().fg(color_for(kind))),
176 Span::raw(" "),
177 dim(instructions),
178 ])];
179 match (kind, v.get("criteria")) {
180 ("choice", Some(Value::Object(map))) => {
181 for (label, desc) in map {
182 lines.push(Line::from(vec![
183 Span::raw(" "),
184 Span::styled(label.clone(), Style::new().fg(CHOICE)),
185 dim(match desc {
186 Value::Null => String::new(),
187 v => format!(" — {}", text_of(v)),
188 }),
189 ]));
190 }
191 }
192 ("score", Some(Value::Array(levels))) => {
193 for (i, level) in levels.iter().enumerate() {
194 lines.push(Line::from(vec![
195 Span::raw(" "),
196 Span::styled(i.to_string(), Style::new().fg(SCORE)),
197 dim(format!(" {}", text_of(level))),
198 ]));
199 }
200 }
201 ("noul", Some(Value::Object(map))) => {
202 for (key, v) in map {
203 let label = if key == "true" { "yes" } else { "no" };
204 lines.push(Line::from(vec![
205 Span::raw(" "),
206 Span::styled(label.to_owned(), Style::new().fg(NOUL)),
207 dim(format!(" — {}", text_of(v))),
208 ]));
209 }
210 }
211 _ => {}
212 }
213 lines
214}
215
216pub fn error_lines(err: &Error) -> Vec<Line<'static>> {
218 let (variant, advice) = match err {
219 Error::Config(_) => ("Config", "Fix the client settings — :key sets an API key."),
220 Error::InvalidRequest(_) => (
221 "InvalidRequest",
222 "Rejected before anything was sent; nothing reached the API.",
223 ),
224 Error::Api(e) => (
225 "Api",
226 match e.status {
227 401 => "The API key is missing or wrong.",
228 403 => "The key is valid but not allowed to do this.",
229 422 => "The server rejected the body — check the question criteria.",
230 429 => "Rate limited; the SDK already retried with backoff.",
231 s if s >= 500 => "Server-side; the SDK already retried with backoff.",
232 _ => "Non-2xx after retries.",
233 },
234 ),
235 Error::Connection(_) => (
236 "Connection",
237 "No response: DNS, TLS, reset or a dropped body.",
238 ),
239 Error::Timeout(_) => (
240 "Timeout",
241 "An attempt ran past its per-attempt timeout — see :timeout.",
242 ),
243 Error::ResponseValidation(_) => (
244 "ResponseValidation",
245 "A 2xx body was missing required data; field_path points at it.",
246 ),
247 _ => ("Error", "Unhandled variant."),
248 };
249
250 let mut lines = vec![Line::from(vec![
251 Span::styled(
252 format!(" {variant} "),
253 Style::new().fg(BAD).add_modifier(Modifier::BOLD),
254 ),
255 Span::raw(err.to_string()),
256 ])];
257 if let Error::ResponseValidation(e) = err {
258 lines.push(Line::from(vec![
259 Span::raw(" "),
260 dim(format!("field_path: {}", e.field_path)),
261 ]));
262 }
263 if let Some(api) = err.as_api() {
264 lines.push(Line::from(vec![
265 Span::raw(" "),
266 dim(format!("kind: {:?}", api.kind)),
267 dim(match api.retry_after() {
268 Some(d) => format!(" retry after {:.1}s", d.as_secs_f64()),
269 None => String::new(),
270 }),
271 ]));
272 }
273 if let Some(id) = err.request_id() {
274 lines.push(Line::from(vec![
275 Span::raw(" "),
276 dim(format!("request_id: {id}")),
277 ]));
278 }
279 lines.push(Line::from(vec![Span::raw(" "), dim(advice)]));
280 lines
281}
282
283pub fn text_of(v: &Value) -> String {
285 match v {
286 Value::String(s) => s.clone(),
287 other => other.to_string(),
288 }
289}