Skip to main content

cerno_tui/
render.rs

1//! Drawing.
2
3use 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
13/// Eighth-width blocks, so a probability of one percent is still visible as something.
14const EIGHTHS: [char; 8] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
15const EMPTY: char = '░';
16
17/// Below this share of the model's probability on the offered letters, an answer is flagged. A
18/// display threshold only: the service reports `label_mass` and leaves the judgement to callers.
19/// Half is where the letters stop being what the model most wanted to write.
20const LOW_LABEL_MASS: f64 = 0.5;
21
22/// A horizontal bar `width` cells wide showing `probability`.
23///
24/// Sub-cell resolution matters here: a choice between twenty options routinely puts one percent
25/// on a loser, and rounding that to an empty bar would show "impossible" where the model
26/// actually said "unlikely". Eighth-blocks keep the distinction at any sane width.
27pub 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    // The add row is a list position like the questions above it, so the cursor can reach it.
125    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    // Stateful, so a selection below the bottom edge scrolls the list rather than vanishing.
136    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    // Answers from a previous request are dimmed rather than cleared, so a run at a different
168    // calibration can be compared against the one before it.
169    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    // Counted after wrapping, the way the paragraph will lay them out, so the last line can be
197    // scrolled to and no further.
198    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    // Arrows say there is more than fits, and on which side; `3` or Tab gets there to scroll.
213    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
229/// One option of an answer, as a row in the pane.
230struct Row {
231    label: String,
232    probability: f64,
233    winner: bool,
234}
235
236/// Cells in an option row besides its label and bar: indent, letter and space, the spaces
237/// around the bar, the bound marker and `100.0%`.
238const ROW_FIXED: usize = 2 + 2 + 1 + 1 + 1 + 6;
239
240/// The letter the model answered with for the option at `index`, in the order
241/// `cerno-core`'s `labels` hands them out: a noul's Yes is `A`, a score's level 1 is `A`.
242fn letter(index: usize) -> String {
243    char::from(b'A' + (index as u8).min(25)).to_string()
244}
245
246/// A colour for a stale answer stays dim; everything else takes the accent.
247fn tint(base: Style, colour: Color) -> Style {
248    if base.fg.is_some() {
249        base
250    } else {
251        base.fg(colour)
252    }
253}
254
255/// One answer: headline, the question it was asked with, a bar per option, and warnings.
256fn 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    // Headline: id and value on the left, confidence pushed to the right edge. A noul has no
325    // confidence — its probability already is the answer — so its right side stays empty.
326    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    // The label column fits the longest label, up to two fifths of the pane; the bar takes the
351    // rest, so a wide terminal gets a finer bar rather than empty space.
352    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    // A truncated answer is an upper bound, not an observation, and saying so is the whole
377    // reason the flag travels back with every answer.
378    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    // The bars are normalised over the letters, so they look as decisive when the model was
387    // about to write something else and the letters were far behind. Only the mass says so.
388    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        // A default belongs beside its field, not inside it: text in the field joins whatever
507        // gets typed, which is what produced "urgentq1" and "5unkritisch".
508        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    /// The point of eighth-blocks: one percent must not render as nothing.
631    #[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    /// Probabilities arrive from a softmax and should already be in range, but a bar is not the
657    /// place to find out otherwise.
658    #[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}