1use crate::app::{App, Focus, Status};
4use crate::draft::Kind;
5use crate::editor::{Editor, Field};
6use cerno_types::Answer;
7use ratatui::Frame;
8use ratatui::layout::{Constraint, Direction, Layout, Rect};
9use ratatui::style::{Color, Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap};
12
13const EIGHTHS: [char; 8] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
15const EMPTY: char = '░';
16
17const LOW_LABEL_MASS: f64 = 0.5;
21
22pub fn bar(probability: f64, width: usize) -> String {
28 if width == 0 {
29 return String::new();
30 }
31
32 let clamped = probability.clamp(0.0, 1.0);
33 let eighths = (clamped * width as f64 * 8.0).round() as usize;
34 let full = (eighths / 8).min(width);
35 let remainder = eighths % 8;
36
37 let mut out = String::with_capacity(width * 3);
38 for _ in 0..full {
39 out.push(EIGHTHS[7]);
40 }
41 if full < width && remainder > 0 {
42 out.push(EIGHTHS[remainder - 1]);
43 }
44
45 let drawn = full + usize::from(full < width && remainder > 0);
46 for _ in drawn..width {
47 out.push(EMPTY);
48 }
49 out
50}
51
52pub fn draw(frame: &mut Frame, app: &App) {
53 let [body, status] =
54 Layout::vertical([Constraint::Min(6), Constraint::Length(1)]).areas(frame.area());
55 let [left, right] =
56 Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]).areas(body);
57 let [state_area, questions_area] =
58 Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)]).areas(left);
59
60 draw_state(frame, app, state_area);
61 draw_questions(frame, app, questions_area);
62 draw_answers(frame, app, right);
63 draw_status(frame, app, status);
64
65 if let Some(editor) = &app.editor {
66 draw_editor(frame, editor, frame.area());
67 }
68 if app.show_help {
69 draw_help(frame, frame.area());
70 }
71}
72
73fn border(focused: bool) -> Style {
74 if focused {
75 Style::default().fg(Color::Cyan)
76 } else {
77 Style::default().fg(Color::DarkGray)
78 }
79}
80
81fn draw_state(frame: &mut Frame, app: &App, area: Rect) {
82 let block = Block::bordered()
83 .title(" State (1) ")
84 .border_style(border(app.focus == Focus::State));
85
86 frame.render_widget(&block, area);
87 frame.render_widget(&app.state, block.inner(area));
88}
89
90fn draw_questions(frame: &mut Frame, app: &App, area: Rect) {
91 let focused = app.focus == Focus::Questions;
92 let block = Block::bordered()
93 .title(" Questions (2) ")
94 .border_style(border(focused));
95 let inner = block.inner(area);
96 frame.render_widget(block, area);
97
98 let mut items: Vec<ListItem> = Vec::with_capacity(app.questions.len() + 1);
99
100 for (index, draft) in app.questions.iter().enumerate() {
101 let selected = focused && index == app.selected;
102 let blamed = app.is_blamed(index);
103
104 let marker = if selected { "▸ " } else { " " };
105 let style = if blamed {
106 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
107 } else if selected {
108 Style::default().add_modifier(Modifier::BOLD)
109 } else {
110 Style::default()
111 };
112
113 items.push(ListItem::new(Line::from(vec![
114 Span::styled(marker, style),
115 Span::styled(format!("{:<10}", truncate(&draft.id, 10)), style),
116 Span::styled(
117 format!("{:<7}", draft.kind.label()),
118 Style::default().fg(kind_colour(draft.kind)),
119 ),
120 Span::styled(draft.summary(), Style::default().fg(Color::Gray)),
121 ])));
122 }
123
124 let on_add = focused && app.on_add_row();
126 items.push(ListItem::new(Span::styled(
127 format!("{}+ add question (a)", if on_add { "▸ " } else { " " }),
128 if on_add {
129 Style::default().add_modifier(Modifier::BOLD)
130 } else {
131 Style::default().fg(Color::DarkGray)
132 },
133 )));
134
135 let mut state = ListState::default().with_selected(Some(app.selected));
137 frame.render_stateful_widget(List::new(items), inner, &mut state);
138}
139
140fn kind_colour(kind: Kind) -> Color {
141 match kind {
142 Kind::Noul => Color::Green,
143 Kind::Choice => Color::Blue,
144 Kind::Score => Color::Magenta,
145 }
146}
147
148fn draw_answers(frame: &mut Frame, app: &App, area: Rect) {
149 let block = Block::bordered().border_style(border(app.focus == Focus::Answers));
150 let inner = block.inner(area);
151
152 let Some(answers) = &app.answers else {
153 app.answers_max_scroll.set(0);
154 frame.render_widget(block.title(" Answers "), area);
155 let hint = if app.questions.is_empty() {
156 "Add a question with `a`, then send with Ctrl+S."
157 } else {
158 "Ctrl+S to send."
159 };
160 frame.render_widget(
161 Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
162 inner,
163 );
164 return;
165 };
166
167 let base = if app.stale {
170 Style::default().fg(Color::DarkGray)
171 } else {
172 Style::default()
173 };
174
175 let width = inner.width as usize;
176 let mut lines: Vec<Line> = Vec::new();
177
178 for id in app.answer_order() {
179 let Ok(answer) = answers.get(id) else {
180 continue;
181 };
182 lines.extend(answer_lines(id, app.question_for(id), answer, width, base));
183 lines.push(Line::default());
184 }
185
186 lines.push(Line::from(Span::styled(
187 format!(
188 "{} · {} ms · {} tokens",
189 answers.model(),
190 answers.timing_ms(),
191 answers.usage().input_tokens
192 ),
193 Style::default().fg(Color::DarkGray),
194 )));
195
196 let pane_width = usize::from(inner.width.max(1));
199 let drawn: usize = lines
200 .iter()
201 .map(|line| line.width().div_ceil(pane_width).max(1))
202 .sum();
203 let max_scroll =
204 u16::try_from(drawn.saturating_sub(usize::from(inner.height))).unwrap_or(u16::MAX);
205 app.answers_max_scroll.set(max_scroll);
206 let scroll = app.answers_scroll.min(max_scroll);
207
208 let mut title = String::from(" Answers ");
209 if app.stale {
210 title.push_str("(stale) ");
211 }
212 if scroll > 0 {
214 title.push_str("↑ ");
215 }
216 if scroll < max_scroll {
217 title.push_str("↓ ");
218 }
219 frame.render_widget(block.title(title), area);
220
221 frame.render_widget(
222 Paragraph::new(lines)
223 .wrap(Wrap { trim: false })
224 .scroll((scroll, 0)),
225 inner,
226 );
227}
228
229struct Row {
231 label: String,
232 probability: f64,
233 winner: bool,
234}
235
236const ROW_FIXED: usize = 2 + 2 + 1 + 1 + 1 + 6;
239
240fn letter(index: usize) -> String {
243 char::from(b'A' + (index as u8).min(25)).to_string()
244}
245
246fn tint(base: Style, colour: Color) -> Style {
248 if base.fg.is_some() {
249 base
250 } else {
251 base.fg(colour)
252 }
253}
254
255fn answer_lines(
257 id: &str,
258 question: Option<&str>,
259 answer: &Answer,
260 width: usize,
261 base: Style,
262) -> Vec<Line<'static>> {
263 let bold = base.add_modifier(Modifier::BOLD);
264 let dim = base.fg(Color::DarkGray);
265 let mut lines = Vec::new();
266
267 let (value, rows, detail) = match answer {
268 Answer::Noul { noul, .. } => (
269 format!("noul {noul:.4}"),
270 vec![
271 Row {
272 label: "Yes".into(),
273 probability: *noul,
274 winner: *noul >= 0.5,
275 },
276 Row {
277 label: "No".into(),
278 probability: 1.0 - *noul,
279 winner: *noul < 0.5,
280 },
281 ],
282 None,
283 ),
284
285 Answer::Choice {
286 choice,
287 index,
288 probabilities,
289 ..
290 } => (
291 format!("choice → {choice}"),
292 probabilities
293 .iter()
294 .enumerate()
295 .map(|(i, entry)| Row {
296 label: entry.option.clone(),
297 probability: entry.probability,
298 winner: i == *index,
299 })
300 .collect(),
301 None,
302 ),
303
304 Answer::Score {
305 score,
306 expected_score,
307 legend,
308 probabilities,
309 ..
310 } => (
311 format!("score → {score} \"{legend}\""),
312 probabilities
313 .iter()
314 .map(|entry| Row {
315 label: format!("{} {}", entry.level, entry.legend),
316 probability: entry.probability,
317 winner: entry.level == *score,
318 })
319 .collect(),
320 Some(format!("expected {expected_score:.2}")),
321 ),
322 };
323
324 let left = format!("{} {value}", truncate(id, 20));
327 let right = answer
328 .confidence()
329 .map(|confidence| format!("conf {confidence:.3} {}", bar(confidence, 10)))
330 .unwrap_or_default();
331 let gap = width
332 .saturating_sub(left.chars().count() + right.chars().count())
333 .max(2);
334 lines.push(Line::from(vec![
335 Span::styled(left, bold),
336 Span::raw(" ".repeat(gap)),
337 Span::styled(right, dim),
338 ]));
339
340 if let Some(question) = question.filter(|q| !q.is_empty()) {
341 lines.push(Line::from(Span::styled(
342 format!(" {question}"),
343 dim.add_modifier(Modifier::ITALIC),
344 )));
345 }
346 if let Some(detail) = detail {
347 lines.push(Line::from(Span::styled(format!(" {detail}"), dim)));
348 }
349
350 let longest = rows
353 .iter()
354 .map(|r| r.label.chars().count())
355 .max()
356 .unwrap_or(0);
357 let label_width = longest.min(width * 2 / 5).max(3);
358 let bar_width = width.saturating_sub(ROW_FIXED + label_width).max(1);
359
360 let bounded = answer.truncated_labels();
361 for (index, row) in rows.iter().enumerate() {
362 let letter = letter(index);
363 let bound = bounded
364 .iter()
365 .any(|l| l.trim().eq_ignore_ascii_case(&letter));
366 lines.push(option_line(
367 &letter,
368 row,
369 label_width,
370 bar_width,
371 bound,
372 base,
373 ));
374 }
375
376 if answer.truncated() {
379 let text = format!(
380 " ⚠ truncated: {} fell outside the host's window; ≤ marks an upper bound",
381 bounded.join(", ")
382 );
383 lines.push(Line::from(Span::styled(text, tint(base, Color::Yellow))));
384 }
385
386 let mass = answer.label_mass();
389 if mass < LOW_LABEL_MASS {
390 let text = format!(
391 " ⚠ only {:.1}% of the model's probability was on the letters; it was not answering \
392 with one",
393 mass * 100.0
394 );
395 lines.push(Line::from(Span::styled(text, tint(base, Color::Yellow))));
396 }
397
398 lines
399}
400
401fn option_line(
402 letter: &str,
403 row: &Row,
404 label_width: usize,
405 bar_width: usize,
406 bound: bool,
407 base: Style,
408) -> Line<'static> {
409 let (text, colour) = if row.winner {
410 (base.add_modifier(Modifier::BOLD), Color::Green)
411 } else {
412 (base, Color::Cyan)
413 };
414
415 Line::from(vec![
416 Span::styled(format!(" {letter} "), base.fg(Color::DarkGray)),
417 Span::styled(
418 format!("{:<label_width$} ", truncate(&row.label, label_width)),
419 text,
420 ),
421 Span::styled(bar(row.probability, bar_width), tint(base, colour)),
422 Span::styled(if bound { " ≤" } else { " " }, tint(base, Color::Yellow)),
423 Span::styled(format!("{:>5.1}%", row.probability * 100.0), text),
424 ])
425}
426
427fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
428 let health = match app.healthy {
429 Some(true) => Span::styled(" ●", Style::default().fg(Color::Green)),
430 Some(false) => Span::styled(" ○ unreachable", Style::default().fg(Color::Red)),
431 None => Span::styled(" ?", Style::default().fg(Color::DarkGray)),
432 };
433
434 let mut spans = vec![
435 Span::styled(app.url.clone(), Style::default().fg(Color::DarkGray)),
436 health,
437 Span::styled(
438 format!(" · {}", app.model.as_deref().unwrap_or("default model")),
439 Style::default().fg(Color::DarkGray),
440 ),
441 ];
442
443 if let Some(temperature) = app.calibration {
444 spans.push(Span::styled(
445 format!(" · T {temperature:.2}"),
446 Style::default().fg(Color::Magenta),
447 ));
448 }
449
450 match &app.status {
451 Status::Sending { started, .. } => {
452 const SPINNER: [char; 4] = ['|', '/', '-', '\\'];
453 spans.push(Span::styled(
454 format!(
455 " · {} sending {:.1}s (Esc cancels)",
456 SPINNER[(app.tick as usize) % SPINNER.len()],
457 started.elapsed().as_secs_f32()
458 ),
459 Style::default().fg(Color::Yellow),
460 ));
461 }
462 Status::Failed(failure) => {
463 let code = failure
464 .code
465 .map(|c| format!("{c:?}"))
466 .unwrap_or_else(|| "error".into());
467 spans.push(Span::styled(
468 format!(" · {code}: {}", failure.message),
469 Style::default().fg(Color::Red),
470 ));
471 }
472 Status::Idle => spans.push(Span::styled(
473 " · ^S send · ? help",
474 Style::default().fg(Color::DarkGray),
475 )),
476 }
477
478 frame.render_widget(Paragraph::new(Line::from(spans)), area);
479}
480
481fn draw_editor(frame: &mut Frame, editor: &Editor, area: Rect) {
482 let fields = editor.fields();
483 let height = fields.len() as u16 * 3 + 2;
484 let area = centred(area, 64, height.min(area.height));
485
486 frame.render_widget(Clear, area);
487 let block = Block::bordered()
488 .title(if editor.is_editing() {
489 " Edit question "
490 } else {
491 " New question "
492 })
493 .border_style(Style::default().fg(Color::Cyan));
494 let inner = block.inner(area);
495 frame.render_widget(block, area);
496
497 let constraints: Vec<Constraint> = fields.iter().map(|_| Constraint::Length(3)).collect();
498 let rows = Layout::default()
499 .direction(Direction::Vertical)
500 .constraints(constraints)
501 .split(inner);
502
503 for (field, row) in fields.iter().zip(rows.iter()) {
504 let focused = *field == editor.focused();
505
506 let title = match field {
509 Field::Id if editor.is_blank(Field::Id) => {
510 format!(" id (blank → {}) ", editor.fallback_id())
511 }
512 Field::Levels if editor.is_blank(Field::Levels) => {
513 format!(" levels (blank → {}) ", Editor::DEFAULT_LEVELS)
514 }
515 _ => format!(" {} ", field.label()),
516 };
517
518 let field_block = Block::bordered().title(title).border_style(border(focused));
519 let field_inner = field_block.inner(*row);
520 frame.render_widget(field_block, *row);
521
522 if *field == Field::Kind {
523 let spans: Vec<Span> = Kind::ALL
524 .iter()
525 .flat_map(|kind| {
526 let selected = *kind == editor.kind();
527 let style = if selected {
528 Style::default()
529 .fg(kind_colour(*kind))
530 .add_modifier(Modifier::BOLD)
531 } else {
532 Style::default().fg(Color::DarkGray)
533 };
534 [
535 Span::styled(
536 if selected {
537 format!("[{}]", kind.label())
538 } else {
539 format!(" {} ", kind.label())
540 },
541 style,
542 ),
543 Span::raw(" "),
544 ]
545 })
546 .collect();
547 frame.render_widget(Paragraph::new(Line::from(spans)), field_inner);
548 } else {
549 frame.render_widget(editor.field(*field), field_inner);
550 }
551 }
552}
553
554fn draw_help(frame: &mut Frame, area: Rect) {
555 let lines = vec![
556 "Tab / Shift+Tab move between panes; 1 2 3 jump to one",
557 "↑ ↓ select a question, or the add row below them",
558 "Enter edit the selected question, or add on the add row",
559 "a / e / d add · edit · delete",
560 "↑ ↓ PgUp PgDn scroll the answers, when they have focus",
561 "Ctrl+S send",
562 "Esc close a dialog, or cancel a request in flight",
563 "m next model",
564 "t / T / c temperature up · down · back to the default",
565 "? this help",
566 "Ctrl+C quit (q also quits outside the state box)",
567 ];
568
569 let area = centred(area, 70, lines.len() as u16 + 2);
570 frame.render_widget(Clear, area);
571
572 let block = Block::bordered()
573 .title(" Keys ")
574 .border_style(Style::default().fg(Color::Cyan));
575 let inner = block.inner(area);
576 frame.render_widget(block, area);
577 frame.render_widget(Paragraph::new(lines.join("\n")), inner);
578}
579
580fn centred(area: Rect, width: u16, height: u16) -> Rect {
581 let width = width.min(area.width);
582 let height = height.min(area.height);
583 Rect {
584 x: area.x + (area.width - width) / 2,
585 y: area.y + (area.height - height) / 2,
586 width,
587 height,
588 }
589}
590
591fn truncate(value: &str, width: usize) -> String {
592 if value.chars().count() <= width {
593 return value.to_string();
594 }
595 value
596 .chars()
597 .take(width.saturating_sub(1))
598 .collect::<String>()
599 + "…"
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605
606 fn filled(bar: &str) -> usize {
607 bar.chars().filter(|c| *c != EMPTY).count()
608 }
609
610 #[test]
611 fn a_bar_is_always_exactly_its_width() {
612 for probability in [0.0, 0.001, 0.25, 0.5, 0.987, 1.0] {
613 for width in [1usize, 4, 16, 24] {
614 let rendered = bar(probability, width);
615 assert_eq!(
616 rendered.chars().count(),
617 width,
618 "p={probability} width={width} gave {rendered:?}"
619 );
620 }
621 }
622 }
623
624 #[test]
625 fn a_full_bar_is_all_blocks_and_an_empty_one_is_none() {
626 assert_eq!(bar(1.0, 4), "████");
627 assert_eq!(bar(0.0, 4), "░░░░");
628 }
629
630 #[test]
632 fn a_small_probability_still_shows_something() {
633 let rendered = bar(0.011, 16);
634
635 assert!(
636 filled(&rendered) > 0,
637 "1.1% rendered as empty: {rendered:?}"
638 );
639 assert!(
640 filled(&rendered) < 2,
641 "1.1% should be a sliver: {rendered:?}"
642 );
643 }
644
645 #[test]
646 fn bars_grow_monotonically_with_probability() {
647 let mut previous = 0usize;
648 for step in 0..=20 {
649 let rendered = bar(step as f64 / 20.0, 20);
650 let ink = rendered.chars().filter(|c| *c == EIGHTHS[7]).count();
651 assert!(ink >= previous, "went backwards at {step}: {rendered:?}");
652 previous = ink;
653 }
654 }
655
656 #[test]
659 fn out_of_range_values_are_clamped_rather_than_panicking() {
660 assert_eq!(bar(-0.5, 4), "░░░░");
661 assert_eq!(bar(2.0, 4), "████");
662 assert_eq!(bar(f64::NAN, 4).chars().count(), 4);
663 assert_eq!(bar(0.5, 0), "");
664 }
665
666 #[test]
667 fn long_labels_are_truncated_with_an_ellipsis() {
668 assert_eq!(truncate("short", 10), "short");
669 assert_eq!(truncate("a-very-long-option", 8), "a-very-…");
670 assert_eq!(truncate("exactly10!", 10), "exactly10!");
671 }
672}